@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,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
+ })