@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.
- package/README.md +46 -7
- package/package.json +3 -2
- package/src/cli/args.ts +4 -2
- package/src/cli/codegen.ts +2 -2
- package/src/cli/comb-config.ts +8 -3
- package/src/cli/file-path.ts +25 -0
- package/src/cli/migrate-apply.ts +5 -21
- package/src/cli/migrate-d1.ts +5 -1
- package/src/cli/migrate-json.ts +48 -0
- package/src/cli/migrate.ts +26 -4
- package/src/migrate/core/apply.ts +63 -22
- package/src/migrate/core/atlas-apply-runs.ts +15 -26
- package/src/migrate/core/cleanup.ts +22 -10
- package/src/migrate/core/d1-engine-child.mjs +27 -11
- package/src/migrate/core/d1-history.ts +23 -26
- package/src/migrate/core/d1-wrangler-error.ts +24 -1
- package/src/migrate/core/pg-replay-commit.ts +41 -38
- package/src/migrate/core/pg-replay-drops.ts +17 -32
- package/src/migrate/core/pg-replay-hidden.ts +75 -0
- package/src/migrate/core/pg-replay-roles.ts +2 -4
- package/src/migrate/core/pg-replay.ts +21 -19
- package/src/migrate/core/pg-server-snapshot.ts +4 -24
- package/src/migrate/core/proof-readers.ts +80 -0
- package/src/migrate/core/proof-run.ts +19 -5
- package/src/migrate/core/proof-scope.ts +27 -14
- package/src/migrate/core/sql-code.ts +36 -5
- package/src/migrate/core/sql-scan.ts +7 -1
- package/src/migrate/core/sqlite-keywords.ts +70 -0
- package/src/migrate/core/stopped.ts +70 -0
- package/src/migrate/core/trigger-write.d.mts +7 -0
- package/src/migrate/core/trigger-write.mjs +42 -0
- package/src/query/cursor-mint.ts +11 -18
- package/src/query/cursor-refusals.ts +22 -11
- package/src/query/descriptions.ts +4 -9
- package/src/query/filter-parse.ts +80 -22
- package/src/query/filter-validate.ts +10 -13
- package/src/query/filter.ts +40 -18
- package/src/query/list-extras.ts +7 -10
- package/src/query/list-schema.ts +0 -2
- package/src/query/merged.ts +3 -2
- package/src/query/pagination.ts +30 -6
- package/src/query/rounded-key.ts +5 -8
- package/src/query/schema-params.ts +12 -5
- package/src/query/select-validate.ts +7 -4
- package/src/query/sqlite/cursor-fit.ts +10 -2
- package/src/query/trusted.ts +7 -2
- package/types/cli/args.d.ts.map +1 -1
- package/types/cli/comb-config.d.ts +5 -2
- package/types/cli/comb-config.d.ts.map +1 -1
- package/types/cli/file-path.d.ts +11 -0
- package/types/cli/file-path.d.ts.map +1 -0
- package/types/cli/migrate-apply.d.ts +1 -0
- package/types/cli/migrate-apply.d.ts.map +1 -1
- package/types/cli/migrate-d1.d.ts.map +1 -1
- package/types/cli/migrate-json.d.ts +4 -0
- package/types/cli/migrate-json.d.ts.map +1 -0
- package/types/cli/migrate.d.ts.map +1 -1
- package/types/migrate/core/apply.d.ts +3 -2
- package/types/migrate/core/apply.d.ts.map +1 -1
- package/types/migrate/core/atlas-apply-runs.d.ts +6 -0
- package/types/migrate/core/atlas-apply-runs.d.ts.map +1 -1
- package/types/migrate/core/cleanup.d.ts +16 -4
- package/types/migrate/core/cleanup.d.ts.map +1 -1
- package/types/migrate/core/d1-history.d.ts.map +1 -1
- package/types/migrate/core/d1-wrangler-error.d.ts +11 -0
- package/types/migrate/core/d1-wrangler-error.d.ts.map +1 -1
- package/types/migrate/core/pg-replay-commit.d.ts +14 -9
- package/types/migrate/core/pg-replay-commit.d.ts.map +1 -1
- package/types/migrate/core/pg-replay-drops.d.ts +7 -12
- package/types/migrate/core/pg-replay-drops.d.ts.map +1 -1
- package/types/migrate/core/pg-replay-hidden.d.ts +36 -0
- package/types/migrate/core/pg-replay-hidden.d.ts.map +1 -0
- package/types/migrate/core/pg-replay-roles.d.ts +2 -4
- package/types/migrate/core/pg-replay-roles.d.ts.map +1 -1
- package/types/migrate/core/pg-replay.d.ts.map +1 -1
- package/types/migrate/core/pg-server-snapshot.d.ts +4 -11
- package/types/migrate/core/pg-server-snapshot.d.ts.map +1 -1
- package/types/migrate/core/proof-readers.d.ts +19 -0
- package/types/migrate/core/proof-readers.d.ts.map +1 -0
- package/types/migrate/core/proof-run.d.ts.map +1 -1
- package/types/migrate/core/proof-scope.d.ts +15 -7
- package/types/migrate/core/proof-scope.d.ts.map +1 -1
- package/types/migrate/core/sql-code.d.ts +7 -3
- package/types/migrate/core/sql-code.d.ts.map +1 -1
- package/types/migrate/core/sql-scan.d.ts.map +1 -1
- package/types/migrate/core/sqlite-keywords.d.ts +11 -0
- package/types/migrate/core/sqlite-keywords.d.ts.map +1 -0
- package/types/migrate/core/stopped.d.ts +23 -0
- package/types/migrate/core/stopped.d.ts.map +1 -0
- package/types/query/cursor-mint.d.ts.map +1 -1
- package/types/query/cursor-refusals.d.ts +17 -1
- package/types/query/cursor-refusals.d.ts.map +1 -1
- package/types/query/descriptions.d.ts +1 -1
- package/types/query/descriptions.d.ts.map +1 -1
- package/types/query/filter-parse.d.ts +15 -4
- package/types/query/filter-parse.d.ts.map +1 -1
- package/types/query/filter-validate.d.ts +1 -1
- package/types/query/filter-validate.d.ts.map +1 -1
- package/types/query/filter.d.ts +9 -1
- package/types/query/filter.d.ts.map +1 -1
- package/types/query/list-extras.d.ts.map +1 -1
- package/types/query/list-schema.d.ts.map +1 -1
- package/types/query/merged.d.ts.map +1 -1
- package/types/query/pagination.d.ts +13 -1
- package/types/query/pagination.d.ts.map +1 -1
- package/types/query/rounded-key.d.ts +2 -11
- package/types/query/rounded-key.d.ts.map +1 -1
- package/types/query/schema-params.d.ts.map +1 -1
- package/types/query/select-validate.d.ts.map +1 -1
- package/types/query/sqlite/cursor-fit.d.ts.map +1 -1
- package/types/query/trusted.d.ts +1 -1
- 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 =
|
|
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/query/cursor-mint.ts
CHANGED
|
@@ -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"):
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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]
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
163
|
+
this.fail()
|
|
121
164
|
return values
|
|
122
165
|
}
|
|
123
|
-
if (this.peek() === ",")
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
188
|
-
|
|
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 {
|
|
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
|
|
172
|
-
type Shape = { conditions: 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
|
|
175
|
-
const subgroups = group.subgroups.map((subgroup) => checkGroup(subgroup, check
|
|
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
|
|
205
|
-
if (
|
|
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
|
|
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
|
|