@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.
Files changed (199) hide show
  1. package/LICENSE +21 -0
  2. package/dist/LiveEngine.d.ts +89 -0
  3. package/dist/LiveEngine.js +520 -0
  4. package/dist/LivePlugin.d.ts +55 -0
  5. package/dist/LivePlugin.js +165 -0
  6. package/dist/LiveService.d.ts +32 -0
  7. package/dist/LiveService.js +51 -0
  8. package/dist/auth/authorizer.d.ts +33 -0
  9. package/dist/auth/authorizer.js +32 -0
  10. package/dist/bus/InProcessBus.d.ts +8 -0
  11. package/dist/bus/InProcessBus.js +29 -0
  12. package/dist/bus/InvalidationBus.d.ts +15 -0
  13. package/dist/bus/InvalidationBus.js +2 -0
  14. package/dist/bus/PgNotifyBus.d.ts +55 -0
  15. package/dist/bus/PgNotifyBus.js +138 -0
  16. package/dist/client/angular.d.ts +19 -0
  17. package/dist/client/angular.js +71 -0
  18. package/dist/client/core.d.ts +111 -0
  19. package/dist/client/core.js +337 -0
  20. package/dist/client/hydrate.d.ts +19 -0
  21. package/dist/client/hydrate.js +65 -0
  22. package/dist/client/optimistic.d.ts +17 -0
  23. package/dist/client/optimistic.js +2 -0
  24. package/dist/client/react.d.ts +22 -0
  25. package/dist/client/react.js +69 -0
  26. package/dist/client/transport.d.ts +133 -0
  27. package/dist/client/transport.js +431 -0
  28. package/dist/client/vanilla.d.ts +54 -0
  29. package/dist/client/vanilla.js +111 -0
  30. package/dist/client/vue.d.ts +12 -0
  31. package/dist/client/vue.js +35 -0
  32. package/dist/config.d.ts +32 -0
  33. package/dist/config.js +21 -0
  34. package/dist/decorators/Live.d.ts +4 -0
  35. package/dist/decorators/Live.js +17 -0
  36. package/dist/emitters/AppEmitter.d.ts +20 -0
  37. package/dist/emitters/AppEmitter.js +59 -0
  38. package/dist/emitters/pg-listener.d.ts +50 -0
  39. package/dist/emitters/pg-listener.js +120 -0
  40. package/dist/emitters/pg-notify-emitter.d.ts +59 -0
  41. package/dist/emitters/pg-notify-emitter.js +105 -0
  42. package/dist/emitters/pg-trigger-sql.d.ts +27 -0
  43. package/dist/emitters/pg-trigger-sql.js +107 -0
  44. package/dist/emitters/statement-keys.d.ts +16 -0
  45. package/dist/emitters/statement-keys.js +182 -0
  46. package/dist/graph/DependencyGraph.d.ts +27 -0
  47. package/dist/graph/DependencyGraph.js +118 -0
  48. package/dist/graph/SubscriptionRegistry.d.ts +22 -0
  49. package/dist/graph/SubscriptionRegistry.js +92 -0
  50. package/dist/graph/dep-key.d.ts +14 -0
  51. package/dist/graph/dep-key.js +37 -0
  52. package/dist/graph/types.d.ts +13 -0
  53. package/dist/graph/types.js +2 -0
  54. package/dist/http/etag.d.ts +41 -0
  55. package/dist/http/etag.js +109 -0
  56. package/dist/index.d.ts +56 -0
  57. package/dist/index.js +111 -0
  58. package/dist/metadata.d.ts +16 -0
  59. package/dist/metadata.js +4 -0
  60. package/dist/observability.d.ts +25 -0
  61. package/dist/observability.js +44 -0
  62. package/dist/patch/PatchEngine.d.ts +20 -0
  63. package/dist/patch/PatchEngine.js +180 -0
  64. package/dist/patch/types.d.ts +33 -0
  65. package/dist/patch/types.js +2 -0
  66. package/dist/resource/ResourceRegistry.d.ts +32 -0
  67. package/dist/resource/ResourceRegistry.js +126 -0
  68. package/dist/resource/dependency-context.d.ts +25 -0
  69. package/dist/resource/dependency-context.js +45 -0
  70. package/dist/resource/instance-id.d.ts +17 -0
  71. package/dist/resource/instance-id.js +61 -0
  72. package/dist/resource/prefetch.d.ts +20 -0
  73. package/dist/resource/prefetch.js +31 -0
  74. package/dist/resource/route-executor.d.ts +13 -0
  75. package/dist/resource/route-executor.js +86 -0
  76. package/dist/resource/types.d.ts +26 -0
  77. package/dist/resource/types.js +2 -0
  78. package/dist/runtime.d.ts +41 -0
  79. package/dist/runtime.js +47 -0
  80. package/dist/shared/canonical.d.ts +14 -0
  81. package/dist/shared/canonical.js +60 -0
  82. package/dist/shared/descriptor.d.ts +44 -0
  83. package/dist/shared/descriptor.js +19 -0
  84. package/dist/shared/hash.d.ts +12 -0
  85. package/dist/shared/hash.js +29 -0
  86. package/dist/shared/inputs.d.ts +19 -0
  87. package/dist/shared/inputs.js +2 -0
  88. package/dist/shared/protocol.d.ts +83 -0
  89. package/dist/shared/protocol.js +9 -0
  90. package/dist/transport/FanTransport.d.ts +18 -0
  91. package/dist/transport/FanTransport.js +29 -0
  92. package/dist/transport/LiveGateway.d.ts +16 -0
  93. package/dist/transport/LiveGateway.js +126 -0
  94. package/dist/transport/SocketTransport.d.ts +18 -0
  95. package/dist/transport/SocketTransport.js +40 -0
  96. package/dist/transport/SseTransport.d.ts +30 -0
  97. package/dist/transport/SseTransport.js +92 -0
  98. package/dist/transport/scope-resolver.d.ts +20 -0
  99. package/dist/transport/scope-resolver.js +17 -0
  100. package/dist/transport/sse-routes.d.ts +24 -0
  101. package/dist/transport/sse-routes.js +78 -0
  102. package/package.json +92 -0
  103. package/src/LiveEngine.ts +730 -0
  104. package/src/LivePlugin.ts +253 -0
  105. package/src/LiveService.ts +47 -0
  106. package/src/auth/authorizer.ts +56 -0
  107. package/src/bus/InProcessBus.ts +28 -0
  108. package/src/bus/InvalidationBus.ts +17 -0
  109. package/src/bus/PgNotifyBus.ts +188 -0
  110. package/src/client/angular.ts +115 -0
  111. package/src/client/core.ts +492 -0
  112. package/src/client/hydrate.ts +80 -0
  113. package/src/client/optimistic.ts +19 -0
  114. package/src/client/react.ts +113 -0
  115. package/src/client/transport.ts +568 -0
  116. package/src/client/vanilla.ts +165 -0
  117. package/src/client/vue.ts +67 -0
  118. package/src/config.ts +49 -0
  119. package/src/decorators/Live.ts +16 -0
  120. package/src/emitters/AppEmitter.ts +69 -0
  121. package/src/emitters/pg-listener.ts +171 -0
  122. package/src/emitters/pg-notify-emitter.ts +143 -0
  123. package/src/emitters/pg-trigger-sql.ts +111 -0
  124. package/src/emitters/statement-keys.ts +229 -0
  125. package/src/graph/DependencyGraph.ts +147 -0
  126. package/src/graph/SubscriptionRegistry.ts +109 -0
  127. package/src/graph/dep-key.ts +41 -0
  128. package/src/graph/types.ts +15 -0
  129. package/src/http/etag.ts +144 -0
  130. package/src/index.ts +81 -0
  131. package/src/metadata.ts +21 -0
  132. package/src/observability.ts +54 -0
  133. package/src/patch/PatchEngine.ts +235 -0
  134. package/src/patch/types.ts +39 -0
  135. package/src/resource/ResourceRegistry.ts +178 -0
  136. package/src/resource/dependency-context.ts +48 -0
  137. package/src/resource/instance-id.ts +65 -0
  138. package/src/resource/prefetch.ts +49 -0
  139. package/src/resource/route-executor.ts +134 -0
  140. package/src/resource/types.ts +35 -0
  141. package/src/runtime.ts +69 -0
  142. package/src/shared/canonical.ts +63 -0
  143. package/src/shared/descriptor.ts +44 -0
  144. package/src/shared/hash.ts +31 -0
  145. package/src/shared/inputs.ts +20 -0
  146. package/src/shared/protocol.ts +96 -0
  147. package/src/transport/FanTransport.ts +34 -0
  148. package/src/transport/LiveGateway.ts +124 -0
  149. package/src/transport/SocketTransport.ts +43 -0
  150. package/src/transport/SseTransport.ts +112 -0
  151. package/src/transport/scope-resolver.ts +25 -0
  152. package/src/transport/sse-routes.ts +94 -0
  153. package/test/acceptance-fase-2.test.ts +248 -0
  154. package/test/acceptance-fase-3.test.ts +216 -0
  155. package/test/acceptance.test.ts +306 -0
  156. package/test/angular-adapter.test.ts +151 -0
  157. package/test/app-emitter.test.ts +112 -0
  158. package/test/authorization.test.ts +175 -0
  159. package/test/client-core.test.ts +287 -0
  160. package/test/client-transport.test.ts +154 -0
  161. package/test/dependency-graph.test.ts +83 -0
  162. package/test/etag.test.ts +193 -0
  163. package/test/fan-transport.test.ts +62 -0
  164. package/test/happydom.ts +24 -0
  165. package/test/inputs-body.test.ts +81 -0
  166. package/test/instance-id.test.ts +104 -0
  167. package/test/live-engine.test.ts +593 -0
  168. package/test/live-post.test.ts +81 -0
  169. package/test/metrics.test.ts +131 -0
  170. package/test/optimistic.test.ts +166 -0
  171. package/test/orm-integration.test.ts +75 -0
  172. package/test/patch-engine.test.ts +121 -0
  173. package/test/pg-bus.test.ts +162 -0
  174. package/test/pg-listen-probe.test.ts +56 -0
  175. package/test/pg-listener.test.ts +134 -0
  176. package/test/pg-notify-emitter.test.ts +113 -0
  177. package/test/pg-notify-integration.test.ts +88 -0
  178. package/test/polling-auth.test.ts +169 -0
  179. package/test/prefetch.test.ts +137 -0
  180. package/test/react-adapter.test.tsx +83 -0
  181. package/test/react-rerender.test.tsx +131 -0
  182. package/test/resource-registry-helper.ts +35 -0
  183. package/test/resource-registry.test.ts +144 -0
  184. package/test/route-pipeline.test.ts +139 -0
  185. package/test/shared.test.ts +53 -0
  186. package/test/sse-routes.test.ts +204 -0
  187. package/test/sse-transport.test.ts +91 -0
  188. package/test/statement-keys.test.ts +126 -0
  189. package/test/subscription-registry.test.ts +61 -0
  190. package/test/transport-ladder.test.ts +293 -0
  191. package/test/transport.test.ts +274 -0
  192. package/test/types/optimistic-types.ts +34 -0
  193. package/test/use-live-action.test.ts +127 -0
  194. package/test/use-live.test.ts +109 -0
  195. package/test/vanilla-adapter.test.ts +200 -0
  196. package/test/vue-adapter.test.ts +122 -0
  197. package/tsconfig.json +15 -0
  198. package/tsconfig.tsbuildinfo +1 -0
  199. package/tsconfig.types.json +10 -0
@@ -0,0 +1,593 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test';
2
+ import { Controller, Get, Query } from '@carno.js/core';
3
+ import { Live } from '../src/decorators/Live';
4
+ import { resolveLiveConfig } from '../src/config';
5
+ import { DependencyGraph } from '../src/graph/DependencyGraph';
6
+ import { SubscriptionRegistry } from '../src/graph/SubscriptionRegistry';
7
+ import { InProcessBus } from '../src/bus/InProcessBus';
8
+ import { ResourceRegistry } from '../src/resource/ResourceRegistry';
9
+ import { dependencyContext } from '../src/resource/dependency-context';
10
+ import { LiveEngine, type LiveTransport } from '../src/LiveEngine';
11
+ import type { ServerMessage } from '../src/shared/protocol';
12
+ import { LiveRouteExecutionError } from '../src/resource/route-executor';
13
+ import type { LiveResourceExecutor } from '../src/resource/types';
14
+ import { directResourceExecutor } from './resource-registry-helper';
15
+
16
+ const rows: { id: number; name: string; hits: number }[] = [];
17
+
18
+ @Controller('/users')
19
+ class UsersController {
20
+ @Get('/')
21
+ @Live({ key: 'id', shared: 'public' })
22
+ list(@Query('q') q?: string) {
23
+ dependencyContext.current()?.add({ key: 'orm:users', columns: ['id', 'name'] });
24
+ return rows.filter(row => !q || row.name.includes(q)).map(row => ({ id: row.id, name: row.name }));
25
+ }
26
+
27
+ @Get('/private')
28
+ @Live()
29
+ mine() {
30
+ dependencyContext.current()?.add({ key: 'orm:users', columns: null });
31
+ return { ok: true };
32
+ }
33
+ }
34
+
35
+ class FakeTransport implements LiveTransport {
36
+ readonly sent: { connectionId: string; message: ServerMessage }[] = [];
37
+ result = 1;
38
+
39
+ send(connectionId: string, message: ServerMessage): number {
40
+ this.sent.push({ connectionId, message });
41
+ return this.result;
42
+ }
43
+
44
+ messagesFor(connectionId: string): ServerMessage[] {
45
+ return this.sent.filter(entry => entry.connectionId === connectionId).map(entry => entry.message);
46
+ }
47
+
48
+ clear(): void {
49
+ this.sent.length = 0;
50
+ }
51
+ }
52
+
53
+ function build(overrides = {}) {
54
+ const resources = new ResourceRegistry();
55
+ resources.register(UsersController, new UsersController(), directResourceExecutor);
56
+
57
+ const bus = new InProcessBus();
58
+ const transport = new FakeTransport();
59
+ const engine = new LiveEngine(
60
+ resources,
61
+ new DependencyGraph(),
62
+ new SubscriptionRegistry(),
63
+ bus,
64
+ transport,
65
+ resolveLiveConfig({ coalesceMs: 1, unsubGraceMs: 5, ...overrides })
66
+ );
67
+ engine.start();
68
+
69
+ return { engine, bus, transport };
70
+ }
71
+
72
+ const settle = () => new Promise(resolve => setTimeout(resolve, 25));
73
+
74
+ beforeEach(() => {
75
+ rows.length = 0;
76
+ rows.push({ id: 1, name: 'Ada', hits: 0 });
77
+ });
78
+
79
+ describe('LiveEngine.subscribe', () => {
80
+ test('answers a first subscription with a snapshot', async () => {
81
+ const { engine, transport } = build();
82
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
83
+
84
+ const [message] = transport.messagesFor('c1');
85
+ expect(message.t).toBe('snapshot');
86
+ expect((message as any).data).toEqual([{ id: 1, name: 'Ada' }]);
87
+ expect((message as any).hash).toMatch(/^[0-9a-f]{16}$/);
88
+ });
89
+
90
+ test('answers with current when the client hash already matches', async () => {
91
+ const { engine, transport } = build();
92
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
93
+ const hash = (transport.messagesFor('c1')[0] as any).hash;
94
+ transport.clear();
95
+
96
+ await engine.subscribe('c2', 's1', 'UsersController.list', { params: {}, query: {} }, {}, hash);
97
+
98
+ const [message] = transport.messagesFor('c2');
99
+ expect(message.t).toBe('current');
100
+ expect((message as any).data).toBeUndefined();
101
+ });
102
+
103
+ test('two connections with the same inputs and scope share one instance', async () => {
104
+ const { engine } = build();
105
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
106
+ await engine.subscribe('c2', 's1', 'UsersController.list', { params: {}, query: {} }, {});
107
+
108
+ expect(engine.stats().instances).toBe(1);
109
+ });
110
+
111
+ test('private scope keeps two principals apart', async () => {
112
+ const { engine } = build();
113
+ await engine.subscribe('c1', 's1', 'UsersController.mine', { params: {}, query: {} }, { principal: 1 });
114
+ await engine.subscribe('c2', 's1', 'UsersController.mine', { params: {}, query: {} }, { principal: 2 });
115
+
116
+ expect(engine.stats().instances).toBe(2);
117
+ });
118
+
119
+ test('rejects an unknown resource with an error message', async () => {
120
+ const { engine, transport } = build();
121
+ await engine.subscribe('c1', 's1', 'Nope.nope', { params: {}, query: {} }, {});
122
+
123
+ expect(transport.messagesFor('c1')[0]).toMatchObject({ t: 'error', code: 'unknown_resource' });
124
+ });
125
+
126
+ test('enforces the per-connection instance ceiling', async () => {
127
+ const { engine, transport } = build({ maxInstancesPerConnection: 1 });
128
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
129
+ await engine.subscribe('c1', 's2', 'UsersController.list', { params: {}, query: { q: 'A' } }, {});
130
+
131
+ expect(transport.messagesFor('c1')[1]).toMatchObject({ t: 'error', code: 'too_many_instances' });
132
+ });
133
+
134
+ test('single-flights initial creation for a shared instance', async () => {
135
+ let releaseCompute!: () => void;
136
+ const computeGate = new Promise<void>(resolve => {
137
+ releaseCompute = resolve;
138
+ });
139
+ let firstComputeStarted!: () => void;
140
+ const firstStarted = new Promise<void>(resolve => {
141
+ firstComputeStarted = resolve;
142
+ });
143
+ let computeCalls = 0;
144
+
145
+ @Controller('/single-flight')
146
+ class SingleFlightController {
147
+ @Get('/')
148
+ @Live({ shared: 'public' })
149
+ async list() {
150
+ computeCalls++;
151
+ firstComputeStarted();
152
+ await computeGate;
153
+ return { value: 'shared' };
154
+ }
155
+ }
156
+
157
+ const resources = new ResourceRegistry();
158
+ resources.register(SingleFlightController, new SingleFlightController(), directResourceExecutor);
159
+ const transport = new FakeTransport();
160
+ const engine = new LiveEngine(
161
+ resources,
162
+ new DependencyGraph(),
163
+ new SubscriptionRegistry(),
164
+ new InProcessBus(),
165
+ transport,
166
+ resolveLiveConfig({ coalesceMs: 1 })
167
+ );
168
+ engine.start();
169
+
170
+ const inputs = { params: {}, query: {} };
171
+ const first = engine.subscribe('c1', 's1', 'SingleFlightController.list', inputs, {});
172
+ const second = engine.subscribe('c2', 's2', 'SingleFlightController.list', inputs, {});
173
+
174
+ await firstStarted;
175
+ releaseCompute();
176
+ await Promise.all([first, second]);
177
+
178
+ expect(computeCalls).toBe(1);
179
+ expect(engine.stats().instances).toBe(1);
180
+ expect(transport.messagesFor('c1')[0]).toMatchObject({
181
+ t: 'snapshot',
182
+ data: { value: 'shared' }
183
+ });
184
+ expect(transport.messagesFor('c2')[0]).toMatchObject({
185
+ t: 'snapshot',
186
+ data: { value: 'shared' }
187
+ });
188
+ });
189
+
190
+ test('reserves node capacity for a distinct instance being created', async () => {
191
+ let releaseCompute!: () => void;
192
+ const computeGate = new Promise<void>(resolve => {
193
+ releaseCompute = resolve;
194
+ });
195
+ let firstComputeStarted!: () => void;
196
+ const firstStarted = new Promise<void>(resolve => {
197
+ firstComputeStarted = resolve;
198
+ });
199
+ let computeCalls = 0;
200
+
201
+ @Controller('/capacity-flight')
202
+ class CapacityFlightController {
203
+ @Get('/')
204
+ @Live({ shared: 'public' })
205
+ async list(@Query('q') q?: string) {
206
+ computeCalls++;
207
+ firstComputeStarted();
208
+ await computeGate;
209
+ return { value: q ?? 'all' };
210
+ }
211
+ }
212
+
213
+ const resources = new ResourceRegistry();
214
+ resources.register(CapacityFlightController, new CapacityFlightController(), directResourceExecutor);
215
+ const transport = new FakeTransport();
216
+ const engine = new LiveEngine(
217
+ resources,
218
+ new DependencyGraph(),
219
+ new SubscriptionRegistry(),
220
+ new InProcessBus(),
221
+ transport,
222
+ resolveLiveConfig({ coalesceMs: 1, maxInstancesPerNode: 1 })
223
+ );
224
+ engine.start();
225
+
226
+ const first = engine.subscribe(
227
+ 'c1',
228
+ 's1',
229
+ 'CapacityFlightController.list',
230
+ { params: {}, query: {} },
231
+ {}
232
+ );
233
+ await firstStarted;
234
+
235
+ const second = engine.subscribe(
236
+ 'c2',
237
+ 's2',
238
+ 'CapacityFlightController.list',
239
+ { params: {}, query: { q: 'other' } },
240
+ {}
241
+ );
242
+
243
+ await new Promise(resolve => setTimeout(resolve, 0));
244
+ releaseCompute();
245
+ await Promise.all([first, second]);
246
+
247
+ expect(computeCalls).toBe(1);
248
+ expect(transport.messagesFor('c1')[0]).toMatchObject({ t: 'snapshot' });
249
+ expect(transport.messagesFor('c2')[0]).toMatchObject({
250
+ t: 'error',
251
+ code: 'node_at_capacity'
252
+ });
253
+ expect(engine.stats().instances).toBe(1);
254
+ });
255
+
256
+ test('recomputes with the subscription scope and revokes on a pipeline denial', async () => {
257
+ let allowed = true;
258
+ let receivedHeader: string | null = null;
259
+
260
+ @Controller('/scoped')
261
+ class ScopedController {
262
+ @Get('/')
263
+ @Live({ shared: 'public' })
264
+ read() {
265
+ dependencyContext.current()?.add({ key: 'orm:scope', columns: null });
266
+ return { ok: true };
267
+ }
268
+ }
269
+
270
+ const executor: LiveResourceExecutor = async (instance, resource, inputs, context) => {
271
+ receivedHeader = new Headers(context.scope?.headers).get('x-allow');
272
+
273
+ if (!allowed) {
274
+ throw new LiveRouteExecutionError(403, 'forbidden');
275
+ }
276
+
277
+ return directResourceExecutor(instance, resource, inputs, context);
278
+ };
279
+ const resources = new ResourceRegistry();
280
+ resources.register(ScopedController, new ScopedController(), executor);
281
+ const bus = new InProcessBus();
282
+ const transport = new FakeTransport();
283
+ const engine = new LiveEngine(
284
+ resources,
285
+ new DependencyGraph(),
286
+ new SubscriptionRegistry(),
287
+ bus,
288
+ transport,
289
+ resolveLiveConfig({ coalesceMs: 1, unsubGraceMs: 5 })
290
+ );
291
+ engine.start();
292
+
293
+ await engine.subscribe(
294
+ 'c1',
295
+ 's1',
296
+ 'ScopedController.read',
297
+ { params: {}, query: {} },
298
+ { principal: 'ada', headers: { 'x-allow': 'yes' } }
299
+ );
300
+ expect(receivedHeader).toBe('yes');
301
+ transport.clear();
302
+
303
+ allowed = false;
304
+ engine.invalidate('orm:scope');
305
+ await settle();
306
+
307
+ expect(transport.messagesFor('c1')).toEqual([
308
+ expect.objectContaining({ t: 'error', code: 'forbidden' })
309
+ ]);
310
+ });
311
+ });
312
+
313
+ describe('LiveEngine invalidation', () => {
314
+ test('an invalidation that changes data produces a patch', async () => {
315
+ const { engine, bus, transport } = build();
316
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
317
+ transport.clear();
318
+
319
+ rows.push({ id: 2, name: 'Bob', hits: 0 });
320
+ bus.publish([{ key: 'orm:users', columns: ['name'] }]);
321
+ await settle();
322
+
323
+ const [message] = transport.messagesFor('c1');
324
+ expect(message.t).toBe('patch');
325
+ expect((message as any).ops).toEqual([
326
+ { op: 'upsert', path: [], key: 2, index: 1, value: { id: 2, name: 'Bob' } },
327
+ { op: 'order', path: [], keys: [1, 2] }
328
+ ]);
329
+ expect((message as any).from).toBe(1);
330
+ expect((message as any).to).toBe(2);
331
+ });
332
+
333
+ test('a recompute that changes nothing sends nothing', async () => {
334
+ const { engine, bus, transport } = build();
335
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
336
+ transport.clear();
337
+
338
+ bus.publish([{ key: 'orm:users', columns: ['name'] }]);
339
+ await settle();
340
+
341
+ expect(transport.sent).toHaveLength(0);
342
+ expect(engine.stats().recomputesWithoutPatch).toBe(1);
343
+ });
344
+
345
+ test('a write to a column the resource does not read is ignored', async () => {
346
+ const { engine, bus, transport } = build();
347
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
348
+ transport.clear();
349
+
350
+ rows.push({ id: 2, name: 'Bob', hits: 0 });
351
+ bus.publish([{ key: 'orm:users', columns: ['last_seen_at'] }]);
352
+ await settle();
353
+
354
+ expect(transport.sent).toHaveLength(0);
355
+ expect(engine.stats().recomputes).toBe(1);
356
+ });
357
+
358
+ test('coalesces a burst of invalidations into one patch', async () => {
359
+ const { engine, bus, transport } = build();
360
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
361
+ transport.clear();
362
+
363
+ for (let i = 2; i <= 10; i++) {
364
+ rows.push({ id: i, name: `U${i}`, hits: 0 });
365
+ bus.publish([{ key: 'orm:users', columns: ['name'] }]);
366
+ }
367
+ await settle();
368
+
369
+ expect(transport.messagesFor('c1').filter(m => m.t === 'patch')).toHaveLength(1);
370
+ });
371
+
372
+ test('fans one patch out to every subscriber of the instance', async () => {
373
+ const { engine, bus, transport } = build();
374
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
375
+ await engine.subscribe('c2', 'sX', 'UsersController.list', { params: {}, query: {} }, {});
376
+ transport.clear();
377
+
378
+ rows.push({ id: 2, name: 'Bob', hits: 0 });
379
+ bus.publish([{ key: 'orm:users', columns: ['name'] }]);
380
+ await settle();
381
+
382
+ expect(transport.messagesFor('c1')[0]).toMatchObject({ t: 'patch', sid: 's1' });
383
+ expect(transport.messagesFor('c2')[0]).toMatchObject({ t: 'patch', sid: 'sX' });
384
+ });
385
+
386
+ test('sends a snapshot instead of a patch when the socket keeps back-pressuring', async () => {
387
+ const { engine, bus, transport } = build({ maxPendingPatches: 1 });
388
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
389
+ transport.clear();
390
+ transport.result = -1;
391
+
392
+ for (let i = 2; i <= 4; i++) {
393
+ rows.push({ id: i, name: `U${i}`, hits: 0 });
394
+ bus.publish([{ key: 'orm:users', columns: ['name'] }]);
395
+ await settle();
396
+ }
397
+
398
+ expect(transport.messagesFor('c1').some(m => m.t === 'snapshot')).toBe(true);
399
+ });
400
+
401
+ test('reports stale when a recompute throws', async () => {
402
+ const { engine, bus, transport } = build();
403
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
404
+ transport.clear();
405
+
406
+ const original = rows.filter;
407
+ (rows as any).filter = () => { throw new Error('db down'); };
408
+ bus.publish([{ key: 'orm:users', columns: ['name'] }]);
409
+ await settle();
410
+ (rows as any).filter = original;
411
+
412
+ expect(transport.messagesFor('c1')[0]).toMatchObject({ t: 'stale' });
413
+ });
414
+ });
415
+
416
+ describe('LiveEngine lifecycle', () => {
417
+ test('drops the instance only after the grace period', async () => {
418
+ const { engine } = build({ unsubGraceMs: 30 });
419
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
420
+ engine.unsubscribe('c1', 's1');
421
+
422
+ expect(engine.stats().instances).toBe(1);
423
+ await new Promise(resolve => setTimeout(resolve, 60));
424
+ expect(engine.stats().instances).toBe(0);
425
+ });
426
+
427
+ test('resubscribing inside the grace period reuses the instance', async () => {
428
+ const { engine, transport } = build({ unsubGraceMs: 50 });
429
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
430
+ engine.unsubscribe('c1', 's1');
431
+ transport.clear();
432
+
433
+ await engine.subscribe('c1', 's2', 'UsersController.list', { params: {}, query: {} }, {});
434
+ await new Promise(resolve => setTimeout(resolve, 80));
435
+
436
+ expect(engine.stats().instances).toBe(1);
437
+ expect(engine.stats().recomputes).toBe(1);
438
+ });
439
+
440
+ test('resubscribe after an invalidation during unsub grace does not send stale state', async () => {
441
+ const { engine, bus, transport } = build({ unsubGraceMs: 80 });
442
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
443
+ const hash = (transport.messagesFor('c1')[0] as any).hash;
444
+ engine.unsubscribe('c1', 's1');
445
+ transport.clear();
446
+
447
+ rows.push({ id: 2, name: 'Bob', hits: 0 });
448
+ bus.publish([{ key: 'orm:users', columns: ['name'] }]);
449
+ await settle();
450
+
451
+ await engine.subscribe('c1', 's2', 'UsersController.list', { params: {}, query: {} }, {}, hash);
452
+
453
+ const [message] = transport.messagesFor('c1');
454
+ expect(message.t).toBe('snapshot');
455
+ expect((message as any).data).toEqual([
456
+ { id: 1, name: 'Ada' },
457
+ { id: 2, name: 'Bob' }
458
+ ]);
459
+ });
460
+
461
+ test('dropping a connection releases everything it held', async () => {
462
+ const { engine } = build({ unsubGraceMs: 1 });
463
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
464
+ engine.dropConnection('c1');
465
+ await new Promise(resolve => setTimeout(resolve, 20));
466
+
467
+ expect(engine.stats().instances).toBe(0);
468
+ });
469
+
470
+ test('dropping the connection during the first createInstance does not orphan the instance', async () => {
471
+ let releaseCompute!: () => void;
472
+ const computeGate = new Promise<void>(resolve => {
473
+ releaseCompute = resolve;
474
+ });
475
+
476
+ @Controller('/slow')
477
+ class SlowController {
478
+ @Get('/')
479
+ @Live({ shared: 'public' })
480
+ async list() {
481
+ await computeGate;
482
+ return [{ id: 1 }];
483
+ }
484
+ }
485
+
486
+ const resources = new ResourceRegistry();
487
+ resources.register(SlowController, new SlowController(), directResourceExecutor);
488
+ const engine = new LiveEngine(
489
+ resources,
490
+ new DependencyGraph(),
491
+ new SubscriptionRegistry(),
492
+ new InProcessBus(),
493
+ new FakeTransport(),
494
+ resolveLiveConfig({ coalesceMs: 1, unsubGraceMs: 15 })
495
+ );
496
+ engine.start();
497
+
498
+ const pending = engine.subscribe('c1', 's1', 'SlowController.list', { params: {}, query: {} }, {});
499
+ await new Promise(resolve => setTimeout(resolve, 0));
500
+ engine.dropConnection('c1');
501
+ releaseCompute();
502
+ await pending;
503
+ await new Promise(resolve => setTimeout(resolve, 40));
504
+
505
+ expect(engine.stats().instances).toBe(0);
506
+ });
507
+
508
+ test('dropping the first subscriber during createInstance does not roll back a second', async () => {
509
+ let releaseCompute!: () => void;
510
+ const computeGate = new Promise<void>(resolve => {
511
+ releaseCompute = resolve;
512
+ });
513
+
514
+ @Controller('/slow2')
515
+ class SlowController {
516
+ @Get('/')
517
+ @Live({ shared: 'public' })
518
+ async list() {
519
+ await computeGate;
520
+ return [{ id: 1 }];
521
+ }
522
+ }
523
+
524
+ const resources = new ResourceRegistry();
525
+ resources.register(SlowController, new SlowController(), directResourceExecutor);
526
+ const transport = new FakeTransport();
527
+ const engine = new LiveEngine(
528
+ resources,
529
+ new DependencyGraph(),
530
+ new SubscriptionRegistry(),
531
+ new InProcessBus(),
532
+ transport,
533
+ resolveLiveConfig({ coalesceMs: 1, unsubGraceMs: 15 })
534
+ );
535
+ engine.start();
536
+
537
+ const first = engine.subscribe('c1', 's1', 'SlowController.list', { params: {}, query: {} }, {});
538
+ const second = engine.subscribe('c2', 's1', 'SlowController.list', { params: {}, query: {} }, {});
539
+ await new Promise(resolve => setTimeout(resolve, 0));
540
+ engine.dropConnection('c1');
541
+ releaseCompute();
542
+ await Promise.all([first, second]);
543
+
544
+ expect(transport.messagesFor('c2')[0]).toMatchObject({ t: 'snapshot' });
545
+ expect(engine.stats().instances).toBe(1);
546
+ await new Promise(resolve => setTimeout(resolve, 40));
547
+ expect(engine.stats().instances).toBe(1);
548
+ });
549
+
550
+ test('resubscribing the same sid does not leak the instance after one unsub', async () => {
551
+ const { engine, transport } = build({ unsubGraceMs: 20 });
552
+ const inputs = { params: {}, query: {} };
553
+
554
+ await engine.subscribe('c1', 's1', 'UsersController.list', inputs, {});
555
+ transport.clear();
556
+ await engine.subscribe('c1', 's1', 'UsersController.list', inputs, {});
557
+
558
+ expect(transport.messagesFor('c1')).toHaveLength(1);
559
+
560
+ engine.unsubscribe('c1', 's1');
561
+ await new Promise(resolve => setTimeout(resolve, 40));
562
+
563
+ expect(engine.stats().instances).toBe(0);
564
+ });
565
+
566
+ test('reusing a sid for different inputs releases the previous instance', async () => {
567
+ const { engine } = build({ unsubGraceMs: 20 });
568
+
569
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: {} }, {});
570
+ await engine.subscribe('c1', 's1', 'UsersController.list', { params: {}, query: { q: 'A' } }, {});
571
+ engine.unsubscribe('c1', 's1');
572
+ await new Promise(resolve => setTimeout(resolve, 40));
573
+
574
+ expect(engine.stats().instances).toBe(0);
575
+ });
576
+
577
+ test('two sids on the same instance both have to unsubscribe', async () => {
578
+ const { engine } = build({ unsubGraceMs: 20 });
579
+ const inputs = { params: {}, query: {} };
580
+
581
+ await engine.subscribe('c1', 's1', 'UsersController.list', inputs, {});
582
+ await engine.subscribe('c1', 's2', 'UsersController.list', inputs, {});
583
+ engine.unsubscribe('c1', 's1');
584
+ await new Promise(resolve => setTimeout(resolve, 40));
585
+
586
+ expect(engine.stats().instances).toBe(1);
587
+
588
+ engine.unsubscribe('c1', 's2');
589
+ await new Promise(resolve => setTimeout(resolve, 40));
590
+
591
+ expect(engine.stats().instances).toBe(0);
592
+ });
593
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { Body, Controller, Get, Post, Query } from '@carno.js/core';
3
+ import { Live } from '../src/decorators/Live';
4
+ import { LiveValidationError, ResourceRegistry } from '../src/resource/ResourceRegistry';
5
+ import { directResourceExecutor } from './resource-registry-helper';
6
+
7
+ interface ReportFilter {
8
+ status: string;
9
+ limit: number;
10
+ }
11
+
12
+ @Controller('/reports')
13
+ class ReportsController {
14
+ @Post('/search')
15
+ @Live({ key: 'id' })
16
+ search(@Body() filter: ReportFilter) {
17
+ return [{ id: 1, status: filter.status, limit: filter.limit }];
18
+ }
19
+
20
+ @Post('/by-status')
21
+ @Live()
22
+ byStatus(@Body('status') status: string) {
23
+ return { status };
24
+ }
25
+ }
26
+
27
+ @Controller('/bad-get-body')
28
+ class GetWithBodyController {
29
+ @Get('/')
30
+ @Live()
31
+ read(@Body() filter: unknown) {
32
+ return filter;
33
+ }
34
+ }
35
+
36
+ describe('@Live() on @Post()', () => {
37
+ test('registers a POST handler as a live resource', () => {
38
+ const registry = new ResourceRegistry();
39
+ registry.register(ReportsController, new ReportsController(), directResourceExecutor);
40
+
41
+ expect(registry.ids().sort()).toEqual([
42
+ 'ReportsController.byStatus',
43
+ 'ReportsController.search'
44
+ ]);
45
+ });
46
+
47
+ test('binds the whole body to a bare @Body()', async () => {
48
+ const registry = new ResourceRegistry();
49
+ registry.register(ReportsController, new ReportsController(), directResourceExecutor);
50
+
51
+ const resource = registry.get('ReportsController.search')!;
52
+ const { data } = await registry.compute(resource, {
53
+ params: {},
54
+ query: {},
55
+ body: { status: 'open', limit: 10 }
56
+ });
57
+
58
+ expect(data).toEqual([{ id: 1, status: 'open', limit: 10 }]);
59
+ });
60
+
61
+ test('binds one field to @Body(key)', async () => {
62
+ const registry = new ResourceRegistry();
63
+ registry.register(ReportsController, new ReportsController(), directResourceExecutor);
64
+
65
+ const resource = registry.get('ReportsController.byStatus')!;
66
+ const { data } = await registry.compute(resource, {
67
+ params: {},
68
+ query: {},
69
+ body: { status: 'closed' }
70
+ });
71
+
72
+ expect(data).toEqual({ status: 'closed' });
73
+ });
74
+
75
+ test('refuses @Body() on a live @Get()', () => {
76
+ const registry = new ResourceRegistry();
77
+
78
+ expect(() => registry.register(GetWithBodyController, new GetWithBodyController(), directResourceExecutor))
79
+ .toThrow(/carries no body/);
80
+ });
81
+ });