@carno.js/live 1.8.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/dist/LiveEngine.d.ts +89 -0
- package/dist/LiveEngine.js +520 -0
- package/dist/LivePlugin.d.ts +55 -0
- package/dist/LivePlugin.js +165 -0
- package/dist/LiveService.d.ts +32 -0
- package/dist/LiveService.js +51 -0
- package/dist/auth/authorizer.d.ts +33 -0
- package/dist/auth/authorizer.js +32 -0
- package/dist/bus/InProcessBus.d.ts +8 -0
- package/dist/bus/InProcessBus.js +29 -0
- package/dist/bus/InvalidationBus.d.ts +15 -0
- package/dist/bus/InvalidationBus.js +2 -0
- package/dist/bus/PgNotifyBus.d.ts +55 -0
- package/dist/bus/PgNotifyBus.js +138 -0
- package/dist/client/angular.d.ts +19 -0
- package/dist/client/angular.js +71 -0
- package/dist/client/core.d.ts +111 -0
- package/dist/client/core.js +337 -0
- package/dist/client/hydrate.d.ts +19 -0
- package/dist/client/hydrate.js +65 -0
- package/dist/client/optimistic.d.ts +17 -0
- package/dist/client/optimistic.js +2 -0
- package/dist/client/react.d.ts +22 -0
- package/dist/client/react.js +69 -0
- package/dist/client/transport.d.ts +133 -0
- package/dist/client/transport.js +431 -0
- package/dist/client/vanilla.d.ts +54 -0
- package/dist/client/vanilla.js +111 -0
- package/dist/client/vue.d.ts +12 -0
- package/dist/client/vue.js +35 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +21 -0
- package/dist/decorators/Live.d.ts +4 -0
- package/dist/decorators/Live.js +17 -0
- package/dist/emitters/AppEmitter.d.ts +20 -0
- package/dist/emitters/AppEmitter.js +59 -0
- package/dist/emitters/pg-listener.d.ts +50 -0
- package/dist/emitters/pg-listener.js +120 -0
- package/dist/emitters/pg-notify-emitter.d.ts +59 -0
- package/dist/emitters/pg-notify-emitter.js +105 -0
- package/dist/emitters/pg-trigger-sql.d.ts +27 -0
- package/dist/emitters/pg-trigger-sql.js +107 -0
- package/dist/emitters/statement-keys.d.ts +16 -0
- package/dist/emitters/statement-keys.js +182 -0
- package/dist/graph/DependencyGraph.d.ts +27 -0
- package/dist/graph/DependencyGraph.js +118 -0
- package/dist/graph/SubscriptionRegistry.d.ts +22 -0
- package/dist/graph/SubscriptionRegistry.js +92 -0
- package/dist/graph/dep-key.d.ts +14 -0
- package/dist/graph/dep-key.js +37 -0
- package/dist/graph/types.d.ts +13 -0
- package/dist/graph/types.js +2 -0
- package/dist/http/etag.d.ts +41 -0
- package/dist/http/etag.js +109 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +111 -0
- package/dist/metadata.d.ts +16 -0
- package/dist/metadata.js +4 -0
- package/dist/observability.d.ts +25 -0
- package/dist/observability.js +44 -0
- package/dist/patch/PatchEngine.d.ts +20 -0
- package/dist/patch/PatchEngine.js +180 -0
- package/dist/patch/types.d.ts +33 -0
- package/dist/patch/types.js +2 -0
- package/dist/resource/ResourceRegistry.d.ts +32 -0
- package/dist/resource/ResourceRegistry.js +126 -0
- package/dist/resource/dependency-context.d.ts +25 -0
- package/dist/resource/dependency-context.js +45 -0
- package/dist/resource/instance-id.d.ts +17 -0
- package/dist/resource/instance-id.js +61 -0
- package/dist/resource/prefetch.d.ts +20 -0
- package/dist/resource/prefetch.js +31 -0
- package/dist/resource/route-executor.d.ts +13 -0
- package/dist/resource/route-executor.js +86 -0
- package/dist/resource/types.d.ts +26 -0
- package/dist/resource/types.js +2 -0
- package/dist/runtime.d.ts +41 -0
- package/dist/runtime.js +47 -0
- package/dist/shared/canonical.d.ts +14 -0
- package/dist/shared/canonical.js +60 -0
- package/dist/shared/descriptor.d.ts +44 -0
- package/dist/shared/descriptor.js +19 -0
- package/dist/shared/hash.d.ts +12 -0
- package/dist/shared/hash.js +29 -0
- package/dist/shared/inputs.d.ts +19 -0
- package/dist/shared/inputs.js +2 -0
- package/dist/shared/protocol.d.ts +83 -0
- package/dist/shared/protocol.js +9 -0
- package/dist/transport/FanTransport.d.ts +18 -0
- package/dist/transport/FanTransport.js +29 -0
- package/dist/transport/LiveGateway.d.ts +16 -0
- package/dist/transport/LiveGateway.js +126 -0
- package/dist/transport/SocketTransport.d.ts +18 -0
- package/dist/transport/SocketTransport.js +40 -0
- package/dist/transport/SseTransport.d.ts +30 -0
- package/dist/transport/SseTransport.js +92 -0
- package/dist/transport/scope-resolver.d.ts +20 -0
- package/dist/transport/scope-resolver.js +17 -0
- package/dist/transport/sse-routes.d.ts +24 -0
- package/dist/transport/sse-routes.js +78 -0
- package/package.json +92 -0
- package/src/LiveEngine.ts +730 -0
- package/src/LivePlugin.ts +253 -0
- package/src/LiveService.ts +47 -0
- package/src/auth/authorizer.ts +56 -0
- package/src/bus/InProcessBus.ts +28 -0
- package/src/bus/InvalidationBus.ts +17 -0
- package/src/bus/PgNotifyBus.ts +188 -0
- package/src/client/angular.ts +115 -0
- package/src/client/core.ts +492 -0
- package/src/client/hydrate.ts +80 -0
- package/src/client/optimistic.ts +19 -0
- package/src/client/react.ts +113 -0
- package/src/client/transport.ts +568 -0
- package/src/client/vanilla.ts +165 -0
- package/src/client/vue.ts +67 -0
- package/src/config.ts +49 -0
- package/src/decorators/Live.ts +16 -0
- package/src/emitters/AppEmitter.ts +69 -0
- package/src/emitters/pg-listener.ts +171 -0
- package/src/emitters/pg-notify-emitter.ts +143 -0
- package/src/emitters/pg-trigger-sql.ts +111 -0
- package/src/emitters/statement-keys.ts +229 -0
- package/src/graph/DependencyGraph.ts +147 -0
- package/src/graph/SubscriptionRegistry.ts +109 -0
- package/src/graph/dep-key.ts +41 -0
- package/src/graph/types.ts +15 -0
- package/src/http/etag.ts +144 -0
- package/src/index.ts +81 -0
- package/src/metadata.ts +21 -0
- package/src/observability.ts +54 -0
- package/src/patch/PatchEngine.ts +235 -0
- package/src/patch/types.ts +39 -0
- package/src/resource/ResourceRegistry.ts +178 -0
- package/src/resource/dependency-context.ts +48 -0
- package/src/resource/instance-id.ts +65 -0
- package/src/resource/prefetch.ts +49 -0
- package/src/resource/route-executor.ts +134 -0
- package/src/resource/types.ts +35 -0
- package/src/runtime.ts +69 -0
- package/src/shared/canonical.ts +63 -0
- package/src/shared/descriptor.ts +44 -0
- package/src/shared/hash.ts +31 -0
- package/src/shared/inputs.ts +20 -0
- package/src/shared/protocol.ts +96 -0
- package/src/transport/FanTransport.ts +34 -0
- package/src/transport/LiveGateway.ts +124 -0
- package/src/transport/SocketTransport.ts +43 -0
- package/src/transport/SseTransport.ts +112 -0
- package/src/transport/scope-resolver.ts +25 -0
- package/src/transport/sse-routes.ts +94 -0
- package/test/acceptance-fase-2.test.ts +248 -0
- package/test/acceptance-fase-3.test.ts +216 -0
- package/test/acceptance.test.ts +306 -0
- package/test/angular-adapter.test.ts +151 -0
- package/test/app-emitter.test.ts +112 -0
- package/test/authorization.test.ts +175 -0
- package/test/client-core.test.ts +287 -0
- package/test/client-transport.test.ts +154 -0
- package/test/dependency-graph.test.ts +83 -0
- package/test/etag.test.ts +193 -0
- package/test/fan-transport.test.ts +62 -0
- package/test/happydom.ts +24 -0
- package/test/inputs-body.test.ts +81 -0
- package/test/instance-id.test.ts +104 -0
- package/test/live-engine.test.ts +593 -0
- package/test/live-post.test.ts +81 -0
- package/test/metrics.test.ts +131 -0
- package/test/optimistic.test.ts +166 -0
- package/test/orm-integration.test.ts +75 -0
- package/test/patch-engine.test.ts +121 -0
- package/test/pg-bus.test.ts +162 -0
- package/test/pg-listen-probe.test.ts +56 -0
- package/test/pg-listener.test.ts +134 -0
- package/test/pg-notify-emitter.test.ts +113 -0
- package/test/pg-notify-integration.test.ts +88 -0
- package/test/polling-auth.test.ts +169 -0
- package/test/prefetch.test.ts +137 -0
- package/test/react-adapter.test.tsx +83 -0
- package/test/react-rerender.test.tsx +131 -0
- package/test/resource-registry-helper.ts +35 -0
- package/test/resource-registry.test.ts +144 -0
- package/test/route-pipeline.test.ts +139 -0
- package/test/shared.test.ts +53 -0
- package/test/sse-routes.test.ts +204 -0
- package/test/sse-transport.test.ts +91 -0
- package/test/statement-keys.test.ts +126 -0
- package/test/subscription-registry.test.ts +61 -0
- package/test/transport-ladder.test.ts +293 -0
- package/test/transport.test.ts +274 -0
- package/test/types/optimistic-types.ts +34 -0
- package/test/use-live-action.test.ts +127 -0
- package/test/use-live.test.ts +109 -0
- package/test/vanilla-adapter.test.ts +200 -0
- package/test/vue-adapter.test.ts +122 -0
- package/tsconfig.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsconfig.types.json +10 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { createElement } from 'react';
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
4
|
+
import { LiveClient, storeKey, type LiveSocket } from '../src/client/core';
|
|
5
|
+
import { LiveProvider, useLiveAction } from '../src/client/react';
|
|
6
|
+
import type { LiveDescriptor } from '../src/shared/descriptor';
|
|
7
|
+
|
|
8
|
+
interface Card {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const listDescriptor = {
|
|
14
|
+
method: 'get',
|
|
15
|
+
path: '/cards',
|
|
16
|
+
resourceId: 'BoardController.list',
|
|
17
|
+
live: { shared: 'public', key: 'id' }
|
|
18
|
+
} as LiveDescriptor<{ response: Card[] }>;
|
|
19
|
+
|
|
20
|
+
class SilentSocket implements LiveSocket {
|
|
21
|
+
onopen: (() => void) | null = null;
|
|
22
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
23
|
+
onclose: (() => void) | null = null;
|
|
24
|
+
onerror: ((error: unknown) => void) | null = null;
|
|
25
|
+
send(): void {}
|
|
26
|
+
close(): void {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clientWithBoard() {
|
|
30
|
+
const inputs = { params: {}, query: {}, body: undefined };
|
|
31
|
+
const client = new LiveClient({
|
|
32
|
+
url: 'ws://test/live',
|
|
33
|
+
socketFactory: () => new SilentSocket(),
|
|
34
|
+
hydrate: {
|
|
35
|
+
[storeKey('BoardController.list', inputs)]: {
|
|
36
|
+
data: [{ id: '1', title: 'Ada' }],
|
|
37
|
+
hash: 'h1'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const store = client.store<Card[]>('BoardController.list', inputs);
|
|
43
|
+
store.subscribe(() => {});
|
|
44
|
+
|
|
45
|
+
return { client, store };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Render once so the hook runs, and hand the produced action back out. */
|
|
49
|
+
function actionFrom(client: LiveClient, build: () => (dto: any) => Promise<any>) {
|
|
50
|
+
let captured: ((dto: any) => Promise<any>) | null = null;
|
|
51
|
+
|
|
52
|
+
function Probe() {
|
|
53
|
+
captured = build();
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
renderToStaticMarkup(createElement(LiveProvider, { client }, createElement(Probe)));
|
|
58
|
+
|
|
59
|
+
return captured!;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('useLiveAction', () => {
|
|
63
|
+
test('applies the overlay while the action is in flight and drops it after', async () => {
|
|
64
|
+
const { client, store } = clientWithBoard();
|
|
65
|
+
const seen: unknown[] = [];
|
|
66
|
+
let release: (() => void) | null = null;
|
|
67
|
+
|
|
68
|
+
const send = actionFrom(client, () =>
|
|
69
|
+
useLiveAction(
|
|
70
|
+
(dto: { title: string }) =>
|
|
71
|
+
new Promise<Card>(resolve => {
|
|
72
|
+
release = () => resolve({ id: '2', title: dto.title });
|
|
73
|
+
}),
|
|
74
|
+
{
|
|
75
|
+
optimistic: [{
|
|
76
|
+
on: listDescriptor,
|
|
77
|
+
apply: (draft, dto) => {
|
|
78
|
+
draft.push({ id: 'temp', title: dto.title });
|
|
79
|
+
}
|
|
80
|
+
}]
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const pending = send({ title: 'Linus' });
|
|
86
|
+
seen.push(store.getSnapshot().data);
|
|
87
|
+
|
|
88
|
+
release!();
|
|
89
|
+
await pending;
|
|
90
|
+
seen.push(store.getSnapshot().data);
|
|
91
|
+
|
|
92
|
+
expect(seen[0]).toEqual([{ id: '1', title: 'Ada' }, { id: 'temp', title: 'Linus' }]);
|
|
93
|
+
expect(seen[1]).toEqual([{ id: '1', title: 'Ada' }]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('drops the overlay when the action fails', async () => {
|
|
97
|
+
const { client, store } = clientWithBoard();
|
|
98
|
+
|
|
99
|
+
const send = actionFrom(client, () =>
|
|
100
|
+
useLiveAction(
|
|
101
|
+
async () => {
|
|
102
|
+
throw new Error('rejected by the server');
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
optimistic: [{
|
|
106
|
+
on: listDescriptor,
|
|
107
|
+
apply: draft => {
|
|
108
|
+
draft.push({ id: 'temp', title: 'never' });
|
|
109
|
+
}
|
|
110
|
+
}]
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
await expect(send({})).rejects.toThrow('rejected by the server');
|
|
116
|
+
expect(store.getSnapshot().data).toEqual([{ id: '1', title: 'Ada' }]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('an action with no optimistic entry just runs', async () => {
|
|
120
|
+
const { client, store } = clientWithBoard();
|
|
121
|
+
|
|
122
|
+
const send = actionFrom(client, () => useLiveAction(async (dto: { title: string }) => dto.title));
|
|
123
|
+
|
|
124
|
+
await expect(send({ title: 'plain' })).resolves.toBe('plain');
|
|
125
|
+
expect(store.getSnapshot().data).toEqual([{ id: '1', title: 'Ada' }]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { createElement } from 'react';
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
4
|
+
import { LiveClient, storeKey, type LiveSocket } from '../src/client/core';
|
|
5
|
+
import { LiveProvider, useLive } from '../src/client/react';
|
|
6
|
+
import {
|
|
7
|
+
normalizeLiveInputs,
|
|
8
|
+
resourceIdOf,
|
|
9
|
+
type LiveDescriptor
|
|
10
|
+
} from '../src/shared/descriptor';
|
|
11
|
+
|
|
12
|
+
interface Card {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const listDescriptor = {
|
|
18
|
+
method: 'get',
|
|
19
|
+
path: '/cards',
|
|
20
|
+
resourceId: 'BoardController.list',
|
|
21
|
+
live: { shared: 'tenant', key: 'id' }
|
|
22
|
+
} as LiveDescriptor<{ query: { status?: string }; response: Card[] }>;
|
|
23
|
+
|
|
24
|
+
const plainDescriptor = {
|
|
25
|
+
method: 'post',
|
|
26
|
+
path: '/cards'
|
|
27
|
+
} as LiveDescriptor<{ body: { title: string }; response: Card }>;
|
|
28
|
+
|
|
29
|
+
class SilentSocket implements LiveSocket {
|
|
30
|
+
onopen: (() => void) | null = null;
|
|
31
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
32
|
+
onclose: (() => void) | null = null;
|
|
33
|
+
onerror: ((error: unknown) => void) | null = null;
|
|
34
|
+
send(): void {}
|
|
35
|
+
close(): void {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('resourceIdOf', () => {
|
|
39
|
+
test('returns the id of a live route', () => {
|
|
40
|
+
expect(resourceIdOf(listDescriptor)).toBe('BoardController.list');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('explains itself when the route is not live', () => {
|
|
44
|
+
expect(() => resourceIdOf(plainDescriptor)).toThrow(/not a live resource/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('normalizeLiveInputs', () => {
|
|
49
|
+
test('fills the three slots', () => {
|
|
50
|
+
expect(normalizeLiveInputs({ query: { status: 'open' } })).toEqual({
|
|
51
|
+
params: {},
|
|
52
|
+
query: { status: 'open' },
|
|
53
|
+
body: undefined
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('accepts nothing at all', () => {
|
|
58
|
+
expect(normalizeLiveInputs()).toEqual({ params: {}, query: {}, body: undefined });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('useLive with a descriptor', () => {
|
|
63
|
+
test('reads the hydrated store for that resource and inputs', () => {
|
|
64
|
+
const inputs = { params: {}, query: { status: 'open' }, body: undefined };
|
|
65
|
+
const client = new LiveClient({
|
|
66
|
+
url: 'ws://test/live',
|
|
67
|
+
socketFactory: () => new SilentSocket(),
|
|
68
|
+
hydrate: {
|
|
69
|
+
[storeKey('BoardController.list', inputs)]: {
|
|
70
|
+
data: [{ id: '1', title: 'Ada' }],
|
|
71
|
+
hash: 'h1'
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function Board() {
|
|
77
|
+
const state = useLive(listDescriptor, { query: { status: 'open' } });
|
|
78
|
+
return createElement('div', null, JSON.stringify(state.data ?? null));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const html = renderToStaticMarkup(
|
|
82
|
+
createElement(LiveProvider, { client }, createElement(Board))
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(html).toContain('Ada');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('the string form from phase 1 still works', () => {
|
|
89
|
+
const inputs = { params: {}, query: {}, body: undefined };
|
|
90
|
+
const client = new LiveClient({
|
|
91
|
+
url: 'ws://test/live',
|
|
92
|
+
socketFactory: () => new SilentSocket(),
|
|
93
|
+
hydrate: {
|
|
94
|
+
[storeKey('BoardController.list', inputs)]: { data: [{ id: '9' }], hash: 'h9' }
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function Board() {
|
|
99
|
+
const state = useLive<{ id: string }[]>('BoardController.list');
|
|
100
|
+
return createElement('div', null, JSON.stringify(state.data ?? null));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const html = renderToStaticMarkup(
|
|
104
|
+
createElement(LiveProvider, { client }, createElement(Board))
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(html).toContain('"9"');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { LiveClient, type LiveSocket } from '../src/client/core';
|
|
3
|
+
import { LiveSlot, liveStore, liveStoreOf } from '../src/client/vanilla';
|
|
4
|
+
import type { LiveDescriptor } from '../src/shared/descriptor';
|
|
5
|
+
|
|
6
|
+
function fakeSocket(): LiveSocket & { sent: string[]; deliver: (message: unknown) => void } {
|
|
7
|
+
const socket = {
|
|
8
|
+
sent: [] as string[],
|
|
9
|
+
send(data: string) { socket.sent.push(data); },
|
|
10
|
+
close() {},
|
|
11
|
+
onopen: null as (() => void) | null,
|
|
12
|
+
onmessage: null as ((event: { data: string }) => void) | null,
|
|
13
|
+
onclose: null as (() => void) | null,
|
|
14
|
+
onerror: null as ((error: unknown) => void) | null,
|
|
15
|
+
deliver(message: unknown) { socket.onmessage?.({ data: JSON.stringify(message) }); }
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return socket;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface CardsRoute {
|
|
22
|
+
response: { id: number; title: string }[];
|
|
23
|
+
query: { done?: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const cardsList: LiveDescriptor<CardsRoute> = {
|
|
27
|
+
method: 'get',
|
|
28
|
+
path: '/cards',
|
|
29
|
+
resourceId: 'CardsController.list',
|
|
30
|
+
live: { shared: 'public', key: 'id' }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('liveStore', () => {
|
|
34
|
+
test('subscribes on the first listener and reports the snapshot', () => {
|
|
35
|
+
const socket = fakeSocket();
|
|
36
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
37
|
+
const handle = liveStore(client, cardsList, { query: { done: 'false' } });
|
|
38
|
+
const seen: unknown[] = [];
|
|
39
|
+
|
|
40
|
+
handle.subscribe(state => seen.push(state.data));
|
|
41
|
+
socket.onopen?.();
|
|
42
|
+
|
|
43
|
+
const sub = JSON.parse(socket.sent.find(raw => raw.includes('"t":"sub"'))!);
|
|
44
|
+
expect(sub.resource).toBe('CardsController.list');
|
|
45
|
+
expect(sub.inputs.query).toEqual({ done: 'false' });
|
|
46
|
+
|
|
47
|
+
socket.deliver({ t: 'snapshot', sid: sub.sid, rev: 1, hash: 'h1', data: [{ id: 1, title: 'a' }] });
|
|
48
|
+
|
|
49
|
+
expect(handle.get().data).toEqual([{ id: 1, title: 'a' }]);
|
|
50
|
+
expect(seen.at(-1)).toEqual([{ id: 1, title: 'a' }]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('get() works before anyone subscribes', () => {
|
|
54
|
+
const socket = fakeSocket();
|
|
55
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
56
|
+
const handle = liveStore(client, cardsList);
|
|
57
|
+
|
|
58
|
+
expect(handle.get()).toEqual({ data: undefined, pending: true, error: null, stale: false });
|
|
59
|
+
expect(socket.sent).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('close() drops the listener without touching the others', () => {
|
|
63
|
+
const socket = fakeSocket();
|
|
64
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
65
|
+
const first = liveStore(client, cardsList);
|
|
66
|
+
const second = liveStore(client, cardsList);
|
|
67
|
+
let firstSaw = 0;
|
|
68
|
+
let secondSaw = 0;
|
|
69
|
+
|
|
70
|
+
first.subscribe(() => { firstSaw += 1; });
|
|
71
|
+
second.subscribe(() => { secondSaw += 1; });
|
|
72
|
+
socket.onopen?.();
|
|
73
|
+
|
|
74
|
+
const sub = JSON.parse(socket.sent.find(raw => raw.includes('"t":"sub"'))!);
|
|
75
|
+
first.close();
|
|
76
|
+
socket.deliver({ t: 'snapshot', sid: sub.sid, rev: 1, hash: 'h1', data: [] });
|
|
77
|
+
|
|
78
|
+
expect(firstSaw).toBe(0);
|
|
79
|
+
expect(secondSaw).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('two handles for the same resource and inputs share one subscription', () => {
|
|
83
|
+
const socket = fakeSocket();
|
|
84
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
85
|
+
|
|
86
|
+
liveStore(client, cardsList, { query: { done: 'true' } }).subscribe(() => {});
|
|
87
|
+
liveStore(client, cardsList, { query: { done: 'true' } }).subscribe(() => {});
|
|
88
|
+
socket.onopen?.();
|
|
89
|
+
|
|
90
|
+
expect(socket.sent.filter(raw => raw.includes('"t":"sub"')).length).toBe(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a plain resource id works without a descriptor', () => {
|
|
94
|
+
const socket = fakeSocket();
|
|
95
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
96
|
+
|
|
97
|
+
liveStore<number[]>(client, 'CardsController.list').subscribe(() => {});
|
|
98
|
+
socket.onopen?.();
|
|
99
|
+
|
|
100
|
+
expect(JSON.parse(socket.sent[1]).resource).toBe('CardsController.list');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a descriptor without @Live() is refused with a message that says what to do', () => {
|
|
104
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => fakeSocket() });
|
|
105
|
+
const plain: LiveDescriptor<CardsRoute> = { method: 'get', path: '/cards' };
|
|
106
|
+
|
|
107
|
+
expect(() => liveStore(client, plain)).toThrow(/not a live resource/);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('liveStoreOf', () => {
|
|
112
|
+
test('resolves a descriptor to the same store a resource id resolves to', () => {
|
|
113
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => fakeSocket() });
|
|
114
|
+
|
|
115
|
+
const fromDescriptor = liveStoreOf(client, cardsList, {});
|
|
116
|
+
const fromId = liveStoreOf(client, 'CardsController.list', {});
|
|
117
|
+
|
|
118
|
+
expect(fromDescriptor).toBe(fromId);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('LiveSlot', () => {
|
|
123
|
+
test('reports the state of whatever it currently points at', () => {
|
|
124
|
+
const socket = fakeSocket();
|
|
125
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
126
|
+
const seen: unknown[] = [];
|
|
127
|
+
const slot = new LiveSlot<{ id: number }[]>(client, state => seen.push(state.data));
|
|
128
|
+
|
|
129
|
+
slot.point(cardsList, { query: { done: 'false' } });
|
|
130
|
+
socket.onopen?.();
|
|
131
|
+
|
|
132
|
+
const sub = JSON.parse(socket.sent.find(raw => raw.includes('"t":"sub"'))!);
|
|
133
|
+
socket.deliver({ t: 'snapshot', sid: sub.sid, rev: 1, hash: 'h1', data: [{ id: 1 }] });
|
|
134
|
+
|
|
135
|
+
expect(slot.get().data).toEqual([{ id: 1 }]);
|
|
136
|
+
expect(seen.at(-1)).toEqual([{ id: 1 }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('pointing at the same inputs again is a no-op, not a resubscribe', () => {
|
|
140
|
+
const socket = fakeSocket();
|
|
141
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
142
|
+
const slot = new LiveSlot(client, () => {});
|
|
143
|
+
|
|
144
|
+
slot.point(cardsList, { query: { done: 'false' } });
|
|
145
|
+
socket.onopen?.();
|
|
146
|
+
const afterFirst = socket.sent.length;
|
|
147
|
+
|
|
148
|
+
// A reactive input that recomputed to the same value must not churn
|
|
149
|
+
// the subscription: the server would drop and rebuild the instance.
|
|
150
|
+
slot.point(cardsList, { query: { done: 'false' } });
|
|
151
|
+
|
|
152
|
+
expect(socket.sent.length).toBe(afterFirst);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('pointing somewhere new releases the old target before retaining the new one', () => {
|
|
156
|
+
const socket = fakeSocket();
|
|
157
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket, unsubGraceMs: 0 });
|
|
158
|
+
const slot = new LiveSlot(client, () => {});
|
|
159
|
+
|
|
160
|
+
slot.point(cardsList, { query: { done: 'false' } });
|
|
161
|
+
socket.onopen?.();
|
|
162
|
+
slot.point(cardsList, { query: { done: 'true' } });
|
|
163
|
+
|
|
164
|
+
// Order matters: holding both at once is how a dragged filter walks a
|
|
165
|
+
// connection into maxInstancesPerConnection.
|
|
166
|
+
const kinds = socket.sent.map(raw => JSON.parse(raw).t);
|
|
167
|
+
expect(kinds.filter(kind => kind === 'sub').length).toBe(2);
|
|
168
|
+
expect(kinds.indexOf('sub')).toBeLessThan(kinds.lastIndexOf('sub'));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('a state message for the old target after a switch is ignored', () => {
|
|
172
|
+
const socket = fakeSocket();
|
|
173
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
174
|
+
const seen: unknown[] = [];
|
|
175
|
+
const slot = new LiveSlot<{ id: number }[]>(client, state => seen.push(state.data));
|
|
176
|
+
|
|
177
|
+
slot.point(cardsList, { query: { done: 'false' } });
|
|
178
|
+
socket.onopen?.();
|
|
179
|
+
const first = JSON.parse(socket.sent.find(raw => raw.includes('"t":"sub"'))!);
|
|
180
|
+
|
|
181
|
+
slot.point(cardsList, { query: { done: 'true' } });
|
|
182
|
+
socket.deliver({ t: 'snapshot', sid: first.sid, rev: 1, hash: 'h1', data: [{ id: 9 }] });
|
|
183
|
+
|
|
184
|
+
expect(seen.some(data => JSON.stringify(data) === JSON.stringify([{ id: 9 }]))).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('close() releases whatever it was pointing at', async () => {
|
|
188
|
+
const socket = fakeSocket();
|
|
189
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket, unsubGraceMs: 0 });
|
|
190
|
+
const slot = new LiveSlot(client, () => {});
|
|
191
|
+
|
|
192
|
+
slot.point(cardsList);
|
|
193
|
+
socket.onopen?.();
|
|
194
|
+
slot.close();
|
|
195
|
+
|
|
196
|
+
// LiveClient always schedules unsub through setTimeout, even at 0ms.
|
|
197
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
198
|
+
expect(socket.sent.some(raw => raw.includes('"t":"unsub"'))).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { effectScope, nextTick, ref } from 'vue';
|
|
3
|
+
import { LiveClient, type LiveSocket } from '../src/client/core';
|
|
4
|
+
import { useLiveQuery } from '../src/client/vue';
|
|
5
|
+
import type { LiveDescriptor } from '../src/shared/descriptor';
|
|
6
|
+
|
|
7
|
+
function fakeSocket(): LiveSocket & { sent: string[]; deliver: (message: unknown) => void } {
|
|
8
|
+
const socket = {
|
|
9
|
+
sent: [] as string[],
|
|
10
|
+
send(data: string) { socket.sent.push(data); },
|
|
11
|
+
close() {},
|
|
12
|
+
onopen: null as (() => void) | null,
|
|
13
|
+
onmessage: null as ((event: { data: string }) => void) | null,
|
|
14
|
+
onclose: null as (() => void) | null,
|
|
15
|
+
onerror: null as ((error: unknown) => void) | null,
|
|
16
|
+
deliver(message: unknown) { socket.onmessage?.({ data: JSON.stringify(message) }); }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return socket;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CardsRoute {
|
|
23
|
+
response: { id: number; title: string }[];
|
|
24
|
+
query: { done?: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cardsList: LiveDescriptor<CardsRoute> = {
|
|
28
|
+
method: 'get',
|
|
29
|
+
path: '/cards',
|
|
30
|
+
resourceId: 'CardsController.list',
|
|
31
|
+
live: { shared: 'public', key: 'id' }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function harness() {
|
|
35
|
+
const socket = fakeSocket();
|
|
36
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket, unsubGraceMs: 0 });
|
|
37
|
+
const scope = effectScope();
|
|
38
|
+
|
|
39
|
+
const lastSub = () => {
|
|
40
|
+
const raw = [...socket.sent].reverse().find(entry => entry.includes('"t":"sub"'));
|
|
41
|
+
return raw ? JSON.parse(raw) : null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return { socket, client, scope, lastSub };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('useLiveQuery', () => {
|
|
48
|
+
test('starts pending and fills in from a snapshot', () => {
|
|
49
|
+
const { socket, client, scope, lastSub } = harness();
|
|
50
|
+
const state = scope.run(() => useLiveQuery(cardsList, undefined, { client }))!;
|
|
51
|
+
|
|
52
|
+
expect(state.value.pending).toBe(true);
|
|
53
|
+
|
|
54
|
+
socket.onopen?.();
|
|
55
|
+
socket.deliver({ t: 'snapshot', sid: lastSub().sid, rev: 1, hash: 'h1', data: [{ id: 1, title: 'a' }] });
|
|
56
|
+
|
|
57
|
+
expect(state.value.data).toEqual([{ id: 1, title: 'a' }]);
|
|
58
|
+
expect(state.value.pending).toBe(false);
|
|
59
|
+
scope.stop();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('changing a reactive input resubscribes', async () => {
|
|
63
|
+
const { socket, client, scope, lastSub } = harness();
|
|
64
|
+
const done = ref('false');
|
|
65
|
+
|
|
66
|
+
scope.run(() => useLiveQuery(cardsList, () => ({ query: { done: done.value } }), { client }));
|
|
67
|
+
socket.onopen?.();
|
|
68
|
+
|
|
69
|
+
expect(lastSub().inputs.query).toEqual({ done: 'false' });
|
|
70
|
+
|
|
71
|
+
done.value = 'true';
|
|
72
|
+
await nextTick();
|
|
73
|
+
// LiveClient always schedules unsub through setTimeout, even at 0ms.
|
|
74
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
75
|
+
|
|
76
|
+
expect(lastSub().inputs.query).toEqual({ done: 'true' });
|
|
77
|
+
expect(socket.sent.filter(raw => raw.includes('"t":"unsub"')).length).toBe(1);
|
|
78
|
+
scope.stop();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a reactive input that recomputes to the same value does not resubscribe', async () => {
|
|
82
|
+
const { socket, client, scope } = harness();
|
|
83
|
+
const unrelated = ref(0);
|
|
84
|
+
|
|
85
|
+
scope.run(() => useLiveQuery(cardsList, () => {
|
|
86
|
+
void unrelated.value;
|
|
87
|
+
return { query: { done: 'false' } };
|
|
88
|
+
}, { client }));
|
|
89
|
+
socket.onopen?.();
|
|
90
|
+
const before = socket.sent.length;
|
|
91
|
+
|
|
92
|
+
unrelated.value = 1;
|
|
93
|
+
await nextTick();
|
|
94
|
+
|
|
95
|
+
expect(socket.sent.length).toBe(before);
|
|
96
|
+
scope.stop();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('stopping the scope unsubscribes', async () => {
|
|
100
|
+
const { socket, client, scope } = harness();
|
|
101
|
+
|
|
102
|
+
scope.run(() => useLiveQuery(cardsList, undefined, { client }));
|
|
103
|
+
socket.onopen?.();
|
|
104
|
+
scope.stop();
|
|
105
|
+
|
|
106
|
+
// LiveClient always schedules unsub through setTimeout, even at 0ms.
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
108
|
+
expect(socket.sent.some(raw => raw.includes('"t":"unsub"'))).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the ref is shallow: the state object is replaced, never mutated', () => {
|
|
112
|
+
const { socket, client, scope, lastSub } = harness();
|
|
113
|
+
const state = scope.run(() => useLiveQuery(cardsList, undefined, { client }))!;
|
|
114
|
+
const first = state.value;
|
|
115
|
+
|
|
116
|
+
socket.onopen?.();
|
|
117
|
+
socket.deliver({ t: 'snapshot', sid: lastSub().sid, rev: 1, hash: 'h1', data: [] });
|
|
118
|
+
|
|
119
|
+
expect(state.value).not.toBe(first);
|
|
120
|
+
scope.stop();
|
|
121
|
+
});
|
|
122
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"include": ["src/**/*"],
|
|
4
|
+
"exclude": ["node_modules", "dist", "test"],
|
|
5
|
+
"references": [
|
|
6
|
+
{ "path": "../core" },
|
|
7
|
+
{ "path": "../orm" },
|
|
8
|
+
{ "path": "../websocket" }
|
|
9
|
+
],
|
|
10
|
+
"compilerOptions": {
|
|
11
|
+
"outDir": "./dist",
|
|
12
|
+
"rootDir": "./src",
|
|
13
|
+
"composite": true
|
|
14
|
+
}
|
|
15
|
+
}
|