@workerstack/database 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +95 -30
  2. package/docs/meta-contract.md +1 -1
  3. package/package.json +3 -2
  4. package/src/cli/args.ts +4 -2
  5. package/src/cli/codegen.ts +2 -2
  6. package/src/cli/comb-config.ts +8 -3
  7. package/src/cli/file-path.ts +25 -0
  8. package/src/cli/migrate-apply.ts +5 -21
  9. package/src/cli/migrate-d1.ts +5 -1
  10. package/src/cli/migrate-json.ts +48 -0
  11. package/src/cli/migrate.ts +26 -4
  12. package/src/formats/bcp47.ts +12 -0
  13. package/src/formats/catalog.ts +27 -4
  14. package/src/index.ts +1 -0
  15. package/src/migrate/core/apply.ts +63 -22
  16. package/src/migrate/core/atlas-apply-runs.ts +15 -26
  17. package/src/migrate/core/cleanup.ts +22 -10
  18. package/src/migrate/core/d1-engine-child.mjs +27 -11
  19. package/src/migrate/core/d1-history.ts +23 -26
  20. package/src/migrate/core/d1-wrangler-error.ts +24 -1
  21. package/src/migrate/core/pg-replay-commit.ts +41 -38
  22. package/src/migrate/core/pg-replay-drops.ts +17 -32
  23. package/src/migrate/core/pg-replay-hidden.ts +75 -0
  24. package/src/migrate/core/pg-replay-roles.ts +2 -4
  25. package/src/migrate/core/pg-replay.ts +21 -19
  26. package/src/migrate/core/pg-server-snapshot.ts +4 -24
  27. package/src/migrate/core/proof-readers.ts +80 -0
  28. package/src/migrate/core/proof-run.ts +19 -5
  29. package/src/migrate/core/proof-scope.ts +27 -14
  30. package/src/migrate/core/sql-code.ts +36 -5
  31. package/src/migrate/core/sql-scan.ts +7 -1
  32. package/src/migrate/core/sqlite-keywords.ts +70 -0
  33. package/src/migrate/core/stopped.ts +70 -0
  34. package/src/migrate/core/trigger-write.d.mts +7 -0
  35. package/src/migrate/core/trigger-write.mjs +42 -0
  36. package/src/query/cursor-mint.ts +11 -18
  37. package/src/query/cursor-refusals.ts +22 -11
  38. package/src/query/descriptions.ts +4 -9
  39. package/src/query/filter-parse.ts +80 -22
  40. package/src/query/filter-validate.ts +10 -13
  41. package/src/query/filter.ts +40 -18
  42. package/src/query/list-extras.ts +7 -10
  43. package/src/query/list-schema.ts +0 -2
  44. package/src/query/merged.ts +3 -2
  45. package/src/query/pagination.ts +30 -6
  46. package/src/query/rounded-key.ts +5 -8
  47. package/src/query/schema-params.ts +12 -5
  48. package/src/query/select-validate.ts +7 -4
  49. package/src/query/sqlite/cursor-fit.ts +10 -2
  50. package/src/query/trusted.ts +7 -2
  51. package/types/cli/args.d.ts.map +1 -1
  52. package/types/cli/comb-config.d.ts +5 -2
  53. package/types/cli/comb-config.d.ts.map +1 -1
  54. package/types/cli/file-path.d.ts +11 -0
  55. package/types/cli/file-path.d.ts.map +1 -0
  56. package/types/cli/migrate-apply.d.ts +1 -0
  57. package/types/cli/migrate-apply.d.ts.map +1 -1
  58. package/types/cli/migrate-d1.d.ts.map +1 -1
  59. package/types/cli/migrate-json.d.ts +4 -0
  60. package/types/cli/migrate-json.d.ts.map +1 -0
  61. package/types/cli/migrate.d.ts.map +1 -1
  62. package/types/formats/bcp47.d.ts +9 -0
  63. package/types/formats/bcp47.d.ts.map +1 -0
  64. package/types/formats/catalog.d.ts.map +1 -1
  65. package/types/index.d.ts +1 -0
  66. package/types/index.d.ts.map +1 -1
  67. package/types/migrate/core/apply.d.ts +3 -2
  68. package/types/migrate/core/apply.d.ts.map +1 -1
  69. package/types/migrate/core/atlas-apply-runs.d.ts +6 -0
  70. package/types/migrate/core/atlas-apply-runs.d.ts.map +1 -1
  71. package/types/migrate/core/cleanup.d.ts +16 -4
  72. package/types/migrate/core/cleanup.d.ts.map +1 -1
  73. package/types/migrate/core/d1-history.d.ts.map +1 -1
  74. package/types/migrate/core/d1-wrangler-error.d.ts +11 -0
  75. package/types/migrate/core/d1-wrangler-error.d.ts.map +1 -1
  76. package/types/migrate/core/pg-replay-commit.d.ts +14 -9
  77. package/types/migrate/core/pg-replay-commit.d.ts.map +1 -1
  78. package/types/migrate/core/pg-replay-drops.d.ts +7 -12
  79. package/types/migrate/core/pg-replay-drops.d.ts.map +1 -1
  80. package/types/migrate/core/pg-replay-hidden.d.ts +36 -0
  81. package/types/migrate/core/pg-replay-hidden.d.ts.map +1 -0
  82. package/types/migrate/core/pg-replay-roles.d.ts +2 -4
  83. package/types/migrate/core/pg-replay-roles.d.ts.map +1 -1
  84. package/types/migrate/core/pg-replay.d.ts.map +1 -1
  85. package/types/migrate/core/pg-server-snapshot.d.ts +4 -11
  86. package/types/migrate/core/pg-server-snapshot.d.ts.map +1 -1
  87. package/types/migrate/core/proof-readers.d.ts +19 -0
  88. package/types/migrate/core/proof-readers.d.ts.map +1 -0
  89. package/types/migrate/core/proof-run.d.ts.map +1 -1
  90. package/types/migrate/core/proof-scope.d.ts +15 -7
  91. package/types/migrate/core/proof-scope.d.ts.map +1 -1
  92. package/types/migrate/core/sql-code.d.ts +7 -3
  93. package/types/migrate/core/sql-code.d.ts.map +1 -1
  94. package/types/migrate/core/sql-scan.d.ts.map +1 -1
  95. package/types/migrate/core/sqlite-keywords.d.ts +11 -0
  96. package/types/migrate/core/sqlite-keywords.d.ts.map +1 -0
  97. package/types/migrate/core/stopped.d.ts +23 -0
  98. package/types/migrate/core/stopped.d.ts.map +1 -0
  99. package/types/query/cursor-mint.d.ts.map +1 -1
  100. package/types/query/cursor-refusals.d.ts +17 -1
  101. package/types/query/cursor-refusals.d.ts.map +1 -1
  102. package/types/query/descriptions.d.ts +1 -1
  103. package/types/query/descriptions.d.ts.map +1 -1
  104. package/types/query/filter-parse.d.ts +15 -4
  105. package/types/query/filter-parse.d.ts.map +1 -1
  106. package/types/query/filter-validate.d.ts +1 -1
  107. package/types/query/filter-validate.d.ts.map +1 -1
  108. package/types/query/filter.d.ts +9 -1
  109. package/types/query/filter.d.ts.map +1 -1
  110. package/types/query/list-extras.d.ts.map +1 -1
  111. package/types/query/list-schema.d.ts.map +1 -1
  112. package/types/query/merged.d.ts.map +1 -1
  113. package/types/query/pagination.d.ts +13 -1
  114. package/types/query/pagination.d.ts.map +1 -1
  115. package/types/query/rounded-key.d.ts +2 -11
  116. package/types/query/rounded-key.d.ts.map +1 -1
  117. package/types/query/schema-params.d.ts.map +1 -1
  118. package/types/query/select-validate.d.ts.map +1 -1
  119. package/types/query/sqlite/cursor-fit.d.ts.map +1 -1
  120. package/types/query/trusted.d.ts +1 -1
  121. package/types/query/trusted.d.ts.map +1 -1
package/README.md CHANGED
@@ -52,6 +52,8 @@ This README is the full usage manual. An agent that reads only this file should
52
52
  - [Package exports](#package-exports)
53
53
  - [Repository layout](#repository-layout)
54
54
  - [Develop](#develop)
55
+ - [Upgrading to 0.23](#upgrading-to-023)
56
+ - [Upgrading to 0.22.1](#upgrading-to-0221)
55
57
  - [Upgrading to 0.22](#upgrading-to-022)
56
58
  - [Upgrading to 0.21](#upgrading-to-021)
57
59
  - [Upgrading to 0.20.1](#upgrading-to-0201)
@@ -233,12 +235,12 @@ OPTIONS:
233
235
  -e, --env <name> Environment from comb.migrate.config.ts [migrate apply, migrate baseline, migrate check, migrate diff, migrate status]
234
236
  --dir <path> Migrations directory (default: the config's, else ./atlas-migrations) [migrate apply, migrate baseline, migrate check, migrate status]
235
237
  --dialect <name> The project's dialect: sqlite (default) or postgres [migrate init]
236
- --dry-run Write nothing: exit 1 printing the migration a diff would write, 0 in sync [migrate diff]
238
+ --dry-run Write nothing: exit 1 printing the migration a diff would write, 0 in sync, 2 when the check could not run [migrate diff]
237
239
  --file <path> Apply this one migration file [migrate apply]
238
240
  -y, --yes Approve cautious changes without asking (not unsafe ones) [migrate apply, migrate check]
239
241
  --local Without a migrate config: the local D1 (wrangler --local) [migrate apply, migrate baseline, migrate check, migrate status]
240
242
  --hashes On D1: record the current hash of each applied file that has none (check the files against git first) [migrate baseline]
241
- --json Print one JSON object (database, applied, pending, refused, unknown) as the only stdout [migrate apply, migrate check, migrate status]
243
+ --json Print one JSON object (database, applied, pending, refused, unknown; stoppedBy when a signal stopped the run) as the only stdout [migrate apply, migrate check, migrate status]
242
244
  -h, --help Show this help
243
245
  --version Print comb's version
244
246
  ```
@@ -286,7 +288,7 @@ Config mode reads `comb.config.ts` (or `.js`) from the working directory. Direct
286
288
 
287
289
  On SQLite, libsql and Turso, apply with `comb migrate apply`, not drizzle-kit migrate: comb's `__drizzle_migrations` is its own history, not drizzle-kit's, and drizzle-kit 1.0's migrate fails on it with "no column named applied_at".
288
290
 
289
- `init` scaffolds `drizzle.config.ts` and `atlas-migrations/`, and no `atlas.hcl`: comb runs Atlas with a config of its own, and comb's unsafe gate, not an Atlas `skip` block, is what stops a drop. Delete an `atlas.hcl` an earlier `init` wrote; `init --dialect postgres` adds a `comb.migrate.config.ts` whose URL comes from `DATABASE_URL`, and no file holds a credential. `check` runs the check every apply runs first and applies nothing; it reads the database's history, so it needs the database. `baseline` adopts a database that already has the schema: it records the migrations as applied without running any, but only once the database's schema is the one they leave (on D1, only the files comb 0.7's `__drizzle_migrations` records). `status` lists a database's applied and pending migrations. `diff --dry-run` writes nothing: it exits 0 when the schema is in sync, and 1 printing the migration a diff would write.
291
+ `init` scaffolds `drizzle.config.ts` and `atlas-migrations/`, and no `atlas.hcl`: comb runs Atlas with a config of its own, and comb's unsafe gate, not an Atlas `skip` block, is what stops a drop. Delete an `atlas.hcl` an earlier `init` wrote; `init --dialect postgres` adds a `comb.migrate.config.ts` whose URL comes from `DATABASE_URL`, and no file holds a credential. `check` runs the check every apply runs first and applies nothing; it reads the database's history, so it needs the database. `baseline` adopts a database that already has the schema: it records the migrations as applied without running any, but only once the database's schema is the one they leave (on D1, only the files comb 0.7's `__drizzle_migrations` records). `status` lists a database's applied and pending migrations. `diff --dry-run` writes nothing: it exits 0 when the schema is in sync, 1 printing the migration a diff would write, and 2 when the check could not run (Atlas or drizzle-kit missing or failing, an unreachable `devUrl`, a config or an argument comb refuses), so a gate can act on drift alone.
290
292
 
291
293
  ## Columns
292
294
 
@@ -411,15 +413,24 @@ A text format trims the value, then writes it in its standard's case, before it
411
413
 
412
414
  A text format's rule is its pattern, which Go's RE2, Rust's regex and JavaScript's `u` mode read alike, and its length bounds, published as `pattern`, `minLength` and `maxLength` beside `x-comb-format` and the `x-comb-transform` it runs (`["trim", "uppercase"]` on `iso4217`), and its OpenAPI example is a value of the standard as written (`EUR` on `iso4217`), none where the column's own bounds leave that value out. It takes no `x-comb-text`, as no format's pattern admits U+0000 or a surrogate. The format sets the column's trim, case and length, so codegen refuses a `trim`, `lowercase` or `uppercase` beside it (`users.currency: an iso4217 text takes no lowercase: the format sets its case and trim`), a `min` or `max` outside its range, which the format already holds or which leaves no value (`users.currency: max 4 is past iso4217's length 3: the format holds it`), and an `email`, `url`, `password`, `imageUri` or `name` beside it, each a rule of its own (`users.currency: format and email contradict; declare one`). A bound inside the range, or a `pattern`, beside a format narrows it. `bcp47` sets no maximum, as RFC 5646's 35 characters are the least an implementation must hold, not a cap on a tag, so a column declares its own `max`. An id comb does not know is refused with the list it does (`users.currency: comb knows no format "iso-4217"; text formats: base64url, bcp47, …`).
413
415
 
414
- Where the table names a predicate, a value must also be a member of the pinned registry it reads, which no pattern states: `HRK` matches `^[A-Z]{3}$` and is no `iso4217` value, withdrawn in 2023, and `Europe/Atlantis` matches the zone pattern and names no zone. Each registry is a snapshot of the release the table names, committed in comb's source: nothing fetches at build, test or run time, and a comb release moves to a newer one. The five predicates are root exports, each taking any string, exactly as written, and never throwing; a bundle that calls none of them carries none of their lists.
416
+ Where the table names a predicate, a value must also be a member of the pinned registry it reads, which no pattern states: `HRK` matches `^[A-Z]{3}$` and is no `iso4217` value, withdrawn in 2023, and `Europe/Atlantis` matches the zone pattern and names no zone. Each registry is a snapshot of the release the table names, committed in comb's source: nothing fetches at build, test or run time, and a comb release moves to a newer one. The five registry predicates are root exports, each taking any string, exactly as written, and never throwing; a bundle that calls none of them carries none of their lists. `isBcp47` is the one predicate that checks a pattern alone: it takes what a `format: "bcp47"` column takes before its trim, RFC 5646 §2.1's whole Language-Tag, `x-private` and `i-klingon` among them, from the same grammar.
415
417
 
416
418
  ```ts
417
- import { isIanaTimeZone, isIso3166Alpha2, isIso3166Alpha3, isIso4217, isIso639Alpha2 } from "@workerstack/database"
419
+ import {
420
+ isBcp47,
421
+ isIanaTimeZone,
422
+ isIso3166Alpha2,
423
+ isIso3166Alpha3,
424
+ isIso4217,
425
+ isIso639Alpha2,
426
+ } from "@workerstack/database"
418
427
 
419
428
  isIso4217("EUR") // true
420
429
  isIso4217("HRK") // false: withdrawn in 2023
421
430
  isIso4217("eur") // false: the DTO uppercases a value first, the predicate does not
422
431
  isIso3166Alpha2("UK") // false: reserved, not assigned; GB is the code
432
+ isBcp47("zh-Hant-TW") // true
433
+ isBcp47(" en") // false: the DTO trims a value first, the predicate does not
423
434
  ```
424
435
 
425
436
  A reader that validates from the JSON Schema alone checks such a value with the predicate, or with the release meta-contract §2.1 names beside the id. The `uts35-*` formats are the values of UTS #35's Unicode locale extension keys in CLDR 49, deprecated ones left out (`uts35-hc` is `h11`, `h12`, `h23`, `h24`, `c12` and `c24`); their pattern lists every value, so it is the whole rule, as on every format the table names no registry for.
@@ -833,11 +844,11 @@ A migration's hash is its SQL with comments and layout normalized away, never th
833
844
 
834
845
  Without a terminal or an approval, `apply` stops before running the file. A SQLite table rebuild (Atlas copies the rows into `new_t`, drops `t` and renames `new_t`) is cautious when it copies every column the earlier files gave `t`, and unsafe when it leaves one out, which is how Atlas drops a SQLite column. A rebuild of a table other tables reference is unsafe unless it carries comb's guard exactly as `comb migrate diff` wrote it (see below); an edit inside the guard makes it unsafe again.
835
846
 
836
- On bun:sqlite, libsql and D1, `check` and `apply` also run a proof first. Each pending file runs on a private copy of the database's live schema, on the SQLite its files apply on (the runtime's for bun:sqlite, libsql's own for a libsql or Turso database, so a vector index is proven and syntax libsql's older SQLite refuses is refused at check), one statement at a time, each file on a fresh connection, as it applies. The copy holds what the pending files can observe: the objects they name, and the keys, indexes, triggers, views and virtual tables those reach, such as an fts5vocab table's FTS table or an FTS table's content table or view. When a file alters a table it also holds every view and trigger, since SQLite checks them all on a rename or a dropped column. When a file, or an object the copy holds, reads the schema itself through a `sqlite_` table (`sqlite_master`, `sqlite_sequence`, `sqlite_stat1`), a `pragma_` function or a `PRAGMA` statement, the copy holds every object and seeds every table. A pragma that only sets how statements run is no such read: `foreign_keys`, `defer_foreign_keys`, `legacy_alter_table`, `recursive_triggers`, `case_sensitive_like`, `ignore_check_constraints` and `busy_timeout`, so drizzle-kit's table rebuild, which turns `foreign_keys` off and on, holds only what it reaches. Otherwise only the tables a file names or its writes can reach, and the tables their keys need, get rows: six each, chosen so a change shows. comb splits a SQLite file where SQLite ends a statement (its `sqlite3_complete()` rule): at a `;` in code, except inside a trigger's body, which ends at `END` and `;`, so a column or table named `begin` or `end` is a name wherever it stands. The copy's own SQLite then confirms each piece is one statement, and a piece it reads as more is refused, naming the file and the statement, since libsql's client runs only the first statement of a string. On bun:sqlite and D1 the copy is a file in a temporary directory only you can open, removed after the run; one a killed run left is removed by a later run. On libsql and Turso it is held in memory, so no file is left. Before each statement runs, SQLite itself (`EXPLAIN`) says what it writes to the tables that were there before the file. An update of a column they had, a delete of their rows (a `REPLACE` that meets one included), a trigger on them it can fire (its `WHEN` is not evaluated), an update, delete or replace of a virtual table's rows, and a temp table or view that takes one's name make the file unsafe, whatever rows the statement meets and however it is spelled. A backfill of only the columns the file added is cautious, and a write to a table the file created is safe. Then every table that was there before is compared, row for row, on the columns it had. A lost row, a lost column, or a value changed, copied between columns or moved between rows makes the file unsafe, and the reason names the table and column. The one allowance: a NULL that becomes the new default of a column the file made NOT NULL is cautious. A file the copy cannot run is unsafe, with the statement and why (or `COMMIT` and why, for a key the file deferred and left broken), and so is every pending file after it, which nothing then proves. A table comb cannot seed is listed as unproven, with why. On Postgres, writes are rated from their text, and `check` lets Postgres decide what no database runs: on a `postgres://` `devUrl` it replays the history and the pending files there before any approval, as Atlas applies them: each file in a transaction of its own, a `-- atlas:txmode none` file statement by statement, each in a transaction of its own first, so the counts below refuse a change past its database before it commits, and bare, as Atlas sends it, where Postgres runs it only so, in a database of its own (see `devUrl` above). So a batched backfill that commits inside a `DO` block or a procedure passes, and a `LOCK TABLE`, `SAVEPOINT`, `RELEASE`, `ROLLBACK TO` or a cursor not held, outside the file's own `BEGIN`, stops `check` as it stops `apply`, named with Postgres's message while comb rates the file, so on any devUrl. A statement the dev database refuses stops it, naming the file, the statement and Postgres's message (a retype of a column a view, a rule, a policy, a trigger's `WHEN`, a generated column or a `BEGIN ATOMIC` function reads). A statement whose scope is the server or another database is left out, and `check` names it: `ALTER SYSTEM`, `CREATE`, `ALTER` and `DROP DATABASE`, `ALTER` and `DROP ROLE`, `DROP` and `REASSIGN OWNED` and a grant or revoke of a role the replay did not make, a tablespace, a subscription, `CHECKPOINT`, `REINDEX DATABASE` or `SYSTEM`, a prepared transaction, a `COPY` to a server file or from a program, and an owner, comment, grant, rename or security label of a database, a role, a tablespace or a server parameter. So is a statement that runs a function acting on the server, named as a call of it: a replication slot's making, dropping or reading of changes, `pg_terminate_backend` and `pg_cancel_backend`, `pg_reload_conf`, `pg_switch_wal`, a statistics reset, `lo_export`, a replication origin's work, and every `dblink` function, whatever schema holds them, as a connection left out leaves its cursors and queries nothing to reach; a hidden call whose effect leaves no state comb can read, such as an ended session or a written file, runs on devUrl's server; a definition that only names one (a view, a function, a trigger, a policy, `EXPLAIN` without `ANALYZE`) runs as written. A replication slot a `DO` block or a function makes is read from the server after the file, refused when the file names it, and dropped. A change a `DO` block or a function makes to the database it runs in, named by `current_database()` (a setting, a grant on it, its comment), is the replay's own, since the replay drops that database after it; making it a template or giving it another owner stays refused, as the replay could not drop it then. A `CREATE ROLE` runs, since a policy may need its role: a role the server already has is used as it is, and one the replay made is dropped after it. A grant or revoke of roles the replay made runs too, such as the `GRANT app TO CURRENT_USER` a role that is no superuser needs before `ALTER TABLE … OWNER TO app`, since dropping the role undoes it. So do a rename, `ALTER ROLE`, `ALTER GROUP`, `ALTER ROLE … SET`, `DROP ROLE`, and `DROP` and `REASSIGN OWNED` of roles the replay made that no other database holds; `ALTER ROLE` runs without a login, a password, `SUPERUSER`, `REPLICATION` and `BYPASSRLS`, and `check` says so. A table, an index or a materialized view a statement puts in a tablespace whose `CREATE TABLESPACE` the replay left out is made in `pg_default`, and `check` says so. A pending file that makes a role the target's server already holds, by `CREATE ROLE` or a rename to it, stops `check` as it would stop `apply`, with `role "<name>" already exists; apply would stop at <file>`: another database's apply on the same server may have made it. `check` reads the target's roles for this on a `docker://` `devUrl` too. A `DO` block, a function, `EXECUTE` and `CALL` run as written, and Postgres says whether one changed the server, twice. In a file that runs in a transaction, Postgres's own counts of the transaction's writes decide before its `COMMIT`: a file that changed a role, a membership, a database, a setting, a comment, a tablespace or anything else the server's databases share, past what the replay drops after it, is rolled back and refused, naming the file and the change. A role such a statement adds is the replay's to drop, as one its own `CREATE ROLE` made: the replay adopts it, makes it `NOLOGIN NOSUPERUSER NOREPLICATION NOBYPASSRLS` with no password before the file commits, and drops it after, and `check` says so for each, so the usual `DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app') THEN CREATE ROLE app; END IF; END $$` passes. A change to a role the replay made or adopted is its own too: the role's options and password, a membership in it or of it, its settings and its comment or security label. A change to any other role stays refused. On a superuser's devUrl comb reads `pg_authid`, so a password change is named as one (`role app changed (rolpassword)`) and the password itself is never read out; on any other devUrl a change its role cannot read, such as a role's password, is refused as one. A devUrl whose server keeps no counts for its role (`track_counts` off) is refused, naming the setting, and so is a file that turns them off. Around every file and every statement a `-- atlas:txmode none` file sends bare, comb also reads the server's shared catalogs as committed, another replay's own rows left out. That sees what a bare statement changed, which no transaction counts, and what a second connection committed, such as `dblink_exec`'s `CREATE DATABASE` or `CREATE ROLE`, which the replay's own counts never see. A `txmode none` statement runs bare only where a block changes what it does: one Postgres refuses in a block (an outside-only statement such as `CREATE INDEX CONCURRENTLY` or `VACUUM`, or a `DO` block or procedure that commits), one Postgres runs only in a block (a `LOCK`, a `SAVEPOINT`, `RELEASE` or `ROLLBACK TO`, a cursor not held), and the file's own `BEGIN` and `COMMIT` and what runs between them. A change one of these makes past its database has committed by the time the read sees it, so `check` refuses it and names what devUrl's server keeps, which comb cannot undo. These are the server's catalogs, not the replay's, so a change there is the file's only when the role, database or other object it is to is one the file names: unquoted (folded to lower case, as Postgres folds it), quoted, or inside a string a `DO` body, `dblink_exec` or `format()` may run. `CURRENT_USER`, `CURRENT_ROLE`, `SESSION_USER` and `USER` name devUrl's role, and `ALL` in `ALTER ROLE ALL` names every role, so a setting a `DO` block commits with `ALTER ROLE CURRENT_USER SET …` or `ALTER ROLE ALL SET …` is refused. Such a change is refused, naming the file, the statement and the change; a role it added is dropped, and so is a database it added that no session is in. A change to any other object is another session's, and `check` passes it. A name the file builds at run time, by concatenation, from a query, or in a function another file defines, is not one it names, so `check` neither refuses nor drops that change. A role's password reads only from `pg_authid`, so a password a bare statement changes is seen only on a superuser's devUrl. This needs the `postgres` package; on a `docker://` `devUrl`, which comb cannot reach, or without `postgres`, `check` says it did not replay and rates the files alone. The target is only read, and a file that changes the server is refused before anything reaches it.
847
+ On bun:sqlite, libsql and D1, `check` and `apply` also run a proof first. Each pending file runs on a private copy of the database's live schema, on the SQLite its files apply on (the runtime's for bun:sqlite, libsql's own for a libsql or Turso database, so a vector index is proven and syntax libsql's older SQLite refuses is refused at check), one statement at a time, each file on a fresh connection, as it applies. The copy holds what the pending files can observe: the objects they name, and the keys, indexes, triggers, views and virtual tables those reach, such as an fts5vocab table's FTS table or an FTS table's content table or view. When a file alters a table it also holds every view and trigger, since SQLite checks them all on a rename or a dropped column, and it holds the views and triggers that read a view a file names. After each file the proof compiles every view and trigger that survives it and uses something the file dropped or changed, since SQLite's `DROP TABLE` and `DROP VIEW` check none of them: a file that leaves one unable to run is refused, naming it and SQLite's error, and no approval overrides it. When a file, or an object the copy holds, reads the schema itself through a `sqlite_` table (`sqlite_master`, `sqlite_sequence`, `sqlite_stat1`), a `pragma_` function or a `PRAGMA` statement, the copy holds every object and seeds every table. A pragma that only sets how statements run is no such read: `foreign_keys`, `defer_foreign_keys`, `legacy_alter_table`, `recursive_triggers`, `case_sensitive_like`, `ignore_check_constraints` and `busy_timeout`, so drizzle-kit's table rebuild, which turns `foreign_keys` off and on, holds only what it reaches. Otherwise only the tables a file names or its writes can reach, and the tables their keys need, get rows: six each, chosen so a change shows. comb splits a SQLite file where SQLite ends a statement (its `sqlite3_complete()` rule): at a `;` in code, except inside a trigger's body, which ends at `END` and `;`, so a column or table named `begin` or `end` is a name wherever it stands. The copy's own SQLite then confirms each piece is one statement, and a piece it reads as more is refused, naming the file and the statement, since libsql's client runs only the first statement of a string. On bun:sqlite and D1 the copy is a file in a temporary directory only you can open, removed after the run; one a killed run left is removed by a later run. On libsql and Turso it is held in memory, so no file is left. Before each statement runs, SQLite itself (`EXPLAIN`) says what it writes to the tables that were there before the file. An update of a column they had, a delete of their rows (a `REPLACE` that meets one included), a trigger on them it can fire (its `WHEN` is not evaluated), an update, delete or replace of a virtual table's rows, and a temp table or view that takes one's name make the file unsafe, whatever rows the statement meets and however it is spelled. A backfill of only the columns the file added is cautious, and a write to a table the file created is safe. Then every table that was there before is compared, row for row, on the columns it had. A lost row, a lost column, or a value changed, copied between columns or moved between rows makes the file unsafe, and the reason names the table and column. The one allowance: a NULL that becomes the new default of a column the file made NOT NULL is cautious. A file the copy cannot run is unsafe, with the statement and why (or `COMMIT` and why, for a key the file deferred and left broken), and so is every pending file after it, which nothing then proves. A table comb cannot seed is listed as unproven, with why. On Postgres, writes are rated from their text, and `check` lets Postgres decide what no database runs: on a `postgres://` `devUrl` it replays the history and the pending files there before any approval, as Atlas applies them: each file in a transaction of its own, a `-- atlas:txmode none` file statement by statement, each in a transaction of its own first, so the counts below refuse a change past its database before it commits, and bare, as Atlas sends it, where Postgres runs it only so, in a database of its own (see `devUrl` above). So a batched backfill that commits inside a `DO` block or a procedure passes, and a `LOCK TABLE`, `SAVEPOINT`, `RELEASE`, `ROLLBACK TO` or a cursor not held, outside the file's own `BEGIN`, stops `check` as it stops `apply`, named with Postgres's message while comb rates the file, so on any devUrl. A statement the dev database refuses stops it, naming the file, the statement and Postgres's message (a retype of a column a view, a rule, a policy, a trigger's `WHEN`, a generated column or a `BEGIN ATOMIC` function reads). A statement whose scope is the server or another database is left out, and `check` names it: `ALTER SYSTEM`, `CREATE`, `ALTER` and `DROP DATABASE`, `ALTER` and `DROP ROLE`, `DROP` and `REASSIGN OWNED` and a grant or revoke of a role the replay did not make, a tablespace, a subscription, `CHECKPOINT`, `REINDEX DATABASE` or `SYSTEM`, a prepared transaction, a `COPY` to a server file or from a program, and an owner, comment, grant, rename or security label of a database, a role, a tablespace or a server parameter. So is a statement that runs a function acting on the server, named as a call of it: a replication slot's making, dropping or reading of changes, `pg_terminate_backend` and `pg_cancel_backend`, `pg_reload_conf`, `pg_switch_wal`, a statistics reset, `lo_export`, a replication origin's work, and every `dblink` function, whatever schema holds them, as a connection left out leaves its cursors and queries nothing to reach; a hidden call whose effect leaves no state comb can read, such as an ended session or a written file, runs on devUrl's server; a definition that only names one (a view, a function, a trigger, a policy, `EXPLAIN` without `ANALYZE`) runs as written. A replication slot a `DO` block or a function makes is read from the server after the file, refused when the file names it, and dropped. A change a `DO` block or a function makes to the database it runs in, named by `current_database()` (a setting, a grant on it, its comment), is the replay's own, since the replay drops that database after it; making it a template or giving it another owner stays refused, as the replay could not drop it then. A `CREATE ROLE` runs, since a policy may need its role: a role the server already has is used as it is, and one the replay made is dropped after it. A grant or revoke of roles the replay made runs too, such as the `GRANT app TO CURRENT_USER` a role that is no superuser needs before `ALTER TABLE … OWNER TO app`, since dropping the role undoes it. So do a rename, `ALTER ROLE`, `ALTER GROUP`, `ALTER ROLE … SET`, `DROP ROLE`, and `DROP` and `REASSIGN OWNED` of roles the replay made that no other database holds; `ALTER ROLE` runs without a login, a password, `SUPERUSER`, `REPLICATION` and `BYPASSRLS`, and `check` says so. A table, an index or a materialized view a statement puts in a tablespace whose `CREATE TABLESPACE` the replay left out is made in `pg_default`, and `check` says so. A pending file that makes a role the target's server already holds, by `CREATE ROLE` or a rename to it, stops `check` as it would stop `apply`, with `role "<name>" already exists; apply would stop at <file>`: another database's apply on the same server may have made it. `check` reads the target's roles for this on a `docker://` `devUrl` too. A `DO` block, a function, `EXECUTE` and `CALL` run as written, and Postgres says whether one changed the server, twice. In a file that runs in a transaction, Postgres's own counts of the transaction's writes decide before its `COMMIT`: a file that changed a role, a membership, a database, a setting, a comment, a tablespace or anything else the server's databases share, past what the replay drops after it, is rolled back and refused, naming the file and the change. A role such a statement adds is the replay's to drop, as one its own `CREATE ROLE` made: the replay adopts it, makes it `NOLOGIN NOSUPERUSER NOREPLICATION NOBYPASSRLS` with no password before the file commits, and drops it after, and `check` says so for each, so the usual `DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app') THEN CREATE ROLE app; END IF; END $$` passes. A change to a role the replay made or adopted is its own too: the role's options and password, a membership in it or of it, its settings and its comment or security label. A change to any other role stays refused. On a superuser's devUrl comb reads `pg_authid`, so a password change is named as one (`role app changed (rolpassword)`) and the password itself is never read out; on any other devUrl a change its role cannot read, such as a role's password, is refused as one. A devUrl whose server keeps no counts for its role (`track_counts` off) is refused, naming the setting, and so is a file that turns them off. Around every file and every statement a `-- atlas:txmode none` file sends bare, comb also reads the server's shared catalogs as committed, another replay's own rows left out. That sees what a bare statement changed, which no transaction counts, and what a second connection committed, such as `dblink_exec`'s `CREATE DATABASE` or `CREATE ROLE`, which the replay's own counts never see. A `txmode none` statement runs bare only where a block changes what it does: one Postgres refuses in a block (an outside-only statement such as `CREATE INDEX CONCURRENTLY` or `VACUUM`, or a `DO` block or procedure that commits), one Postgres runs only in a block (a `LOCK`, a `SAVEPOINT`, `RELEASE` or `ROLLBACK TO`, a cursor not held), and the file's own `BEGIN` and `COMMIT` and what runs between them. A change one of these makes past its database has committed by the time the read sees it, so `check` refuses it and names what devUrl's server keeps, which comb cannot undo. These are the server's catalogs, not the replay's, so a change there is the file's only when the role, database or other object it is to is one the file names: unquoted (folded to lower case, as Postgres folds it), quoted, or inside a string a `DO` body, `dblink_exec` or `format()` may run; and only when the file's statements so far run code comb cannot read, a `DO` block, a `CALL`, an `EXECUTE` or a call of a function outside `pg_catalog`, since a file of plain statements makes no such change unseen, and Postgres records no creator for a role, a database or a slot that could tell another session's from the file's. `CURRENT_USER`, `CURRENT_ROLE`, `SESSION_USER` and `USER` name devUrl's role, and `ALL` in `ALTER ROLE ALL` names every role, so a setting a `DO` block commits with `ALTER ROLE CURRENT_USER SET …` or `ALTER ROLE ALL SET …` is refused. Such a change is refused, naming the file, the statement and the change, and left on devUrl's server for you to undo there, since another session may have made it; comb drops only the replay's own database and the roles it made. A change to any other object is another session's, and `check` passes it. A name the file builds at run time, by concatenation, from a query, or in a function another file defines, is not one it names, so `check` neither refuses nor drops that change. A role's password reads only from `pg_authid`, so a password a bare statement changes is seen only on a superuser's devUrl. This needs the `postgres` package; on a `docker://` `devUrl`, which comb cannot reach, or without `postgres`, `check` says it did not replay and rates the files alone. The target is only read, and a file that changes the server is refused before anything reaches it.
837
848
 
838
849
  4. On SQLite, libsql and Turso, comb applies each file's statements, as written, and the insert of its hash as one batch, which every provider runs all-or-nothing. On a SQLite file (bun:sqlite, a libsql `file:` URL), each file applies on a connection opened for it and closed after it, as wrangler runs each file on D1, so a pragma a file sets on its connection (`ignore_check_constraints`, `recursive_triggers`, `legacy_alter_table`, `case_sensitive_like` and the like) ends with the file, and the next file runs as it would alone. A libsql server, which a libsql URL of any other scheme (`http`, `https`, `ws`, `wss`, `libsql`) reaches and which Turso runs, refuses those pragmas: it parses each statement first and runs only what it lists. So check holds a file for such a target to what the libsql server (sqld) runs, measured on the sqld comb's tests pin, and refuses the rest with no override, naming the server: a pragma outside its list or with a quoted name (it runs `foreign_keys`, `defer_foreign_keys`, `user_version`, the `table_info` family and the checks, and reads most others), `ANALYZE`, `ATTACH`, a `TEMP` object and `load_extension()`. Turso cloud is held to the same set. With no connection setting left to end, a server's files apply through the one client the run writes with, and a Turso apply mints one token for it. An in-memory database (`:memory:`) lives in its one connection, so every file applies through that one. A pragma whose setting is the process's (`hard_heap_limit`, `soft_heap_limit`, `temp_store_directory`) reaches every connection and outlives the file, so check refuses it, with no override. Every SQLite provider applies with foreign keys on, as the proof runs: bun:sqlite, which SQLite opens with them off, turns them on for each connection comb opens. A file is applied and recorded, or neither. A transient failure retries the whole batch through `withRetry()`, which reads it from the error's structured fields alone (on the error or a cause): an HTTP status of 429 or 5xx, or a network code such as `ECONNRESET` or `UND_ERR_SOCKET`. An error with a `SQLITE_` code is never retried, whatever its message says. On a SQLite file, a write lock that the app serving the file or a second runner holds is waited for rather than failed on: every connection comb opens to the file sets `PRAGMA busy_timeout = 5000`, SQLite's own wait, which retries no failed statement, and each file's batch begins `IMMEDIATE`, taking the lock before any statement. Its record is written first, so a file another run applied while this one waited fails on its name before it runs anything, and is skipped as `Skip (applied by another run)`. `--file` applies one file on its own, which no sum or order covers.
839
850
 
840
- On D1, wrangler applies. comb reads the binding from the wrangler config with wrangler's own reader, refuses when its `migrations_dir` is not comb's `migrationsDir` or its `migrations_pattern` takes anything but that directory's `.sql` files, orders the files as wrangler does (by leading number, then name, so 8- and 14-digit prefixes mix), checks exactly the files wrangler will apply, and runs `wrangler d1 migrations apply` with stdin closed. wrangler runs each file and its `d1_migrations` row as one batch locally and one query remotely, and takes no backup, so bookmark a production database (Time Travel) before an apply. comb then reads the table back and fails unless it holds exactly the files it checked, printing wrangler's own words; a declined prompt or a failed file leaves its row out. A file that landed in the directory while wrangler ran, which wrangler applied and comb never checked, is named, and listed under `applied` in `--json`: check it against git, then run `comb migrate baseline <db> --hashes`. `--file` does not apply to D1. D1's own engine refuses much a SQLite build runs: pragmas outside [Cloudflare's list](https://developers.cloudflare.com/d1/sql-api/sql-statements/), functions outside workerd's allowlist, virtual-table modules but fts5, fts5vocab, rtree and rtree_i32, TEMP objects, savepoints, `_cf_` names, and its limits. So check runs each pending file on D1's own engine: after the proof, on a scratch local D1 that the checked wrangler's Miniflare opens with the proof's copy and its rows, each file as wrangler sends it (its `splitSqlQuery`, with the `d1_migrations` row it appends), then compiles each view, and each table's writes, that the file made or changed, since D1 resolves a function in a trigger, a view or a generated column only when a statement uses it. A file D1 refuses there is refused, naming D1's error, and no approval overrides it. When the local D1 itself crashes compiling a statement, comb compiles it again on a fresh scratch D1; a second crash proves nothing about the live database, so the file is cautious, "unproven on D1's engine", and runs with `--yes` or a yes on a terminal. wrangler `--local` deletes the first `BEGIN TRANSACTION;` and `COMMIT;` of a file holding `BEGIN TRANSACTION`, inside literals and comments too, where a remote apply sends the text as written, so check refuses such a file; split the words, as in `'BEGIN ' || 'TRANSACTION'`. A database comb 0.7 migrated records its files in `__drizzle_migrations`; apply refuses it until `comb migrate baseline <db>` copies that record into `d1_migrations` once. wrangler's table records names only, so comb records each applied file's hash beside it and refuses a file applied, then edited. A name is in wrangler's table only once comb holds its hash or its intent. Apply records each pending file's hash as an intent before wrangler runs, and moves it into comb's hashes once wrangler's table names the file, so a recorded hash is always the text wrangler ran: an apply that fails or is lost leaves only intents, which the next apply clears, and a file wrangler applied while comb lost the move counts as hashed while its text still matches its intent, until the next apply or `--hashes` records it. Baseline writes the hashes before wrangler's rows, so a call lost between them leaves nothing a rerun cannot finish. A rerun of baseline copies every file `__drizzle_migrations` records that lacks either row, so it also finishes a baseline a lost call left half done. A file applied before comb recorded hashes has none, and `status` names it; once you have checked those files against git, `comb migrate baseline <db> --hashes` records the current hash of each applied file that has none, after the same checks apply runs (it refuses while a name in the history matches no file, or a recorded hash is not its file's). Run it once; `--hashes` is for D1 alone, as every other history records each hash as it applies. A D1 database's history is in four tables, which a tool that empties a database must keep, rows and all: `d1HistoryTables(migrationsTable?)` from `@workerstack/database/migrate` names them, the binding's `migrations_table` first (`D1_MIGRATIONS_TABLE`, `d1_migrations`, by default), then `D1_HASH_TABLE` (`_comb_migration_hashes`), whose loss lets an edited applied file pass `check`, `_comb_migration_hash_intents`, the intents of an apply in flight, and `DRIZZLE_HISTORY_TABLE` (`__drizzle_migrations`, comb's history before 0.8).
851
+ On D1, wrangler applies. comb reads the binding from the wrangler config with wrangler's own reader, refuses when its `migrations_dir` is not comb's `migrationsDir` or its `migrations_pattern` takes anything but that directory's `.sql` files, orders the files as wrangler does (by leading number, then name, so 8- and 14-digit prefixes mix), checks exactly the files wrangler will apply, and runs `wrangler d1 migrations apply` with stdin closed. wrangler runs each file and its `d1_migrations` row as one batch locally and one query remotely, and takes no backup, so bookmark a production database (Time Travel) before an apply. comb then reads the table back and fails unless it holds exactly the files it checked, printing wrangler's own words; a declined prompt or a failed file leaves its row out. A file that landed in the directory while wrangler ran, which wrangler applied and comb never checked, is named, and listed under `applied` in `--json`: check it against git, then run `comb migrate baseline <db> --hashes`. `--file` does not apply to D1. D1's own engine refuses much a SQLite build runs: pragmas outside [Cloudflare's list](https://developers.cloudflare.com/d1/sql-api/sql-statements/), functions outside workerd's allowlist, virtual-table modules but fts5, fts5vocab, rtree and rtree_i32, TEMP objects, savepoints, `_cf_` names, and its limits. So check runs each pending file on D1's own engine: after the proof, on a scratch local D1 that the checked wrangler's Miniflare opens with the proof's copy and its rows, each file as wrangler sends it (its `splitSqlQuery`, with the `d1_migrations` row it appends), then compiles each view, and each table's writes, that the file made or changed, a view's writes only for the events its triggers fire on, since D1 resolves a function in a trigger, a view or a generated column only when a statement uses it. A file D1 refuses there is refused, naming D1's error, and no approval overrides it. When the local D1 itself crashes compiling a statement, comb compiles it again on a fresh scratch D1; a second crash proves nothing about the live database, so the file is cautious, "unproven on D1's engine", and runs with `--yes` or a yes on a terminal. wrangler `--local` deletes the first `BEGIN TRANSACTION;` and `COMMIT;` of a file holding `BEGIN TRANSACTION`, inside literals and comments too, where a remote apply sends the text as written, so check refuses such a file; split the words, as in `'BEGIN ' || 'TRANSACTION'`. A database comb 0.7 migrated records its files in `__drizzle_migrations`; apply refuses it until `comb migrate baseline <db>` copies that record into `d1_migrations` once. wrangler's table records names only, so comb records each applied file's hash beside it and refuses a file applied, then edited. A name is in wrangler's table only once comb holds its hash or its intent. Apply records each pending file's hash as an intent before wrangler runs, and moves it into comb's hashes once wrangler's table names the file, so a recorded hash is always the text wrangler ran: an apply that fails or is lost leaves only intents, which the next apply clears, and a file wrangler applied while comb lost the move counts as hashed while its text still matches its intent, until the next apply or `--hashes` records it. Baseline writes the hashes before wrangler's rows, so a call lost between them leaves nothing a rerun cannot finish. A rerun of baseline copies every file `__drizzle_migrations` records that lacks either row, so it also finishes a baseline a lost call left half done. A file applied before comb recorded hashes has none, and `status` names it; once you have checked those files against git, `comb migrate baseline <db> --hashes` records the current hash of each applied file that has none, after the same checks apply runs (it refuses while a name in the history matches no file, or a recorded hash is not its file's). Run it once; `--hashes` is for D1 alone, as every other history records each hash as it applies. A D1 database's history is in four tables, which a tool that empties a database must keep, rows and all: `d1HistoryTables(migrationsTable?)` from `@workerstack/database/migrate` names them, the binding's `migrations_table` first (`D1_MIGRATIONS_TABLE`, `d1_migrations`, by default), then `D1_HASH_TABLE` (`_comb_migration_hashes`), whose loss lets an edited applied file pass `check`, `_comb_migration_hash_intents`, the intents of an apply in flight, and `DRIZZLE_HISTORY_TABLE` (`__drizzle_migrations`, comb's history before 0.8).
841
852
 
842
853
  `baseline <db>` records the files as applied without running them, for a database whose schema was set up out of band, after Atlas compares the database's schema with the one the history leaves; any difference is printed and nothing is recorded. On SQLite comb reads the schema from the database itself and compares FTS5 tables by name, since Atlas's dev SQLite has no fts5 module. On D1 it copies only what `__drizzle_migrations` records.
843
854
 
@@ -1045,7 +1056,7 @@ A filter on a relation field (`tags.slug`, through a `relations` entry) asks abo
1045
1056
 
1046
1057
  `buildListQuery()` and `queryHelpers.listOptions()` take a filter and an order only from the list query schema's output, which carries `validated: true`. A hand-built `filterAst` or `parsedSort` without it is refused (`filter_not_wired` / `sort_not_wired`, 500): checked against nothing, it could name any column of the table, `password_hash` included. Code that validated a filter against its own fields, as `validateFilter()` does, sets `validated: true` itself. The flag covers the filter and the order, never the page: a page is always a whole number from 1, and past the default `maxPage` (10,000) it is `page_too_deep`, unless it carries a `pageBound`, which the schema sets to the `maxPage` it was built with; then that number holds it. A limit is held the same way: past the default `maxLimit` (100) it is a 400, unless a `limitBound` states another. Each comes from the schema's config, never from a request, and a parsed query with a field changed stays held to them. Either on an input you make by hand is your app stating its own bound. `buildDerivedListQuery()` needs no flag: its caller passes the filter as SQL and maps every sortable field to a column.
1047
1058
 
1048
- `buildDerivedListQuery()` handles queries over a subquery or CTE, where sort columns come from a `sortColumns` map rather than the table. A derived list whose rows are one table, its sort columns that table's, names it as `anchor: { table, idColumn }`: a value too long for a cursor then goes by reference and is read back from that table, as on the table's own list. With `idColumn` alone no row holds its keys, so such a value is a 500 (`sort_not_wired`) on the page that meets it, and so is a sort key SQLite stores as INTEGER that the driver read as a number past ±(2^53 − 1), rounded (D1 always, bun:sqlite by default), which the next page would seek by; a REAL past 2^53 is a double on disk, read back exactly, and pages. On Postgres the same holds by type: a `bigint` read as a number past 2^53 is the 500, and a `double precision` or `real` pages. A `numeric` read in number mode is read as the nearest double, so 0.1000000000000000001 and 0.1000000000000000002 both read as 0.1: every list, derived or not, carries its text in `_combKeyTexts`, and the cursor binds that text as a `numeric`, exact whether or not its row is still there. Any other number under a type no double names, such as a domain's read through `mapWith(Number)`, is the 500 when it isn't a safe integer. Read such an integer as bigint, or name the `anchor`, whose row the keyset reads the exact key back from. A derived list must not dedupe with `DISTINCT`: SQL counts `_total` before `DISTINCT` runs, so the count would be the joined rows. Express such a join as `EXISTS` in `filterWhere`, or `GROUP BY` the id, which the count runs after. The list's database is read from the columns its id and sort columns hold, wherever an expression holds them; a list of SQL expressions alone, such as a union whose id is `` sql`inbox.uid` ``, states it with `dialect: "postgres"` or `dialect: "sqlite"`, and is a TypeError naming `dialect` without one, since Postgres types a key by its operand and SQLite by its storage class. A SQLite derived list whose rows sit on several shards, such as a union of two joined selects per shard, takes `merged: {}` (with `maxRowsPerShard`, as `buildListQuery`'s does): read each shard with `applyListQuery` and page the reads with `paginateMerged`. Its rows carry each sort field under its name, which the merge orders by, and it takes no `anchor`, as a merged list reads no key by reference. A merged Postgres derived list is a TypeError at build: the merge orders as SQLite does, while Postgres orders a `numeric` or `bigint` read as a decimal string, and text under the server's collation, in ways no in-memory merge can know.
1059
+ `buildDerivedListQuery()` handles queries over a subquery or CTE, where sort columns come from a `sortColumns` map rather than the table. A derived list whose rows are one table, its sort columns that table's, names it as `anchor: { table, idColumn }`: a value too long for a cursor then goes by reference and is read back from that table, as on the table's own list. With `idColumn` alone no row holds its keys, so such a value is a 500 (`sort_not_wired`) on a page served by cursor that meets it, and so is a sort key SQLite stores as INTEGER that the driver read as a number past ±(2^53 − 1), rounded (D1 always, bun:sqlite by default), which the next page would seek by; a REAL past 2^53 is a double on disk, read back exactly, and pages. On Postgres the same holds by type: a `bigint` read as a number past 2^53 is the 500, and a `double precision` or `real` pages. A page by number serves such a row: when it is the page's last row, the one its next cursor would be minted from, its `nextCursor` is `null` while `hasMore` still says there is a next page by number. A `numeric` read in number mode is read as the nearest double, so 0.1000000000000000001 and 0.1000000000000000002 both read as 0.1: every list, derived or not, carries its text in `_combKeyTexts`, and the cursor binds that text as a `numeric`, exact whether or not its row is still there. Any other number under a type no double names, such as a domain's read through `mapWith(Number)`, is the 500 when it isn't a safe integer. Read such an integer as bigint, or name the `anchor`, whose row the keyset reads the exact key back from. A derived list must not dedupe with `DISTINCT`: SQL counts `_total` before `DISTINCT` runs, so the count would be the joined rows. Express such a join as `EXISTS` in `filterWhere`, or `GROUP BY` the id, which the count runs after. The list's database is read from the columns its id and sort columns hold, wherever an expression holds them; a list of SQL expressions alone, such as a union whose id is `` sql`inbox.uid` ``, states it with `dialect: "postgres"` or `dialect: "sqlite"`, and is a TypeError naming `dialect` without one, since Postgres types a key by its operand and SQLite by its storage class. A SQLite derived list whose rows sit on several shards, such as a union of two joined selects per shard, takes `merged: {}` (with `maxRowsPerShard`, as `buildListQuery`'s does): read each shard with `applyListQuery` and page the reads with `paginateMerged`. Its rows carry each sort field under its name, which the merge orders by, and it takes no `anchor`, as a merged list reads no key by reference. A merged Postgres derived list is a TypeError at build: the merge orders as SQLite does, while Postgres orders a `numeric` or `bigint` read as a decimal string, and text under the server's collation, in ways no in-memory merge can know.
1049
1060
 
1050
1061
  `queryHelpers.paginate(rows, built)` takes the rows and the query that read them, any of the three builders' results (a merged list's goes to `paginateMerged`), and returns `[items, pagination]`; the built query's `pagination` says what it read, so nothing is passed twice. A list ordered by a computed sort, or a table with no tiebreak column, has no cursor to resume it: its `nextCursor` is `null` while `hasMore` still says there is a next page by number. More rows than the built query reads, one past the page, are a 500 (`page_not_wired`): rows a query read without its limit and offset are no page of this list. Underneath, `filterToSQL()` and `conditionToSQL()` are exported for advanced use, along with `orderSQL()` / `orderByKeys()` / `keysetSQL()` — the one ordering and keyset implementation every builder shares — and `patternToSQL()` for `like` / `ilike`.
1051
1062
 
@@ -1144,7 +1155,7 @@ To place orgs yourself, `place({ orgId, shardId }, deps, ctx)` writes the same w
1144
1155
 
1145
1156
  Tunables: `lruMaxSize` (1000), `lruTtlMs` (5 min), `lruHardTtlMs` (15 min), `kvTtlSeconds` (60), `missTtlMs` (5 s), `maxOrgsPerShard` (500), `pick`. The constructor throws a `RangeError` unless `totalShards` and `maxOrgsPerShard` are positive integers and `kvTtlSeconds` is an integer of at least 60, KV's floor for an expiration TTL: under it every put fails, so KV would cache nothing. An org id is at most 64 UTF-16 code units, the length `shard_map.org_id` holds (`ORG_ID_MAX_LENGTH`): `place`, `move` and `allocate` (so `resolveOrAllocate`) throw a `RangeError` for a longer one, and `resolve` answers it as a miss. A route that takes the org id from its path validates it first. Pass `log(event, data)` for a structured hook: `shard-router.hit` (`{ orgId, tier: "lru" | "kv" | "d1" }`, plus `stale` on an LRU hit), `shard-router.miss` (`{ orgId }`, also for an id longer than `shard_map.org_id`'s 64 characters, answered before KV, whose keys stop at 512 bytes), `shard-router.allocate` (`{ orgId, shardId }`, plus `revived: true` for a revived org, from allocation and `place`, once per row written: a `place` or allocation of an org already placed, or the isolate that lost a race to place it, logs none), `shard-router.move` (`{ orgId, shardId }`), `shard-router.reresolve` (`{ orgId, from, to }`, `to` undefined for an org since unplaced), `shard-router.kv.error` (`{ orgId, op: "get" | "put" | "delete", error }`) and `shard-router.revalidate.error` (`{ orgId, error }`). With no `log`, the two `.error` events go to `console.warn`; pass `log: () => {}` to mute them.
1146
1157
 
1147
- A list whose rows sit on several shards, such as a user's orgs across the shards they have presence on, is merged in memory. Build it once, `buildListQuery({ cursorScope, cursorSecret, merged: {}, parsed, table, … })` with its filter, search and `baseWhere` as for one database; read each shard with that one query, its `extras` selected (`applyListQuery` does it), and pass the rows of all of them with it to `paginateMerged(rowsPerShard, built)`, which returns `[items, pagination]` as `queryHelpers.paginate` does. The shards are SQLite: the merge orders as SQLite does, so a merged list over a Postgres table is a `TypeError` at build, as a merged Postgres derived list is. A merged list's query is paged by `paginateMerged` alone, and a single database's by `paginate` alone: the types keep them apart, and each refuses the other's as a 500 (`page_not_wired`), as it refuses a shard's rows that lack the count or the tiebreak. A cursor page reads the page and one more past the cursor from each shard; a page by number reads every row up to its end from each, as any shard may hold any of them, and each shard counts its part, so `pagination.count` is the whole list's and `page=N` gives page N. The merge orders as SQLite does: text by code point (its BINARY collation), numbers before text, the null placement the order names, then the id, so the merged list pages by number or by cursor exactly as one database holding every row would. That holds for sort keys under SQLite's default BINARY collation, which is every column drizzle declares: a column a migration declared `COLLATE NOCASE` or `RTRIM` orders each shard by its fold, which the merge cannot know. `paginateMerged` throws `sort_not_wired` (500) when a shard's own rows come back out of its order, but a case pair that sits on two shards is past seeing, so keep a merged list's sort keys BINARY. A sort key SQLite stores as INTEGER past ±(2^53 − 1) that the driver reads as a JS number (D1 always does, as does bun:sqlite by default) is already rounded, so two such keys on two shards could merge in the wrong order: `paginateMerged` refuses one as `sort_not_wired` (500), as it does such a tiebreak; read it as bigint (libsql's `intMode: "bigint"`, bun:sqlite's `safeIntegers`), or keep it within the bound. A REAL key past 2^53 is exact as a double and merges in order, and a REAL ±Infinity, which D1 reads as null, merges as itself. Its ids must be unique across the shards, as a cursor names one row by its id: `paginateMerged` throws `sort_not_wired` when two shards return the same id, though only for rows it was given. A cursor page serves no row at or before the cursor, and none past the earliest last row of a shard that read its whole page and one more, so a shard that resumed early, as one reading a NULL key's anchor does, neither repeats nor skips rows. Offset pages cost every shard the rows before them, so a page whose end passes `maxRowsPerShard` (1001 rows by default, `merged: { maxRowsPerShard }`) throws `page_too_deep` (400): page a deep merged list by cursor. A cursor names its row by value, never by reference, as only one shard holds the row a reference reads back: a sort key too long for a cursor is a 500 (`sort_not_wired`) on the page that meets it, so a merged list's sort keys must fit the cursor.
1158
+ A list whose rows sit on several shards, such as a user's orgs across the shards they have presence on, is merged in memory. Build it once, `buildListQuery({ cursorScope, cursorSecret, merged: {}, parsed, table, … })` with its filter, search and `baseWhere` as for one database; read each shard with that one query, its `extras` selected (`applyListQuery` does it), and pass the rows of all of them with it to `paginateMerged(rowsPerShard, built)`, which returns `[items, pagination]` as `queryHelpers.paginate` does. The shards are SQLite: the merge orders as SQLite does, so a merged list over a Postgres table is a `TypeError` at build, as a merged Postgres derived list is. A merged list's query is paged by `paginateMerged` alone, and a single database's by `paginate` alone: the types keep them apart, and each refuses the other's as a 500 (`page_not_wired`), as it refuses a shard's rows that lack the count or the tiebreak. A cursor page reads the page and one more past the cursor from each shard; a page by number reads every row up to its end from each, as any shard may hold any of them, and each shard counts its part, so `pagination.count` is the whole list's and `page=N` gives page N. The merge orders as SQLite does: text by code point (its BINARY collation), numbers before text, the null placement the order names, then the id, so the merged list pages by number or by cursor exactly as one database holding every row would. That holds for sort keys under SQLite's default BINARY collation, which is every column drizzle declares: a column a migration declared `COLLATE NOCASE` or `RTRIM` orders each shard by its fold, which the merge cannot know. `paginateMerged` throws `sort_not_wired` (500) when a shard's own rows come back out of its order, but a case pair that sits on two shards is past seeing, so keep a merged list's sort keys BINARY. A sort key SQLite stores as INTEGER past ±(2^53 − 1) that the driver reads as a JS number (D1 always does, as does bun:sqlite by default) is already rounded, so two such keys on two shards could merge in the wrong order: `paginateMerged` refuses one as `sort_not_wired` (500), as it does such a tiebreak; read it as bigint (libsql's `intMode: "bigint"`, bun:sqlite's `safeIntegers`), or keep it within the bound. A REAL key past 2^53 is exact as a double and merges in order, and a REAL ±Infinity, which D1 reads as null, merges as itself. Its ids must be unique across the shards, as a cursor names one row by its id: `paginateMerged` throws `sort_not_wired` when two shards return the same id, though only for rows it was given. A cursor page serves no row at or before the cursor, and none past the earliest last row of a shard that read its whole page and one more, so a shard that resumed early, as one reading a NULL key's anchor does, neither repeats nor skips rows. Offset pages cost every shard the rows before them, so a page whose end passes `maxRowsPerShard` (1001 rows by default, `merged: { maxRowsPerShard }`) throws `page_too_deep` (400): page a deep merged list by cursor. A cursor names its row by value, never by reference, as only one shard holds the row a reference reads back: a sort key too long for a cursor is a 500 (`sort_not_wired`) on a page served by cursor that meets it, so a merged list's sort keys must fit the cursor. A page by number serves such a row: when it is the page's last row, the one its next cursor would be minted from, its `nextCursor` is `null` while `hasMore` still says there is a next page by number.
1148
1159
 
1149
1160
  ## Meta contract
1150
1161
 
@@ -1198,27 +1209,27 @@ ULIDs use Crockford base32 with a 10-character timestamp and 16 characters of ra
1198
1209
 
1199
1210
  ## Package exports
1200
1211
 
1201
- | Specifier | Contents |
1202
- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
1203
- | `@workerstack/database` | `CombError`, `createUlidFactory`, `generateId`, `generateUlid`, `LruCache`, the format registry predicates, error keys and types |
1204
- | `@workerstack/database/types` | `StatusKey`, `ConstraintMap`, `DatabaseErrorHandler`, … |
1205
- | `@workerstack/database/cli` | `runCli(argv, io)`: run the CLI in process and get its exit code |
1206
- | `@workerstack/database/codegen` | `analyze`, `validateTables`, `fieldConstraintsOf`, analysis types |
1207
- | `@workerstack/database/codegen/generators` | every `generate*` function |
1208
- | `@workerstack/database/meta` | `combMeta`, `readCombMeta`, `readCombEntityMeta`, `readCombQueryMeta`, `carryCombMeta`, meta types |
1209
- | `@workerstack/database/migrate` | config, providers, apply, parse, Atlas helpers |
1210
- | `@workerstack/database/query` | filter, order, select, cursors, Zod builders |
1211
- | `@workerstack/database/query/sqlite` | SQL generation, FTS5, JSON helpers |
1212
- | `@workerstack/database/sqlite/columns` | `c` for SQLite |
1213
- | `@workerstack/database/sqlite/constraints` | constraint types |
1214
- | `@workerstack/database/sqlite/constraint-handler` | `sqliteErrorHandler`, `classifySqliteError`, `handleConstraintViolation` |
1215
- | `@workerstack/database/sqlite/d1/session` | `openD1Session` |
1216
- | `@workerstack/database/sqlite/d1/bookmark` | `BookmarkTracker` |
1217
- | `@workerstack/database/sqlite/d1/error-handler` | `d1ErrorHandler`, `classifyD1Error` |
1218
- | `@workerstack/database/sqlite/d1/sharding` | `ShardRouter`, `shardMap` |
1219
- | `@workerstack/database/states` | `defineStateMachine`, `canTransition`, `assertTransition`, `nextStates`, `isState`, `isTerminal` |
1220
- | `@workerstack/database/pg/columns` | `c` for PostgreSQL |
1221
- | `@workerstack/database/pg/error-handler` | `pgErrorHandler`, `classifyPgError` |
1212
+ | Specifier | Contents |
1213
+ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
1214
+ | `@workerstack/database` | `CombError`, `createUlidFactory`, `generateId`, `generateUlid`, `LruCache`, the format registry predicates, `isBcp47`, error keys and types |
1215
+ | `@workerstack/database/types` | `StatusKey`, `ConstraintMap`, `DatabaseErrorHandler`, … |
1216
+ | `@workerstack/database/cli` | `runCli(argv, io)`: run the CLI in process and get its exit code |
1217
+ | `@workerstack/database/codegen` | `analyze`, `validateTables`, `fieldConstraintsOf`, analysis types |
1218
+ | `@workerstack/database/codegen/generators` | every `generate*` function |
1219
+ | `@workerstack/database/meta` | `combMeta`, `readCombMeta`, `readCombEntityMeta`, `readCombQueryMeta`, `carryCombMeta`, meta types |
1220
+ | `@workerstack/database/migrate` | config, providers, apply, parse, Atlas helpers |
1221
+ | `@workerstack/database/query` | filter, order, select, cursors, Zod builders |
1222
+ | `@workerstack/database/query/sqlite` | SQL generation, FTS5, JSON helpers |
1223
+ | `@workerstack/database/sqlite/columns` | `c` for SQLite |
1224
+ | `@workerstack/database/sqlite/constraints` | constraint types |
1225
+ | `@workerstack/database/sqlite/constraint-handler` | `sqliteErrorHandler`, `classifySqliteError`, `handleConstraintViolation` |
1226
+ | `@workerstack/database/sqlite/d1/session` | `openD1Session` |
1227
+ | `@workerstack/database/sqlite/d1/bookmark` | `BookmarkTracker` |
1228
+ | `@workerstack/database/sqlite/d1/error-handler` | `d1ErrorHandler`, `classifyD1Error` |
1229
+ | `@workerstack/database/sqlite/d1/sharding` | `ShardRouter`, `shardMap` |
1230
+ | `@workerstack/database/states` | `defineStateMachine`, `canTransition`, `assertTransition`, `nextStates`, `isState`, `isTerminal` |
1231
+ | `@workerstack/database/pg/columns` | `c` for PostgreSQL |
1232
+ | `@workerstack/database/pg/error-handler` | `pgErrorHandler`, `classifyPgError` |
1222
1233
 
1223
1234
  ## Repository layout
1224
1235
 
@@ -1266,6 +1277,60 @@ bun run test:bun # bun test — the bun:sqlite replay suite
1266
1277
 
1267
1278
  Commits follow [Conventional Commits](https://www.conventionalcommits.org). `bun install` points `core.hooksPath` at `.githooks`, so the `commit-msg` hook enforces it locally; CI re-checks every commit on a pull request.
1268
1279
 
1280
+ ## Upgrading to 0.23
1281
+
1282
+ What changes for a project on 0.22.1. Before deploying it:
1283
+
1284
+ 1. **Know what changed in the API.** A test holds these three lists to the exports themselves.
1285
+
1286
+ New exports: `isBcp47`.
1287
+
1288
+ Removed: none. **Fix:** none.
1289
+
1290
+ Changed: none.
1291
+
1292
+ `isBcp47(value)` answers whether a string is a well-formed BCP 47 language tag, RFC 5646 §2.1's Language-Tag, exactly as written: it reads the grammar a `format: "bcp47"` column checks, takes what the column takes before its trim, never trims or folds case (`" en"` is false), and never throws, as the registry predicates do ([Formats](#formats)). **Fix:** none.
1293
+
1294
+ 2. **Expect `format: "bcp47"` to take private-use and grandfathered tags.** The format named RFC 5646 §2.1 but checked its `langtag` alone, so it refused a private-use tag such as `x-private` and the irregular grandfathered tags, `i-klingon`, `i-default`, `en-GB-oed`, `sgn-BE-FR` and the rest of RFC 5646's list, which are well-formed tags of the standard. It now checks the whole Language-Tag, each class case-insensitive, and its published `pattern` grows to match: a column, its generated DTO and `isBcp47` (step 1) take them, and still take every tag they took before. The regular grandfathered tags, such as `zh-min-nan` and `art-lojban`, were already taken as langtags. **Fix:** none, unless code behind a `bcp47` column assumes a tag starts with a language subtag: handle a tag starting `x-` or `i-` there, or narrow the column with a `pattern`; regenerate, so the published spec carries the new pattern.
1295
+
1296
+ ## Upgrading to 0.22.1
1297
+
1298
+ What changes for a project on 0.22. No export changed. Before deploying it:
1299
+
1300
+ 1. **Expect a page past `maxPage` to be `page_too_deep` at every size.** A page number past `maxPage` and above 2^53 was a validation issue, and one past 1e308 read as Infinity and answered `invalid_page`; now every page past `maxPage`, however many digits it has, throws `CombError` `page_too_deep` (400), as a page just past it already did. **Fix:** none, unless a client matched `invalid_page` or a validation issue on a huge page: match `page_too_deep`.
1301
+
1302
+ 2. **Expect `select=*,name` to refuse an unknown name.** The wildcard was expanded before the other names were checked, so `select=*,secret` dropped `secret` and answered 200 where `select=secret` is a 400. Each name beside `*` is now checked against the declared fields first, at the root and in a relation, and an unknown one is the same 400 it is alone. **Fix:** a client that sent a stale field beside `*` drops it.
1303
+
1304
+ 3. **Expect a filter's empty member refused.** A trailing comma (`a.eq.x,`), two commas together, or an empty list member (`a.in.(x,,y)`, `a.nin.(,)`) was taken, the empty member dropped; now each is `Invalid filter syntax: empty member at position N`, N counted in the string the client sent. `in.()` stays the one empty list. **Fix:** a client that built a filter by joining with a trailing comma stops sending it.
1305
+
1306
+ 4. **Expect a deeply nested filter to be a 400, not a 500.** The parser recursed once per `or()` or `and()` group and checked `maxDepth` only on the finished tree, so a few thousand levels overflowed the stack and answered 500. It now refuses the first group past the cap where it opens, with `Filter nesting too deep at position N (max D)` in place of `Filter nesting too deep: 4 (max 3)`, and reads no further than 256 levels whatever `maxDepth` says: a `maxDepth` above 256 acts as 256. `parseFilter` returns `null` past that depth instead of throwing. **Fix:** a client or test that matched the old message matches the new one.
1307
+
1308
+ 5. **Expect every row once from a cursor over a column of mixed storage classes.** A key held apart, in an untyped or BLOB column holding TEXT that reads as a number beside numbers, was bound through `cast(? as real)` or `cast(? as integer)`, whose affinity made SQLite compare the TEXT `'10'` as the number 10: a cursor walk skipped rows ascending and looped descending. The bound value now carries no affinity. **Fix:** none.
1309
+
1310
+ 6. **Expect pages by number that 0.21 refused to serve.** Since 0.21 a list answered 500 (`sort_not_wired`) for any page holding a row no cursor can hold (a key too long for a cursor, an Invalid Date as read, a rounded integer, a JSON key, a held storage class), even a page asked for by number or a last page, which mint no cursor from it. A page served by cursor still checks every row it serves, as item 85 of 0.21 says. A page by number now serves its rows, and when the last row, the one its next cursor would be minted from, is one no cursor can hold, its `nextCursor` is `null` while `hasMore` still says there is a next page by number. **Fix:** none; a client that pages by number pages on, and one that follows `nextCursor` stops where it is `null`.
1311
+
1312
+ 7. **Expect the published `order` pattern to take what the parser takes.** The `order` pattern was written by hand beside the parser: it refused the empty `order` the parser reads as the default, and took as many entries as `maxSortKeys` even on a list with fewer fields, where an extra entry repeats a field the parser refuses. It is now built from the parser's own rule: it takes the empty value, takes no more entries than the list has sortable fields, and its description says each field may appear at most once and that an absent or empty `order` is the default. **Fix:** regenerate, so the published spec carries the new pattern; a contract test that sent `order=` expecting a 400 expects the default order.
1313
+
1314
+ 8. **Expect a D1 check to name a table called by a keyword as a name.** The proof read every bare word of a file as a possible name, so the `TABLE` keyword in every file put a table named `table` in scope, and each table's own `CREATE TABLE` made it a child of every other: a schema with such a table copied everything into every proof. A bare keyword SQLite never takes as a name no longer counts. **Fix:** none; such a check takes about as long as one on a schema without that table.
1315
+
1316
+ 9. **Expect check to refuse a file that leaves a view or trigger unable to run.** SQLite's `DROP TABLE` and `DROP VIEW` check none of the views and triggers that use what they drop, and the proof compiled only what a file made or changed, so a file dropping a view another view reads passed check and every later read of that view, or write to the trigger's table, failed. The proof now compiles, after each file, every surviving view and trigger that uses something the file dropped or changed, on bun:sqlite, libsql, Turso and D1, and refuses the file with no approval to override it, for example `0002_next.sql: after this file, view v2 cannot be read: no such table: main.v1; drop or change it in this file, or keep what it uses`. **Fix:** drop or change the dependent view or trigger in the same file, or keep what it uses.
1317
+
1318
+ 10. **Expect an `INSTEAD OF` trigger for some of a view's writes to pass a D1 check.** The D1 proof compiled an insert, an update and a delete on a view for any trigger on it, so a file adding an `INSTEAD OF` trigger for one or two of those events was refused with `cannot modify v because it is a view`, though D1 applies it and runs its writes. A view now gets only the writes its triggers fire on. **Fix:** none.
1319
+
1320
+ 11. **Expect a statement to keep a name that ends in a Unicode space.** The SQLite splitter trimmed each statement with JavaScript's `trim`, which strips U+00A0, U+3000 and the other spaces SQLite reads as part of a name, so comb applied a column `c` where SQLite makes `c<NBSP>`, and ran a statement SQLite refuses. It now trims SQLite's own whitespace alone, as the hash already did. **Fix:** none.
1321
+
1322
+ 12. **Expect a history drizzle made to hold each file once.** A migrations table drizzle's migrator made gains `name` with no unique index, so two applies at once could both record and run the same file: a seed or a backfill ran twice and both exited 0. comb now adds a unique index on `name` where none holds it, and refuses, naming the files, a history that already records one twice. **Fix:** if apply refuses a history for a duplicate, delete the extra row after checking the file's effect ran once.
1323
+
1324
+ 13. **Expect a Postgres check to leave another session's role and database alone.** Postgres records no creator for a role, a database or a replication slot, so the replay took any change to a name the file spelled for the file's own: a file of plain statements was refused when another session made a role or database it names during the replay, and the cleanup then dropped that session's role and database. A change to a name the file spells now counts only when the file's statements so far run code comb cannot read (a `DO` block, a `CALL`, an `EXECUTE`, or a call of a function outside `pg_catalog`), and a refused file's role, database or slot is left on devUrl's server and named in the refusal (`comb leaves role X added, database Y added, Z on devUrl's server, as another session may have made those changes: undo there each that is the file's`), where 0.21's item 66 dropped it. **Fix:** after such a refusal, drop on devUrl's server what the file made.
1325
+
1326
+ 14. **Expect `SIGHUP` and `SIGQUIT` to stop comb as `SIGTERM` does.** Atlas runs in a process group of its own, so a hangup from a closed terminal no longer reached it, while comb ended at once on `SIGHUP` or `SIGQUIT`, printing no `Stopped by` line and leaving Atlas to finish the file in flight. All four of `SIGHUP`, `SIGINT`, `SIGQUIT` and `SIGTERM` now take 0.21's stop path. **Fix:** none.
1327
+
1328
+ 15. **Read `stoppedBy` from `--json` when a signal stops apply.** Under `--json`, an apply a signal stopped printed no object, so a runner reading it recorded nothing applied. It now prints the one object it prints on any other end, with `applied` naming each file that committed, the signal in `stoppedBy`, and the `Stopped by` line in `refused`, for example `{"applied":["20260101000000_a.sql"],"database":"db","pending":["20260101000001_b.sql"],"refused":["Stopped by SIGTERM: applied 20260101000000_a.sql; not applied 20260101000001_b.sql"],"stoppedBy":"SIGTERM","unknown":[]}`, and still ends by the signal. The signal is any of `SIGHUP`, `SIGINT`, `SIGQUIT` and `SIGTERM` (step 14). On SQLite the `Stopped by` line now goes to stderr too. **Fix:** a runner reads `applied` from that object, and `stoppedBy` to tell a stop from a refusal.
1329
+
1330
+ 16. **Expect a path of the wrong kind refused by name.** `comb migrate diff <database> <name>` refuses a name holding `/` or `\`, or `.` or `..` alone, with its usage, where Atlas failed on a file it could not open in comb's scratch directory. `--tables`, or comb.config.ts `tables`, naming a directory is refused as `tables <path> is a directory, not a file`, where loading it failed with `EISDIR`. A d1 provider's `wranglerConfig` naming a directory is refused as `wranglerConfig <path> is a directory, not a file`, where comb said the config binds no such database. **Fix:** give a diff a name alone, point `tables` at the module that exports the tables, and point `wranglerConfig` at the wrangler.json, wrangler.jsonc or wrangler.toml itself.
1331
+
1332
+ 17. **Read `migrate diff --dry-run`'s exit 2 as a check that could not run.** A dry run exits 1 only for drift now; a failure that kept it from checking (Atlas or drizzle-kit missing or failing, an unreachable `devUrl`, a config or an argument comb refuses) exits 2, where 0.21 exited 1 for both. **Fix:** a gate that fails on any non-zero exit needs no change; one that acts on drift, by writing the migration or opening a pull request, acts on exit 1 alone.
1333
+
1269
1334
  ## Upgrading to 0.22
1270
1335
 
1271
1336
  What changes for a project on 0.21. Before deploying it:
@@ -178,7 +178,7 @@ no `anyOf` walk. A nullable trimmed text with a `max` of 9, as codegen publishes
178
178
  `maximum` are the standard's range, or a declared one inside it. A read of a text or an integer
179
179
  publishes none. The ids, each with its standard and release:
180
180
  - `base64url`: RFC 4648 §5 base64url, unpadded.
181
- - `bcp47`: BCP 47 language tag, RFC 5646 §2.1 well-formed.
181
+ - `bcp47`: BCP 47 language tag, RFC 5646 §2.1 well-formed. Predicate: `isBcp47`.
182
182
  - `e164`: ITU-T E.164 phone number, + and up to 15 digits.
183
183
  - `iana-time-zone`: IANA time zone name, tzdata 2026f. Registry: `isIanaTimeZone`.
184
184
  - `ip`: IPv4 dotted quad or IPv6 address, RFC 4291 §2.2.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workerstack/database",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Database framework for Cloudflare D1, PostgreSQL, and SQLite. Schema builders, error handling, D1 sessions, sharding, codegen, migrations.",
5
5
  "keywords": [
6
6
  "cloudflare-d1",
@@ -142,7 +142,8 @@
142
142
  "prepack": "rm -rf types && cp ../../README.md ../../LICENSE . && bun run types:emit",
143
143
  "postpack": "rm -rf types README.md LICENSE",
144
144
  "test": "vitest run",
145
- "test:bun": "bun test tests/bun",
145
+ "test:bun": "bun test tests/bun --path-ignore-patterns=tests/bun/differential-lists.test.ts",
146
+ "test:differential": "vitest run --config vitest.differential.config.ts && bun test tests/bun/differential-lists.test.ts",
146
147
  "test:coverage": "vitest run --coverage",
147
148
  "test:consumers": "vitest run --config vitest.consumers.config.ts",
148
149
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
package/src/cli/args.ts CHANGED
@@ -55,7 +55,8 @@ export const OPTIONS: readonly OptionSpec[] = [
55
55
  },
56
56
  {
57
57
  commands: ["migrate diff"],
58
- description: "Write nothing: exit 1 printing the migration a diff would write, 0 in sync",
58
+ description:
59
+ "Write nothing: exit 1 printing the migration a diff would write, 0 in sync, 2 when the check could not run",
59
60
  long: "--dry-run",
60
61
  },
61
62
  { commands: ["migrate apply"], description: "Apply this one migration file", long: "--file", value: "path" },
@@ -78,7 +79,8 @@ export const OPTIONS: readonly OptionSpec[] = [
78
79
  },
79
80
  {
80
81
  commands: ["migrate apply", "migrate check", "migrate status"],
81
- description: "Print one JSON object (database, applied, pending, refused, unknown) as the only stdout",
82
+ description:
83
+ "Print one JSON object (database, applied, pending, refused, unknown; stoppedBy when a signal stopped the run) as the only stdout",
82
84
  long: "--json",
83
85
  },
84
86
  ]
@@ -249,7 +249,7 @@ async function generate(
249
249
  /** The databases a codegen run covers: the one --tables names, or those comb.config.ts declares */
250
250
  async function databases(positionals: readonly string[], tables: string | undefined, io: CliIo): Promise<Database[]> {
251
251
  if (tables !== undefined) {
252
- const tablesPath = tablesFile(io.cwd, tables)
252
+ const tablesPath = tablesFile(io.cwd, tables, "--tables")
253
253
  return [{ db: { tables }, direct: true, exportNamespace: undefined, generators: DIRECT, name: "", tablesPath }]
254
254
  }
255
255
  const config = await loadCombConfig(io.cwd)
@@ -263,7 +263,7 @@ async function databases(positionals: readonly string[], tables: string | undefi
263
263
  exportNamespace: namespaces.get(name),
264
264
  generators: db.generators ?? GENERATORS,
265
265
  name,
266
- tablesPath: tablesFile(io.cwd, db.tables),
266
+ tablesPath: tablesFile(io.cwd, db.tables, "tables"),
267
267
  }))
268
268
  }
269
269
 
@@ -6,6 +6,7 @@ import * as z from "zod"
6
6
  import type { CodegenConfig, DatabaseCodegen } from "../codegen/config.ts"
7
7
  import type { FtsTableConfig } from "../codegen/generators/fts-sql.ts"
8
8
  import { codegenConfigSchema } from "../codegen/config-schema.ts"
9
+ import { refuseNotFile } from "./file-path.ts"
9
10
  import { CliError, type CliIo } from "./output.ts"
10
11
 
11
12
  export type { CodegenConfig, DatabaseCodegen }
@@ -58,10 +59,14 @@ export function databaseIn(config: CodegenConfig, name: string, hint?: string):
58
59
  )
59
60
  }
60
61
 
61
- /** The tables file `tables` names, resolved against `cwd`; a CliError when it is missing */
62
- export function tablesFile(cwd: string, tables: string): string {
62
+ /**
63
+ * The tables file `tables` names, resolved against `cwd`, as `what` gave it (--tables, or
64
+ * comb.config.ts `tables`); a CliError when it is missing or not a file
65
+ */
66
+ export function tablesFile(cwd: string, tables: string, what: "--tables" | "tables"): string {
63
67
  const resolved = path.resolve(cwd, tables)
64
68
  if (!fs.existsSync(resolved)) throw new CliError(`Tables file not found: ${resolved}`)
69
+ refuseNotFile(resolved, what, "point it at the module that exports the tables")
65
70
  return resolved
66
71
  }
67
72
 
@@ -85,7 +90,7 @@ export async function declaredFts(
85
90
 
86
91
  /** The FTS5 DDL `declared` asks for, for the migration diff to keep apart from what Atlas sees */
87
92
  export async function ftsSql(cwd: string, declared: DeclaredFts, io: CliIo): Promise<string> {
88
- const resolvedTables = tablesFile(cwd, declared.tables)
93
+ const resolvedTables = tablesFile(cwd, declared.tables, "tables")
89
94
  const { analyze } = await import("../codegen/analyzer.ts")
90
95
  const { generateFtsSql } = await import("../codegen/generators/fts-sql.ts")
91
96
  return generateFtsSql((await analyze(resolvedTables, { cwd })).tables, declared.fts, io.err)
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The CLI's checks of a path or a name a command or a config gives before it reaches the
3
+ * filesystem, so a wrong kind is refused by name rather than as the error of whatever reads it.
4
+ */
5
+ import fs from "node:fs"
6
+ import { CliError } from "./output.ts"
7
+
8
+ /**
9
+ * Refuse `file`, which `what` names (an option or a config field), when it exists as other than a
10
+ * file, saying what it is, then `fix`; a missing one is left to the caller, which refuses it its own way
11
+ */
12
+ export function refuseNotFile(file: string, what: string, fix: string): void {
13
+ const stat = fs.statSync(file, { throwIfNoEntry: false })
14
+ if (stat === undefined || stat.isFile()) return
15
+ throw new CliError(`${what} ${file} ${stat.isDirectory() ? "is a directory, not a file" : "is not a file"}: ${fix}`)
16
+ }
17
+
18
+ /**
19
+ * Refuse a name a file is made from, `what` naming it, when it would name a path: one holding a
20
+ * separator, or `.` or `..` alone; then `usage`
21
+ */
22
+ export function refusePathName(name: string, what: string, usage: string): void {
23
+ if (!/[/\\]/.test(name) && name !== "." && name !== "..") return
24
+ throw new CliError(`${what} "${name}" names a path: give a name alone, such as add_users\n${usage}`)
25
+ }
@@ -33,8 +33,8 @@ import type { Connection } from "../migrate/types.ts"
33
33
  import { parseArgs, type ParsedArgs } from "./args.ts"
34
34
  import { fileName, openSqliteTarget, type SqliteTarget } from "./migrate-sqlite-target.ts"
35
35
  import { resolveMigrateTarget } from "./migrate-target.ts"
36
- import { Refused } from "../migrate/core/refused.ts"
37
- import { checkPassed, CliError, type CliContext, type MigrateOutcome, Reported } from "./output.ts"
36
+ import { asJson } from "./migrate-json.ts"
37
+ import { checkPassed, CliError, type CliContext, type MigrateOutcome } from "./output.ts"
38
38
 
39
39
  type Mode = "apply" | "baseline" | "check" | "status"
40
40
 
@@ -220,11 +220,6 @@ async function migrateSqlite({ args, io, mode }: Request, target: Target): Promi
220
220
  return { applied: pendingNames, pending: [] }
221
221
  }
222
222
 
223
- /**
224
- * `migrate apply|baseline|check|status`. With --json the human lines go to stderr and stdout is
225
- * one JSON object: the database, the files applied (or found applied) and pending, what was
226
- * refused, and the files whose answer was lost, which may have committed; a refusal still exits 1.
227
- */
228
223
  /** `mode`'s argv read: its database and options; refused by usage without one database */
229
224
  function readArgv(mode: Mode, argv: readonly string[]): { args: ParsedArgs; db: string } {
230
225
  const args = parseArgs(argv, `migrate ${mode}`)
@@ -232,22 +227,11 @@ function readArgv(mode: Mode, argv: readonly string[]): { args: ParsedArgs; db:
232
227
  return { args, db: args.positionals[0] as string }
233
228
  }
234
229
 
230
+ /** `migrate apply|baseline|check|status`, with --json as one JSON object (migrate-json.ts) */
235
231
  export async function migrateApply(mode: Mode, argv: readonly string[], io: CliContext): Promise<void> {
236
232
  const { args, db } = readArgv(mode, argv)
237
- if (args.options["json"] !== true) {
238
- await runMode({ args, db, io, mode })
239
- return
240
- }
241
- const report = (outcome: MigrateOutcome, refused: string[], unknown: string[] = []) =>
242
- io.out(JSON.stringify({ applied: outcome.applied, database: db, pending: outcome.pending, refused, unknown }))
243
- try {
244
- report(await runMode({ args, db, io: { ...io, out: io.err }, mode }), [])
245
- } catch (error) {
246
- /* a refusal or a failed command is an Error; a user's config can throw anything */
247
- const { applied, pending, unknown } = error instanceof Refused ? error : { applied: [], pending: [], unknown: [] }
248
- report({ applied, pending }, [error instanceof Error ? error.message : String(error)], unknown)
249
- throw new Reported(error)
250
- }
233
+ if (args.options["json"] === true) await asJson(db, io, (each) => runMode({ args, db, io: each, mode }))
234
+ else await runMode({ args, db, io, mode })
251
235
  }
252
236
 
253
237
  /**
@@ -14,6 +14,7 @@ import { connectD1, wranglerFlags } from "../migrate/drivers/d1.ts"
14
14
  import type { D1ProviderConfig } from "../migrate/types.ts"
15
15
  import type { ParsedArgs } from "./args.ts"
16
16
  import { type MigrateTarget, refuseNoDir } from "./migrate-target.ts"
17
+ import { refuseNotFile } from "./file-path.ts"
17
18
  import { checkPassed, CliError, type CliContext, type MigrateOutcome } from "./output.ts"
18
19
 
19
20
  type D1Mode = "apply" | "baseline" | "check" | "status"
@@ -27,7 +28,10 @@ function d1Config(request: D1Request, resolved: MigrateTarget | undefined): { co
27
28
  if (resolved?.provider.type === "d1") {
28
29
  if (args.options["local"] !== undefined)
29
30
  throw new CliError("--local applies without comb.migrate.config.ts; set local: true on its d1 provider")
30
- const wranglerConfig = resolved.provider.wranglerConfig ?? findWranglerConfig(resolved.configDir)
31
+ const configured = resolved.provider.wranglerConfig
32
+ const fix = `point ${db}'s d1 provider at its wrangler.json, wrangler.jsonc or wrangler.toml`
33
+ if (configured !== undefined) refuseNotFile(configured, "wranglerConfig", fix)
34
+ const wranglerConfig = configured ?? findWranglerConfig(resolved.configDir)
31
35
  if (wranglerConfig === undefined)
32
36
  throw new CliError(`no wrangler config next to comb.migrate.config.ts; set wranglerConfig on ${db}'s d1 provider`)
33
37
  return { config: { ...resolved.provider, wranglerConfig }, dir: resolved.migrationsDir }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `--json` for `migrate apply|baseline|check|status`: the human lines go to stderr and stdout is one
3
+ * JSON object, the database, the files applied (or found applied) and pending, what was refused, and
4
+ * the files whose answer was lost, which may have committed; a refusal still exits 1. A run a signal
5
+ * stopped (SIGHUP, SIGINT, SIGQUIT, SIGTERM) prints that object too, once the loop that applies files
6
+ * has said what committed (stopped.ts), with `stoppedBy` naming the signal, and then ends by the
7
+ * signal. A stop parks the run itself, so that object comes from the step held here.
8
+ */
9
+ import { hold } from "../migrate/core/cleanup.ts"
10
+ import { Refused } from "../migrate/core/refused.ts"
11
+ import { Stopped } from "../migrate/core/stopped.ts"
12
+ import { type CliContext, type MigrateOutcome, Reported } from "./output.ts"
13
+
14
+ /** One end of a run, as the object prints it */
15
+ type End = MigrateOutcome & { refused: string[]; stoppedBy?: NodeJS.Signals | undefined; unknown: string[] }
16
+
17
+ /** `run` on `db`, its human lines on stderr and its one JSON object on `io.out` */
18
+ export async function asJson(
19
+ db: string,
20
+ io: CliContext,
21
+ run: (io: CliContext) => Promise<MigrateOutcome>,
22
+ ): Promise<void> {
23
+ /* once: a run that fails as a signal stops it must not print a second object */
24
+ let printed = false
25
+ const report = ({ applied, pending, refused, stoppedBy, unknown }: End) => {
26
+ if (printed) return
27
+ printed = true
28
+ io.out(JSON.stringify({ applied, database: db, pending, refused, ...(stoppedBy && { stoppedBy }), unknown }))
29
+ }
30
+ /* held before the run, so it runs after every step the run holds: they have said what committed by then */
31
+ const release = hold((signal, earlier) => {
32
+ const found = earlier.find((each) => each instanceof Stopped)
33
+ /* a stop before any file applied: nothing committed, and no loop has a report to give */
34
+ const stopped = found ?? new Stopped(signal, `Stopped by ${signal}`, { pending: [] })
35
+ if (found === undefined) io.err(stopped.message)
36
+ report({ ...stopped, refused: [stopped.message], stoppedBy: signal, unknown: stopped.unknown })
37
+ })
38
+ try {
39
+ report({ ...(await run({ ...io, out: io.err })), refused: [], unknown: [] })
40
+ } catch (error) {
41
+ /* a refusal or a failed command is an Error; a user's config can throw anything */
42
+ const { applied, pending, unknown } = error instanceof Refused ? error : { applied: [], pending: [], unknown: [] }
43
+ report({ applied, pending, refused: [error instanceof Error ? error.message : String(error)], unknown })
44
+ throw new Reported(error)
45
+ } finally {
46
+ release()
47
+ }
48
+ }