@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,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LiveSlot = void 0;
4
+ exports.liveStoreOf = liveStoreOf;
5
+ exports.liveIdentity = liveIdentity;
6
+ exports.liveStore = liveStore;
7
+ const canonical_1 = require("../shared/canonical");
8
+ const descriptor_1 = require("../shared/descriptor");
9
+ /**
10
+ * Resolve a descriptor or a resource id to the underlying store.
11
+ *
12
+ * Framework adapters go through here rather than through `liveStore()`: they
13
+ * already own their own teardown, and wrapping a second lifecycle around the
14
+ * one the framework gives them is how an adapter starts leaking.
15
+ */
16
+ function liveStoreOf(client, resource, inputs = {}) {
17
+ const resourceId = typeof resource === 'string' ? resource : (0, descriptor_1.resourceIdOf)(resource);
18
+ return client.store(resourceId, (0, descriptor_1.normalizeLiveInputs)(inputs));
19
+ }
20
+ /**
21
+ * Canonical identity of a subscription, for adapters that need to know whether
22
+ * reactive inputs actually changed before tearing a subscription down.
23
+ */
24
+ function liveIdentity(resource, inputs = {}) {
25
+ const resourceId = typeof resource === 'string' ? resource : (0, descriptor_1.resourceIdOf)(resource);
26
+ const normalized = (0, descriptor_1.normalizeLiveInputs)(inputs);
27
+ return `${resourceId}|${(0, canonical_1.canonical)({
28
+ params: normalized.params,
29
+ query: normalized.query,
30
+ body: normalized.body ?? null
31
+ })}`;
32
+ }
33
+ /**
34
+ * Subscribe without a framework.
35
+ *
36
+ * This is the whole vanilla adapter, and it is small on purpose: everything
37
+ * hard -- dedupe, revisions, resync, reconnect, the optimistic stack -- is in
38
+ * the client, and an adapter that grows is logic leaking out of it.
39
+ */
40
+ function liveStore(client, resource,
41
+ // Loose on purpose, and invisible to callers: a descriptor's own `query`
42
+ // type is a plain object, which no index signature accepts. The overloads
43
+ // above are what anyone actually sees.
44
+ inputs = {}) {
45
+ const store = liveStoreOf(client, resource, inputs);
46
+ const drops = new Set();
47
+ return {
48
+ get: () => store.getSnapshot(),
49
+ subscribe(listener) {
50
+ const drop = store.subscribe(() => listener(store.getSnapshot()));
51
+ drops.add(drop);
52
+ // Idempotent: calling this and then close() must not release the
53
+ // client's refcount twice, or an unrelated handle loses its data.
54
+ return () => {
55
+ if (drops.delete(drop)) {
56
+ drop();
57
+ }
58
+ };
59
+ },
60
+ close() {
61
+ for (const drop of [...drops]) {
62
+ drops.delete(drop);
63
+ drop();
64
+ }
65
+ }
66
+ };
67
+ }
68
+ const EMPTY_STATE = { data: undefined, pending: true, error: null, stale: false };
69
+ /**
70
+ * A subscription that points at one target at a time.
71
+ *
72
+ * Reactive frameworks re-run an expression when its inputs change, and for a
73
+ * live subscription that means "this component now wants a different instance".
74
+ * The rule that makes that safe has nothing to do with any framework, so it
75
+ * lives here: recomputing to the same inputs must not churn the subscription,
76
+ * and switching targets must release the old one *before* retaining the new
77
+ * one. Holding both across the switch is how a dragged filter walks a
78
+ * connection into `maxInstancesPerConnection`.
79
+ */
80
+ class LiveSlot {
81
+ constructor(client, onState) {
82
+ this.client = client;
83
+ this.onState = onState;
84
+ this.identity = null;
85
+ this.release = null;
86
+ this.store = null;
87
+ }
88
+ point(resource, inputs = {}) {
89
+ const next = liveIdentity(resource, inputs);
90
+ if (next === this.identity) {
91
+ return;
92
+ }
93
+ this.release?.();
94
+ this.release = null;
95
+ this.identity = next;
96
+ this.store = liveStoreOf(this.client, resource, inputs);
97
+ const store = this.store;
98
+ this.release = store.subscribe(() => this.onState(store.getSnapshot()));
99
+ this.onState(store.getSnapshot());
100
+ }
101
+ get() {
102
+ return this.store?.getSnapshot() ?? EMPTY_STATE;
103
+ }
104
+ close() {
105
+ this.release?.();
106
+ this.release = null;
107
+ this.store = null;
108
+ this.identity = null;
109
+ }
110
+ }
111
+ exports.LiveSlot = LiveSlot;
@@ -0,0 +1,12 @@
1
+ import { type InjectionKey, type ShallowRef } from 'vue';
2
+ import type { LiveDataOf, LiveDescriptor, LiveInputsOf } from '../shared/descriptor';
3
+ import type { LiveClient, LiveState } from './core';
4
+ export declare const LIVE_CLIENT_KEY: InjectionKey<LiveClient>;
5
+ /** Call once, high in the tree. Every useLiveQuery() below it finds the client. */
6
+ export declare function provideLiveClient(client: LiveClient): void;
7
+ export declare function useLiveQuery<R>(descriptor: LiveDescriptor<R>, inputs?: () => LiveInputsOf<R>, options?: {
8
+ client?: LiveClient;
9
+ }): ShallowRef<LiveState<LiveDataOf<R>>>;
10
+ export declare function useLiveQuery<T>(resourceId: string, inputs?: () => Record<string, any>, options?: {
11
+ client?: LiveClient;
12
+ }): ShallowRef<LiveState<T>>;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LIVE_CLIENT_KEY = void 0;
4
+ exports.provideLiveClient = provideLiveClient;
5
+ exports.useLiveQuery = useLiveQuery;
6
+ const vue_1 = require("vue");
7
+ const vanilla_1 = require("./vanilla");
8
+ exports.LIVE_CLIENT_KEY = Symbol('carno.live.client');
9
+ /** Call once, high in the tree. Every useLiveQuery() below it finds the client. */
10
+ function provideLiveClient(client) {
11
+ (0, vue_1.provide)(exports.LIVE_CLIENT_KEY, client);
12
+ }
13
+ const PENDING = { data: undefined, pending: true, error: null, stale: false };
14
+ /**
15
+ * Subscribe a component to server-owned state, as a shallow ref.
16
+ *
17
+ * Shallow because the server replaces the whole snapshot and nothing ever
18
+ * writes into it: a deep proxy would pay to track mutations that cannot
19
+ * happen. `inputs` is read inside a watchEffect, so a ref it touches
20
+ * re-points the subscription; the effect scope tears it down.
21
+ */
22
+ function useLiveQuery(resource, inputs = () => ({}), options = {}) {
23
+ const client = options.client
24
+ ?? ((0, vue_1.getCurrentInstance)() ? (0, vue_1.inject)(exports.LIVE_CLIENT_KEY, undefined) : undefined);
25
+ if (!client) {
26
+ throw new Error('useLiveQuery() found no LiveClient. Call provideLiveClient(client) in an ancestor ' +
27
+ 'component, or pass { client } explicitly.');
28
+ }
29
+ const state = (0, vue_1.shallowRef)(PENDING);
30
+ const slot = new vanilla_1.LiveSlot(client, next => { state.value = next; });
31
+ const target = (0, vue_1.computed)(() => inputs());
32
+ (0, vue_1.watchEffect)(() => slot.point(resource, target.value));
33
+ (0, vue_1.onScopeDispose)(() => slot.close());
34
+ return state;
35
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Tunables from §10.1 of the design. These are starting points to calibrate
3
+ * against the recompute-without-patch metric, not measured values.
4
+ */
5
+ export interface LiveConfig {
6
+ /** Window in ms over which invalidations for one instance are grouped. */
7
+ coalesceMs: number;
8
+ /** Above this many row keys, one read collapses to its table key. */
9
+ maxKeysPerRead: number;
10
+ /** Ceiling on the canonicalized inputs of a single subscription. */
11
+ maxInputBytes: number;
12
+ /** Grace period before dropping an instance whose refcount hit zero. */
13
+ unsubGraceMs: number;
14
+ /** Consecutive back-pressured sends before collapsing to a snapshot. */
15
+ maxPendingPatches: number;
16
+ /** Above this fan-out, recompute is queued instead of run inline. */
17
+ fanoutQueueThreshold: number;
18
+ /** Ceiling on live instances held by a single connection. */
19
+ maxInstancesPerConnection: number;
20
+ /** Ceiling on live instances held by this process. */
21
+ maxInstancesPerNode: number;
22
+ /** Path of the SSE downstream, when the SSE transport is on. */
23
+ ssePath: string;
24
+ /** Path client messages are posted to, when the SSE transport is on. */
25
+ sseControlPath: string;
26
+ /** Comment frame interval that keeps idle-timeout proxies from reaping. */
27
+ sseHeartbeatMs: number;
28
+ /** Ceiling on concurrent SSE streams held by this process. */
29
+ sseMaxConnections: number;
30
+ }
31
+ export declare const DEFAULT_LIVE_CONFIG: LiveConfig;
32
+ export declare function resolveLiveConfig(overrides?: Partial<LiveConfig>): LiveConfig;
package/dist/config.js ADDED
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_LIVE_CONFIG = void 0;
4
+ exports.resolveLiveConfig = resolveLiveConfig;
5
+ exports.DEFAULT_LIVE_CONFIG = {
6
+ coalesceMs: 16,
7
+ maxKeysPerRead: 64,
8
+ maxInputBytes: 8192,
9
+ unsubGraceMs: 5000,
10
+ maxPendingPatches: 32,
11
+ fanoutQueueThreshold: 500,
12
+ maxInstancesPerConnection: 64,
13
+ maxInstancesPerNode: 50000,
14
+ ssePath: '/live/sse',
15
+ sseControlPath: '/live/control',
16
+ sseHeartbeatMs: 15000,
17
+ sseMaxConnections: 10000
18
+ };
19
+ function resolveLiveConfig(overrides = {}) {
20
+ return { ...exports.DEFAULT_LIVE_CONFIG, ...overrides };
21
+ }
@@ -0,0 +1,4 @@
1
+ import 'reflect-metadata';
2
+ import { type LiveOptions } from '../metadata';
3
+ /** Marks an existing route as subscribable while keeping it a normal HTTP route. */
4
+ export declare function Live(options?: LiveOptions): MethodDecorator;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Live = Live;
4
+ require("reflect-metadata");
5
+ const metadata_1 = require("../metadata");
6
+ /** Marks an existing route as subscribable while keeping it a normal HTTP route. */
7
+ function Live(options = {}) {
8
+ return function (target, propertyKey) {
9
+ const meta = {
10
+ key: options.key,
11
+ shared: options.shared ?? 'private',
12
+ dependsOn: options.dependsOn ?? [],
13
+ handlerName: String(propertyKey)
14
+ };
15
+ Reflect.defineMetadata(metadata_1.LIVE_META, meta, target.constructor, String(propertyKey));
16
+ };
17
+ }
@@ -0,0 +1,20 @@
1
+ import type { InvalidationBus } from '../bus/InvalidationBus';
2
+ import type { LiveConfig } from '../config';
3
+ export declare class WriteDuringComputeError extends Error {
4
+ constructor(table: string | undefined, operation: string | undefined);
5
+ }
6
+ /**
7
+ * First of the three invalidation sources in §4.4: writes issued through
8
+ * @carno.js/orm. Costs no infrastructure, and covers everything the
9
+ * application itself writes.
10
+ */
11
+ export declare class AppEmitter {
12
+ private readonly bus;
13
+ private readonly config;
14
+ /** Tables announced by another emitter, so we do not announce them twice. */
15
+ private covered;
16
+ constructor(bus: InvalidationBus, config: LiveConfig);
17
+ setCoveredTables(tables: Iterable<string>): void;
18
+ attach(): void;
19
+ detach(): void;
20
+ }
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AppEmitter = exports.WriteDuringComputeError = void 0;
4
+ const orm_1 = require("@carno.js/orm");
5
+ const dep_key_1 = require("../graph/dep-key");
6
+ const dependency_context_1 = require("../resource/dependency-context");
7
+ const statement_keys_1 = require("./statement-keys");
8
+ class WriteDuringComputeError extends Error {
9
+ constructor(table, operation) {
10
+ super(`A live resource compute attempted a ${operation ?? 'write'} on "${table ?? 'unknown'}". ` +
11
+ `A resource reads; an action writes. Re-running the handler on every change would ` +
12
+ `duplicate the side effect, so the write is refused.`);
13
+ this.name = 'WriteDuringComputeError';
14
+ }
15
+ }
16
+ exports.WriteDuringComputeError = WriteDuringComputeError;
17
+ /**
18
+ * First of the three invalidation sources in §4.4: writes issued through
19
+ * @carno.js/orm. Costs no infrastructure, and covers everything the
20
+ * application itself writes.
21
+ */
22
+ class AppEmitter {
23
+ constructor(bus, config) {
24
+ this.bus = bus;
25
+ this.config = config;
26
+ /** Tables announced by another emitter, so we do not announce them twice. */
27
+ this.covered = new Set();
28
+ }
29
+ setCoveredTables(tables) {
30
+ this.covered = new Set(tables);
31
+ }
32
+ attach() {
33
+ orm_1.statementObserver.onRead((statement) => {
34
+ const collector = dependency_context_1.dependencyContext.current();
35
+ if (!collector) {
36
+ // A read outside any compute: an ordinary request. Nothing to record.
37
+ return;
38
+ }
39
+ collector.addAll((0, statement_keys_1.readDependencies)(statement, this.config.maxKeysPerRead));
40
+ });
41
+ orm_1.statementObserver.onWriteAttempt((statement) => {
42
+ if (dependency_context_1.dependencyContext.isActive()) {
43
+ throw new WriteDuringComputeError(statement.table, statement.statement);
44
+ }
45
+ });
46
+ orm_1.statementObserver.onWrite((statement) => {
47
+ const events = (0, statement_keys_1.writeEvents)(statement, this.config.maxKeysPerRead);
48
+ // A table watched by the Postgres emitter already announces itself
49
+ // through the trigger, on every node at once. Publishing here too
50
+ // would only buy a duplicate recompute.
51
+ const ours = events.filter(event => !this.covered.has((0, dep_key_1.tableOfKey)(event.key) ?? ''));
52
+ this.bus.publish(ours);
53
+ });
54
+ }
55
+ detach() {
56
+ orm_1.statementObserver.reset();
57
+ }
58
+ }
59
+ exports.AppEmitter = AppEmitter;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The slice of Bun's Postgres client this module needs.
3
+ *
4
+ * Bun 1.4 implements `listen` and `notify` at runtime, but `@types/bun` does
5
+ * not declare them and the published docs still list them as unimplemented.
6
+ * Declaring the slice here keeps the cast in one place, and makes the whole
7
+ * thing injectable so the unit tests need no database.
8
+ */
9
+ export interface ListenableSql {
10
+ listen(channel: string, onNotify: (payload: string) => void): Promise<unknown>;
11
+ notify(channel: string, payload?: string): Promise<unknown>;
12
+ unsafe(query: string): Promise<unknown>;
13
+ close(): Promise<void>;
14
+ }
15
+ export interface PgListenerOptions {
16
+ url: string;
17
+ /** Liveness check interval. Zero disables the timer; `check()` still works. */
18
+ heartbeatMs?: number;
19
+ /** Delay between reconnection attempts. */
20
+ retryMs?: number;
21
+ /** Injected in tests. Defaults to a dedicated single Bun connection. */
22
+ sqlFactory?: (url: string) => ListenableSql;
23
+ /**
24
+ * Fired after the connection came back and every channel was re-listened.
25
+ *
26
+ * Whatever was published while the socket was down is gone with no trace,
27
+ * so the caller has to assume the worst — see PgNotifyEmitter.
28
+ */
29
+ onReconnect?: () => void;
30
+ }
31
+ /** One dedicated Postgres connection held open for LISTEN, with reconnection. */
32
+ export declare class PgListener {
33
+ private readonly options;
34
+ private sql;
35
+ private readonly channels;
36
+ private timer;
37
+ private opening;
38
+ private closed;
39
+ constructor(options: PgListenerOptions);
40
+ listen(channel: string, onNotify: (payload: string) => void): Promise<void>;
41
+ notify(channel: string, payload: string): Promise<void>;
42
+ /** One liveness probe. Called by the heartbeat and directly by tests. */
43
+ check(): Promise<void>;
44
+ close(): Promise<void>;
45
+ private factory;
46
+ private connect;
47
+ private open;
48
+ private startHeartbeat;
49
+ private reconnect;
50
+ }
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PgListener = void 0;
4
+ const bun_1 = require("bun");
5
+ const DEFAULT_HEARTBEAT_MS = 5000;
6
+ const DEFAULT_RETRY_MS = 1000;
7
+ function defaultSqlFactory(url) {
8
+ // A LISTEN connection cannot be shared with the query pool: it sits open
9
+ // waiting for asynchronous notifications, so it gets its own socket.
10
+ return new bun_1.SQL({ url, max: 1 });
11
+ }
12
+ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
13
+ /** One dedicated Postgres connection held open for LISTEN, with reconnection. */
14
+ class PgListener {
15
+ constructor(options) {
16
+ this.options = options;
17
+ this.sql = null;
18
+ this.channels = new Map();
19
+ this.timer = null;
20
+ this.opening = null;
21
+ this.closed = false;
22
+ }
23
+ async listen(channel, onNotify) {
24
+ const known = this.channels.has(channel);
25
+ this.channels.set(channel, onNotify);
26
+ if (!this.sql) {
27
+ // `open()` subscribes every registered channel, this one included.
28
+ await this.connect();
29
+ return;
30
+ }
31
+ if (!known) {
32
+ await this.sql.listen(channel, onNotify);
33
+ }
34
+ }
35
+ async notify(channel, payload) {
36
+ await this.connect();
37
+ await this.sql?.notify(channel, payload);
38
+ }
39
+ /** One liveness probe. Called by the heartbeat and directly by tests. */
40
+ async check() {
41
+ if (this.closed || !this.sql) {
42
+ return;
43
+ }
44
+ try {
45
+ await this.sql.unsafe('SELECT 1');
46
+ }
47
+ catch {
48
+ await this.reconnect();
49
+ }
50
+ }
51
+ async close() {
52
+ this.closed = true;
53
+ if (this.timer) {
54
+ clearInterval(this.timer);
55
+ this.timer = null;
56
+ }
57
+ const sql = this.sql;
58
+ this.sql = null;
59
+ try {
60
+ await sql?.close();
61
+ }
62
+ catch {
63
+ // Already gone. Nothing to do and nothing to report.
64
+ }
65
+ }
66
+ // ------------------------------------------------------------ internals
67
+ factory() {
68
+ return this.options.sqlFactory ?? defaultSqlFactory;
69
+ }
70
+ async connect() {
71
+ if (this.sql || this.closed) {
72
+ return;
73
+ }
74
+ if (!this.opening) {
75
+ this.opening = this.open().finally(() => {
76
+ this.opening = null;
77
+ });
78
+ }
79
+ await this.opening;
80
+ }
81
+ async open() {
82
+ const sql = this.factory()(this.options.url);
83
+ for (const [channel, handler] of this.channels) {
84
+ await sql.listen(channel, handler);
85
+ }
86
+ this.sql = sql;
87
+ this.startHeartbeat();
88
+ }
89
+ startHeartbeat() {
90
+ const interval = this.options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
91
+ if (this.timer || interval <= 0) {
92
+ return;
93
+ }
94
+ this.timer = setInterval(() => {
95
+ void this.check();
96
+ }, interval);
97
+ }
98
+ async reconnect() {
99
+ const previous = this.sql;
100
+ this.sql = null;
101
+ try {
102
+ await previous?.close();
103
+ }
104
+ catch {
105
+ // The socket is what just failed; closing it is best effort.
106
+ }
107
+ while (!this.closed) {
108
+ try {
109
+ await this.open();
110
+ this.options.onReconnect?.();
111
+ return;
112
+ }
113
+ catch {
114
+ this.sql = null;
115
+ await delay(this.options.retryMs ?? DEFAULT_RETRY_MS);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ exports.PgListener = PgListener;
@@ -0,0 +1,59 @@
1
+ import type { InvalidationEvent } from '../graph/types';
2
+ import { PgListener } from './pg-listener';
3
+ export interface PgNotifyTable {
4
+ /** Table name as it exists in the database, unqualified. */
5
+ table: string;
6
+ /** Primary key column, as it exists in the database. */
7
+ primaryKey: string;
8
+ }
9
+ export interface PgNotifyEmitterOptions {
10
+ tables: PgNotifyTable[];
11
+ /** Connection string. Defaults to the ORM's own. */
12
+ url: string;
13
+ /** Runs the DDL. Defaults to the ORM driver's `executeSql`. */
14
+ execute: (sql: string) => Promise<unknown>;
15
+ channel?: string;
16
+ maxPayloadBytes?: number;
17
+ heartbeatMs?: number;
18
+ retryMs?: number;
19
+ /** Injected in tests. */
20
+ listener?: PgListener;
21
+ }
22
+ export declare const DEFAULT_PG_CHANNEL = "carno_live";
23
+ export declare const DEFAULT_PG_MAX_PAYLOAD_BYTES = 7000;
24
+ /**
25
+ * Turn one trigger payload into invalidation events.
26
+ *
27
+ * Exported on its own because it is the whole translation layer between
28
+ * Postgres and the graph, and it is worth testing without a database.
29
+ */
30
+ export declare function eventsFromPayload(raw: string): InvalidationEvent[];
31
+ /**
32
+ * The second emitter of §4.4: writes that never went through @carno.js/orm.
33
+ *
34
+ * A trigger per watched table produces table + primary key + changed columns,
35
+ * which is the same key vocabulary the application emitter produces. The graph
36
+ * cannot tell them apart, and does not need to.
37
+ */
38
+ export declare class PgNotifyEmitter {
39
+ private readonly deliver;
40
+ private readonly options;
41
+ private readonly listener;
42
+ private readonly channel;
43
+ private attached;
44
+ constructor(deliver: (events: InvalidationEvent[]) => void, options: PgNotifyEmitterOptions);
45
+ /** Tables this emitter announces, so the ORM emitter can skip them. */
46
+ coveredTables(): Set<string>;
47
+ /** Install the trigger function and one trigger per watched table. */
48
+ install(): Promise<void>;
49
+ attach(): Promise<void>;
50
+ detach(): Promise<void>;
51
+ /** Remove the triggers. The function is left in place; it is harmless. */
52
+ uninstall(): Promise<void>;
53
+ /**
54
+ * Whatever was written while the socket was down arrived nowhere, and there
55
+ * is no way to ask Postgres what we missed. The only correct move is to
56
+ * assume everything watched is stale.
57
+ */
58
+ private onReconnect;
59
+ }
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PgNotifyEmitter = exports.DEFAULT_PG_MAX_PAYLOAD_BYTES = exports.DEFAULT_PG_CHANNEL = void 0;
4
+ exports.eventsFromPayload = eventsFromPayload;
5
+ const dep_key_1 = require("../graph/dep-key");
6
+ const pg_listener_1 = require("./pg-listener");
7
+ const pg_trigger_sql_1 = require("./pg-trigger-sql");
8
+ exports.DEFAULT_PG_CHANNEL = 'carno_live';
9
+ exports.DEFAULT_PG_MAX_PAYLOAD_BYTES = 7000;
10
+ /**
11
+ * Turn one trigger payload into invalidation events.
12
+ *
13
+ * Exported on its own because it is the whole translation layer between
14
+ * Postgres and the graph, and it is worth testing without a database.
15
+ */
16
+ function eventsFromPayload(raw) {
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(raw);
20
+ }
21
+ catch {
22
+ // Someone else is using our channel. Not our problem, and not a crash.
23
+ return [];
24
+ }
25
+ if (!parsed || typeof parsed.t !== 'string' || parsed.t === '') {
26
+ return [];
27
+ }
28
+ const table = parsed.t;
29
+ const columns = Array.isArray(parsed.c)
30
+ ? parsed.c.filter(item => typeof item === 'string')
31
+ : [];
32
+ const id = typeof parsed.i === 'string' || typeof parsed.i === 'number' ? parsed.i : null;
33
+ return [{
34
+ key: id === null ? (0, dep_key_1.tableKey)(table) : (0, dep_key_1.rowKey)(table, id),
35
+ // An empty list is "we do not know which columns", not "no columns".
36
+ columns: columns.length > 0 ? columns : null
37
+ }];
38
+ }
39
+ /**
40
+ * The second emitter of §4.4: writes that never went through @carno.js/orm.
41
+ *
42
+ * A trigger per watched table produces table + primary key + changed columns,
43
+ * which is the same key vocabulary the application emitter produces. The graph
44
+ * cannot tell them apart, and does not need to.
45
+ */
46
+ class PgNotifyEmitter {
47
+ constructor(deliver, options) {
48
+ this.deliver = deliver;
49
+ this.options = options;
50
+ this.attached = false;
51
+ this.channel = options.channel ?? exports.DEFAULT_PG_CHANNEL;
52
+ this.listener = options.listener ?? new pg_listener_1.PgListener({
53
+ url: options.url,
54
+ heartbeatMs: options.heartbeatMs,
55
+ retryMs: options.retryMs,
56
+ onReconnect: () => this.onReconnect()
57
+ });
58
+ }
59
+ /** Tables this emitter announces, so the ORM emitter can skip them. */
60
+ coveredTables() {
61
+ return new Set(this.options.tables.map(entry => entry.table));
62
+ }
63
+ /** Install the trigger function and one trigger per watched table. */
64
+ async install() {
65
+ await this.options.execute((0, pg_trigger_sql_1.createFunctionSql)(this.options.maxPayloadBytes ?? exports.DEFAULT_PG_MAX_PAYLOAD_BYTES));
66
+ for (const entry of this.options.tables) {
67
+ await this.options.execute((0, pg_trigger_sql_1.createTriggerSql)(entry.table, entry.primaryKey, this.channel));
68
+ }
69
+ }
70
+ async attach() {
71
+ if (this.attached) {
72
+ return;
73
+ }
74
+ this.attached = true;
75
+ await this.install();
76
+ await this.listener.listen(this.channel, payload => {
77
+ const events = eventsFromPayload(payload);
78
+ if (events.length > 0) {
79
+ this.deliver(events);
80
+ }
81
+ });
82
+ }
83
+ async detach() {
84
+ this.attached = false;
85
+ await this.listener.close();
86
+ }
87
+ /** Remove the triggers. The function is left in place; it is harmless. */
88
+ async uninstall() {
89
+ for (const entry of this.options.tables) {
90
+ await this.options.execute((0, pg_trigger_sql_1.dropTriggerSql)(entry.table));
91
+ }
92
+ }
93
+ /**
94
+ * Whatever was written while the socket was down arrived nowhere, and there
95
+ * is no way to ask Postgres what we missed. The only correct move is to
96
+ * assume everything watched is stale.
97
+ */
98
+ onReconnect() {
99
+ this.deliver(this.options.tables.map(entry => ({
100
+ key: (0, dep_key_1.tableKey)(entry.table),
101
+ columns: null
102
+ })));
103
+ }
104
+ }
105
+ exports.PgNotifyEmitter = PgNotifyEmitter;