Skip to content

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 Row schema in the shape of its columns. Reads decode through SqlSchema with Result: Row. Writes encode through Request: Row, so JSON and booleans are never hand-encoded into an INSERT.
  • One Row per 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 decode
const 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

  • fromRow and toRow are plain functions that only rearrange fields. They never parse, cast or call makeUnsafe. 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 rule app/exhaustive-row-mapper catches 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 Row change. A repository is written for its database’s dialect; one Row never serves two dialects.
  • On Postgres, a jsonb column’s codec is the plain schema X, and every write wraps that field in sql.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*Request and Patch*Request structs. 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, createdAt and 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:

packages/contracts/src/labeling.ts
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.ts
export const toPublicRule = (rule: LabelingRule): PublicLabelingRule => ({ … })

Source: notes/07-schema-and-data/schema-at-boundaries.md · Decision 2