@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,56 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { SQL } from 'bun';
3
+ import { getDriverType } from '../../orm/src/driver/driver-factory';
4
+
5
+ const URL = process.env.CARNO_TEST_PG_URL
6
+ ?? 'postgres://postgres:postgres@localhost:5433/postgres';
7
+
8
+ const describePostgres = getDriverType() === 'postgres' ? describe : describe.skip;
9
+
10
+ describePostgres('Bun LISTEN/NOTIFY', () => {
11
+ test('a notification sent on one connection arrives on the listening one', async () => {
12
+ const listener = new SQL({ url: URL, max: 1 }) as any;
13
+ const writer = new SQL({ url: URL, max: 1 }) as any;
14
+ const received: string[] = [];
15
+
16
+ expect(typeof listener.listen).toBe('function');
17
+ expect(typeof writer.notify).toBe('function');
18
+
19
+ await listener.listen('carno_probe', (payload: string) => {
20
+ received.push(payload);
21
+ });
22
+ await writer.notify('carno_probe', 'hello');
23
+
24
+ const deadline = Date.now() + 3000;
25
+
26
+ while (received.length === 0 && Date.now() < deadline) {
27
+ await new Promise(resolve => setTimeout(resolve, 10));
28
+ }
29
+
30
+ await listener.close();
31
+ await writer.close();
32
+
33
+ expect(received).toEqual(['hello']);
34
+ });
35
+ });
36
+
37
+ describePostgres('PgListener against a real database', () => {
38
+ test('delivers a notification through the listener', async () => {
39
+ const { PgListener } = await import('../src/emitters/pg-listener');
40
+ const listener = new PgListener({ url: URL, heartbeatMs: 0 });
41
+ const seen: string[] = [];
42
+
43
+ await listener.listen('carno_probe_listener', payload => seen.push(payload));
44
+ await listener.notify('carno_probe_listener', '{"t":"users","i":"7"}');
45
+
46
+ const deadline = Date.now() + 3000;
47
+
48
+ while (seen.length === 0 && Date.now() < deadline) {
49
+ await new Promise(resolve => setTimeout(resolve, 10));
50
+ }
51
+
52
+ await listener.close();
53
+
54
+ expect(seen).toEqual(['{"t":"users","i":"7"}']);
55
+ });
56
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { PgListener, type ListenableSql } from '../src/emitters/pg-listener';
3
+
4
+ class FakeSql implements ListenableSql {
5
+ readonly listened: string[] = [];
6
+ readonly notified: { channel: string; payload: string }[] = [];
7
+ readonly handlers = new Map<string, (payload: string) => void>();
8
+ healthy = true;
9
+ closed = false;
10
+
11
+ async listen(channel: string, onNotify: (payload: string) => void): Promise<void> {
12
+ this.listened.push(channel);
13
+ this.handlers.set(channel, onNotify);
14
+ }
15
+
16
+ async notify(channel: string, payload = ''): Promise<void> {
17
+ this.notified.push({ channel, payload });
18
+ }
19
+
20
+ async unsafe(): Promise<void> {
21
+ if (!this.healthy) {
22
+ throw new Error('connection lost');
23
+ }
24
+ }
25
+
26
+ async close(): Promise<void> {
27
+ this.closed = true;
28
+ }
29
+
30
+ emit(channel: string, payload: string): void {
31
+ this.handlers.get(channel)?.(payload);
32
+ }
33
+ }
34
+
35
+ function build() {
36
+ const created: FakeSql[] = [];
37
+ let reconnects = 0;
38
+ const listener = new PgListener({
39
+ url: 'postgres://ignored',
40
+ heartbeatMs: 0,
41
+ retryMs: 1,
42
+ sqlFactory: () => {
43
+ const sql = new FakeSql();
44
+ created.push(sql);
45
+ return sql;
46
+ },
47
+ onReconnect: () => {
48
+ reconnects += 1;
49
+ }
50
+ });
51
+
52
+ return { listener, created, reconnects: () => reconnects };
53
+ }
54
+
55
+ describe('PgListener', () => {
56
+ test('subscribes the channel and delivers payloads', async () => {
57
+ const { listener, created } = build();
58
+ const seen: string[] = [];
59
+
60
+ await listener.listen('carno_live', payload => seen.push(payload));
61
+ created[0].emit('carno_live', '{"t":"users"}');
62
+
63
+ expect(created[0].listened).toEqual(['carno_live']);
64
+ expect(seen).toEqual(['{"t":"users"}']);
65
+
66
+ await listener.close();
67
+ });
68
+
69
+ test('a second channel reuses the same connection', async () => {
70
+ const { listener, created } = build();
71
+
72
+ await listener.listen('one', () => {});
73
+ await listener.listen('two', () => {});
74
+
75
+ expect(created).toHaveLength(1);
76
+ expect(created[0].listened).toEqual(['one', 'two']);
77
+
78
+ await listener.close();
79
+ });
80
+
81
+ test('a dead connection is rebuilt and every channel is re-listened', async () => {
82
+ const { listener, created, reconnects } = build();
83
+ const seen: string[] = [];
84
+
85
+ await listener.listen('carno_live', payload => seen.push(payload));
86
+ created[0].healthy = false;
87
+
88
+ await listener.check();
89
+
90
+ expect(created).toHaveLength(2);
91
+ expect(created[0].closed).toBe(true);
92
+ expect(created[1].listened).toEqual(['carno_live']);
93
+ expect(reconnects()).toBe(1);
94
+
95
+ created[1].emit('carno_live', 'after');
96
+ expect(seen).toEqual(['after']);
97
+
98
+ await listener.close();
99
+ });
100
+
101
+ test('a healthy connection is left alone', async () => {
102
+ const { listener, created, reconnects } = build();
103
+
104
+ await listener.listen('carno_live', () => {});
105
+ await listener.check();
106
+
107
+ expect(created).toHaveLength(1);
108
+ expect(reconnects()).toBe(0);
109
+
110
+ await listener.close();
111
+ });
112
+
113
+ test('notify goes out on the same connection', async () => {
114
+ const { listener, created } = build();
115
+
116
+ await listener.notify('carno_bus', 'payload');
117
+
118
+ expect(created[0].notified).toEqual([{ channel: 'carno_bus', payload: 'payload' }]);
119
+
120
+ await listener.close();
121
+ });
122
+
123
+ test('close shuts the connection and stops reconnecting', async () => {
124
+ const { listener, created } = build();
125
+
126
+ await listener.listen('carno_live', () => {});
127
+ await listener.close();
128
+
129
+ expect(created[0].closed).toBe(true);
130
+
131
+ await listener.check();
132
+ expect(created).toHaveLength(1);
133
+ });
134
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ UnsafeIdentifierError,
4
+ assertIdentifier,
5
+ createFunctionSql,
6
+ createTriggerSql,
7
+ dropTriggerSql,
8
+ triggerNameOf
9
+ } from '../src/emitters/pg-trigger-sql';
10
+ import { eventsFromPayload } from '../src/emitters/pg-notify-emitter';
11
+ import { DEFAULT_LIVE_CONFIG } from '../src/config';
12
+ import { AppEmitter } from '../src/emitters/AppEmitter';
13
+ import { InProcessBus } from '../src/bus/InProcessBus';
14
+ import { statementObserver } from '@carno.js/orm';
15
+ import type { InvalidationEvent } from '../src/graph/types';
16
+
17
+ describe('trigger SQL', () => {
18
+ test('refuses an identifier that is not a bare name', () => {
19
+ expect(() => assertIdentifier('table', 'users; DROP TABLE users')).toThrow(UnsafeIdentifierError);
20
+ expect(() => assertIdentifier('table', 'public.users')).toThrow(UnsafeIdentifierError);
21
+ expect(() => assertIdentifier('table', '"users"')).toThrow(UnsafeIdentifierError);
22
+ expect(assertIdentifier('table', 'live_tasks')).toBe('live_tasks');
23
+ });
24
+
25
+ test('names the trigger after the table', () => {
26
+ expect(triggerNameOf('live_tasks')).toBe('carno_live_live_tasks');
27
+ });
28
+
29
+ test('the trigger fires on every write and carries the key column', () => {
30
+ const sql = createTriggerSql('live_tasks', 'id', 'carno_live');
31
+
32
+ expect(sql).toContain('AFTER INSERT OR UPDATE OR DELETE ON live_tasks');
33
+ expect(sql).toContain('FOR EACH ROW');
34
+ expect(sql).toContain(`EXECUTE FUNCTION carno_live_notify('id', 'carno_live')`);
35
+ expect(sql).toContain('DROP TRIGGER IF EXISTS carno_live_live_tasks ON live_tasks');
36
+ });
37
+
38
+ test('the function refuses to notify when an UPDATE changed nothing', () => {
39
+ const sql = createFunctionSql(7000);
40
+
41
+ expect(sql).toContain('IF changed IS NULL THEN');
42
+ expect(sql).toContain('octet_length(payload) > 7000');
43
+ expect(sql).toContain('PERFORM pg_notify(channel, payload)');
44
+ });
45
+
46
+ test('drop is idempotent', () => {
47
+ expect(dropTriggerSql('live_tasks')).toBe(
48
+ 'DROP TRIGGER IF EXISTS carno_live_live_tasks ON live_tasks;'
49
+ );
50
+ });
51
+ });
52
+
53
+ describe('eventsFromPayload', () => {
54
+ test('a row write becomes a row key with its columns', () => {
55
+ expect(eventsFromPayload('{"t":"live_tasks","i":"42","c":["title"]}')).toEqual([
56
+ { key: 'orm:live_tasks#42', columns: ['title'] }
57
+ ]);
58
+ });
59
+
60
+ test('a write with no columns is a wildcard', () => {
61
+ expect(eventsFromPayload('{"t":"live_tasks","i":"42","c":null}')).toEqual([
62
+ { key: 'orm:live_tasks#42', columns: null }
63
+ ]);
64
+ });
65
+
66
+ test('an empty column list is a wildcard, not an empty filter', () => {
67
+ expect(eventsFromPayload('{"t":"live_tasks","i":"42","c":[]}')).toEqual([
68
+ { key: 'orm:live_tasks#42', columns: null }
69
+ ]);
70
+ });
71
+
72
+ test('a payload with no id degrades to the whole table', () => {
73
+ expect(eventsFromPayload('{"t":"live_tasks","i":null,"c":null}')).toEqual([
74
+ { key: 'orm:live_tasks', columns: null }
75
+ ]);
76
+ });
77
+
78
+ test('garbage on the channel is ignored, not thrown', () => {
79
+ expect(eventsFromPayload('not json')).toEqual([]);
80
+ expect(eventsFromPayload('{"i":"42"}')).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe('AppEmitter with a covered table', () => {
85
+ test('does not publish writes announced by another emitter', () => {
86
+ const bus = new InProcessBus();
87
+ const published: InvalidationEvent[] = [];
88
+ bus.subscribe(events => published.push(...events));
89
+
90
+ const emitter = new AppEmitter(bus, DEFAULT_LIVE_CONFIG);
91
+ emitter.setCoveredTables(['live_tasks']);
92
+ emitter.attach();
93
+
94
+ statementObserver.notifyWrite({
95
+ statement: 'update',
96
+ table: 'live_tasks',
97
+ where: 'id = 1',
98
+ values: { title: 'x' },
99
+ primaryKeyColumnName: 'id'
100
+ } as any);
101
+ statementObserver.notifyWrite({
102
+ statement: 'update',
103
+ table: 'other_table',
104
+ where: 'id = 1',
105
+ values: { title: 'x' },
106
+ primaryKeyColumnName: 'id'
107
+ } as any);
108
+
109
+ emitter.detach();
110
+
111
+ expect(published).toEqual([{ key: 'orm:other_table#1', columns: ['title'] }]);
112
+ });
113
+ });
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { withDatabase } from '../../orm/src/testing';
3
+ import { getDriverType } from '../../orm/src/driver/driver-factory';
4
+ import { PgNotifyEmitter } from '../src/emitters/pg-notify-emitter';
5
+ import type { InvalidationEvent } from '../src/graph/types';
6
+
7
+ const URL = process.env.CARNO_TEST_PG_URL
8
+ ?? 'postgres://postgres:postgres@localhost:5433/postgres';
9
+
10
+ const TABLE_STATEMENTS = [
11
+ 'CREATE TABLE pg_notify_rows (id SERIAL PRIMARY KEY, title TEXT NOT NULL, note TEXT NULL);'
12
+ ];
13
+
14
+ const describePostgres = getDriverType() === 'postgres' ? describe : describe.skip;
15
+
16
+ async function waitFor(events: InvalidationEvent[], count: number): Promise<void> {
17
+ const deadline = Date.now() + 3000;
18
+
19
+ while (events.length < count && Date.now() < deadline) {
20
+ await new Promise(resolve => setTimeout(resolve, 10));
21
+ }
22
+ }
23
+
24
+ describePostgres('PgNotifyEmitter against a real database', () => {
25
+ test('a raw SQL write that never touched the ORM produces an invalidation', async () => {
26
+ await withDatabase(TABLE_STATEMENTS, async ({ executeSql }) => {
27
+ const received: InvalidationEvent[] = [];
28
+ const emitter = new PgNotifyEmitter(events => received.push(...events), {
29
+ tables: [{ table: 'pg_notify_rows', primaryKey: 'id' }],
30
+ url: URL,
31
+ execute: sql => executeSql(sql),
32
+ channel: 'carno_live_test',
33
+ heartbeatMs: 0
34
+ });
35
+
36
+ await emitter.attach();
37
+
38
+ // No ORM, no entity, no repository: this is what a migration, a
39
+ // psql session or a service in another language looks like.
40
+ await executeSql(`INSERT INTO pg_notify_rows (title) VALUES ('first');`);
41
+ await waitFor(received, 1);
42
+
43
+ await executeSql(`UPDATE pg_notify_rows SET title = 'second' WHERE id = 1;`);
44
+ await waitFor(received, 2);
45
+
46
+ // An update that writes the same value must not wake anyone.
47
+ await executeSql(`UPDATE pg_notify_rows SET title = 'second' WHERE id = 1;`);
48
+ await new Promise(resolve => setTimeout(resolve, 300));
49
+
50
+ await executeSql(`DELETE FROM pg_notify_rows WHERE id = 1;`);
51
+ await waitFor(received, 3);
52
+
53
+ await emitter.detach();
54
+
55
+ expect(received).toEqual([
56
+ { key: 'orm:pg_notify_rows#1', columns: null },
57
+ { key: 'orm:pg_notify_rows#1', columns: ['title'] },
58
+ { key: 'orm:pg_notify_rows#1', columns: null }
59
+ ]);
60
+ });
61
+ });
62
+ });
63
+
64
+ describePostgres('PgNotifyBus against a real database', () => {
65
+ test('two nodes see each other and not their own echo', async () => {
66
+ const { PgNotifyBus } = await import('../src/bus/PgNotifyBus');
67
+ const nodeA = new PgNotifyBus({ url: URL, channel: 'carno_bus_test', nodeId: 'node-a' });
68
+ const nodeB = new PgNotifyBus({ url: URL, channel: 'carno_bus_test', nodeId: 'node-b' });
69
+ const seenByA: InvalidationEvent[] = [];
70
+ const seenByB: InvalidationEvent[] = [];
71
+
72
+ nodeA.subscribe(events => seenByA.push(...events));
73
+ nodeB.subscribe(events => seenByB.push(...events));
74
+
75
+ await nodeA.start();
76
+ await nodeB.start();
77
+
78
+ nodeA.publish([{ key: 'orm:users#1', columns: ['name'] }]);
79
+ await waitFor(seenByB, 1);
80
+
81
+ await nodeA.stop();
82
+ await nodeB.stop();
83
+
84
+ expect(seenByB).toEqual([{ key: 'orm:users#1', columns: ['name'] }]);
85
+ // Delivered locally once, when published; the echo back was dropped.
86
+ expect(seenByA).toEqual([{ key: 'orm:users#1', columns: ['name'] }]);
87
+ });
88
+ });
@@ -0,0 +1,169 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { Controller, Get, Use } from '@carno.js/core';
3
+ import { createTestHarness } from '@carno.js/core';
4
+ import { Live, LivePlugin } from '../src';
5
+ import type { LiveAuthorizationRequest, LiveAuthorizer } from '../src/auth/authorizer';
6
+ import type { LiveScopeResolver } from '../src/transport/scope-resolver';
7
+ import { PollingTransport } from '../src/client/transport';
8
+ import { closeLiveRuntime } from '../src/runtime';
9
+
10
+ @Controller('/private')
11
+ class PrivateController {
12
+ @Get('/')
13
+ @Live({ shared: 'private' })
14
+ read() {
15
+ return { secret: true };
16
+ }
17
+ }
18
+
19
+ class DenyPolling implements LiveAuthorizer {
20
+ calls = 0;
21
+
22
+ authorize(_request: LiveAuthorizationRequest): boolean {
23
+ this.calls++;
24
+ return false;
25
+ }
26
+ }
27
+
28
+ class AllowPolling implements LiveAuthorizer {
29
+ calls = 0;
30
+
31
+ authorize(_request: LiveAuthorizationRequest): boolean {
32
+ this.calls++;
33
+ return true;
34
+ }
35
+ }
36
+
37
+ @Controller('/scoped')
38
+ @Use(ctx => {
39
+ if (ctx.headers.get('x-live-tenant') !== 'acme') {
40
+ return new Response('forbidden', { status: 403 });
41
+ }
42
+ })
43
+ class ScopedController {
44
+ @Get('/')
45
+ @Live({ shared: 'private' })
46
+ read() {
47
+ return { tenant: 'acme' };
48
+ }
49
+ }
50
+
51
+ describe('PollingTransport authorization', () => {
52
+ afterEach(async () => {
53
+ await closeLiveRuntime();
54
+ });
55
+
56
+ test('consults the live scope and authorizer instead of polling the route anonymously', async () => {
57
+ const authorizer = new DenyPolling();
58
+ let resolvedToken: string | undefined;
59
+ const resolver: LiveScopeResolver = {
60
+ resolve: async ({ token }) => {
61
+ resolvedToken = token;
62
+ return { principal: 'ada' };
63
+ }
64
+ };
65
+ const harness = await createTestHarness({
66
+ plugins: [LivePlugin.create({
67
+ controllers: [PrivateController],
68
+ scopeResolver: resolver,
69
+ authorizer
70
+ })],
71
+ listen: true
72
+ });
73
+
74
+ const received: unknown[] = [];
75
+ const nativeFetch = (globalThis as { __carnoNativeFetch?: typeof fetch }).__carnoNativeFetch ?? fetch;
76
+ const transport = new PollingTransport(
77
+ `http://127.0.0.1:${harness.port}`,
78
+ {
79
+ 'PrivateController.read': { method: 'GET', path: '/private' }
80
+ },
81
+ {
82
+ intervalMs: 10_000,
83
+ token: 'poll-secret',
84
+ fetch: (async (url, init) => nativeFetch(url, init)) as typeof fetch
85
+ }
86
+ );
87
+
88
+ try {
89
+ transport.start({
90
+ onOpen: () => {},
91
+ onMessage: raw => received.push(JSON.parse(raw)),
92
+ onClose: () => {}
93
+ });
94
+ transport.send(JSON.stringify({
95
+ t: 'sub',
96
+ sid: 's1',
97
+ resource: 'PrivateController.read',
98
+ inputs: { params: {}, query: {} }
99
+ }));
100
+
101
+ await new Promise(resolve => setTimeout(resolve, 30));
102
+
103
+ expect(authorizer.calls).toBe(1);
104
+ expect(resolvedToken).toBe('poll-secret');
105
+ expect(received[0]).toMatchObject({ t: 'error', sid: 's1', code: 'forbidden' });
106
+ } finally {
107
+ transport.close();
108
+ await harness.close();
109
+ }
110
+ });
111
+
112
+ test('propagates the token and resolver headers through the HTTP route pipeline', async () => {
113
+ const authorizer = new AllowPolling();
114
+ let resolvedToken: string | undefined;
115
+ const resolver: LiveScopeResolver = {
116
+ resolve: async ({ token }) => {
117
+ resolvedToken = token;
118
+ return { principal: 'ada', headers: { 'x-live-tenant': 'acme' } };
119
+ }
120
+ };
121
+ const harness = await createTestHarness({
122
+ plugins: [LivePlugin.create({
123
+ controllers: [ScopedController],
124
+ scopeResolver: resolver,
125
+ authorizer
126
+ })],
127
+ listen: true
128
+ });
129
+
130
+ const received: unknown[] = [];
131
+ const nativeFetch = (globalThis as { __carnoNativeFetch?: typeof fetch }).__carnoNativeFetch ?? fetch;
132
+ const transport = new PollingTransport(
133
+ `http://127.0.0.1:${harness.port}`,
134
+ { 'ScopedController.read': { method: 'GET', path: '/scoped' } },
135
+ {
136
+ intervalMs: 10_000,
137
+ token: 'poll-secret',
138
+ fetch: (async (url, init) => nativeFetch(url, init)) as typeof fetch
139
+ }
140
+ );
141
+
142
+ try {
143
+ transport.start({
144
+ onOpen: () => {},
145
+ onMessage: raw => received.push(JSON.parse(raw)),
146
+ onClose: () => {}
147
+ });
148
+ transport.send(JSON.stringify({
149
+ t: 'sub',
150
+ sid: 's1',
151
+ resource: 'ScopedController.read',
152
+ inputs: { params: {}, query: {} }
153
+ }));
154
+
155
+ await new Promise(resolve => setTimeout(resolve, 30));
156
+
157
+ expect(resolvedToken).toBe('poll-secret');
158
+ expect(authorizer.calls).toBe(1);
159
+ expect(received[0]).toMatchObject({
160
+ t: 'snapshot',
161
+ sid: 's1',
162
+ data: { tenant: 'acme' }
163
+ });
164
+ } finally {
165
+ transport.close();
166
+ await harness.close();
167
+ }
168
+ });
169
+ });