@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,131 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { LiveMetrics, type MetricSink } from '../src/observability';
|
|
3
|
+
|
|
4
|
+
function recorder(): MetricSink & { seen: { name: string; value: number; tags?: Record<string, any> }[] } {
|
|
5
|
+
const seen: { name: string; value: number; tags?: Record<string, any> }[] = [];
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
seen,
|
|
9
|
+
onMetric(name, value, tags) { seen.push({ name, value, tags }); }
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('LiveMetrics', () => {
|
|
14
|
+
test('a recompute that produced a patch publishes the count, the duration and the size', () => {
|
|
15
|
+
const sink = recorder();
|
|
16
|
+
|
|
17
|
+
new LiveMetrics(sink).recompute('CardsController.list', true, 3, 12.5);
|
|
18
|
+
|
|
19
|
+
expect(sink.seen).toEqual([
|
|
20
|
+
{ name: 'live.recompute', value: 1, tags: { resource: 'CardsController.list', patched: true } },
|
|
21
|
+
{ name: 'live.recompute.ms', value: 12.5, tags: { resource: 'CardsController.list' } },
|
|
22
|
+
{ name: 'live.patch.ops', value: 3, tags: { resource: 'CardsController.list' } }
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('a recompute that produced no patch is counted, and publishes no patch size', () => {
|
|
27
|
+
const sink = recorder();
|
|
28
|
+
|
|
29
|
+
new LiveMetrics(sink).recompute('CardsController.list', false, 0, 4);
|
|
30
|
+
|
|
31
|
+
expect(sink.seen.map(entry => entry.name)).toEqual(['live.recompute', 'live.recompute.ms']);
|
|
32
|
+
expect(sink.seen[0].tags).toEqual({ resource: 'CardsController.list', patched: false });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('an invalidation publishes how many keys arrived and how many instances woke', () => {
|
|
36
|
+
const sink = recorder();
|
|
37
|
+
|
|
38
|
+
new LiveMetrics(sink).invalidation(2, 17);
|
|
39
|
+
|
|
40
|
+
expect(sink.seen).toEqual([
|
|
41
|
+
{ name: 'live.invalidation.keys', value: 2, tags: undefined },
|
|
42
|
+
{ name: 'live.invalidation.fanout', value: 17, tags: undefined }
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('an invalidation that woke nothing still publishes the fan-out', () => {
|
|
47
|
+
const sink = recorder();
|
|
48
|
+
|
|
49
|
+
// Zero fan-out is the healthy case and the interesting one: it is what
|
|
50
|
+
// a precise graph looks like. Dropping it would bias the average up.
|
|
51
|
+
new LiveMetrics(sink).invalidation(1, 0);
|
|
52
|
+
|
|
53
|
+
expect(sink.seen.find(entry => entry.name === 'live.invalidation.fanout')?.value).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('none() swallows everything without a sink', () => {
|
|
57
|
+
expect(() => {
|
|
58
|
+
LiveMetrics.none().recompute('X.y', true, 1, 1);
|
|
59
|
+
LiveMetrics.none().invalidation(1, 1);
|
|
60
|
+
LiveMetrics.none().instances(5);
|
|
61
|
+
}).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a sink that throws does not take the caller down', () => {
|
|
65
|
+
const metrics = new LiveMetrics({
|
|
66
|
+
onMetric() { throw new Error('the metrics backend is down'); }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Losing a number is acceptable. Losing a recompute is not.
|
|
70
|
+
expect(() => metrics.recompute('X.y', true, 1, 1)).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
import { DependencyGraph } from '../src/graph/DependencyGraph';
|
|
75
|
+
import { InProcessBus } from '../src/bus/InProcessBus';
|
|
76
|
+
import { LiveEngine } from '../src/LiveEngine';
|
|
77
|
+
import { ResourceRegistry } from '../src/resource/ResourceRegistry';
|
|
78
|
+
import { directResourceExecutor } from './resource-registry-helper';
|
|
79
|
+
import { SubscriptionRegistry } from '../src/graph/SubscriptionRegistry';
|
|
80
|
+
import { resolveLiveConfig } from '../src/config';
|
|
81
|
+
import { Controller, Get } from '@carno.js/core';
|
|
82
|
+
import { Live } from '../src/decorators/Live';
|
|
83
|
+
|
|
84
|
+
describe('LiveEngine metrics', () => {
|
|
85
|
+
test('a recompute that changes nothing publishes patched=false', async () => {
|
|
86
|
+
let payload = [{ id: 1 }];
|
|
87
|
+
|
|
88
|
+
@Controller('/things')
|
|
89
|
+
class ThingsController {
|
|
90
|
+
@Get('/')
|
|
91
|
+
@Live({ shared: 'public', dependsOn: ['orm:things'] })
|
|
92
|
+
list() { return payload; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sink = recorder();
|
|
96
|
+
const resources = new ResourceRegistry();
|
|
97
|
+
resources.register(ThingsController, new ThingsController(), directResourceExecutor);
|
|
98
|
+
|
|
99
|
+
const bus = new InProcessBus();
|
|
100
|
+
const engine = new LiveEngine(
|
|
101
|
+
resources,
|
|
102
|
+
new DependencyGraph(),
|
|
103
|
+
new SubscriptionRegistry(),
|
|
104
|
+
bus,
|
|
105
|
+
{ send: () => 1 },
|
|
106
|
+
resolveLiveConfig({ coalesceMs: 1 }),
|
|
107
|
+
undefined,
|
|
108
|
+
new LiveMetrics(sink)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
engine.start();
|
|
112
|
+
await engine.subscribe('c1', 's1', 'ThingsController.list', { params: {}, query: {} }, {});
|
|
113
|
+
|
|
114
|
+
bus.publish([{ key: 'orm:things', columns: null }]);
|
|
115
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
116
|
+
|
|
117
|
+
const recomputes = sink.seen.filter(entry => entry.name === 'live.recompute');
|
|
118
|
+
expect(recomputes.some(entry => entry.tags?.patched === false)).toBe(true);
|
|
119
|
+
expect(sink.seen.some(entry => entry.name === 'live.patch.ops')).toBe(false);
|
|
120
|
+
|
|
121
|
+
// And the same invalidation, now producing real change, flips it.
|
|
122
|
+
payload = [{ id: 1 }, { id: 2 }];
|
|
123
|
+
bus.publish([{ key: 'orm:things', columns: null }]);
|
|
124
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
125
|
+
|
|
126
|
+
expect(sink.seen.some(entry => entry.name === 'live.patch.ops')).toBe(true);
|
|
127
|
+
expect(sink.seen.some(entry => entry.name === 'live.invalidation.fanout' && entry.value === 1)).toBe(true);
|
|
128
|
+
|
|
129
|
+
engine.stop();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { LiveClient, type LiveSocket } from '../src/client/core';
|
|
3
|
+
import type { ClientMessage, ServerMessage } from '../src/shared/protocol';
|
|
4
|
+
|
|
5
|
+
class FakeSocket implements LiveSocket {
|
|
6
|
+
onopen: (() => void) | null = null;
|
|
7
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
8
|
+
onclose: (() => void) | null = null;
|
|
9
|
+
onerror: ((error: unknown) => void) | null = null;
|
|
10
|
+
|
|
11
|
+
readonly sent: ClientMessage[] = [];
|
|
12
|
+
|
|
13
|
+
send(data: string): void {
|
|
14
|
+
this.sent.push(JSON.parse(data));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
close(): void {}
|
|
18
|
+
|
|
19
|
+
open(): void {
|
|
20
|
+
this.onopen?.();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
deliver(message: ServerMessage): void {
|
|
24
|
+
this.onmessage?.({ data: JSON.stringify(message) });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
sid(): string {
|
|
28
|
+
const sub = this.sent.find(message => message.t === 'sub') as { sid: string };
|
|
29
|
+
return sub.sid;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function build() {
|
|
34
|
+
const socket = new FakeSocket();
|
|
35
|
+
const client = new LiveClient({
|
|
36
|
+
url: 'ws://test/live',
|
|
37
|
+
socketFactory: () => socket,
|
|
38
|
+
unsubGraceMs: 5
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const store = client.store<{ id: number; title: string }[]>('Cards.list', { params: {}, query: {} });
|
|
42
|
+
store.subscribe(() => {});
|
|
43
|
+
socket.open();
|
|
44
|
+
socket.deliver({
|
|
45
|
+
t: 'snapshot',
|
|
46
|
+
sid: socket.sid(),
|
|
47
|
+
rev: 1,
|
|
48
|
+
hash: 'h1',
|
|
49
|
+
data: [{ id: 1, title: 'Ada' }],
|
|
50
|
+
key: 'id'
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return { client, socket, store };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('optimistic overlay', () => {
|
|
57
|
+
test('shows the optimistic row before the server knows about it', () => {
|
|
58
|
+
const { client, store } = build();
|
|
59
|
+
|
|
60
|
+
client.overlay('Cards.list', draft => {
|
|
61
|
+
(draft as { id: number; title: string }[]).push({ id: -1, title: 'pending' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(store.getSnapshot().data).toEqual([
|
|
65
|
+
{ id: 1, title: 'Ada' },
|
|
66
|
+
{ id: -1, title: 'pending' }
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('removing the overlay goes back to what the server confirmed', () => {
|
|
71
|
+
const { client, store } = build();
|
|
72
|
+
|
|
73
|
+
const remove = client.overlay('Cards.list', draft => {
|
|
74
|
+
(draft as { id: number; title: string }[]).push({ id: -1, title: 'pending' });
|
|
75
|
+
});
|
|
76
|
+
remove();
|
|
77
|
+
|
|
78
|
+
expect(store.getSnapshot().data).toEqual([{ id: 1, title: 'Ada' }]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a server patch during the action applies underneath the overlay', () => {
|
|
82
|
+
const { client, socket, store } = build();
|
|
83
|
+
|
|
84
|
+
client.overlay('Cards.list', draft => {
|
|
85
|
+
(draft as { id: number; title: string }[]).push({ id: -1, title: 'pending' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
socket.deliver({
|
|
89
|
+
t: 'patch',
|
|
90
|
+
sid: socket.sid(),
|
|
91
|
+
from: 1,
|
|
92
|
+
to: 2,
|
|
93
|
+
hash: 'h2',
|
|
94
|
+
ops: [{ op: 'upsert', path: [], key: 2, index: 1, value: { id: 2, title: 'Linus' } }]
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// The server's row landed on the confirmed snapshot; the optimistic row
|
|
98
|
+
// is still projected on top of it.
|
|
99
|
+
expect(store.getSnapshot().data).toEqual([
|
|
100
|
+
{ id: 1, title: 'Ada' },
|
|
101
|
+
{ id: 2, title: 'Linus' },
|
|
102
|
+
{ id: -1, title: 'pending' }
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('the confirmed snapshot survives the overlay being dropped', () => {
|
|
107
|
+
const { client, socket, store } = build();
|
|
108
|
+
|
|
109
|
+
const remove = client.overlay('Cards.list', draft => {
|
|
110
|
+
(draft as { id: number; title: string }[]).push({ id: -1, title: 'pending' });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
socket.deliver({
|
|
114
|
+
t: 'patch',
|
|
115
|
+
sid: socket.sid(),
|
|
116
|
+
from: 1,
|
|
117
|
+
to: 2,
|
|
118
|
+
hash: 'h2',
|
|
119
|
+
ops: [{ op: 'upsert', path: [], key: 2, index: 1, value: { id: 2, title: 'Linus' } }]
|
|
120
|
+
});
|
|
121
|
+
remove();
|
|
122
|
+
|
|
123
|
+
expect(store.getSnapshot().data).toEqual([
|
|
124
|
+
{ id: 1, title: 'Ada' },
|
|
125
|
+
{ id: 2, title: 'Linus' }
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('two overlays apply in the order they were added', () => {
|
|
130
|
+
const { client, store } = build();
|
|
131
|
+
|
|
132
|
+
client.overlay('Cards.list', draft => {
|
|
133
|
+
(draft as { id: number; title: string }[]).push({ id: -1, title: 'first' });
|
|
134
|
+
});
|
|
135
|
+
client.overlay('Cards.list', draft => {
|
|
136
|
+
(draft as { id: number; title: string }[]).push({ id: -2, title: 'second' });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(store.getSnapshot().data).toEqual([
|
|
140
|
+
{ id: 1, title: 'Ada' },
|
|
141
|
+
{ id: -1, title: 'first' },
|
|
142
|
+
{ id: -2, title: 'second' }
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('an overlay for another resource leaves this store alone', () => {
|
|
147
|
+
const { client, store } = build();
|
|
148
|
+
const before = store.getSnapshot();
|
|
149
|
+
|
|
150
|
+
client.overlay('Cards.other', draft => {
|
|
151
|
+
(draft as unknown[]).push({ id: -1 });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(store.getSnapshot()).toBe(before);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('an overlay that throws does not break the store', () => {
|
|
158
|
+
const { client, store } = build();
|
|
159
|
+
|
|
160
|
+
client.overlay('Cards.list', () => {
|
|
161
|
+
throw new Error('bad optimistic update');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(store.getSnapshot().data).toEqual([{ id: 1, title: 'Ada' }]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Entity, PrimaryKey, Property, BaseEntity, statementObserver, type Statement } from '../../orm/src';
|
|
3
|
+
import { withDatabase } from '../../orm/src/testing';
|
|
4
|
+
import { readDependencies, writeEvents } from '../src/emitters/statement-keys';
|
|
5
|
+
import { DEFAULT_LIVE_CONFIG } from '../src/config';
|
|
6
|
+
|
|
7
|
+
const TABLE_STATEMENTS = [
|
|
8
|
+
'CREATE TABLE live_users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, last_seen_at TIMESTAMP NULL);'
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
@Entity({ tableName: 'live_users' })
|
|
12
|
+
class LiveUser extends BaseEntity<LiveUser> {
|
|
13
|
+
@PrimaryKey()
|
|
14
|
+
id!: number;
|
|
15
|
+
|
|
16
|
+
@Property()
|
|
17
|
+
name!: string;
|
|
18
|
+
|
|
19
|
+
@Property({ nullable: true })
|
|
20
|
+
lastSeenAt?: Date;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
statementObserver.reset();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('statement keys against real ORM output', () => {
|
|
28
|
+
test('a findOne by primary key yields a row dependency', async () => {
|
|
29
|
+
await withDatabase(TABLE_STATEMENTS, async () => {
|
|
30
|
+
const reads: Statement<any>[] = [];
|
|
31
|
+
statementObserver.onRead(statement => reads.push(statement));
|
|
32
|
+
|
|
33
|
+
const created = await LiveUser.create({ name: 'Ada' });
|
|
34
|
+
await LiveUser.findOne({ id: created.id });
|
|
35
|
+
|
|
36
|
+
const select = reads.find(statement => statement.statement === 'select');
|
|
37
|
+
expect(select).toBeDefined();
|
|
38
|
+
|
|
39
|
+
const deps = readDependencies(select!, DEFAULT_LIVE_CONFIG.maxKeysPerRead);
|
|
40
|
+
expect(deps[0].key).toBe(`orm:live_users#${created.id}`);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a write emits the row key with the columns it wrote', async () => {
|
|
45
|
+
await withDatabase(TABLE_STATEMENTS, async () => {
|
|
46
|
+
const writes: Statement<any>[] = [];
|
|
47
|
+
statementObserver.onWrite(statement => writes.push(statement));
|
|
48
|
+
|
|
49
|
+
const created = await LiveUser.create({ name: 'Ada' });
|
|
50
|
+
await LiveUser.update({ id: created.id }, { name: 'Ada Lovelace' });
|
|
51
|
+
|
|
52
|
+
const update = writes.find(statement => statement.statement === 'update');
|
|
53
|
+
expect(update).toBeDefined();
|
|
54
|
+
|
|
55
|
+
const events = writeEvents(update!, DEFAULT_LIVE_CONFIG.maxKeysPerRead);
|
|
56
|
+
expect(events[0].key).toBe(`orm:live_users#${created.id}`);
|
|
57
|
+
expect(events[0].columns).toContain('name');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the generated column list normalizes to bare column names', async () => {
|
|
62
|
+
await withDatabase(TABLE_STATEMENTS, async () => {
|
|
63
|
+
const reads: Statement<any>[] = [];
|
|
64
|
+
statementObserver.onRead(statement => reads.push(statement));
|
|
65
|
+
|
|
66
|
+
await LiveUser.find({});
|
|
67
|
+
|
|
68
|
+
const select = reads.find(statement => statement.statement === 'select');
|
|
69
|
+
const deps = readDependencies(select!, DEFAULT_LIVE_CONFIG.maxKeysPerRead);
|
|
70
|
+
|
|
71
|
+
expect(deps[0].key).toBe('orm:live_users');
|
|
72
|
+
expect(deps[0].columns).toEqual(expect.arrayContaining(['id', 'name']));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { PatchEngine } from '../src/patch/PatchEngine';
|
|
3
|
+
|
|
4
|
+
describe('PatchEngine without a key', () => {
|
|
5
|
+
const engine = new PatchEngine();
|
|
6
|
+
|
|
7
|
+
test('produces no ops for deep-equal values', () => {
|
|
8
|
+
expect(engine.diff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toEqual([]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('sets a changed leaf', () => {
|
|
12
|
+
expect(engine.diff({ a: 1 }, { a: 2 })).toEqual([{ op: 'set', path: ['a'], value: 2 }]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('unsets a removed property', () => {
|
|
16
|
+
expect(engine.diff({ a: 1, b: 2 }, { a: 1 })).toEqual([{ op: 'unset', path: ['b'] }]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('recurses into nested objects instead of replacing them', () => {
|
|
20
|
+
expect(engine.diff({ a: { b: 1, c: 2 } }, { a: { b: 9, c: 2 } }))
|
|
21
|
+
.toEqual([{ op: 'set', path: ['a', 'b'], value: 9 }]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('replaces an unkeyed array wholesale', () => {
|
|
25
|
+
expect(engine.diff({ xs: [1, 2] }, { xs: [1, 2, 3] }))
|
|
26
|
+
.toEqual([{ op: 'set', path: ['xs'], value: [1, 2, 3] }]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('PatchEngine with a key', () => {
|
|
31
|
+
const engine = new PatchEngine('id');
|
|
32
|
+
|
|
33
|
+
const a = { id: 1, name: 'Ada' };
|
|
34
|
+
const b = { id: 2, name: 'Bob' };
|
|
35
|
+
const c = { id: 3, name: 'Cid' };
|
|
36
|
+
|
|
37
|
+
test('inserting at the top is an upsert plus an order, not a rebuild', () => {
|
|
38
|
+
const ops = engine.diff([a, b], [c, a, b]);
|
|
39
|
+
|
|
40
|
+
expect(ops).toEqual([
|
|
41
|
+
{ op: 'upsert', path: [], key: 3, index: 0, value: c },
|
|
42
|
+
{ op: 'order', path: [], keys: [3, 1, 2] }
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('changing one row touches only that row', () => {
|
|
47
|
+
const ops = engine.diff([a, b], [a, { id: 2, name: 'Bobby' }]);
|
|
48
|
+
|
|
49
|
+
expect(ops).toEqual([
|
|
50
|
+
{ op: 'upsert', path: [], key: 2, index: 1, value: { id: 2, name: 'Bobby' } }
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('removing a row emits remove', () => {
|
|
55
|
+
expect(engine.diff([a, b], [a])).toEqual([{ op: 'remove', path: [], key: 2 }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('reordering without changing content emits only order', () => {
|
|
59
|
+
expect(engine.diff([a, b], [b, a])).toEqual([{ op: 'order', path: [], keys: [2, 1] }]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('applies keyed diffs to arrays nested in objects', () => {
|
|
63
|
+
const ops = engine.diff({ users: [a, b] }, { users: [a] });
|
|
64
|
+
expect(ops).toEqual([{ op: 'remove', path: ['users'], key: 2 }]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('falls back to a whole-array set when an element lacks the key', () => {
|
|
68
|
+
const ops = engine.diff([a], [a, { name: 'no id' }]);
|
|
69
|
+
expect(ops).toEqual([{ op: 'set', path: [], value: [a, { name: 'no id' }] }]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('PatchEngine.apply', () => {
|
|
74
|
+
test('round-trips: apply(prev, diff(prev, next)) deep-equals next', () => {
|
|
75
|
+
const engine = new PatchEngine('id');
|
|
76
|
+
const cases: [unknown, unknown][] = [
|
|
77
|
+
[{ a: 1 }, { a: 2 }],
|
|
78
|
+
[{ a: 1, b: 2 }, { a: 1 }],
|
|
79
|
+
[{ a: { b: 1 } }, { a: { b: 2, c: 3 } }],
|
|
80
|
+
[[{ id: 1 }, { id: 2 }], [{ id: 2 }, { id: 1 }]],
|
|
81
|
+
[[{ id: 1, n: 'a' }], [{ id: 1, n: 'b' }, { id: 2, n: 'c' }]],
|
|
82
|
+
[{ users: [{ id: 1 }] }, { users: [] }],
|
|
83
|
+
[{ xs: [1, 2] }, { xs: [3] }]
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
for (const [prev, next] of cases) {
|
|
87
|
+
expect(engine.apply(prev, engine.diff(prev, next))).toEqual(next as any);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('untouched sibling objects keep reference identity', () => {
|
|
92
|
+
const engine = new PatchEngine();
|
|
93
|
+
const prev = { left: { deep: { n: 1 } }, right: { n: 2 } };
|
|
94
|
+
const next = { left: { deep: { n: 1 } }, right: { n: 3 } };
|
|
95
|
+
|
|
96
|
+
const applied = engine.apply(prev, engine.diff(prev, next)) as typeof prev;
|
|
97
|
+
|
|
98
|
+
expect(applied.left).toBe(prev.left);
|
|
99
|
+
expect(applied).not.toBe(prev);
|
|
100
|
+
expect(applied.right).not.toBe(prev.right);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('untouched rows of a keyed list keep reference identity', () => {
|
|
104
|
+
const engine = new PatchEngine('id');
|
|
105
|
+
const kept = { id: 1, name: 'Ada' };
|
|
106
|
+
const prev = [kept, { id: 2, name: 'Bob' }];
|
|
107
|
+
const next = [kept, { id: 2, name: 'Bobby' }];
|
|
108
|
+
|
|
109
|
+
const applied = engine.apply(prev, engine.diff(prev, next)) as typeof prev;
|
|
110
|
+
|
|
111
|
+
expect(applied[0]).toBe(kept);
|
|
112
|
+
expect(applied).not.toBe(prev);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('an empty op list returns the very same root', () => {
|
|
116
|
+
const engine = new PatchEngine();
|
|
117
|
+
const prev = { a: 1 };
|
|
118
|
+
|
|
119
|
+
expect(engine.apply(prev, [])).toBe(prev);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { PgNotifyBus, chunkEvents } from '../src/bus/PgNotifyBus';
|
|
3
|
+
import { PgListener, type ListenableSql } from '../src/emitters/pg-listener';
|
|
4
|
+
import type { InvalidationEvent } from '../src/graph/types';
|
|
5
|
+
|
|
6
|
+
/** Stands in for the Postgres notification bus, shared by several connections. */
|
|
7
|
+
class FakeBroker {
|
|
8
|
+
private readonly handlers = new Map<string, Set<(payload: string) => void>>();
|
|
9
|
+
|
|
10
|
+
connection(): ListenableSql {
|
|
11
|
+
const broker = this;
|
|
12
|
+
const own = new Set<{ channel: string; handler: (payload: string) => void }>();
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
async listen(channel: string, onNotify: (payload: string) => void): Promise<void> {
|
|
16
|
+
let bucket = broker.handlers.get(channel);
|
|
17
|
+
|
|
18
|
+
if (!bucket) {
|
|
19
|
+
bucket = new Set();
|
|
20
|
+
broker.handlers.set(channel, bucket);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
bucket.add(onNotify);
|
|
24
|
+
own.add({ channel, handler: onNotify });
|
|
25
|
+
},
|
|
26
|
+
async notify(channel: string, payload = ''): Promise<void> {
|
|
27
|
+
// Postgres delivers to every listening session, sender included.
|
|
28
|
+
for (const handler of [...(broker.handlers.get(channel) ?? [])]) {
|
|
29
|
+
handler(payload);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
async unsafe(): Promise<void> {},
|
|
33
|
+
async close(): Promise<void> {
|
|
34
|
+
for (const entry of own) {
|
|
35
|
+
broker.handlers.get(entry.channel)?.delete(entry.handler);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
own.clear();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function node(broker: FakeBroker, nodeId: string) {
|
|
45
|
+
const received: InvalidationEvent[] = [];
|
|
46
|
+
const bus = new PgNotifyBus({
|
|
47
|
+
url: 'postgres://ignored',
|
|
48
|
+
channel: 'carno_test_bus',
|
|
49
|
+
nodeId,
|
|
50
|
+
listener: new PgListener({
|
|
51
|
+
url: 'postgres://ignored',
|
|
52
|
+
heartbeatMs: 0,
|
|
53
|
+
sqlFactory: () => broker.connection()
|
|
54
|
+
})
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
bus.subscribe(events => received.push(...events));
|
|
58
|
+
|
|
59
|
+
return { bus, received };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('chunkEvents', () => {
|
|
63
|
+
test('keeps everything in one frame when it fits', () => {
|
|
64
|
+
const frames = chunkEvents([{ key: 'orm:users#1', columns: null }], 'n1', 7000);
|
|
65
|
+
|
|
66
|
+
expect(frames).toHaveLength(1);
|
|
67
|
+
expect(JSON.parse(frames[0])).toEqual({ n: 'n1', e: [{ key: 'orm:users#1', columns: null }] });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('splits into frames under the ceiling', () => {
|
|
71
|
+
const events = Array.from({ length: 50 }, (_, index) => ({
|
|
72
|
+
key: `orm:users#${index}`,
|
|
73
|
+
columns: ['name', 'email']
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
const frames = chunkEvents(events, 'n1', 400);
|
|
77
|
+
|
|
78
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
79
|
+
|
|
80
|
+
for (const frame of frames) {
|
|
81
|
+
expect(Buffer.byteLength(frame, 'utf8')).toBeLessThanOrEqual(400);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
expect(frames.flatMap(frame => JSON.parse(frame).e)).toEqual(events);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('one oversized event degrades to its table instead of overflowing', () => {
|
|
88
|
+
const frames = chunkEvents(
|
|
89
|
+
[{ key: 'orm:users#1', columns: Array.from({ length: 200 }, (_, i) => `column_${i}`) }],
|
|
90
|
+
'n1',
|
|
91
|
+
300
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(frames).toHaveLength(1);
|
|
95
|
+
expect(JSON.parse(frames[0]).e).toEqual([{ key: 'orm:users', columns: null }]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('PgNotifyBus', () => {
|
|
100
|
+
test('an invalidation published on one node arrives on the other', async () => {
|
|
101
|
+
const broker = new FakeBroker();
|
|
102
|
+
const a = node(broker, 'a');
|
|
103
|
+
const b = node(broker, 'b');
|
|
104
|
+
|
|
105
|
+
await a.bus.start();
|
|
106
|
+
await b.bus.start();
|
|
107
|
+
|
|
108
|
+
a.bus.publish([{ key: 'orm:users#1', columns: ['name'] }]);
|
|
109
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
110
|
+
|
|
111
|
+
expect(b.received).toEqual([{ key: 'orm:users#1', columns: ['name'] }]);
|
|
112
|
+
|
|
113
|
+
await a.bus.stop();
|
|
114
|
+
await b.bus.stop();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('the publishing node delivers locally exactly once', async () => {
|
|
118
|
+
const broker = new FakeBroker();
|
|
119
|
+
const a = node(broker, 'a');
|
|
120
|
+
|
|
121
|
+
await a.bus.start();
|
|
122
|
+
a.bus.publish([{ key: 'orm:users#1', columns: null }]);
|
|
123
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
124
|
+
|
|
125
|
+
// Postgres echoes the notification back to the sender; the node id is
|
|
126
|
+
// what keeps it from becoming a second invalidation.
|
|
127
|
+
expect(a.received).toEqual([{ key: 'orm:users#1', columns: null }]);
|
|
128
|
+
|
|
129
|
+
await a.bus.stop();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('publishLocal stays on this node', async () => {
|
|
133
|
+
const broker = new FakeBroker();
|
|
134
|
+
const a = node(broker, 'a');
|
|
135
|
+
const b = node(broker, 'b');
|
|
136
|
+
|
|
137
|
+
await a.bus.start();
|
|
138
|
+
await b.bus.start();
|
|
139
|
+
|
|
140
|
+
a.bus.publishLocal([{ key: 'orm:users#1', columns: null }]);
|
|
141
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
142
|
+
|
|
143
|
+
expect(a.received).toHaveLength(1);
|
|
144
|
+
expect(b.received).toHaveLength(0);
|
|
145
|
+
|
|
146
|
+
await a.bus.stop();
|
|
147
|
+
await b.bus.stop();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('garbage on the channel is ignored', async () => {
|
|
151
|
+
const broker = new FakeBroker();
|
|
152
|
+
const a = node(broker, 'a');
|
|
153
|
+
|
|
154
|
+
await a.bus.start();
|
|
155
|
+
await broker.connection().notify('carno_test_bus', 'not json');
|
|
156
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
157
|
+
|
|
158
|
+
expect(a.received).toHaveLength(0);
|
|
159
|
+
|
|
160
|
+
await a.bus.stop();
|
|
161
|
+
});
|
|
162
|
+
});
|