@workerstack/database 0.21.0 → 0.22.1

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 (192) hide show
  1. package/README.md +243 -40
  2. package/docs/meta-contract.md +55 -1
  3. package/package.json +3 -7
  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/codegen/analyzer-types.ts +6 -1
  13. package/src/codegen/annotations.ts +66 -9
  14. package/src/codegen/format-constraints.ts +114 -0
  15. package/src/codegen/generators/dtos/entity-file.ts +2 -1
  16. package/src/codegen/generators/dtos/example-meta.ts +22 -2
  17. package/src/codegen/generators/dtos/field-schema.ts +31 -8
  18. package/src/codegen/generators/dtos/ip-patterns.ts +25 -11
  19. package/src/codegen/generators/dtos/number-range.ts +22 -3
  20. package/src/codegen/generators/dtos/schema-aliases.ts +5 -3
  21. package/src/codegen/generators/dtos/text-format.ts +63 -0
  22. package/src/codegen/generators/dtos/text-schema.ts +49 -2
  23. package/src/codegen/generators/manifest.ts +9 -1
  24. package/src/codegen/runtime/read-comb.ts +71 -3
  25. package/src/codegen/runtime/read-tables.ts +31 -19
  26. package/src/codegen/runtime/snapshot-types.ts +3 -1
  27. package/src/codegen/runtime/to-meta.ts +6 -3
  28. package/src/formats/catalog.ts +255 -0
  29. package/src/formats/iana-time-zone.ts +117 -0
  30. package/src/formats/iso3166-1.ts +78 -0
  31. package/src/formats/iso4217.ts +42 -0
  32. package/src/formats/iso639-1.ts +41 -0
  33. package/src/formats/uts35.ts +143 -0
  34. package/src/index.ts +5 -0
  35. package/src/meta/column.ts +22 -9
  36. package/src/migrate/core/apply.ts +63 -22
  37. package/src/migrate/core/atlas-apply-runs.ts +15 -26
  38. package/src/migrate/core/cleanup.ts +22 -10
  39. package/src/migrate/core/d1-engine-child.mjs +27 -11
  40. package/src/migrate/core/d1-history.ts +23 -26
  41. package/src/migrate/core/d1-wrangler-error.ts +24 -1
  42. package/src/migrate/core/pg-replay-commit.ts +41 -38
  43. package/src/migrate/core/pg-replay-drops.ts +17 -32
  44. package/src/migrate/core/pg-replay-hidden.ts +75 -0
  45. package/src/migrate/core/pg-replay-roles.ts +2 -4
  46. package/src/migrate/core/pg-replay.ts +21 -19
  47. package/src/migrate/core/pg-server-snapshot.ts +4 -24
  48. package/src/migrate/core/proof-readers.ts +80 -0
  49. package/src/migrate/core/proof-run.ts +19 -5
  50. package/src/migrate/core/proof-scope.ts +27 -14
  51. package/src/migrate/core/sql-code.ts +36 -5
  52. package/src/migrate/core/sql-scan.ts +7 -1
  53. package/src/migrate/core/sqlite-keywords.ts +70 -0
  54. package/src/migrate/core/stopped.ts +70 -0
  55. package/src/migrate/core/trigger-write.d.mts +7 -0
  56. package/src/migrate/core/trigger-write.mjs +42 -0
  57. package/src/pg/columns.ts +6 -4
  58. package/src/query/cursor-mint.ts +11 -18
  59. package/src/query/cursor-refusals.ts +22 -11
  60. package/src/query/descriptions.ts +4 -9
  61. package/src/query/filter-parse.ts +80 -22
  62. package/src/query/filter-validate.ts +10 -13
  63. package/src/query/filter.ts +40 -18
  64. package/src/query/list-extras.ts +7 -10
  65. package/src/query/list-schema.ts +0 -2
  66. package/src/query/merged.ts +3 -2
  67. package/src/query/pagination.ts +30 -6
  68. package/src/query/rounded-key.ts +5 -8
  69. package/src/query/schema-params.ts +12 -5
  70. package/src/query/select-validate.ts +7 -4
  71. package/src/query/sqlite/cursor-fit.ts +10 -2
  72. package/src/query/trusted.ts +7 -2
  73. package/src/sqlite/columns.ts +6 -4
  74. package/src/sqlite/constraints.ts +120 -6
  75. package/types/cli/args.d.ts.map +1 -1
  76. package/types/cli/comb-config.d.ts +5 -2
  77. package/types/cli/comb-config.d.ts.map +1 -1
  78. package/types/cli/file-path.d.ts +11 -0
  79. package/types/cli/file-path.d.ts.map +1 -0
  80. package/types/cli/migrate-apply.d.ts +1 -0
  81. package/types/cli/migrate-apply.d.ts.map +1 -1
  82. package/types/cli/migrate-d1.d.ts.map +1 -1
  83. package/types/cli/migrate-json.d.ts +4 -0
  84. package/types/cli/migrate-json.d.ts.map +1 -0
  85. package/types/cli/migrate.d.ts.map +1 -1
  86. package/types/codegen/analyzer-types.d.ts +6 -1
  87. package/types/codegen/analyzer-types.d.ts.map +1 -1
  88. package/types/codegen/annotations.d.ts +12 -7
  89. package/types/codegen/annotations.d.ts.map +1 -1
  90. package/types/codegen/format-constraints.d.ts +22 -0
  91. package/types/codegen/format-constraints.d.ts.map +1 -0
  92. package/types/codegen/generators/dtos/entity-file.d.ts.map +1 -1
  93. package/types/codegen/generators/dtos/example-meta.d.ts.map +1 -1
  94. package/types/codegen/generators/dtos/field-schema.d.ts.map +1 -1
  95. package/types/codegen/generators/dtos/ip-patterns.d.ts +7 -0
  96. package/types/codegen/generators/dtos/ip-patterns.d.ts.map +1 -1
  97. package/types/codegen/generators/dtos/number-range.d.ts +8 -0
  98. package/types/codegen/generators/dtos/number-range.d.ts.map +1 -1
  99. package/types/codegen/generators/dtos/schema-aliases.d.ts +1 -1
  100. package/types/codegen/generators/dtos/schema-aliases.d.ts.map +1 -1
  101. package/types/codegen/generators/dtos/text-format.d.ts +31 -0
  102. package/types/codegen/generators/dtos/text-format.d.ts.map +1 -0
  103. package/types/codegen/generators/dtos/text-schema.d.ts +8 -1
  104. package/types/codegen/generators/dtos/text-schema.d.ts.map +1 -1
  105. package/types/codegen/generators/manifest.d.ts +5 -0
  106. package/types/codegen/generators/manifest.d.ts.map +1 -1
  107. package/types/codegen/runtime/read-comb.d.ts +17 -3
  108. package/types/codegen/runtime/read-comb.d.ts.map +1 -1
  109. package/types/codegen/runtime/read-tables.d.ts.map +1 -1
  110. package/types/codegen/runtime/snapshot-types.d.ts +3 -1
  111. package/types/codegen/runtime/snapshot-types.d.ts.map +1 -1
  112. package/types/codegen/runtime/to-meta.d.ts.map +1 -1
  113. package/types/formats/catalog.d.ts +51 -0
  114. package/types/formats/catalog.d.ts.map +1 -0
  115. package/types/formats/iana-time-zone.d.ts +7 -0
  116. package/types/formats/iana-time-zone.d.ts.map +1 -0
  117. package/types/formats/iso3166-1.d.ts +12 -0
  118. package/types/formats/iso3166-1.d.ts.map +1 -0
  119. package/types/formats/iso4217.d.ts +7 -0
  120. package/types/formats/iso4217.d.ts.map +1 -0
  121. package/types/formats/iso639-1.d.ts +6 -0
  122. package/types/formats/iso639-1.d.ts.map +1 -0
  123. package/types/formats/uts35.d.ts +13 -0
  124. package/types/formats/uts35.d.ts.map +1 -0
  125. package/types/index.d.ts +5 -0
  126. package/types/index.d.ts.map +1 -1
  127. package/types/meta/column.d.ts +7 -1
  128. package/types/meta/column.d.ts.map +1 -1
  129. package/types/migrate/core/apply.d.ts +3 -2
  130. package/types/migrate/core/apply.d.ts.map +1 -1
  131. package/types/migrate/core/atlas-apply-runs.d.ts +6 -0
  132. package/types/migrate/core/atlas-apply-runs.d.ts.map +1 -1
  133. package/types/migrate/core/cleanup.d.ts +16 -4
  134. package/types/migrate/core/cleanup.d.ts.map +1 -1
  135. package/types/migrate/core/d1-history.d.ts.map +1 -1
  136. package/types/migrate/core/d1-wrangler-error.d.ts +11 -0
  137. package/types/migrate/core/d1-wrangler-error.d.ts.map +1 -1
  138. package/types/migrate/core/pg-replay-commit.d.ts +14 -9
  139. package/types/migrate/core/pg-replay-commit.d.ts.map +1 -1
  140. package/types/migrate/core/pg-replay-drops.d.ts +7 -12
  141. package/types/migrate/core/pg-replay-drops.d.ts.map +1 -1
  142. package/types/migrate/core/pg-replay-hidden.d.ts +36 -0
  143. package/types/migrate/core/pg-replay-hidden.d.ts.map +1 -0
  144. package/types/migrate/core/pg-replay-roles.d.ts +2 -4
  145. package/types/migrate/core/pg-replay-roles.d.ts.map +1 -1
  146. package/types/migrate/core/pg-replay.d.ts.map +1 -1
  147. package/types/migrate/core/pg-server-snapshot.d.ts +4 -11
  148. package/types/migrate/core/pg-server-snapshot.d.ts.map +1 -1
  149. package/types/migrate/core/proof-readers.d.ts +19 -0
  150. package/types/migrate/core/proof-readers.d.ts.map +1 -0
  151. package/types/migrate/core/proof-run.d.ts.map +1 -1
  152. package/types/migrate/core/proof-scope.d.ts +15 -7
  153. package/types/migrate/core/proof-scope.d.ts.map +1 -1
  154. package/types/migrate/core/sql-code.d.ts +7 -3
  155. package/types/migrate/core/sql-code.d.ts.map +1 -1
  156. package/types/migrate/core/sql-scan.d.ts.map +1 -1
  157. package/types/migrate/core/sqlite-keywords.d.ts +11 -0
  158. package/types/migrate/core/sqlite-keywords.d.ts.map +1 -0
  159. package/types/migrate/core/stopped.d.ts +23 -0
  160. package/types/migrate/core/stopped.d.ts.map +1 -0
  161. package/types/pg/columns.d.ts +3 -3
  162. package/types/pg/columns.d.ts.map +1 -1
  163. package/types/query/cursor-mint.d.ts.map +1 -1
  164. package/types/query/cursor-refusals.d.ts +17 -1
  165. package/types/query/cursor-refusals.d.ts.map +1 -1
  166. package/types/query/descriptions.d.ts +1 -1
  167. package/types/query/descriptions.d.ts.map +1 -1
  168. package/types/query/filter-parse.d.ts +15 -4
  169. package/types/query/filter-parse.d.ts.map +1 -1
  170. package/types/query/filter-validate.d.ts +1 -1
  171. package/types/query/filter-validate.d.ts.map +1 -1
  172. package/types/query/filter.d.ts +9 -1
  173. package/types/query/filter.d.ts.map +1 -1
  174. package/types/query/list-extras.d.ts.map +1 -1
  175. package/types/query/list-schema.d.ts.map +1 -1
  176. package/types/query/merged.d.ts.map +1 -1
  177. package/types/query/pagination.d.ts +13 -1
  178. package/types/query/pagination.d.ts.map +1 -1
  179. package/types/query/rounded-key.d.ts +2 -11
  180. package/types/query/rounded-key.d.ts.map +1 -1
  181. package/types/query/schema-params.d.ts.map +1 -1
  182. package/types/query/select-validate.d.ts.map +1 -1
  183. package/types/query/sqlite/cursor-fit.d.ts.map +1 -1
  184. package/types/query/trusted.d.ts +1 -1
  185. package/types/query/trusted.d.ts.map +1 -1
  186. package/types/sqlite/columns.d.ts +3 -3
  187. package/types/sqlite/columns.d.ts.map +1 -1
  188. package/types/sqlite/constraints.d.ts +117 -5
  189. package/types/sqlite/constraints.d.ts.map +1 -1
  190. package/src/sqlite/patterns.ts +0 -19
  191. package/types/sqlite/patterns.d.ts +0 -19
  192. package/types/sqlite/patterns.d.ts.map +0 -1
package/README.md CHANGED
@@ -4,6 +4,8 @@ Database framework for Cloudflare D1, PostgreSQL, and SQLite. Dialect-agnostic c
4
4
 
5
5
  This repo is the source of the [`@workerstack/database`](https://www.npmjs.com/package/@workerstack/database) npm package. The CLI binary is `comb`.
6
6
 
7
+ Moved from `@lovrozagar/comb`: `@workerstack/database@0.21.0` is the same release as `@lovrozagar/comb@0.21.0` under the new name, and the old package is deprecated. To switch, follow step 3 of [Upgrading to 0.22](#upgrading-to-022).
8
+
7
9
  This README is the full usage manual. An agent that reads only this file should be able to define tables, generate artifacts, run migrations, and serve list queries without opening source.
8
10
 
9
11
  ## Table of contents
@@ -18,7 +20,8 @@ This README is the full usage manual. An agent that reads only this file should
18
20
  - [SQLite factories](#sqlite-factories)
19
21
  - [PostgreSQL factories](#postgresql-factories)
20
22
  - [Constraints](#constraints)
21
- - [Patterns](#patterns)
23
+ - [Formats](#formats)
24
+ - [Column schemas](#column-schemas)
22
25
  - [Errors](#errors)
23
26
  - [`CombError`](#comberror)
24
27
  - [Constraint maps](#constraint-maps)
@@ -49,6 +52,8 @@ This README is the full usage manual. An agent that reads only this file should
49
52
  - [Package exports](#package-exports)
50
53
  - [Repository layout](#repository-layout)
51
54
  - [Develop](#develop)
55
+ - [Upgrading to 0.22.1](#upgrading-to-0221)
56
+ - [Upgrading to 0.22](#upgrading-to-022)
52
57
  - [Upgrading to 0.21](#upgrading-to-021)
53
58
  - [Upgrading to 0.20.1](#upgrading-to-0201)
54
59
  - [Upgrading to 0.20](#upgrading-to-020)
@@ -150,7 +155,6 @@ Define a table with Comb's column factories instead of Drizzle's raw ones.
150
155
  /* src/db/core.tables.ts */
151
156
  import { sqliteTable, index } from "drizzle-orm/sqlite-core"
152
157
  import { c } from "@workerstack/database/sqlite/columns"
153
- import { patterns } from "@workerstack/database/sqlite/patterns"
154
158
  import * as z from "zod"
155
159
 
156
160
  /* a c.json schema is exported, so the generated DTOs can import it, and is the stored document:
@@ -163,7 +167,7 @@ export const author = sqliteTable(
163
167
  id: c.id("aut"),
164
168
  email: c.text("email", { max: 254, email: true, lowercase: true, trim: true }),
165
169
  display_name: c.text("display_name", { min: 2, max: 64, trim: true }),
166
- locale: c.text("locale", { pattern: patterns.LOCALE }),
170
+ locale: c.text("locale", { format: "bcp47" }),
167
171
  settings: c.json("settings", authorSettings),
168
172
  created_at: c.createdAt("created_at"),
169
173
  updated_at: c.updatedAt("updated_at"),
@@ -183,7 +187,7 @@ export const post = sqliteTable("post", {
183
187
  })
184
188
  ```
185
189
 
186
- The second argument to each factory is a **constraint object**. At runtime most of it is inert — `c.text("title", { max: 200 })` just becomes `text("title", { length: 200 })` — and the helper records it on the column for codegen, which turns `{ min: 2, max: 64, trim: true }` into a Zod schema, a CHECK constraint, and an OpenAPI description. Constraints live next to the column, once. Codegen loads the tables module, so a constraint value is whatever the module computes: a constant (`pattern: patterns.E164_PHONE`, `max: NAME_MAX`), an enum member, an object spread over another, a constraint object passed by name, or an enum list built by a call. Each value must be a boolean, a number or a string; `pattern` is a `/source/flags` string.
190
+ The second argument to each factory is a **constraint object**. At runtime most of it is inert — `c.text("title", { max: 200 })` just becomes `text("title", { length: 200 })` — and the helper records it on the column for codegen, which turns `{ min: 2, max: 64, trim: true }` into a Zod schema, a CHECK constraint, and an OpenAPI description. Constraints live next to the column, once. Codegen loads the tables module, so a constraint value is whatever the module computes: a constant (`pattern: SKU_PATTERN`, `max: NAME_MAX`), an enum member, an object spread over another, a constraint object passed by name, or an enum list built by a call. Each value must be a boolean, a number or a string; `pattern` is a `/source/flags` string, and `schema`, the one value of another kind, an exported zod schema ([Column schemas](#column-schemas)).
187
191
 
188
192
  Point a config at the file:
189
193
 
@@ -230,12 +234,12 @@ OPTIONS:
230
234
  -e, --env <name> Environment from comb.migrate.config.ts [migrate apply, migrate baseline, migrate check, migrate diff, migrate status]
231
235
  --dir <path> Migrations directory (default: the config's, else ./atlas-migrations) [migrate apply, migrate baseline, migrate check, migrate status]
232
236
  --dialect <name> The project's dialect: sqlite (default) or postgres [migrate init]
233
- --dry-run Write nothing: exit 1 printing the migration a diff would write, 0 in sync [migrate diff]
237
+ --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]
234
238
  --file <path> Apply this one migration file [migrate apply]
235
239
  -y, --yes Approve cautious changes without asking (not unsafe ones) [migrate apply, migrate check]
236
240
  --local Without a migrate config: the local D1 (wrangler --local) [migrate apply, migrate baseline, migrate check, migrate status]
237
241
  --hashes On D1: record the current hash of each applied file that has none (check the files against git first) [migrate baseline]
238
- --json Print one JSON object (database, applied, pending, refused, unknown) as the only stdout [migrate apply, migrate check, migrate status]
242
+ --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]
239
243
  -h, --help Show this help
240
244
  --version Print comb's version
241
245
  ```
@@ -283,7 +287,7 @@ Config mode reads `comb.config.ts` (or `.js`) from the working directory. Direct
283
287
 
284
288
  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".
285
289
 
286
- `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.
290
+ `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.
287
291
 
288
292
  ## Columns
289
293
 
@@ -322,6 +326,8 @@ The constraint object is shared by both dialects. `@workerstack/database/sqlite/
322
326
  | `min` / `max` | text, integer, real | length or numeric bounds |
323
327
  | `maxBytes` | json | UTF-8 byte bound on the serialized value, on writes |
324
328
  | `pattern` | text | regex, as a source-level string |
329
+ | `format` | text, enum, integer | a standard by its id: a text's pattern, case, length and registry, an enum's values checked at codegen, an integer's range ([Formats](#formats)) |
330
+ | `schema` | text, integer, real | an exported zod schema the write DTOs pipe the value into after comb's own rules ([Column schemas](#column-schemas)) |
325
331
  | `email` / `url` | text | an email checked by zod's email pattern, which publishes; a url by the WHATWG URL parser, https only, its https pattern published; neither publishes a `format` |
326
332
  | `trim`, `lowercase`, `uppercase` | text | transforms applied in generated schemas |
327
333
  | `password` | text | a stored password hash: kept as written, held to its declared bounds and pattern alone; `passwordText()` holds the plaintext |
@@ -350,19 +356,155 @@ const signIn = z.object({ email: z.email(), password: passwordText.typed() })
350
356
 
351
357
  `passwordText()` sets a password: any storable text of 15 to 128 characters, counted by code point, with no composition rule and nothing trimmed. A password used only beside a second factor may take `passwordText({ min: 8 })`, as rev 4 allows; `min` and `max` are yours to set. `passwordText.typed()` reads one typed to sign in and holds only the max, so a password set under an older or shorter minimum still signs in. Both normalize the value to NFC before they count it, and give the NFC string, so a password typed in another normalization hashes to the same value; an app that adopts it over hashes made from input it never normalized rehashes each one on its next successful sign-in. Under `{ hash: "bcrypt" }` both refuse past 72 UTF-8 bytes, which bcrypt reads no further than, and publish `x-comb-maxUtf8Bytes: 72`, the string's own bytes (`x-comb-maxBytes` is a JSON text's); `passwordText({ hash: "bcrypt", min })` with a `min` past 72 throws, as no password would pass it. The JSON Schema states the rewrite as `x-comb-transform: ["nfc"]` and the bounds as `minLength` and `maxLength`, which count the NFC form's code points, and a password out of bounds is zod's own `too_small` or `too_big` issue with its `minimum` or `maximum`, bcrypt's bytes included.
352
358
 
353
- ### Patterns
359
+ ### Formats
360
+
361
+ `format` holds a column to a published standard, named by its id: a `c.text` value's syntax, case and length, and its membership where the standard is a registry; a `c.enum`'s listed values, at codegen; a `c.integer`'s range. Like every constraint it shapes the generated DTOs alone, with no CHECK and no change to the column's type, the same on SQLite, D1 and Postgres, and a column stays nullable unless `.notNull()`: a null passes, a value is checked.
362
+
363
+ ```ts
364
+ import { sqliteTable } from "drizzle-orm/sqlite-core"
365
+ import { c } from "@workerstack/database/sqlite/columns"
366
+
367
+ export const users = sqliteTable("users", {
368
+ id: c.id("usr"),
369
+ currency: c.text("currency", { format: "iso4217" }),
370
+ time_zone: c.text("time_zone", { format: "iana-time-zone" }),
371
+ locale: c.text("locale", { format: "bcp47" }),
372
+ country: c.text("country", { format: "iso3166-1-alpha-2" }),
373
+ pay_currency: c.enum("pay_currency", ["USD", "EUR", "GBP"], { format: "iso4217" }),
374
+ hour_cycle: c.enum("hour_cycle", ["h12", "h23"], { format: "uts35-hc" }),
375
+ week_start: c.integer("week_start", { format: "iso8601-weekday" }), // 1 Monday … 7 Sunday
376
+ })
377
+ ```
378
+
379
+ A text format trims the value, then writes it in its standard's case, before it checks it. The table names each text format's standard and release, the case a written value is put in, and the registry it must also belong to; an editor shows the same list on the `format` key.
380
+
381
+ | Format | Standard | Case | Registry |
382
+ | --------------------- | ---------------------------------------------------------------------- | ---------- | ----------------- |
383
+ | `base64url` | RFC 4648 §5 base64url, unpadded | kept | |
384
+ | `bcp47` | BCP 47 language tag, RFC 5646 §2.1 well-formed | kept | |
385
+ | `e164` | ITU-T E.164 phone number, + and up to 15 digits | kept | |
386
+ | `iana-time-zone` | IANA time zone name, tzdata 2026f | kept | `isIanaTimeZone` |
387
+ | `ip` | IPv4 dotted quad or IPv6 address, RFC 4291 §2.2 | lowercased | |
388
+ | `ipv4` | IPv4 address, dotted quad without leading zeros | kept | |
389
+ | `ipv6` | IPv6 address, RFC 4291 §2.2 | lowercased | |
390
+ | `iso3166-1-alpha-2` | ISO 3166-1 alpha-2 country code, Debian iso-codes v4.20.1 | uppercased | `isIso3166Alpha2` |
391
+ | `iso3166-1-alpha-3` | ISO 3166-1 alpha-3 country code, Debian iso-codes v4.20.1 | uppercased | `isIso3166Alpha3` |
392
+ | `iso4217` | ISO 4217 alphabetic currency code, SIX list 2026-09-17 | uppercased | `isIso4217` |
393
+ | `iso639-1` | ISO 639-1 language code, Debian iso-codes v4.20.1 | lowercased | `isIso639Alpha2` |
394
+ | `kebab-slug` | kebab-case slug, lowercase letters and digits joined by single hyphens | kept | |
395
+ | `pkce-s256-challenge` | RFC 7636 §4.2 PKCE SHA-256 code challenge | kept | |
396
+ | `rfc1123-hostname` | RFC 1123 §2.1 host name | lowercased | |
397
+ | `semver` | Semantic Versioning 2.0.0 version | kept | |
398
+ | `sha256-hex` | SHA-256 digest, FIPS 180-4, lowercase hex | lowercased | |
399
+ | `sha512-hex` | SHA-512 digest, FIPS 180-4, lowercase hex | lowercased | |
400
+ | `srgb-hex` | sRGB color as #rrggbb, CSS Color 4 | lowercased | |
401
+ | `srgb-hex-alpha` | sRGB color with alpha as #rrggbbaa, CSS Color 4 | lowercased | |
402
+ | `ulid` | ULID, Crockford base32 in lowercase | lowercased | |
403
+ | `uts35-ca` | UTS #35 calendar (ca) key value, CLDR 49 | lowercased | |
404
+ | `uts35-fw` | UTS #35 first day of the week (fw) key value, CLDR 49 | lowercased | |
405
+ | `uts35-hc` | UTS #35 hour cycle (hc) key value, CLDR 49 | lowercased | |
406
+ | `uts35-ms` | UTS #35 measurement system (ms) key value, CLDR 49 | lowercased | |
407
+ | `uts35-mu` | UTS #35 measurement unit (mu) key value, CLDR 49 | lowercased | |
408
+ | `uts35-nu` | UTS #35 numbering system (nu) key value, CLDR 49 | lowercased | |
409
+ | `uuid` | RFC 9562 UUID, lowercase hex | lowercased | |
410
+ | `uuid-v4` | RFC 9562 UUID version 4, lowercase hex | lowercased | |
411
+ | `uuid-v7` | RFC 9562 UUID version 7, lowercase hex | lowercased | |
412
+
413
+ 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, …`).
414
+
415
+ 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
+
417
+ ```ts
418
+ import { isIanaTimeZone, isIso3166Alpha2, isIso3166Alpha3, isIso4217, isIso639Alpha2 } from "@workerstack/database"
419
+
420
+ isIso4217("EUR") // true
421
+ isIso4217("HRK") // false: withdrawn in 2023
422
+ isIso4217("eur") // false: the DTO uppercases a value first, the predicate does not
423
+ isIso3166Alpha2("UK") // false: reserved, not assigned; GB is the code
424
+ ```
425
+
426
+ 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.
427
+
428
+ On a `c.enum` the listed values stay the rule, and `format` says what they mean: codegen refuses a value the format does not take as written (`users.pay_currency: "usd" is no iso4217 value; write the standard's form, "USD"`, `users.pay_currency: "HRK" is no iso4217 value`), and every DTO of the column publishes its `enum` with `x-comb-format`.
429
+
430
+ `c.integer` takes the two integer formats: `iso8601-month`, 1 January to 12 December, and `iso8601-weekday`, 1 Monday to 7 Sunday (ISO 8601 §3.2.2), the numbering `Intl.Locale`'s `getWeekInfo().firstDay` returns. The write DTOs hold the value to the standard's range, or to a declared `min` or `max` inside it, published as `minimum` and `maximum` beside `x-comb-format`, and a value outside it gets zod's own `too_small` or `too_big`; codegen refuses a bound past the standard's (`users.week_start: max 8 is past iso8601-weekday's 7: the format holds it`) and a text format's id (`users.week_start: c.integer takes no format "iso4217"; integer formats: iso8601-month, iso8601-weekday`). `c.real`, `c.ref`, `c.json` and the timestamps take no format: TypeScript refuses one, and codegen refuses it from a tables module in JavaScript (`users.owner_id: c.ref takes no format`).
431
+
432
+ A text its format refuses gets exactly one issue, zod's `invalid_format` with the id as its `format` and `origin: "string"`, and no message of comb's, so zod words it in the locale you configure, and an error map can key a message of your own on the id:
433
+
434
+ ```ts
435
+ import * as z from "zod"
436
+
437
+ // " eu " or "HRK" on an iso4217 field:
438
+ // { code: "invalid_format", format: "iso4217", origin: "string", path: ["currency"], message: "Invalid iso4217" }
354
439
 
355
- `@workerstack/database/sqlite/patterns` ships regexes as source-level strings so the codegen parser can copy them verbatim into `.regex()` calls:
440
+ z.config(z.locales.de()) // message: "Ungültig: iso4217"
441
+
442
+ z.config({
443
+ customError: (issue) =>
444
+ issue.code === "invalid_format" && issue.format === "iso4217" ? "Unknown currency" : undefined,
445
+ })
446
+ ```
447
+
448
+ An error map that returns `undefined` leaves the issue to the locale, as that one does for every other issue.
449
+
450
+ ### Column schemas
451
+
452
+ `schema` takes a rule comb has no key for: a zod schema the create and update DTOs pipe a `c.text`, `c.integer` or `c.real` value into after every rule of comb's, so it reads the value trimmed, cased and checked. Codegen imports it as it imports a `c.json` schema, so it must be exported, and it is best in a module of its own:
356
453
 
357
454
  ```ts
358
- import { patterns } from "@workerstack/database/sqlite/patterns"
455
+ /* src/db/schemas.ts: a module of its own, so a DTO that imports a schema loads zod and this module alone */
456
+ import * as z from "zod"
359
457
 
360
- patterns.COUNTRY_ALPHA2 // "/^[A-Z]{2}$/"
361
- patterns.E164_PHONE // "/^\\+[1-9][0-9]{7,14}$/"
362
- patterns.IANA_TIMEZONE // "/^[A-Za-z_/]+$/"
363
- patterns.LOCALE // "/^[a-z]{2}(-[A-Z]{2})?$/"
458
+ export const dateOrder = z.enum(["iso", "dmy", "mdy"])
459
+ export const evenMinutes = z.int().refine((value) => value % 2 === 0, { error: "Must be even" })
364
460
  ```
365
461
 
462
+ The tables module imports both, `import { dateOrder, evenMinutes } from "./schemas.ts"`, and names each on its column:
463
+
464
+ <!-- ts-context
465
+ declare const dateOrder: import("zod").ZodType<string, string>
466
+ declare const evenMinutes: import("zod").ZodType<number, number>
467
+ -->
468
+
469
+ ```ts
470
+ import { sqliteTable } from "drizzle-orm/sqlite-core"
471
+ import { c } from "@workerstack/database/sqlite/columns"
472
+
473
+ export const users = sqliteTable("users", {
474
+ id: c.id("usr"),
475
+ date_order: c.text("date_order", { schema: dateOrder, trim: true }),
476
+ slot_minutes: c.integer("slot_minutes", { min: 0, schema: evenMinutes }),
477
+ })
478
+ ```
479
+
480
+ A schema exported from the tables module works too, but then every DTO that imports it loads the tables module and drizzle with it, a frontend form's bundle among them, and as a `const` it must sit above the table that names it: one below throws a `ReferenceError` as the module loads, which codegen reports in the module's own words. An inline schema type-checks, yet codegen refuses it, as it can only import an export: `users.date_order: no module of the project exports the schema its schema key is given; export it, above the table that names it, so the generated DTOs can import it`, with the note of packages that would not load on their own that a `c.json` schema's error carries.
481
+
482
+ The text key takes a `z.ZodType<string, string>` and the number keys a `z.ZodType<number, number>`, so a `z.int()` on a text, or a transform to another type, fails to compile. The schema must come from the `zod` the generated DTOs import, one `zod` in the project's tree, as a `c.json` schema must. Codegen refuses a value that is no zod schema (`users.date_order: the constraint schema is no zod schema; comb's DTOs are zod 4, so the schema must be one`) and one with no JSON Schema form, such as a `z.custom()`, keeping zod's reason (`… has no JSON Schema form (zod: Custom types cannot be represented in JSON Schema); comb publishes every DTO rule`).
483
+
484
+ Its issues reach the caller exactly as zod yields them, and comb adds no code or message: `"x"` on `date_order` is the enum's own `invalid_value` with its values, and `3` on `slot_minutes` is the refine's `custom` issue, `"Must be even"`. A value comb's own rules refuse never reaches the schema, so `-3` there gets the `too_small` of `min: 0` alone.
485
+
486
+ The JSON Schema publishes the schema's export name as `x-comb-schema` and the schema's own input JSON Schema in an `allOf` beside it, which codegen computes with `z.toJSONSchema(schema, { io: "input" })` and writes as a literal; on a nullable field the `allOf` sits on the non-null member of its `anyOf`, as the schema's `type` would refuse the null the field takes. A nullable `date_order` publishes:
487
+
488
+ ```text
489
+ {
490
+ "anyOf": [
491
+ {
492
+ "type": "string",
493
+ "allOf": [{ "type": "string", "enum": ["iso", "dmy", "mdy"] }],
494
+ "x-comb-schema": "dateOrder",
495
+ "x-comb-text": "no U+0000 or unpaired surrogate",
496
+ "x-comb-transform": ["trim"]
497
+ },
498
+ { "type": "null" }
499
+ ],
500
+ "x-comb-schema": "dateOrder",
501
+ "x-comb-text": "no U+0000 or unpaired surrogate",
502
+ "x-comb-transform": ["trim"]
503
+ }
504
+ ```
505
+
506
+ A refine publishes nothing: `evenMinutes` publishes `{ "type": "integer" }` with a safe integer's range and no word of evenness. `x-comb-schema` is how a reader that runs no JavaScript learns the producer runs a rule it cannot see, and such a reader may take a value the server then refuses. A column with a `schema` carries no generated example, as a refine its JSON Schema does not state may refuse any value comb would pick. The read DTO runs no schema, as a read never refuses a stored row, and the manifest names the export as the column's `schema`. `c.enum`, `c.json`, `c.ref` and the timestamps take no `schema` (`users.status: c.enum takes no schema`): an enum's values and a `c.json` schema are rules of their own, and a reference's value is another table's key.
507
+
366
508
  ## Errors
367
509
 
368
510
  ### `CombError`
@@ -692,11 +834,11 @@ A migration's hash is its SQL with comments and layout normalized away, never th
692
834
 
693
835
  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.
694
836
 
695
- 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.
837
+ 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.
696
838
 
697
839
  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.
698
840
 
699
- 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).
841
+ 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).
700
842
 
701
843
  `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.
702
844
 
@@ -904,7 +1046,7 @@ A filter on a relation field (`tags.slug`, through a `relations` entry) asks abo
904
1046
 
905
1047
  `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.
906
1048
 
907
- `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.
1049
+ `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.
908
1050
 
909
1051
  `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`.
910
1052
 
@@ -1003,7 +1145,7 @@ To place orgs yourself, `place({ orgId, shardId }, deps, ctx)` writes the same w
1003
1145
 
1004
1146
  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.
1005
1147
 
1006
- 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.
1148
+ 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.
1007
1149
 
1008
1150
  ## Meta contract
1009
1151
 
@@ -1057,28 +1199,27 @@ ULIDs use Crockford base32 with a 10-character timestamp and 16 characters of ra
1057
1199
 
1058
1200
  ## Package exports
1059
1201
 
1060
- | Specifier | Contents |
1061
- | -------------------------------------------- | -------------------------------------------------------------------------------------------------- |
1062
- | `@workerstack/database` | `CombError`, `createUlidFactory`, `generateId`, `generateUlid`, `LruCache`, error keys and types |
1063
- | `@workerstack/database/types` | `StatusKey`, `ConstraintMap`, `DatabaseErrorHandler`, … |
1064
- | `@workerstack/database/cli` | `runCli(argv, io)`: run the CLI in process and get its exit code |
1065
- | `@workerstack/database/codegen` | `analyze`, `validateTables`, `fieldConstraintsOf`, analysis types |
1066
- | `@workerstack/database/codegen/generators` | every `generate*` function |
1067
- | `@workerstack/database/meta` | `combMeta`, `readCombMeta`, `readCombEntityMeta`, `readCombQueryMeta`, `carryCombMeta`, meta types |
1068
- | `@workerstack/database/migrate` | config, providers, apply, parse, Atlas helpers |
1069
- | `@workerstack/database/query` | filter, order, select, cursors, Zod builders |
1070
- | `@workerstack/database/query/sqlite` | SQL generation, FTS5, JSON helpers |
1071
- | `@workerstack/database/sqlite/columns` | `c` for SQLite |
1072
- | `@workerstack/database/sqlite/constraints` | constraint types |
1073
- | `@workerstack/database/sqlite/patterns` | shared regex patterns |
1074
- | `@workerstack/database/sqlite/constraint-handler` | `sqliteErrorHandler`, `classifySqliteError`, `handleConstraintViolation` |
1075
- | `@workerstack/database/sqlite/d1/session` | `openD1Session` |
1076
- | `@workerstack/database/sqlite/d1/bookmark` | `BookmarkTracker` |
1077
- | `@workerstack/database/sqlite/d1/error-handler` | `d1ErrorHandler`, `classifyD1Error` |
1078
- | `@workerstack/database/sqlite/d1/sharding` | `ShardRouter`, `shardMap` |
1079
- | `@workerstack/database/states` | `defineStateMachine`, `canTransition`, `assertTransition`, `nextStates`, `isState`, `isTerminal` |
1080
- | `@workerstack/database/pg/columns` | `c` for PostgreSQL |
1081
- | `@workerstack/database/pg/error-handler` | `pgErrorHandler`, `classifyPgError` |
1202
+ | Specifier | Contents |
1203
+ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
1204
+ | `@workerstack/database` | `CombError`, `createUlidFactory`, `generateId`, `generateUlid`, `LruCache`, the format registry predicates, error keys and types |
1205
+ | `@workerstack/database/types` | `StatusKey`, `ConstraintMap`, `DatabaseErrorHandler`, … |
1206
+ | `@workerstack/database/cli` | `runCli(argv, io)`: run the CLI in process and get its exit code |
1207
+ | `@workerstack/database/codegen` | `analyze`, `validateTables`, `fieldConstraintsOf`, analysis types |
1208
+ | `@workerstack/database/codegen/generators` | every `generate*` function |
1209
+ | `@workerstack/database/meta` | `combMeta`, `readCombMeta`, `readCombEntityMeta`, `readCombQueryMeta`, `carryCombMeta`, meta types |
1210
+ | `@workerstack/database/migrate` | config, providers, apply, parse, Atlas helpers |
1211
+ | `@workerstack/database/query` | filter, order, select, cursors, Zod builders |
1212
+ | `@workerstack/database/query/sqlite` | SQL generation, FTS5, JSON helpers |
1213
+ | `@workerstack/database/sqlite/columns` | `c` for SQLite |
1214
+ | `@workerstack/database/sqlite/constraints` | constraint types |
1215
+ | `@workerstack/database/sqlite/constraint-handler` | `sqliteErrorHandler`, `classifySqliteError`, `handleConstraintViolation` |
1216
+ | `@workerstack/database/sqlite/d1/session` | `openD1Session` |
1217
+ | `@workerstack/database/sqlite/d1/bookmark` | `BookmarkTracker` |
1218
+ | `@workerstack/database/sqlite/d1/error-handler` | `d1ErrorHandler`, `classifyD1Error` |
1219
+ | `@workerstack/database/sqlite/d1/sharding` | `ShardRouter`, `shardMap` |
1220
+ | `@workerstack/database/states` | `defineStateMachine`, `canTransition`, `assertTransition`, `nextStates`, `isState`, `isTerminal` |
1221
+ | `@workerstack/database/pg/columns` | `c` for PostgreSQL |
1222
+ | `@workerstack/database/pg/error-handler` | `pgErrorHandler`, `classifyPgError` |
1082
1223
 
1083
1224
  ## Repository layout
1084
1225
 
@@ -1126,6 +1267,68 @@ bun run test:bun # bun test — the bun:sqlite replay suite
1126
1267
 
1127
1268
  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.
1128
1269
 
1270
+ ## Upgrading to 0.22.1
1271
+
1272
+ What changes for a project on 0.22. No export changed. Before deploying it:
1273
+
1274
+ 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`.
1275
+
1276
+ 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.
1277
+
1278
+ 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.
1279
+
1280
+ 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.
1281
+
1282
+ 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.
1283
+
1284
+ 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`.
1285
+
1286
+ 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.
1287
+
1288
+ 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.
1289
+
1290
+ 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.
1291
+
1292
+ 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.
1293
+
1294
+ 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.
1295
+
1296
+ 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.
1297
+
1298
+ 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.
1299
+
1300
+ 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.
1301
+
1302
+ 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.
1303
+
1304
+ 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.
1305
+
1306
+ 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.
1307
+
1308
+ ## Upgrading to 0.22
1309
+
1310
+ What changes for a project on 0.21. Before deploying it:
1311
+
1312
+ 1. **Know what changed in the API.** A test holds these three lists to the exports themselves.
1313
+
1314
+ New exports: `IntegerFormat`, `RealConstraints`, `RefConstraints`, `TextFormat`, `isIanaTimeZone`, `isIso3166Alpha2`, `isIso3166Alpha3`, `isIso4217`, `isIso639Alpha2`.
1315
+
1316
+ Removed: `patterns`, with its entry point, @workerstack/database/sqlite/patterns. **Fix:** declare the format that holds the standard a pattern of it stood for, as item 2 maps each one.
1317
+
1318
+ Changed: `AnalysisResult`, `CodegenConfig`, `DatabaseCodegen`, `EnumOptions`, `FieldConstraints`, `FieldConstraintsMeta`, `FieldMeta`, `GeneratorContext`, `IntegerConstraints`, `Manifest`, `ManifestColumn`, `ManifestEntity`, `OrmConfig`, `PgColumns`, `ShardMapColumns`, `ShardMapTable`, `SqliteColumns`, `TableMeta`, `TextConstraints`, `analyze`, `buildManifest`, `c`, `defineCodegenConfig`, `deriveEntityMeta`, `fieldConstraintsOf`, `generateColumns`, `generateConstraints`, `generateDiagrams`, `generateDtos`, `generateEntities`, `generateEnumChecks`, `generateFieldNames`, `generateFts`, `generateFtsSql`, `generateManifest`, `generateOrm`, `generateRelations`, `generateRows`, `generateTranslations`, `isExcludedFromUpdate`, `shardMap`, `validateTables`.
1319
+
1320
+ `TextConstraints` gains `format` and `schema`, `IntegerConstraints` gains `format` and `schema`, and `EnumOptions` gains `format`, each optional ([Formats](#formats), [Column schemas](#column-schemas)), a `format` standing where a regex of the removed `patterns` did (step 2). `c.real` takes `RealConstraints`, a real's bounds and `schema` with no `format`, where 0.21 typed it with `IntegerConstraints`, and `c.ref` takes `RefConstraints`, a text's keys but `format` and `schema`. The `c` of @workerstack/database/sqlite/columns, @workerstack/database/pg/columns and @workerstack/database/sqlite/d1/sharding changes with them, as do `SqliteColumns`, `PgColumns`, `ShardMapColumns`, `ShardMapTable` and `shardMap`. **Fix:** none: a call that compiled on 0.21, one passing a value typed `IntegerConstraints` to `c.real` or `TextConstraints` to `c.ref` among them, compiles on 0.22.
1321
+
1322
+ `ManifestColumn` gains `format`, the id of the standard a write checks the value against, and `schema`, the export name of the zod schema a text or number column declares, each null when the column declares none and `schema` null on a `c.json` column, whose schema the manifest already describes. `FieldConstraintsMeta`, which the package root, @workerstack/database/codegen, @workerstack/database/meta and @workerstack/database/query reach, gains `format`, and `FieldMeta` gains `schemaJson`, the schema's input JSON Schema, which the DTOs publish. Each is required, as every field of these types is (`ManifestColumn.format`, `ManifestColumn.schema`, `FieldConstraintsMeta.format`, `FieldMeta.schemaJson`), so a `ManifestColumn` or `FieldConstraintsMeta` you build fails to compile (TS2741). The manifest stays `v` 3, and a manifest reader meets the two fields as nulls on every column that declares neither. **Fix:** a `ManifestColumn` you build sets `format: null` and `schema: null`, a `FieldConstraintsMeta` sets `format: null`, and a `FieldMeta` sets `schemaJson: null`.
1323
+
1324
+ `fieldConstraintsOf` takes `values`, the column's enum values, each of which a `c.enum`'s format must take, or `null` for a column with none. **Fix:** a direct caller of `fieldConstraintsOf` passes `values`.
1325
+
1326
+ The rest of the list changes only through those types: `FieldConstraints` is `FieldConstraintsMeta`; `AnalysisResult`, `TableMeta`, `Manifest` and `ManifestEntity` hold a `FieldMeta` or a `ManifestColumn`; and `analyze`, `buildManifest`, `validateTables`, `deriveEntityMeta`, `isExcludedFromUpdate`, `CodegenConfig`, `DatabaseCodegen`, `GeneratorContext`, `OrmConfig`, `defineCodegenConfig` and each `generate*` function take or return one. **Fix:** none.
1327
+
1328
+ 2. **Declare a `format` where a `patterns` regex stood.** Each of the four stood for a standard it held loosely: `COUNTRY_ALPHA2` took any two capitals, `UK` and `XX` among them; `E164_PHONE` refused a number of fewer than 8 digits; `IANA_TIMEZONE` took `a/b/c` and refused `Etc/GMT+5` and `America/Port-au-Prince`; `LOCALE` refused `zh-Hant-TW` and `es-419`. A format holds the standard itself, and a registry's format its pinned list: `iso3166-1-alpha-2` refuses `UK` and `XX`, `e164` takes 2 to 15 digits after the `+`, `iana-time-zone` takes the names of tzdata 2026f alone, and `bcp47` takes every well-formed tag. A format also trims the value and writes it in its standard's case before its check, so `" hr"` is stored as `HR` where the pattern refused it. A value it refuses gets zod's `invalid_format` with the id as its `format` and the locale's message, where a pattern's issue was `format: "regex"` with the pattern and an English message. **Fix:** replace `pattern: patterns.COUNTRY_ALPHA2` with `format: "iso3166-1-alpha-2"`, `pattern: patterns.E164_PHONE` with `format: "e164"`, `pattern: patterns.IANA_TIMEZONE` with `format: "iana-time-zone"` and `pattern: patterns.LOCALE` with `format: "bcp47"`; drop a `trim`, `lowercase` or `uppercase` beside it, and a `min` or `max` outside its range, which codegen refuses; a client that read a pattern's `format: "regex"` reads the id.
1329
+
1330
+ 3. **Install `@workerstack/database` in place of `@lovrozagar/comb`.** The package moved to the workerstack org and the old name gets no new releases; `@workerstack/database@0.21.0` is `@lovrozagar/comb@0.21.0` under the new name, so you can switch before taking 0.22. The `comb` CLI, `comb.config.ts`, `CombError` and the migration tables keep their names. Generated DTOs and entities import from the package by name, so files generated on the old name still import `@lovrozagar/comb`. **Fix:** run `bun remove @lovrozagar/comb && bun add @workerstack/database`, replace every `@lovrozagar/comb` import prefix with `@workerstack/database`, and rerun `comb codegen`.
1331
+
1129
1332
  ## Upgrading to 0.21
1130
1333
 
1131
1334
  What changes for a project on 0.20. Before deploying it: