@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.
- package/dist/index.mjs +3278 -0
- package/dist/trigger-scan.mjs +95 -0
- package/package.json +40 -0
- package/src/auto-crud.ts +244 -0
- package/src/billing.ts +297 -0
- package/src/broker.ts +85 -0
- package/src/codec.ts +41 -0
- package/src/ctx.ts +33 -0
- package/src/db.ts +258 -0
- package/src/dispatcher.ts +257 -0
- package/src/http-error.ts +20 -0
- package/src/index.ts +702 -0
- package/src/migrator.ts +167 -0
- package/src/notifications.ts +628 -0
- package/src/predicate.ts +440 -0
- package/src/storage.ts +384 -0
- package/src/subscriptions.ts +654 -0
- package/src/test/auto-crud.test.ts +385 -0
- package/src/test/dispatcher.test.ts +271 -0
- package/src/test/migrator.test.ts +166 -0
- package/src/test/notifications.test.ts +96 -0
- package/src/test/predicate.test.ts +267 -0
- package/src/test/schema-swap.test.ts +252 -0
- package/src/test/security.test.ts +323 -0
- package/src/test/subscriptions.test.ts +878 -0
- package/src/test/trigger-scan.test.ts +78 -0
- package/src/trigger-scan.ts +173 -0
- package/src/triggers.ts +837 -0
- package/src/web-push.d.ts +18 -0
- package/src/workflows.test.ts +127 -0
- package/src/workflows.ts +438 -0
package/src/predicate.ts
ADDED
|
@@ -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
|
+
}
|