Skip to content

feat(storage-foundation): zod schemas, public + private stores, markdown pipeline#13

Merged
themightychris merged 11 commits into
mainfrom
feat/storage-foundation
May 16, 2026
Merged

feat(storage-foundation): zod schemas, public + private stores, markdown pipeline#13
themightychris merged 11 commits into
mainfrom
feat/storage-foundation

Conversation

@themightychris
Copy link
Copy Markdown
Member

Summary

  • Zod schemas for all 13 entities from specs/data-model.md in packages/shared/src/schemas/ — public and private, with field-level validators matching the spec (slug regex, max lengths, enum values, cross-field refinements)
  • JSON Schema generation via Zod v4's built-in toJSONSchema; committed to .gitsheets/schemas/; check-schemas script fails CI on drift
  • Public store (apps/api/src/store/public.ts) — openPublicStore() via gitsheets openStore() with typed validators for all 11 public sheets
  • Private store (apps/api/src/store/private/) — PrivateStore interface, FilesystemPrivateStore (dev, atomic temp-file-then-rename), S3PrivateStore (production, @aws-sdk/client-s3); both follow load-at-boot + in-memory + PUT-on-mutation pattern; transact() with rollback on failure
  • Dual-store coordinator (Store class) — store.transact() with writeOrder: 'private-first' | 'public-first' per the atomicity spec; cross-store failure uses reconciliation (not git-revert) per spec decision
  • Markdown pipeline (packages/shared/src/markdown.ts) — renderMarkdown() via unified/remark/rehype-sanitize; heading demotion, loading="lazy" on images, sanitization of <script>, javascript:, on* attrs, non-https image src; plain-text excerpt at 280 chars
  • Boot loader (apps/api/src/store/boot.ts) — bootStores(env) fails loudly if either store unreachable
  • gitsheets moved from devDependencies to dependencies in apps/api (production store code imports it)
  • createTestPrivateStore shim retained — downstream tests use real FilesystemPrivateStore instead; shim kept as a lighter alternative for the original harness test

Test plan

  • All Zod schemas round-trip against valid + invalid fixtures (52 tests in packages/shared)
  • generate-schemas script produces correct JSON Schema outputs (committed); check-schemas exits 1 on drift
  • Store booted against createTestRepo(), upserts Project, queries back, path template contains slug
  • store.transact inserts Person (public) + PrivateProfile (private) in one atomic call; both stores reflect the change
  • Cross-store rollback: handler throws → no public commit, no private PUT
  • Dual-write failure: private flush fails after public commit → error thrown, in-memory state rolled back via reconciliation hooks
  • renderMarkdown('# Hello\n[link](https://x.org)') produces sanitized HTML + plain-text excerpt
  • Sanitizer rejects <script>, javascript:, on*=, non-https image src (RFC-style negative tests)
  • createTestPrivateStore shim evaluated — downstream tests migrated to FilesystemPrivateStore (shim retained as lighter alternative for legacy harness test)

🤖 Generated with Claude Code

…line + s3 sdk

- apps/api: move gitsheets from devDependencies to dependencies (production store code imports it); add @aws-sdk/client-s3, @cfp/shared
- packages/shared: add zod, zod-to-json-schema, unified, remark-parse, remark-gfm, remark-breaks, remark-rehype, rehype-sanitize, rehype-stringify, remark-stringify, strip-markdown

Commands:
  npm install --workspace=apps/api gitsheets@1.0.3
  npm install --workspace=apps/api @aws-sdk/client-s3 @cfp/shared
  npm install --workspace=packages/shared zod zod-to-json-schema unified remark-parse remark-gfm remark-breaks remark-rehype rehype-sanitize rehype-stringify remark-stringify strip-markdown
All 13 entities from specs/data-model.md — public (Person, Project,
ProjectMembership, ProjectUpdate, ProjectBuzz, Tag, TagAssignment,
HelpWantedRole, HelpWantedInterestExpression, SlugHistory, Revocation)
and private (PrivateProfile, LegacyPasswordCredential).

Each schema exports both the ZodObject and the inferred TypeScript type.
Validators match field-level rules from specs/data-model.md (slug regex,
max lengths, enum values, cross-field refinements for featured projects).
packages/shared/scripts/generate-json-schemas.ts generates JSON Schemas
from Zod schemas using Zod v4's built-in toJSONSchema (output mode).
$schema field is stripped since gitsheets uses ajv@8 in draft-07 mode.

.gitsheets/schemas/<Entity>.schema.json — committed generated outputs.
Check-schemas script verifies drift via `git diff --exit-code`.

Command to regenerate: npm run generate-schemas -w packages/shared
packages/shared/src/markdown.ts implements renderMarkdown(source) returning
{ html, excerpt } per specs/behaviors/markdown-rendering.md.

Pipeline: remark-parse → remark-gfm → remark-breaks → remark-rehype
         → heading demotion (h1→h3, min h6) → img attrs (loading=lazy)
         → rehype-sanitize → rehype-stringify

Sanitization: strips <script>, javascript:, on* attrs, non-https image srcs,
style attrs, raw HTML. Allows http/https/mailto hrefs, https image src.

Excerpt: first-paragraph text via strip-markdown, capped at 280 chars
with word-boundary truncation (first paragraph approach — see Notes).
Public store (apps/api/src/store/public.ts):
- openPublicStore(repoPath) returns typed gitsheets Store via openStore()
- All 11 public sheets declared with typed validators (Zod → StandardSchemaV1)
- Type cast used for Zod v4 compatibility (structural mismatch in FailureResult)

Private store (apps/api/src/store/private/):
- PrivateStore interface per specs/behaviors/private-storage.md
- BasePrivateStore: in-memory load, indices, transact with rollback on failure
- FilesystemPrivateStore: atomic temp-file-then-rename writes
- S3PrivateStore: @aws-sdk/client-s3 backed (production)
- PrivateStoreError for structured error reporting

Dual-store coordinator (apps/api/src/store/store.ts):
- Store class wraps both stores with transact() for cross-store mutations
- writeOrder: 'private-first' | 'public-first' per use-case
- Cross-store failure uses reconciliation approach (not git-revert)

Boot loader (apps/api/src/store/boot.ts):
- bootStores(env) fails loudly if either store unreachable
- Selects filesystem or S3 backend from STORAGE_BACKEND env var
Schema tests (packages/shared/tests/schemas.test.ts):
- Valid and invalid fixture per entity (13 schemas total, 52 tests)
- Tests for cross-field refinements (featured project) and regex validators

Markdown tests (packages/shared/tests/markdown.test.ts):
- Renders heading, link, GFM table, code blocks, blockquotes, images
- Sanitization: rejects <script>, javascript:, on* event attrs, non-https images
- Heading demotion: h1→h3, h2→h4, caps at h6
- loading=lazy on images, excerpt truncation at word boundary

Store tests (apps/api/tests/store.test.ts):
- Boots Store against createTestRepo() + upserts/queries a Project
- Path template verified to contain slug
- Dual-store: upserts Person (public) + PrivateProfile (private) in one transact
- Cross-store rollback: handler throw → no public commit, no private PUT
- Dual-write: private flush fails after public commit → error thrown, rollback

Validation: createTestPrivateStore shim retained; tests use real
FilesystemPrivateStore instead (decision documented in plan Notes).
themightychris added a commit that referenced this pull request May 16, 2026
Verified all 9 validation criteria. Notable decisions documented in Notes:

- Cross-store rollback: chose reconciliation (b), not git-revert (a). In-memory
  is rolled back on flush failure; reconcile-private-store.ts handles repair.
- Zod v4 built-in toJSONSchema used instead of zod-to-json-schema (library
  doesn't support Zod v4 yet — issue #14 filed for cleanup).
- StandardSchemaV1 type cast needed for Zod v4 ↔ gitsheets compatibility.
- createTestPrivateStore shim retained as lighter alternative; new store
  tests use real FilesystemPrivateStore directly.
- Sheet.defineIndex and reconcile-private-store.ts deferred to write-api plan.
themightychris and others added 4 commits May 16, 2026 15:15
The previous implementation checked hasPrivateMutations() before the
handler ran — staging arrays were always empty at that point so the
'private-first' branch was unreachable. Callers silently fell through
to public-first semantics.

Fix: run the handler inside public.transact's callback, then flush
private either inside the callback (private-first) or after public
commits (.then, public-first). The flush decision is made after the
handler has staged its mutations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ite tests

- Add assertion that the public commit DID land when private flush fails
  (the load-bearing claim of the reconciliation strategy: public is
  committed, private is orphaned, manual recovery needed)
- Remove unused dualStore variable from the same test block
- Fix the cross-instance persistence test: create a fresh FilesystemPrivateStore
  from the same dir and load() it, instead of querying the original instance
  (which was testing nothing about persistence)
- Update makePrivateStore() to return dir so callers can create fresh instances
- Remove unused openRepo import and SHEET_CONFIGS const
- Add test for writeOrder: 'private-first' path verifying that when private
  flush fails inside the public.transact callback, the public tree is also
  not committed (protecting both sides, unlike public-first behavior)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…chema

Rename _stripped to \$schema and add void \$schema to make the intent
explicit: the field is destructured specifically to omit it from the
output object. The _ prefix does not satisfy the project's lint config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@themightychris themightychris force-pushed the feat/storage-foundation branch from b444536 to 574ba9f Compare May 16, 2026 19:20
@themightychris themightychris merged commit 53a64a3 into main May 16, 2026
1 check passed
@themightychris themightychris deleted the feat/storage-foundation branch May 16, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant