@kyneta/changefeed 1.3.0

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,347 @@
1
+ import { describe, expect, it, vi } from "vitest"
2
+ import { createCallable } from "../callable.js"
3
+ import type { ChangeBase } from "../change.js"
4
+ import {
5
+ CHANGEFEED,
6
+ type Changeset,
7
+ changefeed,
8
+ createChangefeed,
9
+ hasChangefeed,
10
+ staticChangefeed,
11
+ } from "../changefeed.js"
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // hasChangefeed
15
+ // ---------------------------------------------------------------------------
16
+
17
+ describe("hasChangefeed", () => {
18
+ it("returns false for nullish values and primitives", () => {
19
+ expect(hasChangefeed(null)).toBe(false)
20
+ expect(hasChangefeed(undefined)).toBe(false)
21
+ expect(hasChangefeed(42)).toBe(false)
22
+ expect(hasChangefeed("hello")).toBe(false)
23
+ expect(hasChangefeed(true)).toBe(false)
24
+ })
25
+
26
+ it("returns false for a plain object", () => {
27
+ expect(hasChangefeed({ foo: 1 })).toBe(false)
28
+ })
29
+
30
+ it("returns true for an object with [CHANGEFEED]", () => {
31
+ const obj = {
32
+ [CHANGEFEED]: {
33
+ get current() {
34
+ return 0
35
+ },
36
+ subscribe: () => () => {},
37
+ },
38
+ }
39
+ expect(hasChangefeed(obj)).toBe(true)
40
+ })
41
+
42
+ it("returns true for a function with [CHANGEFEED]", () => {
43
+ const fn: any = () => 0
44
+ fn[CHANGEFEED] = {
45
+ get current() {
46
+ return 0
47
+ },
48
+ subscribe: () => () => {},
49
+ }
50
+ expect(hasChangefeed(fn)).toBe(true)
51
+ })
52
+ })
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Symbol identity
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe("CHANGEFEED symbol identity", () => {
59
+ it("matches Symbol.for('kyneta:changefeed') — cross-package detection works", () => {
60
+ // This is the critical invariant of the package extraction:
61
+ // any object created with Symbol.for("kyneta:changefeed") (e.g. by
62
+ // @kyneta/schema's withChangefeed interpreter) must be detectable
63
+ // by hasChangefeed from @kyneta/changefeed.
64
+ const externalSymbol = Symbol.for("kyneta:changefeed")
65
+ const obj: Record<symbol, unknown> = {}
66
+ Object.defineProperty(obj, externalSymbol, {
67
+ value: { current: 0, subscribe: () => () => {} },
68
+ enumerable: false,
69
+ })
70
+ expect(hasChangefeed(obj)).toBe(true)
71
+ expect((obj as any)[CHANGEFEED]).toBe((obj as any)[externalSymbol])
72
+ })
73
+ })
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // createChangefeed
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe("createChangefeed", () => {
80
+ it(".current reads the live value", () => {
81
+ let value = "hello"
82
+ const [feed] = createChangefeed(() => value)
83
+ expect(feed.current).toBe("hello")
84
+
85
+ value = "world"
86
+ expect(feed.current).toBe("world")
87
+ })
88
+
89
+ it(".subscribe() receives emitted changesets with origin", () => {
90
+ let value = 0
91
+ const [feed, emit] = createChangefeed<number, ChangeBase>(() => value)
92
+
93
+ const received: Changeset<ChangeBase>[] = []
94
+ feed.subscribe(cs => {
95
+ received.push(cs)
96
+ })
97
+
98
+ value = 1
99
+ emit({ changes: [{ type: "replace" }] })
100
+
101
+ value = 2
102
+ emit({ changes: [{ type: "replace" }], origin: "sync" })
103
+
104
+ expect(received).toHaveLength(2)
105
+ expect(received[0]?.changes).toEqual([{ type: "replace" }])
106
+ expect(received[0]?.origin).toBeUndefined()
107
+ expect(received[1]?.origin).toBe("sync")
108
+ })
109
+
110
+ it("hasChangefeed() returns true for the feed", () => {
111
+ const [feed] = createChangefeed(() => 0)
112
+ expect(hasChangefeed(feed)).toBe(true)
113
+ })
114
+
115
+ it("[CHANGEFEED] protocol object has live .current", () => {
116
+ let value = 10
117
+ const [feed] = createChangefeed(() => value)
118
+ expect(feed[CHANGEFEED].current).toBe(10)
119
+
120
+ value = 20
121
+ expect(feed[CHANGEFEED].current).toBe(20)
122
+ })
123
+
124
+ it("unsubscribe stops delivery", () => {
125
+ const value = 0
126
+ const [feed, emit] = createChangefeed<number, ChangeBase>(() => value)
127
+
128
+ const received: Changeset<ChangeBase>[] = []
129
+ const unsub = feed.subscribe(cs => {
130
+ received.push(cs)
131
+ })
132
+
133
+ emit({ changes: [{ type: "replace" }] })
134
+ expect(received).toHaveLength(1)
135
+
136
+ unsub()
137
+ emit({ changes: [{ type: "replace" }] })
138
+ expect(received).toHaveLength(1)
139
+ })
140
+
141
+ it("multiple subscribers all receive changesets", () => {
142
+ const [feed, emit] = createChangefeed<number, ChangeBase>(() => 0)
143
+
144
+ const a: Changeset<ChangeBase>[] = []
145
+ const b: Changeset<ChangeBase>[] = []
146
+ feed.subscribe(cs => a.push(cs))
147
+ feed.subscribe(cs => b.push(cs))
148
+
149
+ emit({ changes: [{ type: "replace" }] })
150
+ expect(a).toHaveLength(1)
151
+ expect(b).toHaveLength(1)
152
+ })
153
+ })
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // changefeed() projector
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe("changefeed() projector", () => {
160
+ it("delegates .current to source protocol", () => {
161
+ let value = "initial"
162
+ const source = {
163
+ [CHANGEFEED]: {
164
+ get current() {
165
+ return value
166
+ },
167
+ subscribe: () => () => {},
168
+ },
169
+ }
170
+
171
+ const feed = changefeed(source)
172
+ expect(feed.current).toBe("initial")
173
+
174
+ value = "updated"
175
+ expect(feed.current).toBe("updated")
176
+ })
177
+
178
+ it("delegates .subscribe() to source protocol", () => {
179
+ const cb = vi.fn()
180
+ const box: { cb: ((cs: Changeset) => void) | null } = { cb: null }
181
+
182
+ const source = {
183
+ [CHANGEFEED]: {
184
+ get current() {
185
+ return 0
186
+ },
187
+ subscribe: (callback: (cs: Changeset) => void) => {
188
+ box.cb = callback
189
+ return () => {
190
+ box.cb = null
191
+ }
192
+ },
193
+ },
194
+ }
195
+
196
+ const feed = changefeed(source)
197
+ const unsub = feed.subscribe(cb)
198
+
199
+ expect(box.cb).not.toBeNull()
200
+ if (box.cb) box.cb({ changes: [{ type: "replace" }] })
201
+ expect(cb).toHaveBeenCalledTimes(1)
202
+
203
+ unsub()
204
+ expect(box.cb).toBeNull()
205
+ })
206
+
207
+ it("[CHANGEFEED] on projected feed is the source protocol", () => {
208
+ const protocol = {
209
+ get current() {
210
+ return 42
211
+ },
212
+ subscribe: () => () => {},
213
+ }
214
+
215
+ const source = { [CHANGEFEED]: protocol }
216
+ const feed = changefeed(source)
217
+ expect(feed[CHANGEFEED]).toBe(protocol)
218
+ })
219
+
220
+ it("hasChangefeed() returns true for projected feed", () => {
221
+ const source = {
222
+ [CHANGEFEED]: {
223
+ get current() {
224
+ return 0
225
+ },
226
+ subscribe: () => () => {},
227
+ },
228
+ }
229
+ expect(hasChangefeed(changefeed(source))).toBe(true)
230
+ })
231
+ })
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // staticChangefeed
235
+ // ---------------------------------------------------------------------------
236
+
237
+ describe("staticChangefeed", () => {
238
+ it(".current returns the value and subscribe never fires", () => {
239
+ const cf = staticChangefeed("hello")
240
+ expect(cf.current).toBe("hello")
241
+
242
+ const cb = vi.fn()
243
+ const unsub = cf.subscribe(cb)
244
+ expect(cb).not.toHaveBeenCalled()
245
+ unsub()
246
+ expect(cb).not.toHaveBeenCalled()
247
+ })
248
+ })
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // createCallable
252
+ // ---------------------------------------------------------------------------
253
+
254
+ describe("createCallable", () => {
255
+ it("calling the feed returns current value", () => {
256
+ let value = 5
257
+ const [source] = createChangefeed(() => value)
258
+ const feed = createCallable(source)
259
+
260
+ expect(feed()).toBe(5)
261
+
262
+ value = 10
263
+ expect(feed()).toBe(10)
264
+ })
265
+
266
+ it(".current returns the current value", () => {
267
+ let value = "a"
268
+ const [source] = createChangefeed(() => value)
269
+ const feed = createCallable(source)
270
+
271
+ expect(feed.current).toBe("a")
272
+
273
+ value = "b"
274
+ expect(feed.current).toBe("b")
275
+ })
276
+
277
+ it(".subscribe() delegates to source subscribe", () => {
278
+ let value = 0
279
+ const [source, emit] = createChangefeed<number, ChangeBase>(() => value)
280
+ const feed = createCallable(source)
281
+
282
+ const received: Changeset<ChangeBase>[] = []
283
+ feed.subscribe(cs => {
284
+ received.push(cs)
285
+ })
286
+
287
+ value = 1
288
+ emit({ changes: [{ type: "replace" }] })
289
+ expect(received).toHaveLength(1)
290
+ })
291
+
292
+ it("hasChangefeed() returns true", () => {
293
+ const [source] = createChangefeed(() => 0)
294
+ const feed = createCallable(source)
295
+ expect(hasChangefeed(feed)).toBe(true)
296
+ })
297
+
298
+ it("[CHANGEFEED] protocol delegates to source", () => {
299
+ let value = 100
300
+ const [source] = createChangefeed(() => value)
301
+ const feed = createCallable(source)
302
+
303
+ expect(feed[CHANGEFEED].current).toBe(100)
304
+
305
+ value = 200
306
+ expect(feed[CHANGEFEED].current).toBe(200)
307
+ })
308
+
309
+ it("feed() reflects source value changes", () => {
310
+ let value = 0
311
+ const [source, _emit] = createChangefeed<number, ChangeBase>(() => value)
312
+ const feed = createCallable(source)
313
+
314
+ expect(feed()).toBe(0)
315
+
316
+ // Value changes are reflected through the getter delegation,
317
+ // independent of emit (which only notifies subscribers).
318
+ value = 42
319
+ expect(feed()).toBe(42)
320
+ expect(feed.current).toBe(42)
321
+ })
322
+
323
+ it("unsubscribe stops delivery", () => {
324
+ const [source, emit] = createChangefeed<number, ChangeBase>(() => 0)
325
+ const feed = createCallable(source)
326
+
327
+ const received: Changeset<ChangeBase>[] = []
328
+ const unsub = feed.subscribe(cs => {
329
+ received.push(cs)
330
+ })
331
+
332
+ emit({ changes: [{ type: "replace" }] })
333
+ expect(received).toHaveLength(1)
334
+
335
+ unsub()
336
+ emit({ changes: [{ type: "replace" }] })
337
+ expect(received).toHaveLength(1)
338
+ })
339
+
340
+ it("[CHANGEFEED] is non-enumerable but discoverable via getOwnPropertySymbols", () => {
341
+ const [source] = createChangefeed(() => 0)
342
+ const feed = createCallable(source)
343
+ expect(Object.keys(feed)).not.toContain(CHANGEFEED.toString())
344
+ const symbols = Object.getOwnPropertySymbols(feed)
345
+ expect(symbols).toContain(CHANGEFEED)
346
+ })
347
+ })
@@ -0,0 +1,324 @@
1
+ // ReactiveMap — unit tests for the reactive map combinator.
2
+
3
+ import { describe, expect, it, vi } from "vitest"
4
+ import {
5
+ type CallableChangefeed,
6
+ type ChangeBase,
7
+ type Changeset,
8
+ createReactiveMap,
9
+ hasChangefeed,
10
+ } from "../index.js"
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Test change type
14
+ // ---------------------------------------------------------------------------
15
+
16
+ interface TestChange extends ChangeBase {
17
+ readonly type: "set" | "delete"
18
+ readonly key: string
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Construction
23
+ // ---------------------------------------------------------------------------
24
+
25
+ describe("createReactiveMap", () => {
26
+ it("returns a tuple of [ReactiveMap, ReactiveMapHandle]", () => {
27
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
28
+ expect(typeof map).toBe("function")
29
+ expect(handle).toBeDefined()
30
+ expect(typeof handle.set).toBe("function")
31
+ expect(typeof handle.delete).toBe("function")
32
+ expect(typeof handle.clear).toBe("function")
33
+ expect(typeof handle.emit).toBe("function")
34
+ })
35
+
36
+ it("starts empty", () => {
37
+ const [map] = createReactiveMap<string, number, TestChange>()
38
+ expect(map.size).toBe(0)
39
+ expect([...map]).toEqual([])
40
+ })
41
+ })
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Call signature
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe("call signature", () => {
48
+ it("calling the map returns a ReadonlyMap snapshot", () => {
49
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
50
+ handle.set("a", 1)
51
+ const snapshot = map()
52
+ expect(snapshot).toBeInstanceOf(Map)
53
+ expect(snapshot.get("a")).toBe(1)
54
+ })
55
+
56
+ it(".current returns the same map as calling", () => {
57
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
58
+ handle.set("x", 42)
59
+ expect(map.current).toBe(map())
60
+ })
61
+ })
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Lifted collection accessors
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe("collection accessors", () => {
68
+ it(".get() delegates to the internal map", () => {
69
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
70
+ expect(map.get("missing")).toBeUndefined()
71
+ handle.set("a", 1)
72
+ expect(map.get("a")).toBe(1)
73
+ })
74
+
75
+ it(".has() delegates to the internal map", () => {
76
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
77
+ expect(map.has("a")).toBe(false)
78
+ handle.set("a", 1)
79
+ expect(map.has("a")).toBe(true)
80
+ })
81
+
82
+ it(".keys() yields all keys", () => {
83
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
84
+ handle.set("a", 1)
85
+ handle.set("b", 2)
86
+ expect([...map.keys()]).toEqual(["a", "b"])
87
+ })
88
+
89
+ it(".size reflects the number of entries", () => {
90
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
91
+ expect(map.size).toBe(0)
92
+ handle.set("a", 1)
93
+ expect(map.size).toBe(1)
94
+ handle.set("b", 2)
95
+ expect(map.size).toBe(2)
96
+ handle.delete("a")
97
+ expect(map.size).toBe(1)
98
+ })
99
+
100
+ it("[Symbol.iterator] yields [key, value] pairs", () => {
101
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
102
+ handle.set("x", 10)
103
+ handle.set("y", 20)
104
+ const entries = [...map]
105
+ expect(entries).toEqual([
106
+ ["x", 10],
107
+ ["y", 20],
108
+ ])
109
+ })
110
+ })
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Handle mutations
114
+ // ---------------------------------------------------------------------------
115
+
116
+ describe("ReactiveMapHandle", () => {
117
+ it("set() inserts entries visible via the map", () => {
118
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
119
+ handle.set("a", 1)
120
+ handle.set("b", 2)
121
+ expect(map.get("a")).toBe(1)
122
+ expect(map.get("b")).toBe(2)
123
+ })
124
+
125
+ it("set() overwrites existing entries", () => {
126
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
127
+ handle.set("a", 1)
128
+ handle.set("a", 99)
129
+ expect(map.get("a")).toBe(99)
130
+ expect(map.size).toBe(1)
131
+ })
132
+
133
+ it("delete() removes an entry and returns true", () => {
134
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
135
+ handle.set("a", 1)
136
+ const result = handle.delete("a")
137
+ expect(result).toBe(true)
138
+ expect(map.has("a")).toBe(false)
139
+ expect(map.size).toBe(0)
140
+ })
141
+
142
+ it("delete() returns false for missing key", () => {
143
+ const [, handle] = createReactiveMap<string, number, TestChange>()
144
+ expect(handle.delete("nope")).toBe(false)
145
+ })
146
+
147
+ it("clear() removes all entries", () => {
148
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
149
+ handle.set("a", 1)
150
+ handle.set("b", 2)
151
+ handle.set("c", 3)
152
+ handle.clear()
153
+ expect(map.size).toBe(0)
154
+ expect([...map]).toEqual([])
155
+ })
156
+
157
+ it("mutations do NOT automatically emit", () => {
158
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
159
+ const cb = vi.fn()
160
+ map.subscribe(cb)
161
+
162
+ handle.set("a", 1)
163
+ handle.delete("a")
164
+ handle.clear()
165
+
166
+ expect(cb).not.toHaveBeenCalled()
167
+ })
168
+ })
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Changefeed protocol — subscribe / emit
172
+ // ---------------------------------------------------------------------------
173
+
174
+ describe("changefeed protocol", () => {
175
+ it("emit() delivers changeset to subscribers", () => {
176
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
177
+ const received: Changeset<TestChange>[] = []
178
+ map.subscribe(cs => received.push(cs))
179
+
180
+ handle.set("a", 1)
181
+ handle.emit({ changes: [{ type: "set", key: "a" }] })
182
+
183
+ expect(received).toHaveLength(1)
184
+ expect(received[0].changes).toEqual([{ type: "set", key: "a" }])
185
+ })
186
+
187
+ it("multiple subscribers all receive the changeset", () => {
188
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
189
+ const cb1 = vi.fn()
190
+ const cb2 = vi.fn()
191
+ map.subscribe(cb1)
192
+ map.subscribe(cb2)
193
+
194
+ handle.emit({ changes: [{ type: "set", key: "x" }] })
195
+
196
+ expect(cb1).toHaveBeenCalledTimes(1)
197
+ expect(cb2).toHaveBeenCalledTimes(1)
198
+ })
199
+
200
+ it("unsubscribe stops delivery", () => {
201
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
202
+ const cb = vi.fn()
203
+ const unsub = map.subscribe(cb)
204
+
205
+ handle.emit({ changes: [{ type: "set", key: "a" }] })
206
+ expect(cb).toHaveBeenCalledTimes(1)
207
+
208
+ unsub()
209
+
210
+ handle.emit({ changes: [{ type: "set", key: "b" }] })
211
+ expect(cb).toHaveBeenCalledTimes(1)
212
+ })
213
+
214
+ it("changeset preserves origin", () => {
215
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
216
+ const received: Changeset<TestChange>[] = []
217
+ map.subscribe(cs => received.push(cs))
218
+
219
+ handle.emit({
220
+ changes: [{ type: "set", key: "a" }],
221
+ origin: "sync",
222
+ })
223
+
224
+ expect(received[0].origin).toBe("sync")
225
+ })
226
+
227
+ it("subscriber sees current state at time of emit", () => {
228
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
229
+ let snapshotSize = -1
230
+ map.subscribe(() => {
231
+ snapshotSize = map.size
232
+ })
233
+
234
+ handle.set("a", 1)
235
+ handle.set("b", 2)
236
+ handle.emit({
237
+ changes: [
238
+ { type: "set", key: "a" },
239
+ { type: "set", key: "b" },
240
+ ],
241
+ })
242
+
243
+ expect(snapshotSize).toBe(2)
244
+ })
245
+ })
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // hasChangefeed type guard
249
+ // ---------------------------------------------------------------------------
250
+
251
+ describe("hasChangefeed", () => {
252
+ it("returns true for a ReactiveMap", () => {
253
+ const [map] = createReactiveMap<string, number, TestChange>()
254
+ expect(hasChangefeed(map)).toBe(true)
255
+ })
256
+ })
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Backward compatibility — assignable to CallableChangefeed
260
+ // ---------------------------------------------------------------------------
261
+
262
+ describe("backward compatibility", () => {
263
+ it("ReactiveMap is assignable to CallableChangefeed", () => {
264
+ const [map] = createReactiveMap<string, number, TestChange>()
265
+
266
+ // Type-level test: assign to CallableChangefeed
267
+ const callable: CallableChangefeed<
268
+ ReadonlyMap<string, number>,
269
+ TestChange
270
+ > = map
271
+
272
+ // Runtime: callable still works
273
+ expect(callable()).toBeInstanceOf(Map)
274
+ expect(callable.current).toBeInstanceOf(Map)
275
+ expect(typeof callable.subscribe).toBe("function")
276
+ })
277
+ })
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Wholesale rebuild pattern (exchange.peers use case)
281
+ // ---------------------------------------------------------------------------
282
+
283
+ describe("wholesale rebuild pattern", () => {
284
+ it("clear → set × N → emit rebuilds and notifies", () => {
285
+ const [map, handle] = createReactiveMap<string, number, TestChange>()
286
+
287
+ // Initial state
288
+ handle.set("a", 1)
289
+ handle.set("b", 2)
290
+ handle.emit({
291
+ changes: [
292
+ { type: "set", key: "a" },
293
+ { type: "set", key: "b" },
294
+ ],
295
+ })
296
+
297
+ const received: Changeset<TestChange>[] = []
298
+ map.subscribe(cs => received.push(cs))
299
+
300
+ // Wholesale rebuild
301
+ handle.clear()
302
+ handle.set("c", 3)
303
+ handle.set("d", 4)
304
+ handle.emit({
305
+ changes: [
306
+ { type: "delete", key: "a" },
307
+ { type: "delete", key: "b" },
308
+ { type: "set", key: "c" },
309
+ { type: "set", key: "d" },
310
+ ],
311
+ })
312
+
313
+ // One changeset with all changes
314
+ expect(received).toHaveLength(1)
315
+ expect(received[0].changes).toHaveLength(4)
316
+
317
+ // Map reflects new state
318
+ expect(map.size).toBe(2)
319
+ expect(map.has("a")).toBe(false)
320
+ expect(map.has("b")).toBe(false)
321
+ expect(map.get("c")).toBe(3)
322
+ expect(map.get("d")).toBe(4)
323
+ })
324
+ })