@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
@@ -2,8 +2,9 @@
2
2
  * The server's shared catalogs read as committed, in one query: what a bare
3
3
  * statement changed, which no transaction counts, and what a second connection committed, which this
4
4
  * backend's counts never see (pg-server-writes.ts). The read is the server's, another session's work
5
- * in it too, so each row carries the names of the objects it is about, and a change is a file's only
6
- * when one is a name the file spells (namesIn in sql-code.ts). A replay's own rows are left out, but
5
+ * in it too, so each row carries the names of the objects it is about: a change is a file's when it
6
+ * is to the replay's own database, or to a name the file spells (namesIn in sql-code.ts) beside a
7
+ * statement that can run code comb cannot read (pg-replay-hidden.ts). A replay's own rows are left out, but
7
8
  * for the fields of its own database's row that keep the cleanup from dropping it, which only that
8
9
  * replay changes. Beside the catalogs, the server's replication slots, which no catalog
9
10
  * holds.
@@ -142,27 +143,6 @@ function snapshotChanges(before: Snapshot, after: Snapshot, names?: ReadonlySet<
142
143
  return changes
143
144
  }
144
145
 
145
- /** What the cleanup drops, by the noun its subject starts with */
146
- type Strays = { databases: string[]; roles: string[]; slots: string[] }
147
-
148
- // lookup-table: the nouns of what a file adds that the cleanup drops, by where it keeps them
149
- const DROPPED: ReadonlyArray<[keyof Strays, string]> = [
150
- ["databases", "database "],
151
- ["roles", "role "],
152
- ["slots", "replication slot "],
153
- ]
154
-
155
- /** The roles, databases and replication slots named in `names` that `after` holds and `before` does not */
156
- function added(before: Snapshot, after: Snapshot, names: ReadonlySet<string>): Strays {
157
- const out: Strays = { databases: [], roles: [], slots: [] }
158
- for (const subject of after.keys()) {
159
- if (before.has(subject) || !about(subject, { after, before }, names)) continue
160
- const [where, noun] = DROPPED.find(([, prefix]) => subject.startsWith(prefix)) ?? []
161
- if (where !== undefined) out[where].push(subject.slice(noun?.length))
162
- }
163
- return out
164
- }
165
-
166
146
  /** The subscriptions `after` holds in `database` and `before` does not, by subject: its drop takes them */
167
147
  const subscriptionsIn = (before: Snapshot, after: Snapshot, database: string): string[] =>
168
148
  [...after]
@@ -171,4 +151,4 @@ const subscriptionsIn = (before: Snapshot, after: Snapshot, database: string): s
171
151
  )
172
152
  .map(([subject]) => subject)
173
153
 
174
- export { added, prepareSnapshot, type Snapshot, snapshot, snapshotChanges, type Strays, subscriptionsIn }
154
+ export { prepareSnapshot, type Snapshot, snapshot, snapshotChanges, subscriptionsIn }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * What a file leaves unable to run: SQLite's DROP TABLE and DROP VIEW check no view or trigger that names what they
3
+ * drop, and SQLite, D1 and libsql resolve those names only when a statement runs. So a file can apply and leave every
4
+ * read of a view, or every write a trigger fires on, failing. After a file applies to the proof's copy, each view and
5
+ * trigger that survives it and names an object the file dropped or changed is compiled: a view's read, a trigger's
6
+ * own write on its table or view (trigger-write.mjs). SQLite resolves a name only by name, and the copy holds every
7
+ * reader that names a table a file reaches (proof-scope.ts), so this finds every reader the file breaks.
8
+ */
9
+ import type { LocalDb } from "../drivers/local-db.ts"
10
+ import type { LiveObject } from "./proof-copy.ts"
11
+ import { referencesOf } from "./proof-scope.ts"
12
+ import { asciiFold } from "./sql-code.ts"
13
+ import { quote, triggerEvent, writeOf } from "./trigger-write.mjs"
14
+
15
+ /** The copy's schema, each object by type and folded name */
16
+ function schemaOf(db: LocalDb): Map<string, LiveObject> {
17
+ const rows = db.all("SELECT type, name, sql, tbl_name FROM main.sqlite_master WHERE sql IS NOT NULL")
18
+ return new Map(
19
+ rows.map((row) => {
20
+ const object = {
21
+ name: String(row["name"]),
22
+ sql: String(row["sql"]),
23
+ table: String(row["tbl_name"]),
24
+ type: String(row["type"]),
25
+ }
26
+ return [`${object.type} ${asciiFold(object.name)}`, object]
27
+ }),
28
+ )
29
+ }
30
+
31
+ /** SQLite's error compiling `sql`, or undefined */
32
+ function compileError(db: LocalDb, sql: string): string | undefined {
33
+ try {
34
+ db.all(`EXPLAIN ${sql}`)
35
+ return undefined
36
+ } catch (error) {
37
+ // reason: what SQLite refuses to compile is the finding
38
+ return (error as Error).message
39
+ }
40
+ }
41
+
42
+ /** Why `reader`, a view or a trigger, fails on `db`, or undefined */
43
+ function brokenRead(db: LocalDb, reader: LiveObject): string | undefined {
44
+ if (reader.type === "view") {
45
+ const error = compileError(db, `SELECT * FROM ${quote(reader.name)}`)
46
+ return error === undefined ? undefined : `view ${reader.name} cannot be read: ${error}`
47
+ }
48
+ const event = triggerEvent(reader.sql)
49
+ const columns = db
50
+ .all(`PRAGMA main.table_xinfo(${quote(reader.table)})`)
51
+ .filter((row) => row["hidden"] === 0)
52
+ .map((row) => String(row["name"]))
53
+ const error = compileError(db, writeOf(reader.table, event, columns))
54
+ return error === undefined
55
+ ? undefined
56
+ : `every ${event} on ${reader.table} fails, as a trigger it fires cannot compile: ${error}`
57
+ }
58
+
59
+ /**
60
+ * Throws when a file that turned the copy's schema `before` into `db`'s leaves a view or trigger unable to run,
61
+ * naming it and SQLite's error: no approval overrides, since every read or write through it would fail
62
+ */
63
+ function refuseBrokenReaders(db: LocalDb, before: ReadonlyMap<string, LiveObject>): void {
64
+ const after = schemaOf(db)
65
+ const touched = new Set(
66
+ [...before]
67
+ .filter(([key, object]) => after.get(key)?.sql !== object.sql)
68
+ .map(([, object]) => asciiFold(object.name)),
69
+ )
70
+ if (touched.size === 0) return
71
+ for (const reader of after.values()) {
72
+ if (reader.type !== "view" && reader.type !== "trigger") continue
73
+ if (![...referencesOf(reader)].some((name) => touched.has(name))) continue
74
+ const broken = brokenRead(db, reader)
75
+ if (broken !== undefined)
76
+ throw new Error(`after this file, ${broken}; drop or change it in this file, or keep what it uses`)
77
+ }
78
+ }
79
+
80
+ export { refuseBrokenReaders, schemaOf }
@@ -12,7 +12,8 @@
12
12
  * Seeded rows cannot meet every predicate a file holds, so what each statement writes is read from
13
13
  * SQLite itself as it runs (proof-engine.ts): an update of a column that was there before, a
14
14
  * delete, a trigger fired or a temp shadow is unsafe whichever rows it meets, and a backfill of the
15
- * file's own columns is cautious. A file the copy cannot run is unproven, and unproven is unsafe.
15
+ * file's own columns is cautious. A file the copy cannot run is unproven, and unproven is unsafe. A
16
+ * file that leaves a view or trigger unable to run is refused outright (proof-readers.ts).
16
17
  */
17
18
  import { isMirror, mirrorFindings, mirrorRows } from "./proof-mirror.ts"
18
19
  import { type LocalDb, openScratch, type ScratchEngine } from "../drivers/local-db.ts"
@@ -21,6 +22,7 @@ import { notEngineTables } from "./engine-tables.ts"
21
22
  import { compare, type Shape } from "./proof-compare.ts"
22
23
  import { type Copy, copyOf, buildCopy, creatable, jsonColumns, type LiveObject, liveObjects } from "./proof-copy.ts"
23
24
  import { applyToCopy, engineFindings } from "./proof-engine.ts"
25
+ import { refuseBrokenReaders, schemaOf } from "./proof-readers.ts"
24
26
  import { quote } from "./proof-seed.ts"
25
27
  import { observable } from "./proof-scope.ts"
26
28
  import { asciiFold } from "./sql-code.ts"
@@ -174,7 +176,10 @@ function snapshotAfter(db: Copy["db"]): Map<string, Shape> | string {
174
176
  }
175
177
  }
176
178
 
177
- /** The proof of a file that applied as `run`, from the copy's tables and mirrored rows before it and its tables after */
179
+ /**
180
+ * The proof of file `name`, which applied as `run`, from the copy's tables, mirrored rows and schema before it and its
181
+ * tables after; throws, naming the file, when it leaves a view or trigger unable to run, which no approval overrides
182
+ */
178
183
  function appliedProof(
179
184
  copy: Copy,
180
185
  run: ReturnType<typeof applyToCopy>,
@@ -182,8 +187,17 @@ function appliedProof(
182
187
  after,
183
188
  before,
184
189
  mirrors,
185
- }: { after: Map<string, Shape>; before: Map<string, Shape>; mirrors: ReturnType<typeof mirrorRows> },
190
+ name,
191
+ schema,
192
+ }: {
193
+ after: Map<string, Shape>
194
+ before: Map<string, Shape>
195
+ mirrors: ReturnType<typeof mirrorRows>
196
+ name: string
197
+ schema: ReturnType<typeof schemaOf>
198
+ },
186
199
  ): Proof {
200
+ named(name, () => refuseBrokenReaders(copy.db, schema))
187
201
  const found = compare(copy.db, new Map([...before].filter(([table]) => !copy.unproven.has(table))), after)
188
202
  const steps = engineFindings(run.steps, run.start)
189
203
  return {
@@ -228,7 +242,7 @@ async function provePending(
228
242
  proofs.set(name, failedProof(copy, failed))
229
243
  continue
230
244
  }
231
- let [before, mirrors] = [snapshot(copy.db), mirrorRows(copy.db)]
245
+ let [before, mirrors, schema] = [snapshot(copy.db), mirrorRows(copy.db), schemaOf(copy.db)]
232
246
  let run = named(name, () => applyToCopy(copy.db, sql))
233
247
  const retry = run.error === undefined ? undefined : await reseeded(objects, applied, making)
234
248
  if (retry !== undefined) {
@@ -263,7 +277,7 @@ async function provePending(
263
277
  failed = `an earlier pending file, ${name}, left a table SQLite cannot read, so this one is unproven`
264
278
  continue
265
279
  }
266
- proofs.set(name, appliedProof(copy, run, { after, before, mirrors }))
280
+ proofs.set(name, appliedProof(copy, run, { after, before, mirrors, name, schema }))
267
281
  }
268
282
  } finally {
269
283
  copy.db.close()
@@ -3,15 +3,17 @@
3
3
  * can observe, created, and the tables whose rows they can observe, seeded, so the copy refuses what the whole
4
4
  * schema would and costs what the files reach.
5
5
  *
6
- * - An object's references are every name its text spells (sql-code's lexer, compared folded, never as a substring)
7
- * and, for a virtual table, each string and quoted name among its module's arguments, whole. A table's FK parents
8
- * are the other tables it references; a reader is a view, a trigger or a virtual table, and reads what it references.
6
+ * - An object's references are every name its text spells where SQLite's grammar takes a name (sql-code's lexer,
7
+ * compared folded, never as a substring: the `TABLE` of `CREATE TABLE` is no name) and, for a virtual table, each
8
+ * word, string and quoted name among its module's arguments, whole. A table's FK parents are the other tables it
9
+ * references; a reader is a view, a trigger or a virtual table, and reads what it references.
9
10
  * - N: each live object whose name a file spells, and the history table; S is N's tables, which a file creates,
10
11
  * alters, drops, renames or writes.
11
12
  * - Rows go to S, each table a reader reached from S references (to a fixpoint), D (their FK descendants: a cascade
12
13
  * reaches them) and A (the FK ancestors of all those: their keys must be met).
13
14
  * - The copy creates N, the seeded tables, and each reader that references an S table (SQLite checks views and
14
- * triggers on DROP COLUMN and RENAME COLUMN, and a rename rewrites them). Then to a fixpoint each index and trigger
15
+ * triggers on DROP COLUMN and RENAME COLUMN, and a rename rewrites them) or a view of N (a file that drops the
16
+ * view leaves the reader unable to run, which the proof compiles it to see). Then to a fixpoint each index and trigger
15
17
  * on a created table or view (a view's INSTEAD OF triggers carry out a write through it), each table and view a
16
18
  * created reader references, and each created table's FK ancestors, which a write to it compiles checks against.
17
19
  * - Reflection reads the schema itself: a name with SQLite's reserved `sqlite_` prefix (its catalogs, sqlite_sequence,
@@ -66,24 +68,31 @@ const nameOf = ({ kind, text }: Token): string =>
66
68
  asciiFold(kind === "literal" ? text.slice(1, -1).replaceAll("''", "'") : unquote(text))
67
69
 
68
70
  /**
69
- * Virtual table `object`'s module, the name after USING however it is written, and each string and quoted name among
70
- * the module's arguments, which the module may read as a table's name (fts5vocab, content=)
71
+ * Virtual table `object`'s module, the name after USING however it is written, each string and quoted name among
72
+ * the module's arguments, which the module may read as a table's name (fts5vocab, content=), and each bare word there
71
73
  */
72
- function virtualOf(object: LiveObject): { args: string[]; module: string } | undefined {
74
+ function virtualOf(object: LiveObject): { args: string[]; module: string; words: string[] } | undefined {
73
75
  if (!isVirtual(object)) return undefined
74
76
  const tokens = tokensOf(object.sql)
75
77
  const at = tokens.findIndex(({ kind, text }) => kind === "code" && /^using$/i.test(text)) + 1
76
- const args = tokens[at + 1]?.text === "(" ? tokens.slice(at + 2).filter(({ kind }) => kind !== "code") : []
77
- return { args: args.map(nameOf), module: nameOf(tokens[at] as Token) }
78
+ const inside = tokens[at + 1]?.text === "(" ? tokens.slice(at + 2) : []
79
+ return {
80
+ args: inside.filter(({ kind }) => kind !== "code").map(nameOf),
81
+ module: nameOf(tokens[at] as Token),
82
+ words: inside.filter(({ kind, text }) => kind === "code" && /^[A-Za-z_\u0080-\uffff]/.test(text)).map(nameOf),
83
+ }
78
84
  }
79
85
 
80
86
  /** The folded names of the shadow tables virtual table `object`'s module makes */
81
87
  const shadowsOf = (object: LiveObject): string[] =>
82
88
  (SHADOWS[virtualOf(object)?.module as string] ?? []).map((suffix) => asciiFold(`${object.name}_${suffix}`))
83
89
 
84
- /** Every name `object` references, folded (see the module's comment) */
90
+ /**
91
+ * Every name `object` references, folded (see the module's comment). A virtual table's module reads its own arguments,
92
+ * not SQL, so each bare word among them may name a table, `group` in `content=group` among them
93
+ */
85
94
  const referencesOf = (object: LiveObject): Set<string> =>
86
- new Set([...sqliteNames(object.sql), ...(virtualOf(object)?.args ?? [])])
95
+ new Set([...sqliteNames(object.sql), ...(virtualOf(object)?.args ?? []), ...(virtualOf(object)?.words ?? [])])
87
96
 
88
97
  /* a name SQLite reserves for its own tables, or a pragma's table-valued function: either reads the schema itself */
89
98
  const REFLECTION = /^(?:sqlite_|pragma_)/
@@ -216,9 +225,13 @@ function observable(
216
225
  const statements = files.flatMap(({ sql }) => splitSqlStatements(sql))
217
226
  const checks = objects.filter((object) => object.type === "view" || object.type === "trigger")
218
227
  /* a rename rewrites the text of each view and trigger naming an S table, and D1 then compiles the writes to its
219
- table; a virtual table reading one reads the rows a file writes */
228
+ table; a virtual table reading one reads the rows a file writes; a drop of a view of N breaks each reading it */
229
+ const reached = new Set([
230
+ ...s,
231
+ ...n.filter((object) => object.type === "view").map((object) => asciiFold(object.name)),
232
+ ])
220
233
  const rewritten = objects.filter(
221
- (object) => isReader(object) && [...(graph.names.get(object) as Set<string>)].some((name) => s.has(name)),
234
+ (object) => isReader(object) && [...(graph.names.get(object) as Set<string>)].some((name) => reached.has(name)),
222
235
  )
223
236
  /* a write to a created table compiles its keys' checks, which need each parent table */
224
237
  let made = forward(graph, [...n, ...objectsOf(graph, seeded), ...rewritten])
@@ -240,4 +253,4 @@ function observable(
240
253
  return { objects: kept, seeded }
241
254
  }
242
255
 
243
- export { isVirtual, observable, type Scope, shadowsOf, virtualOf }
256
+ export { isVirtual, observable, referencesOf, type Scope, shadowsOf, virtualOf }
@@ -4,7 +4,8 @@
4
4
  * an action list splits only at its top-level commas.
5
5
  */
6
6
  import type { Dialect } from "../types.ts"
7
- import { bodyText, joined, segments } from "../../sql-lexer.ts"
7
+ import { bodyText, segments } from "../../sql-lexer.ts"
8
+ import { NEVER_NAMES } from "./sqlite-keywords.ts"
8
9
 
9
10
  /** One name part: double-quoted, backtick or bracket quoted, or bare */
10
11
  const IDENT: string = `(?:"(?:[^"]|"")*"|\`[^\`]*\`|\\[[^\\]]*\\]|[A-Za-z_\\u0080-\\uffff][\\w$\\u0080-\\uffff]*)`
@@ -100,13 +101,43 @@ function pragmaOf(code: string): Pragma | undefined {
100
101
  const qualifiedKey = (name: string): string =>
101
102
  [...name.matchAll(PARTS)].map((match) => partKey(match[0], "sqlite")).join(".")
102
103
 
104
+ /* the words and punctuation of SQLite code: a bare word, or one character that breaks a run of words */
105
+ const CODE_TOKEN = /[A-Za-z_\u0080-\uffff][\w$\u0080-\uffff]*|\S/g
106
+
103
107
  /**
104
- * Every name SQLite code can spell, as SQLite resolves one: each bare word and each quoted name unquoted, and each
105
- * word of a string, which SQLite reads as a name where one stands (`FROM 'post'`) and a module as its option's
106
- * (`content='post'`), folded by asciiFold; never a word of a comment
108
+ * Every name SQLite code can spell, as SQLite resolves one: each quoted name unquoted, each word of a string, which
109
+ * SQLite reads as a name where one stands (`FROM 'post'`) and a module as its option's (`content='post'`), and each
110
+ * bare word that can be one, all folded by asciiFold; never a word of a comment. A bare keyword is no name
111
+ * where SQLite's grammar takes none: `TABLE`, `CREATE`, `NULL` and the rest of NEVER_NAMES whenever it is bare, and
112
+ * `KEY` after `PRIMARY` or `FOREIGN`, which no name can follow. So a table named `table` is named by `"table"` and
113
+ * not by the `CREATE TABLE` of every file. Any other bare word may be a name and is read as one: a name read
114
+ * where there is none only widens what a proof copies, a name missed would narrow it
107
115
  */
108
116
  function sqliteNames(sql: string): Set<string> {
109
- return new Set([...joined(sql, "sqlite", true).matchAll(PARTS)].map((match) => partKey(match[0], "sqlite")))
117
+ const names = new Set<string>()
118
+ const quoted = (text: string) => {
119
+ for (const match of text.matchAll(PARTS)) names.add(partKey(match[0], "sqlite"))
120
+ }
121
+ let before: string | undefined
122
+ for (const { kind, text } of segments(sql, "sqlite")) {
123
+ if (kind === "comment") continue
124
+ if (kind !== "code") {
125
+ quoted(text)
126
+ before = undefined
127
+ continue
128
+ }
129
+ for (const [token] of text.matchAll(CODE_TOKEN)) {
130
+ if (!/^[A-Za-z_\u0080-\uffff]/.test(token)) {
131
+ before = undefined
132
+ continue
133
+ }
134
+ const word = asciiFold(token)
135
+ const keyword = NEVER_NAMES.has(word) || (word === "key" && (before === "primary" || before === "foreign"))
136
+ if (!keyword) names.add(word)
137
+ before = word
138
+ }
139
+ }
140
+ return names
110
141
  }
111
142
 
112
143
  /* a bare word in Postgres code, which may be a name */
@@ -67,9 +67,15 @@ function readCode(reading: Reading, text: string): string[] {
67
67
  return done
68
68
  }
69
69
 
70
+ /* SQLITE_SPACE's whitespace at either end of a statement */
71
+ const SQLITE_EDGE_SPACE = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g
72
+
70
73
  /** The statement read so far, reset for the next; empty when it held no code */
71
74
  function finish(reading: Reading): string {
72
- const statement = reading.code.replace(/;/g, "").trim() === "" ? "" : reading.text.trim()
75
+ const statement =
76
+ reading.code.replace(/;/g, "").replace(SQLITE_EDGE_SPACE, "") === ""
77
+ ? ""
78
+ : reading.text.replace(SQLITE_EDGE_SPACE, "")
73
79
  reading.code = ""
74
80
  reading.state = 0
75
81
  reading.text = ""
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The words SQLite reads only as keywords when they stand bare in code: no table, column, index, view,
3
+ * trigger or alias can be named by one unless it is quoted. The other keywords (`key`, `end`, `action`, `left`,
4
+ * `if`) SQLite takes as a name where its grammar wants one, so a bare one may be a name and is read as one.
5
+ * The list is the engine's: tests/sqlite-keywords.test.ts holds every word here to SQLite's refusal as each kind of
6
+ * name, in node:sqlite's SQLite and libsql's. `nothing` and `returning` are left out, as an SQLite before 3.24 and
7
+ * 3.35 reads them as plain names.
8
+ */
9
+
10
+ // lookup-table: the keywords no bare name can be, lower case, by the engine's own refusal
11
+ const NEVER_NAMES: ReadonlySet<string> = /* @__PURE__ */ new Set([
12
+ "add",
13
+ "all",
14
+ "alter",
15
+ "and",
16
+ "as",
17
+ "autoincrement",
18
+ "between",
19
+ "case",
20
+ "check",
21
+ "collate",
22
+ "commit",
23
+ "constraint",
24
+ "create",
25
+ "default",
26
+ "deferrable",
27
+ "delete",
28
+ "distinct",
29
+ "drop",
30
+ "else",
31
+ "escape",
32
+ "except",
33
+ "exists",
34
+ "foreign",
35
+ "from",
36
+ "group",
37
+ "having",
38
+ "in",
39
+ "index",
40
+ "insert",
41
+ "intersect",
42
+ "into",
43
+ "is",
44
+ "isnull",
45
+ "join",
46
+ "limit",
47
+ "not",
48
+ "notnull",
49
+ "null",
50
+ "on",
51
+ "or",
52
+ "order",
53
+ "primary",
54
+ "references",
55
+ "select",
56
+ "set",
57
+ "table",
58
+ "then",
59
+ "to",
60
+ "transaction",
61
+ "union",
62
+ "unique",
63
+ "update",
64
+ "using",
65
+ "values",
66
+ "when",
67
+ "where",
68
+ ])
69
+
70
+ export { NEVER_NAMES }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The one report of a run a signal stopped: which files applied and which did not, as an
3
+ * object both of a command's outputs render, the human line and the `--json` object. The loop that
4
+ * applies files holds a step for the length of its work; when a signal comes, the step reads what
5
+ * committed once the work in flight has ended, prints the line and returns the report, which the
6
+ * command's own step, held before the loop's and so run after it, reads and prints as JSON. A stop
7
+ * parks the loop itself (cleanup.ts pastStop), so a run it stopped never throws its own report.
8
+ */
9
+ import { hold } from "./cleanup.ts"
10
+ import { Refused } from "./refused.ts"
11
+
12
+ /** The files a stop found applied by the run, and left pending */
13
+ type Found = { applied: string[]; pending: string[] }
14
+
15
+ /** A run a signal stopped: the signal, and as a Refused the files it applied, left, or may have applied */
16
+ export class Stopped extends Refused {
17
+ readonly signal: NodeJS.Signals
18
+
19
+ constructor(signal: NodeJS.Signals, message: string, files: ConstructorParameters<typeof Refused>[1]) {
20
+ super(message, files)
21
+ this.name = "Stopped"
22
+ this.signal = signal
23
+ }
24
+ }
25
+
26
+ const list = (names: readonly string[]) => names.join(", ") || "none"
27
+
28
+ /**
29
+ * The report of a run `signal` stopped, whose approved files are `names`: what `read` found, or,
30
+ * when it cannot read, every one of `names` as unknown, since any of them may have committed
31
+ */
32
+ async function reportOf(
33
+ signal: NodeJS.Signals,
34
+ { names, read }: { names: readonly string[]; read: () => Promise<Found> },
35
+ ): Promise<Stopped> {
36
+ try {
37
+ const { applied, pending } = await read()
38
+ return new Stopped(signal, `Stopped by ${signal}: applied ${list(applied)}; not applied ${list(pending)}`, {
39
+ applied,
40
+ pending,
41
+ })
42
+ } catch (error) {
43
+ return new Stopped(
44
+ signal,
45
+ `Stopped by ${signal}: comb could not read what was applied: ${(error as Error).message}`,
46
+ { pending: [], unknown: names },
47
+ )
48
+ }
49
+ }
50
+
51
+ /**
52
+ * `work`, with a step held while it runs that, on a signal, says on `print` which of `names` the run
53
+ * applied and which it did not, as `read` finds them once the work in flight has ended (cleanup.ts
54
+ * runs a child's own step before this one), and returns the report. The process then ends by the signal.
55
+ */
56
+ export async function whileStoppable<T>(
57
+ { names, print, read }: { names: readonly string[]; print: (line: string) => void; read: () => Promise<Found> },
58
+ work: () => Promise<T>,
59
+ ): Promise<T> {
60
+ const release = hold(async (signal) => {
61
+ const stopped = await reportOf(signal, { names, read })
62
+ print(stopped.message)
63
+ return stopped
64
+ })
65
+ try {
66
+ return await work()
67
+ } finally {
68
+ release()
69
+ }
70
+ }
@@ -0,0 +1,7 @@
1
+ /** A write a table takes */
2
+ export type Event = "DELETE" | "INSERT" | "UPDATE"
3
+
4
+ export const EVENTS: readonly ["INSERT", "UPDATE", "DELETE"]
5
+ export function quote(name: string): string
6
+ export function triggerEvent(sql: string): Event
7
+ export function writeOf(table: string, event: Event, columns: readonly string[]): string
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The write that compiles a trigger: SQLite compiles a trigger's body into the program of each statement of its
3
+ * event on its table or view, so `EXPLAIN` of that statement resolves every name the body uses and every function
4
+ * it calls. A view takes a write only for an event an INSTEAD OF trigger on it fires on; for any other event SQLite
5
+ * answers "cannot modify ... because it is a view", and no program exists to fail. Plain JavaScript, shared by the
6
+ * D1 proof's child (d1-engine-child.mjs), which node runs untransformed, and the SQLite proof (proof-readers.ts).
7
+ */
8
+
9
+ /** The writes a table takes, in the order the proofs compile them */
10
+ export const EVENTS = /** @type {const} */ (["INSERT", "UPDATE", "DELETE"])
11
+
12
+ /** @param {string} name */
13
+ export const quote = (name) => `"${name.replaceAll('"', '""')}"`
14
+
15
+ /* what in SQL text may spell any word: a comment, a string, a quoted name */
16
+ const HIDDEN = /--[^\n]*|\/\*[\s\S]*?(?:\*\/|$)|'(?:[^']|'')*'|"(?:[^"]|"")*"|`(?:[^`]|``)*`|\[[^\]]*\]/g
17
+
18
+ /**
19
+ * The event of trigger text `sql`, as sqlite_master holds it: the first bare INSERT, UPDATE or DELETE outside a
20
+ * comment, a string and a quoted name. CREATE TRIGGER's grammar puts the event before ON and the body, and no bare
21
+ * name before it can be one of those keywords
22
+ * @param {string} sql
23
+ * @returns {"DELETE" | "INSERT" | "UPDATE"}
24
+ */
25
+ export function triggerEvent(sql) {
26
+ const event = /** @type {RegExpExecArray} */ (/\b(DELETE|INSERT|UPDATE)\b/i.exec(sql.replace(HIDDEN, " ")))
27
+ return /** @type {"DELETE" | "INSERT" | "UPDATE"} */ (/** @type {string} */ (event[1]).toUpperCase())
28
+ }
29
+
30
+ /**
31
+ * The statement of `event` on `table` that compiles each trigger it fires and each generated column it computes:
32
+ * an insert of defaults, an update assigning each of `columns` (the stored and virtual ones, by name) to itself, a
33
+ * delete
34
+ * @param {string} table
35
+ * @param {"DELETE" | "INSERT" | "UPDATE"} event
36
+ * @param {readonly string[]} columns
37
+ */
38
+ export function writeOf(table, event, columns) {
39
+ if (event === "INSERT") return `INSERT INTO ${quote(table)} DEFAULT VALUES`
40
+ if (event === "DELETE") return `DELETE FROM ${quote(table)}`
41
+ return `UPDATE ${quote(table)} SET ${columns.map((column) => `${quote(column)} = ${quote(column)}`).join(", ")}`
42
+ }
package/src/pg/columns.ts CHANGED
@@ -31,6 +31,8 @@ import type {
31
31
  EnumOptions,
32
32
  IntegerConstraints,
33
33
  JsonConstraints,
34
+ RealConstraints,
35
+ RefConstraints,
34
36
  TextConstraints,
35
37
  TimestampConstraints,
36
38
  } from "../sqlite/constraints.ts"
@@ -100,9 +102,9 @@ export type PgColumns = {
100
102
  _constraints?: JsonConstraints,
101
103
  ) => Set$Type<PgJsonbBuilder, z.infer<TSchema>>
102
104
  /** a floating-point number, stored as double precision */
103
- real: (name: string, _constraints?: IntegerConstraints) => PgDoublePrecisionBuilder
105
+ real: (name: string, _constraints?: RealConstraints) => PgDoublePrecisionBuilder
104
106
  /** a text foreign key; chain `.references()` to name the column it points at */
105
- ref: (name: string, _constraints?: TextConstraints) => Text
107
+ ref: (name: string, _constraints?: RefConstraints) => Text
106
108
  /** the auto-incrementing 4-byte `serial` primary key `id`, filled by the database */
107
109
  serialId: () => SetIsPrimaryKey<PgSerialBuilder>
108
110
  /**
@@ -158,11 +160,11 @@ export const c: PgColumns = {
158
160
  withCombColumn(jsonb(name).$type<z.infer<TSchema>>(), { constraints, kind: "json", schema }),
159
161
 
160
162
  /* Floating point (double precision) */
161
- real: (name: string, constraints?: IntegerConstraints) =>
163
+ real: (name: string, constraints?: RealConstraints) =>
162
164
  withCombColumn(doublePrecision(name), { constraints, kind: "real" }),
163
165
 
164
166
  /* Foreign key reference - use with .references() */
165
- ref: (name: string, constraints?: TextConstraints) => withCombColumn(text(name), { constraints, kind: "ref" }),
167
+ ref: (name: string, constraints?: RefConstraints) => withCombColumn(text(name), { constraints, kind: "ref" }),
166
168
 
167
169
  /* Auto-increment integer primary key */
168
170
  serialId: () => withCombColumn(serial("id").primaryKey(), { kind: "serialId" }),
@@ -3,13 +3,12 @@
3
3
  * MAX_CURSOR_LENGTH, a value too long for it carried by reference (cursor-ref.ts) on a table's
4
4
  * list, and refused on a derived list, which no row holds to read a reference back from.
5
5
  */
6
- import { CombError } from "../error.ts"
7
- import { combErrorKeys } from "../types.ts"
8
6
  import { cursorBytes, CursorRef, MAX_CURSOR_LENGTH } from "./cursor.ts"
9
7
  import { readsApart } from "./cursor-reads.ts"
10
8
  import { type CursorOptions, seal, sealedLength } from "./cursor-seal.ts"
11
9
  import { roundedKey } from "./rounded-key.ts"
12
10
  import type { SortField } from "./types.ts"
11
+ import { UnmintableRow } from "./cursor-refusals.ts"
13
12
 
14
13
  /**
15
14
  * The open's column check a builder hands the mint: the index of the first value, the tiebreak id last,
@@ -37,7 +36,7 @@ function longestFitting(record: Record<string, unknown>, field: string, fits: (r
37
36
  }
38
37
 
39
38
  /** The overflow, as paginate reports it on the page that met it: a 500 naming the field and the cap */
40
- function tooLong(fields: string, length: number, kind: KeysetKind | "unread"): CombError {
39
+ function tooLong(fields: string, length: number, kind: KeysetKind | "unread"): UnmintableRow {
41
40
  const reasons = {
42
41
  derived: "a derived list reads no row to page by reference, so sort it by a key of bounded length",
43
42
  merged:
@@ -47,11 +46,9 @@ function tooLong(fields: string, length: number, kind: KeysetKind | "unread"): C
47
46
  "its long key is a column not of the anchor table, or an expression over one, so no reference reads its value back; sort by a key of bounded length, or by a column of the anchor table",
48
47
  }
49
48
  const reason = reasons[kind]
50
- return new CombError({
51
- cause: `a row's ${fields} is too long to page this list by: its cursor would be ${length} characters, past ${MAX_CURSOR_LENGTH}; ${reason}`,
52
- errorKey: combErrorKeys.SORT_NOT_WIRED,
53
- status: "internal_server_error",
54
- })
49
+ return new UnmintableRow(
50
+ `a row's ${fields} is too long to page this list by: its cursor would be ${length} characters, past ${MAX_CURSOR_LENGTH}; ${reason}`,
51
+ )
55
52
  }
56
53
 
57
54
  /**
@@ -159,11 +156,9 @@ function refuseInvalidDate(
159
156
  ({ field }) => at.asRead(field) && row[field] instanceof Date && Number.isNaN((row[field] as Date).getTime()),
160
157
  )
161
158
  if (unheld === undefined) return
162
- throw new CombError({
163
- cause: `the sort by ${unheld.field} reads a date no JS Date holds (a Postgres 'infinity' read in date mode, or a SQLite timestamp holding ±Infinity), which this list has no row to read back by, ${at.derived ? "a derived list having none" : "its key being no column of the anchor table"}; select it in string mode or sort by another column`,
164
- errorKey: combErrorKeys.SORT_NOT_WIRED,
165
- status: "internal_server_error",
166
- })
159
+ throw new UnmintableRow(
160
+ `the sort by ${unheld.field} reads a date no JS Date holds (a Postgres 'infinity' read in date mode, or a SQLite timestamp holding ±Infinity), which this list has no row to read back by, ${at.derived ? "a derived list having none" : "its key being no column of the anchor table"}; select it in string mode or sort by another column`,
161
+ )
167
162
  }
168
163
 
169
164
  /** A 500 naming the first value of `record` its column does not hold (ColumnCheck), the cursor the next page would refuse */
@@ -179,11 +174,9 @@ function refuseUnfit(
179
174
  const index = options.holds(values, (i) => options.carried?.has(sort[i]?.field ?? "") ?? false)
180
175
  if (index === -1) return
181
176
  const named = sort[index] === undefined ? `the tiebreak id ${idField}` : `the sort by ${sort[index].field}`
182
- throw new CombError({
183
- cause: `${named} reads a value its column's mode does not hold (a JSON-mode key's, or a SQLite column's of another storage class than its mode reads), which no cursor of this list resumes after; sort by another column`,
184
- errorKey: combErrorKeys.SORT_NOT_WIRED,
185
- status: "internal_server_error",
186
- })
177
+ throw new UnmintableRow(
178
+ `${named} reads a value its column's mode does not hold (a JSON-mode key's, or a SQLite column's of another storage class than its mode reads), which no cursor of this list resumes after; sort by another column`,
179
+ )
187
180
  }
188
181
 
189
182
  export { type ColumnCheck, createCursor, fitCursor, fittedCursor, type KeysetKind }