@omg-dev/server 0.4.24

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.
@@ -0,0 +1,440 @@
1
+ // ── Predicate engine ─────────────────────────────────────────────────────────
2
+ //
3
+ // A small JSON predicate AST that compiles to BOTH a parameterized SQL WHERE
4
+ // clause and an equivalent in-memory JS row matcher. The duality is the
5
+ // load-bearing piece: subscribers send a JSON predicate to narrow their
6
+ // initial snapshot (SQL path), and the same compiled JS function decides
7
+ // on every write whether a changed row enters or leaves their result set
8
+ // (used in Phase 3 for delta computation).
9
+ //
10
+ // Wire format is deliberately small — every operator widens the surface
11
+ // for predicate-translation bugs and the SQLite/JS semantics that differ
12
+ // between them (collation, NULL behavior, type coercion). Adding new
13
+ // operators is fine; doing so loosely is not. Each operator's SQL and JS
14
+ // emitters are tested side-by-side against the same random rows in
15
+ // predicate.test.ts to keep them honest.
16
+ //
17
+ // Column names go through IDENTIFIER_RE before they reach the SQL emitter
18
+ // so the WHERE clause stays interpolation-safe. Values are always bound as
19
+ // parameters — never string-concatenated.
20
+
21
+ // ── Types ────────────────────────────────────────────────────────────────────
22
+
23
+ export type Literal = string | number | boolean | null
24
+
25
+ export type Predicate =
26
+ | { op: "and"; clauses: Predicate[] }
27
+ | { op: "or"; clauses: Predicate[] }
28
+ | { op: "not"; clause: Predicate }
29
+ | { op: "eq"; column: string; value: Literal }
30
+ | { op: "ne"; column: string; value: Literal }
31
+ | { op: "gt"; column: string; value: number | string }
32
+ | { op: "gte"; column: string; value: number | string }
33
+ | { op: "lt"; column: string; value: number | string }
34
+ | { op: "lte"; column: string; value: number | string }
35
+ | { op: "in"; column: string; values: Literal[] }
36
+ | { op: "like"; column: string; pattern: string }
37
+ | { op: "isNull"; column: string }
38
+ | { op: "isNotNull"; column: string }
39
+
40
+ export class PredicateValidationError extends Error {
41
+ constructor(message: string) {
42
+ super(message)
43
+ this.name = "PredicateValidationError"
44
+ }
45
+ }
46
+
47
+ const IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/
48
+
49
+ // Bound a malicious / accidental client from sending pathological trees.
50
+ // These are deliberately small — the predicate engine is meant for filter
51
+ // expressions that fit on a screen, not arbitrary query plans.
52
+ const MAX_DEPTH = 32
53
+ const MAX_CLAUSES = 256
54
+
55
+ // ── Validation ───────────────────────────────────────────────────────────────
56
+
57
+ export function validatePredicate(node: unknown): asserts node is Predicate {
58
+ validateAtDepth(node, 0)
59
+ }
60
+
61
+ function validateAtDepth(node: unknown, depth: number): asserts node is Predicate {
62
+ if (depth > MAX_DEPTH) {
63
+ throw new PredicateValidationError(`predicate nested too deep (>${MAX_DEPTH})`)
64
+ }
65
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
66
+ throw new PredicateValidationError("predicate must be an object")
67
+ }
68
+ const n = node as { op: unknown }
69
+ if (typeof n.op !== "string") {
70
+ throw new PredicateValidationError("predicate missing 'op'")
71
+ }
72
+
73
+ switch (n.op) {
74
+ case "and":
75
+ case "or": {
76
+ const m = n as unknown as { clauses?: unknown }
77
+ if (!Array.isArray(m.clauses)) {
78
+ throw new PredicateValidationError(`'${n.op}' requires 'clauses' array`)
79
+ }
80
+ if (m.clauses.length === 0) {
81
+ throw new PredicateValidationError(`'${n.op}' requires at least one clause`)
82
+ }
83
+ if (m.clauses.length > MAX_CLAUSES) {
84
+ throw new PredicateValidationError(`'${n.op}' has too many clauses (>${MAX_CLAUSES})`)
85
+ }
86
+ for (const c of m.clauses) validateAtDepth(c, depth + 1)
87
+ return
88
+ }
89
+ case "not": {
90
+ const m = n as unknown as { clause?: unknown }
91
+ validateAtDepth(m.clause, depth + 1)
92
+ return
93
+ }
94
+ case "eq":
95
+ case "ne": {
96
+ const m = n as unknown as { column?: unknown; value?: unknown }
97
+ requireColumn(m.column)
98
+ requireLiteral(m.value)
99
+ return
100
+ }
101
+ case "gt":
102
+ case "gte":
103
+ case "lt":
104
+ case "lte": {
105
+ const m = n as unknown as { column?: unknown; value?: unknown }
106
+ requireColumn(m.column)
107
+ // Ordering operators reject booleans and null — those have no
108
+ // meaningful ordering in either SQLite or JS.
109
+ const v = m.value
110
+ if (v === null || typeof v === "boolean") {
111
+ throw new PredicateValidationError(`'${n.op}' value must be number or string, got ${v === null ? "null" : "boolean"}`)
112
+ }
113
+ if (typeof v !== "number" && typeof v !== "string") {
114
+ throw new PredicateValidationError(`'${n.op}' value must be number or string`)
115
+ }
116
+ if (typeof v === "number" && !Number.isFinite(v)) {
117
+ throw new PredicateValidationError(`'${n.op}' value must be a finite number`)
118
+ }
119
+ return
120
+ }
121
+ case "in": {
122
+ const m = n as unknown as { column?: unknown; values?: unknown }
123
+ requireColumn(m.column)
124
+ if (!Array.isArray(m.values)) {
125
+ throw new PredicateValidationError("'in' requires 'values' array")
126
+ }
127
+ if (m.values.length === 0) {
128
+ throw new PredicateValidationError("'in' values must be non-empty")
129
+ }
130
+ if (m.values.length > MAX_CLAUSES) {
131
+ throw new PredicateValidationError(`'in' has too many values (>${MAX_CLAUSES})`)
132
+ }
133
+ for (const v of m.values) requireLiteral(v)
134
+ return
135
+ }
136
+ case "like": {
137
+ const m = n as unknown as { column?: unknown; pattern?: unknown }
138
+ requireColumn(m.column)
139
+ if (typeof m.pattern !== "string") {
140
+ throw new PredicateValidationError("'like' requires string 'pattern'")
141
+ }
142
+ return
143
+ }
144
+ case "isNull":
145
+ case "isNotNull": {
146
+ const m = n as unknown as { column?: unknown }
147
+ requireColumn(m.column)
148
+ return
149
+ }
150
+ default:
151
+ throw new PredicateValidationError(`unknown op: ${String(n.op)}`)
152
+ }
153
+ }
154
+
155
+ function requireColumn(c: unknown): asserts c is string {
156
+ if (typeof c !== "string" || !IDENTIFIER_RE.test(c)) {
157
+ throw new PredicateValidationError(`invalid column: ${String(c)}`)
158
+ }
159
+ }
160
+
161
+ function requireLiteral(v: unknown): asserts v is Literal {
162
+ if (v === null) return
163
+ const t = typeof v
164
+ if (t !== "string" && t !== "number" && t !== "boolean") {
165
+ throw new PredicateValidationError(`unsupported literal: ${t}`)
166
+ }
167
+ if (t === "number" && !Number.isFinite(v as number)) {
168
+ throw new PredicateValidationError("non-finite numbers (NaN/Infinity) not allowed")
169
+ }
170
+ }
171
+
172
+ // ── SQL emitter ──────────────────────────────────────────────────────────────
173
+
174
+ export interface CompiledSql {
175
+ /** Parenthesized SQL fragment safe to AND with other WHERE conditions. */
176
+ sql: string
177
+ /** Bound parameters in left-to-right order. */
178
+ params: unknown[]
179
+ }
180
+
181
+ export function compileToSql(pred: Predicate): CompiledSql {
182
+ const params: unknown[] = []
183
+ const sql = emitSql(pred, params)
184
+ return { sql, params }
185
+ }
186
+
187
+ function emitSql(p: Predicate, params: unknown[]): string {
188
+ switch (p.op) {
189
+ case "and":
190
+ return `(${p.clauses.map(c => emitSql(c, params)).join(" AND ")})`
191
+ case "or":
192
+ return `(${p.clauses.map(c => emitSql(c, params)).join(" OR ")})`
193
+ case "not":
194
+ return `(NOT ${emitSql(p.clause, params)})`
195
+ case "eq":
196
+ params.push(encodeLiteral(p.value))
197
+ return `${p.column} = ?`
198
+ case "ne":
199
+ params.push(encodeLiteral(p.value))
200
+ return `${p.column} != ?`
201
+ case "gt":
202
+ params.push(encodeLiteral(p.value))
203
+ return `${p.column} > ?`
204
+ case "gte":
205
+ params.push(encodeLiteral(p.value))
206
+ return `${p.column} >= ?`
207
+ case "lt":
208
+ params.push(encodeLiteral(p.value))
209
+ return `${p.column} < ?`
210
+ case "lte":
211
+ params.push(encodeLiteral(p.value))
212
+ return `${p.column} <= ?`
213
+ case "in": {
214
+ for (const v of p.values) params.push(encodeLiteral(v))
215
+ return `${p.column} IN (${p.values.map(() => "?").join(", ")})`
216
+ }
217
+ case "like":
218
+ params.push(p.pattern)
219
+ return `${p.column} LIKE ?`
220
+ case "isNull":
221
+ return `${p.column} IS NULL`
222
+ case "isNotNull":
223
+ return `${p.column} IS NOT NULL`
224
+ }
225
+ }
226
+
227
+ // SQLite stores booleans as INTEGER 0/1 — coerce on the way to a bound param
228
+ // so an `eq done true` predicate hits a `done = 1` row.
229
+ function encodeLiteral(v: Literal): unknown {
230
+ if (typeof v === "boolean") return v ? 1 : 0
231
+ return v
232
+ }
233
+
234
+ // ── JS emitter ───────────────────────────────────────────────────────────────
235
+ //
236
+ // SQLite uses three-valued logic — a comparison involving NULL is UNKNOWN
237
+ // (not FALSE), and AND/OR/NOT propagate UNKNOWN per the SQL truth tables.
238
+ // The top-level WHERE keeps only rows whose result is exactly TRUE, so
239
+ // NOT UNKNOWN excludes a row (it does NOT flip to TRUE the way a naive
240
+ // two-valued NOT would).
241
+ //
242
+ // The JS emitter therefore tracks UNKNOWN as the literal value `null` in
243
+ // an internal TriState evaluator, then collapses to `boolean` at the top.
244
+ // Without this, `NOT (NULL >= 'x')` returns TRUE in JS but UNKNOWN in
245
+ // SQLite — the property test in predicate.test.ts proves the difference.
246
+
247
+ type TriState = true | false | null
248
+
249
+ /**
250
+ * Compile to a row matcher. The row is the *decoded* shape — same flavour
251
+ * as what auto-crud returns to REST clients (booleans as `true`/`false`,
252
+ * arrays as JS arrays, etc). Subscribers feed decoded rows in.
253
+ */
254
+ export function compileToJs(pred: Predicate): (row: Record<string, unknown>) => boolean {
255
+ const tri = compileToTri(pred)
256
+ return (row) => tri(row) === true
257
+ }
258
+
259
+ function compileToTri(pred: Predicate): (row: Record<string, unknown>) => TriState {
260
+ switch (pred.op) {
261
+ case "and": {
262
+ const cs = pred.clauses.map(compileToTri)
263
+ return (r) => {
264
+ // SQL AND: any FALSE → FALSE; any UNKNOWN (without FALSE) → UNKNOWN;
265
+ // all TRUE → TRUE.
266
+ let seenNull = false
267
+ for (const c of cs) {
268
+ const v = c(r)
269
+ if (v === false) return false
270
+ if (v === null) seenNull = true
271
+ }
272
+ return seenNull ? null : true
273
+ }
274
+ }
275
+ case "or": {
276
+ const cs = pred.clauses.map(compileToTri)
277
+ return (r) => {
278
+ // SQL OR: any TRUE → TRUE; any UNKNOWN (without TRUE) → UNKNOWN;
279
+ // all FALSE → FALSE.
280
+ let seenNull = false
281
+ for (const c of cs) {
282
+ const v = c(r)
283
+ if (v === true) return true
284
+ if (v === null) seenNull = true
285
+ }
286
+ return seenNull ? null : false
287
+ }
288
+ }
289
+ case "not": {
290
+ const c = compileToTri(pred.clause)
291
+ return (r) => {
292
+ const v = c(r)
293
+ if (v === null) return null
294
+ return !v
295
+ }
296
+ }
297
+ case "eq": {
298
+ const { column, value } = pred
299
+ return (r) => {
300
+ const cv = r[column]
301
+ if (cv === null || cv === undefined) return null
302
+ if (value === null) return null
303
+ return valuesEqual(cv, value)
304
+ }
305
+ }
306
+ case "ne": {
307
+ const { column, value } = pred
308
+ return (r) => {
309
+ const cv = r[column]
310
+ if (cv === null || cv === undefined) return null
311
+ if (value === null) return null
312
+ return !valuesEqual(cv, value)
313
+ }
314
+ }
315
+ case "gt": {
316
+ const { column, value } = pred
317
+ return (r) => triCompare(r[column], value, (n) => n > 0)
318
+ }
319
+ case "gte": {
320
+ const { column, value } = pred
321
+ return (r) => triCompare(r[column], value, (n) => n >= 0)
322
+ }
323
+ case "lt": {
324
+ const { column, value } = pred
325
+ return (r) => triCompare(r[column], value, (n) => n < 0)
326
+ }
327
+ case "lte": {
328
+ const { column, value } = pred
329
+ return (r) => triCompare(r[column], value, (n) => n <= 0)
330
+ }
331
+ case "in": {
332
+ const { column, values } = pred
333
+ return (r) => {
334
+ const cv = r[column]
335
+ if (cv === null || cv === undefined) return null
336
+ // SQL `x IN (..., NULL, ...)` returns TRUE if x equals a non-null
337
+ // entry, otherwise UNKNOWN if any NULL is in the list, otherwise
338
+ // FALSE. Mirror that.
339
+ let seenNull = false
340
+ for (const x of values) {
341
+ if (x === null) { seenNull = true; continue }
342
+ if (valuesEqual(cv, x)) return true
343
+ }
344
+ return seenNull ? null : false
345
+ }
346
+ }
347
+ case "like": {
348
+ const { column, pattern } = pred
349
+ const re = likeToRegExp(pattern)
350
+ return (r) => {
351
+ const cv = r[column]
352
+ if (cv === null || cv === undefined) return null
353
+ // Non-string columns under LIKE — SQLite coerces with TEXT
354
+ // affinity; we stringify for the same effective result.
355
+ const s = typeof cv === "string" ? cv : String(cv)
356
+ return re.test(s)
357
+ }
358
+ }
359
+ case "isNull": {
360
+ const { column } = pred
361
+ // isNull is total — never UNKNOWN.
362
+ return (r) => r[column] === null || r[column] === undefined
363
+ }
364
+ case "isNotNull": {
365
+ const { column } = pred
366
+ return (r) => r[column] !== null && r[column] !== undefined
367
+ }
368
+ }
369
+ }
370
+
371
+ function triCompare(
372
+ a: unknown,
373
+ b: number | string,
374
+ pick: (n: number) => boolean,
375
+ ): TriState {
376
+ if (a === null || a === undefined) return null
377
+ const n = orderedCompare(a, b)
378
+ if (Number.isNaN(n)) return null
379
+ return pick(n)
380
+ }
381
+
382
+ // ── JS comparison helpers ────────────────────────────────────────────────────
383
+
384
+ /**
385
+ * Equality with SQLite-ish semantics:
386
+ * - NULL is never equal to anything (including NULL) → false. Use isNull
387
+ * if you want NULL matching.
388
+ * - boolean ↔ 0/1 matches (so a decoded `true` matches a stored `1` row
389
+ * if anyone hands us a raw row by accident).
390
+ * - Number/string types compare loosely so an unquoted JSON literal
391
+ * matches the matching DB column.
392
+ */
393
+ function valuesEqual(a: unknown, b: Literal): boolean {
394
+ if (a === null || a === undefined) return false
395
+ if (typeof b === "boolean") {
396
+ if (typeof a === "boolean") return a === b
397
+ if (typeof a === "number") return (b ? 1 : 0) === a
398
+ return false
399
+ }
400
+ if (b === null) return false
401
+ // string-string and number-number are strict.
402
+ if (typeof a === typeof b) return a === b
403
+ // Mixed-type compare: stringify both sides, mirroring SQLite's TEXT
404
+ // affinity behaviour for `=` between INTEGER and TEXT.
405
+ return String(a) === String(b)
406
+ }
407
+
408
+ /**
409
+ * Returns -1 / 0 / +1, or NaN if either side is null/undefined or types
410
+ * don't permit ordering. Callers treat NaN as "predicate false" — same as
411
+ * SQL where any comparison involving NULL yields UNKNOWN.
412
+ */
413
+ function orderedCompare(a: unknown, b: number | string): number {
414
+ if (a === null || a === undefined) return NaN
415
+ if (typeof b === "number") {
416
+ const av = typeof a === "number" ? a : Number(a)
417
+ if (!Number.isFinite(av)) return NaN
418
+ return av < b ? -1 : av > b ? 1 : 0
419
+ }
420
+ // string ordering — lexicographic, matching SQLite's BINARY collation.
421
+ const as = typeof a === "string" ? a : String(a)
422
+ return as < b ? -1 : as > b ? 1 : 0
423
+ }
424
+
425
+ /**
426
+ * Translate a SQL LIKE pattern to a JS RegExp. SQLite LIKE is
427
+ * case-insensitive for ASCII by default — we mirror that with the `i` flag.
428
+ * `%` matches any run of characters; `_` matches exactly one. No escape
429
+ * handling yet — adding `ESCAPE '\\'` is a Phase 6 concern.
430
+ */
431
+ function likeToRegExp(pattern: string): RegExp {
432
+ let re = "^"
433
+ for (const ch of pattern) {
434
+ if (ch === "%") re += ".*"
435
+ else if (ch === "_") re += "."
436
+ else re += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
437
+ }
438
+ re += "$"
439
+ return new RegExp(re, "i")
440
+ }