@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,53 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { canonical, NonSerializableInputError } from '../src/shared/canonical';
3
+ import { fnv1a64 } from '../src/shared/hash';
4
+
5
+ describe('canonical', () => {
6
+ test('orders object keys so equal inputs produce equal strings', () => {
7
+ expect(canonical({ b: 1, a: 2 })).toBe(canonical({ a: 2, b: 1 }));
8
+ expect(canonical({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
9
+ });
10
+
11
+ test('preserves array order', () => {
12
+ expect(canonical([3, 1, 2])).toBe('[3,1,2]');
13
+ });
14
+
15
+ test('drops undefined properties but keeps null', () => {
16
+ expect(canonical({ a: undefined, b: null })).toBe('{"b":null}');
17
+ });
18
+
19
+ test('normalizes negative zero', () => {
20
+ expect(canonical(-0)).toBe(canonical(0));
21
+ });
22
+
23
+ test('rejects non-serializable inputs', () => {
24
+ expect(() => canonical(new Date())).toThrow(NonSerializableInputError);
25
+ expect(() => canonical({ a: () => 1 })).toThrow(NonSerializableInputError);
26
+ expect(() => canonical(Number.NaN)).toThrow(NonSerializableInputError);
27
+ });
28
+
29
+ test('reports the path of the offending value', () => {
30
+ try {
31
+ canonical({ filters: [{ since: new Date() }] });
32
+ throw new Error('should have thrown');
33
+ } catch (err) {
34
+ expect((err as NonSerializableInputError).path).toBe('$.filters[0].since');
35
+ }
36
+ });
37
+ });
38
+
39
+ describe('fnv1a64', () => {
40
+ test('is deterministic and 16 hex chars wide', () => {
41
+ const hash = fnv1a64('carno');
42
+ expect(hash).toMatch(/^[0-9a-f]{16}$/);
43
+ expect(fnv1a64('carno')).toBe(hash);
44
+ });
45
+
46
+ test('separates inputs that differ only by order', () => {
47
+ expect(fnv1a64('ab')).not.toBe(fnv1a64('ba'));
48
+ });
49
+
50
+ test('separates the empty string from a zero byte', () => {
51
+ expect(fnv1a64('')).not.toBe(fnv1a64(String.fromCharCode(0)));
52
+ });
53
+ });
@@ -0,0 +1,204 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { Controller, Get } from '@carno.js/core';
3
+ import { createTestHarness } from '../../core/dist/testing/TestHarness.js';
4
+ import { Live } from '../src/decorators/Live';
5
+ import { LivePlugin } from '../src/LivePlugin';
6
+ import { closeLiveRuntime, getLiveRuntime } from '../src/runtime';
7
+
8
+ // happy-dom (loaded by react-rerender.test.tsx) replaces global fetch with a
9
+ // browser fetch that enforces CORS and buffers the body. These tests talk to
10
+ // a real Bun.serve, so they use the native one captured before that register.
11
+ const fetch =
12
+ (globalThis as { __carnoNativeFetch?: typeof globalThis.fetch }).__carnoNativeFetch
13
+ ?? globalThis.fetch;
14
+ const NativeAbortController =
15
+ (globalThis as { __carnoNativeAbortController?: typeof AbortController }).__carnoNativeAbortController
16
+ ?? globalThis.AbortController;
17
+
18
+ @Controller('/numbers')
19
+ class NumbersController {
20
+ @Get('/')
21
+ @Live({ shared: 'public', dependsOn: ['app:numbers'] })
22
+ list() {
23
+ return [1, 2, 3];
24
+ }
25
+ }
26
+
27
+ /** Reads SSE frames off the response body, one `data:` payload at a time. */
28
+ async function* frames(response: Response): AsyncGenerator<any> {
29
+ const reader = response.body!.getReader();
30
+ const decoder = new TextDecoder();
31
+ let buffer = '';
32
+
33
+ while (true) {
34
+ const { value, done } = await reader.read();
35
+
36
+ if (done) {
37
+ return;
38
+ }
39
+
40
+ buffer += decoder.decode(value, { stream: true });
41
+
42
+ let split = buffer.indexOf('\n\n');
43
+
44
+ while (split !== -1) {
45
+ const frame = buffer.slice(0, split);
46
+ buffer = buffer.slice(split + 2);
47
+
48
+ if (frame.startsWith('data: ')) {
49
+ yield JSON.parse(frame.slice(6));
50
+ }
51
+
52
+ split = buffer.indexOf('\n\n');
53
+ }
54
+ }
55
+ }
56
+
57
+ async function next(stream: AsyncGenerator<any>, predicate: (frame: any) => boolean, timeoutMs = 4000): Promise<any> {
58
+ const deadline = Date.now() + timeoutMs;
59
+
60
+ while (Date.now() < deadline) {
61
+ const { value, done } = await stream.next();
62
+
63
+ if (done) {
64
+ throw new Error('the stream ended before the frame arrived');
65
+ }
66
+
67
+ if (predicate(value)) {
68
+ return value;
69
+ }
70
+ }
71
+
72
+ throw new Error('timed out waiting for a frame');
73
+ }
74
+
75
+ afterEach(async () => {
76
+ await closeLiveRuntime();
77
+ });
78
+
79
+ describe('SSE routes', () => {
80
+ test('a subscription over SSE receives a snapshot and then a patch', async () => {
81
+ const harness = await createTestHarness({
82
+ plugins: [LivePlugin.create({
83
+ controllers: [NumbersController],
84
+ sse: true,
85
+ config: { coalesceMs: 5, sseHeartbeatMs: 0 }
86
+ })],
87
+ listen: true
88
+ });
89
+
90
+ const response = await fetch(`http://127.0.0.1:${harness.port}/live/sse`);
91
+ expect(response.headers.get('content-type')).toContain('text/event-stream');
92
+
93
+ const stream = frames(response);
94
+ const ready = await next(stream, frame => frame.t === 'ready');
95
+ expect(typeof ready.cid).toBe('string');
96
+
97
+ const post = (message: unknown) => fetch(`http://127.0.0.1:${harness.port}/live/control`, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({ cid: ready.cid, message })
101
+ });
102
+
103
+ await post({ t: 'hello', v: 1 });
104
+ await post({ t: 'sub', sid: 's1', resource: 'NumbersController.list', inputs: { params: {}, query: {} } });
105
+
106
+ const snapshot = await next(stream, frame => frame.t === 'snapshot');
107
+ expect(snapshot.data).toEqual([1, 2, 3]);
108
+
109
+ await harness.close();
110
+ });
111
+
112
+ test('the control endpoint refuses an unknown connection id', async () => {
113
+ const harness = await createTestHarness({
114
+ plugins: [LivePlugin.create({ controllers: [NumbersController], sse: true })],
115
+ listen: true
116
+ });
117
+
118
+ const response = await fetch(`http://127.0.0.1:${harness.port}/live/control`, {
119
+ method: 'POST',
120
+ headers: { 'Content-Type': 'application/json' },
121
+ body: JSON.stringify({ cid: 'sse:not-a-real-one', message: { t: 'hello', v: 1 } })
122
+ });
123
+
124
+ // The cid is a bearer for a live connection. An unknown one is not a
125
+ // no-op to be swallowed; it is a request that must not be served.
126
+ expect(response.status).toBe(404);
127
+ await harness.close();
128
+ });
129
+
130
+ test('the control endpoint refuses a malformed body', async () => {
131
+ const harness = await createTestHarness({
132
+ plugins: [LivePlugin.create({ controllers: [NumbersController], sse: true })],
133
+ listen: true
134
+ });
135
+
136
+ const response = await fetch(`http://127.0.0.1:${harness.port}/live/control`, {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: 'not json'
140
+ });
141
+
142
+ expect(response.status).toBe(400);
143
+ await harness.close();
144
+ });
145
+
146
+ test('cancelling the stream drops the scope and refuses a late control message', async () => {
147
+ const harness = await createTestHarness({
148
+ plugins: [LivePlugin.create({
149
+ controllers: [NumbersController],
150
+ sse: true,
151
+ config: { sseHeartbeatMs: 0 }
152
+ })],
153
+ listen: true
154
+ });
155
+
156
+ const abort = new NativeAbortController();
157
+ const response = await fetch(`http://127.0.0.1:${harness.port}/live/sse`, { signal: abort.signal });
158
+ const reader = response.body!.getReader();
159
+ const ready = JSON.parse(
160
+ new TextDecoder().decode((await reader.read()).value).replace(/^data: /, '').trim()
161
+ );
162
+ expect(typeof ready.cid).toBe('string');
163
+
164
+ await fetch(`http://127.0.0.1:${harness.port}/live/control`, {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/json' },
167
+ body: JSON.stringify({ cid: ready.cid, message: { t: 'hello', v: 1 } })
168
+ });
169
+ expect(getLiveRuntime().scopes.has(ready.cid)).toBe(true);
170
+
171
+ abort.abort();
172
+
173
+ const deadline = Date.now() + 1000;
174
+ while (getLiveRuntime().scopes.has(ready.cid) && Date.now() < deadline) {
175
+ await new Promise(resolve => setTimeout(resolve, 5));
176
+ }
177
+
178
+ expect(getLiveRuntime().scopes.has(ready.cid)).toBe(false);
179
+
180
+ const late = await fetch(`http://127.0.0.1:${harness.port}/live/control`, {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify({
184
+ cid: ready.cid,
185
+ message: { t: 'sub', sid: 's1', resource: 'NumbersController.list', inputs: { params: {}, query: {} } }
186
+ })
187
+ });
188
+ expect(late.status).toBe(404);
189
+
190
+ await harness.close();
191
+ });
192
+
193
+ test('no routes exist when sse is off', async () => {
194
+ const harness = await createTestHarness({
195
+ plugins: [LivePlugin.create({ controllers: [NumbersController] })],
196
+ listen: true
197
+ });
198
+
199
+ const response = await fetch(`http://127.0.0.1:${harness.port}/live/sse`);
200
+
201
+ expect(response.status).toBe(404);
202
+ await harness.close();
203
+ });
204
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { SseTransport } from '../src/transport/SseTransport';
3
+ import type { ServerMessage } from '../src/shared/protocol';
4
+
5
+ const SNAPSHOT: ServerMessage = { t: 'snapshot', sid: 's1', rev: 1, hash: 'h1', data: [{ id: 1 }] };
6
+
7
+ async function readOne(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
8
+ const { value } = await reader.read();
9
+ return new TextDecoder().decode(value);
10
+ }
11
+
12
+ describe('SseTransport', () => {
13
+ test('open() hands back a stream, and owns() claims the connection', () => {
14
+ const transport = new SseTransport({ heartbeatMs: 0, maxConnections: 10 });
15
+ const stream = transport.open('sse:1');
16
+
17
+ expect(stream).toBeInstanceOf(ReadableStream);
18
+ expect(transport.owns('sse:1')).toBe(true);
19
+ expect(transport.owns('sse:2')).toBe(false);
20
+ });
21
+
22
+ test('the first frame carries the connection id, because the client has no other way to learn it', async () => {
23
+ const transport = new SseTransport({ heartbeatMs: 0, maxConnections: 10 });
24
+ const reader = transport.open('sse:1').getReader();
25
+
26
+ expect(await readOne(reader)).toBe('data: {"t":"ready","cid":"sse:1"}\n\n');
27
+ });
28
+
29
+ test('a message is written as one SSE frame of JSON', async () => {
30
+ const transport = new SseTransport({ heartbeatMs: 0, maxConnections: 10 });
31
+ const reader = transport.open('sse:1').getReader();
32
+ await readOne(reader);
33
+
34
+ expect(transport.send('sse:1', SNAPSHOT)).toBe(1);
35
+ expect(await readOne(reader)).toBe(`data: ${JSON.stringify(SNAPSHOT)}\n\n`);
36
+ });
37
+
38
+ test('sending to a closed connection is a drop, not a throw', () => {
39
+ const transport = new SseTransport({ heartbeatMs: 0, maxConnections: 10 });
40
+ transport.open('sse:1');
41
+ transport.close('sse:1');
42
+
43
+ expect(transport.send('sse:1', SNAPSHOT)).toBe(0);
44
+ expect(transport.owns('sse:1')).toBe(false);
45
+ });
46
+
47
+ test('refuses to open past the ceiling', () => {
48
+ const transport = new SseTransport({ heartbeatMs: 0, maxConnections: 1 });
49
+ transport.open('sse:1');
50
+
51
+ expect(() => transport.open('sse:2')).toThrow(/at capacity/);
52
+ });
53
+
54
+ test('a heartbeat keeps the connection from being reaped by a proxy', async () => {
55
+ const transport = new SseTransport({ heartbeatMs: 5, maxConnections: 10 });
56
+ const reader = transport.open('sse:1').getReader();
57
+ await readOne(reader);
58
+
59
+ // A comment frame: valid SSE, ignored by EventSource, enough traffic
60
+ // to stop an idle-timeout proxy from closing the stream.
61
+ expect(await readOne(reader)).toBe(': ping\n\n');
62
+ transport.stop();
63
+ });
64
+
65
+ test('stop() closes every stream it holds', () => {
66
+ const transport = new SseTransport({ heartbeatMs: 0, maxConnections: 10 });
67
+ transport.open('sse:1');
68
+ transport.open('sse:2');
69
+
70
+ transport.stop();
71
+
72
+ expect(transport.count()).toBe(0);
73
+ });
74
+
75
+ test('the client cancelling the stream releases the connection', async () => {
76
+ const transport = new SseTransport({ heartbeatMs: 0, maxConnections: 10 });
77
+ const dropped: string[] = [];
78
+ const withHook = new SseTransport({
79
+ heartbeatMs: 0,
80
+ maxConnections: 10,
81
+ onDisconnect: id => dropped.push(id)
82
+ });
83
+ void transport;
84
+
85
+ const stream = withHook.open('sse:1');
86
+ await stream.cancel();
87
+
88
+ expect(dropped).toEqual(['sse:1']);
89
+ expect(withHook.owns('sse:1')).toBe(false);
90
+ });
91
+ });
@@ -0,0 +1,126 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { Statement } from '@carno.js/orm';
3
+ import { ancestorsOf, rowKey, tableKey } from '../src/graph/dep-key';
4
+ import { normalizeColumns, readDependencies, writeEvents } from '../src/emitters/statement-keys';
5
+
6
+ const MAX = 64;
7
+
8
+ function select(overrides: Partial<Statement<any>>): Statement<any> {
9
+ return { statement: 'select', table: 'users', alias: 'u', primaryKeyColumnName: 'id', ...overrides };
10
+ }
11
+
12
+ describe('dep-key', () => {
13
+ test('builds table and row keys', () => {
14
+ expect(tableKey('users')).toBe('orm:users');
15
+ expect(rowKey('users', 42)).toBe('orm:users#42');
16
+ });
17
+
18
+ test('a row key has its table as ancestor', () => {
19
+ expect(ancestorsOf('orm:users#42')).toEqual(['orm:users#42', 'orm:users']);
20
+ });
21
+
22
+ test('a table key has only itself', () => {
23
+ expect(ancestorsOf('orm:users')).toEqual(['orm:users']);
24
+ });
25
+
26
+ test('an arbitrary app key is not split on colons', () => {
27
+ expect(ancestorsOf('app:report:2026-08')).toEqual(['app:report:2026-08']);
28
+ });
29
+ });
30
+
31
+ describe('normalizeColumns', () => {
32
+ test('strips alias, quotes and the AS clause the ORM generates', () => {
33
+ expect(normalizeColumns(['u."name" as "u_name"', 'u."id" as "u_id"'])).toEqual(['id', 'name']);
34
+ });
35
+
36
+ test('treats a star select as wildcard', () => {
37
+ expect(normalizeColumns(['*'])).toBeNull();
38
+ expect(normalizeColumns(undefined)).toBeNull();
39
+ });
40
+ });
41
+
42
+ describe('readDependencies', () => {
43
+ test('a primary-key lookup depends on the row', () => {
44
+ const deps = readDependencies(select({ where: 'u."id" = 42', columns: ['u."name" as "u_name"'] }), MAX);
45
+ expect(deps).toEqual([{ key: 'orm:users#42', columns: ['name'] }]);
46
+ });
47
+
48
+ test('a filtered list depends on the table', () => {
49
+ const deps = readDependencies(select({ where: "u.\"status\" = 'active'", columns: ['u."id" as "u_id"'] }), MAX);
50
+ expect(deps).toEqual([{ key: 'orm:users', columns: ['id'] }]);
51
+ });
52
+
53
+ test('an IN list produces one row key per id', () => {
54
+ const deps = readDependencies(select({ where: 'u."id" IN (1, 2, 3)' }), MAX);
55
+ expect(deps.map(d => d.key)).toEqual(['orm:users#1', 'orm:users#2', 'orm:users#3']);
56
+ });
57
+
58
+ test('collapses to the table when the IN list exceeds maxKeysPerRead', () => {
59
+ const ids = Array.from({ length: 5 }, (_, i) => i + 1).join(', ');
60
+ const deps = readDependencies(select({ where: `u."id" IN (${ids})` }), 4);
61
+ expect(deps.map(d => d.key)).toEqual(['orm:users']);
62
+ });
63
+
64
+ test('a join adds the joined table as its own dependency', () => {
65
+ const statement = select({
66
+ where: 'u."id" = 7',
67
+ join: [{ joinTable: 'orders', joinAlias: 'o', type: 'INNER', on: 'o.user_id = u.id' } as any]
68
+ });
69
+ expect(readDependencies(statement, MAX).map(d => d.key)).toEqual(['orm:users#7', 'orm:orders']);
70
+ });
71
+
72
+ test('a select-strategy join adds every child table as a dependency', () => {
73
+ const statement = select({
74
+ where: 'u."id" = 7',
75
+ selectJoin: [{
76
+ statement: 'select',
77
+ table: '"public"."orders"',
78
+ alias: 'o',
79
+ columns: ['o."id"'],
80
+ where: 'o.user_id IN (7)'
81
+ }]
82
+ });
83
+
84
+ expect(readDependencies(statement, MAX).map(d => d.key)).toEqual(['orm:users#7', 'orm:orders']);
85
+ });
86
+
87
+ test('normalizes a qualified ORM table name', () => {
88
+ expect(readDependencies(select({ table: '"public"."users"' }), MAX)[0].key).toBe('orm:users');
89
+ });
90
+
91
+ test('ignores write statements', () => {
92
+ expect(readDependencies(select({ statement: 'update', values: { name: 'x' } }), MAX)).toEqual([]);
93
+ });
94
+ });
95
+
96
+ describe('writeEvents', () => {
97
+ test('an update by id emits the row key carrying the written columns', () => {
98
+ const statement = select({ statement: 'update', where: 'u."id" = 42', values: { name: 'Ada' } });
99
+ expect(writeEvents(statement, MAX)).toEqual([{ key: 'orm:users#42', columns: ['name'] }]);
100
+ });
101
+
102
+ test('an update by predicate emits the table key', () => {
103
+ const statement = select({ statement: 'update', where: "u.\"created_at\" < '2020-01-01'", values: { archived: true } });
104
+ expect(writeEvents(statement, MAX)).toEqual([{ key: 'orm:users', columns: ['archived'] }]);
105
+ });
106
+
107
+ test('a delete emits a wildcard because the whole row is gone', () => {
108
+ const statement = select({ statement: 'delete', where: 'u."id" = 9' });
109
+ expect(writeEvents(statement, MAX)).toEqual([{ key: 'orm:users#9', columns: null }]);
110
+ });
111
+
112
+ test('an insert emits the row key so table subscribers wake through the ancestor', () => {
113
+ const statement = select({ statement: 'insert', values: { id: 5, name: 'Ada' } });
114
+ expect(writeEvents(statement, MAX)).toEqual([{ key: 'orm:users#5', columns: ['id', 'name'] }]);
115
+ });
116
+
117
+ test('an insert without a primary key falls back to the table', () => {
118
+ const statement = select({ statement: 'insert', values: { name: 'Ada' } });
119
+ expect(writeEvents(statement, MAX)).toEqual([{ key: 'orm:users', columns: ['name'] }]);
120
+ });
121
+
122
+ test('a bulk insert emits one key per row', () => {
123
+ const statement = select({ statement: 'insert', bulk: true, values: [{ id: 1 }, { id: 2 }] });
124
+ expect(writeEvents(statement, MAX).map(e => e.key)).toEqual(['orm:users#1', 'orm:users#2']);
125
+ });
126
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { SubscriptionRegistry } from '../src/graph/SubscriptionRegistry';
3
+
4
+ describe('SubscriptionRegistry', () => {
5
+ test('tracks one connection subscribing once', () => {
6
+ const registry = new SubscriptionRegistry();
7
+
8
+ expect(registry.subscribe('c1', 'i1')).toBe(1);
9
+ expect(registry.connectionsOf('i1')).toEqual(['c1']);
10
+ expect(registry.instanceCount()).toBe(1);
11
+ });
12
+
13
+ test('refcounts repeated subscriptions from the same connection', () => {
14
+ const registry = new SubscriptionRegistry();
15
+ registry.subscribe('c1', 'i1');
16
+
17
+ expect(registry.subscribe('c1', 'i1')).toBe(2);
18
+ expect(registry.connectionsOf('i1')).toEqual(['c1']);
19
+ expect(registry.unsubscribe('c1', 'i1')).toBe(1);
20
+ expect(registry.hasSubscribers('i1')).toBe(true);
21
+ expect(registry.unsubscribe('c1', 'i1')).toBe(0);
22
+ expect(registry.hasSubscribers('i1')).toBe(false);
23
+ });
24
+
25
+ test('two connections share one instance', () => {
26
+ const registry = new SubscriptionRegistry();
27
+ registry.subscribe('c1', 'i1');
28
+ registry.subscribe('c2', 'i1');
29
+
30
+ expect(registry.connectionsOf('i1').sort()).toEqual(['c1', 'c2']);
31
+ registry.unsubscribe('c1', 'i1');
32
+ expect(registry.hasSubscribers('i1')).toBe(true);
33
+ });
34
+
35
+ test('counts distinct instances per connection for the per-connection ceiling', () => {
36
+ const registry = new SubscriptionRegistry();
37
+ registry.subscribe('c1', 'i1');
38
+ registry.subscribe('c1', 'i1');
39
+ registry.subscribe('c1', 'i2');
40
+
41
+ expect(registry.countForConnection('c1')).toBe(2);
42
+ });
43
+
44
+ test('dropping a connection returns the instances left with no subscriber', () => {
45
+ const registry = new SubscriptionRegistry();
46
+ registry.subscribe('c1', 'i1');
47
+ registry.subscribe('c1', 'i2');
48
+ registry.subscribe('c2', 'i2');
49
+
50
+ expect(registry.dropConnection('c1').sort()).toEqual(['i1']);
51
+ expect(registry.hasSubscribers('i2')).toBe(true);
52
+ expect(registry.countForConnection('c1')).toBe(0);
53
+ });
54
+
55
+ test('unsubscribing something never subscribed is a no-op', () => {
56
+ const registry = new SubscriptionRegistry();
57
+
58
+ expect(registry.unsubscribe('c1', 'i1')).toBe(0);
59
+ expect(registry.dropConnection('nope')).toEqual([]);
60
+ });
61
+ });