Schema at boundaries
A row, a domain value and a wire payload are three different shapes. This page answers how many schemas an entity gets, what connects them and where decoding happens. The short answer: three shapes, two mappers, every crossing decoded, nothing derived across a boundary, nothing cast.
Decode every row through a Row schema, in both directions
Section titled “Decode every row through a Row schema, in both directions”Impact: HIGH a corrupt value fails in the repository, naming the field
- Every repository declares a
Rowschema in the shape of its columns. Reads decode throughSqlSchemawithResult: Row. Writes encode throughRequest: Row, so JSON and booleans are never hand-encoded into anINSERT. - One
Rowper table or query shape, in the repository file. Never redeclare it in a second file. - No query result is ever cast (
as RecordType,as unknown as).
❌ Incorrect — the read is cast and the write is hand-encoded:
const rows = (yield* sql`SELECT * FROM labeling_rules WHERE id = ${id}`) as RuleRecord[]yield* sql`INSERT INTO labeling_rules (evidence, enabled) VALUES (${JSON.stringify(rule.evidence)}, ${rule.enabled ? 1 : 0})`✅ Correct — the Row codec runs on the way in and on the way out:
const listRows = SqlSchema.findAll({ Request: ListRequest, Result: Row, execute: … }) // reads decodeconst insertRow = SqlSchema.void({ Request: Row, // writes encode execute: (row) => sql`INSERT INTO labeling_rules ${sql.insert(row)}`,})Source: notes/07-schema-and-data/schema-at-boundaries.md · Decision 1
Always map row ↔ domain with an exhaustive mapper
Section titled “Always map row ↔ domain with an exhaustive mapper”Impact: HIGH a field added on either side is a compile error until mapped
fromRowandtoRoware plain functions that only rearrange fields. They never parse, cast or callmakeUnsafe. The schema decodes types; the mapper reshapes structure.- The mapper is always there, even for a table that maps 1:1. Aggregates span tables, joins add columns, and flat columns become nested objects. Deriving the row from the domain only works until the two shapes diverge.
- The mapper is exhaustive: destructure every field and end with
rest satisfies Exhausted. A column read on purpose but not mapped gets a_-prefixed name (deleted_at: _deletedAt). The lint ruleapp/exhaustive-row-mappercatches a mapper written without the destructure. - The one canonical sketch lives in SQL and transactions.
❌ Incorrect — the row is derived from the domain, and the mapper constructs values:
const Row = LabelingRule.mapFields(Struct.assign({ evidence: Schema.fromJsonString(Evidence) }))
const fromRow = (row: RawRow): LabelingRule => ({ id: RuleId.makeUnsafe(row.id), // a constructor, not validation evidence: JSON.parse(row.evidence), createdAt: DateTime.makeUnsafe(row.created_at),})✅ Correct — decoded Row in, exhaustive reshaping out:
const fromRow = ( { id, repository_id, enabled, evidence, client_name, client_ip, created_at, org_id: _orgId, deleted_at: _deletedAt, ...rest }: typeof Row.Type,): LabelingRule => { rest satisfies Exhausted return { id, repositoryId: repository_id, enabled, evidence, client: { name: client_name, ip: client_ip }, createdAt: created_at }}Source: notes/07-schema-and-data/schema-at-boundaries.md · Decision 1, amended
Pick each column codec for the database’s dialect
Section titled “Pick each column codec for the database’s dialect”Impact: MEDIUM a wrong codec fails on the first read or write
- The rule is the same on Postgres, SQLite and D1. Only the codecs inside
Rowchange. A repository is written for its database’s dialect; oneRownever serves two dialects. - On Postgres, a
jsonbcolumn’s codec is the plain schemaX, and every write wraps that field insql.json(…). The encoded plain object cannot be bound by the driver on its own.
| column | SQLite / D1 | Postgres |
|---|---|---|
| boolean | Schema.BooleanFromBit (INTEGER 0/1) |
Schema.Boolean |
| instant | Schema.DateTimeUtcFromMillis (INTEGER) |
Schema.DateTimeUtcFromDate (timestamptz) |
| JSON | Schema.fromJsonString(X) (TEXT) |
X (jsonb), written via sql.json |
❌ Incorrect — fromJsonString on a Postgres jsonb column:
evidence: Schema.fromJsonString(Evidence) // the driver returns jsonb already parsed: every read fails✅ Correct — the plain schema, and sql.json on write:
evidence: Evidence, // Row field, Postgres// write:sql`INSERT INTO labeling_rules ${sql.insert({ ...row, evidence: sql.json(row.evidence) })}`Source: notes/07-schema-and-data/schema-at-boundaries.md · Decision 1, amended
Give the wire its own schemas, mapped by the server
Section titled “Give the wire its own schemas, mapped by the server”Impact: HIGH a new domain field cannot leak to clients by accident
- Request and response schemas live in the contracts package as
Public*,Create*RequestandPatch*Requeststructs. They reuse field-level schemas (branded ids, enums,Evidence) but never the domain entity itself. - The server maps domain → wire with a
toPublic*function next to the handler. The contracts package holds no mapping logic. - A response schema is an allowlist. A request schema says exactly what the client controls, so
the server fills
id,createdAtand the owner itself. - Accepted cost: a new public field is added in up to three places (domain, contract,
toPublic*).
❌ Incorrect — the wire is the domain minus a denylist:
export const PublicLabelingRule = LabelingRule.mapFields(Struct.omit(["deletedAt"]))// a future internalScore is published automatically✅ Correct — an explicit allowlist and an explicit mapper:
export const PublicLabelingRule = Schema.Struct({ id: RuleId, enabled: Schema.Boolean, evidence: Evidence, createdAt: Schema.DateTimeUtcFromString, version: Schema.Number,})export const CreateLabelingRuleRequest = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), evidence: Evidence,})// apps/server/src/labeling/http.tsexport const toPublicRule = (rule: LabelingRule): PublicLabelingRule => ({ … })Source: notes/07-schema-and-data/schema-at-boundaries.md · Decision 2