@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
+ /**
2
+ * The SQL the Postgres emitter installs.
3
+ *
4
+ * It lives in its own module with no I/O so it can be asserted on without a
5
+ * database, and so the one place that concatenates identifiers into SQL is one
6
+ * short file you can read end to end.
7
+ */
8
+
9
+ const BARE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
10
+
11
+ export class UnsafeIdentifierError extends Error {
12
+ constructor(kind: string, value: string) {
13
+ super(
14
+ `Refusing to build SQL with an unsafe ${kind}: ${JSON.stringify(value)}. ` +
15
+ `Only bare names matching [A-Za-z_][A-Za-z0-9_]* are accepted.`
16
+ );
17
+ this.name = 'UnsafeIdentifierError';
18
+ }
19
+ }
20
+
21
+ /** Every identifier interpolated into the DDL below passes through here. */
22
+ export function assertIdentifier(kind: string, value: string): string {
23
+ if (!BARE_IDENTIFIER.test(value)) {
24
+ throw new UnsafeIdentifierError(kind, value);
25
+ }
26
+
27
+ return value;
28
+ }
29
+
30
+ export const TRIGGER_FUNCTION_NAME = 'carno_live_notify';
31
+
32
+ export function triggerNameOf(table: string): string {
33
+ return `carno_live_${assertIdentifier('table', table)}`;
34
+ }
35
+
36
+ /**
37
+ * The shared trigger function. Installed once; every table's trigger passes it
38
+ * the primary key column and the channel as arguments.
39
+ *
40
+ * Two decisions are baked in here rather than on the JavaScript side:
41
+ * an UPDATE whose jsonb diff is empty does not notify at all, because a write
42
+ * that changed nothing must not wake a single subscriber; and a payload over
43
+ * the ceiling degrades to the whole table instead of being truncated into
44
+ * something that would parse as a different row.
45
+ */
46
+ export function createFunctionSql(maxPayloadBytes: number): string {
47
+ if (!Number.isInteger(maxPayloadBytes) || maxPayloadBytes <= 0) {
48
+ throw new Error(`maxPayloadBytes must be a positive integer, got ${maxPayloadBytes}.`);
49
+ }
50
+
51
+ return `
52
+ CREATE OR REPLACE FUNCTION ${TRIGGER_FUNCTION_NAME}() RETURNS trigger AS $carno$
53
+ DECLARE
54
+ pk_column text := TG_ARGV[0];
55
+ channel text := TG_ARGV[1];
56
+ new_row jsonb;
57
+ old_row jsonb;
58
+ changed text[];
59
+ row_id text;
60
+ payload text;
61
+ BEGIN
62
+ IF TG_OP = 'DELETE' THEN
63
+ new_row := to_jsonb(OLD);
64
+ ELSE
65
+ new_row := to_jsonb(NEW);
66
+ END IF;
67
+
68
+ row_id := new_row ->> pk_column;
69
+
70
+ IF TG_OP = 'UPDATE' THEN
71
+ old_row := to_jsonb(OLD);
72
+
73
+ SELECT array_agg(entry.key ORDER BY entry.key) INTO changed
74
+ FROM jsonb_each(new_row) AS entry
75
+ WHERE entry.value IS DISTINCT FROM (old_row -> entry.key);
76
+
77
+ IF changed IS NULL THEN
78
+ RETURN NULL;
79
+ END IF;
80
+ END IF;
81
+
82
+ payload := json_build_object('t', TG_TABLE_NAME, 'i', row_id, 'c', changed)::text;
83
+
84
+ IF octet_length(payload) > ${maxPayloadBytes} THEN
85
+ payload := json_build_object('t', TG_TABLE_NAME, 'i', NULL, 'c', NULL)::text;
86
+ END IF;
87
+
88
+ PERFORM pg_notify(channel, payload);
89
+ RETURN NULL;
90
+ END;
91
+ $carno$ LANGUAGE plpgsql;
92
+ `.trim();
93
+ }
94
+
95
+ export function createTriggerSql(table: string, primaryKey: string, channel: string): string {
96
+ const safeTable = assertIdentifier('table', table);
97
+ const safeKey = assertIdentifier('primary key column', primaryKey);
98
+ const safeChannel = assertIdentifier('channel', channel);
99
+ const trigger = triggerNameOf(safeTable);
100
+
101
+ return `
102
+ DROP TRIGGER IF EXISTS ${trigger} ON ${safeTable};
103
+ CREATE TRIGGER ${trigger}
104
+ AFTER INSERT OR UPDATE OR DELETE ON ${safeTable}
105
+ FOR EACH ROW EXECUTE FUNCTION ${TRIGGER_FUNCTION_NAME}('${safeKey}', '${safeChannel}');
106
+ `.trim();
107
+ }
108
+
109
+ export function dropTriggerSql(table: string): string {
110
+ return `DROP TRIGGER IF EXISTS ${triggerNameOf(table)} ON ${assertIdentifier('table', table)};`;
111
+ }
@@ -0,0 +1,229 @@
1
+ import type { Statement } from '@carno.js/orm';
2
+ import { rowKey, tableKey } from '../graph/dep-key';
3
+ import type { Dependency, InvalidationEvent } from '../graph/types';
4
+
5
+ const WRITE_STATEMENTS = new Set(['insert', 'update', 'delete']);
6
+ const READ_STATEMENTS = new Set(['select', 'count']);
7
+
8
+ /**
9
+ * Turn the column list the ORM generated into bare column names.
10
+ * Returns null when the list is absent or contains an expression/star.
11
+ */
12
+ export function normalizeColumns(columns: string[] | undefined): string[] | null {
13
+ if (!columns || columns.length === 0) {
14
+ return null;
15
+ }
16
+
17
+ const names = new Set<string>();
18
+
19
+ for (const raw of columns) {
20
+ const beforeAlias = raw.split(/\s+as\s+/i)[0].trim();
21
+ const lastDot = beforeAlias.lastIndexOf('.');
22
+ const bare = (lastDot === -1 ? beforeAlias : beforeAlias.slice(lastDot + 1))
23
+ .replace(/["`\[\]]/g, '')
24
+ .trim();
25
+
26
+ if (bare === '' || bare === '*' || bare.includes('(')) {
27
+ return null;
28
+ }
29
+
30
+ names.add(bare);
31
+ }
32
+
33
+ return [...names].sort();
34
+ }
35
+
36
+ function unquote(value: string): string {
37
+ return value.replace(/["`\[\]]/g, '');
38
+ }
39
+
40
+ /** SQL statements carry a qualified and quoted table; keys use the table only. */
41
+ function tableNameOf(table: string): string {
42
+ const lastPart = table.trim().split('.').pop() ?? table;
43
+ return unquote(lastPart.trim());
44
+ }
45
+
46
+ function parseLiteral(raw: string): string | number {
47
+ const trimmed = raw.trim();
48
+
49
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
50
+ return trimmed.slice(1, -1);
51
+ }
52
+
53
+ return Number(trimmed);
54
+ }
55
+
56
+ function escapeRegExp(value: string): string {
57
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
+ }
59
+
60
+ function unwrapOuterParentheses(value: string): string {
61
+ let current = value.trim();
62
+
63
+ while (current.startsWith('(') && current.endsWith(')')) {
64
+ let depth = 0;
65
+ let enclosesWholeExpression = true;
66
+
67
+ for (let index = 0; index < current.length; index++) {
68
+ if (current[index] === '(') {
69
+ depth++;
70
+ } else if (current[index] === ')') {
71
+ depth--;
72
+
73
+ if (depth === 0 && index !== current.length - 1) {
74
+ enclosesWholeExpression = false;
75
+ break;
76
+ }
77
+ }
78
+ }
79
+
80
+ if (!enclosesWholeExpression || depth !== 0) {
81
+ break;
82
+ }
83
+
84
+ current = current.slice(1, -1).trim();
85
+ }
86
+
87
+ return current;
88
+ }
89
+
90
+ /**
91
+ * Extract primary-key values only when the WHERE clause is exactly a primary
92
+ * key equality or a primary-key IN list. Anything else degrades to the table.
93
+ */
94
+ export function extractRowIds(
95
+ where: string | undefined,
96
+ primaryKeyColumn: string | undefined
97
+ ): (string | number)[] | null {
98
+ if (!where || !primaryKeyColumn) {
99
+ return null;
100
+ }
101
+
102
+ const trimmed = unwrapOuterParentheses(where.trim());
103
+ const column = `(?:[\\w"\`\\[\\]]+\\.)?["\`\\[]?${escapeRegExp(primaryKeyColumn)}["\`\\]]?`;
104
+
105
+ const equality = new RegExp(`^${column}\\s*=\\s*(\\d+|'[^']*')$`, 'i').exec(trimmed);
106
+ if (equality) {
107
+ return [parseLiteral(equality[1])];
108
+ }
109
+
110
+ const inList = new RegExp(`^${column}\\s+IN\\s*\\(([^()]*)\\)$`, 'i').exec(trimmed);
111
+ if (inList) {
112
+ const items = inList[1].split(',').map(item => item.trim()).filter(item => item !== '');
113
+
114
+ if (items.length === 0 || items.some(item => !/^(\d+|'[^']*')$/.test(item))) {
115
+ return null;
116
+ }
117
+
118
+ return items.map(parseLiteral);
119
+ }
120
+
121
+ return null;
122
+ }
123
+
124
+ /** Dependencies registered by one read. Empty for writes. */
125
+ export function readDependencies(statement: Statement<any>, maxKeysPerRead: number): Dependency[] {
126
+ if (!READ_STATEMENTS.has(statement.statement ?? '') || !statement.table) {
127
+ return [];
128
+ }
129
+
130
+ const table = tableNameOf(statement.table);
131
+ const columns = normalizeColumns(statement.columns);
132
+ const ids = extractRowIds(statement.where, statement.primaryKeyColumnName);
133
+ const deps: Dependency[] = [];
134
+
135
+ if (ids && ids.length <= maxKeysPerRead) {
136
+ for (const id of ids) {
137
+ deps.push({ key: rowKey(table, id), columns });
138
+ }
139
+ } else {
140
+ deps.push({ key: tableKey(table), columns });
141
+ }
142
+
143
+ // A joined read depends on every joined table too. We cannot attribute the
144
+ // selected columns per table, so joins are wildcard.
145
+ for (const join of statement.join ?? []) {
146
+ if (join.joinTable) {
147
+ deps.push({ key: tableKey(tableNameOf(join.joinTable)), columns: null });
148
+ }
149
+ }
150
+
151
+ // Select-strategy relations execute these child statements directly via
152
+ // SqlJoinManager, so they never pass through the ORM's root observer hook.
153
+ // Their filters depend on the root rows and are not stable primary-key
154
+ // predicates; track each child table as a wildcard instead.
155
+ for (const selectJoin of statement.selectJoin ?? []) {
156
+ if (selectJoin.table) {
157
+ deps.push({ key: tableKey(tableNameOf(selectJoin.table)), columns: null });
158
+ }
159
+ }
160
+
161
+ return deps;
162
+ }
163
+
164
+ function writtenColumns(statement: Statement<any>): string[] | null {
165
+ if (statement.statement === 'delete') {
166
+ return null;
167
+ }
168
+
169
+ const values = statement.values;
170
+
171
+ if (!values) {
172
+ return null;
173
+ }
174
+
175
+ const rows: Record<string, unknown>[] = Array.isArray(values) ? values : [values];
176
+ const names = new Set<string>();
177
+
178
+ for (const row of rows) {
179
+ for (const name of Object.keys(row)) {
180
+ names.add(unquote(name));
181
+ }
182
+ }
183
+
184
+ return names.size === 0 ? null : [...names].sort();
185
+ }
186
+
187
+ function insertedIds(statement: Statement<any>): (string | number)[] | null {
188
+ const pk = statement.primaryKeyColumnName;
189
+
190
+ if (!pk || !statement.values) {
191
+ return null;
192
+ }
193
+
194
+ const rows: Record<string, unknown>[] = Array.isArray(statement.values)
195
+ ? statement.values
196
+ : [statement.values];
197
+ const ids: (string | number)[] = [];
198
+
199
+ for (const row of rows) {
200
+ const value = row[pk];
201
+
202
+ if (typeof value !== 'string' && typeof value !== 'number') {
203
+ return null;
204
+ }
205
+
206
+ ids.push(value);
207
+ }
208
+
209
+ return ids.length === 0 ? null : ids;
210
+ }
211
+
212
+ /** Invalidation events announced by one write. Empty for reads. */
213
+ export function writeEvents(statement: Statement<any>, maxKeysPerRead: number): InvalidationEvent[] {
214
+ if (!WRITE_STATEMENTS.has(statement.statement ?? '') || !statement.table) {
215
+ return [];
216
+ }
217
+
218
+ const table = tableNameOf(statement.table);
219
+ const columns = writtenColumns(statement);
220
+ const ids = statement.statement === 'insert'
221
+ ? insertedIds(statement)
222
+ : extractRowIds(statement.where, statement.primaryKeyColumnName);
223
+
224
+ if (ids && ids.length <= maxKeysPerRead) {
225
+ return ids.map(id => ({ key: rowKey(table, id), columns }));
226
+ }
227
+
228
+ return [{ key: tableKey(table), columns }];
229
+ }
@@ -0,0 +1,147 @@
1
+ import { ancestorsOf, type DepKey } from './dep-key';
2
+ import type { Dependency, InvalidationEvent } from './types';
3
+
4
+ /** Column sets registered per instance under one key. null means wildcard. */
5
+ type ColumnSet = Set<string> | null;
6
+
7
+ /**
8
+ * Key ↔ instance index with ancestor resolution and column filtering.
9
+ *
10
+ * Knows nothing about WebSocket, the ORM, or resources — it is a pure data
11
+ * structure, which is why the hard part of invalidation is testable without
12
+ * a server, a database or a socket.
13
+ */
14
+ export class DependencyGraph {
15
+ private readonly byKey = new Map<DepKey, Map<string, ColumnSet>>();
16
+ private readonly byInstance = new Map<string, Set<DepKey>>();
17
+
18
+ /** Replace every dependency held by this instance. */
19
+ setDependencies(instanceId: string, deps: Dependency[]): void {
20
+ this.remove(instanceId);
21
+
22
+ if (deps.length === 0) {
23
+ return;
24
+ }
25
+
26
+ const keys = new Set<DepKey>();
27
+
28
+ for (const dep of deps) {
29
+ keys.add(dep.key);
30
+
31
+ let holders = this.byKey.get(dep.key);
32
+ if (!holders) {
33
+ holders = new Map<string, ColumnSet>();
34
+ this.byKey.set(dep.key, holders);
35
+ }
36
+
37
+ if (!holders.has(instanceId)) {
38
+ holders.set(instanceId, dep.columns === null ? null : new Set(dep.columns));
39
+ continue;
40
+ }
41
+
42
+ const existing = holders.get(instanceId)!;
43
+
44
+ if (existing === null) {
45
+ continue;
46
+ }
47
+
48
+ if (dep.columns === null) {
49
+ holders.set(instanceId, null);
50
+ continue;
51
+ }
52
+
53
+ for (const column of dep.columns) {
54
+ existing.add(column);
55
+ }
56
+ }
57
+
58
+ this.byInstance.set(instanceId, keys);
59
+ }
60
+
61
+ /** Forget the instance entirely. */
62
+ remove(instanceId: string): void {
63
+ const keys = this.byInstance.get(instanceId);
64
+
65
+ if (!keys) {
66
+ return;
67
+ }
68
+
69
+ for (const key of keys) {
70
+ const holders = this.byKey.get(key);
71
+
72
+ if (!holders) {
73
+ continue;
74
+ }
75
+
76
+ holders.delete(instanceId);
77
+
78
+ if (holders.size === 0) {
79
+ this.byKey.delete(key);
80
+ }
81
+ }
82
+
83
+ this.byInstance.delete(instanceId);
84
+ }
85
+
86
+ /**
87
+ * Instances concerned by this write.
88
+ *
89
+ * Both directions of the hierarchy matter. A row write wakes table
90
+ * subscribers, while a table write wakes row subscribers because a
91
+ * predicate write may have touched that row.
92
+ */
93
+ resolve(event: InvalidationEvent): string[] {
94
+ const matched = new Set<string>();
95
+
96
+ for (const key of ancestorsOf(event.key)) {
97
+ this.collect(key, event.columns, matched);
98
+ }
99
+
100
+ const descendantPrefix = `${event.key}#`;
101
+ if (!event.key.includes('#')) {
102
+ for (const key of this.byKey.keys()) {
103
+ if (key.startsWith(descendantPrefix)) {
104
+ this.collect(key, event.columns, matched);
105
+ }
106
+ }
107
+ }
108
+
109
+ return [...matched];
110
+ }
111
+
112
+ keyCount(): number {
113
+ return this.byKey.size;
114
+ }
115
+
116
+ instanceCount(): number {
117
+ return this.byInstance.size;
118
+ }
119
+
120
+ private collect(key: DepKey, writtenColumns: string[] | null, into: Set<string>): void {
121
+ const holders = this.byKey.get(key);
122
+
123
+ if (!holders) {
124
+ return;
125
+ }
126
+
127
+ for (const [instanceId, readColumns] of holders) {
128
+ if (intersects(readColumns, writtenColumns)) {
129
+ into.add(instanceId);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ function intersects(readColumns: Set<string> | null, writtenColumns: string[] | null): boolean {
136
+ if (readColumns === null || writtenColumns === null) {
137
+ return true;
138
+ }
139
+
140
+ for (const column of writtenColumns) {
141
+ if (readColumns.has(column)) {
142
+ return true;
143
+ }
144
+ }
145
+
146
+ return false;
147
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Instance ↔ connection index with per-pair refcounting.
3
+ *
4
+ * The refcount is per (connection, instance) pair because one page can mount
5
+ * two components bound to the same resource with the same inputs: unmounting
6
+ * one must not unsubscribe the other.
7
+ */
8
+ export class SubscriptionRegistry {
9
+ private readonly byInstance = new Map<string, Map<string, number>>();
10
+ private readonly byConnection = new Map<string, Set<string>>();
11
+
12
+ /** Returns the connection's refcount on this instance after the call. */
13
+ subscribe(connectionId: string, instanceId: string): number {
14
+ let holders = this.byInstance.get(instanceId);
15
+ if (!holders) {
16
+ holders = new Map<string, number>();
17
+ this.byInstance.set(instanceId, holders);
18
+ }
19
+
20
+ const next = (holders.get(connectionId) ?? 0) + 1;
21
+ holders.set(connectionId, next);
22
+
23
+ let owned = this.byConnection.get(connectionId);
24
+ if (!owned) {
25
+ owned = new Set<string>();
26
+ this.byConnection.set(connectionId, owned);
27
+ }
28
+ owned.add(instanceId);
29
+
30
+ return next;
31
+ }
32
+
33
+ /** Returns the connection's refcount on this instance after the call. */
34
+ unsubscribe(connectionId: string, instanceId: string): number {
35
+ const holders = this.byInstance.get(instanceId);
36
+ const current = holders?.get(connectionId) ?? 0;
37
+
38
+ if (!holders || current === 0) {
39
+ return 0;
40
+ }
41
+
42
+ const next = current - 1;
43
+
44
+ if (next === 0) {
45
+ holders.delete(connectionId);
46
+ const owned = this.byConnection.get(connectionId);
47
+ owned?.delete(instanceId);
48
+
49
+ if (owned?.size === 0) {
50
+ this.byConnection.delete(connectionId);
51
+ }
52
+
53
+ if (holders.size === 0) {
54
+ this.byInstance.delete(instanceId);
55
+ }
56
+ } else {
57
+ holders.set(connectionId, next);
58
+ }
59
+
60
+ return next;
61
+ }
62
+
63
+ /** Drop the connection; returns instances now left with no subscriber. */
64
+ dropConnection(connectionId: string): string[] {
65
+ const owned = this.byConnection.get(connectionId);
66
+
67
+ if (!owned) {
68
+ return [];
69
+ }
70
+
71
+ const orphaned: string[] = [];
72
+
73
+ for (const instanceId of owned) {
74
+ const holders = this.byInstance.get(instanceId);
75
+
76
+ if (!holders) {
77
+ continue;
78
+ }
79
+
80
+ holders.delete(connectionId);
81
+
82
+ if (holders.size === 0) {
83
+ this.byInstance.delete(instanceId);
84
+ orphaned.push(instanceId);
85
+ }
86
+ }
87
+
88
+ this.byConnection.delete(connectionId);
89
+
90
+ return orphaned;
91
+ }
92
+
93
+ connectionsOf(instanceId: string): string[] {
94
+ return [...(this.byInstance.get(instanceId)?.keys() ?? [])];
95
+ }
96
+
97
+ hasSubscribers(instanceId: string): boolean {
98
+ return this.byInstance.has(instanceId);
99
+ }
100
+
101
+ /** Distinct instances held by the connection — the per-connection ceiling. */
102
+ countForConnection(connectionId: string): number {
103
+ return this.byConnection.get(connectionId)?.size ?? 0;
104
+ }
105
+
106
+ instanceCount(): number {
107
+ return this.byInstance.size;
108
+ }
109
+ }
@@ -0,0 +1,41 @@
1
+ export type DepKey = string;
2
+
3
+ const ROW_SEPARATOR = '#';
4
+
5
+ /** Key naming a whole table: every row and every column of it. */
6
+ export function tableKey(table: string): DepKey {
7
+ return `orm:${table}`;
8
+ }
9
+
10
+ /** Key naming one row by primary key. */
11
+ export function rowKey(table: string, id: string | number): DepKey {
12
+ return `orm:${table}${ROW_SEPARATOR}${id}`;
13
+ }
14
+
15
+ /**
16
+ * The key itself plus every key that contains it.
17
+ *
18
+ * The hierarchy is exactly two levels deep and the only separator is `#`:
19
+ * `orm:users#42` is contained by `orm:users`. Colons are not hierarchical.
20
+ */
21
+ export function ancestorsOf(key: DepKey): DepKey[] {
22
+ const index = key.indexOf(ROW_SEPARATOR);
23
+
24
+ if (index === -1) {
25
+ return [key];
26
+ }
27
+
28
+ return [key, key.slice(0, index)];
29
+ }
30
+
31
+ /** The table an ORM key names, or null for a key from another namespace. */
32
+ export function tableOfKey(key: DepKey): string | null {
33
+ if (!key.startsWith('orm:')) {
34
+ return null;
35
+ }
36
+
37
+ const rest = key.slice('orm:'.length);
38
+ const separator = rest.indexOf(ROW_SEPARATOR);
39
+
40
+ return separator === -1 ? rest : rest.slice(0, separator);
41
+ }
@@ -0,0 +1,15 @@
1
+ import type { DepKey } from './dep-key';
2
+
3
+ /** A read registered by one resource compute. */
4
+ export interface Dependency {
5
+ key: DepKey;
6
+ /** Null is a wildcard for an unenumerated column set. */
7
+ columns: string[] | null;
8
+ }
9
+
10
+ /** A write announced by an invalidation emitter. */
11
+ export interface InvalidationEvent {
12
+ key: DepKey;
13
+ /** Null is a wildcard for a whole-row write. */
14
+ columns: string[] | null;
15
+ }