@jr200-labs/xstate-nats 0.6.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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/dist/actions/connection.d.ts +28 -0
  4. package/dist/actions/connection.d.ts.map +1 -0
  5. package/dist/actions/connection.js +102 -0
  6. package/dist/actions/connection.js.map +1 -0
  7. package/dist/actions/kv.d.ts +21 -0
  8. package/dist/actions/kv.d.ts.map +1 -0
  9. package/dist/actions/kv.js +66 -0
  10. package/dist/actions/kv.js.map +1 -0
  11. package/dist/actions/subject.d.ts +39 -0
  12. package/dist/actions/subject.d.ts.map +1 -0
  13. package/dist/actions/subject.js +79 -0
  14. package/dist/actions/subject.js.map +1 -0
  15. package/dist/actions/types.d.ts +8 -0
  16. package/dist/actions/types.d.ts.map +1 -0
  17. package/dist/actions/types.js +2 -0
  18. package/dist/actions/types.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +6 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/machines/kv.d.ts +190 -0
  24. package/dist/machines/kv.d.ts.map +1 -0
  25. package/dist/machines/kv.js +273 -0
  26. package/dist/machines/kv.js.map +1 -0
  27. package/dist/machines/root.d.ts +510 -0
  28. package/dist/machines/root.d.ts.map +1 -0
  29. package/dist/machines/root.js +245 -0
  30. package/dist/machines/root.js.map +1 -0
  31. package/dist/machines/subject.d.ts +95 -0
  32. package/dist/machines/subject.d.ts.map +1 -0
  33. package/dist/machines/subject.js +162 -0
  34. package/dist/machines/subject.js.map +1 -0
  35. package/dist/utils.d.ts +10 -0
  36. package/dist/utils.d.ts.map +1 -0
  37. package/dist/utils.js +27 -0
  38. package/dist/utils.js.map +1 -0
  39. package/package.json +55 -0
  40. package/src/actions/connection.test.ts +324 -0
  41. package/src/actions/connection.ts +135 -0
  42. package/src/actions/kv.test.ts +439 -0
  43. package/src/actions/kv.ts +92 -0
  44. package/src/actions/subject.test.ts +460 -0
  45. package/src/actions/subject.ts +127 -0
  46. package/src/actions/types.ts +7 -0
  47. package/src/index.ts +20 -0
  48. package/src/machines/kv.test.ts +720 -0
  49. package/src/machines/kv.ts +327 -0
  50. package/src/machines/root.test.ts +329 -0
  51. package/src/machines/root.ts +286 -0
  52. package/src/machines/subject.test.ts +272 -0
  53. package/src/machines/subject.ts +205 -0
  54. package/src/utils.test.ts +35 -0
  55. package/src/utils.ts +30 -0
@@ -0,0 +1,439 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { createActor } from 'xstate'
3
+ import { KvSubscriptionKey, kvConsolidateState } from './kv'
4
+
5
+ describe('KvSubscriptionKey', () => {
6
+ it('should create a key from bucket and key', () => {
7
+ const result = KvSubscriptionKey.key('my-bucket', 'my-key')
8
+ expect(result).toBe('Pair(my-bucket, my-key)')
9
+ })
10
+
11
+ it('should parse a key back', () => {
12
+ const pair = KvSubscriptionKey.fromKey('Pair(bucket1, key1)')
13
+ expect(pair.x).toBe('bucket1')
14
+ expect(pair.y).toBe('key1')
15
+ })
16
+
17
+ it('should support equality check', () => {
18
+ const a = new KvSubscriptionKey('b', 'k')
19
+ const b = new KvSubscriptionKey('b', 'k')
20
+ const c = new KvSubscriptionKey('b', 'other')
21
+ expect(a.equals(b)).toBe(true)
22
+ expect(a.equals(c)).toBe(false)
23
+ })
24
+
25
+ it('should convert to key string', () => {
26
+ const pair = new KvSubscriptionKey('bucket', 'key')
27
+ expect(pair.toKey()).toBe('Pair(bucket, key)')
28
+ })
29
+ })
30
+
31
+ describe('kvConsolidateState', () => {
32
+ it('should throw when connection and kvm are null', async () => {
33
+ let caughtError: Error | undefined
34
+ const origListeners = process.listeners('uncaughtException')
35
+ process.removeAllListeners('uncaughtException')
36
+ process.once('uncaughtException', (err: Error) => {
37
+ caughtError = err
38
+ })
39
+
40
+ const actor = createActor(kvConsolidateState, {
41
+ input: {
42
+ kvm: null,
43
+ connection: null,
44
+ currentState: new Map(),
45
+ targetState: new Map(),
46
+ },
47
+ })
48
+ actor.start()
49
+
50
+ await vi.waitFor(() => {
51
+ expect(caughtError).toBeDefined()
52
+ })
53
+ expect(caughtError!.message).toContain('NATS connection or KVM is not available')
54
+ origListeners.forEach((l) => process.on('uncaughtException', l))
55
+ })
56
+
57
+ it('should throw when kvm is null', async () => {
58
+ let caughtError: Error | undefined
59
+ const origListeners = process.listeners('uncaughtException')
60
+ process.removeAllListeners('uncaughtException')
61
+ process.once('uncaughtException', (err: Error) => {
62
+ caughtError = err
63
+ })
64
+
65
+ const actor = createActor(kvConsolidateState, {
66
+ input: {
67
+ kvm: null,
68
+ connection: {} as any,
69
+ currentState: new Map(),
70
+ targetState: new Map(),
71
+ },
72
+ })
73
+ actor.start()
74
+
75
+ await vi.waitFor(() => {
76
+ expect(caughtError).toBeDefined()
77
+ })
78
+ expect(caughtError!.message).toContain('NATS connection or KVM is not available')
79
+ origListeners.forEach((l) => process.on('uncaughtException', l))
80
+ })
81
+
82
+ it('should unsubscribe from items not in target state', async () => {
83
+ const mockWatcher = { stop: vi.fn() }
84
+ const currentState = new Map([['Pair(bucket, key1)', mockWatcher as any]])
85
+
86
+ const actor = createActor(kvConsolidateState, {
87
+ input: {
88
+ kvm: { open: vi.fn() } as any,
89
+ connection: {} as any,
90
+ currentState,
91
+ targetState: new Map(),
92
+ },
93
+ })
94
+
95
+ const outputPromise = new Promise<any>((resolve) => {
96
+ actor.subscribe((snap) => {
97
+ if (snap.output) resolve(snap.output)
98
+ })
99
+ })
100
+ actor.start()
101
+
102
+ const result = await outputPromise
103
+ expect(mockWatcher.stop).toHaveBeenCalled()
104
+ expect(result.subscriptions.has('Pair(bucket, key1)')).toBe(false)
105
+ })
106
+
107
+ it('should handle unsubscribe errors gracefully', async () => {
108
+ const mockWatcher = {
109
+ stop: vi.fn(() => {
110
+ throw new Error('stop failed')
111
+ }),
112
+ }
113
+ const currentState = new Map([['Pair(bucket, key1)', mockWatcher as any]])
114
+
115
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
116
+ const actor = createActor(kvConsolidateState, {
117
+ input: {
118
+ kvm: { open: vi.fn() } as any,
119
+ connection: {} as any,
120
+ currentState,
121
+ targetState: new Map(),
122
+ },
123
+ })
124
+
125
+ const outputPromise = new Promise<any>((resolve) => {
126
+ actor.subscribe((snap) => {
127
+ if (snap.output) resolve(snap.output)
128
+ })
129
+ })
130
+ actor.start()
131
+ await outputPromise
132
+ consoleSpy.mockRestore()
133
+
134
+ expect(mockWatcher.stop).toHaveBeenCalled()
135
+ })
136
+
137
+ it('should subscribe to new items in target state', async () => {
138
+ const mockWatcher = {
139
+ [Symbol.asyncIterator]: () => ({
140
+ next: () => new Promise(() => {}),
141
+ }),
142
+ }
143
+ const mockKv = {
144
+ watch: vi.fn().mockResolvedValue(mockWatcher),
145
+ }
146
+ const mockKvm = {
147
+ open: vi.fn().mockResolvedValue(mockKv),
148
+ }
149
+
150
+ const callback = vi.fn()
151
+ const targetState = new Map([
152
+ [
153
+ 'Pair(bucket, key1)',
154
+ {
155
+ bucket: 'bucket',
156
+ key: 'key1',
157
+ callback,
158
+ },
159
+ ],
160
+ ])
161
+
162
+ const actor = createActor(kvConsolidateState, {
163
+ input: {
164
+ kvm: mockKvm as any,
165
+ connection: {} as any,
166
+ currentState: new Map(),
167
+ targetState: targetState as any,
168
+ },
169
+ })
170
+
171
+ const outputPromise = new Promise<any>((resolve) => {
172
+ actor.subscribe((snap) => {
173
+ if (snap.output) resolve(snap.output)
174
+ })
175
+ })
176
+ actor.start()
177
+
178
+ const result = await outputPromise
179
+ expect(mockKvm.open).toHaveBeenCalledWith('bucket')
180
+ expect(mockKv.watch).toHaveBeenCalled()
181
+ expect(result.subscriptions.has('Pair(bucket, key1)')).toBe(true)
182
+ })
183
+
184
+ it('should keep existing subscriptions that are still in target', async () => {
185
+ const existingWatcher = { stop: vi.fn() } as any
186
+ const currentState = new Map([['Pair(bucket, key1)', existingWatcher]])
187
+
188
+ const callback = vi.fn()
189
+ const targetState = new Map([
190
+ [
191
+ 'Pair(bucket, key1)',
192
+ {
193
+ bucket: 'bucket',
194
+ key: 'key1',
195
+ callback,
196
+ },
197
+ ],
198
+ ])
199
+
200
+ const mockKvm = { open: vi.fn() }
201
+ const actor = createActor(kvConsolidateState, {
202
+ input: {
203
+ kvm: mockKvm as any,
204
+ connection: {} as any,
205
+ currentState,
206
+ targetState: targetState as any,
207
+ },
208
+ })
209
+
210
+ const outputPromise = new Promise<any>((resolve) => {
211
+ actor.subscribe((snap) => {
212
+ if (snap.output) resolve(snap.output)
213
+ })
214
+ })
215
+ actor.start()
216
+
217
+ const result = await outputPromise
218
+ expect(existingWatcher.stop).not.toHaveBeenCalled()
219
+ expect(mockKvm.open).not.toHaveBeenCalled()
220
+ expect(result.subscriptions.get('Pair(bucket, key1)')).toBe(existingWatcher)
221
+ })
222
+
223
+ it('should invoke callback for watcher entries with JSON values', async () => {
224
+ let entryIndex = 0
225
+ let resolveDelivered: () => void
226
+ const deliveredPromise = new Promise<void>((r) => (resolveDelivered = r))
227
+ const entries = [
228
+ { operation: 'PUT', string: () => '{"val":1}' },
229
+ { operation: 'PUT', string: () => '{"val":2}' },
230
+ ]
231
+ const mockWatcher = {
232
+ [Symbol.asyncIterator]: () => ({
233
+ next: () => {
234
+ if (entryIndex < entries.length) {
235
+ return Promise.resolve({ value: entries[entryIndex++], done: false })
236
+ }
237
+ resolveDelivered!()
238
+ return new Promise(() => {})
239
+ },
240
+ }),
241
+ }
242
+ const mockKv = { watch: vi.fn().mockResolvedValue(mockWatcher) }
243
+ const mockKvm = { open: vi.fn().mockResolvedValue(mockKv) }
244
+
245
+ const callback = vi.fn()
246
+ const targetState = new Map([['Pair(b, k)', { bucket: 'b', key: 'k', callback }]])
247
+
248
+ const actor = createActor(kvConsolidateState, {
249
+ input: {
250
+ kvm: mockKvm as any,
251
+ connection: {} as any,
252
+ currentState: new Map(),
253
+ targetState: targetState as any,
254
+ },
255
+ })
256
+ const outputPromise = new Promise<any>((resolve) => {
257
+ actor.subscribe((snap) => {
258
+ if (snap.output) resolve(snap.output)
259
+ })
260
+ })
261
+ actor.start()
262
+ await outputPromise
263
+ await deliveredPromise
264
+ await new Promise((r) => setTimeout(r, 10))
265
+
266
+ expect(callback).toHaveBeenCalledTimes(2)
267
+ expect(callback).toHaveBeenCalledWith({ bucket: 'b', key: 'k', value: { val: 1 } })
268
+ expect(callback).toHaveBeenCalledWith({ bucket: 'b', key: 'k', value: { val: 2 } })
269
+ })
270
+
271
+ it('should fall back to string when JSON parsing fails in watcher', async () => {
272
+ let delivered = false
273
+ let resolveDelivered: () => void
274
+ const deliveredPromise = new Promise<void>((r) => (resolveDelivered = r))
275
+ const mockWatcher = {
276
+ [Symbol.asyncIterator]: () => ({
277
+ next: () => {
278
+ if (!delivered) {
279
+ delivered = true
280
+ return Promise.resolve({
281
+ value: { operation: 'PUT', string: () => 'not-json' },
282
+ done: false,
283
+ })
284
+ }
285
+ resolveDelivered!()
286
+ return new Promise(() => {})
287
+ },
288
+ }),
289
+ }
290
+ const mockKv = { watch: vi.fn().mockResolvedValue(mockWatcher) }
291
+ const mockKvm = { open: vi.fn().mockResolvedValue(mockKv) }
292
+
293
+ const callback = vi.fn()
294
+ const targetState = new Map([['Pair(b, k)', { bucket: 'b', key: 'k', callback }]])
295
+
296
+ const actor = createActor(kvConsolidateState, {
297
+ input: {
298
+ kvm: mockKvm as any,
299
+ connection: {} as any,
300
+ currentState: new Map(),
301
+ targetState: targetState as any,
302
+ },
303
+ })
304
+ const outputPromise = new Promise<any>((resolve) => {
305
+ actor.subscribe((snap) => {
306
+ if (snap.output) resolve(snap.output)
307
+ })
308
+ })
309
+ actor.start()
310
+ await outputPromise
311
+ await deliveredPromise
312
+ await new Promise((r) => setTimeout(r, 10))
313
+
314
+ expect(callback).toHaveBeenCalledWith({ bucket: 'b', key: 'k', value: 'not-json' })
315
+ })
316
+
317
+ it('should skip DEL operations in watcher', async () => {
318
+ let delivered = false
319
+ let resolveDelivered: () => void
320
+ const deliveredPromise = new Promise<void>((r) => (resolveDelivered = r))
321
+ const mockWatcher = {
322
+ [Symbol.asyncIterator]: () => ({
323
+ next: () => {
324
+ if (!delivered) {
325
+ delivered = true
326
+ return Promise.resolve({
327
+ value: { operation: 'DEL', string: () => '{}' },
328
+ done: false,
329
+ })
330
+ }
331
+ resolveDelivered!()
332
+ return new Promise(() => {})
333
+ },
334
+ }),
335
+ }
336
+ const mockKv = { watch: vi.fn().mockResolvedValue(mockWatcher) }
337
+ const mockKvm = { open: vi.fn().mockResolvedValue(mockKv) }
338
+
339
+ const callback = vi.fn()
340
+ const targetState = new Map([['Pair(b, k)', { bucket: 'b', key: 'k', callback }]])
341
+
342
+ const actor = createActor(kvConsolidateState, {
343
+ input: {
344
+ kvm: mockKvm as any,
345
+ connection: {} as any,
346
+ currentState: new Map(),
347
+ targetState: targetState as any,
348
+ },
349
+ })
350
+ const outputPromise = new Promise<any>((resolve) => {
351
+ actor.subscribe((snap) => {
352
+ if (snap.output) resolve(snap.output)
353
+ })
354
+ })
355
+ actor.start()
356
+ await outputPromise
357
+ await deliveredPromise
358
+ await new Promise((r) => setTimeout(r, 10))
359
+
360
+ expect(callback).not.toHaveBeenCalled()
361
+ })
362
+
363
+ it('should handle watcher loop errors', async () => {
364
+ const mockWatcher = {
365
+ [Symbol.asyncIterator]: () => ({
366
+ next: () => Promise.reject(new Error('watcher broke')),
367
+ }),
368
+ }
369
+ const mockKv = { watch: vi.fn().mockResolvedValue(mockWatcher) }
370
+ const mockKvm = { open: vi.fn().mockResolvedValue(mockKv) }
371
+
372
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
373
+ const callback = vi.fn()
374
+ const targetState = new Map([['Pair(b, k)', { bucket: 'b', key: 'k', callback }]])
375
+
376
+ const actor = createActor(kvConsolidateState, {
377
+ input: {
378
+ kvm: mockKvm as any,
379
+ connection: {} as any,
380
+ currentState: new Map(),
381
+ targetState: targetState as any,
382
+ },
383
+ })
384
+ const outputPromise = new Promise<any>((resolve) => {
385
+ actor.subscribe((snap) => {
386
+ if (snap.output) resolve(snap.output)
387
+ })
388
+ })
389
+ actor.start()
390
+ await outputPromise
391
+
392
+ await vi.waitFor(() => {
393
+ expect(consoleSpy).toHaveBeenCalledWith(
394
+ expect.stringContaining('Watcher loop error'),
395
+ expect.any(Error),
396
+ )
397
+ })
398
+ consoleSpy.mockRestore()
399
+ })
400
+
401
+ it('should handle subscribe errors gracefully', async () => {
402
+ const mockKvm = {
403
+ open: vi.fn().mockRejectedValue(new Error('open failed')),
404
+ }
405
+
406
+ const callback = vi.fn()
407
+ const targetState = new Map([
408
+ [
409
+ 'Pair(bucket, key1)',
410
+ {
411
+ bucket: 'bucket',
412
+ key: 'key1',
413
+ callback,
414
+ },
415
+ ],
416
+ ])
417
+
418
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
419
+ const actor = createActor(kvConsolidateState, {
420
+ input: {
421
+ kvm: mockKvm as any,
422
+ connection: {} as any,
423
+ currentState: new Map(),
424
+ targetState: targetState as any,
425
+ },
426
+ })
427
+
428
+ const outputPromise = new Promise<any>((resolve) => {
429
+ actor.subscribe((snap) => {
430
+ if (snap.output) resolve(snap.output)
431
+ })
432
+ })
433
+ actor.start()
434
+
435
+ const result = await outputPromise
436
+ consoleSpy.mockRestore()
437
+ expect(result.subscriptions.has('Pair(bucket, key1)')).toBe(false)
438
+ })
439
+ })
@@ -0,0 +1,92 @@
1
+ import { NatsConnection, QueuedIterator } from '@nats-io/nats-core'
2
+ import { Kvm, KvWatchEntry, KvWatchOptions } from '@nats-io/kv'
3
+ import { Pair } from '../utils'
4
+ import { fromPromise } from 'xstate'
5
+
6
+ export class KvSubscriptionKey extends Pair<string, string> {}
7
+
8
+ export type KvSubscriptionConfig = {
9
+ bucket: string
10
+ key: string
11
+ callback: (data: any) => void
12
+ opts?: KvWatchOptions
13
+ replayOnReconnect?: boolean
14
+ }
15
+
16
+ export const kvConsolidateState = fromPromise(
17
+ async ({
18
+ input,
19
+ }: {
20
+ input: {
21
+ kvm: Kvm | null
22
+ connection: NatsConnection | null
23
+ currentState: Map<string, QueuedIterator<KvWatchEntry>>
24
+ targetState: Map<string, KvSubscriptionConfig>
25
+ }
26
+ }): Promise<{
27
+ subscriptions: Map<string, QueuedIterator<KvWatchEntry>>
28
+ }> => {
29
+ if (!input.connection || !input.kvm) {
30
+ throw new Error('NATS connection or KVM is not available')
31
+ }
32
+
33
+ const { currentState, targetState } = input
34
+ const syncedState = new Map(currentState)
35
+
36
+ // Unsubscribe from items that are in currentState but not in targetState
37
+ for (const [kvKey, subscription] of currentState) {
38
+ if (!targetState.has(kvKey)) {
39
+ try {
40
+ syncedState.delete(kvKey)
41
+ subscription.stop()
42
+ } catch (error) {
43
+ console.error(`Error unsubscribing from subject "${kvKey}"`, error)
44
+ }
45
+ }
46
+ }
47
+
48
+ // Subscribe to new subjects that are in targetState but not in currentState
49
+ for (const [kvKey, config] of targetState) {
50
+ if (!currentState.has(kvKey)) {
51
+ // the problem is kv key is triggered on successive calls to consolidateState
52
+ // the match is not working?
53
+ try {
54
+ const kv = await input.kvm.open(config.bucket)
55
+
56
+ const watchOptions = config as KvWatchOptions
57
+ const watcher = await kv.watch(watchOptions)
58
+
59
+ syncedState.set(kvKey, watcher)
60
+ ;(async () => {
61
+ try {
62
+ for await (const e of watcher) {
63
+ if (e.operation !== 'DEL') {
64
+ let parsedValue
65
+ try {
66
+ parsedValue = JSON.parse(e.string())
67
+ } catch {
68
+ parsedValue = e.string()
69
+ }
70
+
71
+ config.callback({
72
+ bucket: config.bucket,
73
+ key: config.key,
74
+ value: parsedValue,
75
+ })
76
+ }
77
+ }
78
+ } catch (error) {
79
+ console.error(`KV_SUBSCRIBE (connected): Watcher loop error for ${kvKey}:`, error)
80
+ }
81
+ })()
82
+ } catch (error) {
83
+ console.error(`Error subscribing to subject "${kvKey}"`, error)
84
+ }
85
+ }
86
+ }
87
+
88
+ return {
89
+ subscriptions: syncedState,
90
+ }
91
+ },
92
+ )