0.2

Every published OKModel release, newest first.

all series · 0.3 · 0.1

v0.2.1

0.2.0 was tagged but never published to npm, so 0.2.1 is the first public release and includes everything listed under 0.2.0.

♻️ Changed

  • bun run type-cost prints a query-200+batch row (a batch of an insert, an update and a delete). It has no ceiling. The types of batch are unchanged: refusing restore and expect at compile time was measured and not built, and the known limits say why (D184).

🐛 Fixed

  • The release workflow now publishes the tarball by explicit path (npm publish ./packed/okmodel.tgz). A bare packed/okmodel.tgz was read by npm as a user/repo git shorthand, and the publish step tried to clone github.com/packed/okmodel.tgz. A test now fails when any npm publish argument in a workflow does not start with ./ or /.
  • t.id() on Postgres 15 to 17 with schema({ requires }) not declared no longer fails with the raw function uuidv7() does not exist. okm migrate apply and okm push raise OKM1812 before they send any statement, so nothing is created, not even okm_meta. The message names the column, the migration and the server version, and the fix names both options: Postgres 18 with schema({ requires }), or t.id({ default: "uuidv4" }). A server that already has a uuidv7() function is not refused. Nothing changed in connect() or the runtime entry.

v0.2.0

✨ Added

contracts

  • Input is the row a validated insert accepts. OkmError carries issues (path and a message key) when validation fails.

dialects

  • schema({ tenancy: columnTenancy({ key: "tenantId", type: "uuid" }) }) rewrites tenant tables once before compile. Each gains a guarded uuid key, a primary key of (id, tenant key), and UNIQUE on those columns. .unique() becomes UNIQUE (tenant key, column) unless .unique({ global: "reason" }). Foreign keys between tenant tables include the tenant key. global("reason") opts a table out.
  • A reference can list several target columns. along names the other local columns, so a composite foreign key is an ordinary constraint. Relations join on every column of the key.
  • t.id() still defaults to database uuidv7(). t.id({ default: "uuidv4" }) uses gen_random_uuid(). t.id({ default: "none" }) has no default and is required on insert. A column choice wins over schema({ defaults: { id } }).
  • schema({ defaults: { id } }) sets the generator for every bare t.id(). "uuidv4" and "uuidv7" stay database defaults. A function is filled in the application.
  • An OKID id is text with collation C. Collation is part of the catalog: create, diff, introspect, and plan.
  • .hidden() stays out of default selects and includes, and is returned only when a root select names it. .sensitive() redacts the value in logs, errors, and inspect().
  • filters() is synchronous and throws OKM1123 when allow, sort, or relations names a hidden field. parse() loads the parser on first use.
  • schema({ traits }) applies those traits to every table. A table opts out with omitDefaults and a reason. A field the table already declares is OKM1012.
  • archivable() turns a table's unique constraints into partial unique indexes (WHERE archived_at IS NULL). The primary key stays a full constraint. A tenant unique keeps the tenant key and the same predicate.
  • contains, containedBy, overlaps, hasKey, hasAnyKey, path, and matches filter json, array, range, and tsvector columns. Text contains stays a substring. The value, key, or path is a parameter, and a regconfig name is checked before it is bound.
  • json.set, arr.append, and arr.remove are namespace exports used inside update. Importing one does not pull the others.
  • .validate() stores rules on a column. A table validate section stores rules by field, and $row stores cross-field rules. schema({ validation }) and table({ validation }) store the switch. schema() does not pack or run them.
  • schema({ validation: true }) marks that schema's insert body as Input. A boolean that is not the literal true, an object form, and a table-level validation switch do not change the type. The validate methods are added by importing okmodel/validate and are not part of the table type.
  • Importing okmodel/validate types insert.validate, insert.check, pick, omit, update.validate, and the Standard Schema members on a table that validates, by module augmentation. A program that does not import it does not see those types and pays nothing for them. The Input mark follows the schema's validation option or the table's own, in the boolean and object forms.
  • manyThrough("labels", { through: "taskLabels" }) declares a to-many relation through a join table. Name from and to when the join table has more than one foreign key to a side. It works in include, in has / none / every, and in filters. The join rows and the targets carry the tenant and active-set predicates, so a row in another tenant or archived never shows. The relation carries its own resolver and emitter, so a schema without one does not ship them.
  • t.custom({ ..., accepts: ["Object"] }) names the object kinds a custom codec takes. Without it every object is refused for that column (OKM1121).
  • table({ presets }) and trait(name, { fields, presets }) declare named, typed query refinements. A preset is (q, ...args) => q, its arguments are typed, and the builder has one method, where, which adds a predicate. No method removes or replaces one. A reserved or client-method name is a type error. PresetQuery is exported for presets written apart from the table.
  • A trait's presets are merged into each table's set through the trait object. A schema-level trait's presets reach every table that keeps the defaults. A name defined by a table and a trait, or by two traits, is OKM1040, and its fix names both sources.

adapters

  • okmodel/pg/pg connects with node-postgres, and okmodel/pg/bun connects with Bun.sql. Both are optional peers. The default stays postgres.js. Bun.sql loads only on Bun.
  • node-postgres cancels an in-flight statement and returns notices. Bun.sql does not abort an in-flight statement and does not surface RAISE NOTICE. Neither describes a statement without running it. A plain pool of either exits after the last query.
  • A postgres.js, node-postgres, or Bun.sql connection reserved for tx() that is lost is dropped and never written to again. A COMMIT cut by a lost connection is outcome_unknown (OKM1401), and no ROLLBACK follows it.

runtime

  • okmodel/tenancy exports columnTenancy and global. for({ tenantId }) puts the tenant predicate on every read, write, include, and has / none / every filter. unscoped("reason") omits the predicate and inspect() shows the reason. Insert still needs for(). The root client omits tenant tables. Calling one is OKM1701. Input cannot set the key, including { allow } (OKM1190). Naming it in a filter or an update is OKM1704.
  • An index on a tenant table that does not lead with the tenant key is OKM1706. A global table that references a tenant table is OKM1705. set null and set default are refused when the foreign key includes the tenant key.
  • registerTenancy() records the tenancy check. A tenant key set by input is OKM1190. The package does not register the rule on import. An application that does not import okmodel/tenancy does not load that code.
  • connect() refuses a Postgres server below 15 with OKM1803. schema({ requires }) that names an older major keeps the OKM1802 check instead.
  • okmodel/ids exports uuidv4, uuidv7, and okid. Pass one to .default() or defaults.id. A literal passed to .default() stays a database default. .defaultSql() stays a database expression. The generated value is not in the catalog, so changing a generator does not produce a migration. Insert fills each omitted value once, including each row of a batch, and the returned row carries it.
  • connect({ generators }) replaces a built-in generator by name.
  • An operator that does not fit its column fails with OKM1124. The message names the column type and the operators that column accepts. Text search on a text column says it needs a tsvector column.
  • A table records the file and line where it was defined. inspect() shows that location on the catalog rules, a schema error includes it, and okm prints it. The catalog hash does not.
  • okmodel/safety is the registry later checks plug into. registerRule adds a rule, verify throws OKM1190 with every violation in stable order, and safety.property fails when rule order changes the verdict.
  • okmodel/traits exports timestamps() and trait(). timestamps() adds createdAt and updatedAt. Insert leaves both to now(). Update sets updatedAt to now() and leaves createdAt. Input cannot set either, including { allow }. An application that imports no trait does not load that code.
  • registerTimestamps() records the timestamps check. A timestamp set by input is OKM1190. { allow } does not clear it.
  • registerFieldExposure() records the hidden, sensitive, and guarded checks. A hidden field shown, a sensitive value revealed, or a guarded field set without { allow } is OKM1190.
  • Unknown keys are dropped on insert and update. A guarded field is OKM1190 unless { allow } names it. allow does not unlock a primary key on update.
  • okmodel/traits exports archivable(). It adds archivedAt and archiveId. archive() returns { count, archiveId } and hides the matching rows, including named children, in one statement. restore() returns { count }, clears both columns, and brings back only rows that share that archiveId.
  • Reads, updates, and deletes see the active set. withArchived() and onlyArchived() change that view. delete() stays permanent. Purge is onlyArchived().delete(...). A dynamic call on a table that is not archivable throws OKM1052. strategy: "table" throws OKM1061.
  • registerArchive() records the archive rules. An application that does not call archivable() does not load the archive statement.
  • okmodel/validate exports v. Importing it adds insert.validate, insert.check, pick, omit, update.validate, and the Standard Schema surface. The engine loads on the first validated call. An application that does not import it does not load that code.
  • A validated write runs transforms, then derived checks (length, required, integer range, precision, picklist, uuid, json), then the caller's rules. A failure is OKM1200. A value returned by validate() is frozen and is not checked again. { validate: false } skips that call.
  • A write that would validate throws OKM1201 before any statement, and before a transaction is opened, when okmodel/validate was not imported. The category is input. A schema that does not enable validation still writes. A failed engine import still rejects the call.
  • page({ orderBy, limit, after }) returns { items, next }. It is a keyset on orderBy with the primary key appended, and it is stable while rows are inserted. The cursor holds the order and the values as the database wrote them, so a timestamp keeps its microseconds. A cursor used with another orderBy, or one page() did not return, is OKM1130. The tenant and active-set predicates apply to every page. The planner loads on the first page().
  • aggregate({ where, groupBy, count, sum, avg, min, max, orderBy, limit }) returns grouped rows with the tenant and active-set predicates of any read. groupBy needs limit or .all(reason). orderBy takes groupBy fields. There is no having and no bucket. A hidden field is refused. The planner loads on the first aggregate().
  • Aggregates decode with the source column's codec. count is a number, min and max follow the column, and sum and avg keep the value type of the column: an exact decimal string for numeric by default, a number for numeric with as: "number" and for integer and float columns. A column whose value is neither a string nor a number is OKM1124.
  • .required() on one() and a one() without orderBy that matches more than one row (not_unique) have real-Postgres tests, with inList([]), notIn([]), has / none / every, null ordering, and literal escaping.
  • tasks.pending().ownedBy(userId).find({ limit: 20 }): a preset is a method on the table handle that returns the handle, so calls chain. They apply to find, one, count, exists, aggregate and page, and narrow the rows update, delete, archive and restore act on, including each item of a list update. insert ignores them. The planner writes the tenant predicate, the active set, the caller's where, then each preset's predicates, joined with AND, so a preset cannot remove the tenant predicate or reach an archived row.
  • A write through a preset still needs a where or .all(reason). A preset does not stand in for one (OKM1102).
  • inspect() lists one preset line per call with the preset name, the fields it filters, who defined it (table tasks or trait flagged) and the source location. Values stay parameters in sql().
  • A preset name that is a client method or reserved fails with OKM1040 in trait() when the trait is built and in connect() before the first query. A preset that does not return its builder is OKM1121 when the call is planned.
  • registerPresets() in okmodel/safety registers the rule that a preset adds predicates and never removes or replaces one. A property test composes random chains over a tenant table and checks the statement text and the rows on every read and write.
  • db.tx(fn) and db.tx({ isolation, retry, timeout, signal }, fn) run fn in a transaction on one reserved connection. The callback gets a client t with the same tables, presets, tenancy, and archive handling. The call commits when fn resolves and rolls back when it throws. It exists on drivers with transactions: "interactive"; on any other it is OKM1111. timeout is milliseconds and covers the whole transaction, retries included.
  • A nested tx() is a savepoint on the same connection. It refuses options (OKM1121). If the callback swallows a statement error, the transaction fails with that error instead of committing: Postgres would turn the COMMIT into a silent ROLLBACK.
  • retry runs the callback again, in a fresh transaction, after a serialization failure or a deadlock. outcome_unknown and cancelled are never retried. The timeout is one deadline for all attempts.
  • db.batch([...]) runs writes as one atomic unit on every driver, with results in order. A failure carries batchIndex, or null at commit. Inside tx() it is a savepoint, so the transaction survives a failure. .replica() is OKM1840 and a read in the list is OKM1121.
  • t.afterCommit(fn) runs after the outermost commit, in order, and never on rollback. An error in one goes to hookm.onError and never changes the result.
  • find({ lock: "update" | "share", wait: "nowait" | "skip" }) inside tx() appends FOR UPDATE or FOR SHARE, with NOWAIT or SKIP LOCKED. Outside tx() it is OKM1830. t.advisoryLock(key) takes a transaction-level advisory lock; a string is hashed.
  • Every read and write takes { signal, timeout }. An abort cancels the statement where the driver can and fails as cancelled; a timeout fails as timeout. Cancelling inside tx() rolls it back. connect({ timeouts }) sets acquire, statement, transaction, and idleInTransaction in milliseconds.
  • connect({ hookm }) takes observers. onNotice hears server notices, onTransaction hears start, commit, and rollback (savepoints at depth 1 and up), and onError hears errors with no caller. An error a hook throws is dropped.
  • tx, batch, and locks load on first use. A program that calls none of them pays 215 bytes minified and 225 gzip at startup.

tooling

  • engines.node is >=22. The checks that can run do so on Node, Bun, and Deno, and the runtime entry is imported as an edge bundle. A runtime that cannot run a check prints the reason.
  • okm check reports OKM1030 when a field has rules on the column and in the table validate section, or when validation.style disagrees with where the rules sit.
  • okm check reports OKM1201 when the schema would validate and the project never imports okmodel/validate.
  • The 200-table query probe has gated rows for a project with okmodel/validate imported and for one that uses page, aggregate, and manyThrough, each at its measured value plus 3 percent (D176). scripts/app-relations.ts is a reported app that uses those features.
  • startCutProxy in @okmodel/harness cuts the next COMMIT on a TCP connection, and registerTxSuite runs one conformance suite of 38 cases on postgres.js, PGlite, node-postgres, and Bun.sql: serializable conflict and retry, a real deadlock, skip and nowait, timeout and abort kills, a cut commit and a cut batch, pool reuse with no leaked state, and savepoints. A driver that cannot do a case shows it as a skip with the reason.
  • The 200-table query probe has two more gated rows, query-200+presets (19,180 / 7,128, ceilings 19,755 / 7,341) and query-200+tx (24,283 / 6,948, ceilings 25,011 / 7,156), each at the measured size plus 3 percent (D176).
  • The 0.2 gate runs four property tests on real Postgres with a fixed seed (OKM_PROPERTY_SEED, default 20261005) that is printed on failure: isolation.property (random compositions of tenancy, traits, presets, relations, include, aggregate, page, writes, archive, restore, batch and tx with savepoints and retry, over two tenants with colliding keys, a one-connection pool and a concurrent run), safety.property (every safety rule registered together), statements.shape (the statement count depends on the shape of the call and never on 0, 1, 10 or 1,000 rows), and archive.correctness (archive, restore, cascade, partial uniques and archiveId against a model, inside tx, savepoints and batch). Each is shown to fail when a predicate is dropped on the wire.
  • scripts/app-full.ts is a reported app with tenancy, archivable(), timestamps(), validation, relations, presets, tx and batch together. The size script prints it beside the plain app. It is not a gate.
  • batch-refusals.test.ts runs every write kind in a batch, shows a blocked restore and a wrong expect refused with nothing written, and shows both still work in tx(). snapshot-reads.test.ts shows every read shape is one statement inside tx({ isolation: "read committed" }) and that a reader never sees half of a committed write. raw-sql-paths.test.ts fails when the client or a table gains a method that could take SQL text, or when the runtime layer starts to read the sql template. codes-not-reached.test.ts records that OKM1110, OKM1191 and OKM1702 are registered and not thrown in 0.2.

docs

  • The README lists the key options. The quickstart stays on t.identity(). Schema says how ids are generated and how to change the default.
  • The roadmap is a public board. Each release is a milestone, and a pull request ends with Closes #N.
  • The README shows a jsonb contains filter and an array update with arr.append.
  • The size page records the no-trait startup graph at 83,043 minified bytes and 27,666 gzip. The gates stay. An application that uses timestamps() measures 85,744 / 28,503, and that figure is not a gate.
  • Spec section 9 uses columnTenancy and global("reason"). The size page records the no-tenancy app at 85,356 / 28,361. The gates are that measurement plus 3 percent (D160). Column tenancy measures 91,802 / 30,411 and is not a gate. The 91,000 / 30,000 cap stays.
  • D170 accepts the featureless app at 87,148 / 28,938. The gates, the cap, and the 7,100 type probe stay. An application that uses archivable() measures 95,725 / 31,604 at startup and is not a gate. A unique a trait adds after the tenancy rewrite is still not widened.
  • Validation is opt-in. OKM1030 is reported by okm check, not while schema() compiles. D172 records why the first build was rejected and where the engine lives.
  • D174 moves the missing-import check to the first write. The typed surface arrives in P27 as a module augmentation in okmodel/validate.
  • Spec sections 6.2, 10 and 12 describe manyThrough, page, aggregate, and the decode rule. iStartsWith, iContains, and iEndsWith are not in 0.2 (D176); use ilike() with an escaped pattern.
  • Spec section 6.2.1 describes presets: order of predicates, which calls they reach, names, inspection, and the OKM1040 and OKM1121 cases. D180 records the P28 size and the choices made. Presets are out of the known limits.
  • D176 records the first P27 build (+1,425 / +431 over the stop line), the one redesign in which the relation carries its own emitter, and the probe policy.
  • Spec section 15 gives timeout in milliseconds and says a nested tx() takes no options. D181 records the P29 numbers and the choices.
  • 0.2.0 "Safety" adds tagged operators, final safety verification, field exposure (.hidden(), .guarded(), .sensitive()), column tenancy, the timestamps() and archivable() traits with cascade to direct children, validation, one, many and manyThrough relations with include, page and aggregate, presets, tx with savepoints and retry, batch, row locks, advisory locks, and per-call { signal, timeout }. Its limits are in docs/known-limits.md: a unique added by a trait after the tenancy rewrite is not widened; archive cascades to direct children only; sum and avg over a text column, and over a bigint read as bigint, are OKM1124; iStartsWith, iContains and iEndsWith are not in 0.2; a mixed-sign interval, a non-default IntervalStyle and a seconds offset on timetz are OKM1210 on read; onRead is stored and not applied; there is no JSON Schema export; rls tenancy is not built; on PGlite and Bun.sql a tx timeout or signal cannot kill a statement waiting on a lock; Bun.sql has no notices; batch-mode drivers (Neon, D1) have no adapter; idleInTransaction is a client-side timer; a table named tx or batch is shadowed; and an option or builder that is not in 0.2 throws OKM1061 and names its version. restore and any write with expect are not allowed in batch (OKM1121); use tx().
  • The known-limits page is rewritten in groups and says what happens for each limit. The size page and the README carry the feature-full app (113,705 / 37,506 at startup) and what the entry-chunk size pass could save (at most 31,173 / 11,081 on the plain app, not built).

💥 Breaking Changes

  • import { json } is the write namespace (json.set). A json column is t.json().

♻️ Changed

  • A push to main runs check and attest. The Postgres suite and the tarball job stay on pull requests, the release workflow, and the weekly run.
  • A declared server below 18 that uses uuidv7() fails with OKM1812, and the message names defaults.id.
  • okmodel/pg/pg and okmodel/pg/bun connect entries are gated at the measured size plus 3 percent (D156).
  • t.id() and t.identity() stay omitted from insert and update. A plain primary key is writable on insert and omitted from update. Changing it is OKM1190.
  • A postgres.js pool opened with TLS still lingers about 30 s after the last query on Node and Bun. A plain pool exits on its own. close() or await using releases it.
  • A release checks the tag, packs one tarball, tests that file on Postgres, and publishes that same file. The GitHub Release closes the milestone.
  • The npm description is one sentence.
  • App startup is gated at 85,100 minified and 28,000 gzip (D158). postgres.js connect is 40,800 / 14,100, pg is 41,000 / 14,250, and Bun.sql is 39,800 / 13,800. PGlite stays 38,146 / 13,505.
  • Synchronous filters() takes the app startup graph to 84,907 / 28,017, past the gzip gate. The gates move to measured plus 3 percent (D160): app startup 87,454 / 28,857, postgres.js 41,519 / 14,352, PGlite 38,620 / 13,613, pg 41,677 / 14,543, and Bun.sql 40,545 / 14,096. The 91,000 / 30,000 cap stays.
  • filters() returns the parser directly. The promise is on parse(), which is what a request handler awaits.
  • A signal or timeout, a watched query, checkout, listen, stream, the server-version query, and the fix text for write and include errors load on first use. Error codes, messages, SQL, and catalog output stay the same. The no-trait startup graph goes from 85,568 / 28,329 to 83,043 / 27,666. The gates stay.
  • P24 takes the no-tenancy app from 83,043 / 27,666 to 85,356 / 28,361. The gates move to measured plus 3 percent (D160): app startup 87,900 / 29,210, postgres.js 40,200 / 14,090, PGlite 37,800 / 13,450, pg 40,900 / 14,410, and Bun.sql 39,700 / 13,940. The 91,000 / 30,000 cap stays.
  • P27 takes the no-tenancy app from 87,408 / 29,001 to 88,278 / 29,326. The gates move to measured plus 3 percent (D160), capped at 91,000 / 30,000: app startup 90,900 / 30,000, postgres.js 41,600 / 14,600, PGlite 39,300 / 13,980, pg 42,400 / 14,920, Bun.sql 41,200 / 14,470.
  • P28 takes the no-tenancy app from 88,393 / 29,385 to 88,723 / 29,553 (+330 / +168), inside the stop line of +600 / +180. No gate or ceiling moves. query-200 measures 17,062 / 6,259 (+143 / +40 over P27b) under 17,300 / 7,100.
  • A ReadBuild receives the call it builds for, so aggregate writes the where of the call after presets were applied and not the one it was created with. The planner reads ruleLines for the archive set and for presets.
  • sum and avg over a field typed number or string pass the types, and the result has the type of the field. A bigint field typed bigint is OKM1124.
  • OKM1130 also covers a cursor that page() did not return.
  • P27b takes the no-tenancy app from 88,278 / 29,325 to 88,393 / 29,385 (+115 / +60). No gate or ceiling moves. The gated 200-table probes do not move; query-200-validate is 24,888 (+3).
  • In a where, a bare object is OKM1121 for every column and eq(value) is the equality form for any object value (Temporal, arrays, ranges, json). The comparison operators and not take the codec's objects as operands.
  • P29 takes the no-tenancy app from 88,723 / 29,553 to 88,938 / 29,778 (+215 / +225), inside the stop line of +1,400 / +400. No gate or existing ceiling moves. query-200 is 17,078 / 6,271 (was 17,062 / 6,259).
  • A table named tx or batch is shadowed by the client method.
  • withTenantScope is removed from src/runtime/plan.ts. Nothing called it since withRowFilters took its place. No behaviour changes.

🐛 Fixed

  • batch refuses a restore and any write that carries expect with OKM1121, before any statement is sent, on every driver. Both are checked against the result of their statement, and a batch used to report that after it had committed, leaving its other writes in place (D183). The error names tx().
  • A read of an interval or timetz column no longer fails with OKM1210. interval decodes what Postgres sends (01:30:00, 1 year 2 mons 3 days 04:05:06.5, -2 days) and an ISO-8601 duration. timetz decodes 01:02:03+03 and ±HH:MM, and the offset is ±HH:MM. A negative Temporal.Duration is written with a sign on each field because Postgres refuses a leading minus (D179). An interval that mixes signs and a sql_standard or postgres_verbose IntervalStyle stay in the known limits.
  • insert and update set refused every object value with OKM1121, including the Temporal values of the default timestamptz, timestamp, date, time and interval codecs, json and jsonb values, arrays, bytes, ranges and points. A column now takes the objects its codec declares and OKM1121 stays for any other object, so an operator-looking object never reaches a scalar column. A Date is not an input of any default codec and is still refused.
  • A range object without empty is OKM1210 before any statement. It was sent to the database as a malformed literal.
  • eq(), lt, gt, between, inList and not on a Temporal, array, range or json column failed with OKM1121 for the same reason.
  • insert([...]) typed every key as required on each row, though the runtime already filled an omitted key with DEFAULT per row. Rows in one list may now omit different optional keys or pass undefined; null stays NULL.
  • postgres.js crashed with an uncaught socket.write when a reserved connection was used after its socket closed, and put a closed connection back in its pool on release().
  • node-postgres raised an unhandled error event when a checked-out client lost its socket, and returned that client to the pool.
  • Bun.sql parked a reserved connection that had been lost.