@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,878 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { defineSchema, collection, fields, schemaToSQL, type Schema } from "@omg-dev/schema"
|
|
3
|
+
import { openDb, setDbInstance, markScoped, type VibesDb } from "../db.ts"
|
|
4
|
+
import {
|
|
5
|
+
addSubClient,
|
|
6
|
+
removeSubClient,
|
|
7
|
+
handleSubMessage,
|
|
8
|
+
notifyCollectionChange,
|
|
9
|
+
notifyRowChange,
|
|
10
|
+
subClientCount,
|
|
11
|
+
subscriptionCount,
|
|
12
|
+
setSubscriptionSchema,
|
|
13
|
+
setSubscriptionRingCap,
|
|
14
|
+
setMaxSubsPerClient,
|
|
15
|
+
_resetSubscriptionState,
|
|
16
|
+
_currentSeq,
|
|
17
|
+
type SubClient,
|
|
18
|
+
type SubServerMessage,
|
|
19
|
+
} from "../subscriptions.ts"
|
|
20
|
+
|
|
21
|
+
// ── Fixture ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function makeSchema(): Schema {
|
|
24
|
+
return defineSchema({
|
|
25
|
+
collections: {
|
|
26
|
+
// global/public — exercises the unscoped (no _owner) subscription path
|
|
27
|
+
tasks: collection({
|
|
28
|
+
fields: {
|
|
29
|
+
title: fields.string(),
|
|
30
|
+
done: fields.boolean(),
|
|
31
|
+
},
|
|
32
|
+
}).scoped("global"),
|
|
33
|
+
// user-scoped — exercises _owner filtering
|
|
34
|
+
notes: collection({
|
|
35
|
+
fields: {
|
|
36
|
+
body: fields.string(),
|
|
37
|
+
},
|
|
38
|
+
}).scoped("user"),
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let db: VibesDb
|
|
44
|
+
let schema: Schema
|
|
45
|
+
|
|
46
|
+
function makeClient(userId: string | null = null): SubClient & { sent: SubServerMessage[]; sentRaw: string[] } {
|
|
47
|
+
const sent: SubServerMessage[] = []
|
|
48
|
+
const sentRaw: string[] = []
|
|
49
|
+
return {
|
|
50
|
+
readyState: 1,
|
|
51
|
+
ctx: { userId },
|
|
52
|
+
sent,
|
|
53
|
+
sentRaw,
|
|
54
|
+
send(data: string) {
|
|
55
|
+
sentRaw.push(data)
|
|
56
|
+
sent.push(JSON.parse(data) as SubServerMessage)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
_resetSubscriptionState()
|
|
63
|
+
schema = makeSchema()
|
|
64
|
+
db = openDb(":memory:")
|
|
65
|
+
setDbInstance(db)
|
|
66
|
+
// markScoped is normally invoked by registerScopes during migrate; do it
|
|
67
|
+
// here directly since this test bypasses the migrator.
|
|
68
|
+
markScoped("notes")
|
|
69
|
+
for (const sql of schemaToSQL(schema)) db.raw().exec(sql)
|
|
70
|
+
setSubscriptionSchema(schema)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
_resetSubscriptionState()
|
|
75
|
+
setSubscriptionSchema(null)
|
|
76
|
+
db.close()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// ── Registry lifecycle ───────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe("subscriptions — registry lifecycle", () => {
|
|
82
|
+
it("addSubClient / removeSubClient track client count", () => {
|
|
83
|
+
const c1 = makeClient(), c2 = makeClient()
|
|
84
|
+
expect(subClientCount()).toBe(0)
|
|
85
|
+
addSubClient(c1)
|
|
86
|
+
addSubClient(c2)
|
|
87
|
+
expect(subClientCount()).toBe(2)
|
|
88
|
+
removeSubClient(c1)
|
|
89
|
+
expect(subClientCount()).toBe(1)
|
|
90
|
+
removeSubClient(c2)
|
|
91
|
+
expect(subClientCount()).toBe(0)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("addSubClient is idempotent for the same client instance", () => {
|
|
95
|
+
const c = makeClient()
|
|
96
|
+
addSubClient(c)
|
|
97
|
+
addSubClient(c)
|
|
98
|
+
expect(subClientCount()).toBe(1)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("removeSubClient on unknown client is a no-op", () => {
|
|
102
|
+
removeSubClient(makeClient())
|
|
103
|
+
expect(subClientCount()).toBe(0)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// ── Sub / unsub / snapshot ───────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
describe("subscriptions — sub / unsub", () => {
|
|
110
|
+
it("sub emits a snapshot frame with all rows on a global collection", async () => {
|
|
111
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
112
|
+
.run("t1", "first", 0, "2026-01-01", "2026-01-01")
|
|
113
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
114
|
+
.run("t2", "second", 1, "2026-01-02", "2026-01-02")
|
|
115
|
+
|
|
116
|
+
const c = makeClient()
|
|
117
|
+
addSubClient(c)
|
|
118
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
119
|
+
|
|
120
|
+
expect(c.sent).toHaveLength(1)
|
|
121
|
+
const msg = c.sent[0]
|
|
122
|
+
expect(msg.type).toBe("snapshot")
|
|
123
|
+
if (msg.type !== "snapshot") throw new Error("expected snapshot")
|
|
124
|
+
expect(msg.subId).toBe("a")
|
|
125
|
+
expect(msg.collection).toBe("tasks")
|
|
126
|
+
expect(msg.rows).toHaveLength(2)
|
|
127
|
+
expect(subscriptionCount("tasks")).toBe(1)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("sub on a scoped collection filters by client.ctx.userId", async () => {
|
|
131
|
+
db.raw().prepare("INSERT INTO notes (id, body, _owner, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
132
|
+
.run("n1", "alice's note", "alice", "2026-01-01", "2026-01-01")
|
|
133
|
+
db.raw().prepare("INSERT INTO notes (id, body, _owner, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
134
|
+
.run("n2", "bob's note", "bob", "2026-01-02", "2026-01-02")
|
|
135
|
+
|
|
136
|
+
const alice = makeClient("alice")
|
|
137
|
+
addSubClient(alice)
|
|
138
|
+
await handleSubMessage(alice, JSON.stringify({ op: "sub", subId: "x", collection: "notes" }))
|
|
139
|
+
|
|
140
|
+
expect(alice.sent).toHaveLength(1)
|
|
141
|
+
const msg = alice.sent[0]
|
|
142
|
+
if (msg.type !== "snapshot") throw new Error("expected snapshot")
|
|
143
|
+
expect(msg.rows).toHaveLength(1)
|
|
144
|
+
expect(msg.rows[0].id).toBe("n1")
|
|
145
|
+
expect(msg.rows[0].body).toBe("alice's note")
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("sub on a scoped collection with no auth emits an error frame, not the rows", async () => {
|
|
149
|
+
db.raw().prepare("INSERT INTO notes (id, body, _owner, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
150
|
+
.run("n1", "owned", "alice", "2026-01-01", "2026-01-01")
|
|
151
|
+
|
|
152
|
+
const anon = makeClient(null)
|
|
153
|
+
addSubClient(anon)
|
|
154
|
+
await handleSubMessage(anon, JSON.stringify({ op: "sub", subId: "x", collection: "notes" }))
|
|
155
|
+
|
|
156
|
+
expect(anon.sent).toHaveLength(1)
|
|
157
|
+
const msg = anon.sent[0]
|
|
158
|
+
expect(msg.type).toBe("error")
|
|
159
|
+
if (msg.type !== "error") throw new Error("expected error")
|
|
160
|
+
expect(msg.code).toBe("auth_required")
|
|
161
|
+
expect(msg.message).toContain("notes")
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("unsub removes the subscription so subsequent notifies are not delivered", async () => {
|
|
165
|
+
const c = makeClient()
|
|
166
|
+
addSubClient(c)
|
|
167
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
168
|
+
c.sent.length = 0
|
|
169
|
+
await handleSubMessage(c, JSON.stringify({ op: "unsub", subId: "a" }))
|
|
170
|
+
|
|
171
|
+
expect(subscriptionCount("tasks")).toBe(0)
|
|
172
|
+
await notifyCollectionChange("tasks")
|
|
173
|
+
expect(c.sent).toHaveLength(0)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("duplicate subId on the same client yields an error frame", async () => {
|
|
177
|
+
const c = makeClient()
|
|
178
|
+
addSubClient(c)
|
|
179
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
180
|
+
c.sent.length = 0
|
|
181
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
182
|
+
|
|
183
|
+
expect(c.sent).toHaveLength(1)
|
|
184
|
+
const msg = c.sent[0]
|
|
185
|
+
if (msg.type !== "error") throw new Error("expected error")
|
|
186
|
+
expect(msg.code).toBe("duplicate_subId")
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("bad json / missing op / unknown op all emit an error frame", async () => {
|
|
190
|
+
const c = makeClient()
|
|
191
|
+
addSubClient(c)
|
|
192
|
+
|
|
193
|
+
await handleSubMessage(c, "{not json")
|
|
194
|
+
await handleSubMessage(c, JSON.stringify({ subId: "a", collection: "tasks" }))
|
|
195
|
+
await handleSubMessage(c, JSON.stringify({ op: "garbage", subId: "a" }))
|
|
196
|
+
|
|
197
|
+
expect(c.sent).toHaveLength(3)
|
|
198
|
+
expect((c.sent[0] as { type: string; code: string }).code).toBe("bad_json")
|
|
199
|
+
expect((c.sent[1] as { type: string; code: string }).code).toBe("bad_shape")
|
|
200
|
+
expect((c.sent[2] as { type: string; code: string }).code).toBe("unknown_op")
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("sub before addSubClient yields client_not_registered", async () => {
|
|
204
|
+
const c = makeClient()
|
|
205
|
+
// intentionally NOT calling addSubClient
|
|
206
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
207
|
+
expect(c.sent).toHaveLength(1)
|
|
208
|
+
if (c.sent[0].type !== "error") throw new Error("expected error")
|
|
209
|
+
expect(c.sent[0].code).toBe("client_not_registered")
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// ── Fan-out on write ─────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
describe("subscriptions — notifyCollectionChange fan-out", () => {
|
|
216
|
+
it("re-sends the snapshot to every subscriber of the affected collection", async () => {
|
|
217
|
+
const c1 = makeClient(), c2 = makeClient(), unrelated = makeClient()
|
|
218
|
+
addSubClient(c1); addSubClient(c2); addSubClient(unrelated)
|
|
219
|
+
await handleSubMessage(c1, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
220
|
+
await handleSubMessage(c2, JSON.stringify({ op: "sub", subId: "b", collection: "tasks" }))
|
|
221
|
+
await handleSubMessage(unrelated, JSON.stringify({ op: "sub", subId: "c", collection: "notes" }))
|
|
222
|
+
// Tank the initial snapshots so we can isolate the fan-out.
|
|
223
|
+
c1.sent.length = 0; c2.sent.length = 0; unrelated.sent.length = 0
|
|
224
|
+
|
|
225
|
+
// Note: scoped notes sub with userId=null errored on subscribe, so it's
|
|
226
|
+
// not actually in the registry. That's fine — the only thing we care
|
|
227
|
+
// about here is that a tasks-only notify doesn't reach an unrelated sub.
|
|
228
|
+
// Re-test that path explicitly with a valid scoped sub below.
|
|
229
|
+
|
|
230
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
231
|
+
.run("t1", "x", 0, "2026-01-01", "2026-01-01")
|
|
232
|
+
await notifyCollectionChange("tasks")
|
|
233
|
+
|
|
234
|
+
expect(c1.sent).toHaveLength(1)
|
|
235
|
+
expect(c2.sent).toHaveLength(1)
|
|
236
|
+
expect((c1.sent[0] as { type: string }).type).toBe("snapshot")
|
|
237
|
+
expect((c2.sent[0] as { type: string }).type).toBe("snapshot")
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("does not deliver to clients subscribed only to other collections", async () => {
|
|
241
|
+
const tasksSub = makeClient()
|
|
242
|
+
const notesSub = makeClient("alice")
|
|
243
|
+
addSubClient(tasksSub); addSubClient(notesSub)
|
|
244
|
+
await handleSubMessage(tasksSub, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
245
|
+
await handleSubMessage(notesSub, JSON.stringify({ op: "sub", subId: "b", collection: "notes" }))
|
|
246
|
+
tasksSub.sent.length = 0; notesSub.sent.length = 0
|
|
247
|
+
|
|
248
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
249
|
+
.run("t1", "x", 0, "2026-01-01", "2026-01-01")
|
|
250
|
+
await notifyCollectionChange("tasks")
|
|
251
|
+
|
|
252
|
+
expect(tasksSub.sent).toHaveLength(1)
|
|
253
|
+
expect(notesSub.sent).toHaveLength(0)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("evicts a client whose readyState is 3 (closed) at fan-out time", async () => {
|
|
257
|
+
const open = makeClient(), closed = makeClient()
|
|
258
|
+
addSubClient(open); addSubClient(closed)
|
|
259
|
+
await handleSubMessage(open, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
260
|
+
await handleSubMessage(closed, JSON.stringify({ op: "sub", subId: "b", collection: "tasks" }))
|
|
261
|
+
|
|
262
|
+
closed.readyState = 3
|
|
263
|
+
await notifyCollectionChange("tasks")
|
|
264
|
+
|
|
265
|
+
// open still registered, closed evicted.
|
|
266
|
+
expect(subClientCount()).toBe(1)
|
|
267
|
+
expect(subscriptionCount("tasks")).toBe(1)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it("decodes booleans to true/false in the snapshot rows (REST-shape)", async () => {
|
|
271
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
272
|
+
.run("t1", "open", 0, "2026-01-01", "2026-01-01")
|
|
273
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
274
|
+
.run("t2", "done", 1, "2026-01-02", "2026-01-02")
|
|
275
|
+
|
|
276
|
+
const c = makeClient()
|
|
277
|
+
addSubClient(c)
|
|
278
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
279
|
+
|
|
280
|
+
const msg = c.sent[0]
|
|
281
|
+
if (msg.type !== "snapshot") throw new Error("expected snapshot")
|
|
282
|
+
const byId = new Map(msg.rows.map(r => [r.id, r]))
|
|
283
|
+
expect(byId.get("t1")?.done).toBe(false)
|
|
284
|
+
expect(byId.get("t2")?.done).toBe(true)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("survives a send() that throws — evicts the client without crashing the fan-out", async () => {
|
|
288
|
+
const ok = makeClient()
|
|
289
|
+
const bad: SubClient & { sent: SubServerMessage[] } = {
|
|
290
|
+
readyState: 1,
|
|
291
|
+
ctx: { userId: null },
|
|
292
|
+
sent: [],
|
|
293
|
+
send() { throw new Error("socket exploded") },
|
|
294
|
+
} as SubClient & { sent: SubServerMessage[] }
|
|
295
|
+
|
|
296
|
+
addSubClient(ok); addSubClient(bad)
|
|
297
|
+
await handleSubMessage(ok, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
298
|
+
await handleSubMessage(bad, JSON.stringify({ op: "sub", subId: "b", collection: "tasks" }))
|
|
299
|
+
ok.sent.length = 0
|
|
300
|
+
|
|
301
|
+
// The fan-out will try `bad` first or second; either way the iteration
|
|
302
|
+
// must keep going so `ok` still receives its snapshot.
|
|
303
|
+
await notifyCollectionChange("tasks")
|
|
304
|
+
expect(ok.sent).toHaveLength(1)
|
|
305
|
+
// bad has been evicted because send() threw.
|
|
306
|
+
expect(subClientCount()).toBe(1)
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// ── Predicate filter (Phase 2) ───────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
describe("subscriptions — predicate filter", () => {
|
|
313
|
+
it("filters initial snapshot by a top-level eq predicate", async () => {
|
|
314
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
315
|
+
.run("t1", "open one", 0, "2026-01-01", "2026-01-01")
|
|
316
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
317
|
+
.run("t2", "closed", 1, "2026-01-02", "2026-01-02")
|
|
318
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
319
|
+
.run("t3", "open two", 0, "2026-01-03", "2026-01-03")
|
|
320
|
+
|
|
321
|
+
const c = makeClient()
|
|
322
|
+
addSubClient(c)
|
|
323
|
+
await handleSubMessage(c, JSON.stringify({
|
|
324
|
+
op: "sub", subId: "a", collection: "tasks",
|
|
325
|
+
where: { op: "eq", column: "done", value: false },
|
|
326
|
+
}))
|
|
327
|
+
|
|
328
|
+
const msg = c.sent[0]
|
|
329
|
+
if (msg.type !== "snapshot") throw new Error("expected snapshot, got " + JSON.stringify(msg))
|
|
330
|
+
const ids = msg.rows.map(r => r.id).sort()
|
|
331
|
+
expect(ids).toEqual(["t1", "t3"])
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it("combines predicate with scope filter on user-scoped collections", async () => {
|
|
335
|
+
db.raw().prepare("INSERT INTO notes (id, body, _owner, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
336
|
+
.run("n1", "alice high", "alice", "2026-01-01", "2026-01-01")
|
|
337
|
+
db.raw().prepare("INSERT INTO notes (id, body, _owner, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
338
|
+
.run("n2", "alice low", "alice", "2026-01-02", "2026-01-02")
|
|
339
|
+
db.raw().prepare("INSERT INTO notes (id, body, _owner, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
340
|
+
.run("n3", "bob high", "bob", "2026-01-03", "2026-01-03")
|
|
341
|
+
|
|
342
|
+
const alice = makeClient("alice")
|
|
343
|
+
addSubClient(alice)
|
|
344
|
+
await handleSubMessage(alice, JSON.stringify({
|
|
345
|
+
op: "sub", subId: "a", collection: "notes",
|
|
346
|
+
where: { op: "like", column: "body", pattern: "%high%" },
|
|
347
|
+
}))
|
|
348
|
+
|
|
349
|
+
const msg = alice.sent[0]
|
|
350
|
+
if (msg.type !== "snapshot") throw new Error("expected snapshot")
|
|
351
|
+
const ids = msg.rows.map(r => r.id)
|
|
352
|
+
// Only alice's "high" note — bob's row is excluded by _owner, alice's
|
|
353
|
+
// "low" row is excluded by the predicate.
|
|
354
|
+
expect(ids).toEqual(["n1"])
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it("rejects a malformed predicate with bad_predicate, leaves no subscription", async () => {
|
|
358
|
+
const c = makeClient()
|
|
359
|
+
addSubClient(c)
|
|
360
|
+
await handleSubMessage(c, JSON.stringify({
|
|
361
|
+
op: "sub", subId: "a", collection: "tasks",
|
|
362
|
+
where: { op: "regex", column: "title", pattern: ".*" },
|
|
363
|
+
}))
|
|
364
|
+
|
|
365
|
+
const msg = c.sent[0]
|
|
366
|
+
if (msg.type !== "error") throw new Error("expected error")
|
|
367
|
+
expect(msg.code).toBe("bad_predicate")
|
|
368
|
+
expect(subscriptionCount("tasks")).toBe(0)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it("rejects a predicate referencing an unknown column", async () => {
|
|
372
|
+
const c = makeClient()
|
|
373
|
+
addSubClient(c)
|
|
374
|
+
await handleSubMessage(c, JSON.stringify({
|
|
375
|
+
op: "sub", subId: "a", collection: "tasks",
|
|
376
|
+
where: { op: "eq", column: "nonexistent", value: 1 },
|
|
377
|
+
}))
|
|
378
|
+
|
|
379
|
+
const msg = c.sent[0]
|
|
380
|
+
if (msg.type !== "error") throw new Error("expected error")
|
|
381
|
+
expect(msg.code).toBe("bad_predicate")
|
|
382
|
+
expect(msg.message).toContain("unknown column")
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it("rejects an unknown collection (whitelist via schema)", async () => {
|
|
386
|
+
const c = makeClient()
|
|
387
|
+
addSubClient(c)
|
|
388
|
+
await handleSubMessage(c, JSON.stringify({
|
|
389
|
+
op: "sub", subId: "a", collection: "DROP TABLE tasks",
|
|
390
|
+
}))
|
|
391
|
+
|
|
392
|
+
const msg = c.sent[0]
|
|
393
|
+
if (msg.type !== "error") throw new Error("expected error")
|
|
394
|
+
expect(msg.code).toBe("unknown_collection")
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it("notifyCollectionChange (fallback path) still re-applies the predicate on the re-snapshot", async () => {
|
|
398
|
+
const c = makeClient()
|
|
399
|
+
addSubClient(c)
|
|
400
|
+
await handleSubMessage(c, JSON.stringify({
|
|
401
|
+
op: "sub", subId: "a", collection: "tasks",
|
|
402
|
+
where: { op: "eq", column: "done", value: false },
|
|
403
|
+
}))
|
|
404
|
+
c.sent.length = 0
|
|
405
|
+
|
|
406
|
+
// Insert a row that does NOT match the predicate.
|
|
407
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
408
|
+
.run("t1", "finished", 1, "2026-01-01", "2026-01-01")
|
|
409
|
+
await notifyCollectionChange("tasks")
|
|
410
|
+
|
|
411
|
+
// Subscriber DOES get a re-snapshot (Phase 3 will narrow this), but its
|
|
412
|
+
// contents respect the predicate: row t1 (done=true) is filtered out.
|
|
413
|
+
expect(c.sent).toHaveLength(1)
|
|
414
|
+
if (c.sent[0].type !== "snapshot") throw new Error("expected snapshot")
|
|
415
|
+
expect(c.sent[0].rows).toEqual([])
|
|
416
|
+
|
|
417
|
+
// Now insert a matching row.
|
|
418
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
419
|
+
.run("t2", "open", 0, "2026-01-02", "2026-01-02")
|
|
420
|
+
await notifyCollectionChange("tasks")
|
|
421
|
+
|
|
422
|
+
if (c.sent[1].type !== "snapshot") throw new Error("expected snapshot")
|
|
423
|
+
expect(c.sent[1].rows.map(r => r.id)).toEqual(["t2"])
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
// ── Delta computation (Phase 3) ──────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
describe("subscriptions — notifyRowChange delta matrix", () => {
|
|
430
|
+
// Helpers — build encoded (storage-shape) rows since notifyRowChange
|
|
431
|
+
// takes raw rows the way auto-crud does.
|
|
432
|
+
const t = (id: string, title: string, done: 0 | 1): Record<string, unknown> => ({
|
|
433
|
+
id, title, done, created_at: "2026-01-01", updated_at: "2026-01-01",
|
|
434
|
+
})
|
|
435
|
+
const n = (id: string, body: string, owner: string): Record<string, unknown> => ({
|
|
436
|
+
id, body, _owner: owner, created_at: "2026-01-01", updated_at: "2026-01-01",
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it("insert: emits an insert delta to subs whose predicate matches the new row", async () => {
|
|
440
|
+
const yes = makeClient(), no = makeClient()
|
|
441
|
+
addSubClient(yes); addSubClient(no)
|
|
442
|
+
await handleSubMessage(yes, JSON.stringify({
|
|
443
|
+
op: "sub", subId: "a", collection: "tasks",
|
|
444
|
+
where: { op: "eq", column: "done", value: false },
|
|
445
|
+
}))
|
|
446
|
+
await handleSubMessage(no, JSON.stringify({
|
|
447
|
+
op: "sub", subId: "b", collection: "tasks",
|
|
448
|
+
where: { op: "eq", column: "done", value: true },
|
|
449
|
+
}))
|
|
450
|
+
yes.sent.length = 0; no.sent.length = 0
|
|
451
|
+
|
|
452
|
+
await notifyRowChange("tasks", "insert", null, t("t1", "x", 0))
|
|
453
|
+
|
|
454
|
+
expect(yes.sent).toHaveLength(1)
|
|
455
|
+
expect(no.sent).toHaveLength(0)
|
|
456
|
+
const m = yes.sent[0]
|
|
457
|
+
if (m.type !== "delta") throw new Error("expected delta")
|
|
458
|
+
expect(m.op).toBe("insert")
|
|
459
|
+
if (m.op !== "insert") throw new Error("unreachable")
|
|
460
|
+
expect(m.row.id).toBe("t1")
|
|
461
|
+
expect(m.row.done).toBe(false) // decoded
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it("subs with no predicate get every insert", async () => {
|
|
465
|
+
const c = makeClient()
|
|
466
|
+
addSubClient(c)
|
|
467
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
468
|
+
c.sent.length = 0
|
|
469
|
+
|
|
470
|
+
await notifyRowChange("tasks", "insert", null, t("t1", "x", 0))
|
|
471
|
+
await notifyRowChange("tasks", "insert", null, t("t2", "y", 1))
|
|
472
|
+
|
|
473
|
+
expect(c.sent).toHaveLength(2)
|
|
474
|
+
const ids = c.sent.map(m => {
|
|
475
|
+
if (m.type !== "delta" || m.op !== "insert") throw new Error("bad")
|
|
476
|
+
return m.row.id
|
|
477
|
+
})
|
|
478
|
+
expect(ids).toEqual(["t1", "t2"])
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it("update: matrix — out→in emits insert, in→in emits update, in→out emits delete, out→out is silent", async () => {
|
|
482
|
+
const c = makeClient()
|
|
483
|
+
addSubClient(c)
|
|
484
|
+
await handleSubMessage(c, JSON.stringify({
|
|
485
|
+
op: "sub", subId: "a", collection: "tasks",
|
|
486
|
+
where: { op: "eq", column: "done", value: false },
|
|
487
|
+
}))
|
|
488
|
+
c.sent.length = 0
|
|
489
|
+
|
|
490
|
+
// out (done=1) → in (done=0): insert
|
|
491
|
+
await notifyRowChange("tasks", "update", t("r1", "x", 1), t("r1", "x", 0))
|
|
492
|
+
// in (done=0) → in (done=0): update (other column changed)
|
|
493
|
+
await notifyRowChange("tasks", "update", t("r2", "old", 0), t("r2", "new", 0))
|
|
494
|
+
// in (done=0) → out (done=1): delete
|
|
495
|
+
await notifyRowChange("tasks", "update", t("r3", "x", 0), t("r3", "x", 1))
|
|
496
|
+
// out → out: silent
|
|
497
|
+
await notifyRowChange("tasks", "update", t("r4", "a", 1), t("r4", "b", 1))
|
|
498
|
+
|
|
499
|
+
expect(c.sent).toHaveLength(3)
|
|
500
|
+
const summarize = (m: SubServerMessage) => {
|
|
501
|
+
if (m.type !== "delta") throw new Error("expected delta")
|
|
502
|
+
if (m.op === "delete") return `delete ${m.id}`
|
|
503
|
+
return `${m.op} ${m.row.id}`
|
|
504
|
+
}
|
|
505
|
+
expect(c.sent.map(summarize)).toEqual([
|
|
506
|
+
"insert r1",
|
|
507
|
+
"update r2",
|
|
508
|
+
"delete r3",
|
|
509
|
+
])
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it("delete: emits delete delta with id only when the row was in the read set", async () => {
|
|
513
|
+
const c = makeClient()
|
|
514
|
+
addSubClient(c)
|
|
515
|
+
await handleSubMessage(c, JSON.stringify({
|
|
516
|
+
op: "sub", subId: "a", collection: "tasks",
|
|
517
|
+
where: { op: "eq", column: "done", value: false },
|
|
518
|
+
}))
|
|
519
|
+
c.sent.length = 0
|
|
520
|
+
|
|
521
|
+
// In-set row → delete delta.
|
|
522
|
+
await notifyRowChange("tasks", "delete", t("r1", "x", 0), null)
|
|
523
|
+
// Out-of-set row → no delta.
|
|
524
|
+
await notifyRowChange("tasks", "delete", t("r2", "x", 1), null)
|
|
525
|
+
|
|
526
|
+
expect(c.sent).toHaveLength(1)
|
|
527
|
+
const m = c.sent[0]
|
|
528
|
+
if (m.type !== "delta" || m.op !== "delete") throw new Error("expected delete delta")
|
|
529
|
+
expect(m.id).toBe("r1")
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it("scope: deltas only reach subs whose ctx.userId matches the row's _owner", async () => {
|
|
533
|
+
const alice = makeClient("alice")
|
|
534
|
+
const bob = makeClient("bob")
|
|
535
|
+
addSubClient(alice); addSubClient(bob)
|
|
536
|
+
await handleSubMessage(alice, JSON.stringify({ op: "sub", subId: "a", collection: "notes" }))
|
|
537
|
+
await handleSubMessage(bob, JSON.stringify({ op: "sub", subId: "b", collection: "notes" }))
|
|
538
|
+
alice.sent.length = 0; bob.sent.length = 0
|
|
539
|
+
|
|
540
|
+
await notifyRowChange("notes", "insert", null, n("n1", "alice only", "alice"))
|
|
541
|
+
|
|
542
|
+
expect(alice.sent).toHaveLength(1)
|
|
543
|
+
expect(bob.sent).toHaveLength(0)
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it("scope: anonymous client on a scoped collection never receives any delta", async () => {
|
|
547
|
+
const anon = makeClient(null)
|
|
548
|
+
addSubClient(anon)
|
|
549
|
+
// Anon sub on scoped collection errors during subscribe (auth_required),
|
|
550
|
+
// so the sub is NOT registered. notifyRowChange has nothing to send.
|
|
551
|
+
await handleSubMessage(anon, JSON.stringify({ op: "sub", subId: "a", collection: "notes" }))
|
|
552
|
+
expect(subscriptionCount("notes")).toBe(0)
|
|
553
|
+
anon.sent.length = 0
|
|
554
|
+
|
|
555
|
+
await notifyRowChange("notes", "insert", null, n("n1", "x", "alice"))
|
|
556
|
+
expect(anon.sent).toHaveLength(0)
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
it("scope: a delete from a different owner does NOT leak the row id to other subs", async () => {
|
|
560
|
+
const alice = makeClient("alice")
|
|
561
|
+
const bob = makeClient("bob")
|
|
562
|
+
addSubClient(alice); addSubClient(bob)
|
|
563
|
+
await handleSubMessage(alice, JSON.stringify({ op: "sub", subId: "a", collection: "notes" }))
|
|
564
|
+
await handleSubMessage(bob, JSON.stringify({ op: "sub", subId: "b", collection: "notes" }))
|
|
565
|
+
alice.sent.length = 0; bob.sent.length = 0
|
|
566
|
+
|
|
567
|
+
// Bob deletes one of his rows. Alice MUST NOT receive that delta —
|
|
568
|
+
// it would let her enumerate bob's row ids.
|
|
569
|
+
await notifyRowChange("notes", "delete", n("n1", "bob private", "bob"), null)
|
|
570
|
+
|
|
571
|
+
expect(bob.sent).toHaveLength(1)
|
|
572
|
+
expect(alice.sent).toHaveLength(0)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it("delta payload uses decoded shape (booleans as true/false)", async () => {
|
|
576
|
+
const c = makeClient()
|
|
577
|
+
addSubClient(c)
|
|
578
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
579
|
+
c.sent.length = 0
|
|
580
|
+
|
|
581
|
+
await notifyRowChange("tasks", "insert", null, t("t1", "x", 1))
|
|
582
|
+
const m = c.sent[0]
|
|
583
|
+
if (m.type !== "delta" || m.op !== "insert") throw new Error("expected insert")
|
|
584
|
+
expect(m.row.done).toBe(true)
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it("zero subs on a collection is a fast no-op", async () => {
|
|
588
|
+
// notifyRowChange should return Promise.resolve() without touching the
|
|
589
|
+
// DB when nobody's listening. We can't assert "no DB touched" directly
|
|
590
|
+
// but we can assert the promise resolves and no sends happen.
|
|
591
|
+
await notifyRowChange("tasks", "insert", null, t("t1", "x", 0))
|
|
592
|
+
// No assertions beyond "did not throw" — just exercising the early-out.
|
|
593
|
+
expect(subscriptionCount("tasks")).toBe(0)
|
|
594
|
+
})
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
// ── Sequence numbers + resume (Phase 4) ──────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
describe("subscriptions — seq tagging + resume", () => {
|
|
600
|
+
const t = (id: string, title: string, done: 0 | 1): Record<string, unknown> => ({
|
|
601
|
+
id, title, done, created_at: "2026-01-01", updated_at: "2026-01-01",
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it("snapshot frames carry a seq equal to the current global counter", async () => {
|
|
605
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
606
|
+
.run("t1", "x", 0, "2026-01-01", "2026-01-01")
|
|
607
|
+
const c = makeClient()
|
|
608
|
+
addSubClient(c)
|
|
609
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
610
|
+
const m = c.sent[0]
|
|
611
|
+
if (m.type !== "snapshot") throw new Error("expected snapshot")
|
|
612
|
+
expect(m.seq).toBe(0) // no writes through notifyRowChange yet
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it("delta frames carry strictly increasing seq across writes", async () => {
|
|
616
|
+
const c = makeClient()
|
|
617
|
+
addSubClient(c)
|
|
618
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
619
|
+
c.sent.length = 0
|
|
620
|
+
|
|
621
|
+
await notifyRowChange("tasks", "insert", null, t("r1", "a", 0))
|
|
622
|
+
await notifyRowChange("tasks", "insert", null, t("r2", "b", 0))
|
|
623
|
+
await notifyRowChange("tasks", "insert", null, t("r3", "c", 0))
|
|
624
|
+
|
|
625
|
+
expect(c.sent).toHaveLength(3)
|
|
626
|
+
const seqs = c.sent.map(m => {
|
|
627
|
+
if (m.type !== "delta") throw new Error("expected delta")
|
|
628
|
+
return m.seq
|
|
629
|
+
})
|
|
630
|
+
expect(seqs[1]).toBeGreaterThan(seqs[0])
|
|
631
|
+
expect(seqs[2]).toBeGreaterThan(seqs[1])
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it("ring records writes even with zero subscribers, allowing later resume", async () => {
|
|
635
|
+
// No subs yet — but the ring should still capture the write.
|
|
636
|
+
await notifyRowChange("tasks", "insert", null, t("r1", "x", 0))
|
|
637
|
+
await notifyRowChange("tasks", "insert", null, t("r2", "y", 0))
|
|
638
|
+
const seqAfter = _currentSeq()
|
|
639
|
+
expect(seqAfter).toBe(2)
|
|
640
|
+
|
|
641
|
+
// Now a client subscribes with resumeFromSeq=0 — should replay both.
|
|
642
|
+
const c = makeClient()
|
|
643
|
+
addSubClient(c)
|
|
644
|
+
await handleSubMessage(c, JSON.stringify({
|
|
645
|
+
op: "sub", subId: "a", collection: "tasks", resumeFromSeq: 0,
|
|
646
|
+
}))
|
|
647
|
+
|
|
648
|
+
// Two delta frames, then a resumed ack.
|
|
649
|
+
const types = c.sent.map(m => m.type)
|
|
650
|
+
expect(types).toEqual(["delta", "delta", "resumed"])
|
|
651
|
+
const ack = c.sent[2]
|
|
652
|
+
if (ack.type !== "resumed") throw new Error("expected resumed")
|
|
653
|
+
expect(ack.fromSeq).toBe(0)
|
|
654
|
+
expect(ack.replayed).toBe(2)
|
|
655
|
+
expect(ack.toSeq).toBe(seqAfter)
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
it("resume from current seq returns 'resumed' with 0 replayed (no-op catch-up)", async () => {
|
|
659
|
+
await notifyRowChange("tasks", "insert", null, t("r1", "x", 0))
|
|
660
|
+
const seqAfter = _currentSeq()
|
|
661
|
+
|
|
662
|
+
const c = makeClient()
|
|
663
|
+
addSubClient(c)
|
|
664
|
+
await handleSubMessage(c, JSON.stringify({
|
|
665
|
+
op: "sub", subId: "a", collection: "tasks", resumeFromSeq: seqAfter,
|
|
666
|
+
}))
|
|
667
|
+
|
|
668
|
+
expect(c.sent).toHaveLength(1)
|
|
669
|
+
const m = c.sent[0]
|
|
670
|
+
if (m.type !== "resumed") throw new Error("expected resumed")
|
|
671
|
+
expect(m.replayed).toBe(0)
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
it("falls back to a fresh snapshot when resume gap exceeds the ring cap", async () => {
|
|
675
|
+
// Cap=2, four writes → ring holds seq=3,4. Resuming from seq=1 means
|
|
676
|
+
// we need events 2,3,4 but seq=2 has been evicted — must fall back.
|
|
677
|
+
setSubscriptionRingCap(2)
|
|
678
|
+
await notifyRowChange("tasks", "insert", null, t("r1", "a", 0))
|
|
679
|
+
await notifyRowChange("tasks", "insert", null, t("r2", "b", 0))
|
|
680
|
+
await notifyRowChange("tasks", "insert", null, t("r3", "c", 0))
|
|
681
|
+
await notifyRowChange("tasks", "insert", null, t("r4", "d", 0))
|
|
682
|
+
|
|
683
|
+
// Persist the rows to the DB so the snapshot fallback has something
|
|
684
|
+
// to return — notifyRowChange doesn't write through.
|
|
685
|
+
for (const id of ["r1", "r2", "r3", "r4"]) {
|
|
686
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
687
|
+
.run(id, id, 0, "2026-01-01", "2026-01-01")
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const c = makeClient()
|
|
691
|
+
addSubClient(c)
|
|
692
|
+
await handleSubMessage(c, JSON.stringify({
|
|
693
|
+
op: "sub", subId: "a", collection: "tasks", resumeFromSeq: 1,
|
|
694
|
+
}))
|
|
695
|
+
|
|
696
|
+
expect(c.sent).toHaveLength(1)
|
|
697
|
+
const m = c.sent[0]
|
|
698
|
+
expect(m.type).toBe("snapshot")
|
|
699
|
+
if (m.type !== "snapshot") throw new Error("expected snapshot")
|
|
700
|
+
expect(m.rows.map(r => r.id).sort()).toEqual(["r1", "r2", "r3", "r4"])
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
it("replay respects the sub's predicate (events outside the predicate are skipped)", async () => {
|
|
704
|
+
// Mix of done=true and done=false writes.
|
|
705
|
+
await notifyRowChange("tasks", "insert", null, t("r1", "x", 0))
|
|
706
|
+
await notifyRowChange("tasks", "insert", null, t("r2", "y", 1))
|
|
707
|
+
await notifyRowChange("tasks", "insert", null, t("r3", "z", 0))
|
|
708
|
+
|
|
709
|
+
const c = makeClient()
|
|
710
|
+
addSubClient(c)
|
|
711
|
+
await handleSubMessage(c, JSON.stringify({
|
|
712
|
+
op: "sub", subId: "a", collection: "tasks",
|
|
713
|
+
where: { op: "eq", column: "done", value: false },
|
|
714
|
+
resumeFromSeq: 0,
|
|
715
|
+
}))
|
|
716
|
+
|
|
717
|
+
// Should replay r1 and r3 (done=false), skip r2.
|
|
718
|
+
const deltas = c.sent.filter(m => m.type === "delta")
|
|
719
|
+
expect(deltas).toHaveLength(2)
|
|
720
|
+
const ids = deltas.map(m => {
|
|
721
|
+
if (m.type !== "delta" || m.op !== "insert") throw new Error("bad")
|
|
722
|
+
return m.row.id
|
|
723
|
+
})
|
|
724
|
+
expect(ids).toEqual(["r1", "r3"])
|
|
725
|
+
const ack = c.sent.at(-1)
|
|
726
|
+
if (ack?.type !== "resumed") throw new Error("expected resumed ack")
|
|
727
|
+
expect(ack.replayed).toBe(2)
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
it("replay respects scope — cross-tenant ring entries are dropped", async () => {
|
|
731
|
+
const aliceRow = (id: string, body: string): Record<string, unknown> => ({
|
|
732
|
+
id, body, _owner: "alice", created_at: "2026-01-01", updated_at: "2026-01-01",
|
|
733
|
+
})
|
|
734
|
+
const bobRow = (id: string, body: string): Record<string, unknown> => ({
|
|
735
|
+
id, body, _owner: "bob", created_at: "2026-01-01", updated_at: "2026-01-01",
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
await notifyRowChange("notes", "insert", null, aliceRow("n1", "alice"))
|
|
739
|
+
await notifyRowChange("notes", "insert", null, bobRow("n2", "bob"))
|
|
740
|
+
|
|
741
|
+
const alice = makeClient("alice")
|
|
742
|
+
addSubClient(alice)
|
|
743
|
+
await handleSubMessage(alice, JSON.stringify({
|
|
744
|
+
op: "sub", subId: "a", collection: "notes", resumeFromSeq: 0,
|
|
745
|
+
}))
|
|
746
|
+
|
|
747
|
+
// Should replay n1 only (alice's row), then resumed.
|
|
748
|
+
const deltas = alice.sent.filter(m => m.type === "delta")
|
|
749
|
+
expect(deltas).toHaveLength(1)
|
|
750
|
+
if (deltas[0].type !== "delta" || deltas[0].op !== "insert") throw new Error("bad")
|
|
751
|
+
expect(deltas[0].row.id).toBe("n1")
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
it("ring respects RING_CAP — only the most recent N entries are kept", async () => {
|
|
755
|
+
setSubscriptionRingCap(3)
|
|
756
|
+
for (let i = 1; i <= 5; i++) {
|
|
757
|
+
await notifyRowChange("tasks", "insert", null, t(`r${i}`, "x", 0))
|
|
758
|
+
}
|
|
759
|
+
// Ring should now hold seqs 3, 4, 5 only.
|
|
760
|
+
|
|
761
|
+
const c = makeClient()
|
|
762
|
+
addSubClient(c)
|
|
763
|
+
// Resume from seq 2 — the entry with seq=3 is still in the ring,
|
|
764
|
+
// so the oldest entry (seq=3) is acceptable: ring is intact since
|
|
765
|
+
// resumeFromSeq=2 == 3-1.
|
|
766
|
+
await handleSubMessage(c, JSON.stringify({
|
|
767
|
+
op: "sub", subId: "a", collection: "tasks", resumeFromSeq: 2,
|
|
768
|
+
}))
|
|
769
|
+
|
|
770
|
+
const deltas = c.sent.filter(m => m.type === "delta")
|
|
771
|
+
const ids = deltas.map(m => {
|
|
772
|
+
if (m.type !== "delta" || m.op !== "insert") throw new Error("bad")
|
|
773
|
+
return m.row.id
|
|
774
|
+
})
|
|
775
|
+
expect(ids).toEqual(["r3", "r4", "r5"])
|
|
776
|
+
|
|
777
|
+
// Resuming from seq 1 — ring's oldest is 3, gap is too big.
|
|
778
|
+
for (const id of ["r1", "r2", "r3", "r4", "r5"]) {
|
|
779
|
+
db.raw().prepare("INSERT INTO tasks (id, title, done, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
780
|
+
.run(id, id, 0, `2026-01-${id.slice(1).padStart(2, "0")}`, "2026-01-01")
|
|
781
|
+
}
|
|
782
|
+
const c2 = makeClient()
|
|
783
|
+
addSubClient(c2)
|
|
784
|
+
await handleSubMessage(c2, JSON.stringify({
|
|
785
|
+
op: "sub", subId: "b", collection: "tasks", resumeFromSeq: 1,
|
|
786
|
+
}))
|
|
787
|
+
expect(c2.sent.at(0)?.type).toBe("snapshot")
|
|
788
|
+
})
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
// ── Hardening (Phase 6) ──────────────────────────────────────────────────────
|
|
792
|
+
|
|
793
|
+
describe("subscriptions — quota + hardening", () => {
|
|
794
|
+
it("rejects subs past MAX_SUBS_PER_CLIENT with sub_limit_exceeded", async () => {
|
|
795
|
+
setMaxSubsPerClient(2)
|
|
796
|
+
const c = makeClient()
|
|
797
|
+
addSubClient(c)
|
|
798
|
+
|
|
799
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
800
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "b", collection: "tasks" }))
|
|
801
|
+
// Third is over cap.
|
|
802
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "c", collection: "tasks" }))
|
|
803
|
+
|
|
804
|
+
const errs = c.sent.filter(m => m.type === "error")
|
|
805
|
+
expect(errs).toHaveLength(1)
|
|
806
|
+
if (errs[0].type !== "error") throw new Error("expected error")
|
|
807
|
+
expect(errs[0].code).toBe("sub_limit_exceeded")
|
|
808
|
+
// Two snapshots, one error — no third snapshot.
|
|
809
|
+
expect(c.sent.filter(m => m.type === "snapshot")).toHaveLength(2)
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it("freeing a sub via unsub re-opens a quota slot", async () => {
|
|
813
|
+
setMaxSubsPerClient(2)
|
|
814
|
+
const c = makeClient()
|
|
815
|
+
addSubClient(c)
|
|
816
|
+
|
|
817
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "a", collection: "tasks" }))
|
|
818
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "b", collection: "tasks" }))
|
|
819
|
+
await handleSubMessage(c, JSON.stringify({ op: "unsub", subId: "a" }))
|
|
820
|
+
// Slot freed; this should succeed.
|
|
821
|
+
await handleSubMessage(c, JSON.stringify({ op: "sub", subId: "c", collection: "tasks" }))
|
|
822
|
+
|
|
823
|
+
const errs = c.sent.filter(m => m.type === "error")
|
|
824
|
+
expect(errs).toHaveLength(0)
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
it("security regression: cross-tenant deltas never leak — full insert/update/delete cycle", async () => {
|
|
828
|
+
// Mirrors the security model in test/security.test.ts: every write on a
|
|
829
|
+
// user-scoped collection MUST only reach subs whose ctx.userId matches
|
|
830
|
+
// the row's _owner. This test fans out one of each kind and asserts
|
|
831
|
+
// the wrong tenant got zero deltas.
|
|
832
|
+
const alice = makeClient("alice"), bob = makeClient("bob"), anon = makeClient(null)
|
|
833
|
+
addSubClient(alice); addSubClient(bob); addSubClient(anon)
|
|
834
|
+
await handleSubMessage(alice, JSON.stringify({ op: "sub", subId: "a", collection: "notes" }))
|
|
835
|
+
await handleSubMessage(bob, JSON.stringify({ op: "sub", subId: "b", collection: "notes" }))
|
|
836
|
+
// Anon is rejected at sub time on a scoped collection — expected.
|
|
837
|
+
await handleSubMessage(anon, JSON.stringify({ op: "sub", subId: "x", collection: "notes" }))
|
|
838
|
+
|
|
839
|
+
alice.sent.length = 0; bob.sent.length = 0; anon.sent.length = 0
|
|
840
|
+
|
|
841
|
+
const aliceRow = (id: string, body: string): Record<string, unknown> => ({
|
|
842
|
+
id, body, _owner: "alice", created_at: "2026-01-01", updated_at: "2026-01-01",
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
// Insert alice's row.
|
|
846
|
+
await notifyRowChange("notes", "insert", null, aliceRow("n1", "hello"))
|
|
847
|
+
// Update alice's row.
|
|
848
|
+
await notifyRowChange("notes", "update", aliceRow("n1", "hello"), aliceRow("n1", "hello edited"))
|
|
849
|
+
// Delete alice's row.
|
|
850
|
+
await notifyRowChange("notes", "delete", aliceRow("n1", "hello edited"), null)
|
|
851
|
+
|
|
852
|
+
expect(alice.sent).toHaveLength(3)
|
|
853
|
+
expect(bob.sent).toHaveLength(0)
|
|
854
|
+
expect(anon.sent).toHaveLength(0)
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
it("security regression: predicate gating doesn't bypass scope gating", async () => {
|
|
858
|
+
// Sub with a predicate that's always-true. The predicate must NOT
|
|
859
|
+
// promote a cross-tenant row into the read set.
|
|
860
|
+
const alice = makeClient("alice"), bob = makeClient("bob")
|
|
861
|
+
addSubClient(alice); addSubClient(bob)
|
|
862
|
+
await handleSubMessage(alice, JSON.stringify({
|
|
863
|
+
op: "sub", subId: "a", collection: "notes",
|
|
864
|
+
where: { op: "isNotNull", column: "body" }, // always true for non-null bodies
|
|
865
|
+
}))
|
|
866
|
+
await handleSubMessage(bob, JSON.stringify({ op: "sub", subId: "b", collection: "notes" }))
|
|
867
|
+
alice.sent.length = 0; bob.sent.length = 0
|
|
868
|
+
|
|
869
|
+
// Bob's row appears — must reach bob but never alice.
|
|
870
|
+
await notifyRowChange("notes", "insert", null, {
|
|
871
|
+
id: "b1", body: "bob's", _owner: "bob",
|
|
872
|
+
created_at: "2026-01-01", updated_at: "2026-01-01",
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
expect(bob.sent).toHaveLength(1)
|
|
876
|
+
expect(alice.sent).toHaveLength(0)
|
|
877
|
+
})
|
|
878
|
+
})
|