@spooky-sync/core 0.0.1-canary.29 → 0.0.1-canary.290

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 (254) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2027 -137
  4. package/dist/index.js +14564 -2472
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +329 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +358 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1700 -38
  19. package/dist/worker-edges.js +423 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +82 -0
  30. package/src/boot/auth-flip.saga.ts +62 -0
  31. package/src/boot/boot.saga.test.ts +158 -0
  32. package/src/boot/boot.saga.ts +131 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.ts +366 -0
  46. package/src/client/sp00ky-client.test.ts +271 -0
  47. package/src/client/sp00ky-client.ts +541 -0
  48. package/src/events/events.test.ts +2 -1
  49. package/src/events/index.ts +3 -0
  50. package/src/index.ts +69 -1
  51. package/src/kernel/constants.test.ts +19 -0
  52. package/src/kernel/constants.ts +80 -0
  53. package/src/kernel/effects.test.ts +40 -0
  54. package/src/kernel/effects.ts +161 -0
  55. package/src/kernel/events.ts +58 -0
  56. package/src/kernel/interpreter.test.ts +116 -0
  57. package/src/kernel/interpreter.ts +148 -0
  58. package/src/kernel/saga.test.ts +89 -0
  59. package/src/kernel/saga.ts +73 -0
  60. package/src/live/index.test.ts +478 -0
  61. package/src/live/index.ts +1013 -0
  62. package/src/modules/app-release/index.test.ts +125 -0
  63. package/src/modules/app-release/index.ts +209 -0
  64. package/src/modules/auth/auth.local-first.test.ts +141 -0
  65. package/src/modules/auth/events/index.ts +9 -2
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.test.ts +355 -0
  68. package/src/modules/auth/impersonation.ts +179 -0
  69. package/src/modules/auth/index.ts +386 -17
  70. package/src/modules/crdt/crdt-field.ts +294 -0
  71. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  72. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  73. package/src/modules/crdt/index.ts +463 -0
  74. package/src/modules/crdt/loro-loader.ts +25 -0
  75. package/src/modules/devtools/flags.ts +349 -0
  76. package/src/modules/devtools/index.ts +519 -37
  77. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  78. package/src/modules/devtools/state-shape.test.ts +149 -0
  79. package/src/modules/devtools/storage-info.test.ts +79 -0
  80. package/src/modules/devtools/storage-info.ts +168 -0
  81. package/src/modules/devtools/versions.test.ts +74 -0
  82. package/src/modules/devtools/versions.ts +110 -0
  83. package/src/modules/feature-flag/index.test.ts +251 -0
  84. package/src/modules/feature-flag/index.ts +316 -0
  85. package/src/modules/ref-tables.test.ts +91 -0
  86. package/src/modules/ref-tables.ts +88 -0
  87. package/src/modules/web-push/index.test.ts +824 -0
  88. package/src/modules/web-push/index.ts +1027 -0
  89. package/src/mutation/jobs.test.ts +25 -0
  90. package/src/mutation/jobs.ts +41 -0
  91. package/src/mutation/mutation-id.test.ts +37 -0
  92. package/src/mutation/mutation-id.ts +35 -0
  93. package/src/mutation/pending-writes.integration.test.ts +175 -0
  94. package/src/mutation/push.saga.test.ts +285 -0
  95. package/src/mutation/push.saga.ts +217 -0
  96. package/src/mutation/rows.test.ts +231 -0
  97. package/src/mutation/rows.ts +427 -0
  98. package/src/mutation/tray.saga.test.ts +63 -0
  99. package/src/mutation/tray.saga.ts +48 -0
  100. package/src/mutation/write.saga.test.ts +316 -0
  101. package/src/mutation/write.saga.ts +268 -0
  102. package/src/otel/index.ts +10 -7
  103. package/src/pure.ts +65 -0
  104. package/src/push/types.ts +174 -0
  105. package/src/query/env.test.ts +23 -0
  106. package/src/query/env.ts +56 -0
  107. package/src/query/fetch.saga.test.ts +199 -0
  108. package/src/query/fetch.saga.ts +120 -0
  109. package/src/query/hash.test.ts +14 -0
  110. package/src/query/hash.ts +18 -0
  111. package/src/query/id-set-plan.test.ts +132 -0
  112. package/src/query/lifecycle.saga.test.ts +274 -0
  113. package/src/query/lifecycle.saga.ts +159 -0
  114. package/src/query/materialize.saga.test.ts +115 -0
  115. package/src/query/materialize.saga.ts +84 -0
  116. package/src/query/materialize.test.ts +58 -0
  117. package/src/query/materialize.ts +57 -0
  118. package/src/query/membership.saga.test.ts +350 -0
  119. package/src/query/membership.saga.ts +278 -0
  120. package/src/query/membership.test.ts +118 -0
  121. package/src/query/membership.ts +201 -0
  122. package/src/query/register.saga.test.ts +293 -0
  123. package/src/query/register.saga.ts +226 -0
  124. package/src/query/render-set.test.ts +47 -0
  125. package/src/query/render-set.ts +71 -0
  126. package/src/query/sql.test.ts +63 -0
  127. package/src/query/sql.ts +93 -0
  128. package/src/query/window-query.test.ts +70 -0
  129. package/src/query/window-query.ts +195 -0
  130. package/src/services/blobs/blob-cache.test.ts +359 -0
  131. package/src/services/blobs/blob-cache.ts +603 -0
  132. package/src/services/blobs/blob-manifest.ts +227 -0
  133. package/src/services/blobs/blob-store.test.ts +77 -0
  134. package/src/services/blobs/blob-store.ts +359 -0
  135. package/src/services/blobs/blob.fixture.ts +90 -0
  136. package/src/services/blobs/index.ts +70 -0
  137. package/src/services/database/cache-engine.ts +199 -0
  138. package/src/services/database/connection-supervisor.test.ts +318 -0
  139. package/src/services/database/connection-supervisor.ts +422 -0
  140. package/src/services/database/database.query-timeout.test.ts +140 -0
  141. package/src/services/database/database.responses.test.ts +43 -0
  142. package/src/services/database/database.ts +144 -61
  143. package/src/services/database/engine-factory.ts +33 -0
  144. package/src/services/database/errors.ts +34 -0
  145. package/src/services/database/events/index.ts +2 -1
  146. package/src/services/database/index.ts +7 -0
  147. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  148. package/src/services/database/local-migrator.integration.test.ts +110 -0
  149. package/src/services/database/local-migrator.ts +59 -16
  150. package/src/services/database/local.test.ts +64 -0
  151. package/src/services/database/local.ts +487 -63
  152. package/src/services/database/plan-render.test.ts +159 -0
  153. package/src/services/database/plan-render.ts +108 -0
  154. package/src/services/database/relation-resolver.test.ts +467 -0
  155. package/src/services/database/relation-resolver.ts +0 -0
  156. package/src/services/database/remote.ts +213 -6
  157. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  158. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  159. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  160. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  161. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  162. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  163. package/src/services/database/sqlite-lock-verify.ts +45 -0
  164. package/src/services/database/sqlite-open.test.ts +150 -0
  165. package/src/services/database/sqlite-open.ts +164 -0
  166. package/src/services/database/sqlite-plan-sql.test.ts +139 -0
  167. package/src/services/database/sqlite-plan-sql.ts +179 -0
  168. package/src/services/database/sqlite-projection.test.ts +121 -0
  169. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  170. package/src/services/database/sqlite-select.test.ts +246 -0
  171. package/src/services/database/sqlite-select.ts +131 -0
  172. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  173. package/src/services/database/sqlite-transport.ts +224 -0
  174. package/src/services/database/sqlite-worker.ts +437 -0
  175. package/src/services/database/surql-translate.ts +416 -0
  176. package/src/services/database/surreal-cache-engine.ts +161 -0
  177. package/src/services/logger/index.ts +3 -2
  178. package/src/services/persistence/localstorage.ts +2 -2
  179. package/src/services/persistence/resilient.ts +10 -3
  180. package/src/services/persistence/surrealdb.ts +4 -4
  181. package/src/services/stream-processor/index.ts +779 -67
  182. package/src/services/stream-processor/permissions.test.ts +47 -0
  183. package/src/services/stream-processor/permissions.ts +53 -0
  184. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  185. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  186. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  187. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  188. package/src/services/stream-processor/wasm-types.ts +58 -2
  189. package/src/services/tabs/broker-client.ts +283 -0
  190. package/src/services/tabs/broker.test.ts +327 -0
  191. package/src/services/tabs/coordinator.test.ts +386 -0
  192. package/src/services/tabs/coordinator.ts +638 -0
  193. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  194. package/src/services/tabs/leader-locks.ts +75 -0
  195. package/src/services/tabs/protocol.ts +262 -0
  196. package/src/services/tabs/support.ts +36 -0
  197. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  198. package/src/sp00ky.ts +5 -395
  199. package/src/state/client-state.ts +200 -0
  200. package/src/state/lifecycle.test.ts +118 -0
  201. package/src/state/lifecycle.ts +86 -0
  202. package/src/state/reducers.test.ts +304 -0
  203. package/src/state/reducers.ts +526 -0
  204. package/src/state/selectors.test.ts +167 -0
  205. package/src/state/selectors.ts +216 -0
  206. package/src/sw/index.test.ts +582 -0
  207. package/src/sw/index.ts +607 -0
  208. package/src/sw/store.ts +96 -0
  209. package/src/sync/connection.saga.test.ts +86 -0
  210. package/src/sync/connection.saga.ts +85 -0
  211. package/src/sync/live.saga.test.ts +178 -0
  212. package/src/sync/live.saga.ts +107 -0
  213. package/src/sync/policy.health.test.ts +56 -0
  214. package/src/sync/policy.test.ts +659 -0
  215. package/src/sync/policy.ts +502 -0
  216. package/src/sync/poll.saga.test.ts +51 -0
  217. package/src/sync/poll.saga.ts +36 -0
  218. package/src/sync/tabs.saga.test.ts +78 -0
  219. package/src/sync/tabs.saga.ts +96 -0
  220. package/src/testing/adapters.ts +133 -0
  221. package/src/testing/build.ts +63 -0
  222. package/src/testing/fake-services.ts +34 -0
  223. package/src/testing/run-pure.test.ts +92 -0
  224. package/src/testing/run-pure.ts +128 -0
  225. package/src/testing/services.ts +17 -0
  226. package/src/types.ts +624 -10
  227. package/src/utils/blurhash.ts +90 -0
  228. package/src/utils/edges.ts +39 -0
  229. package/src/utils/error-classification.test.ts +54 -0
  230. package/src/utils/error-classification.ts +25 -0
  231. package/src/utils/index.ts +75 -9
  232. package/src/utils/parser.test.ts +67 -102
  233. package/src/utils/parser.ts +64 -2
  234. package/src/utils/semver.test.ts +32 -0
  235. package/src/utils/semver.ts +30 -0
  236. package/src/utils/sha256.ts +15 -0
  237. package/src/utils/surql.ts +30 -18
  238. package/src/utils/utils.test.ts +2 -1
  239. package/tsdown.config.ts +117 -3
  240. package/vitest.config.ts +48 -0
  241. package/src/modules/cache/index.ts +0 -241
  242. package/src/modules/cache/types.ts +0 -19
  243. package/src/modules/data/data.test.ts +0 -58
  244. package/src/modules/data/index.ts +0 -879
  245. package/src/modules/sync/engine.ts +0 -173
  246. package/src/modules/sync/events/index.ts +0 -77
  247. package/src/modules/sync/index.ts +0 -3
  248. package/src/modules/sync/queue/index.ts +0 -2
  249. package/src/modules/sync/queue/queue-down.ts +0 -89
  250. package/src/modules/sync/queue/queue-up.ts +0 -223
  251. package/src/modules/sync/scheduler.ts +0 -84
  252. package/src/modules/sync/sync.ts +0 -421
  253. package/src/modules/sync/utils.test.ts +0 -311
  254. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,100 @@
1
+ import type { RuntimeEvent } from '../kernel/events';
2
+ import type { Lane, Saga } from '../kernel/saga';
3
+ import type { SagaEnv } from '../query/env';
4
+ import { ensureRegistered, registerRemote } from '../query/register.saga';
5
+ import { readDirtyMembership, readMembership, recoverLostView } from '../query/membership.saga';
6
+ import { fetchRows } from '../query/fetch.saga';
7
+ import { materialize, streamUpdate } from '../query/materialize.saga';
8
+ import { ackPrune, gcTick, lifecycleTick } from '../query/lifecycle.saga';
9
+ import { drain } from '../mutation/push.saga';
10
+ import { adoptPendingWrites, flushPendingWrites, flushWrite } from '../mutation/write.saga';
11
+ import { pollTick } from '../sync/poll.saga';
12
+ import { liveChange, liveStart } from '../sync/live.saga';
13
+ import { connectionChanged, selfHealTick, syncOutcome } from '../sync/connection.saga';
14
+ import { setRole, tabMessage } from '../sync/tabs.saga';
15
+ import { pageHide, primeCircuit, startRemote, versionsPrimed, warmBlobs } from '../boot/boot.saga';
16
+ import { authFlip } from '../boot/auth-flip.saga';
17
+ import { bucketSwitch } from '../boot/bucket-switch.saga';
18
+ import { fx } from '../kernel/effects';
19
+
20
+ export interface RouteTarget {
21
+ saga: Saga<unknown>;
22
+ lane?: Lane;
23
+ }
24
+
25
+ const serial = (key: string): Lane => ({ kind: 'serial', key });
26
+ const dedupe = (key: string): Lane => ({ kind: 'dedupe', key });
27
+
28
+ function* materializeDirty(): Saga<void> {
29
+ const dirty = (yield fx.state.read((s) => [...s.dirty])) as string[];
30
+ for (const hash of dirty) yield fx.dispatch({ type: 'Materialize', hash });
31
+ }
32
+
33
+ /** One table: which saga handles an event, and on which lane. */
34
+ export function route(env: SagaEnv, event: RuntimeEvent): RouteTarget {
35
+ switch (event.type) {
36
+ case 'EnsureRegistered':
37
+ return { saga: ensureRegistered(env, { requireAuth: event.requireAuth, attempt: event.attempt }), lane: serial('ensure') };
38
+ case 'RegisterRemote':
39
+ return { saga: registerRemote(env, event.hash, event.retry), lane: dedupe(`register:${event.hash}`) };
40
+ case 'RecoverLostView':
41
+ return { saga: recoverLostView(event.hash), lane: dedupe(`view-lost:${event.hash}`) };
42
+ case 'ReadDirtyMembership':
43
+ return { saga: readDirtyMembership(env), lane: serial('membership') };
44
+ case 'ReadMembership':
45
+ return { saga: readMembership(env, event.hashes, { force: true }), lane: serial('membership') };
46
+ case 'FetchRows':
47
+ return { saga: fetchRows(env), lane: serial('fetch') };
48
+ case 'Materialize':
49
+ return { saga: materialize(event.hash), lane: serial(`mat:${event.hash}`) };
50
+ case 'MaterializeDirty':
51
+ return { saga: materializeDirty() };
52
+ case 'StreamUpdate':
53
+ return { saga: streamUpdate(event.update), lane: serial(`stream:${event.update.queryHash}`) };
54
+ case 'LifecycleTick':
55
+ case 'HeartbeatNow':
56
+ return { saga: lifecycleTick(env), lane: dedupe('lifecycle') };
57
+ case 'AckPrune':
58
+ return { saga: ackPrune(), lane: dedupe('ack-prune') };
59
+ case 'GcTick':
60
+ return { saga: gcTick(), lane: dedupe('gc') };
61
+ case 'Drain':
62
+ return { saga: drain(env), lane: serial('outbox') };
63
+ case 'FlushWrite':
64
+ return { saga: flushWrite(env, event.key), lane: serial('outbox-write') };
65
+ case 'FlushPendingWrites':
66
+ return { saga: flushPendingWrites() };
67
+ case 'AdoptPendingWrites':
68
+ return { saga: adoptPendingWrites(), lane: serial('outbox-write') };
69
+ case 'PollTick':
70
+ return { saga: pollTick(env), lane: dedupe('poll') };
71
+ case 'SelfHealTick':
72
+ return { saga: selfHealTick(env), lane: dedupe('heal') };
73
+ case 'SyncOutcome':
74
+ return { saga: syncOutcome(env, event.ok, event.error), lane: serial('health') };
75
+ case 'ConnectionChanged':
76
+ return { saga: connectionChanged(env, event.state), lane: serial('connection') };
77
+ case 'LiveStart':
78
+ return { saga: liveStart(env), lane: serial('live') };
79
+ case 'LiveChange':
80
+ return { saga: liveChange(env, event.hashes, event.rows), lane: serial('live-change') };
81
+ case 'TabRole':
82
+ return { saga: setRole(env, event.role), lane: serial('tabs') };
83
+ case 'TabMessage':
84
+ return { saga: tabMessage(env, event.message), lane: serial('tabs') };
85
+ case 'StartRemote':
86
+ return { saga: startRemote(), lane: dedupe('start-remote') };
87
+ case 'PrimeCircuit':
88
+ return { saga: primeCircuit(), lane: dedupe('prime') };
89
+ case 'VersionsPrimed':
90
+ return { saga: versionsPrimed(event.entries) };
91
+ case 'WarmBlobs':
92
+ return { saga: warmBlobs(), lane: dedupe('blobs') };
93
+ case 'AuthFlip':
94
+ return { saga: authFlip(env, event.userId), lane: serial('bucket') };
95
+ case 'BucketSwitch':
96
+ return { saga: bucketSwitch(env, event.target), lane: serial('bucket') };
97
+ case 'PageHide':
98
+ return { saga: pageHide() };
99
+ }
100
+ }
@@ -0,0 +1,195 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { fakeAdapters } from '../testing/adapters';
3
+ import { buildEntry, buildState } from '../testing/build';
4
+ import { defaultEnv } from '../query/env';
5
+ import { fx } from '../kernel/effects';
6
+ import type { Saga } from '../kernel/saga';
7
+ import * as R from '../state/reducers';
8
+ import { Runtime } from './runtime';
9
+
10
+ const env = defaultEnv({ tables: [] } as any, { materializeDebounceMs: 5 });
11
+ const noop = () => {};
12
+ const logs: unknown[] = [];
13
+ const logger: any = { debug: noop, info: noop, warn: noop, trace: noop, error: (o: unknown) => logs.push(o), child: () => logger };
14
+ const make = (over: Parameters<typeof fakeAdapters>[0] = {}, initialState = buildState()) => {
15
+ const a = fakeAdapters(over);
16
+ const rt = new Runtime({ env, adapters: a.adapters, logger, tabId: 'tab-a', initialState });
17
+ return { a, rt };
18
+ };
19
+ const tick = () => new Promise((r) => setTimeout(r, 0));
20
+
21
+ describe('Runtime lanes', () => {
22
+ it('serial lanes run one at a time in order; dedupe lanes join the running saga', async () => {
23
+ const { rt } = make();
24
+ const order: string[] = [];
25
+ function* step(name: string, wait: Promise<void>): Saga<string> {
26
+ order.push(`${name}:start`);
27
+ yield fx.state.wait(() => true);
28
+ await0(wait);
29
+ order.push(`${name}:end`);
30
+ return name;
31
+ }
32
+ let release!: () => void;
33
+ const gate = new Promise<void>((r) => (release = r));
34
+ const await0 = (_p: Promise<void>) => undefined;
35
+ const a = rt.run(step('a', gate), { lane: { kind: 'serial', key: 'x' } });
36
+ const b = rt.run(step('b', gate), { lane: { kind: 'serial', key: 'x' } });
37
+ release();
38
+ expect(await a).toBe('a');
39
+ expect(await b).toBe('b');
40
+ expect(order).toEqual(['a:start', 'a:end', 'b:start', 'b:end']);
41
+ let n = 0;
42
+ function* counted(): Saga<number> {
43
+ n++;
44
+ yield fx.state.wait(() => true);
45
+ return n;
46
+ }
47
+ const d1 = rt.run(counted(), { lane: { kind: 'dedupe', key: 'd' } });
48
+ const d2 = rt.run(counted(), { lane: { kind: 'dedupe', key: 'd' } });
49
+ expect(await d1).toBe(1);
50
+ expect(await d2).toBe(1);
51
+ expect(n).toBe(1);
52
+ const d3 = await rt.run(counted(), { lane: { kind: 'dedupe', key: 'd' } });
53
+ expect(d3).toBe(2);
54
+ });
55
+ it('a failing saga on a serial lane does not block the next one; dispatch logs and never rejects', async () => {
56
+ const { rt } = make();
57
+ function* boom(): Saga<void> {
58
+ yield fx.state.wait(() => true);
59
+ throw new Error('nope');
60
+ }
61
+ function* fine(): Saga<string> {
62
+ yield fx.state.wait(() => true);
63
+ return 'ok';
64
+ }
65
+ await expect(rt.run(boom(), { lane: { kind: 'serial', key: 'k' } })).rejects.toThrow('nope');
66
+ expect(await rt.run(fine(), { lane: { kind: 'serial', key: 'k' } })).toBe('ok');
67
+ logs.length = 0;
68
+ await rt.dispatch({ type: 'Materialize', hash: 'missing' });
69
+ await rt.dispatch({ type: 'ReadMembership', hashes: [] });
70
+ expect(logs).toEqual([]);
71
+ });
72
+ });
73
+
74
+ describe('Runtime state hooks', () => {
75
+ it('dirty queries are materialized after the debounce, once per burst', async () => {
76
+ const { a, rt } = make(
77
+ { local: { select: async () => [{ id: 'thing:1' }] } },
78
+ buildState([buildEntry({ def: { hash: 'q', plan: { table: 'thing' } as any } })], R.reloadViews(new Map([['v', ['thing:1']]])))
79
+ );
80
+ const seen: unknown[] = [];
81
+ rt.subscribe('q', (rows) => seen.push(rows));
82
+ rt.update(R.markDirty(['q']));
83
+ rt.update(R.markDirty(['q']));
84
+ expect(a.timers.pending.has('mat:q')).toBe(true);
85
+ a.timers.fire('mat:q');
86
+ await tick();
87
+ await tick();
88
+ expect(seen).toEqual([[{ id: 'thing:1' }]]);
89
+ expect(rt.state.dirty.has('q')).toBe(false);
90
+ });
91
+ it('status and authority subscriptions fire on transitions, immediate delivers the current value', async () => {
92
+ const { rt } = make({}, buildState([buildEntry({ def: { hash: 'q' } })]));
93
+ const statuses: string[] = [];
94
+ const auths: boolean[] = [];
95
+ const offS = rt.subscribeStatus('q', (s) => statuses.push(s), { immediate: true });
96
+ const offA = rt.subscribeAuthority('q', (k) => auths.push(k), { immediate: true });
97
+ const offA2 = rt.subscribeAuthority('q', () => auths.push(false));
98
+ rt.update(R.applyLifecycle('q', { type: 'fetch-begin' }));
99
+ rt.update(R.applyLifecycle('q', { type: 'fetch-begin' }));
100
+ rt.update(R.applyLifecycle('q', { type: 'fetch-end' }));
101
+ rt.update(R.applyLifecycle('q', { type: 'fetch-end' }));
102
+ rt.emit({ type: 'query:authority', hash: 'q', known: true });
103
+ expect(statuses).toEqual(['idle', 'fetching', 'idle']);
104
+ expect(auths).toEqual([false, true, false]);
105
+ offA2();
106
+ offA();
107
+ rt.emit({ type: 'query:authority', hash: 'q', known: false });
108
+ expect(auths).toHaveLength(3);
109
+ offS();
110
+ rt.update(R.applyLifecycle('q', { type: 'fetch-begin' }));
111
+ expect(statuses).toHaveLength(3);
112
+ const missing: string[] = [];
113
+ rt.subscribeStatus('zz', (s) => missing.push(s), { immediate: true });
114
+ rt.subscribeAuthority('zz', () => missing.push('x'), { immediate: true });
115
+ expect(missing).toEqual([]);
116
+ });
117
+ it('notifies health on a transport change, and only on a real move', () => {
118
+ const { rt } = make();
119
+ const seen: string[] = [];
120
+ rt.on('health:changed', (e) => seen.push(e.type === 'health:changed' ? e.health.connection : '?'));
121
+ rt.update(R.setConnection('connected'));
122
+ rt.update(R.setConnection('connected'));
123
+ rt.update(R.setConnection('reconnecting'));
124
+ expect(seen).toEqual(['connected', 'reconnecting']);
125
+ // A health snapshot rebuilt with the same values must not re-notify.
126
+ rt.update(R.setHealth({ ...rt.state.sync.health }));
127
+ expect(seen).toHaveLength(2);
128
+ rt.update(R.setHealth({ ...rt.state.sync.health, status: 'degraded' }));
129
+ expect(seen).toHaveLength(3);
130
+ });
131
+ it('activity, listeners, records subscription bookkeeping, waitFor and dispose', async () => {
132
+ const { a, rt } = make({}, buildState([buildEntry({ def: { hash: 'q' } })]));
133
+ const events: string[] = [];
134
+ const off = rt.on('activity:changed', (e) => events.push(JSON.stringify(e)));
135
+ rt.on('*', (e) => events.push(`*${e.type}`));
136
+ rt.update(R.applyLifecycle('q', { type: 'fetch-begin' }));
137
+ expect(events).toEqual(['{"type":"activity:changed","fetching":1,"pending":0}', '*activity:changed']);
138
+ off();
139
+ const rows: unknown[] = [];
140
+ const unsub = rt.subscribe('q', (r) => rows.push(r), { immediate: true });
141
+ expect(rows).toEqual([[]]);
142
+ expect(rt.state.queries.get('q')!.subscribers).toBe(1);
143
+ rt.emit({ type: 'query:records', hash: 'q', records: [{ id: 1 }] });
144
+ expect(rows).toHaveLength(2);
145
+ unsub();
146
+ unsub();
147
+ expect(rt.state.queries.get('q')!.subscribers).toBe(0);
148
+ expect(rt.state.queries.get('q')!.lastSubscriberLeftAt).toBe(1_700_000_000_000);
149
+ rt.emit({ type: 'log', level: 'warn', message: 'x' });
150
+ rt.subscribe('q', () => {
151
+ throw new Error('subscriber bug');
152
+ });
153
+ rt.emit({ type: 'query:records', hash: 'q', records: [] });
154
+ expect(logs.at(-1)).toMatchObject({ error: new Error('subscriber bug') });
155
+ await expect(rt.waitFor(() => true)).resolves.toBeUndefined();
156
+ const ctrl = new AbortController();
157
+ ctrl.abort();
158
+ await expect(rt.waitFor(() => false, ctrl.signal)).rejects.toThrow('aborted');
159
+ const ctrl2 = new AbortController();
160
+ const p = rt.waitFor(() => false, ctrl2.signal);
161
+ ctrl2.abort();
162
+ await expect(p).rejects.toThrow('aborted');
163
+ await expect(
164
+ rt.waitFor(() => {
165
+ throw new Error('bad selector');
166
+ })
167
+ ).rejects.toThrow('bad selector');
168
+ let calls = 0;
169
+ const throwingLater = rt.waitFor(() => {
170
+ calls++;
171
+ if (calls > 1) throw new Error('bad selector later');
172
+ return false;
173
+ });
174
+ rt.update(R.setFailedCount(1));
175
+ await expect(throwingLater).rejects.toThrow('bad selector later');
176
+ const pending = rt.waitFor(() => false);
177
+ a.timers.pending.clear();
178
+ rt.update(R.markDirty(['q']));
179
+ expect(a.timers.pending.has('mat:q')).toBe(true);
180
+ rt.dispose();
181
+ await expect(pending).rejects.toThrow('disposed');
182
+ expect(a.timers.pending.size).toBe(0);
183
+ await rt.dispatch({ type: 'PollTick' });
184
+ expect(rt.update(R.setFailedCount(1))).toBeUndefined();
185
+ });
186
+ it('a disposed runtime ignores late timer fires', async () => {
187
+ const { a, rt } = make({}, buildState([buildEntry({ def: { hash: 'q' } })]));
188
+ rt.update(R.markDirty(['q']));
189
+ const fire = a.timers.pending.get('mat:q')!.fire;
190
+ rt.dispose();
191
+ fire();
192
+ await tick();
193
+ expect(rt.state.dirty.has('q')).toBe(true);
194
+ });
195
+ });
@@ -0,0 +1,296 @@
1
+ import type { Logger } from '../services/logger/index';
2
+ import type { QueryAuthorityCallback, QueryHash, QueryStatus, QueryStatusCallback, QueryUpdateCallback } from '../types';
3
+ import type { OutEvent, RuntimeEvent } from '../kernel/events';
4
+ import type { Adapters, Interpreter } from '../kernel/interpreter';
5
+ import { createInterpreter } from '../kernel/interpreter';
6
+ import type { Lane, Saga } from '../kernel/saga';
7
+ import { runSaga } from '../kernel/saga';
8
+ import type { ClientState } from '../state/client-state';
9
+ import { emptyState } from '../state/client-state';
10
+ import { deriveStatus, isAuthoritative } from '../state/lifecycle';
11
+ import * as R from '../state/reducers';
12
+ import { fetchingQueryCount, pendingMutationCount } from '../state/selectors';
13
+ import type { SagaEnv } from '../query/env';
14
+ import { route } from './router';
15
+
16
+ interface Waiter {
17
+ until: (s: ClientState) => boolean;
18
+ resolve: () => void;
19
+ reject: (e: unknown) => void;
20
+ }
21
+
22
+ export interface RuntimeOptions {
23
+ env: SagaEnv;
24
+ adapters: Adapters;
25
+ logger: Logger;
26
+ tabId: string;
27
+ initialState?: ClientState;
28
+ }
29
+
30
+ /**
31
+ * The one effectful object of the engine: holds the state, runs sagas on
32
+ * lanes, fires timers, fans events out to subscribers and schedules
33
+ * materialization for dirty queries. No decision logic lives here.
34
+ */
35
+ export class Runtime {
36
+ private stateValue: ClientState;
37
+ private readonly interpret: Interpreter;
38
+ private readonly env: SagaEnv;
39
+ private readonly adapters: Adapters;
40
+ private readonly logger: Logger;
41
+ private readonly serialLanes = new Map<string, Promise<unknown>>();
42
+ private readonly dedupeLanes = new Map<string, Promise<unknown>>();
43
+ private readonly waiters = new Set<Waiter>();
44
+ private readonly recordSubs = new Map<QueryHash, Set<QueryUpdateCallback>>();
45
+ private readonly statusSubs = new Map<QueryHash, Set<QueryStatusCallback>>();
46
+ private readonly authoritySubs = new Map<QueryHash, Set<QueryAuthorityCallback>>();
47
+ private readonly lastStatus = new Map<QueryHash, QueryStatus>();
48
+ private readonly listeners = new Map<string, Set<(event: OutEvent) => void>>();
49
+ private readonly scheduledMaterialize = new Set<QueryHash>();
50
+ private readonly timerKeys = new Set<string>();
51
+ private lastActivity = { fetching: 0, pending: 0 };
52
+ private lastHealth: ClientState['sync']['health'];
53
+ private disposed = false;
54
+
55
+ constructor(opts: RuntimeOptions) {
56
+ this.env = opts.env;
57
+ this.logger = opts.logger;
58
+ this.stateValue = opts.initialState ?? emptyState({ tabId: opts.tabId });
59
+ this.lastHealth = this.stateValue.sync.health;
60
+ const timers = opts.adapters.timers;
61
+ this.adapters = {
62
+ ...opts.adapters,
63
+ timers: {
64
+ set: (key, ms, fire) => {
65
+ this.timerKeys.add(key);
66
+ timers.set(key, ms, () => {
67
+ this.timerKeys.delete(key);
68
+ if (!this.disposed) fire();
69
+ });
70
+ },
71
+ clear: (key) => {
72
+ this.timerKeys.delete(key);
73
+ timers.clear(key);
74
+ },
75
+ },
76
+ };
77
+ this.interpret = createInterpreter(this.adapters, {
78
+ getState: () => this.stateValue,
79
+ setState: (next) => this.setState(next),
80
+ waitFor: (until, signal) => this.waitFor(until, signal),
81
+ emit: (event) => this.emit(event),
82
+ dispatch: (event) => void this.dispatch(event),
83
+ });
84
+ }
85
+
86
+ get state(): ClientState {
87
+ return this.stateValue;
88
+ }
89
+
90
+ /** Run a saga, optionally on a lane. Rejections propagate to the caller. */
91
+ run<R>(saga: Saga<R>, opts: { lane?: Lane; signal?: AbortSignal } = {}): Promise<R> {
92
+ const exec = () => runSaga(saga, (effect) => this.interpret(effect, { signal: opts.signal }));
93
+ const lane = opts.lane;
94
+ if (!lane) return exec();
95
+ if (lane.kind === 'dedupe') {
96
+ const running = this.dedupeLanes.get(lane.key);
97
+ if (running) return running as Promise<R>;
98
+ const p = exec().finally(() => {
99
+ if (this.dedupeLanes.get(lane.key) === p) this.dedupeLanes.delete(lane.key);
100
+ });
101
+ this.dedupeLanes.set(lane.key, p);
102
+ return p;
103
+ }
104
+ const prev = this.serialLanes.get(lane.key) ?? Promise.resolve();
105
+ const p = prev.then(exec, exec);
106
+ const tail = p.catch(() => undefined);
107
+ this.serialLanes.set(lane.key, tail);
108
+ void tail.then(() => {
109
+ if (this.serialLanes.get(lane.key) === tail) this.serialLanes.delete(lane.key);
110
+ });
111
+ return p;
112
+ }
113
+
114
+ /** Route an event to its saga. Never rejects: failures are logged. */
115
+ dispatch(event: RuntimeEvent): Promise<void> {
116
+ if (this.disposed) return Promise.resolve();
117
+ const target = route(this.env, event);
118
+ return this.run(target.saga, { lane: target.lane }).then(
119
+ () => undefined,
120
+ (error) => {
121
+ this.logger.error({ error, event: event.type, Category: 'sp00ky-client::Runtime::dispatch' }, 'saga failed');
122
+ }
123
+ );
124
+ }
125
+
126
+ private setState(next: ClientState): void {
127
+ const prev = this.stateValue;
128
+ if (next === prev) return;
129
+ this.stateValue = next;
130
+ for (const w of [...this.waiters]) {
131
+ let ok = false;
132
+ try {
133
+ ok = w.until(next);
134
+ } catch (error) {
135
+ this.waiters.delete(w);
136
+ w.reject(error);
137
+ continue;
138
+ }
139
+ if (ok) {
140
+ this.waiters.delete(w);
141
+ w.resolve();
142
+ }
143
+ }
144
+ for (const hash of next.dirty) {
145
+ if (this.scheduledMaterialize.has(hash)) continue;
146
+ this.scheduledMaterialize.add(hash);
147
+ this.adapters.timers.set(`mat:${hash}`, this.env.materializeDebounceMs, () => {
148
+ this.scheduledMaterialize.delete(hash);
149
+ void this.dispatch({ type: 'Materialize', hash });
150
+ });
151
+ }
152
+ for (const [hash, subs] of this.statusSubs) {
153
+ const entry = next.queries.get(hash);
154
+ if (!entry) continue;
155
+ const status = deriveStatus(entry.lifecycle);
156
+ if (this.lastStatus.get(hash) === status) continue;
157
+ this.lastStatus.set(hash, status);
158
+ for (const cb of subs) this.safely(() => cb(status));
159
+ this.notify({ type: 'query:status', hash, status });
160
+ }
161
+ const activity = { fetching: fetchingQueryCount(next), pending: pendingMutationCount(next) };
162
+ if (activity.fetching !== this.lastActivity.fetching || activity.pending !== this.lastActivity.pending) {
163
+ this.lastActivity = activity;
164
+ this.notify({ type: 'activity:changed', ...activity });
165
+ }
166
+ // Health is notified from here, not from the saga that folds a sync round:
167
+ // `connection` moves through `setConnection` on a transport event with no
168
+ // round attached, and a subscriber that only heard the round would show a
169
+ // stale socket state forever. `setHealth` keeps the identity stable while
170
+ // the values are unchanged, so this fires exactly on real transitions.
171
+ if (next.sync.health !== this.lastHealth) {
172
+ this.lastHealth = next.sync.health;
173
+ this.notify({ type: 'health:changed', health: next.sync.health });
174
+ }
175
+ }
176
+
177
+ waitFor(until: (s: ClientState) => boolean, signal?: AbortSignal): Promise<void> {
178
+ try {
179
+ if (until(this.stateValue)) return Promise.resolve();
180
+ } catch (error) {
181
+ return Promise.reject(error);
182
+ }
183
+ if (signal?.aborted) return Promise.reject(new Error('aborted'));
184
+ return new Promise<void>((resolve, reject) => {
185
+ const waiter: Waiter = { until, resolve, reject };
186
+ this.waiters.add(waiter);
187
+ signal?.addEventListener(
188
+ 'abort',
189
+ () => {
190
+ if (this.waiters.delete(waiter)) reject(new Error('aborted'));
191
+ },
192
+ { once: true }
193
+ );
194
+ });
195
+ }
196
+
197
+ /** Deliver an outbound event to its subscribers and listeners. */
198
+ emit(event: OutEvent): void {
199
+ switch (event.type) {
200
+ case 'query:records':
201
+ for (const cb of this.recordSubs.get(event.hash) ?? []) this.safely(() => cb(event.records as Record<string, any>[]));
202
+ break;
203
+ case 'query:authority':
204
+ for (const cb of this.authoritySubs.get(event.hash) ?? []) this.safely(() => cb(event.known));
205
+ break;
206
+ case 'log':
207
+ this.logger[event.level](Object.assign({}, event.data as object | undefined, { Category: 'sp00ky-client::saga' }), event.message);
208
+ break;
209
+ default:
210
+ break;
211
+ }
212
+ this.notify(event);
213
+ }
214
+
215
+ private notify(event: OutEvent): void {
216
+ for (const cb of this.listeners.get(event.type) ?? []) this.safely(() => cb(event));
217
+ for (const cb of this.listeners.get('*') ?? []) this.safely(() => cb(event));
218
+ }
219
+
220
+ private safely(fn: () => void): void {
221
+ try {
222
+ fn();
223
+ } catch (error) {
224
+ this.logger.error({ error, Category: 'sp00ky-client::Runtime::subscriber' }, 'subscriber threw');
225
+ }
226
+ }
227
+
228
+ /** Observe outbound events by type (`'*'` for all). */
229
+ on(type: OutEvent['type'] | '*', cb: (event: OutEvent) => void): () => void {
230
+ const set = this.listeners.get(type) ?? new Set();
231
+ set.add(cb);
232
+ this.listeners.set(type, set);
233
+ return () => void set.delete(cb);
234
+ }
235
+
236
+ subscribe(hash: QueryHash, cb: QueryUpdateCallback, options: { immediate?: boolean } = {}): () => void {
237
+ const set = this.recordSubs.get(hash) ?? new Set();
238
+ set.add(cb);
239
+ this.recordSubs.set(hash, set);
240
+ this.setState(R.subscribe(hash)(this.stateValue));
241
+ if (options.immediate) {
242
+ const entry = this.stateValue.queries.get(hash);
243
+ if (entry) this.safely(() => cb(entry.records as Record<string, any>[]));
244
+ }
245
+ return () => {
246
+ if (!set.delete(cb)) return;
247
+ if (set.size === 0) this.recordSubs.delete(hash);
248
+ this.setState(R.unsubscribe(hash, this.adapters.clock.now())(this.stateValue));
249
+ };
250
+ }
251
+
252
+ subscribeStatus(hash: QueryHash, cb: QueryStatusCallback, options: { immediate?: boolean } = {}): () => void {
253
+ const set = this.statusSubs.get(hash) ?? new Set();
254
+ set.add(cb);
255
+ this.statusSubs.set(hash, set);
256
+ const entry = this.stateValue.queries.get(hash);
257
+ if (entry) {
258
+ const status = deriveStatus(entry.lifecycle);
259
+ this.lastStatus.set(hash, status);
260
+ if (options.immediate) this.safely(() => cb(status));
261
+ }
262
+ return () => {
263
+ set.delete(cb);
264
+ if (set.size === 0) {
265
+ this.statusSubs.delete(hash);
266
+ this.lastStatus.delete(hash);
267
+ }
268
+ };
269
+ }
270
+
271
+ subscribeAuthority(hash: QueryHash, cb: QueryAuthorityCallback, options: { immediate?: boolean } = {}): () => void {
272
+ const set = this.authoritySubs.get(hash) ?? new Set();
273
+ set.add(cb);
274
+ this.authoritySubs.set(hash, set);
275
+ if (options.immediate) {
276
+ const entry = this.stateValue.queries.get(hash);
277
+ if (entry) this.safely(() => cb(isAuthoritative(entry.lifecycle)));
278
+ }
279
+ return () => {
280
+ set.delete(cb);
281
+ if (set.size === 0) this.authoritySubs.delete(hash);
282
+ };
283
+ }
284
+
285
+ /** Apply a reducer outside a saga (facade conveniences such as timing reports). */
286
+ update(reducer: (s: ClientState) => ClientState): void {
287
+ this.setState(reducer(this.stateValue));
288
+ }
289
+
290
+ dispose(): void {
291
+ this.disposed = true;
292
+ for (const key of [...this.timerKeys]) this.adapters.timers.clear(key);
293
+ for (const w of this.waiters) w.reject(new Error('runtime disposed'));
294
+ this.waiters.clear();
295
+ }
296
+ }