@workerstack/database 0.22.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 (112) hide show
  1. package/README.md +46 -7
  2. package/package.json +3 -2
  3. package/src/cli/args.ts +4 -2
  4. package/src/cli/codegen.ts +2 -2
  5. package/src/cli/comb-config.ts +8 -3
  6. package/src/cli/file-path.ts +25 -0
  7. package/src/cli/migrate-apply.ts +5 -21
  8. package/src/cli/migrate-d1.ts +5 -1
  9. package/src/cli/migrate-json.ts +48 -0
  10. package/src/cli/migrate.ts +26 -4
  11. package/src/migrate/core/apply.ts +63 -22
  12. package/src/migrate/core/atlas-apply-runs.ts +15 -26
  13. package/src/migrate/core/cleanup.ts +22 -10
  14. package/src/migrate/core/d1-engine-child.mjs +27 -11
  15. package/src/migrate/core/d1-history.ts +23 -26
  16. package/src/migrate/core/d1-wrangler-error.ts +24 -1
  17. package/src/migrate/core/pg-replay-commit.ts +41 -38
  18. package/src/migrate/core/pg-replay-drops.ts +17 -32
  19. package/src/migrate/core/pg-replay-hidden.ts +75 -0
  20. package/src/migrate/core/pg-replay-roles.ts +2 -4
  21. package/src/migrate/core/pg-replay.ts +21 -19
  22. package/src/migrate/core/pg-server-snapshot.ts +4 -24
  23. package/src/migrate/core/proof-readers.ts +80 -0
  24. package/src/migrate/core/proof-run.ts +19 -5
  25. package/src/migrate/core/proof-scope.ts +27 -14
  26. package/src/migrate/core/sql-code.ts +36 -5
  27. package/src/migrate/core/sql-scan.ts +7 -1
  28. package/src/migrate/core/sqlite-keywords.ts +70 -0
  29. package/src/migrate/core/stopped.ts +70 -0
  30. package/src/migrate/core/trigger-write.d.mts +7 -0
  31. package/src/migrate/core/trigger-write.mjs +42 -0
  32. package/src/query/cursor-mint.ts +11 -18
  33. package/src/query/cursor-refusals.ts +22 -11
  34. package/src/query/descriptions.ts +4 -9
  35. package/src/query/filter-parse.ts +80 -22
  36. package/src/query/filter-validate.ts +10 -13
  37. package/src/query/filter.ts +40 -18
  38. package/src/query/list-extras.ts +7 -10
  39. package/src/query/list-schema.ts +0 -2
  40. package/src/query/merged.ts +3 -2
  41. package/src/query/pagination.ts +30 -6
  42. package/src/query/rounded-key.ts +5 -8
  43. package/src/query/schema-params.ts +12 -5
  44. package/src/query/select-validate.ts +7 -4
  45. package/src/query/sqlite/cursor-fit.ts +10 -2
  46. package/src/query/trusted.ts +7 -2
  47. package/types/cli/args.d.ts.map +1 -1
  48. package/types/cli/comb-config.d.ts +5 -2
  49. package/types/cli/comb-config.d.ts.map +1 -1
  50. package/types/cli/file-path.d.ts +11 -0
  51. package/types/cli/file-path.d.ts.map +1 -0
  52. package/types/cli/migrate-apply.d.ts +1 -0
  53. package/types/cli/migrate-apply.d.ts.map +1 -1
  54. package/types/cli/migrate-d1.d.ts.map +1 -1
  55. package/types/cli/migrate-json.d.ts +4 -0
  56. package/types/cli/migrate-json.d.ts.map +1 -0
  57. package/types/cli/migrate.d.ts.map +1 -1
  58. package/types/migrate/core/apply.d.ts +3 -2
  59. package/types/migrate/core/apply.d.ts.map +1 -1
  60. package/types/migrate/core/atlas-apply-runs.d.ts +6 -0
  61. package/types/migrate/core/atlas-apply-runs.d.ts.map +1 -1
  62. package/types/migrate/core/cleanup.d.ts +16 -4
  63. package/types/migrate/core/cleanup.d.ts.map +1 -1
  64. package/types/migrate/core/d1-history.d.ts.map +1 -1
  65. package/types/migrate/core/d1-wrangler-error.d.ts +11 -0
  66. package/types/migrate/core/d1-wrangler-error.d.ts.map +1 -1
  67. package/types/migrate/core/pg-replay-commit.d.ts +14 -9
  68. package/types/migrate/core/pg-replay-commit.d.ts.map +1 -1
  69. package/types/migrate/core/pg-replay-drops.d.ts +7 -12
  70. package/types/migrate/core/pg-replay-drops.d.ts.map +1 -1
  71. package/types/migrate/core/pg-replay-hidden.d.ts +36 -0
  72. package/types/migrate/core/pg-replay-hidden.d.ts.map +1 -0
  73. package/types/migrate/core/pg-replay-roles.d.ts +2 -4
  74. package/types/migrate/core/pg-replay-roles.d.ts.map +1 -1
  75. package/types/migrate/core/pg-replay.d.ts.map +1 -1
  76. package/types/migrate/core/pg-server-snapshot.d.ts +4 -11
  77. package/types/migrate/core/pg-server-snapshot.d.ts.map +1 -1
  78. package/types/migrate/core/proof-readers.d.ts +19 -0
  79. package/types/migrate/core/proof-readers.d.ts.map +1 -0
  80. package/types/migrate/core/proof-run.d.ts.map +1 -1
  81. package/types/migrate/core/proof-scope.d.ts +15 -7
  82. package/types/migrate/core/proof-scope.d.ts.map +1 -1
  83. package/types/migrate/core/sql-code.d.ts +7 -3
  84. package/types/migrate/core/sql-code.d.ts.map +1 -1
  85. package/types/migrate/core/sql-scan.d.ts.map +1 -1
  86. package/types/migrate/core/sqlite-keywords.d.ts +11 -0
  87. package/types/migrate/core/sqlite-keywords.d.ts.map +1 -0
  88. package/types/migrate/core/stopped.d.ts +23 -0
  89. package/types/migrate/core/stopped.d.ts.map +1 -0
  90. package/types/query/cursor-mint.d.ts.map +1 -1
  91. package/types/query/cursor-refusals.d.ts +17 -1
  92. package/types/query/cursor-refusals.d.ts.map +1 -1
  93. package/types/query/descriptions.d.ts +1 -1
  94. package/types/query/descriptions.d.ts.map +1 -1
  95. package/types/query/filter-parse.d.ts +15 -4
  96. package/types/query/filter-parse.d.ts.map +1 -1
  97. package/types/query/filter-validate.d.ts +1 -1
  98. package/types/query/filter-validate.d.ts.map +1 -1
  99. package/types/query/filter.d.ts +9 -1
  100. package/types/query/filter.d.ts.map +1 -1
  101. package/types/query/list-extras.d.ts.map +1 -1
  102. package/types/query/list-schema.d.ts.map +1 -1
  103. package/types/query/merged.d.ts.map +1 -1
  104. package/types/query/pagination.d.ts +13 -1
  105. package/types/query/pagination.d.ts.map +1 -1
  106. package/types/query/rounded-key.d.ts +2 -11
  107. package/types/query/rounded-key.d.ts.map +1 -1
  108. package/types/query/schema-params.d.ts.map +1 -1
  109. package/types/query/select-validate.d.ts.map +1 -1
  110. package/types/query/sqlite/cursor-fit.d.ts.map +1 -1
  111. package/types/query/trusted.d.ts +1 -1
  112. package/types/query/trusted.d.ts.map +1 -1
@@ -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
 
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * - `field.op.value`: one condition; `,` joins conditions with AND
6
6
  * - `or(...)`, `and(...)`: groups, nestable
7
- * - `in.(a,b)`, `nin.(a,b)`: lists
7
+ * - `in.(a,b)`, `nin.(a,b)`: lists; `in.()` is the one empty list, and a comma beside no member is malformed
8
8
  * - `"..."`: a quoted value may hold `,` `(` `)` and `.`; `\"` and `\\` escape inside it
9
9
  * - `@name`: a computed field
10
10
  */
@@ -31,34 +31,66 @@ const FILTER_OPERATORS: readonly [FilterOperator, ...FilterOperator[]] = [
31
31
  /** An operator and the `.` after it; longest first, so `neq.` is not read as `ne` then `q.` */
32
32
  const OPERATOR_PATTERN = /^(contains|ilike|like|neq|gte|lte|nin|eq|ne|gt|lt|in|is)\./
33
33
 
34
+ /** What a refused filter is told: this alone when no more is known, else this and the reason */
35
+ const SYNTAX = "Invalid filter syntax"
36
+
37
+ /**
38
+ * The deepest nesting the parser reads, whatever cap a caller sets: it descends one call per group,
39
+ * so a bound keeps a deep filter a refusal instead of a stack overflow on every runtime comb runs on
40
+ */
41
+ const FILTER_MAX_DEPTH = 256
42
+
34
43
  class FilterParser {
35
44
  private pos = 0
36
- private malformed = false
45
+ /** Why the input is untrustworthy, the first reason met; null while every term parsed */
46
+ private failure: string | null = null
37
47
  private readonly input: string
48
+ /** Whitespace the caller's string held before the input: positions are told in the caller's string */
49
+ private readonly offset: number
50
+ /** The deepest group the input may open, the top level being 0 */
51
+ private readonly maxDepth: number
38
52
 
39
- constructor(input: string) {
53
+ constructor(input: string, offset: number, maxDepth: number) {
40
54
  this.input = input
55
+ this.offset = offset
56
+ this.maxDepth = Math.min(maxDepth, FILTER_MAX_DEPTH)
57
+ }
58
+
59
+ /** Why any term could not be parsed, or null: the input is not trustworthy while this is set. */
60
+ get error(): string | null {
61
+ return this.failure
62
+ }
63
+
64
+ /** Mark the input malformed; the first reason stays */
65
+ private fail(reason: string = SYNTAX): void {
66
+ this.failure ??= reason
41
67
  }
42
68
 
43
- /** True when any term could not be parsed: the input is not trustworthy. */
44
- get hadError(): boolean {
45
- return this.malformed
69
+ /** Mark a comma beside no member, told by where the member should stand */
70
+ private failEmptyMember(at: number): void {
71
+ this.fail(`${SYNTAX}: empty member at position ${at + this.offset}`)
46
72
  }
47
73
 
48
74
  parse(): FilterAST {
49
- const root = this.parseGroup("and")
75
+ const root = this.parseGroup("and", 0)
50
76
  /* a stray ")" stops the top-level group early; anything left is unparsed */
51
- if (this.pos < this.input.length) this.malformed = true
77
+ if (this.pos < this.input.length) this.fail()
52
78
  return { root }
53
79
  }
54
80
 
55
- private parseGroup(logic: "and" | "or"): FilterGroup {
81
+ private parseGroup(logic: "and" | "or", depth: number): FilterGroup {
56
82
  const group: FilterGroup = { conditions: [], logic, subgroups: [] }
57
83
  while (this.skipWhitespace() < this.input.length && this.peek() !== ")") {
58
84
  const nested = this.matchKeyword("or(") ? "or" : this.matchKeyword("and(") ? "and" : null
85
+ if (nested && depth === this.maxDepth) {
86
+ /* the first group past the cap: the input is refused here, before the descent could overflow */
87
+ this.fail(`Filter nesting too deep at position ${this.pos + this.offset} (max ${this.maxDepth})`)
88
+ this.pos = this.input.length
89
+ break
90
+ }
59
91
  if (nested) {
60
92
  this.pos += nested.length + 1
61
- group.subgroups.push(this.parseGroup(nested))
93
+ group.subgroups.push(this.parseGroup(nested, depth + 1))
62
94
  this.expect(")")
63
95
  } else {
64
96
  this.parseTerm(group)
@@ -66,18 +98,26 @@ class FilterParser {
66
98
  this.skipWhitespace()
67
99
  if (this.peek() !== ",") break
68
100
  this.pos++
101
+ /* a comma owes a member: a group that ends on one holds an empty member */
102
+ if (this.skipWhitespace() >= this.input.length || this.peek() === ")") {
103
+ this.failEmptyMember(this.pos)
104
+ break
105
+ }
69
106
  }
70
107
  return group
71
108
  }
72
109
 
73
110
  /** One condition into `group`; a term it cannot read is skipped and marks the input malformed */
74
111
  private parseTerm(group: FilterGroup): void {
112
+ const start = this.pos
75
113
  const condition = this.parseCondition()
76
114
  if (condition) {
77
115
  group.conditions.push(condition)
78
116
  return
79
117
  }
80
- this.malformed = true
118
+ /* a term that starts at its comma is an empty member, beside another comma or leading the group */
119
+ if (this.input[start] === ",") this.failEmptyMember(start)
120
+ else this.fail()
81
121
  while (this.pos < this.input.length && this.peek() !== "," && this.peek() !== ")") this.pos++
82
122
  }
83
123
 
@@ -113,14 +153,21 @@ class FilterParser {
113
153
  const values: string[] = []
114
154
  while (this.skipWhitespace() < this.input.length && this.peek() !== ")") {
115
155
  const quoted = this.peek() === '"'
156
+ const at = this.pos
116
157
  const value = this.readValue()
158
+ /* a bare member with nothing in it stands beside a comma; only a quoted one may be empty */
117
159
  if (quoted || value !== "") values.push(value)
160
+ else this.failEmptyMember(at)
118
161
  /* a quoted member ends at its quote: only a comma or the list's end may follow */
119
162
  if (quoted && this.skipWhitespace() < this.input.length && this.peek() !== "," && this.peek() !== ")") {
120
- this.malformed = true
163
+ this.fail()
121
164
  return values
122
165
  }
123
- if (this.peek() === ",") this.pos++
166
+ if (this.peek() === ",") {
167
+ this.pos++
168
+ /* a comma owes a member: `in.(x,)` holds an empty one, `in.()` is the only empty list */
169
+ if (this.skipWhitespace() >= this.input.length || this.peek() === ")") this.failEmptyMember(this.pos)
170
+ }
124
171
  }
125
172
  this.expect(")")
126
173
  return values
@@ -135,7 +182,7 @@ class FilterParser {
135
182
  const start = this.pos
136
183
  while (this.pos < this.input.length && this.peek() !== "," && this.peek() !== ")") this.pos++
137
184
  const value = this.input.slice(start, this.pos).trim()
138
- if (value.includes("(")) this.malformed = true
185
+ if (value.includes("(")) this.fail()
139
186
  return value
140
187
  }
141
188
 
@@ -152,7 +199,7 @@ class FilterParser {
152
199
  value += this.peek()
153
200
  }
154
201
  /* an unterminated quote */
155
- this.malformed = true
202
+ this.fail()
156
203
  return value
157
204
  }
158
205
 
@@ -167,7 +214,7 @@ class FilterParser {
167
214
  /** A group that is never closed leaves the input unbalanced: reject it. */
168
215
  private expect(char: string): void {
169
216
  if (this.peek() === char) this.pos++
170
- else this.malformed = true
217
+ else this.fail()
171
218
  }
172
219
 
173
220
  /** Step past whitespace; returns the new position */
@@ -178,15 +225,26 @@ class FilterParser {
178
225
  }
179
226
 
180
227
  /**
181
- * The AST of a filter string, or null when there is none or any part of it cannot be read: a
228
+ * The AST of a non-blank filter string, or why it cannot be read, in words for a caller to show: a
182
229
  * filter the parser does not fully understand is refused, never reduced to "no filter", which
183
- * would widen the result to the whole table on a typo.
230
+ * would widen the result to the whole table on a typo. A group nested past `maxDepth`, or past
231
+ * FILTER_MAX_DEPTH whatever `maxDepth` is, is refused where it opens.
232
+ */
233
+ function readFilter(input: string, maxDepth: number): { ast: FilterAST } | { error: string } {
234
+ const parser = new FilterParser(input.trim(), input.length - input.trimStart().length, maxDepth)
235
+ const ast = parser.parse()
236
+ return parser.error === null ? { ast } : { error: parser.error }
237
+ }
238
+
239
+ /**
240
+ * The AST of a filter string, or null when there is none or any part of it cannot be read, or it
241
+ * nests past FILTER_MAX_DEPTH: a filter the parser does not fully understand is refused, never
242
+ * reduced to "no filter", which would widen the result to the whole table on a typo.
184
243
  */
185
244
  function parseFilter(input: string | null | undefined): FilterAST | null {
186
245
  if (!input || input.trim() === "") return null
187
- const parser = new FilterParser(input.trim())
188
- const ast = parser.parse()
189
- return parser.hadError ? null : ast
246
+ const read = readFilter(input, FILTER_MAX_DEPTH)
247
+ return "ast" in read ? read.ast : null
190
248
  }
191
249
 
192
- export { FILTER_OPERATORS, parseFilter }
250
+ export { FILTER_OPERATORS, parseFilter, readFilter }
@@ -3,7 +3,7 @@
3
3
  * every value typed as its field declares, so SQL generation binds numbers as numbers and
4
4
  * booleans as booleans, and computed resolvers receive the declared type.
5
5
  */
6
- import { parseFilter } from "./filter-parse.ts"
6
+ import { readFilter } from "./filter-parse.ts"
7
7
  import { type CheckedField, typedValue } from "./filter-typed.ts"
8
8
  import { patternOf } from "./like.ts"
9
9
  import type { FieldType, FilterAST, FilterCondition, FilterGroup, FilterOperator } from "./types.ts"
@@ -66,7 +66,7 @@ type FilterValidationResult =
66
66
  type FilterLimits = {
67
67
  /** Conditions in the whole filter, every group counted */
68
68
  maxConditions: number
69
- /** Nested or()/and() groups below the top level */
69
+ /** Nested or()/and() groups below the top level; the parser reads no more than 256, whatever this is */
70
70
  maxDepth: number
71
71
  /** Members of one in/nin list */
72
72
  maxInValues: number
@@ -168,17 +168,16 @@ function checkCondition(condition: FilterCondition, check: Check): FilterConditi
168
168
  return { ...condition, value: typed.value }
169
169
  }
170
170
 
171
- /** The group with every condition checked and typed, and how many conditions and levels it holds */
172
- type Shape = { conditions: number; depth: number; group: FilterGroup; values: number }
171
+ /** The group with every condition checked and typed, and how many conditions and values it holds */
172
+ type Shape = { conditions: number; group: FilterGroup; values: number }
173
173
 
174
- function checkGroup(group: FilterGroup, check: Check, depth: number): Shape {
175
- const subgroups = group.subgroups.map((subgroup) => checkGroup(subgroup, check, depth + 1))
174
+ function checkGroup(group: FilterGroup, check: Check): Shape {
175
+ const subgroups = group.subgroups.map((subgroup) => checkGroup(subgroup, check))
176
176
  /* counted on the typed conditions: is.true binds its boolean, is.null nothing */
177
177
  const conditions = group.conditions.map((condition) => checkCondition(condition, check))
178
178
  const values = conditions.reduce((total, condition) => total + boundBy(condition), 0)
179
179
  return {
180
180
  conditions: subgroups.reduce((total, sub) => total + sub.conditions, group.conditions.length),
181
- depth: Math.max(depth, ...subgroups.map((sub) => sub.depth)),
182
181
  values: subgroups.reduce((total, sub) => total + sub.values, values),
183
182
  group: {
184
183
  conditions,
@@ -201,20 +200,18 @@ function validateFilter(
201
200
  const fields = checkedFields(allowedFields)
202
201
  const caps = limitsOver("validateFilter", FILTER_LIMITS, limits)
203
202
  if (!filterString || filterString.trim() === "") return null
204
- const ast = parseFilter(filterString)
205
- if (!ast) return { errors: ["Invalid filter syntax"], valid: false }
203
+ const read = readFilter(filterString, caps.maxDepth)
204
+ if ("error" in read) return { errors: [read.error], valid: false }
205
+ const ast = read.ast
206
206
 
207
207
  const check: Check = { errors: [], fields, limits: caps }
208
- const shape = checkGroup(ast.root, check, 0)
208
+ const shape = checkGroup(ast.root, check)
209
209
  if (shape.conditions > check.limits.maxConditions) {
210
210
  check.errors.push(`Too many filter conditions: ${shape.conditions} (max ${check.limits.maxConditions})`)
211
211
  }
212
212
  if (shape.values > check.limits.maxValues) {
213
213
  check.errors.push(`Filter binds too many values: ${shape.values} (max ${check.limits.maxValues})`)
214
214
  }
215
- if (shape.depth > check.limits.maxDepth) {
216
- check.errors.push(`Filter nesting too deep: ${shape.depth} (max ${check.limits.maxDepth})`)
217
- }
218
215
  return check.errors.length > 0 ? { errors: check.errors, valid: false } : { ast: { root: shape.group }, valid: true }
219
216
  }
220
217