Expand description
cratefield-tables is the schema a venture declares in its manifest,
and the artifacts derived from that one declaration.
A venture edits [tables] in venture.toml and redeploys without a
rebuild, so the schema is a runtime value: rows are dynamic values
checked by an interpreter reading that value, not typed structs fixed
at compile time. Rust cannot infer a compile-time type from a runtime
value, so the two do not mix, and this is the representation for every
declared table.
The schema type is a small purpose-built enum, not JSON Schema. JSON Schema is sprawling and says nothing about the SQLite and Postgres DDL mapping, so it is emitted as a derived view instead.
§What one declaration produces
Schema::validatechecks the declaration itself and reports every violation together.Schema::ddlrenders forward-only, deterministic SQLite and Postgres DDL.validate_rowchecks aserde_jsonobject against a table and answers with acratefield_core::Problem.json_schemarenders a table as JSON Schema, for interop only.
§The bound on a declaration
A field can say required, length, range, format, enum, uniqueness, foreign key, default and index. Anything that has to read another row or another request is a function, not a field: overlap checks, state machines and side effects belong in a module or a sidecar. Uniqueness is on that list because the database enforces it, not the validator.
§Where the rules are pinned
corpus/rows.json holds triples of table, input and expected verdict,
run by tests/corpus.rs. It is a plain data file so a second
implementation in another language can be run over the same cases.
That is what keeps this validator and a generated client from
disagreeing about empty versus absent, number coercion, null versus
missing, Unicode length and whitespace.
§Pure logic
No I/O, no driver, no clock and no randomness. SQL is generated as
strings, so the crate builds for wasm32-unknown-unknown alongside
cratefield-core.
§Not built here
This crate is the schema definition and nothing downstream of it. The following are deliberately absent, each its own follow-up:
- CRUD route handlers, the access vocabulary and the batched read endpoint.
- The migration diff between two versions of a definition. Everything here only creates.
- The generated
@cratefield/clientTypeScript package, and the second run ofcorpus/rows.jsonagainst its Zod schemas. - Publishing the declared tables on
/__surface, and any control plane or manifest generator wiring.
§cratefield-tables
The schema a venture declares in its manifest. One declaration, several artifacts: the SQLite and Postgres DDL, the row validator, and a JSON Schema view for anyone who wants one.
Pure logic. No I/O, no database driver, no clock and no randomness, so it
builds for wasm32-unknown-unknown alongside cratefield-core.
§The schema type
use cratefield_tables::{FieldDef, FieldKind, Schema, TableDef, TextFormat};
let schema = Schema::new(vec![TableDef::new(
"subscriber",
"id",
vec![
FieldDef::new("id", FieldKind::Uuid).required(),
FieldDef::new(
"email",
FieldKind::Text { min_len: Some(3), max_len: Some(254), format: Some(TextFormat::Email) },
)
.required()
.unique(),
FieldDef::new("signed_up_at", FieldKind::Timestamp).required().indexed(),
],
)]);
schema.validate().expect("the declaration itself is valid");§Rules the declaration must satisfy
Schema::validate reports every violation together, the way
cratefield_core::Venture does:
- identifiers match
[a-z][a-z0-9_]*, hold no double or trailing underscore, and are at most 63 characters, which is what Postgres accepts without truncating; - a name is not a reserved SQL word, does not start with a prefix the
harness keeps (
harness_,sqlite_,pg_,cf_), and is not shaped like card data; - no table name repeats, and no field name repeats inside a table;
- the primary key is non-empty, names declared fields, and every one of
those fields is
requiredor has a default; - a foreign key names a declared field and points at a declared table whose primary key is a single column of the same kind, and the references across the schema hold no cycle;
- no two generated index names collide with each other, and none equals a declared table name;
- an enum declares at least one value, with no duplicates;
- bounds are the right way round, and a default is a value its own field would accept.
Identifiers are rejected rather than quoted. That is deliberate: it is
what lets the generated DDL leave every identifier unquoted, the way the
hand-written module migrations do. RESERVED_WORDS is PostgreSQL’s
reserved and type_func_name_keyword categories, SQLite’s keyword
list, and the Postgres system column names, merged and sorted.
A primary-key column is NOT NULL in the rendered DDL whether or not the
field says required, so a primary key that is neither required nor
defaulted would be a row validate_row and the JSON Schema accept and
the insert then rejects. Rejecting the declaration is what keeps the
three from disagreeing.
Index names and table names share one namespace, and it is the whole
schema rather than one table. {table}_{field}_idx is ambiguous because
an underscore is legal in both halves, so post.author_id and
post_author.id generate the same name; CREATE INDEX IF NOT EXISTS
would leave the second index silently absent, and a generated name
landing on a declared table loses the table instead.
§The manifest
[tables] is a map keyed by table name. A field is one entry in that
table’s fields array, flat: a kind and the attributes that kind
accepts. An attribute that does not apply to the kind is an error, not an
ignored key. A silently dropped max_len is the drift this crate exists
to prevent.
A venture.toml fragment, parsed here by a real TOML parser so the
example cannot drift from what the crate accepts:
use cratefield_tables::{FieldKind, Schema};
#[derive(serde::Deserialize)]
struct Manifest {
tables: Schema,
}
let fragment = r#"
[tables.author]
primary_key = "id"
[[tables.author.fields]]
name = "id"
kind = "uuid"
required = true
[[tables.author.fields]]
name = "email"
kind = "text"
format = "email"
max_len = 254
required = true
unique = true
[tables.post]
primary_key = "id"
[[tables.post.fields]]
name = "id"
kind = "uuid"
required = true
[[tables.post.fields]]
name = "title"
kind = "text"
min_len = 1
max_len = 200
required = true
[[tables.post.fields]]
name = "status"
kind = "enum"
values = ["draft", "published"]
required = true
default = "draft"
[[tables.post.fields]]
name = "read_minutes"
kind = "integer"
min = 0
max = 600
[[tables.post.fields]]
name = "author_id"
kind = "uuid"
indexed = true
[[tables.post.foreign_keys]]
field = "author_id"
references = "author"
"#;
let manifest: Manifest = toml::from_str(fragment).expect("the fragment parses");
manifest.tables.validate().expect("the declaration is valid");
// Tables arrive in name order, whatever order the manifest listed them in.
let names: Vec<&str> = manifest.tables.tables.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["author", "post"]);
let status = manifest.tables.table("post").unwrap().field("status").unwrap();
assert_eq!(
status.kind,
FieldKind::Enum { values: vec!["draft".to_owned(), "published".to_owned()] }
);Keys, and where each applies:
| Key | Applies to | Meaning |
|---|---|---|
name | every field | the column name |
kind | every field | text, integer, real, boolean, timestamp, uuid, json, enum |
required | every field | NOT NULL; a field with a default may still be left out of a write |
unique | every field | UNIQUE, enforced by the database |
indexed | every field | gets its own CREATE INDEX |
default | every field | a literal the field would itself accept |
min_len, max_len | text | length in Unicode characters |
format | text | email or url |
min, max | integer, real | inclusive bounds |
values | enum | the allowed strings, at least one |
primary_key takes a string or an array of strings and defaults to
"id". foreign_keys entries are { field, references }, where
references names another declared table and the referenced column is
that table’s primary key.
§DDL
Schema::ddl renders one script per dialect. It validates first, so SQL
can never come from a definition whose identifiers were not checked.
use cratefield_tables::{Schema, SqlDialect};
#[derive(serde::Deserialize)]
struct Manifest {
tables: Schema,
}
let manifest: Manifest = toml::from_str(r#"
[tables.post]
primary_key = "id"
[[tables.post.fields]]
name = "id"
kind = "uuid"
required = true
[[tables.post.fields]]
name = "status"
kind = "enum"
values = ["draft", "published"]
required = true
default = "draft"
[[tables.post.fields]]
name = "created_at"
kind = "timestamp"
required = true
indexed = true
"#).unwrap();
let sql = manifest.tables.ddl(SqlDialect::Sqlite).unwrap();
assert_eq!(sql, "\
-- Generated by cratefield-tables from the venture manifest.
-- Dialect: sqlite. Forward-only: this file only creates.
CREATE TABLE IF NOT EXISTS post (
id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
created_at TEXT NOT NULL,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS post_created_at_idx ON post (created_at);
");Forward-only. Only CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS are rendered, never DROP and never ALTER. The diff
between two versions of a definition is a separate problem.
Ordered by dependency. Tables are topologically sorted on the
foreign-key graph, so a table is always created after the tables it
references. Postgres refuses an inline REFERENCES to a table that does
not exist yet, and plain name order puts line_item before product.
Among the tables whose references are already created, the alphabetically
first goes next, which makes the order the lexicographically smallest one
that works.
Deterministic. The same definition always renders byte-identical SQL, and a test asserts it: tables in creation order, columns in declaration order, every clause on a column in a fixed position, and no hash map anywhere in the path.
Portable. Both dialects’ output passes
cratefield_core::lint_portable_sql, the predicate fz doctor runs over
a module’s hand-written migrations.
Column types per dialect:
| kind | SQLite | Postgres |
|---|---|---|
text, uuid, json, enum | TEXT | TEXT |
timestamp | TEXT | TEXT |
integer | INTEGER | BIGINT |
real | REAL | DOUBLE PRECISION |
boolean | INTEGER | BOOLEAN |
A timestamp is text in both and a UUID is text in both, because the
harness stores ISO-8601 timestamps and text ids everywhere else. Boolean
is the one kind whose storage differs, 0 and 1 against FALSE and
TRUE, because SQLite has no boolean type.
indexed on a field that is already unique or part of the primary key
adds no second index: the constraint has one.
§Row validation
validate_row checks a serde_json object against a table and answers
with a structured list. The list renders into the harness’s existing
RFC 9457 problem, validation-failed, so a declared table’s 400 reads
like a module’s. No new slug and no new error style.
use cratefield_tables::{ErrorCode, Schema, validate_row};
use serde_json::json;
#[derive(serde::Deserialize)]
struct Manifest {
tables: Schema,
}
let manifest: Manifest = toml::from_str(r#"
[tables.post]
primary_key = "id"
[[tables.post.fields]]
name = "id"
kind = "text"
required = true
[[tables.post.fields]]
name = "title"
kind = "text"
min_len = 1
max_len = 5
required = true
"#).unwrap();
let post = manifest.tables.table("post").unwrap();
validate_row(post, &json!({"id": "x", "title": "hi"})).unwrap();
let errors = validate_row(post, &json!({"id": "x", "title": "far too long"})).unwrap_err();
assert_eq!(errors.errors()[0].code, ErrorCode::TooLong);
let problem = errors.problem();
assert_eq!(problem.slug, "validation-failed");
assert_eq!(problem.status.as_u16(), 400);The rules, which are also the rules a generated client has to match:
- A row is a JSON object. Anything else is one error against the row itself, reported with an empty field name.
nullis absence.{"note": null}and{}always reach the same verdict.- A default satisfies
required. Only a field that is required and has no default has to be sent. - An unknown key is an error, never a dropped key.
- No coercion.
"42"is not an integer,1is nottrue. - An integer is a JSON number with no fractional part, which is
Number.isInteger(v)in JavaScript, so42.0is accepted. - Length is counted in Unicode scalar values, so a JavaScript
implementation counts
[...s].lengthand nevers.length. There is no normalisation:eplus a combining acute is two characters. - Nothing is trimmed. Whitespace is part of the value.
- A text value may not hold U+0000. SQLite stores a NUL in a
TEXTcolumn and PostgreSQL refuses the statement (22P05), so accepting one means a row that writes in development and answers500in production. Only U+0000 — every other control character stores on both engines, and a text column is allowed to hold a newline. In JavaScript the check iss.includes("\u0000"), before any length or format rule. The published JSON Schema says it too —{"not": {"pattern": "\u0000"}}on every text property — so a client generated from the contract refuses what the server would. - Bounds are inclusive at both ends.
- Uniqueness and foreign keys are not checked here. They read other rows, which makes them the database’s job and marks the line between a field and a function.
Errors come out in a fixed order: declared fields in declaration order, then unknown keys sorted by name.
§Normalisation is a separate step
normalize_row rewrites every declared format = "email" field into its
canonical form — trim, Unicode NFC, lowercase — and it is not part of
validate_row. The validator coerces nothing and trims nothing, which is
what lets the corpus mean one thing in two languages, so a quiet rewrite
inside it would break the contract the corpus exists to hold.
Running it is not optional, though. unique is enforced by the database
on the bytes it is handed, so without normalisation Alice@Example.COM
and alice@example.com are two rows, while every module in the harness
treats them as one address (cratefield_core::normalize_email, issue
#10). The writer calls normalize_row and then validate_row, in that
order.
§The conformance corpus
corpus/rows.json holds triples of table, input and expected verdict.
tests/corpus.rs runs the Rust validator over all of them, and a second
implementation in another language runs over the same file. That is what
stops this validator and a generated client disagreeing about empty
versus absent, number coercion, null versus missing, Unicode length and
whitespace. Every one of those five has cases, and a test fails if any
group loses them.
Match on the code, never on a message: codes are the stable vocabulary,
messages are prose. The file lists its own vocabulary, and a test fails
if that list and the crate’s codes drift apart.
§The JSON Schema view
json_schema renders a table as a JSON Schema (draft 2020-12) object.
It is a derived view and never the source. Nothing here reads a JSON
Schema back. It exists so an outside tool that speaks JSON Schema can
describe a declared table, and if the two ever disagree the TableDef is
right.
use cratefield_tables::{Schema, json_schema};
#[derive(serde::Deserialize)]
struct Manifest {
tables: Schema,
}
let manifest: Manifest = toml::from_str(r#"
[tables.post]
primary_key = "id"
[[tables.post.fields]]
name = "id"
kind = "uuid"
required = true
[[tables.post.fields]]
name = "title"
kind = "text"
max_len = 200
required = true
[[tables.post.fields]]
name = "note"
kind = "text"
"#).unwrap();
let view = json_schema(manifest.tables.table("post").unwrap());
assert_eq!(view["type"], "object");
assert_eq!(view["required"], serde_json::json!(["id", "title"]));
assert_eq!(view["additionalProperties"], false);
// An optional field is nullable, because null and absent are the same here.
assert_eq!(view["properties"]["note"]["type"], serde_json::json!(["string", "null"]));Carries over exactly: minLength and maxLength count Unicode code
points in JSON Schema too; type: "integer" in draft 2020-12 is a number
with a zero fractional part, the same rule; additionalProperties: false
is the unknown-key rejection; minimum and maximum are inclusive.
Does not carry over: format is an annotation in JSON Schema and an
assertion here; a validator that distinguishes null from a missing key
will still disagree about a required field set to null; and
uniqueness, foreign keys, indexes and defaults do not appear at all.
§What a change costs
diff(previous, current) reports every difference between two
declarations and what each one costs, in docs/ROLLBACK.md §5’s
vocabulary rather than a new one:
| Step | Meaning |
|---|---|
expand | additive — the old build keeps working and a rollback stays free |
contract | something is removed; safe once nothing deployed reads it, and a rollback past the switch is gone afterwards |
rewrite | cannot be applied forward-only to rows that already exist |
The rule behind every rewrite is the same one: the rows already
stored were written under the old declaration. A new NOT NULL column
with no default has nothing to put in them; a narrowed bound may already
exclude them; a dropped enum member may be the value some of them hold; a
new unique column gives every one of them the same value.
Two are worth calling out because they read as harmless:
- Adding a bound where there was none is narrowing.
Noneadmitted everything, so the firstmax_lenis stricter than nomax_len. - A changed default is additive and reports as such — it is read when a write omits the column, so rows already there keep what they have. An author expecting a backfill needs to be told they are not getting one.
Nothing here refuses anything. Schema::validate says what is legal;
this says what it costs. Refusing a contract would make the tool wrong
for the case the expand/contract discipline exists to serve — dropping a
column on purpose, in its own migration, once nothing reads it.
§A declared table has to say who may reach it
access is one of public-read, owner, tenant-members or admin —
a small fixed vocabulary evaluated in Rust, and it stays a vocabulary.
A policy language in a manifest is a second program with no type checker
and no tests deciding who sees what; four words the harness evaluates are
four things a reviewer can read.
Required, with no default, for the reason the privacy block has one:
public-read by default publishes a venture’s tables the day the CRUD
layer lands, and admin by default makes them useless until somebody
notices.
owner matches a caller against the column the table’s privacy block
names as its subject, so declaring it on a table that holds nothing
personal is a manifest error — there is no column to match against, and a
route falling back to “everyone” or “nobody” would be deciding that
silently.
A venture that declares any table whose access is not public-read
generates a module requiring the Auth port, and a runtime that wires
one from the environment (AUTH_ISSUER, AUTH_CLIENT_ID). The harness
refuses to build a composition whose runtime provides no Auth port at
all, so a hand-written venture that forgets it does not start.
A generated one always provides the port, because on Workers the Env
exists per request and a runtime cannot know at compose time whether the
issuer is set. With those variables unset the port is a verifier that
verifies nothing: an anonymous request stays anonymous, so public tables
are unaffected, and a request presenting a credential gets 503 — never
401, because the token may be perfectly good and nothing there can
tell. The boot log names the missing variables.
So the guarantee is “an unconfigured deployment cannot serve a non-public table”, not “it cannot start”. Making production refuse to start on this belongs with the production-readiness check that already covers a captcha adapter which is present but unbound; it is not built for auth yet.
fz tables diff reports a change to this level, and says which way it
moved. It has to: flipping one table from owner to public-read
changes no column, so a diff that compared schemas alone answered “No
change” and exited zero for an edit that publishes every subject’s
private rows.
The routes that serve these are built now, downstream in
crates/tables-api — and the declaration came first, so no table can be
served without an author having said who may see it, and no manifest
written before the rule needs migrating to it.
§A declared table has to say what it holds
The declaration lives in the manifest rather than here, because a
TableDef is also what cratefield-introspect builds from a live
catalog and a table read out of a database has no author to have declared
anything. See cratefield_manifest::privacy.
It is required, with no default, because both available defaults are
wrong: “nothing personal unless you say” puts a venture’s own tables
outside fz data export, outside subject access and outside erasure,
silently — the exact hole auth-core’s deletion_jobs sat in (#272) —
and “personal unless you say” deletes a venture’s reference data the
first time somebody asks.
cratefield-introspect::drift asks the same question of a live
database: what does this database differ from the declaration by? That
one has two traps a declaration-to-declaration diff does not — a database
holds tables the declaration never named, and a catalog cannot see most of
what a declaration says — and its module docs are where both are written
down.
Still not built: generating the ALTER statements. A generated migration
is a guess about data this crate cannot see, and the report is what an
author needs before the statements are worth writing.
§Where the two engines are not the same
The DDL renders the same shape for both dialects, but three guarantees are weaker on SQLite. Written down because a reader of the column-type table would assume otherwise, and each one is a difference in what the engine actually enforces:
- An integer primary key is a rowid alias in SQLite: it auto-assigns
when a write omits it and accepts no other type. A Postgres
BIGINT PRIMARY KEYis an ordinary column that must be supplied. The row validator requires a primary key to be present or defaulted so the two agree; do not rely on the database filling it in. - A boolean column is unconstrained on SQLite, because it is stored as
INTEGERand nothing stops a direct SQL write of7. Postgres rejects it. The row validator is what makes the two agree, so a write that bypasses it is checked on only one engine. - Foreign keys are inert on SQLite without
PRAGMA foreign_keys=ON, which is per connection, off by default, and set nowhere in this repository.REFERENCESrenders in both dialects; only Postgres enforces it today.
None of the three is worked around here: a generated CHECK, or a pragma
issued behind the caller’s back, would take the output outside the
portable subset the hand-written module migrations use. They belong to
the writer and to connection setup, and are recorded so that layer
inherits a list rather than a surprise.
§Not built here
This crate is the schema definition; everything that serves a
declaration lives downstream of it, in crates/tables-api — the CRUD
routes, the access vocabulary, the batched read endpoint, the
/__surface publishing, and the composite-key single-row routes of ADR
0018. What is still open is the client half:
- The generated
@cratefield/clientTypeScript package, and the second run ofcorpus/rows.jsonagainst its Zod schemas (#155).
MIT. Built in the open for Cratefield, a Factory Zero venture.
Structs§
- Change
- One difference between two declarations.
- Decode
Error - A column that did not come back as the declaration says it is.
- Field
Def - One declared column.
- Filter
- One equality condition on a declared column.
- Foreign
Key - A reference from one declared field to another declared table’s primary key.
- Owned
- Rows belonging to one subject: the condition an
ownertable’s access level puts on every read. - Page
- What a page asks for.
- RowError
- One rejected field.
- RowErrors
- Every reason a row was rejected, in a fixed order. Never empty.
- Schema
- Every table a venture declares, in name order.
- Sort
- An ordering on one declared column.
- Table
Def - One declared table.
- Value
Error - Why one value was rejected: a code to match on and a line to read.
Enums§
- Error
Code - The stable vocabulary a conformance case is matched on. Messages are prose and may be reworded; a code may not, because a second implementation asserts against it.
- Field
Kind - What one column holds. Deliberately small: every variant maps to a column type in both dialects and to one rule in the row validator.
- SqlDialect
- The engine a rendering targets.
- Step
- What a change costs, in
docs/ROLLBACK.md§5’s vocabulary. - Text
Format - A text field’s declared format. The row validator asserts these; JSON Schema only annotates them, which is one of the reasons the JSON Schema rendering is a view and not the source.
- Update
Error - What an
UPDATEcan be refused for.
Constants§
- JSON_
SCHEMA_ DIALECT - The dialect the rendering declares.
- MAX_
DETAIL_ ERRORS - The largest number of errors a problem
detailspells out before it summarises the rest. The structured list is never truncated. - MAX_
IDENTIFIER_ CHARS - The longest identifier both dialects accept without truncation. Postgres cuts identifiers at 63 bytes; the rule keeps a declared name meaning the same thing on both engines.
- MAX_
UNKNOWN_ KEY_ CHARS - The longest an unknown key is echoed back at the caller, in characters. Declared field names are bounded by the schema’s own identifier rules; an unknown key is whatever the caller sent, so a megabyte key name came straight back in the 400 body, ten of them per request. Truncated keys keep the error useful — a misspelling is visible in the first few characters — without turning the validator into an echo service.
- RESERVED_
PREFIXES - Name prefixes the harness keeps for itself. A declared table or field may not start with one, so a venture can never shadow the migration bookkeeping or an engine catalogue.
- RESERVED_
WORDS - Every word either engine treats as a keyword in a table or column position, plus the Postgres system column names. A declared name is rejected rather than quoted, which is why the generated DDL needs no quoting at all.
Functions§
- check_
value - Checks
valueagainstkind. - delete
DELETEof one row by primary key, optionally scoped to one subject.- diff
- Every difference from
previoustocurrent, tables in name order and fields in the order the new declaration lists them. - from_
sql - One column of a returned row, as the JSON the declaration promises.
- index_
name - The name of the index a declared
indexedfield gets:<table>_<field>_idx. Postgres index names are unique across a schema and not per table, so the table name is part of it. - insert
INSERTfor one row.- is_
identifier - Lowercase snake-case: starts with a letter, then letters, digits and single underscores, never ending in one. Nothing else is accepted, and that is what lets the generated DDL leave identifiers unquoted.
- is_
rfc3339 - A strict RFC 3339 timestamp:
YYYY-MM-DDTHH:MM:SS[.fraction](Z|+HH:MM|-HH:MM). - is_url
- An absolute
httporhttpsURL: a case-insensitive scheme,://, a non-empty authority, and no ASCII whitespace or control character anywhere. Deliberately narrow: a declared field is a form input, not a URL parser test suite. - is_uuid
- A hyphenated 8-4-4-4-12 UUID in either case. No braces, no
urn:uuid:prefix, and no version or variant check: the value is an identifier, and the harness does not mint it. - json_
schema - Renders
tableas a JSON Schema (draft 2020-12) object. - normalize_
row - Rewrites every declared
format = "email"field ofrowinto its canonical form, and returns the row. - row_
json - A returned row as the JSON the declaration promises.
- select_
one SELECTof one row by primary key, optionally scoped to one subject.- select_
page SELECTof a page, ordered by primary key, optionally after a cursor.- to_sql
- A JSON value for one declared field, as a bound parameter, or
Nonewhen the value is not the kind the column is declared as. - update
UPDATEof one row by primary key, replacing every declared non-key field, optionally scoped to one subject.- validate_
row - Checks
rowagainsttable.