@workerstack/database 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +95 -30
  2. package/docs/meta-contract.md +1 -1
  3. package/package.json +3 -2
  4. package/src/cli/args.ts +4 -2
  5. package/src/cli/codegen.ts +2 -2
  6. package/src/cli/comb-config.ts +8 -3
  7. package/src/cli/file-path.ts +25 -0
  8. package/src/cli/migrate-apply.ts +5 -21
  9. package/src/cli/migrate-d1.ts +5 -1
  10. package/src/cli/migrate-json.ts +48 -0
  11. package/src/cli/migrate.ts +26 -4
  12. package/src/formats/bcp47.ts +12 -0
  13. package/src/formats/catalog.ts +27 -4
  14. package/src/index.ts +1 -0
  15. package/src/migrate/core/apply.ts +63 -22
  16. package/src/migrate/core/atlas-apply-runs.ts +15 -26
  17. package/src/migrate/core/cleanup.ts +22 -10
  18. package/src/migrate/core/d1-engine-child.mjs +27 -11
  19. package/src/migrate/core/d1-history.ts +23 -26
  20. package/src/migrate/core/d1-wrangler-error.ts +24 -1
  21. package/src/migrate/core/pg-replay-commit.ts +41 -38
  22. package/src/migrate/core/pg-replay-drops.ts +17 -32
  23. package/src/migrate/core/pg-replay-hidden.ts +75 -0
  24. package/src/migrate/core/pg-replay-roles.ts +2 -4
  25. package/src/migrate/core/pg-replay.ts +21 -19
  26. package/src/migrate/core/pg-server-snapshot.ts +4 -24
  27. package/src/migrate/core/proof-readers.ts +80 -0
  28. package/src/migrate/core/proof-run.ts +19 -5
  29. package/src/migrate/core/proof-scope.ts +27 -14
  30. package/src/migrate/core/sql-code.ts +36 -5
  31. package/src/migrate/core/sql-scan.ts +7 -1
  32. package/src/migrate/core/sqlite-keywords.ts +70 -0
  33. package/src/migrate/core/stopped.ts +70 -0
  34. package/src/migrate/core/trigger-write.d.mts +7 -0
  35. package/src/migrate/core/trigger-write.mjs +42 -0
  36. package/src/query/cursor-mint.ts +11 -18
  37. package/src/query/cursor-refusals.ts +22 -11
  38. package/src/query/descriptions.ts +4 -9
  39. package/src/query/filter-parse.ts +80 -22
  40. package/src/query/filter-validate.ts +10 -13
  41. package/src/query/filter.ts +40 -18
  42. package/src/query/list-extras.ts +7 -10
  43. package/src/query/list-schema.ts +0 -2
  44. package/src/query/merged.ts +3 -2
  45. package/src/query/pagination.ts +30 -6
  46. package/src/query/rounded-key.ts +5 -8
  47. package/src/query/schema-params.ts +12 -5
  48. package/src/query/select-validate.ts +7 -4
  49. package/src/query/sqlite/cursor-fit.ts +10 -2
  50. package/src/query/trusted.ts +7 -2
  51. package/types/cli/args.d.ts.map +1 -1
  52. package/types/cli/comb-config.d.ts +5 -2
  53. package/types/cli/comb-config.d.ts.map +1 -1
  54. package/types/cli/file-path.d.ts +11 -0
  55. package/types/cli/file-path.d.ts.map +1 -0
  56. package/types/cli/migrate-apply.d.ts +1 -0
  57. package/types/cli/migrate-apply.d.ts.map +1 -1
  58. package/types/cli/migrate-d1.d.ts.map +1 -1
  59. package/types/cli/migrate-json.d.ts +4 -0
  60. package/types/cli/migrate-json.d.ts.map +1 -0
  61. package/types/cli/migrate.d.ts.map +1 -1
  62. package/types/formats/bcp47.d.ts +9 -0
  63. package/types/formats/bcp47.d.ts.map +1 -0
  64. package/types/formats/catalog.d.ts.map +1 -1
  65. package/types/index.d.ts +1 -0
  66. package/types/index.d.ts.map +1 -1
  67. package/types/migrate/core/apply.d.ts +3 -2
  68. package/types/migrate/core/apply.d.ts.map +1 -1
  69. package/types/migrate/core/atlas-apply-runs.d.ts +6 -0
  70. package/types/migrate/core/atlas-apply-runs.d.ts.map +1 -1
  71. package/types/migrate/core/cleanup.d.ts +16 -4
  72. package/types/migrate/core/cleanup.d.ts.map +1 -1
  73. package/types/migrate/core/d1-history.d.ts.map +1 -1
  74. package/types/migrate/core/d1-wrangler-error.d.ts +11 -0
  75. package/types/migrate/core/d1-wrangler-error.d.ts.map +1 -1
  76. package/types/migrate/core/pg-replay-commit.d.ts +14 -9
  77. package/types/migrate/core/pg-replay-commit.d.ts.map +1 -1
  78. package/types/migrate/core/pg-replay-drops.d.ts +7 -12
  79. package/types/migrate/core/pg-replay-drops.d.ts.map +1 -1
  80. package/types/migrate/core/pg-replay-hidden.d.ts +36 -0
  81. package/types/migrate/core/pg-replay-hidden.d.ts.map +1 -0
  82. package/types/migrate/core/pg-replay-roles.d.ts +2 -4
  83. package/types/migrate/core/pg-replay-roles.d.ts.map +1 -1
  84. package/types/migrate/core/pg-replay.d.ts.map +1 -1
  85. package/types/migrate/core/pg-server-snapshot.d.ts +4 -11
  86. package/types/migrate/core/pg-server-snapshot.d.ts.map +1 -1
  87. package/types/migrate/core/proof-readers.d.ts +19 -0
  88. package/types/migrate/core/proof-readers.d.ts.map +1 -0
  89. package/types/migrate/core/proof-run.d.ts.map +1 -1
  90. package/types/migrate/core/proof-scope.d.ts +15 -7
  91. package/types/migrate/core/proof-scope.d.ts.map +1 -1
  92. package/types/migrate/core/sql-code.d.ts +7 -3
  93. package/types/migrate/core/sql-code.d.ts.map +1 -1
  94. package/types/migrate/core/sql-scan.d.ts.map +1 -1
  95. package/types/migrate/core/sqlite-keywords.d.ts +11 -0
  96. package/types/migrate/core/sqlite-keywords.d.ts.map +1 -0
  97. package/types/migrate/core/stopped.d.ts +23 -0
  98. package/types/migrate/core/stopped.d.ts.map +1 -0
  99. package/types/query/cursor-mint.d.ts.map +1 -1
  100. package/types/query/cursor-refusals.d.ts +17 -1
  101. package/types/query/cursor-refusals.d.ts.map +1 -1
  102. package/types/query/descriptions.d.ts +1 -1
  103. package/types/query/descriptions.d.ts.map +1 -1
  104. package/types/query/filter-parse.d.ts +15 -4
  105. package/types/query/filter-parse.d.ts.map +1 -1
  106. package/types/query/filter-validate.d.ts +1 -1
  107. package/types/query/filter-validate.d.ts.map +1 -1
  108. package/types/query/filter.d.ts +9 -1
  109. package/types/query/filter.d.ts.map +1 -1
  110. package/types/query/list-extras.d.ts.map +1 -1
  111. package/types/query/list-schema.d.ts.map +1 -1
  112. package/types/query/merged.d.ts.map +1 -1
  113. package/types/query/pagination.d.ts +13 -1
  114. package/types/query/pagination.d.ts.map +1 -1
  115. package/types/query/rounded-key.d.ts +2 -11
  116. package/types/query/rounded-key.d.ts.map +1 -1
  117. package/types/query/schema-params.d.ts.map +1 -1
  118. package/types/query/select-validate.d.ts.map +1 -1
  119. package/types/query/sqlite/cursor-fit.d.ts.map +1 -1
  120. package/types/query/trusted.d.ts +1 -1
  121. package/types/query/trusted.d.ts.map +1 -1
@@ -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
+ }
@@ -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 }
@@ -1,12 +1,26 @@
1
1
  /**
2
2
  * What no cursor resumes after, refused by name when a cursor is minted: a computed sort, a tiebreak
3
3
  * id a JS number rounds, and a row without a sort value or its id, or with one no cursor carries.
4
+ * UnmintableRow tells a refusal of a row's own value from one of the list's wiring.
4
5
  */
5
6
  import { CombError } from "../error.ts"
6
7
  import { combErrorKeys } from "../types.ts"
7
8
  import { carried, decodeId, decodeValue } from "./cursor-codec.ts"
8
9
  import type { SortField } from "./types.ts"
9
10
 
11
+ /**
12
+ * A 500 naming a row's value no cursor holds: a key too long for the cap, an Invalid Date or a rounded
13
+ * number as read, a value the cursor would not read back as itself, a class its column's mode reads as
14
+ * another. A page by cursor answers it on the page that serves the row; a page by number serves the row
15
+ * and, when its next cursor would be minted from it, answers `nextCursor: null` with `hasMore` still
16
+ * true. Any other mint refusal is the list's wiring or config, and every page answers it
17
+ */
18
+ class UnmintableRow extends CombError {
19
+ constructor(cause: string) {
20
+ super({ cause, errorKey: combErrorKeys.SORT_NOT_WIRED, status: "internal_server_error" })
21
+ }
22
+ }
23
+
10
24
  /** Whether `value` is an integer a JS number rounds: past ±(2^53 − 1), a lossy read of the row's key */
11
25
  const lossy = (value: unknown): value is number =>
12
26
  typeof value === "number" && Number.isInteger(value) && !Number.isSafeInteger(value)
@@ -17,6 +31,7 @@ const lossy = (value: unknown): value is number =>
17
31
  */
18
32
  function refuseRoundedId(record: Record<string, unknown>, idField: string): void {
19
33
  if (!lossy(record[idField])) return
34
+ /* the tiebreak column's read mode, which every row of the list shares, and the row's own identity: no page serves it */
20
35
  throw new CombError({
21
36
  cause: `the tiebreak id ${idField} reads ${String(record[idField])}, past 2^53, where a JS number rounds it, so no cursor can find its row; read it exactly (bun:sqlite safeIntegers, libsql intMode: "bigint", drizzle's mode: "bigint" on Postgres), or on D1 key the table by a text id`,
22
37
  errorKey: combErrorKeys.SORT_NOT_WIRED,
@@ -71,18 +86,14 @@ function refuseUncarried(record: Record<string, unknown>, parsedSort: readonly S
71
86
  }
72
87
  const key = parsedSort.find(({ field }) => !carried(record[field], decodeValue))
73
88
  if (key !== undefined) {
74
- throw new CombError({
75
- cause: `the sort by ${key.field} reads ${kindOf(record[key.field])}, which no cursor carries: sort by a column of text, numbers, booleans, times or bytes`,
76
- errorKey: combErrorKeys.SORT_NOT_WIRED,
77
- status: "internal_server_error",
78
- })
89
+ throw new UnmintableRow(
90
+ `the sort by ${key.field} reads ${kindOf(record[key.field])}, which no cursor carries: sort by a column of text, numbers, booleans, times or bytes`,
91
+ )
79
92
  }
80
93
  if (carried(record[idField], decodeId)) return
81
- throw new CombError({
82
- cause: `the tiebreak id ${idField} reads ${kindOf(record[idField])}, which no cursor carries: key the table by text or an integer`,
83
- errorKey: combErrorKeys.SORT_NOT_WIRED,
84
- status: "internal_server_error",
85
- })
94
+ throw new UnmintableRow(
95
+ `the tiebreak id ${idField} reads ${kindOf(record[idField])}, which no cursor carries: key the table by text or an integer`,
96
+ )
86
97
  }
87
98
 
88
- export { lossy, NO_KEYSET_CAUSE, refuseComputedSort, refuseRoundedId, refuseUncarried }
99
+ export { lossy, NO_KEYSET_CAUSE, refuseComputedSort, refuseRoundedId, refuseUncarried, UnmintableRow }
@@ -7,7 +7,7 @@
7
7
  * same config that validates the request, so the text cannot drift from what
8
8
  * the parser accepts — and stays when a publisher strips `x-*` extensions.
9
9
  */
10
- import type { FilterLimits } from "./filter.ts"
10
+ import { type FilterLimits, orderPattern } from "./filter.ts"
11
11
  import { REQUEST_BYTES } from "./schema-limits.ts"
12
12
  import type { FieldsConfig } from "./schema-types.ts"
13
13
  import type { FieldType, FilterOperator } from "./types.ts"
@@ -133,10 +133,6 @@ function filterParamMeta(
133
133
  return { description, examples: [...examples] }
134
134
  }
135
135
 
136
- function escapeRegExp(value: string): string {
137
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
138
- }
139
-
140
136
  /** The `order` param's OpenAPI text over the sortable fields in `sort`, the first of which is the default, descending */
141
137
  function orderParamMeta(
142
138
  sort: readonly string[],
@@ -146,18 +142,17 @@ function orderParamMeta(
146
142
  pattern: string
147
143
  } {
148
144
  const [first = "", second] = sort
149
- const entry = `(?:${sort.map(escapeRegExp).join("|")})(?:\\.(?:asc|desc))?(?:\\.(?:nullsfirst|nullslast))?`
150
145
  return {
151
146
  description: [
152
- `Sort keys, comma-separated, each \`field[.asc|.desc][.nullsfirst|.nullslast]\`; at most ${maxSortKeys}.`,
153
- `Sortable: ${sort.join(", ")}. Default: ${first}.desc.`,
147
+ `Sort keys, comma-separated, each \`field[.asc|.desc][.nullsfirst|.nullslast]\` and each field at most once; at most ${maxSortKeys}.`,
148
+ `Sortable: ${sort.join(", ")}. Default, when absent or empty: ${first}.desc.`,
154
149
  "Nulls sort last ascending and first descending unless stated.",
155
150
  ].join(" "),
156
151
  /* a second key only where the endpoint takes two */
157
152
  examples: second
158
153
  ? [`${first}.desc`, maxSortKeys > 1 ? `${second}.asc,${first}.desc` : `${second}.asc`]
159
154
  : [`${first}.desc`, `${first}.asc`],
160
- pattern: `^${entry}(?:,${entry}){0,${Math.max(0, maxSortKeys - 1)}}$`,
155
+ pattern: orderPattern(sort, maxSortKeys),
161
156
  }
162
157
  }
163
158