| status | done | ||||
|---|---|---|---|---|---|
| depends |
|
||||
| specs |
|
||||
| upstream-specs |
|
||||
| issues | |||||
| pr | 13 |
The data layer: Zod schemas for every entity, the gitsheets-backed public store, the bucket-backed private store, in-memory representation, secondary indexes, and the markdown rendering pipeline. Assumes gitsheets v1.0 has shipped — we consume its TypeScript API directly (Repository, Sheet, Transaction, openStore).
Out of scope: HTTP surface (next plan), authorization rules (referenced but enforced in write-api), full-text search index (built in read-api once we have records to index), markdown editor in the UI (web-shell / authoring-screens).
Own specs (this repo):
- data-model.md — every entity gets a Zod schema and Sheet declaration. The public/private split is realized via the two stores.
- behaviors/storage.md — gitsheets repo wiring, single-replica process model, in-memory state + secondary indices, sync-to-GitHub via gitsheets push daemon, the commit-message format including pseudonymous author and trailer policy.
- behaviors/private-storage.md —
PrivateStoreinterface; S3 and filesystem backends; boot-load; PUT-on-mutation; dual-write coordination helper. - behaviors/markdown-rendering.md —
renderMarkdown(source): { html, excerpt }utility inpackages/shared(server-side rendering via unified/remark/rehype-sanitize).
Upstream specs (gitsheets) consumed by this plan — see the gitsheets repo for canonical text:
- Path templates — declared in
.gitsheets/<sheet>.toml; gitsheets v1.0 handles rendering + query-pruning. We don't redefine the syntax. - Transactions — public mutations flow through gitsheets's
repo.transact; the private-side companion isPrivateStore.transact(own minimal mutex). Cross-store coordination is documented locally in behaviors/private-storage.md. - Validation — gitsheets calls our consumer-supplied Zod schemas (attached via
openStore({ validators })) on top of the JSON Schema validation it does itself. - Canonical normalization — gitsheets v1.0 handles array
sortconfig + deep key sorting natively. We declare per-sheet sort rules in the sheet config when relevant.
One file per entity. Export both the Zod schema and the inferred TypeScript type:
export const PersonSchema = z.object({
id: z.string().uuid(),
legacyId: z.number().int().optional(),
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{1,49}$/),
fullName: z.string().min(1).max(120),
// ...
});
export type Person = z.infer<typeof PersonSchema>;Schemas cover: Person, Project, ProjectMembership, ProjectUpdate, ProjectBuzz, HelpWantedRole, HelpWantedInterestExpression, Tag, TagAssignment, SlugHistory, Revocation, plus private: PrivateProfile, LegacyPasswordCredential.
One per public entity. Path templates per data-model.md:
# .gitsheets/people.toml
[gitsheet]
root = 'people'
path = '${{ slug }}'
[gitsheet.schema]
$ref = './schemas/Person.schema.json'
[gitsheet.fields.tags]
sort = ['namespace', 'slug']Where each .gitsheets/schemas/<Entity>.schema.json is JSON Schema generated from the Zod schema via Zod v4's built-in toJSONSchema at build time. (Generated file; committed; CI verifies it's in sync with the Zod source.)
import { openRepo, openStore } from 'gitsheets';
import * as schemas from '@cfp/shared/schemas';
export async function openPublicStore(repoPath: string) {
const repo = await openRepo({ gitDir: `${repoPath}/.git` });
return openStore(repo, { validators: {
people: schemas.PersonSchema,
projects: schemas.ProjectSchema,
// ...
}});
}In-memory secondary indices (bySlug.person, byLegacyId.person, byGithubUserId, bySlackSamlNameId, membershipsByPerson, tagsByAssignment, etc.) declared via Sheet.defineIndex per GitHub gitsheets#134.
Push daemon started at boot per GitHub gitsheets#132.
export interface PrivateStore {
getProfile(personId: string): Promise<PrivateProfile | null>;
putProfile(profile: PrivateProfile): Promise<void>;
deleteProfile(personId: string): Promise<void>;
findPersonIdByEmail(email: string): Promise<string | null>;
listAllProfiles(): AsyncIterable<PrivateProfile>;
getLegacyPassword(personId: string): Promise<LegacyPasswordCredential | null>;
deleteLegacyPassword(personId: string): Promise<void>;
countLegacyPasswords(): Promise<number>;
transact<T>(handler: (tx: PrivateStoreTx) => Promise<T>): Promise<T>;
}Two implementations:
S3PrivateStore— backed by@aws-sdk/client-s3against the configured S3-compatible endpointFilesystemPrivateStore— backed bynode:fs/promisesagainstCFP_PRIVATE_STORAGE_PATH
Both implement the same load-at-boot + in-memory + PUT-on-mutation pattern. The S3 backend enables bucket versioning at deploy; the filesystem backend uses temp-file-then-rename for atomic writes.
A Store class wraps openStore(repo, ...) and a PrivateStore instance. store.transact(opts, handler) runs the handler with both tx.public (gitsheets transaction) and tx.private (private-store transaction) available. On handler success, public commits first OR private writes first per use case (see private-storage.md).
export function renderMarkdown(source: string): { html: string, excerpt: string };Wraps unified + remark + rehype-sanitize per markdown-rendering.md. Used by record-serialization to populate *Html and *Excerpt derived fields on read.
The createTestPrivateStore shim in apps/api/tests/helpers/test-private-store.ts implements only the narrow surface needed by placeholder tests. Once the real PrivateStore interface and FilesystemPrivateStore implementation land in this plan, downstream tests should migrate to the real backend (or a properly typed stub). The shim can be removed or retained as a lighter alternative; that decision is made during implementation.
export async function bootStores(env: Env): Promise<Store> {
const store = await openPublicStore(env.CFP_DATA_REPO_PATH);
const privateStore = env.STORAGE_BACKEND === 's3'
? new S3PrivateStore(env)
: new FilesystemPrivateStore(env);
await privateStore.load();
return new Store(store, privateStore);
}Boot fails if either store is unreachable.
- All Zod schemas in
packages/shared/src/schemas/round-trip against fixture records (one valid + one invalid per schema) -
zod-to-json-schemabuild step keeps.gitsheets/<sheet>.schema.jsonfiles in sync; CI fails if drift -
npm testincludes a test that boots aStoreagainst acreateTestRepo()+createTestPrivateStore(), upserts a Project, queries it back, checks the path template rendered correctly - A test inserts a Person and writes a
PrivateProfilefor them viastore.transact, then verifies both stores reflect the change - A test verifies cross-store transaction rollback: handler throws after public-side stage → no public commit, no private PUT
- A test verifies dual-write semantics: handler succeeds but mock private PUT fails → public commit is rolled back via revert OR reconciliation hooks fire (decide which during implementation; document)
- Markdown pipeline test:
renderMarkdown('# Hello\n[link](https://x.org)')produces sanitized HTML and a plain-text excerpt - Markdown sanitizer rejects
<script>,javascript:,on*=, raw HTML — covered by RFC-style negative tests -
createTestPrivateStoreshim inapps/api/tests/helpers/test-private-store.tsis evaluated: either migrated to use the realFilesystemPrivateStoreor documented as intentionally retained as a lighter test fixture
- Cross-store rollback strategy. "Public committed, private failed" is the worst case. Options: (a) attempt to revert the public commit via
git revert, (b) log loud and let reconciliation handle it. The spec accepts (b); flag (a) as an open implementation question. - JSON Schema generation accuracy.
zod-to-json-schemais decent but can lose information (refinements, transforms). Compensate by hand-editing schemas only on the Zod side, regenerating, and treating the JSON Schema as derived. - Push daemon at scale of one replica. No coordination needed; running it is straightforward. Auth (deploy key) is wired in
deploy.
-
Cross-store rollback chose option (b) — reconciliation.
Store.transact()uses public-first by default (for updates/deletes) and private-first for account creation. If the private flush fails after the public commit, the error is thrown loud and in-memory state is rolled back. No automaticgit revertof the public commit is attempted.apps/api/scripts/reconcile-private-store.ts(not yet written, to be added in a downstream plan) is the recovery path. This matches spec decision inprivate-storage.md. -
Zod v4 does not work with
zod-to-json-schema@3.25.2. The library recognizes the Zod v4 schema class but produces empty schema objects. Used Zod v4's built-intoJSONSchemainstead (available asimport { toJSONSchema } from 'zod'). Thezod-to-json-schemadependency can be removed frompackages/sharedin a follow-up. -
$schemafield stripped from generated JSON Schemas. Zod v4'stoJSONSchemaoutputshttps://json-schema.org/draft/2020-12/schemabut gitsheets usesajv@8in draft-07 mode, which rejects the 2020-12 URI. The schemas use only constructs compatible with both drafts, so stripping the field is safe. -
StandardSchemaV1compatibility between Zod v4 and gitsheets. TypeScript cannot prove that Zod v4'sResult<Output>is assignable to gitsheets'StandardSchemaResult<Output>because of a structural mismatch in theFailureResultshape. A safeas unknown as StandardSchemaV1cast is used inopenPublicStore. Both types are correct at runtime. -
Sheet query after transact requires re-opening the sheet.
openStore/openSheetcaptures the git tree at call time. After atransactcommit, callingqueryFirston the captured sheet returns pre-commit state. Tests and production code that need post-commit reads must callrepo.openSheet()again. This is documented in gitsheets' internals (#dataTreeis fixed at open time). Seestore.test.tsfor the pattern. -
createTestPrivateStoreshim retained. The original harness test inapps/api/tests/harness.test.tsstill uses the shim viaTestPrivateStoreinterface. The new store tests use the realFilesystemPrivateStoredirectly. The shim is lighter (no schema parsing, no transact machinery) and valid for simple fixture use. Downstream plans that add real mutation logic should useFilesystemPrivateStore. -
Secondary in-memory indices not yet wired to
defineIndex. The spec describes usingSheet.defineIndexfor per-sheet secondary indices. This plan establishes the store foundation; the indices (bySlug.person,byLegacyId.person, etc.) are described indata-model.mdbut not yet populated viadefineIndexcalls. They will be needed in the read-api/write-api plans when lookups happen. Deferred there. -
writeOrder: 'private-first'orphan direction. In private-first mode,flushPrivate()runs inside thepublic.transactcallback before gitsheets commits. If private flush fails, the callback exits with an error and gitsheets does NOT commit the public tree — neither side is committed. However, if private flush succeeds but the callback subsequently throws (or gitsheets fails to commit for another reason), a private record without a matching public Person exists. This "private orphan without public" is the opposite direction from public-first failure. It is intentionally preferable for account creation: a private record without a public Person can be detected and cleaned up by the reconcile script, whereas a public Person without a private record (the public-first orphan) is harder to recover because the Person is visible to users without any associated email or auth data.
- Issue #14 — Remove
zod-to-json-schemafrompackages/shareddependencies (replaced by Zod v4's built-intoJSONSchema) - Deferred to write-api — Wire
Sheet.defineIndexcalls for all secondary in-memory indices described indata-model.md(bySlug, byLegacyId, byGithubUserId, membershipsByPerson, etc.) - Deferred to write-api — Implement
apps/api/scripts/reconcile-private-store.tsfor cross-store orphan detection/repair