@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.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/dist/index.d.ts +235 -0
- package/dist/index.js +138 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
- package/src/__tests__/changefeed.test.ts +347 -0
- package/src/__tests__/reactive-map.test.ts +324 -0
- package/src/callable.ts +82 -0
- package/src/change.ts +28 -0
- package/src/changefeed.ts +250 -0
- package/src/index.ts +27 -0
- package/src/reactive-map.ts +162 -0
|
@@ -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
|
+
})
|