@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,327 @@
1
+ import { NatsConnection, QueuedIterator } from '@nats-io/nats-core'
2
+ import { jetstream } from '@nats-io/jetstream'
3
+ import { KvEntry, Kvm, KvOptions, KvStatus, KvWatchEntry } from '@nats-io/kv'
4
+ import { assign, sendParent, setup } from 'xstate'
5
+ import { KvSubscriptionKey, KvSubscriptionConfig, kvConsolidateState } from '../actions/kv'
6
+
7
+ // internal events and events from nats connection
8
+ type InternalEvents = { type: 'ERROR'; error: Error }
9
+
10
+ // events which can be sent to the machine from the user
11
+ export type ExternalEvents =
12
+ | { type: 'KV.CONNECT'; connection: NatsConnection }
13
+ | { type: 'KV.CONNECTED' }
14
+ | { type: 'KV.DISCONNECTED' }
15
+ | {
16
+ type: 'KV.BUCKET_LIST'
17
+ bucket?: string
18
+ onResult: (result: KvStatus[] | string[] | { error: Error }) => void
19
+ }
20
+ | {
21
+ type: 'KV.BUCKET_CREATE'
22
+ bucket: string
23
+ onResult: (result: { ok: true } | { ok: false } | { error: Error }) => void
24
+ }
25
+ | {
26
+ type: 'KV.BUCKET_DELETE'
27
+ bucket: string
28
+ onResult: (result: { ok: true } | { ok: false } | { error: Error }) => void
29
+ }
30
+ | {
31
+ type: 'KV.GET'
32
+ bucket: string
33
+ key: string
34
+ onResult: (result: KvEntry | null | { error: Error }) => void
35
+ }
36
+ | {
37
+ type: 'KV.PUT'
38
+ bucket: string
39
+ key: string
40
+ value: any
41
+ onResult: (result: { ok: true } | { error: Error }) => void
42
+ }
43
+ | {
44
+ type: 'KV.DELETE'
45
+ bucket: string
46
+ key: string
47
+ onResult: (result: { ok: true } | { error: Error }) => void
48
+ }
49
+ | { type: 'KV.SUBSCRIBE'; config: KvSubscriptionConfig }
50
+ | { type: 'KV.UNSUBSCRIBE'; bucket: string; key: string }
51
+ | { type: 'KV.UNSUBSCRIBE_ALL' }
52
+
53
+ export type Events = InternalEvents | ExternalEvents
54
+
55
+ export interface Context {
56
+ uid: string
57
+ cachedConnection: NatsConnection | null
58
+ cachedKvm: Kvm | null
59
+ kvmOpts?: KvOptions
60
+ subscriptions: Map<string, QueuedIterator<KvWatchEntry>>
61
+ subscriptionConfigs: Map<string, KvSubscriptionConfig>
62
+ syncRequired: number
63
+ error?: Error
64
+ }
65
+ export const kvManagerLogic = setup({
66
+ types: {
67
+ context: {} as Context,
68
+ events: {} as Events,
69
+ },
70
+ actors: {
71
+ kvConsolidateState: kvConsolidateState,
72
+ },
73
+ guards: {
74
+ hasPendingSync: ({ context }) => {
75
+ return context.syncRequired > 0
76
+ },
77
+ },
78
+ }).createMachine({
79
+ /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOlgFcAjAKzEwBcB9XCAGzAGIBlAVQCEAUgFEAwgBVGASQByksZICCAGUlchAbQAMAXUSgADgHtYuerkP49IAB6IATJs0kAnAFYAzAEY7rgDQgAT3tXTxJNAA53KOiYrwBfOP80LDxCUgoaOiYCU1x0VlwTfCgOa1h6dHowEnQAMyqAJ2RXRyIOZJwCYjIqWgZmfFz8woIoLV0kECMTMwsrWwQANk0AFhJPNy8ffyCEHzsSV0jY2M8EpIxOtJ7M-swLQgZIbn5hcUZePi4RACVJPg0Ois01yc0mC08jnCJAA7O4Vu4YX5AvYVjDDnZ4YjXOcQB1Ut0Mn0mPd8I8qhAXoJRBIeNJPt8-gDxsDjKDLODEEiDu5FjDPOEkTtEJ4Yc5cfiuulellGKTyc9Pm8JCIlEIFD8PvxGf9ARMDGzZhzQAs4a4SHY7ItXIttiiEN5oTjcfhDBA4FZJWlWTNzMabIgALSLYUIYMSy4E6W3bJsMA+9nzRArOyhy3QiInU4RlJSm7EgZDApFKAJo1JhArTzmzTV23I3bpsLHLNRM6JPGRvNE2XyrKQMt+iswpEkRaChv2eGHHNXQkyu4WWCGAoQSpgRjldeDsEmkWY0IrFYebyTvZomcdr3zmOMMANBqGBo7-0LFaLC0CoX26KHFuthIEiAA */
80
+ initial: 'kv_idle',
81
+ context: {
82
+ uid: new Date().toISOString(),
83
+ cachedConnection: null,
84
+ cachedKvm: null,
85
+ kvmOpts: undefined,
86
+ subscriptions: new Map<string, QueuedIterator<KvWatchEntry>>(),
87
+ subscriptionConfigs: new Map<string, KvSubscriptionConfig>(),
88
+ syncRequired: 0,
89
+ },
90
+ on: {
91
+ 'KV.SUBSCRIBE': {
92
+ actions: [
93
+ assign({
94
+ subscriptionConfigs: ({ context, event }) => {
95
+ const { config } = event
96
+ const newConfigs = new Map(context.subscriptionConfigs)
97
+ const newKvKey = KvSubscriptionKey.key(config.bucket, config.key)
98
+ newConfigs.set(newKvKey, config)
99
+ return newConfigs
100
+ },
101
+ syncRequired: ({ context }) => context.syncRequired + 1,
102
+ }),
103
+ ],
104
+ },
105
+ 'KV.UNSUBSCRIBE': {
106
+ actions: assign(({ context, event }) => {
107
+ const newConfigs = new Map(context.subscriptionConfigs)
108
+ const newKvKey = KvSubscriptionKey.key(event.bucket, event.key)
109
+ newConfigs.delete(newKvKey)
110
+ return {
111
+ subscriptionConfigs: newConfigs,
112
+ syncRequired: context.syncRequired + 1,
113
+ }
114
+ }),
115
+ },
116
+ 'KV.UNSUBSCRIBE_ALL': {
117
+ actions: assign({
118
+ subscriptionConfigs: new Map(),
119
+ syncRequired: ({ context }) => context.syncRequired + 1,
120
+ }),
121
+ },
122
+ },
123
+ states: {
124
+ kv_idle: {
125
+ on: {
126
+ 'KV.CONNECT': {
127
+ target: 'kv_check_sync',
128
+ actions: [
129
+ assign({
130
+ cachedConnection: ({ event }) => event.connection,
131
+ cachedKvm: ({ event }) => new Kvm(event.connection),
132
+ }),
133
+ ],
134
+ },
135
+ },
136
+ },
137
+ kv_disconnecting: {
138
+ target: 'kv_idle',
139
+ entry: [
140
+ // dont close the connection here, it will be closed by the nats connection machine
141
+ assign({
142
+ cachedConnection: null,
143
+ cachedKvm: null,
144
+ subscriptions: new Map<string, QueuedIterator<KvWatchEntry>>(),
145
+ }),
146
+ sendParent({ type: 'KV.DISCONNECTED' }),
147
+ ],
148
+ },
149
+ kv_connected: {
150
+ entry: [sendParent({ type: 'KV.CONNECTED' })],
151
+ always: {
152
+ target: 'kv_syncing',
153
+ guard: 'hasPendingSync',
154
+ },
155
+ on: {
156
+ 'KV.BUCKET_LIST': {
157
+ actions: async ({ context, event }) => {
158
+ try {
159
+ if (!context.cachedKvm) {
160
+ event.onResult({ error: new Error('KVM not initialized') })
161
+ return
162
+ }
163
+
164
+ const results = []
165
+ if (event.bucket) {
166
+ const bucket = await context.cachedKvm.open(event.bucket)
167
+ for await (const key of await bucket.keys()) {
168
+ results.push(key)
169
+ }
170
+ event.onResult(results)
171
+ } else {
172
+ for await (const status of await context.cachedKvm.list()) {
173
+ results.push(status)
174
+ }
175
+ event.onResult(results)
176
+ }
177
+ } catch (error) {
178
+ event.onResult({ error: error as Error })
179
+ }
180
+ },
181
+ },
182
+ 'KV.BUCKET_CREATE': {
183
+ actions: async ({ context, event }) => {
184
+ try {
185
+ if (!context.cachedKvm) throw new Error('KVM not initialized')
186
+
187
+ for await (const status of context.cachedKvm.list()) {
188
+ if (status.bucket === event.bucket) {
189
+ event.onResult?.({ ok: false })
190
+ return
191
+ }
192
+ }
193
+
194
+ await context.cachedKvm.create(event.bucket)
195
+ event.onResult?.({ ok: true })
196
+ } catch (error) {
197
+ event.onResult?.({ error: error as Error })
198
+ }
199
+ },
200
+ },
201
+ 'KV.BUCKET_DELETE': {
202
+ actions: async ({ event }) => {
203
+ try {
204
+ try {
205
+ // workaround: theres no delete bucket method on the kvm
206
+ const connection = (event as any).connection as NatsConnection
207
+ const js = jetstream(connection!)
208
+ const jsm = await js.jetstreamManager()
209
+ const res = await jsm.streams.delete(`KV_${event.bucket}`)
210
+ event.onResult({ ok: res })
211
+ } catch (streamError) {
212
+ // Stream deletion might fail, but that's okay
213
+ event.onResult({ ok: false })
214
+ }
215
+ } catch (error) {
216
+ event.onResult({ error: error as Error })
217
+ }
218
+ },
219
+ },
220
+ 'KV.GET': {
221
+ actions: async ({ context, event }) => {
222
+ try {
223
+ const kv = await context.cachedKvm?.open(event.bucket)
224
+ if (!kv) {
225
+ event.onResult({ error: new Error(`Bucket '${event.bucket}' not found`) })
226
+ return
227
+ }
228
+ const entry = await kv.get(event.key)
229
+ event.onResult(entry)
230
+ } catch (error) {
231
+ event.onResult({ error: error as Error })
232
+ }
233
+ },
234
+ },
235
+ 'KV.PUT': {
236
+ actions: async ({ context, event }) => {
237
+ try {
238
+ const kv = await context.cachedKvm?.open(event.bucket)
239
+ if (!kv) {
240
+ event.onResult({ error: new Error(`Bucket '${event.bucket}' not found`) })
241
+ return
242
+ }
243
+
244
+ await kv.put(event.key, event.value)
245
+ event.onResult({ ok: true })
246
+ } catch (error) {
247
+ event.onResult({ error: error as Error })
248
+ }
249
+ },
250
+ },
251
+ 'KV.DELETE': {
252
+ actions: async ({ context, event }) => {
253
+ try {
254
+ const kv = await context.cachedKvm?.open(event.bucket)
255
+ if (!kv) {
256
+ event.onResult({ error: new Error(`Bucket '${event.bucket}' not found`) })
257
+ return
258
+ }
259
+ await kv.delete(event.key)
260
+ event.onResult({ ok: true })
261
+ } catch (error) {
262
+ event.onResult({ error: error as Error })
263
+ }
264
+ },
265
+ },
266
+ },
267
+ },
268
+ kv_check_sync: {
269
+ always: [
270
+ {
271
+ target: 'kv_syncing',
272
+ guard: 'hasPendingSync',
273
+ },
274
+ {
275
+ target: 'kv_connected',
276
+ },
277
+ ],
278
+ },
279
+ kv_syncing: {
280
+ entry: [
281
+ ({ context }) => {
282
+ // either going to be 0 or 1 (if there were multiple syncs pending)
283
+ context.syncRequired = Math.min(context.syncRequired - 1, 1)
284
+ },
285
+ ],
286
+ invoke: {
287
+ id: 'single-instance-sync',
288
+ src: 'kvConsolidateState',
289
+ input: ({ context }: { context: Context }) => ({
290
+ kvm: context.cachedKvm!,
291
+ connection: context.cachedConnection!,
292
+ currentState: context.subscriptions,
293
+ targetState: context.subscriptionConfigs,
294
+ }),
295
+ onDone: {
296
+ target: 'kv_connected',
297
+ actions: [
298
+ () => {
299
+ // console.log('kvConsolidateState onDone')
300
+ },
301
+ assign(({ event }) => ({
302
+ subscriptions: event.output.subscriptions,
303
+ })),
304
+ ],
305
+ },
306
+ onError: {
307
+ target: 'kv_error',
308
+ actions: [
309
+ assign({
310
+ error: ({ event }) => {
311
+ console.error('kvConsolidateState onError', event.error)
312
+ return event.error as Error
313
+ },
314
+ }),
315
+ ],
316
+ },
317
+ },
318
+ },
319
+ kv_error: {
320
+ on: {
321
+ 'KV.CONNECT': {
322
+ target: 'kv_check_sync',
323
+ },
324
+ },
325
+ },
326
+ },
327
+ })
@@ -0,0 +1,329 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { createActor, fromPromise, createMachine, sendParent } from 'xstate'
3
+ import { natsMachine } from './root'
4
+
5
+ vi.mock('@nats-io/nats-core', () => ({
6
+ wsconnect: vi.fn(),
7
+ credsAuthenticator: vi.fn(),
8
+ }))
9
+
10
+ vi.mock('@nats-io/kv', () => ({
11
+ Kvm: vi.fn().mockImplementation(() => ({})),
12
+ }))
13
+
14
+ const mockSubjectMachine = createMachine({
15
+ initial: 'idle',
16
+ states: {
17
+ idle: {
18
+ on: {
19
+ 'SUBJECT.CONNECT': { target: 'connected' },
20
+ 'SUBJECT.DISCONNECTED': { target: 'idle' },
21
+ },
22
+ },
23
+ connected: {
24
+ entry: sendParent({ type: 'SUBJECT.CONNECTED' }),
25
+ on: {
26
+ 'SUBJECT.*': {},
27
+ 'SUBJECT.DISCONNECTED': { target: 'idle' },
28
+ },
29
+ },
30
+ },
31
+ })
32
+
33
+ const mockKvMachine = createMachine({
34
+ initial: 'idle',
35
+ states: {
36
+ idle: {
37
+ on: {
38
+ 'KV.CONNECT': { target: 'connected' },
39
+ 'KV.DISCONNECTED': { target: 'idle' },
40
+ },
41
+ },
42
+ connected: {
43
+ entry: sendParent({ type: 'KV.CONNECTED' }),
44
+ on: {
45
+ 'KV.*': {},
46
+ 'KV.DISCONNECTED': { target: 'idle' },
47
+ },
48
+ },
49
+ },
50
+ })
51
+
52
+ const mockConnection = {
53
+ drain: vi.fn().mockResolvedValue(undefined),
54
+ close: vi.fn().mockResolvedValue(undefined),
55
+ getServer: vi.fn().mockReturnValue('ws://localhost:4222'),
56
+ status: () => ({
57
+ [Symbol.asyncIterator]: () => ({
58
+ next: () => new Promise(() => {}),
59
+ }),
60
+ }),
61
+ } as any
62
+
63
+ function createTestMachine() {
64
+ return natsMachine.provide({
65
+ actors: {
66
+ connectToNats: fromPromise(async () => mockConnection),
67
+ disconnectNats: fromPromise(async () => {}),
68
+ subject: mockSubjectMachine,
69
+ kv: mockKvMachine,
70
+ },
71
+ })
72
+ }
73
+
74
+ function configureAndConnect(actor: any) {
75
+ actor.send({
76
+ type: 'CONFIGURE',
77
+ config: { opts: { servers: ['ws://localhost:4222'] }, maxRetries: 3 },
78
+ })
79
+ actor.send({ type: 'CONNECT' })
80
+ }
81
+
82
+ describe('natsMachine', () => {
83
+ beforeEach(() => {
84
+ vi.spyOn(console, 'log').mockImplementation(() => {})
85
+ vi.spyOn(console, 'error').mockImplementation(() => {})
86
+ })
87
+
88
+ it('should start in not_configured state', () => {
89
+ const actor = createActor(createTestMachine())
90
+ actor.start()
91
+ expect(actor.getSnapshot().value).toBe('not_configured')
92
+ actor.stop()
93
+ })
94
+
95
+ it('should transition to configured on CONFIGURE', () => {
96
+ const actor = createActor(createTestMachine())
97
+ actor.start()
98
+
99
+ actor.send({
100
+ type: 'CONFIGURE',
101
+ config: { opts: { servers: ['ws://localhost:4222'] }, maxRetries: 3 },
102
+ })
103
+
104
+ expect(actor.getSnapshot().value).toBe('configured')
105
+ expect(actor.getSnapshot().context.natsConfig).toEqual({
106
+ opts: { servers: ['ws://localhost:4222'] },
107
+ maxRetries: 3,
108
+ })
109
+ actor.stop()
110
+ })
111
+
112
+ it('should reach connected state after CONNECT', async () => {
113
+ const actor = createActor(createTestMachine())
114
+ actor.start()
115
+ configureAndConnect(actor)
116
+
117
+ await vi.waitFor(() => {
118
+ expect(actor.getSnapshot().value).toBe('connected')
119
+ })
120
+ expect(actor.getSnapshot().context.connection).toBe(mockConnection)
121
+ expect(actor.getSnapshot().context.subjectManagerReady).toBe(true)
122
+ expect(actor.getSnapshot().context.kvManagerReady).toBe(true)
123
+ actor.stop()
124
+ })
125
+
126
+ it('should transition to closed on DISCONNECT from connected', async () => {
127
+ const actor = createActor(createTestMachine())
128
+ actor.start()
129
+ configureAndConnect(actor)
130
+
131
+ await vi.waitFor(() => {
132
+ expect(actor.getSnapshot().value).toBe('connected')
133
+ })
134
+ actor.send({ type: 'DISCONNECT' })
135
+
136
+ await vi.waitFor(() => {
137
+ expect(actor.getSnapshot().value).toBe('closed')
138
+ })
139
+ actor.stop()
140
+ })
141
+
142
+ it('should transition to error when connection fails', async () => {
143
+ const failError = new Error('connection failed')
144
+ const failMachine = natsMachine.provide({
145
+ actors: {
146
+ connectToNats: fromPromise(async () => {
147
+ throw failError
148
+ }),
149
+ disconnectNats: fromPromise(async () => {}),
150
+ subject: mockSubjectMachine,
151
+ kv: mockKvMachine,
152
+ },
153
+ })
154
+
155
+ const actor = createActor(failMachine)
156
+ actor.start()
157
+ configureAndConnect(actor)
158
+
159
+ await vi.waitFor(() => {
160
+ expect(actor.getSnapshot().value).toBe('error')
161
+ })
162
+ expect(actor.getSnapshot().context.error).toBe(failError)
163
+ expect(actor.getSnapshot().context.retries).toBe(1)
164
+ actor.stop()
165
+ })
166
+
167
+ it('should reset from error state', async () => {
168
+ const failMachine = natsMachine.provide({
169
+ actors: {
170
+ connectToNats: fromPromise(async () => {
171
+ throw new Error('fail')
172
+ }),
173
+ disconnectNats: fromPromise(async () => {}),
174
+ subject: mockSubjectMachine,
175
+ kv: mockKvMachine,
176
+ },
177
+ })
178
+
179
+ const actor = createActor(failMachine)
180
+ actor.start()
181
+ configureAndConnect(actor)
182
+
183
+ await vi.waitFor(() => {
184
+ expect(actor.getSnapshot().value).toBe('error')
185
+ })
186
+
187
+ actor.send({ type: 'RESET' })
188
+ expect(actor.getSnapshot().value).toBe('not_configured')
189
+ expect(actor.getSnapshot().context.natsConfig).toBeUndefined()
190
+ expect(actor.getSnapshot().context.connection).toBeNull()
191
+ expect(actor.getSnapshot().context.retries).toBe(0)
192
+ actor.stop()
193
+ })
194
+
195
+ it('should reset from configured state', () => {
196
+ const actor = createActor(createTestMachine())
197
+ actor.start()
198
+
199
+ actor.send({
200
+ type: 'CONFIGURE',
201
+ config: { opts: { servers: ['ws://localhost:4222'] }, maxRetries: 3 },
202
+ })
203
+ actor.send({ type: 'RESET' })
204
+
205
+ expect(actor.getSnapshot().value).toBe('not_configured')
206
+ actor.stop()
207
+ })
208
+
209
+ it('should reconnect from closed state', async () => {
210
+ const actor = createActor(createTestMachine())
211
+ actor.start()
212
+ configureAndConnect(actor)
213
+
214
+ await vi.waitFor(() => {
215
+ expect(actor.getSnapshot().value).toBe('connected')
216
+ })
217
+ actor.send({ type: 'DISCONNECT' })
218
+ await vi.waitFor(() => {
219
+ expect(actor.getSnapshot().value).toBe('closed')
220
+ })
221
+
222
+ actor.send({ type: 'CONNECT' })
223
+ await vi.waitFor(() => {
224
+ expect(actor.getSnapshot().value).toBe('connected')
225
+ })
226
+ actor.stop()
227
+ })
228
+
229
+ it('should reset from closed state', async () => {
230
+ const actor = createActor(createTestMachine())
231
+ actor.start()
232
+ configureAndConnect(actor)
233
+
234
+ await vi.waitFor(() => {
235
+ expect(actor.getSnapshot().value).toBe('connected')
236
+ })
237
+ actor.send({ type: 'DISCONNECT' })
238
+ await vi.waitFor(() => {
239
+ expect(actor.getSnapshot().value).toBe('closed')
240
+ })
241
+ actor.send({ type: 'RESET' })
242
+
243
+ expect(actor.getSnapshot().value).toBe('not_configured')
244
+ actor.stop()
245
+ })
246
+
247
+ it('should transition to closed on CLOSE event from connected', async () => {
248
+ const actor = createActor(createTestMachine())
249
+ actor.start()
250
+ configureAndConnect(actor)
251
+
252
+ await vi.waitFor(() => {
253
+ expect(actor.getSnapshot().value).toBe('connected')
254
+ })
255
+ actor.send({ type: 'CLOSE' })
256
+
257
+ await vi.waitFor(() => {
258
+ expect(actor.getSnapshot().value).toBe('closed')
259
+ })
260
+ actor.stop()
261
+ })
262
+
263
+ it('should transition to error when disconnect fails', async () => {
264
+ const disconnectError = new Error('disconnect failed')
265
+ const failDisconnectMachine = natsMachine.provide({
266
+ actors: {
267
+ connectToNats: fromPromise(async () => mockConnection),
268
+ disconnectNats: fromPromise(async () => {
269
+ throw disconnectError
270
+ }),
271
+ subject: mockSubjectMachine,
272
+ kv: mockKvMachine,
273
+ },
274
+ })
275
+
276
+ const actor = createActor(failDisconnectMachine)
277
+ actor.start()
278
+ configureAndConnect(actor)
279
+
280
+ await vi.waitFor(() => {
281
+ expect(actor.getSnapshot().value).toBe('connected')
282
+ })
283
+ actor.send({ type: 'DISCONNECT' })
284
+
285
+ await vi.waitFor(() => {
286
+ expect(actor.getSnapshot().value).toBe('error')
287
+ })
288
+ expect(actor.getSnapshot().context.error).toBe(disconnectError)
289
+ actor.stop()
290
+ })
291
+
292
+ it('should forward SUBJECT.* events when connected', async () => {
293
+ const actor = createActor(createTestMachine())
294
+ actor.start()
295
+ configureAndConnect(actor)
296
+
297
+ await vi.waitFor(() => {
298
+ expect(actor.getSnapshot().value).toBe('connected')
299
+ })
300
+
301
+ expect(() => {
302
+ actor.send({
303
+ type: 'SUBJECT.SUBSCRIBE',
304
+ config: { subject: 'test', callback: vi.fn() },
305
+ } as any)
306
+ }).not.toThrow()
307
+
308
+ actor.stop()
309
+ })
310
+
311
+ it('should forward KV.* events when connected', async () => {
312
+ const actor = createActor(createTestMachine())
313
+ actor.start()
314
+ configureAndConnect(actor)
315
+
316
+ await vi.waitFor(() => {
317
+ expect(actor.getSnapshot().value).toBe('connected')
318
+ })
319
+
320
+ expect(() => {
321
+ actor.send({
322
+ type: 'KV.SUBSCRIBE',
323
+ config: { bucket: 'test', key: 'k', callback: vi.fn() },
324
+ } as any)
325
+ }).not.toThrow()
326
+
327
+ actor.stop()
328
+ })
329
+ })