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