docs/designing-systems.md
Rama main · réplica de lectura del repositorio original.
# Designing new systems
When planning a new feature, design it to have every quality below. Each one
names reference implementations in this codebase — read the exemplar before
designing, and copy its shape rather than inventing a new one. These are the
systems we want more of.
## Schema-tized
Model the thing as data first — a typed schema plus a few functions folded over
it — and derive everything else (types, validation, rendering, routes) from that
one declaration. The philosophy is in the Preferences of AGENTS.md ("Schema over
organic structure", "Shared interfaces over branch-per-case"); these are the
mechanisms to copy:
- **A valibot schema as the single source of truth for a value type.** Declare
once; derive the TS type, the runtime guard, and the options list:
```typescript
export const ContactFieldSchema = v.picklist([
"email",
"phone",
"address",
"special_instructions",
]);
export type ContactField = v.InferOutput<typeof ContactFieldSchema>;
export const CONTACT_FIELDS = ContactFieldSchema.options;
export const isContactField = (s: string): s is ContactField =>
v.is(ContactFieldSchema, s);
```
See `src/shared/types.ts` (six of these) and `src/shared/price-modifier.ts` (a
whole family). For structured values, compose `v.object` schemas into a
discriminated union with `v.variant("kind", […])` and a single `v.is` guard —
`src/shared/bulk-email-targets/types.ts` is the reference.
- **Declarative tables.** `defineTable`/`defineIdTable`
(`src/shared/db/table.ts`, `src/shared/db/define-id-table.ts`): a `columns`
config built from the `col.*` builders (`col.boolean`, `col.encrypted`,
`col.generated`, …) drives serialization, encryption, and the derived `Input`
type. Never hand-write row mapping.
- **Config-driven CRUD.** `defineCrudApi` (`src/shared/rest/crud-api.ts`) turns
one config object into the five standard admin API routes. `defineResource`
(`src/shared/rest/resource.ts`) turns `{table, fields, toInput, validate}`
into typed operations that `writeEntity` (`src/shared/rest/write-entity.ts`)
persists. The callers to copy are `src/features/admin/modifiers.ts` and
`src/features/admin/settings-logistics.ts`.
- **Schema-driven forms.** `defineForm` + a `Field[]`
(`src/shared/forms/definition.ts`): one field list drives the HTML rendering,
the parsing/validation, and the `FormValuesFor<>` value types;
`createFormRoute`/`createAuthedFormRoute` (`src/shared/app-forms.ts`) wire
that same schema to both the GET (render) and POST (validate) handlers.
- **Form section headers are a `FormSection[]`, never a hand-rolled heading.** A
form's grouped sections are modelled as data — a `FormSection[]` (`legend`
- `children`) rendered by `FormSections`
(`src/ui/templates/components/aggregate-sections.tsx`), which turns each
entry into a legend-led `SectionFieldset`. A single section uses
`SectionFieldset` directly. Never head a form section with an `<h3>`/`<h4>`
— a `<legend>` is the section header, and routing every section through
`FormSections`/`SectionFieldset` keeps that so. The listing form
(`listings/form-sections.tsx`) and the attendee form
(`admin/attendee-form.tsx`) both build a `FormSection[]`, and show how to
make a section conditional (`compact` drops the ones that do not apply).
- **One vocabulary for "attached to any record".** `defineRecordTarget`
(`src/shared/db/record-target.ts`): a domain says which kinds of record it
accepts and which two columns hold the kind and the id, and gets back the
naming (`of("listing")(7)`), a stable `key`/`fromKey` pair, the
`where`/`whereMany`/`whereChosenBy` clauses, the matching deletes, and an
existence check. Notes (`src/shared/db/notes/target.ts`), image links
(`src/shared/db/images.ts`), and site page items
(`src/shared/site-pages/target.ts`) all use it — a fourth "attach something to
any record" feature declares its kinds, it does not invent a fourth
vocabulary.
- **A data table plus one fold.** `LISTING_DEFAULT_FIELDS` +
`resolveListingDefaults` (`src/shared/listing-defaults.ts`); the admin guide's
`GuideSection[]` + `renderGuideSections`.
If your plan contains a hand-rolled dispatcher, an ad-hoc form, bespoke CRUD
routes, or hand-written row (de)serialization, stop: there is a `define*`
factory for that already. Use it — or extend it for every caller.
## Checked forwards and backwards
Declare each stateful lifecycle as a machine. Use generated models to check
transition safety. Use database tests and bounded scans to check stored facts.
These checks serve different purposes.
- **Declare the machine as data.** Nodes carrying representative states built
through the production transitions, events, and an exhaustive moves table — a
cell missing from the table is a declared refusal, never a fallthrough. The
framework is `src/shared/schema-atlas/machine-spec.ts`; the machines are
`src/shared/payment/{row,refund,review,sumup-recovery}-machine-spec.ts`, with
whole-graph properties (all reachable, all can end, one declared
provider-wait) in their `graph.test.ts` suites over the shared
`#test-utils/machine-graph.ts` walker.
- **Derive, never restate.** SQL guards (`rowWorkMirrorSql`), status words,
danger flags, and the operator map (`SCHEMA_ATLAS_MACHINES`, rendered at
`/admin/schema`) all derive from the spec. Vocabulary lists derive from the
machine's own types — an exhaustive mapped record keyed by the machine's union —
so a grown or renamed state stops compiling until every consumer learns it.
- **Check machine safety with models.** Use the
[model verification guide](model-verification.md) for the author procedure,
focused exemplars, commands, and proof limits. Generate facts from production
decisions or stored outcomes. Keep expected laws independent of the business
calculation. Preserve the identities that each law needs. Check initial
safety, an unrestricted inductive step, and real negative controls.
Representative domains remain bounded even when induction covers any run
length. Model safety does not establish SQL atomicity, provider truth, or
eventual execution.
- **Keep evidence boundaries explicit.** A reconstructed fee basis is not an
observed fee-calculation input. The checkout composition model does not
establish nonzero booking-fee calculation or full tax coverage. Separate
passing models need explicit joins before they support a composed claim.
Grades and JEV are advisory, not substitutes for Apalache or database tests.
- **Observe real writes.** `test/test-utils/payment-seam.ts` records claim and
refund-arm facts through test-only database triggers. A later settlement
cannot erase a recorded violation. The observer checks the authority revision
and request identity at arm time. Callback refunds can precede an attendee
anchor. Keep the schema, permission, concurrency, provider and crash tests.
- **Enumerate flows × crash windows.** List every flow; every await between two
durable writes is a window; each window gets an idempotent-redelivery proof or
a fault-injected test. Faults are triggers at SQLite's own boundary, not stubs
(`test/test-utils/db-fault.ts`), so the failing write rolls back exactly as
production would.
- **Check stored data separately.** A bounded scan reads SumUp state, checkout-id
and clock rules from the live database. It shows each fault to the operator
(`src/shared/db/schema-anomaly-scan.ts`, the `/admin/schema` "Live check").
A proof over valid transitions does not detect corrupt stored records.
## Pure, functional
Write the core of a feature as pure data-in/data-out functions and keep IO (DB
reads, settings, fetches) in a thin shell around it. Pure modules are trivially
unit-testable, which is what keeps 100% coverage and a 100% mutation kill rate
cheap to sustain.
- `src/shared/largest-remainder.ts` — a complete allocation algorithm with zero
imports; the hardest logic in the money paths and the easiest to test.
- `src/shared/listing-defaults.ts` — the header states "This module is pure":
callers fetch, it computes.
- `src/shared/ledger/project.ts` — pure projections over a slice of transfers;
every derived total reuses the single `allBalances` fold, so no two totals can
disagree.
- `src/shared/phone.ts`, `src/shared/countries.ts` — pure normalization, and a
pure data table with total accessors.
Prefer the curried utilities from `#fp` over imperative loops (see "FP Imports"
in AGENTS.md). When a module needs both computation and configuration, split it
the way `src/shared/dates.ts` does: the pure functions take the timezone as an
argument, and thin wrappers inject `settings.timezone` — the pure core stays
testable without a database.
## Modularised
One concept per file; one layer per directory. `docs/repo-structure.md` defines
where things go (`src/features/*` routes, `src/shared/*` domain logic,
`src/ui/*` presentation). Within `shared/`, the shapes to copy:
- `src/shared/rest/` — `resource.ts` (the resource abstraction), `handlers.ts`
(HTTP wiring), `crud-api.ts` (the JSON API): each file is one layer, named for
its job.
- `src/shared/ledger/` — `types.ts`, `project.ts`, `account.ts`, `reconcile.ts`:
a domain split into files you can navigate blind.
- `src/shared/db/attendees/` — `queries.ts`, `pii.ts`, `capacity.ts`,
`stats.ts`, `delete.ts`: a big table's concerns separated instead of one
1,500-line module.
A new system must arrive as a small directory of single-purpose files, not one
grab-bag module — and not as fragments scattered through unrelated existing
files.
## Well-named files
The filename states the concept; the concept fills the file.
`largest-remainder.ts`, `phone.ts`, `slug.ts`, `define-id-table.ts`,
`request-cache.ts`, `keyed-cache.ts` — you can guess each file's exports from
its name and vice versa. Function names carry contracts the same way: the `Raw`
suffix on `getAttendeesRaw`/`getAttendeeRaw` means "PII still encrypted —
decrypt before display", and `getUserDisplayFields` names the exact narrow
column set it selects. If you cannot name the file in a couple of words, it is
probably two concepts — split it.
## Valibot and standard libraries
Validation is valibot; collections are `@std/collections` (via `#fp`); paths,
media types, and cookies are `@std/path`, `@std/media-types`, and
`@std/http/cookie`; date/timezone math is `Temporal` (temporal-polyfill);
formatting is `Intl`. Valibot patterns to copy:
- **Branded scalar** — `src/shared/validation/email.ts`:
`v.pipe(v.string(), v.trim(), v.toLowerCase(), v.email(), v.brand("ValidEmail"))`.
A `ValidEmail` can only be produced by validation, so downstream code needs no
re-checks.
- **Coercing schema factory** — `src/shared/validation/number.ts`:
`createIntSchema(minimum)` validates digits _before_ `v.transform(Number)`
(closing the `parseInt("5abc")` hole); `PositiveIntSchema` and friends are its
specializations.
- **Boundary validation** — `src/features/api/sms-webhook.ts`: `v.safeParse` an
envelope `v.object` immediately after `JSON.parse`, 400 on failure. Validate
at the boundary; pass typed values inward.
- **Deliberate non-use is fine when the platform is better** —
`src/shared/validation/timestamp.ts` delegates instant validation to
`Temporal.Instant.from` (valibot's `isoTimestamp` accepts overflow days) and
documents why.
## Do not reinvent the wheel
Before writing an algorithm, formatter, or parser, check `deno.json` — the
answer is usually already a dependency. When the project's calling convention
differs from a library's, write a thin adapter; do not re-implement:
- `src/fp.ts` — `unique`, `uniqueBy`, `mapNotNullish`, `sumOf`, `chunk` are
one-line curried adapters over `@std/collections`.
- `src/shared/db/table.ts` — `toCamelCase`/`toSnakeCase` delegate to valibot's
case actions rather than bespoke regexes.
- `src/shared/timezone.ts` — all DST/offset math is `Temporal`;
`src/shared/currency.ts` gets currency symbols and decimal places from
`Intl.NumberFormat` instead of a hand-maintained table; `src/shared/slug.ts`
validates with `v.slug()`.
When you genuinely must hand-roll, document the reason at the definition the way
`#fp`'s `groupBy` does (it exists because `@std/collections` lacks the ordering
guarantee its callers rely on).
## Curried helpers
Currying is the house style for both de-duplication (see "Code Duplication" in
AGENTS.md) and API design: the factory takes the configuration, the returned
function takes the data.
- `makeOutcome(succeeded)` → `export const ok = makeOutcome(true)` /
`fail = makeOutcome(false)` (`src/shared/response.ts`).
- `roleIn(levels)` → `isStaffRole`, `isDeliveryRole` (`src/shared/types.ts`) —
predicate factories instead of near-identical functions.
- `balanceOf(account)` → `(transfers) => number` and friends
(`src/shared/ledger/project.ts`) — curried projections that compose.
- At larger scale the same shape becomes the config-driven factories:
`defineTable`, `defineCrudApi`, `defineForm`, and `cachedClientFactory`
(`src/shared/payment-helpers.ts`).
## Readable by the coverage merge
The coverage gate has three recorded failures on `--parallel` runs. Each failure
reported a function's body lines as unhit (`DA:0`) while the tests demonstrably
executed them. Sometimes the same function also carried a non-zero `FNDA`
count. The sightings are `crud-parsers.ts` on #2504, the note in
`src/shared/db/attendees/update.ts`, and the exclusions in
`scripts/coverage-check.ts` with a "mis-attributes" comment. Only the #2504
sighting has a known shape cause: its body sat in the inner arrow of a curried
ternary helper. The other sightings have no known shape cause yet. A minimal
fixture of the #2504 shape did not reproduce the mis-read. Treat the house
shape below as the working rule from that one case, not as a proven cause.
Write the logic in the house shape, so the merge can attribute it:
- The logic lives in a module-level, named, flat function: a block body with
`if`/`return`, no ternary head, no multi-line expression body.
- Currying comes from a shared combinator, never from a hand-written returned
closure in a domain file. `bindFirst` (`#shared/fp-bind.ts`) gives a plain
multi-argument function partial application: write `requiredDate(key, value)`
flat, then `export const requireDateString = bindFirst(requiredDate)`.
- The combinator's own closure lives in `src/shared/fp-bind.ts`, and the modules
that call it (`src/shared/types.ts`, `src/shared/db/settings/raw-writes.ts`,
`src/features/admin/area-loaders.ts`) load it from many isolates. The domain
logic stays a plain function whose lines the merge can sum across workers.
The evidence is a before and after on the real case. `crud-parsers.ts` read
`DA:0` on the inner arrow of a curried ternary helper and failed the gate. The
same logic as a plain two-argument function with an `if`/`return` body passed
the gate with no exclusion (#2504). A load-only isolate contributes a
zero-count range for a compiled-but-uncalled function, so give the merge
nothing to mis-sum in the body lines: keep them flat, named, and block-bodied.
When the gate reports `DA:0` on lines the tests demonstrably run, check the
shape against this section before reaching for an exclusion.
## Built for cold starts
Most production requests land on a freshly booted edge isolate with a ~500ms
startup budget and a limited subrequest budget
(`scripts/bench/cold-start/bundle-load.ts` measures single-file load and
`scripts/bench/cold-start/first-request.ts` measures request round trips). The
rules, with their reference implementations:
- **Nothing heavy at module load.** Entry points only register the handler
(`src/edge.ts`); app boot runs `once()` on the _first request_
(`src/serve-app.ts`). Module-load work is fine only when pure and cheap (for
example `defineTable` building its schemas once).
- **A foundation module must not reach the database.** `#shared/env.ts`,
`#shared/now.ts` and the crypto modules load before anything else, so a new
import in one of them can close a ring back to itself. Whichever module the
runtime then evaluates second reads a name the first has not reached yet, and
the app dies at startup with "Cannot access X before initialization". Which
module loses that race depends on the entry point, so such a ring can pass the
whole Deno suite and take every Cucumber Feature down. When a foundation
module needs a helper, put the helper in a module that imports nothing, the
way `parseDateMs` sits in `#shared/now.ts` rather than in `#shared/dates.ts`,
which reads a site's timezone from the database.
`test/integration/import-cycles.test.ts` holds `src/` at the rings it already
carries, and that list only shrinks.
- **Lazy singletons via `once`/`lazyRef` from `#fp`.** The DB client (`getDb` in
`src/shared/db/client.ts`), the dynamically imported Stripe SDK
(`src/shared/stripe.ts`), the Liquid email engine, crypto key material — all
first-use, never import-time.
- **Request-scoped memoization, not global state.** `requestCache`
(`src/shared/request-cache.ts`) shares one fetch among all callers within a
request. The one request store (`RequestStore` in
`src/shared/request-context.ts`) carries the facts and slots of one request. A
new per-request value is a `RequestSlot` on that store. The store allocates
the slot on first use, and a read outside a live request answers `undefined`.
See `QUERY_LOG_SLOT` in `src/shared/db/query-log.ts` for the pattern. Two
concurrent requests on one isolate cannot clobber each other, and a leaked
post-request context always reads as "outside a request". A scope with a
different lifetime, such as a nested re-scope, still uses the factories in
`src/shared/request-scoped.ts` (`createScope`, `createScopedValue`,
`createBooleanScope`). That module is the only one allowed to touch
`AsyncLocalStorage`. Isolate-lived caches are best-effort and bounded
(`src/shared/db/keyed-cache.ts` and the settings version-stamp cache in
`src/shared/db/settings.ts`) — never authoritative for security decisions, and
invalidated automatically by the write-sniffing db client
(`src/shared/cache-registry.ts`).
- **Compile once, render many.** ICU message templates (including the
`I18N_REPLACEMENTS` rebranding pass) compile once and cache
(`src/shared/i18n.ts`), so rendering is a plain format call.
- **Respect the subrequest budget.** Fixed-cost designs like
`src/shared/limits.ts` (one SELECT plus one batch regardless of batch size),
`UPDATE … RETURNING` instead of update-then-select (`src/shared/db/table.ts`),
and `queryBatch` for multi-read round-trips. Bunny has a hard limit of 50
subrequests per request. One libsql `execute`, batch, transaction
begin/statement/commit/rollback, or external fetch counts as one; statements
inside one batch still count as one. The client guard blocks database call 51,
but routes that also call providers or storage must target at most 40 database
calls so those other fetches still fit.
- **Model realistic database latency.** The request benchmark uses 0, 5, 10, and
20 ms per libsql round trip. Treat 20 ms as the expected worst case for a
replicated database; do not publish 50 or 100 ms projections as realistic
production measurements without evidence from production.
A new feature that adds a top-level `await`, an import-time SDK load, or a
per-request whole-table read is a cold-start regression even if it works.
## Efficient SQL
The rules live in "Database Queries" in AGENTS.md (narrow column lists, bounded
reads, batches vs interactive transactions). Beyond those, copy these shapes:
- **Enforce invariants in the mutating statement itself.**
`src/shared/db/capacity.ts` embeds the capacity check in the same
INSERT/UPDATE that books the attendee — no read-modify-write race, no second
round-trip.
- **Trigger-maintained aggregates instead of scans.**
`listings.booked_quantity`/`tickets_count` are maintained by triggers on
`listing_attendees`
(`src/shared/db/migrations/2026-06-16_listing_aggregates.ts`), so listing
reads never sum attendee rows.
- **One-round-trip patterns.** `UPDATE … RETURNING *` in
`src/shared/db/table.ts`; `queryBatch`/`executeBatchWithResults` in
`src/shared/db/client.ts`; keyset pagination for unbounded reads
(`src/shared/db/backup.ts` with `BACKUP_PAGE_SIZE`).
## Decrypt only what you need
Encrypted data stays encrypted until the moment of display, and lookups never
require decryption:
- **Blind HMAC indexes for lookups.** Alongside each searchable encrypted value
sits a deterministic `hmacHash` index column: `username_index`
(`src/shared/db/users.ts`), `ticket_token_index`
(`src/shared/crypto/hashing.ts`, `src/shared/db/attendees/queries.ts`),
`phone_index` for inbound SMS (`src/shared/db/attendee-phone-index.ts`),
`code_index` on modifiers. Query `WHERE …_index = ?`; never scan-and-decrypt.
(The one sanctioned scan-decrypt — invite codes in `users.ts` — is documented
and bounded by a tiny keyspace.)
- **One blob, one decrypt, decrypt late.** All attendee PII lives in a single
`pii_blob` (`src/shared/db/attendees/pii.ts`); list queries select it without
decrypting (`getAttendeesRaw` and friends in
`src/shared/db/attendees/queries.ts`), and `decryptAttendees` runs only at
render time. `decryptPiiBlob`'s `paidListing` flag even gates which fields
come out of the blob, and `getAttendeeNamesByIds` decrypts just the name.
- **Skip encrypted columns entirely when you can.** `getUserAuthFieldsById`
(`SELECT id, admin_level`) and `getAttendeeKindsByIds` (`SELECT id, kind`)
answer their questions without touching a ciphertext — the same discipline as
"Select only needed columns", applied to plaintext-in-memory.
- **Declarative encryption at the column layer.**
`col.encrypted`/`col.encryptedText` in `src/shared/db/table.ts` decrypt
lazily, per present column; a new encrypted column is declared, not
hand-wired.
- **Keys are request-scoped and short-lived.** The session private key is
fail-closed per request (`src/shared/session-private-key.ts`) and decrypt
caches are TTL-bounded to seconds (`src/shared/crypto/keys.ts`).