@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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test"
|
|
2
|
+
import { Database } from "bun:sqlite"
|
|
3
|
+
import { defineSchema, collection, fields } from "@omg-dev/schema"
|
|
4
|
+
import { migrate } from "../migrator.ts"
|
|
5
|
+
|
|
6
|
+
// Reproduces the boot crash a control-plane / generated app hits when a
|
|
7
|
+
// collection that was created while `scope:"global"` is later marked
|
|
8
|
+
// `.scoped("user").index("_owner", …)`. `_owner` is an implicit column (not a
|
|
9
|
+
// declared field), so schemaDiff never emits an add_column for it; the
|
|
10
|
+
// add_index for the `_owner` index then ran against a table with no `_owner`
|
|
11
|
+
// column → `SQLiteError: no such column: _owner`. The default-private flip
|
|
12
|
+
// (schema 0.3.0) made this transition the common case.
|
|
13
|
+
|
|
14
|
+
function rowCount(db: Database, table: string): number {
|
|
15
|
+
return (db.prepare(`SELECT COUNT(*) c FROM ${table}`).get() as { c: number }).c
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hasColumn(db: Database, table: string, col: string): boolean {
|
|
19
|
+
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
|
|
20
|
+
return cols.some(c => c.name === col)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("migrate — global→user scope transition", () => {
|
|
24
|
+
it("adds the implicit _owner column before creating an _owner index (no crash)", () => {
|
|
25
|
+
const db = new Database(":memory:")
|
|
26
|
+
|
|
27
|
+
// v1: global table, no _owner, no index.
|
|
28
|
+
migrate(
|
|
29
|
+
db,
|
|
30
|
+
defineSchema({
|
|
31
|
+
collections: {
|
|
32
|
+
mig_posts: collection({ fields: { title: fields.string() } }).scoped("global"),
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
expect(hasColumn(db, "mig_posts", "_owner")).toBe(false)
|
|
37
|
+
|
|
38
|
+
// Seed a historical row written while the table was global.
|
|
39
|
+
db.prepare(
|
|
40
|
+
`INSERT INTO mig_posts (id, created_at, updated_at, title) VALUES (?, ?, ?, ?)`,
|
|
41
|
+
).run("p1", "2026-01-01", "2026-01-01", "hello")
|
|
42
|
+
|
|
43
|
+
// v2: same collection becomes user-scoped WITH an _owner index — the exact
|
|
44
|
+
// shape that used to crash the migrator on boot.
|
|
45
|
+
expect(() =>
|
|
46
|
+
migrate(
|
|
47
|
+
db,
|
|
48
|
+
defineSchema({
|
|
49
|
+
collections: {
|
|
50
|
+
mig_posts: collection({ fields: { title: fields.string() } })
|
|
51
|
+
.scoped("user")
|
|
52
|
+
.index("_owner", "created_at"),
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
).not.toThrow()
|
|
57
|
+
|
|
58
|
+
// _owner column added, index created, historical row preserved (NULL owner
|
|
59
|
+
// until backfilled).
|
|
60
|
+
expect(hasColumn(db, "mig_posts", "_owner")).toBe(true)
|
|
61
|
+
expect(rowCount(db, "mig_posts")).toBe(1)
|
|
62
|
+
const idx = db
|
|
63
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND name = ?`)
|
|
64
|
+
.get("idx_mig_posts__owner_created_at") as { name: string } | undefined
|
|
65
|
+
expect(idx?.name).toBe("idx_mig_posts__owner_created_at")
|
|
66
|
+
const row = db.prepare(`SELECT _owner FROM mig_posts WHERE id = ?`).get("p1") as {
|
|
67
|
+
_owner: string | null
|
|
68
|
+
}
|
|
69
|
+
expect(row._owner).toBeNull()
|
|
70
|
+
|
|
71
|
+
db.close()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("adds _owner on a scope transition even when nothing else changed", () => {
|
|
75
|
+
const db = new Database(":memory:")
|
|
76
|
+
|
|
77
|
+
migrate(
|
|
78
|
+
db,
|
|
79
|
+
defineSchema({
|
|
80
|
+
collections: {
|
|
81
|
+
mig_notes: collection({ fields: { body: fields.string() } }).scoped("global"),
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
expect(hasColumn(db, "mig_notes", "_owner")).toBe(false)
|
|
86
|
+
|
|
87
|
+
// No field change, no index — only the scope flips. schemaDiff returns [],
|
|
88
|
+
// so without the dedicated _owner pass the column would never be added and
|
|
89
|
+
// WS owner-filtering would silently break.
|
|
90
|
+
migrate(
|
|
91
|
+
db,
|
|
92
|
+
defineSchema({
|
|
93
|
+
collections: {
|
|
94
|
+
mig_notes: collection({ fields: { body: fields.string() } }).scoped("user"),
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
expect(hasColumn(db, "mig_notes", "_owner")).toBe(true)
|
|
99
|
+
|
|
100
|
+
db.close()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("is idempotent — re-running the scoped schema does not re-add or crash", () => {
|
|
104
|
+
const db = new Database(":memory:")
|
|
105
|
+
const scoped = defineSchema({
|
|
106
|
+
collections: {
|
|
107
|
+
mig_items: collection({ fields: { name: fields.string() } })
|
|
108
|
+
.scoped("user")
|
|
109
|
+
.index("_owner"),
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
migrate(db, scoped)
|
|
113
|
+
expect(hasColumn(db, "mig_items", "_owner")).toBe(true)
|
|
114
|
+
expect(() => migrate(db, scoped)).not.toThrow()
|
|
115
|
+
// Exactly one _owner column.
|
|
116
|
+
const owners = (db.prepare(`PRAGMA table_info(mig_items)`).all() as { name: string }[]).filter(
|
|
117
|
+
c => c.name === "_owner",
|
|
118
|
+
)
|
|
119
|
+
expect(owners).toHaveLength(1)
|
|
120
|
+
db.close()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe("migrate — unique indexes", () => {
|
|
125
|
+
it("creates a unique index that makes duplicate values impossible", () => {
|
|
126
|
+
const db = new Database(":memory:")
|
|
127
|
+
migrate(
|
|
128
|
+
db,
|
|
129
|
+
defineSchema({
|
|
130
|
+
collections: {
|
|
131
|
+
webhookClaims: collection({ fields: { eventId: fields.string() } })
|
|
132
|
+
.scoped("global")
|
|
133
|
+
.unique("eventId"),
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
db.prepare(`INSERT INTO webhookClaims (id, eventId) VALUES (?, ?)`).run("first", "evt_1")
|
|
138
|
+
expect(() =>
|
|
139
|
+
db.prepare(`INSERT INTO webhookClaims (id, eventId) VALUES (?, ?)`).run("second", "evt_1"),
|
|
140
|
+
).toThrow()
|
|
141
|
+
db.close()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("adds a unique index when an existing table gains a unique declaration", () => {
|
|
145
|
+
const db = new Database(":memory:")
|
|
146
|
+
const v1 = defineSchema({
|
|
147
|
+
collections: {
|
|
148
|
+
webhookClaims: collection({ fields: { eventId: fields.string() } }).scoped("global"),
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
const v2 = defineSchema({
|
|
152
|
+
collections: {
|
|
153
|
+
webhookClaims: collection({ fields: { eventId: fields.string() } })
|
|
154
|
+
.scoped("global")
|
|
155
|
+
.unique("eventId"),
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
migrate(db, v1)
|
|
159
|
+
migrate(db, v2)
|
|
160
|
+
const index = db
|
|
161
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`)
|
|
162
|
+
.get("uidx_webhookClaims_eventId") as { name: string } | undefined
|
|
163
|
+
expect(index?.name).toBe("uidx_webhookClaims_eventId")
|
|
164
|
+
db.close()
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test"
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { createVibesServer, type VibesServerInstance } from "../index.ts"
|
|
6
|
+
|
|
7
|
+
let servers: VibesServerInstance[] = []
|
|
8
|
+
let roots: string[] = []
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
for (const server of servers) server.close()
|
|
12
|
+
servers = []
|
|
13
|
+
for (const root of roots) fs.rmSync(root, { recursive: true, force: true })
|
|
14
|
+
roots = []
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
async function makeServer() {
|
|
18
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vibes-notifications-"))
|
|
19
|
+
fs.mkdirSync(path.join(root, ".vibes"), { recursive: true })
|
|
20
|
+
roots.push(root)
|
|
21
|
+
const server = await createVibesServer({
|
|
22
|
+
root,
|
|
23
|
+
db: "data.db",
|
|
24
|
+
auth: async (req) => {
|
|
25
|
+
const userId = req.headers.get("x-test-user")
|
|
26
|
+
return userId ? { userId } : null
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
servers.push(server)
|
|
30
|
+
return server
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function authed(pathname: string, init?: RequestInit) {
|
|
34
|
+
const headers = new Headers(init?.headers)
|
|
35
|
+
headers.set("x-test-user", "user-1")
|
|
36
|
+
return new Request(`http://x${pathname}`, { ...init, headers })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("notifications", () => {
|
|
40
|
+
it("creates, dedupes, lists, and marks inbox notifications read", async () => {
|
|
41
|
+
const server = await makeServer()
|
|
42
|
+
|
|
43
|
+
const create = await server.fetch(authed("/api/_notifications/create", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "content-type": "application/json" },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
kind: "agent_needs_input",
|
|
48
|
+
title: "Agent needs input",
|
|
49
|
+
body: "Pick the next step.",
|
|
50
|
+
url: "/demo",
|
|
51
|
+
dedupeKey: "run:1:needs-input",
|
|
52
|
+
}),
|
|
53
|
+
}))
|
|
54
|
+
expect(create.status).toBe(200)
|
|
55
|
+
const created = await create.json() as { id: string; status: string }
|
|
56
|
+
expect(created.status).toBe("unread")
|
|
57
|
+
|
|
58
|
+
const duplicate = await server.fetch(authed("/api/_notifications/create", {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "content-type": "application/json" },
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
kind: "agent_needs_input",
|
|
63
|
+
title: "Duplicate",
|
|
64
|
+
dedupeKey: "run:1:needs-input",
|
|
65
|
+
}),
|
|
66
|
+
}))
|
|
67
|
+
const dup = await duplicate.json() as { id: string; title: string }
|
|
68
|
+
expect(dup.id).toBe(created.id)
|
|
69
|
+
expect(dup.title).toBe("Agent needs input")
|
|
70
|
+
|
|
71
|
+
const list = await server.fetch(authed("/api/_notifications/list"))
|
|
72
|
+
expect(list.status).toBe(200)
|
|
73
|
+
const rows = await list.json() as Array<{ id: string; title: string }>
|
|
74
|
+
expect(rows).toHaveLength(1)
|
|
75
|
+
expect(rows[0].title).toBe("Agent needs input")
|
|
76
|
+
|
|
77
|
+
const read = await server.fetch(authed("/api/_notifications/read", {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "content-type": "application/json" },
|
|
80
|
+
body: JSON.stringify({ ids: [created.id] }),
|
|
81
|
+
}))
|
|
82
|
+
expect(read.status).toBe(200)
|
|
83
|
+
|
|
84
|
+
const unread = await server.fetch(authed("/api/_notifications/list?unread=1"))
|
|
85
|
+
const unreadRows = await unread.json() as unknown[]
|
|
86
|
+
expect(unreadRows).toHaveLength(0)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("serves the dedicated push service worker", async () => {
|
|
90
|
+
const server = await makeServer()
|
|
91
|
+
const res = await server.fetch(new Request("http://x/__vibes_push/sw.js"))
|
|
92
|
+
expect(res.status).toBe(200)
|
|
93
|
+
expect(res.headers.get("content-type")).toContain("text/javascript")
|
|
94
|
+
expect(await res.text()).toContain("showNotification")
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test"
|
|
2
|
+
import { Database } from "bun:sqlite"
|
|
3
|
+
import {
|
|
4
|
+
validatePredicate,
|
|
5
|
+
compileToSql,
|
|
6
|
+
compileToJs,
|
|
7
|
+
PredicateValidationError,
|
|
8
|
+
type Predicate,
|
|
9
|
+
} from "../predicate.ts"
|
|
10
|
+
|
|
11
|
+
// ── Validation ───────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("predicate — validation", () => {
|
|
14
|
+
it("accepts well-formed leaf operators", () => {
|
|
15
|
+
expect(() => validatePredicate({ op: "eq", column: "x", value: 1 })).not.toThrow()
|
|
16
|
+
expect(() => validatePredicate({ op: "in", column: "tag", values: ["a", "b"] })).not.toThrow()
|
|
17
|
+
expect(() => validatePredicate({ op: "isNull", column: "name" })).not.toThrow()
|
|
18
|
+
expect(() => validatePredicate({ op: "like", column: "title", pattern: "foo%" })).not.toThrow()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("rejects an empty and/or", () => {
|
|
22
|
+
expect(() => validatePredicate({ op: "and", clauses: [] })).toThrow(PredicateValidationError)
|
|
23
|
+
expect(() => validatePredicate({ op: "or", clauses: [] })).toThrow(PredicateValidationError)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("rejects non-identifier columns (no SQL injection vector)", () => {
|
|
27
|
+
expect(() => validatePredicate({ op: "eq", column: "x; DROP TABLE y", value: 1 }))
|
|
28
|
+
.toThrow(PredicateValidationError)
|
|
29
|
+
expect(() => validatePredicate({ op: "eq", column: "1bad", value: 1 }))
|
|
30
|
+
.toThrow(PredicateValidationError)
|
|
31
|
+
expect(() => validatePredicate({ op: "eq", column: "", value: 1 }))
|
|
32
|
+
.toThrow(PredicateValidationError)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("rejects unsupported literal types", () => {
|
|
36
|
+
expect(() => validatePredicate({ op: "eq", column: "x", value: { nested: true } as never }))
|
|
37
|
+
.toThrow(PredicateValidationError)
|
|
38
|
+
expect(() => validatePredicate({ op: "eq", column: "x", value: [1, 2] as never }))
|
|
39
|
+
.toThrow(PredicateValidationError)
|
|
40
|
+
expect(() => validatePredicate({ op: "eq", column: "x", value: Number.NaN as never }))
|
|
41
|
+
.toThrow(PredicateValidationError)
|
|
42
|
+
expect(() => validatePredicate({ op: "eq", column: "x", value: Infinity as never }))
|
|
43
|
+
.toThrow(PredicateValidationError)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("rejects ordering operators against boolean / null", () => {
|
|
47
|
+
expect(() => validatePredicate({ op: "gt", column: "x", value: true as never }))
|
|
48
|
+
.toThrow(PredicateValidationError)
|
|
49
|
+
expect(() => validatePredicate({ op: "lt", column: "x", value: null as never }))
|
|
50
|
+
.toThrow(PredicateValidationError)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("rejects an unknown op", () => {
|
|
54
|
+
expect(() => validatePredicate({ op: "regex", column: "x", pattern: ".*" } as never))
|
|
55
|
+
.toThrow(PredicateValidationError)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("rejects pathologically deep trees", () => {
|
|
59
|
+
let p: Predicate = { op: "eq", column: "x", value: 1 }
|
|
60
|
+
for (let i = 0; i < 40; i++) p = { op: "not", clause: p }
|
|
61
|
+
expect(() => validatePredicate(p)).toThrow(PredicateValidationError)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("rejects pathologically wide and/or", () => {
|
|
65
|
+
const clauses: Predicate[] = []
|
|
66
|
+
for (let i = 0; i < 300; i++) clauses.push({ op: "eq", column: "x", value: i })
|
|
67
|
+
expect(() => validatePredicate({ op: "and", clauses })).toThrow(PredicateValidationError)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ── SQL emitter ──────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe("predicate — compileToSql", () => {
|
|
74
|
+
it("emits parameterized SQL for each operator", () => {
|
|
75
|
+
expect(compileToSql({ op: "eq", column: "x", value: 42 })).toEqual({
|
|
76
|
+
sql: "x = ?", params: [42],
|
|
77
|
+
})
|
|
78
|
+
expect(compileToSql({ op: "ne", column: "x", value: "hi" })).toEqual({
|
|
79
|
+
sql: "x != ?", params: ["hi"],
|
|
80
|
+
})
|
|
81
|
+
expect(compileToSql({ op: "gt", column: "x", value: 1 })).toEqual({
|
|
82
|
+
sql: "x > ?", params: [1],
|
|
83
|
+
})
|
|
84
|
+
expect(compileToSql({ op: "in", column: "t", values: ["a", "b", "c"] })).toEqual({
|
|
85
|
+
sql: "t IN (?, ?, ?)", params: ["a", "b", "c"],
|
|
86
|
+
})
|
|
87
|
+
expect(compileToSql({ op: "like", column: "n", pattern: "foo%" })).toEqual({
|
|
88
|
+
sql: "n LIKE ?", params: ["foo%"],
|
|
89
|
+
})
|
|
90
|
+
expect(compileToSql({ op: "isNull", column: "n" })).toEqual({
|
|
91
|
+
sql: "n IS NULL", params: [],
|
|
92
|
+
})
|
|
93
|
+
expect(compileToSql({ op: "isNotNull", column: "n" })).toEqual({
|
|
94
|
+
sql: "n IS NOT NULL", params: [],
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("encodes booleans as 0/1 in bound params (SQLite storage class)", () => {
|
|
99
|
+
expect(compileToSql({ op: "eq", column: "done", value: true })).toEqual({
|
|
100
|
+
sql: "done = ?", params: [1],
|
|
101
|
+
})
|
|
102
|
+
expect(compileToSql({ op: "eq", column: "done", value: false })).toEqual({
|
|
103
|
+
sql: "done = ?", params: [0],
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("composes and / or / not", () => {
|
|
108
|
+
const { sql, params } = compileToSql({
|
|
109
|
+
op: "and",
|
|
110
|
+
clauses: [
|
|
111
|
+
{ op: "eq", column: "a", value: 1 },
|
|
112
|
+
{ op: "or", clauses: [
|
|
113
|
+
{ op: "lt", column: "b", value: 10 },
|
|
114
|
+
{ op: "not", clause: { op: "isNull", column: "c" } },
|
|
115
|
+
] },
|
|
116
|
+
],
|
|
117
|
+
})
|
|
118
|
+
expect(sql).toBe("(a = ? AND (b < ? OR (NOT c IS NULL)))")
|
|
119
|
+
expect(params).toEqual([1, 10])
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// ── SQL ⇔ JS equivalence (the load-bearing test) ─────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe("predicate — SQL ⇔ JS equivalence on random rows", () => {
|
|
126
|
+
// The duality between compileToSql and compileToJs is what lets the
|
|
127
|
+
// server filter the initial snapshot with SQL and decide per-write
|
|
128
|
+
// delta inclusion with JS. They MUST agree for every row that round-
|
|
129
|
+
// trips through the snapshot/delta protocol — diverging means a sub
|
|
130
|
+
// misses or duplicates rows. This test generates random rows AND random
|
|
131
|
+
// predicates, asks both emitters whether each row passes, and fails if
|
|
132
|
+
// they disagree.
|
|
133
|
+
|
|
134
|
+
// Decoded row shape — matches what auto-crud's REST handlers return.
|
|
135
|
+
type Row = { id: string; n: number; s: string; b: boolean; nilable: string | null }
|
|
136
|
+
|
|
137
|
+
function randRow(rng: () => number, i: number): Row {
|
|
138
|
+
return {
|
|
139
|
+
id: `r${i}`,
|
|
140
|
+
n: Math.floor(rng() * 100) - 50, // -50 .. 49
|
|
141
|
+
s: ["alpha", "beta", "gamma", "delta", ""][Math.floor(rng() * 5)] ?? "",
|
|
142
|
+
b: rng() < 0.5,
|
|
143
|
+
nilable: rng() < 0.3 ? null : `v${Math.floor(rng() * 5)}`,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// SQLite-encoded row (booleans as INT 0/1) — what compileToSql expects
|
|
148
|
+
// the bound row to look like at the storage layer.
|
|
149
|
+
function encodeRow(r: Row): Record<string, unknown> {
|
|
150
|
+
return { id: r.id, n: r.n, s: r.s, b: r.b ? 1 : 0, nilable: r.nilable }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function randPredicate(rng: () => number, depth: number): Predicate {
|
|
154
|
+
if (depth > 2 || rng() < 0.4) {
|
|
155
|
+
const leafOps = ["eq", "ne", "gt", "gte", "lt", "lte", "in", "like", "isNull", "isNotNull"] as const
|
|
156
|
+
const op = leafOps[Math.floor(rng() * leafOps.length)]
|
|
157
|
+
const cols = ["n", "s", "b", "nilable"] as const
|
|
158
|
+
const column = cols[Math.floor(rng() * cols.length)] ?? "n"
|
|
159
|
+
switch (op) {
|
|
160
|
+
case "eq":
|
|
161
|
+
case "ne": {
|
|
162
|
+
// Pick a value from the same domain as the column to get a mix
|
|
163
|
+
// of hits and misses; an all-miss predicate proves little.
|
|
164
|
+
if (column === "n") return { op, column, value: Math.floor(rng() * 100) - 50 }
|
|
165
|
+
if (column === "s") return { op, column, value: ["alpha", "beta", "gamma", "delta", "", "zzz"][Math.floor(rng() * 6)] ?? "" }
|
|
166
|
+
if (column === "b") return { op, column, value: rng() < 0.5 }
|
|
167
|
+
return { op, column, value: rng() < 0.5 ? null : `v${Math.floor(rng() * 5)}` }
|
|
168
|
+
}
|
|
169
|
+
case "gt":
|
|
170
|
+
case "gte":
|
|
171
|
+
case "lt":
|
|
172
|
+
case "lte": {
|
|
173
|
+
if (column === "n") return { op, column, value: Math.floor(rng() * 100) - 50 }
|
|
174
|
+
// string ordering on s or nilable.
|
|
175
|
+
const v = ["alpha", "beta", "gamma", "delta"][Math.floor(rng() * 4)] ?? "alpha"
|
|
176
|
+
return { op, column: column === "b" ? "s" : column, value: v }
|
|
177
|
+
}
|
|
178
|
+
case "in": {
|
|
179
|
+
if (column === "n") return { op, column, values: [1, 2, 3, 4, 5, -1, -2] }
|
|
180
|
+
return { op, column, values: ["alpha", "beta", "v0", "v1"] }
|
|
181
|
+
}
|
|
182
|
+
case "like": {
|
|
183
|
+
return { op, column: "s", pattern: ["alpha%", "%a", "%e%", "delta", "x_z"][Math.floor(rng() * 5)] ?? "%" }
|
|
184
|
+
}
|
|
185
|
+
case "isNull":
|
|
186
|
+
case "isNotNull":
|
|
187
|
+
return { op, column }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const branchOp = rng() < 0.5 ? "and" : "or"
|
|
191
|
+
const arity = 2 + Math.floor(rng() * 2)
|
|
192
|
+
const clauses: Predicate[] = []
|
|
193
|
+
for (let i = 0; i < arity; i++) clauses.push(randPredicate(rng, depth + 1))
|
|
194
|
+
if (rng() < 0.2) return { op: "not", clause: { op: branchOp, clauses } }
|
|
195
|
+
return { op: branchOp, clauses }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Tiny LCG so test failures are reproducible.
|
|
199
|
+
function makeRng(seed: number): () => number {
|
|
200
|
+
let s = seed >>> 0
|
|
201
|
+
return () => {
|
|
202
|
+
s = (s * 1664525 + 1013904223) >>> 0
|
|
203
|
+
return s / 0xffffffff
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const SEEDS = [1, 17, 2025, 0xc0ffee]
|
|
208
|
+
const ROWS_PER_RUN = 80
|
|
209
|
+
const PREDICATES_PER_RUN = 60
|
|
210
|
+
|
|
211
|
+
for (const seed of SEEDS) {
|
|
212
|
+
it(`seed=${seed}: SQL and JS emit the same membership for every (row, predicate) pair`, () => {
|
|
213
|
+
const rng = makeRng(seed)
|
|
214
|
+
const rows: Row[] = []
|
|
215
|
+
for (let i = 0; i < ROWS_PER_RUN; i++) rows.push(randRow(rng, i))
|
|
216
|
+
|
|
217
|
+
const db = new Database(":memory:")
|
|
218
|
+
db.exec(`CREATE TABLE t (
|
|
219
|
+
id TEXT PRIMARY KEY,
|
|
220
|
+
n INTEGER,
|
|
221
|
+
s TEXT,
|
|
222
|
+
b INTEGER,
|
|
223
|
+
nilable TEXT
|
|
224
|
+
)`)
|
|
225
|
+
const insert = db.prepare(
|
|
226
|
+
"INSERT INTO t (id, n, s, b, nilable) VALUES (?, ?, ?, ?, ?)",
|
|
227
|
+
)
|
|
228
|
+
for (const r of rows) {
|
|
229
|
+
const er = encodeRow(r)
|
|
230
|
+
insert.run(er.id, er.n, er.s, er.b, er.nilable)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (let i = 0; i < PREDICATES_PER_RUN; i++) {
|
|
234
|
+
const pred = randPredicate(rng, 0)
|
|
235
|
+
validatePredicate(pred)
|
|
236
|
+
|
|
237
|
+
const { sql, params } = compileToSql(pred)
|
|
238
|
+
const sqlIds = new Set(
|
|
239
|
+
(db.prepare(`SELECT id FROM t WHERE ${sql}`).all(...params) as { id: string }[])
|
|
240
|
+
.map(r => r.id),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
const jsMatch = compileToJs(pred)
|
|
244
|
+
const jsIds = new Set(rows.filter(r => jsMatch(r as unknown as Record<string, unknown>)).map(r => r.id))
|
|
245
|
+
|
|
246
|
+
// Symmetric difference must be empty.
|
|
247
|
+
const onlySql = [...sqlIds].filter(id => !jsIds.has(id))
|
|
248
|
+
const onlyJs = [...jsIds].filter(id => !sqlIds.has(id))
|
|
249
|
+
if (onlySql.length || onlyJs.length) {
|
|
250
|
+
// Surface a debuggable failure rather than just "not equal".
|
|
251
|
+
throw new Error(
|
|
252
|
+
`predicate divergence (seed=${seed}, i=${i}):\n` +
|
|
253
|
+
` predicate: ${JSON.stringify(pred)}\n` +
|
|
254
|
+
` sql: ${sql}\n` +
|
|
255
|
+
` params: ${JSON.stringify(params)}\n` +
|
|
256
|
+
` in SQL only: ${JSON.stringify(onlySql)}\n` +
|
|
257
|
+
` in JS only: ${JSON.stringify(onlyJs)}\n` +
|
|
258
|
+
` rows: ${JSON.stringify(rows)}\n`,
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
db.close()
|
|
264
|
+
expect(true).toBe(true) // satisfies bun:test's "at least one assertion"
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
})
|