@bezoar/schema
v0.0.5
Published
The bezoar contract: the query model, the type table, and runtime validation
Readme
@bezoar/schema
Types and runtime validation for the bezoar contract. The validation uses zod schemas built from the contract.
Install
pnpm add @bezoar/schemaThe contract
A definition declares the contract. @bezoar/kit builds one from the
SQL of a database package. The definition is plain data and holds no SQL.
- enums maps each enum type of the database to its labels, in declaration order.
- queries maps each query name, such as
events.list, to its statement. - A statement has its slots and its columns. A statement with no
column returns no rows: a write without
RETURNING. - A slot is a named parameter of the SQL. A column is a field of each
row. Each has a
name, atype, and anullableflag. The slots are in position order: the slot at indexibinds$i+1. - A type is a scalar of the type table, an enum of the definition, or
either with the
[]suffix. - tables maps each table and view of the database to its columns,
by its bare name when the search path finds it and by
schema.nameotherwise. A table column has aname, atype, anullableflag, and ageneratedflag: whether the database can fill it, through a default, an identity, or a generation expression. A table column of a type the type table does not have isunknown. A table has akind:table, orviewfor one that is read and never written. - roles maps each role of the database to the names of the queries it
has the privileges to run, as the kit found them. The member is optional:
a contract built before roles existed has none, and
Role<D>is thennever.
const contract = {
enums: { event_day: ["sunday", "monday" /* … */] },
queries: {
"events.list": {
slots: [
{ name: "day", type: "event_day", nullable: false },
{ name: "location", type: "text", nullable: true },
],
columns: [
{ name: "id", type: "int4", nullable: false },
{ name: "title", type: "text", nullable: false },
{ name: "weeks", type: "int4[]", nullable: true },
],
},
},
tables: {
events: {
kind: "table",
columns: [
{ name: "id", type: "int4", nullable: false, generated: true },
{ name: "title", type: "text", nullable: false, generated: false },
{ name: "weeks", type: "int4[]", nullable: true, generated: false },
],
},
},
} as const satisfies Definition;The types of the arguments and the rows derive from the definition. A
required slot is a required member. A nullable slot is optional and also
takes null. A nullable column also holds null.
type D = typeof contract;
Arguments<D, "events.list">; // { day: "sunday" | "monday" | …; location?: string | null }
Row<D, "events.list">; // { id: number; title: string; weeks: (number | null)[] | null }
Returns<D, "events.list">; // Row<D, "events.list">[]
Returns<D, "events.touch">; // number, for a query with no column
Database<D>; // { events: { id: Generated<number>; title: string; weeks: (number | null)[] | null } }
Role<D>; // the role names of the contract: "web" | "admin"
RoleQuery<D, "web">; // the queries the role may run: "events.list" | …Database<D> is the type parameter of a kysely
Kysely over the tables of the definition. A generated column is
Generated, so an insert can leave it out. A column of a view is a
ColumnType that selects and never inserts or updates. @bezoar/core
builds the query builder of a service over this type.
The type table
One table maps each Postgres type to its JavaScript form. Scalars declares
the type the kit emits. TABLE holds the zod schema for each, and the
compiler holds the two to each other.
| Postgres | JavaScript | Schema |
| --------------------------------- | ------------ | -------------------------------------------- |
| bool | boolean | z.boolean() |
| int2, int4, int8 | number | z.int(), so an unsafe int8 fails |
| float4, float8 | number | Any number, so NaN and the infinities pass |
| numeric | string | z.string(), so no digit is lost |
| text, varchar, bpchar | string | z.string() |
| uuid | string | z.uuid() |
| date, time | string | z.iso.date(), z.iso.time() |
| timetz, timestamp, interval | string | z.string(), as Postgres writes them |
| timestamptz | Date | z.date() |
| json, jsonb | unknown | z.json(), a JSON value |
| bytea | Uint8Array | z.instanceof(Uint8Array) |
An enum is z.enum over its labels. An array is z.array of its element,
with a null element allowed, since Postgres allows one. Postgres does not
type the dimensions of an array, so the schema of an array is
one-dimensional.
A driver parses the text of Postgres to these types. A time without a zone
has no instant, so the temporal types other than timestamptz stay strings.
defineSchema
defineSchema(definition) returns a Schema<D>. The schema validates
untrusted data against the vocabulary of the definition. Data that passes is
bound to the contract.
import { defineSchema } from "@bezoar/schema";
const schema = defineSchema(contract);
// Returns a boolean. The type of the value narrows on `true`.
if (!schema.check.query(name)) throw new Error("not a query");
// Throws a SchemaError that lists every issue.
schema.assert.arguments("events.list", await readBody(event));
// Throws on an invalid value. Returns the value with its narrowed type.
const row = schema.parse.row("events.list", candidate);
// Returns { success: true, data } or { success: false, issues }.
const result = schema.inspect.definition(candidate);The function checks the definition before it reads it. An invalid definition
throws when you call defineSchema.
The Schema<D> has these members:
baseis the definition.validatorsholds the zod schemas:definition,query,bundle, and for each query underqueriesitsargumentsand itsrow. Use them where a zod schema is wanted, such as a route handler.z.inferof a schema is the type the service uses.checkhas one boolean predicate for each kind.asserthas one assertion for each kind. A failed assertion throws aSchemaErrorthat holds everyIssue.parseasserts a value and returns it with the type of its kind.inspectreturns aResultfor each kind.
Kinds
Each member of check, assert, parse, and inspect validates one
kind. A kind of a query takes the query name first.
definitionis a complete definition.queryis a query name of the contract.bundleis the SQL bundle: the text of every query of the contract, by name, and no other.arguments(query, value)is an object with a member for each slot of the query. A required slot must be present and notnull. A nullable slot can be absent,null, or a value. A key the query does not name is an issue.row(query, value)is an object with a member for each column of the query and no other. A nullable column holds a value ornull.
schema.assert.arguments("events.list", { day: "sunday" }); // passes
schema.assert.arguments("events.list", { day: "someday" }); // SchemaError: day: Invalid option …
schema.check.row("events.list", await res.json()); // true when the row fits the columnsTypes
Definitionis the contract.Statement,Slot,Column,Table,TableColumn, andTableKindare its parts.Query<D>,Enum<D>, andLabel<D, E>are names from a definition.RowQuery<D>is the name of a query with a column.Type<D>is a type name the definition can express.Scalarsmaps each scalar to its JavaScript type.Scalaris the union of its keys.Value<D, T>is the JavaScript type for a type name.Arguments<D, Q>,Row<D, Q>, andReturns<D, Q>are the shapes of a call of the queryQ.Returnsis the rows of a query with a column and the count of one without.Database<D>is the tables of a definition as kysely types them.Bundle<D>is the SQL bundle of a contract.Validators<D>is the shape of the zod schemas.Schema<D>is the bundle thatdefineSchemareturns.Check<D>,Assert<D>,Parse<D>, andInspect<D>are its families of functions.Result<V>is the outcome ofinspect.Issueis a validation failure, as zod reports it.SchemaErroris the error thatassertandparsethrow. It holds theIssuelist.
Related
@bezoar/coreis the runtime service that uses this schema.@bezoar/kitbuilds a definition from SQL.
