@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,248 @@
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 { Body, Controller, Get, Post, Query } from '@carno.js/core';
5
+ import { createTestHarness } from '../../core/dist/testing/TestHarness.js';
6
+ import { getDriverType } from '../../orm/src/driver/driver-factory';
7
+ import { Live } from '../src/decorators/Live';
8
+ import { LivePlugin } from '../src/LivePlugin';
9
+ import { closeLiveRuntime } from '../src/runtime';
10
+ import type { LiveAuthorizationRequest, LiveAuthorizer } from '../src/auth/authorizer';
11
+ import type { ServerMessage } from '../src/shared/protocol';
12
+
13
+ const TABLE_STATEMENTS = [
14
+ 'CREATE TABLE live2_cards (id SERIAL PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE);'
15
+ ];
16
+
17
+ @Entity({ tableName: 'live2_cards' })
18
+ class Card extends BaseEntity<Card> {
19
+ @PrimaryKey()
20
+ id!: number;
21
+
22
+ @Property()
23
+ title!: string;
24
+
25
+ @Property()
26
+ done!: boolean;
27
+ }
28
+
29
+ @Controller('/cards')
30
+ class CardsController {
31
+ @Get('/')
32
+ @Live({ key: 'id', shared: 'public' })
33
+ async list(@Query('done') done?: string) {
34
+ const cards = await Card.find(done === undefined ? {} : { done: done === 'true' });
35
+ return cards.map(card => ({ id: card.id, title: card.title }));
36
+ }
37
+
38
+ @Post('/search')
39
+ @Live({ key: 'id', shared: 'public' })
40
+ async search(@Body() filter: { contains: string }) {
41
+ const cards = await Card.find({});
42
+ return cards
43
+ .filter(card => card.title.includes(filter.contains))
44
+ .map(card => ({ id: card.id, title: card.title }));
45
+ }
46
+ }
47
+
48
+ class DenyEveryone implements LiveAuthorizer {
49
+ authorize(request: LiveAuthorizationRequest): boolean {
50
+ void request;
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /** Minimal protocol client over a real WebSocket. */
56
+ class ProbeClient {
57
+ private readonly socket: WebSocket;
58
+ readonly received: ServerMessage[] = [];
59
+
60
+ private constructor(socket: WebSocket) {
61
+ this.socket = socket;
62
+ socket.onmessage = event => this.received.push(JSON.parse(String(event.data)));
63
+ }
64
+
65
+ static connect(port: number): Promise<ProbeClient> {
66
+ return new Promise((resolve, reject) => {
67
+ const socket = new WebSocket(`ws://127.0.0.1:${port}/live`);
68
+ socket.onopen = () => resolve(new ProbeClient(socket));
69
+ socket.onerror = reject;
70
+ });
71
+ }
72
+
73
+ send(message: unknown): void {
74
+ this.socket.send(JSON.stringify(message));
75
+ }
76
+
77
+ close(): void {
78
+ this.socket.close();
79
+ }
80
+
81
+ async wait(predicate: (message: ServerMessage) => boolean, timeoutMs = 4000): Promise<ServerMessage> {
82
+ const deadline = Date.now() + timeoutMs;
83
+
84
+ while (Date.now() < deadline) {
85
+ const found = this.received.find(predicate);
86
+
87
+ if (found) {
88
+ return found;
89
+ }
90
+
91
+ await new Promise(resolve => setTimeout(resolve, 10));
92
+ }
93
+
94
+ throw new Error(`Timed out. Received: ${JSON.stringify(this.received)}`);
95
+ }
96
+ }
97
+
98
+ const describePostgres = getDriverType() === 'postgres' ? describe : describe.skip;
99
+
100
+ afterEach(async () => {
101
+ statementObserver.reset();
102
+ // Awaited, not fire-and-forget: the LISTEN socket has to be back in the
103
+ // pool before the next test asks Postgres for another one.
104
+ await closeLiveRuntime();
105
+ });
106
+
107
+ describePostgres('Live Resources phase 2 acceptance', () => {
108
+ test('a write that never touched the application reaches the screen (criterion 2)', async () => {
109
+ await withDatabase(TABLE_STATEMENTS, async ({ executeSql }) => {
110
+ const harness = await createTestHarness({
111
+ plugins: [LivePlugin.create({
112
+ controllers: [CardsController],
113
+ config: { coalesceMs: 5 },
114
+ pgNotify: {
115
+ tables: [{ table: 'live2_cards', primaryKey: 'id' }],
116
+ channel: 'carno_live_acceptance'
117
+ }
118
+ })],
119
+ listen: true
120
+ });
121
+
122
+ // The plugin installs the triggers from inside the WebSocket
123
+ // builder, which is not awaited: give the DDL a moment to land.
124
+ await new Promise(resolve => setTimeout(resolve, 500));
125
+
126
+ const probe = await ProbeClient.connect(harness.port);
127
+ probe.send({ t: 'hello', v: 1 });
128
+ probe.send({ t: 'sub', sid: 's1', resource: 'CardsController.list', inputs: { params: {}, query: {} } });
129
+ await probe.wait(message => message.t === 'snapshot');
130
+
131
+ // No entity, no repository, no ORM: a migration or a psql session.
132
+ await executeSql(`INSERT INTO live2_cards (title, done) VALUES ('from outside', false);`);
133
+
134
+ const patch = await probe.wait(message => message.t === 'patch');
135
+
136
+ probe.close();
137
+ await harness.close();
138
+
139
+ expect(patch).toMatchObject({ t: 'patch', sid: 's1' });
140
+ expect(JSON.stringify((patch as { ops: unknown[] }).ops)).toContain('from outside');
141
+ });
142
+ });
143
+
144
+ test('a live @Post() answers plain JSON and also updates over the socket', async () => {
145
+ await withDatabase(TABLE_STATEMENTS, async () => {
146
+ const harness = await createTestHarness({
147
+ plugins: [LivePlugin.create({
148
+ controllers: [CardsController],
149
+ config: { coalesceMs: 5 }
150
+ })],
151
+ listen: true
152
+ });
153
+
154
+ await Card.create({ title: 'alpha', done: false });
155
+
156
+ // The same route, over plain HTTP, with no WebSocket in sight.
157
+ const response = await fetch(`http://127.0.0.1:${harness.port}/cards/search`, {
158
+ method: 'POST',
159
+ headers: { 'content-type': 'application/json' },
160
+ body: JSON.stringify({ contains: 'alp' })
161
+ });
162
+
163
+ expect(response.status).toBe(200);
164
+ expect(await response.json()).toEqual([{ id: 1, title: 'alpha' }]);
165
+
166
+ const probe = await ProbeClient.connect(harness.port);
167
+ probe.send({ t: 'hello', v: 1 });
168
+ probe.send({
169
+ t: 'sub',
170
+ sid: 's1',
171
+ resource: 'CardsController.search',
172
+ inputs: { params: {}, query: {}, body: { contains: 'alp' } }
173
+ });
174
+
175
+ const snapshot = await probe.wait(message => message.t === 'snapshot');
176
+ expect((snapshot as { data: unknown }).data).toEqual([{ id: 1, title: 'alpha' }]);
177
+
178
+ await Card.create({ title: 'alphabet', done: false });
179
+ const patch = await probe.wait(message => message.t === 'patch');
180
+
181
+ probe.close();
182
+ await harness.close();
183
+
184
+ expect(JSON.stringify((patch as { ops: unknown[] }).ops)).toContain('alphabet');
185
+ });
186
+ });
187
+
188
+ test('two subscriptions with different bodies do not share an instance', async () => {
189
+ await withDatabase(TABLE_STATEMENTS, async () => {
190
+ const harness = await createTestHarness({
191
+ plugins: [LivePlugin.create({ controllers: [CardsController], config: { coalesceMs: 5 } })],
192
+ listen: true
193
+ });
194
+
195
+ await Card.create({ title: 'alpha', done: false });
196
+ await Card.create({ title: 'beta', done: false });
197
+
198
+ const probe = await ProbeClient.connect(harness.port);
199
+ probe.send({ t: 'hello', v: 1 });
200
+ probe.send({
201
+ t: 'sub',
202
+ sid: 'a',
203
+ resource: 'CardsController.search',
204
+ inputs: { params: {}, query: {}, body: { contains: 'alp' } }
205
+ });
206
+ probe.send({
207
+ t: 'sub',
208
+ sid: 'b',
209
+ resource: 'CardsController.search',
210
+ inputs: { params: {}, query: {}, body: { contains: 'bet' } }
211
+ });
212
+
213
+ const first = await probe.wait(message => message.t === 'snapshot' && message.sid === 'a');
214
+ const second = await probe.wait(message => message.t === 'snapshot' && message.sid === 'b');
215
+
216
+ probe.close();
217
+ await harness.close();
218
+
219
+ expect((first as { data: unknown }).data).toEqual([{ id: 1, title: 'alpha' }]);
220
+ expect((second as { data: unknown }).data).toEqual([{ id: 2, title: 'beta' }]);
221
+ });
222
+ });
223
+
224
+ test('an unauthorized connection is told so and gets no data', async () => {
225
+ await withDatabase(TABLE_STATEMENTS, async () => {
226
+ const harness = await createTestHarness({
227
+ plugins: [LivePlugin.create({
228
+ controllers: [CardsController],
229
+ authorizer: new DenyEveryone(),
230
+ config: { coalesceMs: 5 }
231
+ })],
232
+ listen: true
233
+ });
234
+
235
+ const probe = await ProbeClient.connect(harness.port);
236
+ probe.send({ t: 'hello', v: 1 });
237
+ probe.send({ t: 'sub', sid: 's1', resource: 'CardsController.list', inputs: { params: {}, query: {} } });
238
+
239
+ const error = await probe.wait(message => message.t === 'error');
240
+
241
+ probe.close();
242
+ await harness.close();
243
+
244
+ expect(error).toMatchObject({ t: 'error', sid: 's1', code: 'forbidden' });
245
+ expect(probe.received.some(message => message.t === 'snapshot')).toBe(false);
246
+ });
247
+ });
248
+ });
@@ -0,0 +1,216 @@
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 } from '@carno.js/core';
5
+ import { createTestHarness } from '../../core/dist/testing/TestHarness.js';
6
+ import { getDriverType } from '../../orm/src/driver/driver-factory';
7
+ import { liveIsland } from '../../views/src/live-island';
8
+ import { Live } from '../src/decorators/Live';
9
+ import { LiveClient, type LiveSocket } from '../src/client/core';
10
+ import { LivePlugin } from '../src/LivePlugin';
11
+ import { closeLiveRuntime, getLiveRuntime } from '../src/runtime';
12
+ import { prefetchLive } from '../src/resource/prefetch';
13
+ import { toHydrateMap } from '../src/client/hydrate';
14
+ import type { ServerMessage } from '../src/shared/protocol';
15
+
16
+ const TABLE_STATEMENTS = [
17
+ 'CREATE TABLE live3_notes (id SERIAL PRIMARY KEY, body TEXT NOT NULL);'
18
+ ];
19
+
20
+ @Entity({ tableName: 'live3_notes' })
21
+ class Note extends BaseEntity<Note> {
22
+ @PrimaryKey()
23
+ id!: number;
24
+
25
+ @Property()
26
+ body!: string;
27
+ }
28
+
29
+ @Controller('/notes')
30
+ class NotesController {
31
+ @Get('/')
32
+ @Live({ key: 'id', shared: 'public' })
33
+ async list() {
34
+ const notes = await Note.find({});
35
+ return notes.map(note => ({ id: note.id, body: note.body }));
36
+ }
37
+ }
38
+
39
+ const describePostgres = getDriverType() === 'postgres' ? describe : describe.skip;
40
+
41
+ afterEach(async () => {
42
+ statementObserver.reset();
43
+ await closeLiveRuntime();
44
+ });
45
+
46
+ function probeSocket() {
47
+ const socket = {
48
+ sent: [] as string[],
49
+ received: [] as ServerMessage[],
50
+ send(data: string) { socket.sent.push(data); },
51
+ close() {},
52
+ onopen: null as (() => void) | null,
53
+ onmessage: null as ((event: { data: string }) => void) | null,
54
+ onclose: null as (() => void) | null,
55
+ onerror: null as ((error: unknown) => void) | null
56
+ };
57
+
58
+ return socket;
59
+ }
60
+
61
+ describePostgres('Live Resources phase 3 acceptance', () => {
62
+ test('a views page renders server-side and only the subscribed island updates (criterion 7)', async () => {
63
+ await withDatabase(TABLE_STATEMENTS, async ({ executeSql }) => {
64
+ await executeSql(`INSERT INTO live3_notes (body) VALUES ('first');`);
65
+
66
+ const harness = await createTestHarness({
67
+ plugins: [LivePlugin.create({
68
+ controllers: [NotesController],
69
+ config: { coalesceMs: 5 }
70
+ })],
71
+ listen: true
72
+ });
73
+
74
+ // --- the server renders the page, island payload included -------
75
+ const payload = await prefetchLive(getLiveRuntime().resources, 'NotesController.list');
76
+ const page = [
77
+ '<h1>Notes</h1>',
78
+ '<div id="static">rendered once, never subscribed</div>',
79
+ `<div id="island"></div>${liveIsland(payload)}`
80
+ ].join('');
81
+
82
+ expect(page).toContain('rendered once, never subscribed');
83
+ expect(page).toContain('data-carno-live');
84
+ expect(payload.data).toEqual([{ id: 1, body: 'first' }]);
85
+
86
+ // --- the client picks the payload up, and starts full -----------
87
+ const hydrate = toHydrateMap([JSON.parse(
88
+ page.slice(page.indexOf('data-carno-live>') + 'data-carno-live>'.length, page.lastIndexOf('</script>'))
89
+ )]);
90
+
91
+ const socket = probeSocket();
92
+ const client = new LiveClient({
93
+ url: `ws://127.0.0.1:${harness.port}/live`,
94
+ hydrate,
95
+ socketFactory: () => socket as unknown as LiveSocket
96
+ });
97
+
98
+ const store = client.store('NotesController.list', { params: {}, query: {} });
99
+
100
+ // No waterfall: the first paint has the data, not a spinner.
101
+ expect(store.getSnapshot().pending).toBe(false);
102
+ expect(store.getSnapshot().data).toEqual([{ id: 1, body: 'first' }]);
103
+
104
+ store.subscribe(() => {});
105
+ socket.onopen?.();
106
+
107
+ const sub = JSON.parse(socket.sent.find(raw => raw.includes('"t":"sub"'))!);
108
+ expect(sub.hash).toBe(payload.hash);
109
+
110
+ await harness.close();
111
+ });
112
+ });
113
+
114
+ test('an island subscribing over a real socket receives a patch, and the hash spares the first send', async () => {
115
+ await withDatabase(TABLE_STATEMENTS, async ({ executeSql }) => {
116
+ await executeSql(`INSERT INTO live3_notes (body) VALUES ('first');`);
117
+
118
+ const harness = await createTestHarness({
119
+ plugins: [LivePlugin.create({
120
+ controllers: [NotesController],
121
+ config: { coalesceMs: 5 }
122
+ })],
123
+ listen: true
124
+ });
125
+
126
+ const payload = await prefetchLive(getLiveRuntime().resources, 'NotesController.list');
127
+ const received: ServerMessage[] = [];
128
+ const socket = new WebSocket(`ws://127.0.0.1:${harness.port}/live`);
129
+
130
+ await new Promise<void>(resolve => { socket.onopen = () => resolve(); });
131
+ socket.onmessage = event => received.push(JSON.parse(String(event.data)));
132
+
133
+ socket.send(JSON.stringify({ t: 'hello', v: 1 }));
134
+ socket.send(JSON.stringify({
135
+ t: 'sub',
136
+ sid: 's1',
137
+ resource: 'NotesController.list',
138
+ inputs: { params: {}, query: {} },
139
+ hash: payload.hash
140
+ }));
141
+
142
+ const wait = async (predicate: (message: ServerMessage) => boolean) => {
143
+ const deadline = Date.now() + 4000;
144
+
145
+ while (Date.now() < deadline) {
146
+ const found = received.find(predicate);
147
+ if (found) return found;
148
+ await new Promise(resolve => setTimeout(resolve, 10));
149
+ }
150
+
151
+ throw new Error(`timed out. Received: ${JSON.stringify(received)}`);
152
+ };
153
+
154
+ // The screen already holds this content, so nothing is sent.
155
+ const current = await wait(message => message.t === 'current');
156
+ expect(current).toMatchObject({ t: 'current', sid: 's1' });
157
+ expect(received.some(message => message.t === 'snapshot')).toBe(false);
158
+
159
+ await Note.create({ body: 'second' });
160
+
161
+ const patch = await wait(message => message.t === 'patch');
162
+ expect(patch).toMatchObject({ t: 'patch', sid: 's1' });
163
+
164
+ socket.close();
165
+ await harness.close();
166
+ });
167
+ });
168
+
169
+ test('an ORM update reaches a liveSignal, closing the Angular half of criterion 1', async () => {
170
+ await withDatabase(TABLE_STATEMENTS, async ({ executeSql }) => {
171
+ await executeSql(`INSERT INTO live3_notes (body) VALUES ('first');`);
172
+
173
+ const { Injector, runInInjectionContext } = await import('@angular/core');
174
+ const { liveSignal, provideLive, reconcileLiveSignal } = await import('../src/client/angular');
175
+
176
+ const harness = await createTestHarness({
177
+ plugins: [LivePlugin.create({
178
+ controllers: [NotesController],
179
+ config: { coalesceMs: 5 }
180
+ })],
181
+ listen: true
182
+ });
183
+
184
+ const client = new LiveClient({ url: `ws://127.0.0.1:${harness.port}/live` });
185
+ const injector = Injector.create({ providers: [provideLive(client)] });
186
+
187
+ const state = runInInjectionContext(injector, () => liveSignal<{ id: number; body: string }[]>(
188
+ 'NotesController.list'
189
+ ));
190
+ reconcileLiveSignal(state);
191
+
192
+ const settle = async (predicate: () => boolean) => {
193
+ const deadline = Date.now() + 4000;
194
+
195
+ while (Date.now() < deadline) {
196
+ if (predicate()) return;
197
+ await new Promise(resolve => setTimeout(resolve, 20));
198
+ }
199
+
200
+ throw new Error(`timed out. Last state: ${JSON.stringify(state())}`);
201
+ };
202
+
203
+ await settle(() => state().data?.length === 1);
204
+
205
+ // No broadcast code anywhere: an ordinary repository write.
206
+ await Note.create({ body: 'second' });
207
+
208
+ await settle(() => state().data?.length === 2);
209
+ expect(state().data?.map(note => note.body)).toEqual(['first', 'second']);
210
+
211
+ injector.destroy();
212
+ client.close();
213
+ await harness.close();
214
+ });
215
+ });
216
+ });