@workerstack/database 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +243 -40
  2. package/docs/meta-contract.md +55 -1
  3. package/package.json +3 -7
  4. package/src/cli/args.ts +4 -2
  5. package/src/cli/codegen.ts +2 -2
  6. package/src/cli/comb-config.ts +8 -3
  7. package/src/cli/file-path.ts +25 -0
  8. package/src/cli/migrate-apply.ts +5 -21
  9. package/src/cli/migrate-d1.ts +5 -1
  10. package/src/cli/migrate-json.ts +48 -0
  11. package/src/cli/migrate.ts +26 -4
  12. package/src/codegen/analyzer-types.ts +6 -1
  13. package/src/codegen/annotations.ts +66 -9
  14. package/src/codegen/format-constraints.ts +114 -0
  15. package/src/codegen/generators/dtos/entity-file.ts +2 -1
  16. package/src/codegen/generators/dtos/example-meta.ts +22 -2
  17. package/src/codegen/generators/dtos/field-schema.ts +31 -8
  18. package/src/codegen/generators/dtos/ip-patterns.ts +25 -11
  19. package/src/codegen/generators/dtos/number-range.ts +22 -3
  20. package/src/codegen/generators/dtos/schema-aliases.ts +5 -3
  21. package/src/codegen/generators/dtos/text-format.ts +63 -0
  22. package/src/codegen/generators/dtos/text-schema.ts +49 -2
  23. package/src/codegen/generators/manifest.ts +9 -1
  24. package/src/codegen/runtime/read-comb.ts +71 -3
  25. package/src/codegen/runtime/read-tables.ts +31 -19
  26. package/src/codegen/runtime/snapshot-types.ts +3 -1
  27. package/src/codegen/runtime/to-meta.ts +6 -3
  28. package/src/formats/catalog.ts +255 -0
  29. package/src/formats/iana-time-zone.ts +117 -0
  30. package/src/formats/iso3166-1.ts +78 -0
  31. package/src/formats/iso4217.ts +42 -0
  32. package/src/formats/iso639-1.ts +41 -0
  33. package/src/formats/uts35.ts +143 -0
  34. package/src/index.ts +5 -0
  35. package/src/meta/column.ts +22 -9
  36. package/src/migrate/core/apply.ts +63 -22
  37. package/src/migrate/core/atlas-apply-runs.ts +15 -26
  38. package/src/migrate/core/cleanup.ts +22 -10
  39. package/src/migrate/core/d1-engine-child.mjs +27 -11
  40. package/src/migrate/core/d1-history.ts +23 -26
  41. package/src/migrate/core/d1-wrangler-error.ts +24 -1
  42. package/src/migrate/core/pg-replay-commit.ts +41 -38
  43. package/src/migrate/core/pg-replay-drops.ts +17 -32
  44. package/src/migrate/core/pg-replay-hidden.ts +75 -0
  45. package/src/migrate/core/pg-replay-roles.ts +2 -4
  46. package/src/migrate/core/pg-replay.ts +21 -19
  47. package/src/migrate/core/pg-server-snapshot.ts +4 -24
  48. package/src/migrate/core/proof-readers.ts +80 -0
  49. package/src/migrate/core/proof-run.ts +19 -5
  50. package/src/migrate/core/proof-scope.ts +27 -14
  51. package/src/migrate/core/sql-code.ts +36 -5
  52. package/src/migrate/core/sql-scan.ts +7 -1
  53. package/src/migrate/core/sqlite-keywords.ts +70 -0
  54. package/src/migrate/core/stopped.ts +70 -0
  55. package/src/migrate/core/trigger-write.d.mts +7 -0
  56. package/src/migrate/core/trigger-write.mjs +42 -0
  57. package/src/pg/columns.ts +6 -4
  58. package/src/query/cursor-mint.ts +11 -18
  59. package/src/query/cursor-refusals.ts +22 -11
  60. package/src/query/descriptions.ts +4 -9
  61. package/src/query/filter-parse.ts +80 -22
  62. package/src/query/filter-validate.ts +10 -13
  63. package/src/query/filter.ts +40 -18
  64. package/src/query/list-extras.ts +7 -10
  65. package/src/query/list-schema.ts +0 -2
  66. package/src/query/merged.ts +3 -2
  67. package/src/query/pagination.ts +30 -6
  68. package/src/query/rounded-key.ts +5 -8
  69. package/src/query/schema-params.ts +12 -5
  70. package/src/query/select-validate.ts +7 -4
  71. package/src/query/sqlite/cursor-fit.ts +10 -2
  72. package/src/query/trusted.ts +7 -2
  73. package/src/sqlite/columns.ts +6 -4
  74. package/src/sqlite/constraints.ts +120 -6
  75. package/types/cli/args.d.ts.map +1 -1
  76. package/types/cli/comb-config.d.ts +5 -2
  77. package/types/cli/comb-config.d.ts.map +1 -1
  78. package/types/cli/file-path.d.ts +11 -0
  79. package/types/cli/file-path.d.ts.map +1 -0
  80. package/types/cli/migrate-apply.d.ts +1 -0
  81. package/types/cli/migrate-apply.d.ts.map +1 -1
  82. package/types/cli/migrate-d1.d.ts.map +1 -1
  83. package/types/cli/migrate-json.d.ts +4 -0
  84. package/types/cli/migrate-json.d.ts.map +1 -0
  85. package/types/cli/migrate.d.ts.map +1 -1
  86. package/types/codegen/analyzer-types.d.ts +6 -1
  87. package/types/codegen/analyzer-types.d.ts.map +1 -1
  88. package/types/codegen/annotations.d.ts +12 -7
  89. package/types/codegen/annotations.d.ts.map +1 -1
  90. package/types/codegen/format-constraints.d.ts +22 -0
  91. package/types/codegen/format-constraints.d.ts.map +1 -0
  92. package/types/codegen/generators/dtos/entity-file.d.ts.map +1 -1
  93. package/types/codegen/generators/dtos/example-meta.d.ts.map +1 -1
  94. package/types/codegen/generators/dtos/field-schema.d.ts.map +1 -1
  95. package/types/codegen/generators/dtos/ip-patterns.d.ts +7 -0
  96. package/types/codegen/generators/dtos/ip-patterns.d.ts.map +1 -1
  97. package/types/codegen/generators/dtos/number-range.d.ts +8 -0
  98. package/types/codegen/generators/dtos/number-range.d.ts.map +1 -1
  99. package/types/codegen/generators/dtos/schema-aliases.d.ts +1 -1
  100. package/types/codegen/generators/dtos/schema-aliases.d.ts.map +1 -1
  101. package/types/codegen/generators/dtos/text-format.d.ts +31 -0
  102. package/types/codegen/generators/dtos/text-format.d.ts.map +1 -0
  103. package/types/codegen/generators/dtos/text-schema.d.ts +8 -1
  104. package/types/codegen/generators/dtos/text-schema.d.ts.map +1 -1
  105. package/types/codegen/generators/manifest.d.ts +5 -0
  106. package/types/codegen/generators/manifest.d.ts.map +1 -1
  107. package/types/codegen/runtime/read-comb.d.ts +17 -3
  108. package/types/codegen/runtime/read-comb.d.ts.map +1 -1
  109. package/types/codegen/runtime/read-tables.d.ts.map +1 -1
  110. package/types/codegen/runtime/snapshot-types.d.ts +3 -1
  111. package/types/codegen/runtime/snapshot-types.d.ts.map +1 -1
  112. package/types/codegen/runtime/to-meta.d.ts.map +1 -1
  113. package/types/formats/catalog.d.ts +51 -0
  114. package/types/formats/catalog.d.ts.map +1 -0
  115. package/types/formats/iana-time-zone.d.ts +7 -0
  116. package/types/formats/iana-time-zone.d.ts.map +1 -0
  117. package/types/formats/iso3166-1.d.ts +12 -0
  118. package/types/formats/iso3166-1.d.ts.map +1 -0
  119. package/types/formats/iso4217.d.ts +7 -0
  120. package/types/formats/iso4217.d.ts.map +1 -0
  121. package/types/formats/iso639-1.d.ts +6 -0
  122. package/types/formats/iso639-1.d.ts.map +1 -0
  123. package/types/formats/uts35.d.ts +13 -0
  124. package/types/formats/uts35.d.ts.map +1 -0
  125. package/types/index.d.ts +5 -0
  126. package/types/index.d.ts.map +1 -1
  127. package/types/meta/column.d.ts +7 -1
  128. package/types/meta/column.d.ts.map +1 -1
  129. package/types/migrate/core/apply.d.ts +3 -2
  130. package/types/migrate/core/apply.d.ts.map +1 -1
  131. package/types/migrate/core/atlas-apply-runs.d.ts +6 -0
  132. package/types/migrate/core/atlas-apply-runs.d.ts.map +1 -1
  133. package/types/migrate/core/cleanup.d.ts +16 -4
  134. package/types/migrate/core/cleanup.d.ts.map +1 -1
  135. package/types/migrate/core/d1-history.d.ts.map +1 -1
  136. package/types/migrate/core/d1-wrangler-error.d.ts +11 -0
  137. package/types/migrate/core/d1-wrangler-error.d.ts.map +1 -1
  138. package/types/migrate/core/pg-replay-commit.d.ts +14 -9
  139. package/types/migrate/core/pg-replay-commit.d.ts.map +1 -1
  140. package/types/migrate/core/pg-replay-drops.d.ts +7 -12
  141. package/types/migrate/core/pg-replay-drops.d.ts.map +1 -1
  142. package/types/migrate/core/pg-replay-hidden.d.ts +36 -0
  143. package/types/migrate/core/pg-replay-hidden.d.ts.map +1 -0
  144. package/types/migrate/core/pg-replay-roles.d.ts +2 -4
  145. package/types/migrate/core/pg-replay-roles.d.ts.map +1 -1
  146. package/types/migrate/core/pg-replay.d.ts.map +1 -1
  147. package/types/migrate/core/pg-server-snapshot.d.ts +4 -11
  148. package/types/migrate/core/pg-server-snapshot.d.ts.map +1 -1
  149. package/types/migrate/core/proof-readers.d.ts +19 -0
  150. package/types/migrate/core/proof-readers.d.ts.map +1 -0
  151. package/types/migrate/core/proof-run.d.ts.map +1 -1
  152. package/types/migrate/core/proof-scope.d.ts +15 -7
  153. package/types/migrate/core/proof-scope.d.ts.map +1 -1
  154. package/types/migrate/core/sql-code.d.ts +7 -3
  155. package/types/migrate/core/sql-code.d.ts.map +1 -1
  156. package/types/migrate/core/sql-scan.d.ts.map +1 -1
  157. package/types/migrate/core/sqlite-keywords.d.ts +11 -0
  158. package/types/migrate/core/sqlite-keywords.d.ts.map +1 -0
  159. package/types/migrate/core/stopped.d.ts +23 -0
  160. package/types/migrate/core/stopped.d.ts.map +1 -0
  161. package/types/pg/columns.d.ts +3 -3
  162. package/types/pg/columns.d.ts.map +1 -1
  163. package/types/query/cursor-mint.d.ts.map +1 -1
  164. package/types/query/cursor-refusals.d.ts +17 -1
  165. package/types/query/cursor-refusals.d.ts.map +1 -1
  166. package/types/query/descriptions.d.ts +1 -1
  167. package/types/query/descriptions.d.ts.map +1 -1
  168. package/types/query/filter-parse.d.ts +15 -4
  169. package/types/query/filter-parse.d.ts.map +1 -1
  170. package/types/query/filter-validate.d.ts +1 -1
  171. package/types/query/filter-validate.d.ts.map +1 -1
  172. package/types/query/filter.d.ts +9 -1
  173. package/types/query/filter.d.ts.map +1 -1
  174. package/types/query/list-extras.d.ts.map +1 -1
  175. package/types/query/list-schema.d.ts.map +1 -1
  176. package/types/query/merged.d.ts.map +1 -1
  177. package/types/query/pagination.d.ts +13 -1
  178. package/types/query/pagination.d.ts.map +1 -1
  179. package/types/query/rounded-key.d.ts +2 -11
  180. package/types/query/rounded-key.d.ts.map +1 -1
  181. package/types/query/schema-params.d.ts.map +1 -1
  182. package/types/query/select-validate.d.ts.map +1 -1
  183. package/types/query/sqlite/cursor-fit.d.ts.map +1 -1
  184. package/types/query/trusted.d.ts +1 -1
  185. package/types/query/trusted.d.ts.map +1 -1
  186. package/types/sqlite/columns.d.ts +3 -3
  187. package/types/sqlite/columns.d.ts.map +1 -1
  188. package/types/sqlite/constraints.d.ts +117 -5
  189. package/types/sqlite/constraints.d.ts.map +1 -1
  190. package/src/sqlite/patterns.ts +0 -19
  191. package/types/sqlite/patterns.d.ts +0 -19
  192. package/types/sqlite/patterns.d.ts.map +0 -1
@@ -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
 
@@ -16,6 +16,43 @@ import {
16
16
  } from "./filter-validate.ts"
17
17
  import type { SortDirection, SortField } from "./types.ts"
18
18
 
19
+ /** The direction suffix an `order` entry may end in, before any nulls suffix, and what it reads as */
20
+ const SORT_DIRECTIONS = { asc: "asc", desc: "desc" } as const satisfies Record<string, SortDirection>
21
+ /** The nulls suffix an `order` entry may end in, and what it reads as */
22
+ const SORT_NULLS = { nullsfirst: "first", nullslast: "last" } as const
23
+
24
+ /** The value `suffixes` reads the last of `tokens` as, popping it, or undefined when it is none of them */
25
+ function suffixOf<T extends string>(tokens: string[], suffixes: Readonly<Record<string, T>>): T | undefined {
26
+ for (const [suffix, value] of Object.entries(suffixes))
27
+ if (tokens.at(-1) === suffix) {
28
+ tokens.pop()
29
+ return value
30
+ }
31
+ return undefined
32
+ }
33
+
34
+ function escapeRegExp(value: string): string {
35
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
36
+ }
37
+
38
+ /** A regular expression taking any one of `names`, each literally */
39
+ function anyOf(names: readonly string[]): string {
40
+ return `(?:${names.map(escapeRegExp).join("|")})`
41
+ }
42
+
43
+ /**
44
+ * A regular expression taking every `order` parseOrder reads over the fields in `sort`, up to
45
+ * `maxSortKeys` entries: the empty value, which reads as the default order, and each entry the field
46
+ * and the suffixes orderEntries reads off it. A field named twice is refused, so no order holds more
47
+ * entries than `sort` has fields; which field repeats needs a lookahead, which RE2 lacks, so the
48
+ * pattern leaves that to the description
49
+ */
50
+ function orderPattern(sort: readonly string[], maxSortKeys: number): string {
51
+ const entry = `${anyOf(sort)}(?:\\.${anyOf(Object.keys(SORT_DIRECTIONS))})?(?:\\.${anyOf(Object.keys(SORT_NULLS))})?`
52
+ const keys = Math.min(maxSortKeys, sort.length)
53
+ return `^(?:${entry}(?:,${entry}){0,${Math.max(0, keys - 1)}})?$`
54
+ }
55
+
19
56
  /**
20
57
  * Parse PostgREST-style `order` string into SortField array.
21
58
  *
@@ -67,24 +104,8 @@ function orderEntries(input: string | null | undefined): Array<SortField | strin
67
104
 
68
105
  const tokens = trimmed.split(".")
69
106
 
70
- let nulls: "first" | "last" | undefined
71
- const last = tokens[tokens.length - 1]
72
- if (last === "nullsfirst") {
73
- nulls = "first"
74
- tokens.pop()
75
- } else if (last === "nullslast") {
76
- nulls = "last"
77
- tokens.pop()
78
- }
79
-
80
- let direction: SortDirection = "asc"
81
- const newLast = tokens[tokens.length - 1]
82
- if (newLast === "asc") {
83
- tokens.pop()
84
- } else if (newLast === "desc") {
85
- direction = "desc"
86
- tokens.pop()
87
- }
107
+ const nulls = suffixOf(tokens, SORT_NULLS)
108
+ const direction: SortDirection = suffixOf(tokens, SORT_DIRECTIONS) ?? "asc"
88
109
 
89
110
  const field = tokens.join(".")
90
111
  if (!field || part !== trimmed) {
@@ -108,6 +129,7 @@ export {
108
129
  parseFilterNumber,
109
130
  namedTwice,
110
131
  orderEntries,
132
+ orderPattern,
111
133
  parseOrder,
112
134
  validateFilter,
113
135
  type FilterField,
@@ -16,6 +16,7 @@ import {
16
16
  unprintedNumber,
17
17
  } from "./held-keys.ts"
18
18
  import type { SortField } from "./types.ts"
19
+ import { UnmintableRow } from "./cursor-refusals.ts"
19
20
 
20
21
  /** The column every built list selects its tiebreak as, which the next cursor's id is minted from */
21
22
  const COMB_ID = "_combId" as const
@@ -140,19 +141,15 @@ const FLOAT_MARK = "!f"
140
141
  * which the read places nowhere a cursor resumes after
141
142
  */
142
143
  const storesUnread = (field: string, stored: string): CombError =>
143
- new CombError({
144
- cause: `${field} stores a ${stored.toUpperCase()} value its column's drizzle mode reads as another, which no cursor of a list that binds the key as read resumes after: store what the mode reads back (a timestamp's epoch number, a boolean's 0 or 1, a finite number), or sort by a column of the list's own table`,
145
- errorKey: combErrorKeys.SORT_NOT_WIRED,
146
- status: "internal_server_error",
147
- })
144
+ new UnmintableRow(
145
+ `${field} stores a ${stored.toUpperCase()} value its column's drizzle mode reads as another, which no cursor of a list that binds the key as read resumes after: store what the mode reads back (a timestamp's epoch number, a boolean's 0 or 1, a finite number), or sort by a column of the list's own table`,
146
+ )
148
147
 
149
148
  /** The 500 for a SQLite number key no text SQLite prints of reads back as it, which no cursor binds exactly */
150
149
  const printsUnread = (field: string): CombError =>
151
- new CombError({
152
- cause: `${field} holds a REAL no text SQLite prints of it reads back as (D1 misprints some past about 1e100 and below 1e-100), which no cursor resumes after: store a number of fewer significant digits, or sort by another column`,
153
- errorKey: combErrorKeys.SORT_NOT_WIRED,
154
- status: "internal_server_error",
155
- })
150
+ new UnmintableRow(
151
+ `${field} holds a REAL no text SQLite prints of it reads back as (D1 misprints some past about 1e100 and below 1e-100), which no cursor resumes after: store a number of fewer significant digits, or sort by another column`,
152
+ )
156
153
 
157
154
  /** The 500 for a Postgres float key read under extra_float_digits 0 or below: its read is rounded */
158
155
  const printsRounded = (field: string): CombError =>
@@ -24,7 +24,6 @@ import {
24
24
  selectQueryFieldFor,
25
25
  sortDirectionEnum,
26
26
  } from "./schema-params.ts"
27
- import { pageTooDeep } from "./trusted.ts"
28
27
  import type { ListQueryConfig, ListQueryLimits, ListQueryOutput, OutputStableShape } from "./schema-types.ts"
29
28
  import { validateSelectWithConfig } from "./select-validate.ts"
30
29
  import type { FieldType } from "./types.ts"
@@ -178,7 +177,6 @@ function createListQuerySchema<
178
177
 
179
178
  const { maxLimit = PAGINATION_DEFAULTS.maxLimit, maxPage = PAGINATION_DEFAULTS.maxPage } = config.pagination ?? {}
180
179
  const piped = schema.transform((data, ctx): ExtendedOutput => {
181
- if (data.page !== undefined && data.page > maxPage) throw pageTooDeep(data.page, maxPage)
182
180
  /* Optional params exist on `data` only when advertised above. */
183
181
  const optional = data as {
184
182
  filter?: string | undefined
@@ -18,6 +18,7 @@ import {
18
18
  COMB_ID,
19
19
  type ListPagination,
20
20
  type ListRow,
21
+ nextCursorOf,
21
22
  type PaginationResponse,
22
23
  totalOf,
23
24
  } from "./pagination.ts"
@@ -238,8 +239,8 @@ function paginateMerged<T extends object>(
238
239
  const window = rows.slice(start, start + limit + 1)
239
240
  const hasMore = full || window.length > limit
240
241
  const page = hasMore ? window.slice(0, limit) : window
241
- /* every row served is one a cursor resumes after; the last one's is sealed */
242
- const last = page.map((row) => pagination.fit(row)).at(-1)
242
+ /* the rows a cursor may be minted from are fitted; the last one's is sealed */
243
+ const last = nextCursorOf(page, { byCursor: cursor !== undefined, hasMore }, (row) => pagination.fit(row))
243
244
  const meta: PaginationResponse = {
244
245
  count: countOf(rowsPerShard, cursor),
245
246
  hasMore,
@@ -16,6 +16,7 @@ import { paginationIn, refuseGoneAnchor, refuseOverRead, refuseUnwired } from ".
16
16
  import type { paginationResponseSchema } from "./schema.ts"
17
17
  import type { ListQueryInput, ParsedFields, SortField } from "./types.ts"
18
18
  import type * as z from "zod"
19
+ import { UnmintableRow } from "./cursor-refusals.ts"
19
20
 
20
21
  /**
21
22
  * The name of one column a builder selects for `paginate` to read, which no row it returns keeps: the
@@ -187,6 +188,28 @@ function fitFrom(
187
188
  })
188
189
  }
189
190
 
191
+ /**
192
+ * What seals a served page's next cursor, or undefined for none, `check` measuring a row's cursor. On
193
+ * a page by cursor it runs on every row: its client walks the list one cursor at a time, so a row no
194
+ * cursor resumes after is refused on the page that serves it, whatever the page size. On a page by
195
+ * number it runs only on the last row of a page with more, the one the cursor is minted from, as
196
+ * 0.20.1 did; a row whose own value no cursor holds (UnmintableRow) mints none, and the page serves it,
197
+ * its `hasMore` still saying a next page by number exists. A refusal of the list's wiring throws on both
198
+ */
199
+ function nextCursorOf<T>(
200
+ page: readonly T[],
201
+ { byCursor, hasMore }: { byCursor: boolean; hasMore: boolean },
202
+ check: (row: T) => (() => string) | undefined,
203
+ ): (() => string) | undefined {
204
+ if (byCursor) return page.map(check).at(-1)
205
+ try {
206
+ return (hasMore ? page.slice(-1) : []).map(check).at(-1)
207
+ } catch (error) {
208
+ if (error instanceof UnmintableRow) return undefined
209
+ throw error
210
+ }
211
+ }
212
+
190
213
  /**
191
214
  * The page of `items`, read with `built`, and its pagination: count, whether there is more, and
192
215
  * the next cursor, sealed under the builder's options; none for a list no cursor can resume
@@ -207,12 +230,12 @@ function paginate<T extends object>(
207
230
  refuseGoneAnchor(items)
208
231
  const hasMore = items.length > pagination.limit
209
232
  const sliced = hasMore ? items.slice(0, pagination.limit) : items
210
- /* a key no cursor bound as read places is refused on the page that serves it, wherever its row lands */
211
- for (const item of sliced) refuseUnheld(item as Record<string, unknown>, pagination.parsedSort)
212
- /* every row served is one a cursor resumes after, checked from the fat row, under the sort the builder
213
- ordered by, so the next request's signature matches; the last one's is sealed */
214
- const fitted = pagination.cursorable ? sliced.map((item) => pagination.fit(item)) : []
215
- const last = fitted.at(-1)
233
+ /* a key no cursor bound as read places is refused as a mint is, checked from the fat row, under the
234
+ sort the builder ordered by, so the next request's signature matches; the last one's is sealed */
235
+ const last = nextCursorOf(sliced, { byCursor: pagination.cursor !== undefined, hasMore }, (item) => {
236
+ refuseUnheld(item as Record<string, unknown>, pagination.parsedSort)
237
+ return pagination.cursorable ? pagination.fit(item) : undefined
238
+ })
216
239
  const meta: PaginationResponse = {
217
240
  count: countOf(items, pagination),
218
241
  hasMore,
@@ -229,6 +252,7 @@ function paginate<T extends object>(
229
252
  export {
230
253
  applySelect,
231
254
  COMB_ID,
255
+ nextCursorOf,
232
256
  type ListExtra,
233
257
  type ListPagination,
234
258
  type ListRow,
@@ -7,10 +7,9 @@
7
7
  * selected does the value alone. A class is trusted only when it names a double, so one comb cannot
8
8
  * place, such as a type no family covers, is refused as an integer is.
9
9
  */
10
- import { CombError } from "../error.ts"
11
- import { combErrorKeys } from "../types.ts"
12
10
  import { storedClasses } from "./list-extras.ts"
13
11
  import type { SortField } from "./types.ts"
12
+ import { UnmintableRow } from "./cursor-refusals.ts"
14
13
 
15
14
  // lookup-table: why a list cannot page an integer sort key the driver rounded, by the list
16
15
  const ROUNDED: Readonly<Record<"derived" | "merged" | "unread", string>> = {
@@ -45,7 +44,7 @@ function roundedKey(
45
44
  row: Record<string, unknown>,
46
45
  sort: readonly SortField[],
47
46
  list: "derived" | "merged" | { unread: (field: string) => boolean },
48
- ): CombError | undefined {
47
+ ): UnmintableRow | undefined {
49
48
  const [kind, judged] = typeof list === "string" ? [list, () => true] : (["unread", list.unread] as const)
50
49
  const stored = storedClasses(row)
51
50
  const rounded = sort.find(({ field }, i) => {
@@ -60,13 +59,11 @@ function roundedKey(
60
59
  })
61
60
  if (rounded === undefined) return undefined
62
61
  const value = row[rounded.field] as number
63
- return new CombError({
64
- cause: Number.isInteger(value)
62
+ return new UnmintableRow(
63
+ Number.isInteger(value)
65
64
  ? `${rounded.field} ${BigInt(value)} is past ±(2^53 − 1), where the driver rounded it: ${ROUNDED[kind]}`
66
65
  : `${rounded.field} ${value} is a fraction under a type no driver reads exactly as a JS number, so the driver may have rounded it: ${FRACTION[kind]}: sort by a Postgres numeric, whose text comb carries, read the key as a string, or sort by a double precision or real`,
67
- errorKey: combErrorKeys.SORT_NOT_WIRED,
68
- status: "internal_server_error",
69
- })
66
+ )
70
67
  }
71
68
 
72
69
  export { roundedKey }
@@ -14,7 +14,7 @@ import {
14
14
  import { FILTER_OPERATORS } from "./filter-parse.ts"
15
15
  import { PAGINATION_DEFAULTS } from "./schema-limits.ts"
16
16
  import type { FieldsConfig, PaginationConfig } from "./schema-types.ts"
17
- import { invalidPage } from "./trusted.ts"
17
+ import { invalidPage, pageTooDeep } from "./trusted.ts"
18
18
  import type { FieldSelection, FilterAST, FilterGroup, ParsedFields } from "./types.ts"
19
19
 
20
20
  /** The `select` param for an endpoint of these fields, its text and examples theirs */
@@ -88,10 +88,17 @@ function fromDigits(raw: unknown, max: number): unknown {
88
88
  return DECIMAL_DIGITS.test(raw) && !padded ? Number(raw) : Number.NaN
89
89
  }
90
90
 
91
- /** `raw` as a page, a whole number from 1; anything else is invalid_page, as the builders answer it */
91
+ /**
92
+ * `raw` as a page, a whole number from 1 to `maxPage`: past it, whatever its size, is `page_too_deep`,
93
+ * decided here on the digits before any number the schema could refuse exists; anything else is
94
+ * invalid_page, as the builders answer it. A digit string past 1e308 reads as Infinity: it is whole
95
+ */
92
96
  function pageFrom(raw: unknown, maxPage: number): unknown {
93
97
  const page = fromDigits(raw, maxPage)
94
- if (typeof page !== "number" || !Number.isInteger(page) || page < 1) throw invalidPage(raw)
98
+ const isDigits = typeof raw === "string"
99
+ if (typeof page !== "number" || page < 1 || !(Number.isInteger(page) || (isDigits && page === Infinity)))
100
+ throw invalidPage(raw)
101
+ if (page > maxPage) throw pageTooDeep(isDigits ? raw.replace(/^0+/, "") : page, maxPage)
95
102
  return page
96
103
  }
97
104
 
@@ -147,8 +154,8 @@ function limitSchemaFor(pagination: PaginationConfig | undefined): z.ZodDefault<
147
154
  /**
148
155
  * The `page` param: decimal digits or a number, a whole number from 1. Neither bound is a field
149
156
  * error, which would give one condition two contracts: a page that is no whole number from 1 is
150
- * `invalid_page`, and maxPage, published but not checked here, is the list schema's
151
- * `page_too_deep`, each as an unbounded list answers it
157
+ * `invalid_page`, and one past maxPage is `page_too_deep` at any size, each as an unbounded list
158
+ * answers it
152
159
  */
153
160
  function pageSchema(maxPage: number): z.ZodOptional<CountParam> {
154
161
  const page = z