Owner Burhan

Table Repository — PRD

Table Repository

Scope

MVP — Tables become first-class, reusable content fundamentals:

  1. 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 optional description.
  2. Repository list + search — browse all Tables; search by short_uid and title.
  3. 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.
  4. Cross-linking & “where used” — one Table referenceable by many Blocks/MCQs; the Table surfaces its linked_entities (where it is attached).
  5. 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).
  6. Status lifecyclepublished / 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_entities is 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, and used_in_l*_ids together. 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 and used_in_l*_ids never needs re-deriving after the fact.

FieldTypeNotes
idstring (ObjectId)System-generated PK.
short_uidstringSystem-generated; prefix TBL (e.g. TBL5RT8W). Unique per Course.
course_idintCourse scope (UPSC=1, TEST=80085).
titlestringRequired.
hide_titlebooleanIf true, title is not rendered as a caption.
descriptionstringOptional.
table_contentstringPlateJS table JSON (same structure as an inline Block table; supports cell fill).
linked_entitiesarray[{ 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_idsarrayDenormalized taxonomy usage derived from linked_entities chain; powers the L1/L2/L3 filter (Option 1).
statusenumpublished | archived.
created_at / updated_atint (Unix ms)Audit.
created_by / updated_bystring (User ObjectId)Audit refs.
is_deletedbooleanLegacy 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 of Table.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

#MilestoneOutcomeStatusPlan
1Table entity + repository storeEditors can create, edit, and list standalone Tables (with cell fill)pending
2SearchEditors find a Table by short_uid or titlepending
3Inline reference into Block & MCQEditors insert a repository Table into a Block or MCQ via a PlateJS Table tag; linked_table_ids / linked_entities stay in syncpending
4Cross-linking & “where used”A Table shows every Block/MCQ it is attached topending
5L1/L2/L3 usage filterEditors filter the Table list by taxonomy of usage (denormalized)pending
6Status lifecycleTables can be published/archived; no hard deletepending

Open Questions

  • None remaining — all resolved during PRD review.

Risks

RiskLikelihoodImpactMitigation
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.MediumMedium — wrong “where used” / filter resultsSingle 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.LowLow — editor effortThe “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 termCode
Table entityTableDBModel (collection tables) / TableProjection
table_contenttable_content: str — PlateJS table JSON, stored plaintext
linked_entities[]TableLinkedEntity { content_short_uid, content_type }
content_typeTableLinkedContentTypeEnum (MCQ=1, BLOCK=2)
status (published/archived)TableStatusEnum (PUBLISHED=1, ARCHIVED=2), default PUBLISHED
Block/MCQ inverse linklinked_table_ids: List[str] (Table short_uids)
custom Table-reference tagPlateJS table_ref node (shared cross-repo contract)

Deviations from the PRD data model (as shipped):

  • linked_entities stores content_short_uid, not content_id. The PRD lists { content_id, content_type }; as built each entry holds the linked Block/MCQ short_uid — symmetric with the forward linked_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_uid is globally unique (prefix TBL) — a superset of the PRD’s “unique per Course”.
  • table_content is 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] (Table short_uids, default_factory=list). MCQ embed surfaces are question stem + solution; Block always.
  • Frontend-derived linking. keystone-web’s collectTableRefIds walker extracts short_uids from table_ref nodes on save and sends linked_table_ids in the create/update payload — the backend never rewrites Plate JSON. The shared node contract is { "type": "table_ref", "short_uid": "TBL…", "children": [{ "text": "" }] } (key table_ref, not table which is reserved by the native plate-table plugin; block-level void; carries only short_uid, so one repository edit propagates to every reference). table_ref is added to _PREVIEW_SKIP_NODE_TYPES so 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 recomputes used_in_* in one update_by_id per affected Table. No Celery. (Create wires the sync only when linked_table_ids is truthy; update wires it when linked_table_ids is not None, so sending [] on update is a deliberate full-detach.)
  • Taxonomy derivation (_derive_taxonomy_usage): MCQ short_uids → mcq.taxonomy_ids; Block short_uids → distinct docket_ids → one batched docket_repo.get_by_idsdocket.taxonomy_ids; unioned + deduped per level. Orphan blocks (no docket_id) and missing levels are silently skipped. Re-derived only when linked_entities changes (taxonomy chains are immutable).

Archive & cascade

  • Archive blocked whenever ≥1 link exists. TableService.archive raises TABLE_ARCHIVE_BLOCKED while linked_entities is non-empty; only 0 links archive (defensively zeroing used_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 recomputing used_in_*. MCQ archive also clears its own linked_table_ids (and flowchart_ids) in the same status write.

Surface & semantics

SurfaceRoutesNotes
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}/archiveJWT + 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 returns id, short_uid, title, hide_title, description, table_content, linked_entities, statusused_in_l*_ids and course_id are 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 params short_uid (exact, plus inherited short_uid__regex), title, status, used_in_l{1,2,3}_ids, updated_at__gte/__lte. Caveat: the title list-filter is wired as exact-equality as built (the field carries no __regex handling), so list-by-title is an exact match rather than the substring search the search milestone implies; short_uid substring search is available via short_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.