|
| 1 | +import type { DevframeNodeContext, DevframeRpcClientFunctions } from 'devframe/types' |
| 2 | +import type { DevframeRpcClient, DevframeRpcContext, RpcClientEvents } from './rpc' |
| 3 | +import assert from 'node:assert/strict' |
| 4 | +import { RpcFunctionsCollectorBase } from 'devframe/rpc' |
| 5 | +import { createEventEmitter } from 'devframe/utils/events' |
| 6 | +import { createSharedState } from 'devframe/utils/shared-state' |
| 7 | +import { expect, it, vi } from 'vitest' |
| 8 | +import { RpcFunctionsHostImpl } from '../node/host-functions' |
| 9 | +import { createRpcSharedStateClientHost } from './rpc-shared-state' |
| 10 | + |
| 11 | +/** Delay only the transport writes; both endpoints retain their native shared-state logic. */ |
| 12 | +function connectSharedState() { |
| 13 | + const server = new RpcFunctionsHostImpl({} as DevframeNodeContext) |
| 14 | + const clientFunctions = new RpcFunctionsCollectorBase<DevframeRpcClientFunctions, DevframeRpcContext>({} as DevframeRpcContext) |
| 15 | + const pendingWrites: (() => Promise<void>)[] = [] |
| 16 | + const pendingBroadcasts: Promise<void>[] = [] |
| 17 | + const connection: Partial<DevframeRpcClient> = { |
| 18 | + connectionMeta: { backend: 'websocket' }, |
| 19 | + isTrusted: true, |
| 20 | + events: createEventEmitter<RpcClientEvents>(), |
| 21 | + client: clientFunctions, |
| 22 | + call: async (method, ...parameters) => { |
| 23 | + assert.equal(method, 'devframe:rpc:server-state:get') |
| 24 | + const key = parameters[0] |
| 25 | + assert.ok(typeof key === 'string') |
| 26 | + return await server.invokeLocal('devframe:rpc:server-state:get', key) |
| 27 | + }, |
| 28 | + callEvent: async (method, ...parameters) => { |
| 29 | + if (method === 'devframe:rpc:server-state:set') { |
| 30 | + pendingWrites.push(async () => { |
| 31 | + await Reflect.apply(server.invokeLocal, server, [method, ...parameters]) |
| 32 | + }) |
| 33 | + } |
| 34 | + }, |
| 35 | + } |
| 36 | + const broadcast = vi.spyOn(server, 'broadcast').mockImplementation((options) => { |
| 37 | + const delivery = clientFunctions.getHandler(options.method).then(handler => Reflect.apply(handler, undefined, options.args)) |
| 38 | + pendingBroadcasts.push(delivery) |
| 39 | + return delivery |
| 40 | + }) |
| 41 | + async function flushBroadcasts() { |
| 42 | + await Promise.all(pendingBroadcasts.splice(0)) |
| 43 | + } |
| 44 | + return { |
| 45 | + server, |
| 46 | + client: createRpcSharedStateClientHost(connection as DevframeRpcClient), |
| 47 | + dispose: () => broadcast.mockRestore(), |
| 48 | + flushBroadcasts, |
| 49 | + async flushWrites() { |
| 50 | + for (const write of pendingWrites.splice(0)) |
| 51 | + await write() |
| 52 | + await flushBroadcasts() |
| 53 | + }, |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +for (const { name, enablePatches } of [ |
| 58 | + { name: 'full snapshots', enablePatches: false }, |
| 59 | + { name: 'patches', enablePatches: true }, |
| 60 | +]) { |
| 61 | + it(`does not echo ${name} into a replacement state while preserving local and nested writes`, async () => { |
| 62 | + expect.assertions(9) |
| 63 | + const connection = connectSharedState() |
| 64 | + try { |
| 65 | + const original = await connection.server.sharedState.get('counter', { |
| 66 | + sharedState: createSharedState({ initialValue: { count: 0 }, enablePatches }), |
| 67 | + }) |
| 68 | + const initialClient = await connection.client.get<{ count: number }>('counter') |
| 69 | + expect(initialClient.value()).toEqual({ count: 0 }) |
| 70 | + original.mutate((draft) => { |
| 71 | + draft.count = 5 |
| 72 | + }) |
| 73 | + await connection.flushBroadcasts() |
| 74 | + expect(initialClient.value()).toEqual({ count: 5 }) |
| 75 | + |
| 76 | + connection.server.sharedState.delete('counter') |
| 77 | + connection.client.delete('counter') |
| 78 | + const replacement = await connection.server.sharedState.get('counter', { |
| 79 | + sharedState: createSharedState({ initialValue: { count: 6 }, enablePatches }), |
| 80 | + }) |
| 81 | + const client = await connection.client.get<{ count: number }>('counter') |
| 82 | + expect(replacement.value()).toEqual({ count: 6 }) |
| 83 | + await connection.flushWrites() |
| 84 | + expect(replacement.value()).toEqual({ count: 6 }) |
| 85 | + expect(client.value()).toEqual({ count: 6 }) |
| 86 | + |
| 87 | + client.mutate((draft) => { |
| 88 | + draft.count = 7 |
| 89 | + }) |
| 90 | + await connection.flushWrites() |
| 91 | + expect(replacement.value()).toEqual({ count: 7 }) |
| 92 | + expect(client.value()).toEqual({ count: 7 }) |
| 93 | + |
| 94 | + const stop = client.on('updated', (snapshot) => { |
| 95 | + if (snapshot.count !== 8) |
| 96 | + return |
| 97 | + stop() |
| 98 | + client.mutate((draft) => { |
| 99 | + draft.count = 9 |
| 100 | + }) |
| 101 | + }) |
| 102 | + replacement.mutate((draft) => { |
| 103 | + draft.count = 8 |
| 104 | + }) |
| 105 | + await connection.flushBroadcasts() |
| 106 | + expect(client.value()).toEqual({ count: 9 }) |
| 107 | + await connection.flushWrites() |
| 108 | + expect(replacement.value()).toEqual({ count: 9 }) |
| 109 | + } |
| 110 | + finally { |
| 111 | + connection.dispose() |
| 112 | + } |
| 113 | + }) |
| 114 | +} |
| 115 | + |
| 116 | +it('forwards a callback write to another state that reuses the received sync ID', async () => { |
| 117 | + expect.assertions(3) |
| 118 | + const connection = connectSharedState() |
| 119 | + try { |
| 120 | + const original = await connection.server.sharedState.get('source', { initialValue: { count: 0 } }) |
| 121 | + const derived = await connection.server.sharedState.get('derived', { initialValue: { count: 0 } }) |
| 122 | + const sourceClient = await connection.client.get<{ count: number }>('source') |
| 123 | + const derivedClient = await connection.client.get<{ count: number }>('derived') |
| 124 | + sourceClient.on('updated', (snapshot, _patches, syncId) => { |
| 125 | + derivedClient.mutate((draft) => { |
| 126 | + draft.count = snapshot.count |
| 127 | + }, syncId) |
| 128 | + }) |
| 129 | + original.mutate((draft) => { |
| 130 | + draft.count = 5 |
| 131 | + }) |
| 132 | + await connection.flushBroadcasts() |
| 133 | + expect(derivedClient.value()).toEqual({ count: 5 }) |
| 134 | + await connection.flushWrites() |
| 135 | + expect(derived.value()).toEqual({ count: 5 }) |
| 136 | + expect(original.value()).toEqual({ count: 5 }) |
| 137 | + } |
| 138 | + finally { |
| 139 | + connection.dispose() |
| 140 | + } |
| 141 | +}) |
0 commit comments