@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,306 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { BaseEntity, Entity, PrimaryKey, Property, statementObserver } from '../../orm/dist/index.js';
|
|
3
|
+
import { withDatabase } from '../../orm/dist/testing/with-database.js';
|
|
4
|
+
import { Controller, Get, Query } from '@carno.js/core';
|
|
5
|
+
import { createTestHarness } from '../../core/dist/testing/TestHarness.js';
|
|
6
|
+
import { Live } from '../src/decorators/Live';
|
|
7
|
+
import { LivePlugin } from '../src/LivePlugin';
|
|
8
|
+
import { resetLiveRuntime } from '../src/runtime';
|
|
9
|
+
import type { ServerMessage } from '../src/shared/protocol';
|
|
10
|
+
|
|
11
|
+
const TABLE_STATEMENTS = [
|
|
12
|
+
'CREATE TABLE live_tasks (id SERIAL PRIMARY KEY, title TEXT NOT NULL, tenant TEXT NOT NULL, touched_at TIMESTAMP NULL);'
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
@Entity({ tableName: 'live_tasks' })
|
|
16
|
+
class Task extends BaseEntity<Task> {
|
|
17
|
+
@PrimaryKey()
|
|
18
|
+
id!: number;
|
|
19
|
+
|
|
20
|
+
@Property()
|
|
21
|
+
title!: string;
|
|
22
|
+
|
|
23
|
+
@Property()
|
|
24
|
+
tenant!: string;
|
|
25
|
+
|
|
26
|
+
@Property({ nullable: true })
|
|
27
|
+
touchedAt?: Date;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Controller('/tasks')
|
|
31
|
+
class TasksController {
|
|
32
|
+
@Get('/')
|
|
33
|
+
@Live({ key: 'id', shared: 'public' })
|
|
34
|
+
async list(@Query('tenant') tenant: string) {
|
|
35
|
+
const tasks = await Task.find({ tenant }, { fields: ['id', 'title'] as any });
|
|
36
|
+
return tasks.map(task => ({ id: task.id, title: task.title }));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Minimal protocol client over a real WebSocket. */
|
|
41
|
+
class ProbeClient {
|
|
42
|
+
private readonly socket: WebSocket;
|
|
43
|
+
readonly received: ServerMessage[] = [];
|
|
44
|
+
|
|
45
|
+
private constructor(socket: WebSocket) {
|
|
46
|
+
this.socket = socket;
|
|
47
|
+
socket.onmessage = event => this.received.push(JSON.parse(String(event.data)));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static connect(port: number): Promise<ProbeClient> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const socket = new WebSocket(`ws://127.0.0.1:${port}/live`);
|
|
53
|
+
socket.onopen = () => resolve(new ProbeClient(socket));
|
|
54
|
+
socket.onerror = reject;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
send(message: unknown): void {
|
|
59
|
+
this.socket.send(JSON.stringify(message));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
close(): void {
|
|
63
|
+
this.socket.close();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Wait for a message matching `predicate`, or fail after `timeoutMs`. */
|
|
67
|
+
async wait(predicate: (message: ServerMessage) => boolean, timeoutMs = 2000): Promise<ServerMessage> {
|
|
68
|
+
const deadline = Date.now() + timeoutMs;
|
|
69
|
+
|
|
70
|
+
while (Date.now() < deadline) {
|
|
71
|
+
const found = this.received.find(predicate);
|
|
72
|
+
|
|
73
|
+
if (found) {
|
|
74
|
+
return found;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new Error(`Timed out. Received: ${JSON.stringify(this.received)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
statementObserver.reset();
|
|
86
|
+
resetLiveRuntime();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('Live Resources acceptance', () => {
|
|
90
|
+
test('an ORM write reaches a subscriber with no broadcast code (criteria 1, 4, 5)', async () => {
|
|
91
|
+
await withDatabase(TABLE_STATEMENTS, async () => {
|
|
92
|
+
const harness = await createTestHarness({
|
|
93
|
+
plugins: [LivePlugin.create({ controllers: [TasksController], config: { coalesceMs: 5 } })],
|
|
94
|
+
listen: true
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await Task.create({ title: 'first', tenant: 'acme' });
|
|
99
|
+
|
|
100
|
+
const probe = await ProbeClient.connect(harness.port!);
|
|
101
|
+
probe.send({ t: 'sub', sid: 'a', resource: 'TasksController.list', inputs: { params: {}, query: { tenant: 'acme' } } });
|
|
102
|
+
|
|
103
|
+
const snapshot = await probe.wait(message => message.t === 'snapshot');
|
|
104
|
+
expect((snapshot as any).data).toEqual([{ id: expect.any(Number), title: 'first' }]);
|
|
105
|
+
|
|
106
|
+
// Criterion 1: nothing below mentions the socket.
|
|
107
|
+
await Task.create({ title: 'second', tenant: 'acme' });
|
|
108
|
+
|
|
109
|
+
const patch = await probe.wait(message => message.t === 'patch');
|
|
110
|
+
|
|
111
|
+
// Criterion 4, by proxy: a keyed upsert, not a whole-array set.
|
|
112
|
+
expect((patch as any).ops.every((op: any) => op.op === 'upsert' || op.op === 'order')).toBe(true);
|
|
113
|
+
|
|
114
|
+
// Criterion 5: a write to a column the resource never selected.
|
|
115
|
+
const before = probe.received.length;
|
|
116
|
+
const [task] = await Task.find({ title: 'first' });
|
|
117
|
+
await Task.update({ id: task.id }, { touchedAt: new Date() });
|
|
118
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
119
|
+
|
|
120
|
+
expect(probe.received.length).toBe(before);
|
|
121
|
+
|
|
122
|
+
probe.close();
|
|
123
|
+
} finally {
|
|
124
|
+
await harness.close();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('does not publish an uncommitted write that is later rolled back', async () => {
|
|
130
|
+
await withDatabase(TABLE_STATEMENTS, async context => {
|
|
131
|
+
const harness = await createTestHarness({
|
|
132
|
+
plugins: [LivePlugin.create({ controllers: [TasksController], config: { coalesceMs: 5 } })],
|
|
133
|
+
listen: true
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
let probe: ProbeClient | undefined;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const task = await Task.create({ title: 'before transaction', tenant: 'acme' });
|
|
140
|
+
probe = await ProbeClient.connect(harness.port!);
|
|
141
|
+
probe.send({
|
|
142
|
+
t: 'sub',
|
|
143
|
+
sid: 'a',
|
|
144
|
+
resource: 'TasksController.list',
|
|
145
|
+
inputs: { params: {}, query: { tenant: 'acme' } }
|
|
146
|
+
});
|
|
147
|
+
await probe.wait(message => message.t === 'snapshot');
|
|
148
|
+
|
|
149
|
+
let failureMessage = '';
|
|
150
|
+
let patchBeforeRollback = false;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await context.orm.transaction(async () => {
|
|
154
|
+
await Task.update({ id: task.id }, { title: 'should be rolled back' });
|
|
155
|
+
|
|
156
|
+
// Keep the transaction open long enough for an eager
|
|
157
|
+
// invalidation implementation to recompute and publish.
|
|
158
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
159
|
+
patchBeforeRollback = probe!.received.some(message => message.t === 'patch');
|
|
160
|
+
throw new Error('rollback live write');
|
|
161
|
+
});
|
|
162
|
+
} catch (error) {
|
|
163
|
+
failureMessage = (error as Error).message;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
expect(failureMessage).toBe('rollback live write');
|
|
167
|
+
expect(patchBeforeRollback).toBe(false);
|
|
168
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
169
|
+
expect(probe.received.some(message => message.t === 'patch')).toBe(false);
|
|
170
|
+
|
|
171
|
+
const persisted = await context.executeSql(
|
|
172
|
+
`SELECT title FROM live_tasks WHERE id = ${task.id}`
|
|
173
|
+
);
|
|
174
|
+
expect(persisted.rows[0].title).toBe('before transaction');
|
|
175
|
+
} finally {
|
|
176
|
+
probe?.close();
|
|
177
|
+
await harness.close();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('publishes a transactional write only after commit', async () => {
|
|
183
|
+
await withDatabase(TABLE_STATEMENTS, async context => {
|
|
184
|
+
const harness = await createTestHarness({
|
|
185
|
+
plugins: [LivePlugin.create({ controllers: [TasksController], config: { coalesceMs: 5 } })],
|
|
186
|
+
listen: true
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
let probe: ProbeClient | undefined;
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const task = await Task.create({ title: 'before commit', tenant: 'acme' });
|
|
193
|
+
probe = await ProbeClient.connect(harness.port!);
|
|
194
|
+
probe.send({
|
|
195
|
+
t: 'sub',
|
|
196
|
+
sid: 'a',
|
|
197
|
+
resource: 'TasksController.list',
|
|
198
|
+
inputs: { params: {}, query: { tenant: 'acme' } }
|
|
199
|
+
});
|
|
200
|
+
await probe.wait(message => message.t === 'snapshot');
|
|
201
|
+
|
|
202
|
+
let patchBeforeCommit = false;
|
|
203
|
+
|
|
204
|
+
await context.orm.transaction(async () => {
|
|
205
|
+
await Task.update({ id: task.id }, { title: 'after commit' });
|
|
206
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
207
|
+
patchBeforeCommit = probe!.received.some(message => message.t === 'patch');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(patchBeforeCommit).toBe(false);
|
|
211
|
+
const patch = await probe.wait(message => message.t === 'patch');
|
|
212
|
+
expect((patch as any).ops).toEqual(expect.arrayContaining([
|
|
213
|
+
expect.objectContaining({ op: 'upsert' })
|
|
214
|
+
]));
|
|
215
|
+
} finally {
|
|
216
|
+
probe?.close();
|
|
217
|
+
await harness.close();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('resubscribing with the current hash retransmits nothing (criterion 3)', async () => {
|
|
223
|
+
await withDatabase(TABLE_STATEMENTS, async () => {
|
|
224
|
+
const harness = await createTestHarness({
|
|
225
|
+
plugins: [LivePlugin.create({ controllers: [TasksController], config: { coalesceMs: 5 } })],
|
|
226
|
+
listen: true
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await Task.create({ title: 'only', tenant: 'acme' });
|
|
231
|
+
|
|
232
|
+
const first = await ProbeClient.connect(harness.port!);
|
|
233
|
+
first.send({ t: 'sub', sid: 'a', resource: 'TasksController.list', inputs: { params: {}, query: { tenant: 'acme' } } });
|
|
234
|
+
const snapshot = await first.wait(message => message.t === 'snapshot');
|
|
235
|
+
first.close();
|
|
236
|
+
|
|
237
|
+
const second = await ProbeClient.connect(harness.port!);
|
|
238
|
+
second.send({
|
|
239
|
+
t: 'sub',
|
|
240
|
+
sid: 'a',
|
|
241
|
+
resource: 'TasksController.list',
|
|
242
|
+
inputs: { params: {}, query: { tenant: 'acme' } },
|
|
243
|
+
hash: (snapshot as any).hash
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const current = await second.wait(message => message.t === 'current');
|
|
247
|
+
expect((current as any).data).toBeUndefined();
|
|
248
|
+
|
|
249
|
+
second.close();
|
|
250
|
+
} finally {
|
|
251
|
+
await harness.close();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('the same route still answers plain JSON (criterion 6)', async () => {
|
|
257
|
+
await withDatabase(TABLE_STATEMENTS, async () => {
|
|
258
|
+
const harness = await createTestHarness({
|
|
259
|
+
plugins: [LivePlugin.create({ controllers: [TasksController] })],
|
|
260
|
+
listen: true
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
await Task.create({ title: 'over http', tenant: 'acme' });
|
|
265
|
+
|
|
266
|
+
const response = await harness.get('/tasks?tenant=acme');
|
|
267
|
+
|
|
268
|
+
expect(response.status).toBe(200);
|
|
269
|
+
expect(await response.json()).toEqual([{ id: expect.any(Number), title: 'over http' }]);
|
|
270
|
+
} finally {
|
|
271
|
+
await harness.close();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('two tenants subscribing the same resource never share an instance (criterion 8)', async () => {
|
|
277
|
+
await withDatabase(TABLE_STATEMENTS, async () => {
|
|
278
|
+
const harness = await createTestHarness({
|
|
279
|
+
plugins: [LivePlugin.create({ controllers: [TasksController], config: { coalesceMs: 5 } })],
|
|
280
|
+
listen: true
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await Task.create({ title: 'acme-only', tenant: 'acme' });
|
|
285
|
+
await Task.create({ title: 'globex-only', tenant: 'globex' });
|
|
286
|
+
|
|
287
|
+
const acme = await ProbeClient.connect(harness.port!);
|
|
288
|
+
const globex = await ProbeClient.connect(harness.port!);
|
|
289
|
+
|
|
290
|
+
acme.send({ t: 'sub', sid: 'a', resource: 'TasksController.list', inputs: { params: {}, query: { tenant: 'acme' } } });
|
|
291
|
+
globex.send({ t: 'sub', sid: 'a', resource: 'TasksController.list', inputs: { params: {}, query: { tenant: 'globex' } } });
|
|
292
|
+
|
|
293
|
+
const acmeSnapshot = await acme.wait(message => message.t === 'snapshot');
|
|
294
|
+
const globexSnapshot = await globex.wait(message => message.t === 'snapshot');
|
|
295
|
+
|
|
296
|
+
expect((acmeSnapshot as any).data).toEqual([{ id: expect.any(Number), title: 'acme-only' }]);
|
|
297
|
+
expect((globexSnapshot as any).data).toEqual([{ id: expect.any(Number), title: 'globex-only' }]);
|
|
298
|
+
|
|
299
|
+
acme.close();
|
|
300
|
+
globex.close();
|
|
301
|
+
} finally {
|
|
302
|
+
await harness.close();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { DestroyRef, EnvironmentInjector, Injector, runInInjectionContext, signal } from '@angular/core';
|
|
3
|
+
import { LiveClient, type LiveSocket } from '../src/client/core';
|
|
4
|
+
import { LIVE_CLIENT, liveSignal, provideLive, reconcileLiveSignal } from '../src/client/angular';
|
|
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
|
+
let socket: ReturnType<typeof fakeSocket>;
|
|
35
|
+
let client: LiveClient;
|
|
36
|
+
let injector: Injector;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
socket = fakeSocket();
|
|
40
|
+
client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket, unsubGraceMs: 0 });
|
|
41
|
+
injector = Injector.create({ providers: [provideLive(client)] });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function lastSub(): any {
|
|
45
|
+
const raw = [...socket.sent].reverse().find(entry => entry.includes('"t":"sub"'));
|
|
46
|
+
return raw ? JSON.parse(raw) : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function host<T>(fn: () => T): T {
|
|
50
|
+
return runInInjectionContext(injector, fn);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('liveSignal', () => {
|
|
54
|
+
test('starts pending and reads the client from LIVE_CLIENT', () => {
|
|
55
|
+
const state = host(() => liveSignal(cardsList));
|
|
56
|
+
reconcileLiveSignal(state);
|
|
57
|
+
|
|
58
|
+
expect(injector.get(LIVE_CLIENT)).toBe(client);
|
|
59
|
+
expect(state()).toEqual({ data: undefined, pending: true, error: null, stale: false });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a snapshot updates the signal', () => {
|
|
63
|
+
const state = host(() => liveSignal(cardsList));
|
|
64
|
+
reconcileLiveSignal(state);
|
|
65
|
+
socket.onopen?.();
|
|
66
|
+
socket.deliver({ t: 'snapshot', sid: lastSub().sid, rev: 1, hash: 'h1', data: [{ id: 1, title: 'a' }] });
|
|
67
|
+
|
|
68
|
+
expect(state().data).toEqual([{ id: 1, title: 'a' }]);
|
|
69
|
+
expect(state().pending).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('changing a reactive input resubscribes with the new inputs', async () => {
|
|
73
|
+
const done = signal('false');
|
|
74
|
+
const state = host(() =>
|
|
75
|
+
liveSignal(cardsList, () => ({ query: { done: done() } }))
|
|
76
|
+
);
|
|
77
|
+
reconcileLiveSignal(state);
|
|
78
|
+
socket.onopen?.();
|
|
79
|
+
|
|
80
|
+
expect(lastSub().inputs.query).toEqual({ done: 'false' });
|
|
81
|
+
|
|
82
|
+
done.set('true');
|
|
83
|
+
reconcileLiveSignal(state);
|
|
84
|
+
|
|
85
|
+
expect(lastSub().inputs.query).toEqual({ done: 'true' });
|
|
86
|
+
// LiveClient always schedules unsub through setTimeout, even at 0ms.
|
|
87
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
88
|
+
expect(socket.sent.filter(raw => raw.includes('"t":"unsub"')).length).toBe(1);
|
|
89
|
+
void state;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a reactive input that recomputes to the same value does not resubscribe', () => {
|
|
93
|
+
const unrelated = signal(0);
|
|
94
|
+
const state = host(() =>
|
|
95
|
+
liveSignal(cardsList, () => {
|
|
96
|
+
void unrelated();
|
|
97
|
+
return { query: { done: 'false' } };
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
reconcileLiveSignal(state);
|
|
101
|
+
socket.onopen?.();
|
|
102
|
+
const before = socket.sent.length;
|
|
103
|
+
|
|
104
|
+
unrelated.set(1);
|
|
105
|
+
reconcileLiveSignal(state);
|
|
106
|
+
|
|
107
|
+
expect(socket.sent.length).toBe(before);
|
|
108
|
+
void state;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('destroying the injection context unsubscribes', async () => {
|
|
112
|
+
const state = host(() => liveSignal(cardsList));
|
|
113
|
+
reconcileLiveSignal(state);
|
|
114
|
+
socket.onopen?.();
|
|
115
|
+
|
|
116
|
+
(injector as EnvironmentInjector).destroy();
|
|
117
|
+
|
|
118
|
+
// LiveClient always schedules unsub through setTimeout, even at 0ms.
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
120
|
+
expect(socket.sent.some(raw => raw.includes('"t":"unsub"'))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('an explicit client wins over the injected one', () => {
|
|
124
|
+
const otherSocket = fakeSocket();
|
|
125
|
+
const other = new LiveClient({ url: 'ws://y/live', socketFactory: () => otherSocket });
|
|
126
|
+
|
|
127
|
+
const state = host(() => liveSignal(cardsList, undefined, { client: other }));
|
|
128
|
+
reconcileLiveSignal(state);
|
|
129
|
+
otherSocket.onopen?.();
|
|
130
|
+
|
|
131
|
+
expect(otherSocket.sent.some(raw => raw.includes('"t":"sub"'))).toBe(true);
|
|
132
|
+
expect(socket.sent).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a non-scheduler error from effect is not swallowed', () => {
|
|
136
|
+
const destroyRef = injector.get(DestroyRef);
|
|
137
|
+
const broken = {
|
|
138
|
+
get(token: unknown) {
|
|
139
|
+
if (token === DestroyRef) {
|
|
140
|
+
return destroyRef;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
throw new Error('boom from injector');
|
|
144
|
+
}
|
|
145
|
+
} as Injector;
|
|
146
|
+
|
|
147
|
+
expect(() => host(() => liveSignal(cardsList, undefined, { injector: broken }))).toThrow(
|
|
148
|
+
'boom from injector'
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { statementObserver, type Statement } from '@carno.js/orm';
|
|
3
|
+
import { AppEmitter, WriteDuringComputeError } from '../src/emitters/AppEmitter';
|
|
4
|
+
import { InProcessBus } from '../src/bus/InProcessBus';
|
|
5
|
+
import { DEFAULT_LIVE_CONFIG } from '../src/config';
|
|
6
|
+
import { dependencyContext } from '../src/resource/dependency-context';
|
|
7
|
+
import type { InvalidationEvent } from '../src/graph/types';
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
statementObserver.reset();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function statement(overrides: Partial<Statement<any>>): Statement<any> {
|
|
14
|
+
return { table: 'users', alias: 'u', primaryKeyColumnName: 'id', ...overrides };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('InProcessBus', () => {
|
|
18
|
+
test('delivers published events to every subscriber', () => {
|
|
19
|
+
const bus = new InProcessBus();
|
|
20
|
+
const seen: InvalidationEvent[][] = [];
|
|
21
|
+
bus.subscribe(events => seen.push(events));
|
|
22
|
+
bus.subscribe(events => seen.push(events));
|
|
23
|
+
|
|
24
|
+
bus.publish([{ key: 'orm:users', columns: null }]);
|
|
25
|
+
|
|
26
|
+
expect(seen).toHaveLength(2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('unsubscribe stops delivery', () => {
|
|
30
|
+
const bus = new InProcessBus();
|
|
31
|
+
let calls = 0;
|
|
32
|
+
const off = bus.subscribe(() => { calls++; });
|
|
33
|
+
|
|
34
|
+
bus.publish([{ key: 'a', columns: null }]);
|
|
35
|
+
off();
|
|
36
|
+
bus.publish([{ key: 'a', columns: null }]);
|
|
37
|
+
|
|
38
|
+
expect(calls).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('one failing subscriber does not stop the others', () => {
|
|
42
|
+
const bus = new InProcessBus();
|
|
43
|
+
let reached = false;
|
|
44
|
+
bus.subscribe(() => { throw new Error('boom'); });
|
|
45
|
+
bus.subscribe(() => { reached = true; });
|
|
46
|
+
|
|
47
|
+
bus.publish([{ key: 'a', columns: null }]);
|
|
48
|
+
|
|
49
|
+
expect(reached).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('AppEmitter', () => {
|
|
54
|
+
test('feeds reads into the active dependency collector', async () => {
|
|
55
|
+
const emitter = new AppEmitter(new InProcessBus(), DEFAULT_LIVE_CONFIG);
|
|
56
|
+
emitter.attach();
|
|
57
|
+
|
|
58
|
+
const { deps } = await dependencyContext.run(() => {
|
|
59
|
+
statementObserver.notifyRead(statement({ statement: 'select', where: 'u."id" = 42' }));
|
|
60
|
+
return Promise.resolve(null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(deps).toEqual([{ key: 'orm:users#42', columns: null }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('drops reads that happen outside a compute', () => {
|
|
67
|
+
const emitter = new AppEmitter(new InProcessBus(), DEFAULT_LIVE_CONFIG);
|
|
68
|
+
emitter.attach();
|
|
69
|
+
|
|
70
|
+
expect(() => statementObserver.notifyRead(statement({ statement: 'select' }))).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('publishes an invalidation for a write', () => {
|
|
74
|
+
const bus = new InProcessBus();
|
|
75
|
+
const seen: InvalidationEvent[][] = [];
|
|
76
|
+
bus.subscribe(events => seen.push(events));
|
|
77
|
+
new AppEmitter(bus, DEFAULT_LIVE_CONFIG).attach();
|
|
78
|
+
|
|
79
|
+
statementObserver.notifyWrite(statement({ statement: 'update', where: 'u."id" = 7', values: { name: 'x' } }));
|
|
80
|
+
|
|
81
|
+
expect(seen).toEqual([[{ key: 'orm:users#7', columns: ['name'] }]]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('refuses a write attempted during a compute', async () => {
|
|
85
|
+
new AppEmitter(new InProcessBus(), DEFAULT_LIVE_CONFIG).attach();
|
|
86
|
+
|
|
87
|
+
await dependencyContext.run(() => {
|
|
88
|
+
expect(() => statementObserver.notifyWriteAttempt(statement({ statement: 'update' })))
|
|
89
|
+
.toThrow(WriteDuringComputeError);
|
|
90
|
+
return Promise.resolve(null);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('allows writes outside a compute', () => {
|
|
95
|
+
new AppEmitter(new InProcessBus(), DEFAULT_LIVE_CONFIG).attach();
|
|
96
|
+
|
|
97
|
+
expect(() => statementObserver.notifyWriteAttempt(statement({ statement: 'update' }))).not.toThrow();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('detach unhooks the observer', () => {
|
|
101
|
+
const bus = new InProcessBus();
|
|
102
|
+
let calls = 0;
|
|
103
|
+
bus.subscribe(() => { calls++; });
|
|
104
|
+
const emitter = new AppEmitter(bus, DEFAULT_LIVE_CONFIG);
|
|
105
|
+
|
|
106
|
+
emitter.attach();
|
|
107
|
+
emitter.detach();
|
|
108
|
+
statementObserver.notifyWrite(statement({ statement: 'update', values: { a: 1 } }));
|
|
109
|
+
|
|
110
|
+
expect(calls).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
});
|