@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
@@ -166,6 +166,55 @@ no `anyOf` walk. A nullable trimmed text with a `max` of 9, as codegen publishes
166
166
  - `x-comb-rectangular`: `true` on a multidimensional array: every sub-array of one level, across the
167
167
  whole level, has the same length, as Postgres stores an array: `[[1, 2], [3, 4]]` is one,
168
168
  `[[1, 2], [3]]` is not.
169
+ - `x-comb-format`: the standard a written value is held to, by its id, on a text, an enum or an
170
+ integer. On a text, the value after its `x-comb-transform` matches the published `pattern` and
171
+ length bounds; where the id below names a registry, it is also a member of that pinned registry,
172
+ which no pattern states, so a reader must check it, with the predicate named, which
173
+ `@workerstack/database` exports, or with the release named (`HRK` matches `^[A-Z]{3}$` and is no
174
+ `iso4217` value: withdrawn in 2023). A text failing it gets zod's `invalid_format` issue with the
175
+ id as its `format` and no message of comb's, so a zod locale or error map words it. On an enum the
176
+ listed values are the rule, each one the format takes, and the id is what they mean; every DTO of
177
+ an enum publishes it, as each holds the values. On an integer the published `minimum` and
178
+ `maximum` are the standard's range, or a declared one inside it. A read of a text or an integer
179
+ publishes none. The ids, each with its standard and release:
180
+ - `base64url`: RFC 4648 §5 base64url, unpadded.
181
+ - `bcp47`: BCP 47 language tag, RFC 5646 §2.1 well-formed.
182
+ - `e164`: ITU-T E.164 phone number, + and up to 15 digits.
183
+ - `iana-time-zone`: IANA time zone name, tzdata 2026f. Registry: `isIanaTimeZone`.
184
+ - `ip`: IPv4 dotted quad or IPv6 address, RFC 4291 §2.2.
185
+ - `ipv4`: IPv4 address, dotted quad without leading zeros.
186
+ - `ipv6`: IPv6 address, RFC 4291 §2.2.
187
+ - `iso3166-1-alpha-2`: ISO 3166-1 alpha-2 country code, Debian iso-codes v4.20.1. Registry: `isIso3166Alpha2`.
188
+ - `iso3166-1-alpha-3`: ISO 3166-1 alpha-3 country code, Debian iso-codes v4.20.1. Registry: `isIso3166Alpha3`.
189
+ - `iso4217`: ISO 4217 alphabetic currency code, SIX list 2026-09-17. Registry: `isIso4217`.
190
+ - `iso639-1`: ISO 639-1 language code, Debian iso-codes v4.20.1. Registry: `isIso639Alpha2`.
191
+ - `kebab-slug`: kebab-case slug, lowercase letters and digits joined by single hyphens.
192
+ - `pkce-s256-challenge`: RFC 7636 §4.2 PKCE SHA-256 code challenge.
193
+ - `rfc1123-hostname`: RFC 1123 §2.1 host name.
194
+ - `semver`: Semantic Versioning 2.0.0 version.
195
+ - `sha256-hex`: SHA-256 digest, FIPS 180-4, lowercase hex.
196
+ - `sha512-hex`: SHA-512 digest, FIPS 180-4, lowercase hex.
197
+ - `srgb-hex`: sRGB color as #rrggbb, CSS Color 4.
198
+ - `srgb-hex-alpha`: sRGB color with alpha as #rrggbbaa, CSS Color 4.
199
+ - `ulid`: ULID, Crockford base32 in lowercase.
200
+ - `uts35-ca`: UTS #35 calendar (ca) key value, CLDR 49.
201
+ - `uts35-fw`: UTS #35 first day of the week (fw) key value, CLDR 49.
202
+ - `uts35-hc`: UTS #35 hour cycle (hc) key value, CLDR 49.
203
+ - `uts35-ms`: UTS #35 measurement system (ms) key value, CLDR 49.
204
+ - `uts35-mu`: UTS #35 measurement unit (mu) key value, CLDR 49.
205
+ - `uts35-nu`: UTS #35 numbering system (nu) key value, CLDR 49.
206
+ - `uuid`: RFC 9562 UUID, lowercase hex.
207
+ - `uuid-v4`: RFC 9562 UUID version 4, lowercase hex.
208
+ - `uuid-v7`: RFC 9562 UUID version 7, lowercase hex.
209
+ - `iso8601-month`: ISO 8601 month number, 1 January to 12 December.
210
+ - `iso8601-weekday`: ISO 8601 §3.2.2 weekday number, 1 Monday to 7 Sunday.
211
+ - `x-comb-schema`: the export name of the zod schema a written text or number is piped into after
212
+ every other rule, on the value as rewritten; its issues reach the caller as the schema yields
213
+ them. The `allOf` beside it is the schema's published part: its input JSON Schema, last, after
214
+ the patterns of the field's own chain when that has two or more. A refine in the schema publishes
215
+ nothing, so a rule it states only as a refine is one the producer alone runs: a reader may take a
216
+ value the producer then refuses. On a nullable field the `allOf` sits on the non-null member
217
+ alone, as its `type` would refuse the null the field takes. A read publishes none.
169
218
  - `x-comb-minLength`, `x-comb-maxLength`, `x-comb-minimum`, `x-comb-maximum`: on a read, the
170
219
  declared bounds it states without enforcing them, so a stored row past one still reads.
171
220
 
@@ -652,7 +701,12 @@ reader joins through the manifest alone. A composite foreign key is one relation
652
701
  per column, each carrying the whole key as `key: { columns, refColumns }`, paired by position, so
653
702
  a reader joins on every column together rather than on independent pairs. Per column it carries the helper that
654
703
  declared it, its `sqlType`, the column's SQL type with its modifier as drizzle writes it (`timestamp(3) with time zone`, `interval day`, `numeric(3, -2)`), an array's that of its element, beside its `dimensions` (0 for a column of one value, 2 for `integer[][]`) and `nullElements`, whether a written element may be null, as the column's drizzle type declares it (false for a column of one value), the bounds a write takes and enum values, `textKind` (`"email"`, `"url"`, `"imageUri"`,
655
- `"password"`, `"name"`, or null), `transform`, the list the DTO publishes as `x-comb-transform`
704
+ `"password"`, `"name"`, or null), `format`, the id of the standard format a write checks the
705
+ value against (`"iso4217"`, `"iso8601-weekday"`), as `x-comb-format` publishes it (§2.1), or null
706
+ when the column declares none, `schema`, the export name of the zod schema a text or number column
707
+ declares, which a write runs after the column's own rules and whose published part is
708
+ `x-comb-schema`'s (§2.1), or null when none and on a `c.json` column, whose schema is its value's
709
+ shape rather than a rule beside it, `transform`, the list the DTO publishes as `x-comb-transform`
656
710
  (§2.1), and `generated`, `immutable` and `private` flags. `min`, `max`, `patterns` and `maxBytes`
657
711
  are what the create DTO enforces, not what the column declares, from `writeRules`, which reads
658
712
  the rules the DTO's builders read: a text's declared bounds with comb's own (a required text's 1, a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workerstack/database",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "Database framework for Cloudflare D1, PostgreSQL, and SQLite. Schema builders, error handling, D1 sessions, sharding, codegen, migrations.",
5
5
  "keywords": [
6
6
  "cloudflare-d1",
@@ -124,11 +124,6 @@
124
124
  "types": "./types/typescript-floor-types.d.ts",
125
125
  "default": "./src/sqlite/d1/sharding/index.ts"
126
126
  },
127
- "./sqlite/patterns": {
128
- "types@>=5.7": "./types/sqlite/patterns.d.ts",
129
- "types": "./types/typescript-floor-types.d.ts",
130
- "default": "./src/sqlite/patterns.ts"
131
- },
132
127
  "./states": {
133
128
  "types@>=5.7": "./types/states.d.ts",
134
129
  "types": "./types/typescript-floor-types.d.ts",
@@ -147,7 +142,8 @@
147
142
  "prepack": "rm -rf types && cp ../../README.md ../../LICENSE . && bun run types:emit",
148
143
  "postpack": "rm -rf types README.md LICENSE",
149
144
  "test": "vitest run",
150
- "test:bun": "bun test tests/bun",
145
+ "test:bun": "bun test tests/bun --path-ignore-patterns=tests/bun/differential-lists.test.ts",
146
+ "test:differential": "vitest run --config vitest.differential.config.ts && bun test tests/bun/differential-lists.test.ts",
151
147
  "test:coverage": "vitest run --coverage",
152
148
  "test:consumers": "vitest run --config vitest.consumers.config.ts",
153
149
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
package/src/cli/args.ts CHANGED
@@ -55,7 +55,8 @@ export const OPTIONS: readonly OptionSpec[] = [
55
55
  },
56
56
  {
57
57
  commands: ["migrate diff"],
58
- description: "Write nothing: exit 1 printing the migration a diff would write, 0 in sync",
58
+ description:
59
+ "Write nothing: exit 1 printing the migration a diff would write, 0 in sync, 2 when the check could not run",
59
60
  long: "--dry-run",
60
61
  },
61
62
  { commands: ["migrate apply"], description: "Apply this one migration file", long: "--file", value: "path" },
@@ -78,7 +79,8 @@ export const OPTIONS: readonly OptionSpec[] = [
78
79
  },
79
80
  {
80
81
  commands: ["migrate apply", "migrate check", "migrate status"],
81
- description: "Print one JSON object (database, applied, pending, refused, unknown) as the only stdout",
82
+ description:
83
+ "Print one JSON object (database, applied, pending, refused, unknown; stoppedBy when a signal stopped the run) as the only stdout",
82
84
  long: "--json",
83
85
  },
84
86
  ]
@@ -249,7 +249,7 @@ async function generate(
249
249
  /** The databases a codegen run covers: the one --tables names, or those comb.config.ts declares */
250
250
  async function databases(positionals: readonly string[], tables: string | undefined, io: CliIo): Promise<Database[]> {
251
251
  if (tables !== undefined) {
252
- const tablesPath = tablesFile(io.cwd, tables)
252
+ const tablesPath = tablesFile(io.cwd, tables, "--tables")
253
253
  return [{ db: { tables }, direct: true, exportNamespace: undefined, generators: DIRECT, name: "", tablesPath }]
254
254
  }
255
255
  const config = await loadCombConfig(io.cwd)
@@ -263,7 +263,7 @@ async function databases(positionals: readonly string[], tables: string | undefi
263
263
  exportNamespace: namespaces.get(name),
264
264
  generators: db.generators ?? GENERATORS,
265
265
  name,
266
- tablesPath: tablesFile(io.cwd, db.tables),
266
+ tablesPath: tablesFile(io.cwd, db.tables, "tables"),
267
267
  }))
268
268
  }
269
269
 
@@ -6,6 +6,7 @@ import * as z from "zod"
6
6
  import type { CodegenConfig, DatabaseCodegen } from "../codegen/config.ts"
7
7
  import type { FtsTableConfig } from "../codegen/generators/fts-sql.ts"
8
8
  import { codegenConfigSchema } from "../codegen/config-schema.ts"
9
+ import { refuseNotFile } from "./file-path.ts"
9
10
  import { CliError, type CliIo } from "./output.ts"
10
11
 
11
12
  export type { CodegenConfig, DatabaseCodegen }
@@ -58,10 +59,14 @@ export function databaseIn(config: CodegenConfig, name: string, hint?: string):
58
59
  )
59
60
  }
60
61
 
61
- /** The tables file `tables` names, resolved against `cwd`; a CliError when it is missing */
62
- export function tablesFile(cwd: string, tables: string): string {
62
+ /**
63
+ * The tables file `tables` names, resolved against `cwd`, as `what` gave it (--tables, or
64
+ * comb.config.ts `tables`); a CliError when it is missing or not a file
65
+ */
66
+ export function tablesFile(cwd: string, tables: string, what: "--tables" | "tables"): string {
63
67
  const resolved = path.resolve(cwd, tables)
64
68
  if (!fs.existsSync(resolved)) throw new CliError(`Tables file not found: ${resolved}`)
69
+ refuseNotFile(resolved, what, "point it at the module that exports the tables")
65
70
  return resolved
66
71
  }
67
72
 
@@ -85,7 +90,7 @@ export async function declaredFts(
85
90
 
86
91
  /** The FTS5 DDL `declared` asks for, for the migration diff to keep apart from what Atlas sees */
87
92
  export async function ftsSql(cwd: string, declared: DeclaredFts, io: CliIo): Promise<string> {
88
- const resolvedTables = tablesFile(cwd, declared.tables)
93
+ const resolvedTables = tablesFile(cwd, declared.tables, "tables")
89
94
  const { analyze } = await import("../codegen/analyzer.ts")
90
95
  const { generateFtsSql } = await import("../codegen/generators/fts-sql.ts")
91
96
  return generateFtsSql((await analyze(resolvedTables, { cwd })).tables, declared.fts, io.err)
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The CLI's checks of a path or a name a command or a config gives before it reaches the
3
+ * filesystem, so a wrong kind is refused by name rather than as the error of whatever reads it.
4
+ */
5
+ import fs from "node:fs"
6
+ import { CliError } from "./output.ts"
7
+
8
+ /**
9
+ * Refuse `file`, which `what` names (an option or a config field), when it exists as other than a
10
+ * file, saying what it is, then `fix`; a missing one is left to the caller, which refuses it its own way
11
+ */
12
+ export function refuseNotFile(file: string, what: string, fix: string): void {
13
+ const stat = fs.statSync(file, { throwIfNoEntry: false })
14
+ if (stat === undefined || stat.isFile()) return
15
+ throw new CliError(`${what} ${file} ${stat.isDirectory() ? "is a directory, not a file" : "is not a file"}: ${fix}`)
16
+ }
17
+
18
+ /**
19
+ * Refuse a name a file is made from, `what` naming it, when it would name a path: one holding a
20
+ * separator, or `.` or `..` alone; then `usage`
21
+ */
22
+ export function refusePathName(name: string, what: string, usage: string): void {
23
+ if (!/[/\\]/.test(name) && name !== "." && name !== "..") return
24
+ throw new CliError(`${what} "${name}" names a path: give a name alone, such as add_users\n${usage}`)
25
+ }
@@ -33,8 +33,8 @@ import type { Connection } from "../migrate/types.ts"
33
33
  import { parseArgs, type ParsedArgs } from "./args.ts"
34
34
  import { fileName, openSqliteTarget, type SqliteTarget } from "./migrate-sqlite-target.ts"
35
35
  import { resolveMigrateTarget } from "./migrate-target.ts"
36
- import { Refused } from "../migrate/core/refused.ts"
37
- import { checkPassed, CliError, type CliContext, type MigrateOutcome, Reported } from "./output.ts"
36
+ import { asJson } from "./migrate-json.ts"
37
+ import { checkPassed, CliError, type CliContext, type MigrateOutcome } from "./output.ts"
38
38
 
39
39
  type Mode = "apply" | "baseline" | "check" | "status"
40
40
 
@@ -220,11 +220,6 @@ async function migrateSqlite({ args, io, mode }: Request, target: Target): Promi
220
220
  return { applied: pendingNames, pending: [] }
221
221
  }
222
222
 
223
- /**
224
- * `migrate apply|baseline|check|status`. With --json the human lines go to stderr and stdout is
225
- * one JSON object: the database, the files applied (or found applied) and pending, what was
226
- * refused, and the files whose answer was lost, which may have committed; a refusal still exits 1.
227
- */
228
223
  /** `mode`'s argv read: its database and options; refused by usage without one database */
229
224
  function readArgv(mode: Mode, argv: readonly string[]): { args: ParsedArgs; db: string } {
230
225
  const args = parseArgs(argv, `migrate ${mode}`)
@@ -232,22 +227,11 @@ function readArgv(mode: Mode, argv: readonly string[]): { args: ParsedArgs; db:
232
227
  return { args, db: args.positionals[0] as string }
233
228
  }
234
229
 
230
+ /** `migrate apply|baseline|check|status`, with --json as one JSON object (migrate-json.ts) */
235
231
  export async function migrateApply(mode: Mode, argv: readonly string[], io: CliContext): Promise<void> {
236
232
  const { args, db } = readArgv(mode, argv)
237
- if (args.options["json"] !== true) {
238
- await runMode({ args, db, io, mode })
239
- return
240
- }
241
- const report = (outcome: MigrateOutcome, refused: string[], unknown: string[] = []) =>
242
- io.out(JSON.stringify({ applied: outcome.applied, database: db, pending: outcome.pending, refused, unknown }))
243
- try {
244
- report(await runMode({ args, db, io: { ...io, out: io.err }, mode }), [])
245
- } catch (error) {
246
- /* a refusal or a failed command is an Error; a user's config can throw anything */
247
- const { applied, pending, unknown } = error instanceof Refused ? error : { applied: [], pending: [], unknown: [] }
248
- report({ applied, pending }, [error instanceof Error ? error.message : String(error)], unknown)
249
- throw new Reported(error)
250
- }
233
+ if (args.options["json"] === true) await asJson(db, io, (each) => runMode({ args, db, io: each, mode }))
234
+ else await runMode({ args, db, io, mode })
251
235
  }
252
236
 
253
237
  /**
@@ -14,6 +14,7 @@ import { connectD1, wranglerFlags } from "../migrate/drivers/d1.ts"
14
14
  import type { D1ProviderConfig } from "../migrate/types.ts"
15
15
  import type { ParsedArgs } from "./args.ts"
16
16
  import { type MigrateTarget, refuseNoDir } from "./migrate-target.ts"
17
+ import { refuseNotFile } from "./file-path.ts"
17
18
  import { checkPassed, CliError, type CliContext, type MigrateOutcome } from "./output.ts"
18
19
 
19
20
  type D1Mode = "apply" | "baseline" | "check" | "status"
@@ -27,7 +28,10 @@ function d1Config(request: D1Request, resolved: MigrateTarget | undefined): { co
27
28
  if (resolved?.provider.type === "d1") {
28
29
  if (args.options["local"] !== undefined)
29
30
  throw new CliError("--local applies without comb.migrate.config.ts; set local: true on its d1 provider")
30
- const wranglerConfig = resolved.provider.wranglerConfig ?? findWranglerConfig(resolved.configDir)
31
+ const configured = resolved.provider.wranglerConfig
32
+ const fix = `point ${db}'s d1 provider at its wrangler.json, wrangler.jsonc or wrangler.toml`
33
+ if (configured !== undefined) refuseNotFile(configured, "wranglerConfig", fix)
34
+ const wranglerConfig = configured ?? findWranglerConfig(resolved.configDir)
31
35
  if (wranglerConfig === undefined)
32
36
  throw new CliError(`no wrangler config next to comb.migrate.config.ts; set wranglerConfig on ${db}'s d1 provider`)
33
37
  return { config: { ...resolved.provider, wranglerConfig }, dir: resolved.migrationsDir }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `--json` for `migrate apply|baseline|check|status`: the human lines go to stderr and stdout is one
3
+ * JSON object, the database, the files applied (or found applied) and pending, what was refused, and
4
+ * the files whose answer was lost, which may have committed; a refusal still exits 1. A run a signal
5
+ * stopped (SIGHUP, SIGINT, SIGQUIT, SIGTERM) prints that object too, once the loop that applies files
6
+ * has said what committed (stopped.ts), with `stoppedBy` naming the signal, and then ends by the
7
+ * signal. A stop parks the run itself, so that object comes from the step held here.
8
+ */
9
+ import { hold } from "../migrate/core/cleanup.ts"
10
+ import { Refused } from "../migrate/core/refused.ts"
11
+ import { Stopped } from "../migrate/core/stopped.ts"
12
+ import { type CliContext, type MigrateOutcome, Reported } from "./output.ts"
13
+
14
+ /** One end of a run, as the object prints it */
15
+ type End = MigrateOutcome & { refused: string[]; stoppedBy?: NodeJS.Signals | undefined; unknown: string[] }
16
+
17
+ /** `run` on `db`, its human lines on stderr and its one JSON object on `io.out` */
18
+ export async function asJson(
19
+ db: string,
20
+ io: CliContext,
21
+ run: (io: CliContext) => Promise<MigrateOutcome>,
22
+ ): Promise<void> {
23
+ /* once: a run that fails as a signal stops it must not print a second object */
24
+ let printed = false
25
+ const report = ({ applied, pending, refused, stoppedBy, unknown }: End) => {
26
+ if (printed) return
27
+ printed = true
28
+ io.out(JSON.stringify({ applied, database: db, pending, refused, ...(stoppedBy && { stoppedBy }), unknown }))
29
+ }
30
+ /* held before the run, so it runs after every step the run holds: they have said what committed by then */
31
+ const release = hold((signal, earlier) => {
32
+ const found = earlier.find((each) => each instanceof Stopped)
33
+ /* a stop before any file applied: nothing committed, and no loop has a report to give */
34
+ const stopped = found ?? new Stopped(signal, `Stopped by ${signal}`, { pending: [] })
35
+ if (found === undefined) io.err(stopped.message)
36
+ report({ ...stopped, refused: [stopped.message], stoppedBy: signal, unknown: stopped.unknown })
37
+ })
38
+ try {
39
+ report({ ...(await run({ ...io, out: io.err })), refused: [], unknown: [] })
40
+ } catch (error) {
41
+ /* a refusal or a failed command is an Error; a user's config can throw anything */
42
+ const { applied, pending, unknown } = error instanceof Refused ? error : { applied: [], pending: [], unknown: [] }
43
+ report({ applied, pending, refused: [error instanceof Error ? error.message : String(error)], unknown })
44
+ throw new Reported(error)
45
+ } finally {
46
+ release()
47
+ }
48
+ }
@@ -1,7 +1,7 @@
1
1
  /** `comb migrate`: init, diff, check, apply, baseline and status. */
2
2
  import fs from "node:fs"
3
3
  import path from "node:path"
4
- import { parseArgs, refuseFurther } from "./args.ts"
4
+ import { parseArgs, refuseFurther, usageOf } from "./args.ts"
5
5
  import { binOf } from "../migrate/core/optional-peer.ts"
6
6
  import { declaredFts, ftsSql } from "./comb-config.ts"
7
7
  import {
@@ -14,7 +14,8 @@ import {
14
14
  } from "./drizzle-dialect.ts"
15
15
  import { migrateApply, needPeers } from "./migrate-apply.ts"
16
16
  import { type MigrateTarget, refuseBlank, refuseNoDir, resolveMigrateTarget } from "./migrate-target.ts"
17
- import { CliError, type CliContext } from "./output.ts"
17
+ import { refusePathName } from "./file-path.ts"
18
+ import { CliError, type CliContext, ShowHelp } from "./output.ts"
18
19
  import { scratchDir } from "../migrate/core/cleanup.ts"
19
20
 
20
21
  const DRIZZLE_CONFIG = `import { defineConfig } from "drizzle-kit"
@@ -100,10 +101,12 @@ function migrateInit(argv: readonly string[], io: CliContext): void {
100
101
  * database's dialect, directory and drizzle config come from it; without, it is SQLite in
101
102
  * ./atlas-migrations.
102
103
  */
103
- async function migrateDiff(argv: readonly string[], io: CliContext): Promise<void> {
104
+ async function runDiff(argv: readonly string[], io: CliContext): Promise<void> {
104
105
  const args = parseArgs(argv, "migrate diff")
105
106
  /* parseArgs refuses a diff that names no database */
106
107
  const [db, name] = args.positionals as [string, (string | undefined)?]
108
+ /* Atlas makes the file's name from it: a path would land outside the directory, or nowhere */
109
+ if (name !== undefined) refusePathName(name, "Migration name", usageOf("migrate diff"))
107
110
  const env = args.options["env"] as string | undefined
108
111
  const { d1, expected, exported, fts, migrationsDir, pg, provider, target } = await diffTarget(db, env, io)
109
112
  /* a diff starts a missing directory, and refuses a file before anything runs */
@@ -229,7 +232,26 @@ function reportDrift(db: string, files: readonly string[], io: CliContext): neve
229
232
  io.out(`-- ${path.basename(file)}`)
230
233
  io.out(fs.readFileSync(file, "utf8"))
231
234
  }
232
- throw new CliError(`${db} has drifted from its migrations: comb migrate diff ${db} would write the SQL above`)
235
+ throw new Drifted(`${db} has drifted from its migrations: comb migrate diff ${db} would write the SQL above`)
236
+ }
237
+
238
+ /** A dry run's refusal for drift, which exits 1 */
239
+ class Drifted extends CliError {}
240
+
241
+ /**
242
+ * `migrate diff`. A dry run is a drift gate, so it exits 1 for drift alone, and 2 for every failure
243
+ * that kept it from checking, the argv's own refusal among them: --dry-run is read from argv, as
244
+ * the CLI reads --json, so that refusal knows it is a dry run's
245
+ */
246
+ async function migrateDiff(argv: readonly string[], io: CliContext): Promise<void> {
247
+ if (!argv.includes("--dry-run")) return runDiff(argv, io)
248
+ try {
249
+ await runDiff(argv, io)
250
+ } catch (error) {
251
+ if (error instanceof Drifted || error instanceof ShowHelp) throw error
252
+ /* an Error: comb throws only Errors, and a config's load wraps whatever the config throws (migrate/config.ts) */
253
+ throw new CliError((error as Error).message, 2, { cause: error })
254
+ }
233
255
  }
234
256
 
235
257
  export async function cmdMigrate(argv: readonly string[], io: CliContext, help: string): Promise<void> {
@@ -1,3 +1,4 @@
1
+ import type { IntegerFormat, TextFormat } from "../formats/catalog.ts"
1
2
  import type { CombColumnKind } from "../meta/column.ts"
2
3
  /**
3
4
  * TableMeta — serializable intermediate format produced by TableAnalyzer.
@@ -10,6 +11,8 @@ type FieldConstraintsMeta = {
10
11
  autogenerate: boolean
11
12
  /** the generated DTOs take only an email address */
12
13
  email: boolean
14
+ /** the standard the value is held to: a text's or an enum's text format, an integer's integer format; null when none is declared */
15
+ format: TextFormat | IntegerFormat | null
13
16
  /** an https URL or a data:image/ URI */
14
17
  imageUri: boolean
15
18
  /** the generated DTOs lowercase the text before they check it */
@@ -127,8 +130,10 @@ type FieldMeta = {
127
130
  isPrimaryKey: boolean
128
131
  /** the name the generated DTO imports a c.json column's schema under, null for any other column */
129
132
  jsonSchemaName: string | null
130
- /** the names the field's generated schema uses and must import (a c.json schema) */
133
+ /** the names the field's generated schema uses and must import: a c.json schema, or the zod schema a text or number column's `schema` key names */
131
134
  schemaImports: ImportRef[]
135
+ /** the input JSON Schema of the zod schema a text or number column's `schema` key names, self-contained and without `$schema`, which the generated DTOs publish beside it; null for any other column. The schema's import is the field's one `schemaImports` entry */
136
+ schemaJson: Readonly<Record<string, unknown>> | null
132
137
  /** a Postgres array's declared dimensions; 0 or absent for a column of one value */
133
138
  dimensions?: number | undefined
134
139
  /** whether a Postgres array's elements may be null, as its drizzle type declares; false or absent otherwise */
@@ -5,12 +5,14 @@
5
5
  */
6
6
  import type { CombColumnKind } from "../meta/column.ts"
7
7
  import type { ConstantValue, FieldConstraintsMeta } from "./analyzer-types.ts"
8
+ import { formatOf } from "./format-constraints.ts"
8
9
  import { patternLiteral } from "./utils/pattern.ts"
9
10
 
10
11
  /** Field constraints extracted from type arguments */
11
12
  export type FieldConstraints = FieldConstraintsMeta
12
13
 
13
14
  /* the keys of each constraint type in src/sqlite/constraints.ts, which tests/types/constraint-keys.typetest.ts holds them to */
15
+ /* a timestamp's keys: its bounds and the flags every helper takes */
14
16
  export const NUMBER_KEYS = [
15
17
  "autogenerate",
16
18
  "max",
@@ -21,11 +23,57 @@ export const NUMBER_KEYS = [
21
23
  "relation",
22
24
  "tenant",
23
25
  ] as const
26
+ /* a real's keys: a timestamp's, and the zod schema its value is piped into */
27
+ export const REAL_KEYS = [
28
+ "autogenerate",
29
+ "max",
30
+ "min",
31
+ "mutable",
32
+ "nomutate",
33
+ "private",
34
+ "relation",
35
+ "schema",
36
+ "tenant",
37
+ ] as const
38
+ /* an integer's keys: a real's, and the standard format that numbers its values */
39
+ export const INTEGER_KEYS = [
40
+ "autogenerate",
41
+ "format",
42
+ "max",
43
+ "min",
44
+ "mutable",
45
+ "nomutate",
46
+ "private",
47
+ "relation",
48
+ "schema",
49
+ "tenant",
50
+ ] as const
24
51
  export const JSON_KEYS = ["autogenerate", "maxBytes", "mutable", "nomutate", "private", "relation", "tenant"] as const
25
- export const ENUM_KEYS = ["nomutate", "private", "tenant"] as const
52
+ export const ENUM_KEYS = ["format", "nomutate", "private", "tenant"] as const
53
+ /* a foreign key's keys: a text's but format and schema, as its value is another table's key */
54
+ export const REF_KEYS = [
55
+ "autogenerate",
56
+ "email",
57
+ "imageUri",
58
+ "lowercase",
59
+ "max",
60
+ "min",
61
+ "mutable",
62
+ "name",
63
+ "nomutate",
64
+ "password",
65
+ "pattern",
66
+ "private",
67
+ "relation",
68
+ "tenant",
69
+ "trim",
70
+ "uppercase",
71
+ "url",
72
+ ] as const
26
73
  export const TEXT_KEYS = [
27
74
  "autogenerate",
28
75
  "email",
76
+ "format",
29
77
  "imageUri",
30
78
  "lowercase",
31
79
  "max",
@@ -37,6 +85,7 @@ export const TEXT_KEYS = [
37
85
  "pattern",
38
86
  "private",
39
87
  "relation",
88
+ "schema",
40
89
  "tenant",
41
90
  "trim",
42
91
  "uppercase",
@@ -53,10 +102,10 @@ export type ConstraintKeys = {
53
102
  readonly deletedAt: Keys
54
103
  readonly enum: typeof ENUM_KEYS
55
104
  readonly id: None
56
- readonly integer: Keys
105
+ readonly integer: typeof INTEGER_KEYS
57
106
  readonly json: typeof JSON_KEYS
58
- readonly real: Keys
59
- readonly ref: typeof TEXT_KEYS
107
+ readonly real: typeof REAL_KEYS
108
+ readonly ref: typeof REF_KEYS
60
109
  readonly serialId: None
61
110
  readonly text: typeof TEXT_KEYS
62
111
  readonly timestamp: Keys
@@ -70,10 +119,10 @@ export const CONSTRAINT_KEYS: ConstraintKeys = {
70
119
  deletedAt: NUMBER_KEYS,
71
120
  enum: ENUM_KEYS,
72
121
  id: [],
73
- integer: NUMBER_KEYS,
122
+ integer: INTEGER_KEYS,
74
123
  json: JSON_KEYS,
75
- real: NUMBER_KEYS,
76
- ref: TEXT_KEYS,
124
+ real: REAL_KEYS,
125
+ ref: REF_KEYS,
77
126
  serialId: [],
78
127
  text: TEXT_KEYS,
79
128
  timestamp: NUMBER_KEYS,
@@ -115,10 +164,13 @@ const DROPPED: Readonly<Record<string, { keys: readonly string[]; said: string;
115
164
  url: { keys: ["lowercase", "uppercase"], said: "it is kept as written, but for its trim", what: "a url" },
116
165
  }
117
166
 
118
- /** Refuses a second kind, or a key the one declared would drop, each of which a reader of the tables file believes holds */
167
+ /** Refuses a second kind, a format beside a kind, or a key the one declared would drop, each of which a reader of the tables file believes holds */
119
168
  function refuseKinds(constraints: Readonly<Record<string, ConstantValue>>, place: string): void {
120
169
  const [kind, second] = KINDS.filter((each) => constraints[each] === true)
121
170
  if (second !== undefined) throw new Error(`${place}: ${kind} and ${second} contradict; declare one`)
171
+ /* a format is a text's whole rule, as a kind is */
172
+ if (kind !== undefined && constraints["format"] !== undefined)
173
+ throw new Error(`${place}: format and ${kind} contradict; declare one`)
122
174
  const dropped = DROPPED[kind as string]
123
175
  const key = dropped?.keys.find((each) => constraints[each] === true)
124
176
  if (key !== undefined) throw new Error(`${place}: ${dropped?.what} takes no ${key}: ${dropped?.said}`)
@@ -180,7 +232,7 @@ function refuseMalformed(
180
232
  /**
181
233
  * A column's declared constraints, checked and with comb's defaults applied. Throws on a key the
182
234
  * helper does not take, a value of the wrong type, two keys that contradict, or bounds no value
183
- * meets, each message prefixed with `place`
235
+ * meets, or a format the helper cannot hold, each message prefixed with `place`
184
236
  */
185
237
  export function fieldConstraintsOf(
186
238
  constraints: Readonly<Record<string, ConstantValue>>,
@@ -190,6 +242,7 @@ export function fieldConstraintsOf(
190
242
  helper,
191
243
  measure,
192
244
  place,
245
+ values,
193
246
  }: {
194
247
  /** the column's property name; one starting with `_` is private */
195
248
  fieldName: string
@@ -201,9 +254,12 @@ export function fieldConstraintsOf(
201
254
  measure: "length" | "value"
202
255
  /** where the column is declared, as each error message names it */
203
256
  place: string
257
+ /** the column's enum values, each of which a c.enum's format must take; null when it has none */
258
+ values: readonly string[] | null
204
259
  },
205
260
  ): FieldConstraints {
206
261
  refuseMalformed(constraints, { helper, measure, place })
262
+ const format = formatOf(constraints, { helper, place, values })
207
263
  const relation = constraints["relation"]
208
264
 
209
265
  const autogenerate = filled ? constraints["autogenerate"] !== false : constraints["autogenerate"] === true
@@ -211,6 +267,7 @@ export function fieldConstraintsOf(
211
267
  return {
212
268
  autogenerate,
213
269
  email: constraints["email"] === true,
270
+ format,
214
271
  imageUri: constraints["imageUri"] === true,
215
272
  lowercase: constraints["lowercase"] === true,
216
273
  max: typeof constraints["max"] === "number" ? constraints["max"] : null,