Table Repository — PRD
Table Repository
Scope
MVP — Tables become first-class, reusable content fundamentals:
- Create / edit a Table as a standalone entity (PlateJS table object),
with cell background fill (fill the cell, not just text color),
title(required),hide_title, and optionaldescription. - Repository list + search — browse all Tables; search by
short_uidandtitle. - Insert inline into a Block or MCQ — reference a repository Table inside
a Block’s or MCQ’s PlateJS rich text via a custom Table-reference tag
(the same pattern as the existing inline-image / image-bank flow), by
short_uid. - Cross-linking & “where used” — one Table referenceable by many
Blocks/MCQs; the Table surfaces its
linked_entities(where it is attached). - Filter the list by L1 / L2 / L3 usage — via denormalized taxonomy usage on the Table (Option 1), derived from each linked entity’s immutable chain (see Consistency rule).
- Status lifecycle —
published/archived; no hard delete (archive is the only termination, per platform rule). A Table can be archived only when it has no linked entities (linked_entitiesis empty). While any linkage remains, archive is blocked — the editor is shown the list of linked Block/MCQs and must remove all linkages first, then archive.
Data Model (requirements-level)
The shape of a Table. Consistency rule (decided): all denormalized fields are maintained synchronously through a single write path — one operation updates the entity’s rich text (PlateJS ref node),
linked_table_ids,linked_entities, andused_in_l*_idstogether. No async / background recompute. The only trigger is link / unlink of a Table to a Block/MCQ — Block→Docket moves and Docket re-parenting do not exist (disallowed by the platform), so a linked entity’s taxonomy chain is immutable andused_in_l*_idsnever needs re-deriving after the fact.
| Field | Type | Notes |
|---|---|---|
id | string (ObjectId) | System-generated PK. |
short_uid | string | System-generated; prefix TBL (e.g. TBL5RT8W). Unique per Course. |
course_id | int | Course scope (UPSC=1, TEST=80085). |
title | string | Required. |
hide_title | boolean | If true, title is not rendered as a caption. |
description | string | Optional. |
table_content | string | PlateJS table JSON (same structure as an inline Block table; supports cell fill). |
linked_entities | array | [{ content_id, content_type: "block" | "mcq" }] — where the Table is attached (denormalized to save read cost). |
used_in_l1_ids / used_in_l2_ids / used_in_l3_ids | array | Denormalized taxonomy usage derived from linked_entities chain; powers the L1/L2/L3 filter (Option 1). |
status | enum | published | archived. |
created_at / updated_at | int (Unix ms) | Audit. |
created_by / updated_by | string (User ObjectId) | Audit refs. |
is_deleted | boolean | Legacy soft-delete; deferred to Sync convention. |
Block & MCQ changes
linked_table_ids— on both Block and MCQ — denormalized list of Tables referenced in that entity’s rich text (the inverse side ofTable.linked_entities; both stored to save read cost).- A custom PlateJS Table-reference node in the Block’s / MCQ’s rich text
(mirrors the inline-image ref node), which drives
linked_table_ids.
Delivery Milestones
| # | Milestone | Outcome | Status | Plan |
|---|---|---|---|---|
| 1 | Table entity + repository store | Editors can create, edit, and list standalone Tables (with cell fill) | pending | — |
| 2 | Search | Editors find a Table by short_uid or title | pending | — |
| 3 | Inline reference into Block & MCQ | Editors insert a repository Table into a Block or MCQ via a PlateJS Table tag; linked_table_ids / linked_entities stay in sync | pending | — |
| 4 | Cross-linking & “where used” | A Table shows every Block/MCQ it is attached to | pending | — |
| 5 | L1/L2/L3 usage filter | Editors filter the Table list by taxonomy of usage (denormalized) | pending | — |
| 6 | Status lifecycle | Tables can be published/archived; no hard delete | pending | — |
Open Questions
- None remaining — all resolved during PRD review.
Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
Ref-node ↔ denormalization drift. PlateJS Table ref nodes in a Block/MCQ’s rich text and the denormalized lists (linked_table_ids, linked_entities, used_in_l*_ids) can diverge if updated separately. | Medium | Medium — wrong “where used” / filter results | Single write path (confirmed): one atomic operation updates rich text + all denormalized lists together; validate on save. |
| Archive friction. A Table reused across many entities can only be archived after every reference is unlinked by hand. | Low | Low — editor effort | The “where used” view (milestone 4) lists every linkage so the editor can unlink quickly before archiving. |
Technical Details (as built)
Implemented in PR #755 (feature/table-repository). Design spec: keystone/docs/superpowers/specs/2026-06-04-table-repository-design.md; backend plan: keystone/docs/superpowers/plans/2026-06-04-table-repository-backend.md. Built by cloning the Flowchart feature and swapping the payload (S3 image → PlateJS table JSON) — same linkage / taxonomy / archive machinery. This section covers the keystone backend; the table_ref editor node, the dedicated Table Bank editor (with cell-fill), and live read-only rendering live in keystone-web / parixa.
Naming: PRD vs code
| PRD term | Code |
|---|---|
| Table entity | TableDBModel (collection tables) / TableProjection |
table_content | table_content: str — PlateJS table JSON, stored plaintext |
linked_entities[] | TableLinkedEntity { content_short_uid, content_type } |
content_type | TableLinkedContentTypeEnum (MCQ=1, BLOCK=2) |
status (published/archived) | TableStatusEnum (PUBLISHED=1, ARCHIVED=2), default PUBLISHED |
| Block/MCQ inverse link | linked_table_ids: List[str] (Table short_uids) |
| custom Table-reference tag | PlateJS table_ref node (shared cross-repo contract) |
Deviations from the PRD data model (as shipped):
linked_entitiesstorescontent_short_uid, notcontent_id. The PRD lists{ content_id, content_type }; as built each entry holds the linked Block/MCQshort_uid— symmetric with the forwardlinked_table_ids(also short_uids) and the admin’s by-short_uid lookups. (This is the deliberate divergence from Flowchart, which stores the Mongo_id.)short_uidis globally unique (prefixTBL) — a superset of the PRD’s “unique per Course”.table_contentis plaintext (no at-rest encryption), unlike encrypted block content.
Entity (src/models/table_models/)
TableDBModel(BaseBeanieDocumentModel), collection tables, short_uid prefix TBL. Fields: course_id, title (required), hide_title (False), description, table_content, linked_entities, used_in_l1_ids/l2_ids/l3_ids (denormalized taxonomy), status — plus inherited audit fields. table_content has a light @field_validator: must parse as JSON with the root a single {"type":"table", …} node, else TABLE_INVALID_DATA. On update the service re-invokes the validator manually because the repo $set bypasses Beanie validation.
8 indexes (tbl_ prefix): unique short_uid; course_id; (course_id, is_deleted, updated_at DESC, _id DESC) (bidirectional listing); (course_id, status); linked_entities.content_short_uid (reverse cascade lookup); (course_id, used_in_l{1,2,3}_ids) (L1/L2/L3 filter).
Cross-entity linkage (Block & MCQ)
- Block and MCQ each carry
linked_table_ids: List[str](Tableshort_uids,default_factory=list). MCQ embed surfaces are question stem + solution; Block always. - Frontend-derived linking. keystone-web’s
collectTableRefIdswalker extractsshort_uids fromtable_refnodes on save and sendslinked_table_idsin the create/update payload — the backend never rewrites Plate JSON. The shared node contract is{ "type": "table_ref", "short_uid": "TBL…", "children": [{ "text": "" }] }(keytable_ref, nottablewhich is reserved by the native plate-table plugin; block-level void; carries onlyshort_uid, so one repository edit propagates to every reference).table_refis added to_PREVIEW_SKIP_NODE_TYPESso it doesn’t pollute preview/search text. - Single synchronous write path.
TableService.sync_linked_entities_for_content(course_id, content_short_uid, content_type, old_table_short_uids, new_table_short_uids)— called by Block/MCQ create+update via the unified service container — diffs old vs new, adds/removes the{content_short_uid, content_type}reverse entry (idempotent on the(short_uid, content_type)pair), and recomputesused_in_*in oneupdate_by_idper affected Table. No Celery. (Create wires the sync only whenlinked_table_idsis truthy; update wires it whenlinked_table_ids is not None, so sending[]on update is a deliberate full-detach.) - Taxonomy derivation (
_derive_taxonomy_usage): MCQshort_uids →mcq.taxonomy_ids; Blockshort_uids → distinctdocket_ids → one batcheddocket_repo.get_by_ids→docket.taxonomy_ids; unioned + deduped per level. Orphan blocks (nodocket_id) and missing levels are silently skipped. Re-derived only whenlinked_entitieschanges (taxonomy chains are immutable).
Archive & cascade
- Archive blocked whenever ≥1 link exists.
TableService.archiveraisesTABLE_ARCHIVE_BLOCKEDwhilelinked_entitiesis non-empty; only 0 links archive (defensively zeroingused_in_*). Editor must unlink all references first. - Reverse cascade. Block delete and MCQ archive/delete call
unlink_content_from_tables(content_short_uid), removing that content from every linking Table and recomputingused_in_*. MCQ archive also clears its ownlinked_table_ids(andflowchart_ids) in the same status write.
Surface & semantics
| Surface | Routes | Notes |
|---|---|---|
| av1 (admin, write) | POST /table (JSON body — no file upload), GET /table/filters, GET /table/list-updated-at-bidirectional (limit 1–120), GET /table/{id}, PATCH /table/{id} (un-archive via status), POST /table/{id}/archive | JWT + editor/admin role; admin GET can preview archived. |
| v1 (mobile, read) | GET /v1/table/{short_uid} | published_only=True; v1 transport encryption. Endpoint built; mobile renderer deferred. |
| v2 (web, read) | GET /v2/table/{short_uid} | published_only=True; plain response. |
- v1/v2 schema modules re-export av1’s
TableDetailsResponse. That response returnsid, short_uid, title, hide_title, description, table_content, linked_entities, status—used_in_l*_idsandcourse_idare not in the payload; the denormalized taxonomy lists back the admin filter only. - Archived tables are hidden from clients via
published_only=True. - Search is MongoDB-filter based, no Typesense: bidirectional keyset cursor on
(updated_at, _id)(TableTimestampCursor); filter paramsshort_uid(exact, plus inheritedshort_uid__regex),title,status,used_in_l{1,2,3}_ids,updated_at__gte/__lte. Caveat: thetitlelist-filter is wired as exact-equality as built (the field carries no__regexhandling), so list-by-titleis an exact match rather than the substring search the search milestone implies;short_uidsubstring search is available viashort_uid__regex. - New error codes:
TABLE_NOT_FOUND=9400,TABLE_INVALID_DATA=9401,TABLE_OPERATION_FAILED=9403,TABLE_ARCHIVE_BLOCKED=9404(9402 / 9405+ reserved).
Migration
No data backfill — Table is a new entity with no existing rows. The tables collection and its 8 indexes are created on boot by registering TableDBModel in MODELS_TO_MIGRATE.