MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-45

1.4.2 Schema: WorkItem model (incl. descriptionMd / explanationMd / explanationSource) + kind-parent/depth/cycle triggers + repo + Markdown render stack

Done
Description

Estimate: 38m · Depends on: 1.3.1

Add the WorkItem Prisma model with the verbatim shape from this Story's acceptance criteria, generate the migration, write the Postgres trigger functions that enforce the kind-parent rule + depth limit + cycle prevention at the DB layer, and ship the repository (single-Prisma-op leaves with required-tx on writes). No service layer in this Subtask — that's 1.4.4.

Why DB-level constraints, not service-layer-only: the kind-parent rule is a structural invariant of the data model. If it lives only in the service layer, then any future direct Prisma access (a maintenance script, an admin path, a future microservice) can violate it silently. A Postgres trigger is the durable shape — it fires on every INSERT/UPDATE regardless of where the call originated, mirroring how RLS guarantees workspace isolation regardless of application path. The triggers are written in prisma/sql/work_item_triggers.sql and applied via a raw-SQL Prisma migration (Prisma supports raw SQL migrations natively).

Trigger sketch: CREATE FUNCTION enforce_work_item_kind_parent() RETURNS TRIGGER AS $$ BEGIN IF NEW.parent_id IS NULL THEN IF NEW.kind = 'subtask' THEN RAISE EXCEPTION 'WI_SUBTASK_NEEDS_PARENT' USING ERRCODE = '23514'; END IF; RETURN NEW; END IF; SELECT kind INTO parent_kind FROM work_item WHERE id = NEW.parent_id; IF NOT is_legal_parent_child(parent_kind, NEW.kind) THEN RAISE EXCEPTION 'WI_ILLEGAL_PARENT_TYPE' USING ERRCODE = '23514'; END IF; -- depth + cycle checks here too RETURN NEW; END; $$ LANGUAGE plpgsql; The repository's create/update methods catch SQLSTATE 23514 with the specific MESSAGE markers and translate to typed errors from lib/workItems/errors.ts.

Fractional indexing for position: use the fractional-indexing library (LexoRank-style, the Linear/Notion/Figma standard). New items at the end of a parent get a key after the current last child; reorders compute a key between the two neighbors. Decimal(20,10) is plenty of headroom — the keys are short strings, but they sort lexically as decimals after parsing. Choosing this over an integer position with bulk-shift-on-insert because shifts are O(N) writes per reorder, and the bulk-shift pattern is what Jira's original design got wrong.

What you'll do: Extend prisma/schema.prisma with WorkItem + the three enums (WorkItemKind, WorkItemPriority, WorkItemExplanationSource). Write the trigger SQL in prisma/sql/work_item_triggers.sql. Generate the migration add_work_items, append the trigger SQL to the migration file. Add lib/repositories/workItemRepository.ts with the single-op methods listed in the AC. Add lib/workItems/errors.ts with the typed errors. Add lib/dto/workItems.ts + lib/mappers/workItemMappers.ts (Prisma row → DTO conversion). Install fractional-indexing as a runtime dependency. Install the Markdown render stack (react-markdown, remark-gfm, rehype-sanitize, rehype-highlight) as runtime dependencies — the renderer component itself is built in Epic 2's issue-detail Subtask, but the deps land here so the schema and a smoke-rendered /dev/markdown page can confirm GFM features render correctly before the UI Subtask consumes them. Add lib/markdown/render.tsx with a single renderMarkdown(md: string) helper that pipes through the standard sanitize + GFM + highlight chain — this is the canonical renderer Epic 2 + Epic 5 + Epic 7 all consume. No Markdown editor in this Subtask — that's Epic 2; for v1.4 verification the test endpoint writes raw Markdown strings.

Acceptance criteria

  • WorkItem + WorkItemKind + WorkItemPriority + WorkItemExplanationSource in prisma/schema.prisma with the verbatim field set + relations + indexes from this Story's AC, including descriptionMd String? @db.Text, explanationMd String? @db.Text, explanationSource WorkItemExplanationSource @default(user_authored). pnpm prisma generate succeeds.
  • Markdown render stack installed: react-markdown, remark-gfm, rehype-sanitize, rehype-highlight. lib/markdown/render.tsx exports a renderMarkdown(md: string) component that pipes through the sanitize + GFM + highlight chain. Smoke test (unit test) renders a fixture containing headings, lists, tables, task checkboxes, code blocks, links, images, and an inline <script> tag — asserts the script tag is stripped and every other element renders as expected semantic HTML.
  • Migration add_work_items applies cleanly. The migration includes the trigger functions and CREATE TRIGGER statements. Down-migration is reversible (drops triggers, then the table).
  • Triggers reject illegal kind-parent pairs (every illegal pair from the matrix in the AC), depth > 4, and re-parent cycles, surfacing SQLSTATE 23514 with distinct error messages per failure mode.
  • workItemRepository exports findById, findByIdentifier, findByProject, findSubtree, findChildren, create(data, tx), update(id, data, tx), archive(id, tx). All writes require tx: Prisma.TransactionClient. Trigger errors are translated to IllegalParentTypeError / DepthLimitExceededError / ParentCycleError at the repository edge.
  • findSubtree uses a recursive CTE inside $queryRaw and returns the full subtree (with depth info) in one round-trip. Verify on a tree of ~50 items: single query.
  • lib/dto/workItems.ts exports WorkItemDto + WorkItemSummaryDto + WorkItemRevisionDto. lib/mappers/workItemMappers.ts converts. The repository never returns raw Prisma rows past the public boundary except to the service layer (kept internal).
  • fractional-indexing installed; a helper lib/workItems/positioning.ts exposes keyForAppend(last), keyBetween(prev, next), keyForPrepend(first). Unit tests over each (deterministic outputs vs. known fixtures).
  • Repository-layer Vitest tests: create persists; create with illegal parent kind rejects with IllegalParentTypeError; create at depth 5 rejects with DepthLimitExceededError; update setting parentId to a descendant rejects with ParentCycleError; findSubtree returns the right shape. Tests use a real Postgres per the standing no-mocks rule.
  • No db.* / $transaction outside the repository layer (no service layer exists yet).
  • All quality gates green: pnpm prisma generate && typecheck && lint && format:check && build && test. Existing suite stays green.

Context refs

  • motir-core/CLAUDE.md — 4-layer rule (auto-loaded)
  • prisma/schema.prisma — current Workspace / WorkspaceMembership / Project models
  • lib/repositories/projectRepository.ts — the single-op + required-tx pattern; allocateWorkItemNumber is the method this Story's service layer will consume (in 1.4.4)
  • lib/repositories/workspaceRepository.ts — error-translation pattern (Prisma error → typed error at the repository edge)
  • lib/workspaces/errors.ts + lib/projects/errors.ts — typed-error pattern
  • lib/dto/projects.ts + lib/mappers/projectMappers.ts — DTO/mapper pattern
  • This Story page (Story 1.4) — schema spec + trigger spec + AC
  • fractional-indexing npm page for the helper API surface