@spooky-sync/core 0.0.1-canary.38 → 0.0.1-canary.381

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 (266) hide show
  1. package/AGENTS.md +91 -0
  2. package/README.md +59 -16
  3. package/dist/index.d.ts +2238 -144
  4. package/dist/index.js +18149 -2475
  5. package/dist/live.d.ts +149 -0
  6. package/dist/live.js +739 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/pure.d.ts +401 -0
  9. package/dist/pure.js +24 -0
  10. package/dist/sqlite-open.js +392 -0
  11. package/dist/sqlite-worker.d.ts +1 -0
  12. package/dist/sqlite-worker.js +439 -0
  13. package/dist/sw.d.ts +207 -0
  14. package/dist/sw.js +393 -0
  15. package/dist/tabs-broker-worker.d.ts +8 -0
  16. package/dist/tabs-broker-worker.js +477 -0
  17. package/dist/types.d.ts +2082 -39
  18. package/dist/worker-edges.js +498 -0
  19. package/dist/worker-impersonation.js +60 -0
  20. package/dist/worker-ref-tables.d.ts +48 -0
  21. package/dist/worker-types.d.ts +112 -0
  22. package/dist/worker-types.js +73 -0
  23. package/package.json +28 -7
  24. package/scripts/check-broker-bundle.mjs +33 -0
  25. package/scripts/check-worker-entries.mjs +173 -0
  26. package/skills/sp00ky-core/SKILL.md +42 -8
  27. package/skills/sp00ky-core/references/config.md +8 -2
  28. package/src/boot/auth-flip.saga.test.ts +174 -0
  29. package/src/boot/auth-flip.saga.ts +77 -0
  30. package/src/boot/boot.saga.test.ts +203 -0
  31. package/src/boot/boot.saga.ts +146 -0
  32. package/src/boot/bucket-switch.saga.test.ts +191 -0
  33. package/src/boot/bucket-switch.saga.ts +151 -0
  34. package/src/boot/preload.saga.test.ts +96 -0
  35. package/src/boot/preload.saga.ts +45 -0
  36. package/src/bucket-blurhash.test.ts +148 -0
  37. package/src/build-globals.d.ts +12 -0
  38. package/src/client/bucket-handle.test.ts +195 -0
  39. package/src/client/bucket-handle.ts +352 -0
  40. package/src/client/router.test.ts +59 -0
  41. package/src/client/router.ts +104 -0
  42. package/src/client/runtime.test.ts +319 -0
  43. package/src/client/runtime.ts +307 -0
  44. package/src/client/services.test.ts +74 -0
  45. package/src/client/services.ts +437 -0
  46. package/src/client/sp00ky-client.test.ts +428 -0
  47. package/src/client/sp00ky-client.ts +737 -0
  48. package/src/events/index.ts +3 -0
  49. package/src/index.ts +86 -1
  50. package/src/kernel/constants.test.ts +19 -0
  51. package/src/kernel/constants.ts +94 -0
  52. package/src/kernel/effects.test.ts +40 -0
  53. package/src/kernel/effects.ts +171 -0
  54. package/src/kernel/events.ts +60 -0
  55. package/src/kernel/interpreter.test.ts +116 -0
  56. package/src/kernel/interpreter.ts +148 -0
  57. package/src/kernel/saga.test.ts +89 -0
  58. package/src/kernel/saga.ts +73 -0
  59. package/src/live/index.test.ts +512 -0
  60. package/src/live/index.ts +1059 -0
  61. package/src/modules/app-release/index.test.ts +125 -0
  62. package/src/modules/app-release/index.ts +209 -0
  63. package/src/modules/auth/auth.local-first.test.ts +228 -0
  64. package/src/modules/auth/auth.session-recheck.test.ts +140 -0
  65. package/src/modules/auth/events/index.ts +7 -1
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.integration.test.ts +209 -0
  68. package/src/modules/auth/impersonation.test.ts +377 -0
  69. package/src/modules/auth/impersonation.ts +179 -0
  70. package/src/modules/auth/index.ts +538 -14
  71. package/src/modules/channel/channel.test.ts +752 -0
  72. package/src/modules/channel/index.ts +1153 -0
  73. package/src/modules/channel/webrtc.ts +472 -0
  74. package/src/modules/crdt/acceptance.ts +75 -0
  75. package/src/modules/crdt/bytes.ts +50 -0
  76. package/src/modules/crdt/crdt-field.ts +125 -0
  77. package/src/modules/crdt/crdt.test.ts +546 -0
  78. package/src/modules/crdt/hash.ts +103 -0
  79. package/src/modules/crdt/index.ts +821 -0
  80. package/src/modules/crdt/loro-loader.ts +25 -0
  81. package/src/modules/crdt/mirror.ts +37 -0
  82. package/src/modules/devtools/flags.ts +349 -0
  83. package/src/modules/devtools/index.ts +746 -93
  84. package/src/modules/devtools/mutations.test.ts +92 -0
  85. package/src/modules/devtools/mutations.ts +160 -0
  86. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  87. package/src/modules/devtools/state-shape.test.ts +263 -0
  88. package/src/modules/devtools/storage-info.test.ts +79 -0
  89. package/src/modules/devtools/storage-info.ts +168 -0
  90. package/src/modules/devtools/versions.test.ts +74 -0
  91. package/src/modules/devtools/versions.ts +110 -0
  92. package/src/modules/feature-flag/index.test.ts +298 -0
  93. package/src/modules/feature-flag/index.ts +316 -0
  94. package/src/modules/ref-tables.test.ts +93 -0
  95. package/src/modules/ref-tables.ts +91 -0
  96. package/src/modules/web-push/index.test.ts +824 -0
  97. package/src/modules/web-push/index.ts +1027 -0
  98. package/src/mutation/ingest-outage.integration.test.ts +400 -0
  99. package/src/mutation/jobs.test.ts +25 -0
  100. package/src/mutation/jobs.ts +41 -0
  101. package/src/mutation/mutation-id.test.ts +37 -0
  102. package/src/mutation/mutation-id.ts +35 -0
  103. package/src/mutation/pending-writes.integration.test.ts +175 -0
  104. package/src/mutation/push.saga.test.ts +361 -0
  105. package/src/mutation/push.saga.ts +245 -0
  106. package/src/mutation/rows.test.ts +278 -0
  107. package/src/mutation/rows.ts +515 -0
  108. package/src/mutation/tray.saga.test.ts +63 -0
  109. package/src/mutation/tray.saga.ts +48 -0
  110. package/src/mutation/write.saga.test.ts +436 -0
  111. package/src/mutation/write.saga.ts +384 -0
  112. package/src/pure.ts +66 -0
  113. package/src/push/types.ts +174 -0
  114. package/src/query/anon-query-isolation.integration.test.ts +531 -0
  115. package/src/query/env.test.ts +23 -0
  116. package/src/query/env.ts +65 -0
  117. package/src/query/fetch.saga.test.ts +199 -0
  118. package/src/query/fetch.saga.ts +120 -0
  119. package/src/query/hash.test.ts +14 -0
  120. package/src/query/hash.ts +18 -0
  121. package/src/query/id-set-plan.test.ts +132 -0
  122. package/src/query/lifecycle.saga.test.ts +274 -0
  123. package/src/query/lifecycle.saga.ts +186 -0
  124. package/src/query/list-ref-live.integration.test.ts +203 -0
  125. package/src/query/materialize.saga.test.ts +200 -0
  126. package/src/query/materialize.saga.ts +102 -0
  127. package/src/query/materialize.test.ts +58 -0
  128. package/src/query/materialize.ts +57 -0
  129. package/src/query/membership.saga.test.ts +514 -0
  130. package/src/query/membership.saga.ts +339 -0
  131. package/src/query/membership.test.ts +151 -0
  132. package/src/query/membership.ts +234 -0
  133. package/src/query/register.saga.test.ts +369 -0
  134. package/src/query/register.saga.ts +293 -0
  135. package/src/query/render-set.test.ts +47 -0
  136. package/src/query/render-set.ts +71 -0
  137. package/src/query/session-guard.integration.test.ts +304 -0
  138. package/src/query/sql.test.ts +118 -0
  139. package/src/query/sql.ts +174 -0
  140. package/src/query/window-query.test.ts +70 -0
  141. package/src/query/window-query.ts +208 -0
  142. package/src/services/blobs/blob-cache.test.ts +359 -0
  143. package/src/services/blobs/blob-cache.ts +603 -0
  144. package/src/services/blobs/blob-manifest.ts +227 -0
  145. package/src/services/blobs/blob-store.test.ts +77 -0
  146. package/src/services/blobs/blob-store.ts +359 -0
  147. package/src/services/blobs/blob.fixture.ts +90 -0
  148. package/src/services/blobs/index.ts +70 -0
  149. package/src/services/database/cache-engine.ts +209 -0
  150. package/src/services/database/connection-supervisor.test.ts +318 -0
  151. package/src/services/database/connection-supervisor.ts +422 -0
  152. package/src/services/database/database.query-timeout.test.ts +140 -0
  153. package/src/services/database/database.responses.test.ts +43 -0
  154. package/src/services/database/database.ts +151 -56
  155. package/src/services/database/engine-factory.ts +33 -0
  156. package/src/services/database/errors.ts +34 -0
  157. package/src/services/database/index.ts +7 -0
  158. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  159. package/src/services/database/local-migrator.integration.test.ts +110 -0
  160. package/src/services/database/local-migrator.ts +54 -9
  161. package/src/services/database/local.test.ts +150 -0
  162. package/src/services/database/local.ts +503 -59
  163. package/src/services/database/plan-render.test.ts +159 -0
  164. package/src/services/database/plan-render.ts +108 -0
  165. package/src/services/database/relation-resolver.test.ts +484 -0
  166. package/src/services/database/relation-resolver.ts +0 -0
  167. package/src/services/database/remote.ts +283 -4
  168. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  169. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  170. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  171. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  172. package/src/services/database/sqlite-column-reset.ts +91 -0
  173. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  174. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  175. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  176. package/src/services/database/sqlite-lock-verify.ts +45 -0
  177. package/src/services/database/sqlite-open.test.ts +150 -0
  178. package/src/services/database/sqlite-open.ts +164 -0
  179. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  180. package/src/services/database/sqlite-plan-sql.ts +196 -0
  181. package/src/services/database/sqlite-projection.test.ts +121 -0
  182. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  183. package/src/services/database/sqlite-select.test.ts +261 -0
  184. package/src/services/database/sqlite-select.ts +131 -0
  185. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  186. package/src/services/database/sqlite-transport.ts +224 -0
  187. package/src/services/database/sqlite-worker.ts +437 -0
  188. package/src/services/database/surql-translate.ts +417 -0
  189. package/src/services/database/surreal-cache-engine.ts +163 -0
  190. package/src/services/logger/index.test.ts +132 -0
  191. package/src/services/logger/index.ts +187 -6
  192. package/src/services/persistence/resilient.ts +8 -1
  193. package/src/services/persistence/surrealdb.ts +2 -2
  194. package/src/services/stream-processor/index.ts +801 -63
  195. package/src/services/stream-processor/permissions.test.ts +47 -0
  196. package/src/services/stream-processor/permissions.ts +53 -0
  197. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  198. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  199. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  200. package/src/services/stream-processor/wasm-types.ts +57 -1
  201. package/src/services/tabs/broker-client.ts +284 -0
  202. package/src/services/tabs/broker.test.ts +381 -0
  203. package/src/services/tabs/coordinator.test.ts +386 -0
  204. package/src/services/tabs/coordinator.ts +646 -0
  205. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  206. package/src/services/tabs/leader-locks.ts +75 -0
  207. package/src/services/tabs/protocol.ts +262 -0
  208. package/src/services/tabs/support.ts +36 -0
  209. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  210. package/src/sp00ky.ts +5 -394
  211. package/src/state/client-state.ts +231 -0
  212. package/src/state/lifecycle.test.ts +184 -0
  213. package/src/state/lifecycle.ts +127 -0
  214. package/src/state/reducers.test.ts +337 -0
  215. package/src/state/reducers.ts +571 -0
  216. package/src/state/selectors.test.ts +203 -0
  217. package/src/state/selectors.ts +264 -0
  218. package/src/sw/index.test.ts +582 -0
  219. package/src/sw/index.ts +607 -0
  220. package/src/sw/store.ts +96 -0
  221. package/src/sync/connection.saga.test.ts +97 -0
  222. package/src/sync/connection.saga.ts +89 -0
  223. package/src/sync/live.saga.test.ts +229 -0
  224. package/src/sync/live.saga.ts +116 -0
  225. package/src/sync/policy.health.test.ts +56 -0
  226. package/src/sync/policy.test.ts +663 -0
  227. package/src/sync/policy.ts +515 -0
  228. package/src/sync/poll.saga.test.ts +51 -0
  229. package/src/sync/poll.saga.ts +36 -0
  230. package/src/sync/tabs.saga.test.ts +78 -0
  231. package/src/sync/tabs.saga.ts +96 -0
  232. package/src/testing/adapters.ts +137 -0
  233. package/src/testing/build.ts +63 -0
  234. package/src/testing/fake-services.ts +63 -0
  235. package/src/testing/run-pure.test.ts +92 -0
  236. package/src/testing/run-pure.ts +128 -0
  237. package/src/testing/services.ts +17 -0
  238. package/src/types.ts +694 -7
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +57 -0
  241. package/src/utils/error-classification.test.ts +116 -0
  242. package/src/utils/error-classification.ts +74 -0
  243. package/src/utils/index.ts +83 -6
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +63 -2
  246. package/src/utils/semver.test.ts +32 -0
  247. package/src/utils/semver.ts +30 -0
  248. package/src/utils/sha256.ts +15 -0
  249. package/src/utils/surql.ts +22 -11
  250. package/src/utils/utils.test.ts +3 -2
  251. package/tsdown.config.ts +117 -3
  252. package/vitest.config.ts +48 -0
  253. package/src/modules/cache/index.ts +0 -241
  254. package/src/modules/cache/types.ts +0 -19
  255. package/src/modules/data/data.test.ts +0 -58
  256. package/src/modules/data/index.ts +0 -882
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -78
  259. package/src/modules/sync/index.ts +0 -3
  260. package/src/modules/sync/queue/index.ts +0 -2
  261. package/src/modules/sync/queue/queue-down.ts +0 -90
  262. package/src/modules/sync/queue/queue-up.ts +0 -224
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -422
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -169
@@ -0,0 +1,104 @@
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, releaseSuperseded } 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, peerWriteExpired } 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 'ReleaseSuperseded':
41
+ return { saga: releaseSuperseded(env, event.ids), lane: serial('release') };
42
+ case 'RecoverLostView':
43
+ return { saga: recoverLostView(event.hash), lane: dedupe(`view-lost:${event.hash}`) };
44
+ case 'ReadDirtyMembership':
45
+ return { saga: readDirtyMembership(env), lane: serial('membership') };
46
+ case 'ReadMembership':
47
+ return { saga: readMembership(env, event.hashes, { force: true }), lane: serial('membership') };
48
+ case 'FetchRows':
49
+ return { saga: fetchRows(env), lane: serial('fetch') };
50
+ case 'Materialize':
51
+ return { saga: materialize(event.hash), lane: serial(`mat:${event.hash}`) };
52
+ case 'MaterializeDirty':
53
+ return { saga: materializeDirty() };
54
+ case 'StreamUpdate':
55
+ return { saga: streamUpdate(event.update), lane: serial(`stream:${event.update.queryHash}`) };
56
+ case 'LifecycleTick':
57
+ case 'HeartbeatNow':
58
+ return { saga: lifecycleTick(env), lane: dedupe('lifecycle') };
59
+ case 'AckPrune':
60
+ return { saga: ackPrune(), lane: dedupe('ack-prune') };
61
+ case 'GcTick':
62
+ return { saga: gcTick(), lane: dedupe('gc') };
63
+ case 'Drain':
64
+ return { saga: drain(env), lane: serial('outbox') };
65
+ case 'FlushWrite':
66
+ return { saga: flushWrite(env, event.key), lane: serial('outbox-write') };
67
+ case 'PeerWriteExpired':
68
+ return { saga: peerWriteExpired(event.id), lane: serial(`peer:${event.id}`) };
69
+ case 'FlushPendingWrites':
70
+ return { saga: flushPendingWrites() };
71
+ case 'AdoptPendingWrites':
72
+ return { saga: adoptPendingWrites(), lane: serial('outbox-write') };
73
+ case 'PollTick':
74
+ return { saga: pollTick(env), lane: dedupe('poll') };
75
+ case 'SelfHealTick':
76
+ return { saga: selfHealTick(env), lane: dedupe('heal') };
77
+ case 'SyncOutcome':
78
+ return { saga: syncOutcome(env, event.ok, event.error), lane: serial('health') };
79
+ case 'ConnectionChanged':
80
+ return { saga: connectionChanged(env, event.state), lane: serial('connection') };
81
+ case 'LiveStart':
82
+ return { saga: liveStart(env), lane: serial('live') };
83
+ case 'LiveChange':
84
+ return { saga: liveChange(env, event.hashes, event.rows, event.deltas), lane: serial('live-change') };
85
+ case 'TabRole':
86
+ return { saga: setRole(env, event.role), lane: serial('tabs') };
87
+ case 'TabMessage':
88
+ return { saga: tabMessage(env, event.message), lane: serial('tabs') };
89
+ case 'StartRemote':
90
+ return { saga: startRemote(), lane: dedupe('start-remote') };
91
+ case 'PrimeCircuit':
92
+ return { saga: primeCircuit(), lane: dedupe('prime') };
93
+ case 'VersionsPrimed':
94
+ return { saga: versionsPrimed(event.entries) };
95
+ case 'WarmBlobs':
96
+ return { saga: warmBlobs(), lane: dedupe('blobs') };
97
+ case 'AuthFlip':
98
+ return { saga: authFlip(env, event.userId), lane: serial('bucket') };
99
+ case 'BucketSwitch':
100
+ return { saga: bucketSwitch(env, event.target), lane: serial('bucket') };
101
+ case 'PageHide':
102
+ return { saga: pageHide() };
103
+ }
104
+ }
@@ -0,0 +1,319 @@
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
+ import { rebindQueries } from '../boot/bucket-switch.saga';
10
+ import { registerLocal } from '../query/register.saga';
11
+
12
+ const env = defaultEnv({ tables: [] } as any, { materializeDebounceMs: 5 });
13
+ const noop = () => {};
14
+ const logs: unknown[] = [];
15
+ const logger: any = { debug: noop, info: noop, warn: noop, trace: noop, error: (o: unknown) => logs.push(o), child: () => logger };
16
+ const make = (over: Parameters<typeof fakeAdapters>[0] = {}, initialState = buildState()) => {
17
+ const a = fakeAdapters(over);
18
+ const rt = new Runtime({ env, adapters: a.adapters, logger, tabId: 'tab-a', initialState });
19
+ return { a, rt };
20
+ };
21
+ const tick = () => new Promise((r) => setTimeout(r, 0));
22
+
23
+ describe('Runtime lanes', () => {
24
+ it('serial lanes run one at a time in order; dedupe lanes join the running saga', async () => {
25
+ const { rt } = make();
26
+ const order: string[] = [];
27
+ function* step(name: string, wait: Promise<void>): Saga<string> {
28
+ order.push(`${name}:start`);
29
+ yield fx.state.wait(() => true);
30
+ await0(wait);
31
+ order.push(`${name}:end`);
32
+ return name;
33
+ }
34
+ let release!: () => void;
35
+ const gate = new Promise<void>((r) => (release = r));
36
+ const await0 = (_p: Promise<void>) => undefined;
37
+ const a = rt.run(step('a', gate), { lane: { kind: 'serial', key: 'x' } });
38
+ const b = rt.run(step('b', gate), { lane: { kind: 'serial', key: 'x' } });
39
+ release();
40
+ expect(await a).toBe('a');
41
+ expect(await b).toBe('b');
42
+ expect(order).toEqual(['a:start', 'a:end', 'b:start', 'b:end']);
43
+ let n = 0;
44
+ function* counted(): Saga<number> {
45
+ n++;
46
+ yield fx.state.wait(() => true);
47
+ return n;
48
+ }
49
+ const d1 = rt.run(counted(), { lane: { kind: 'dedupe', key: 'd' } });
50
+ const d2 = rt.run(counted(), { lane: { kind: 'dedupe', key: 'd' } });
51
+ expect(await d1).toBe(1);
52
+ expect(await d2).toBe(1);
53
+ expect(n).toBe(1);
54
+ const d3 = await rt.run(counted(), { lane: { kind: 'dedupe', key: 'd' } });
55
+ expect(d3).toBe(2);
56
+ });
57
+ it('a failing saga on a serial lane does not block the next one; dispatch logs and never rejects', async () => {
58
+ const { rt } = make();
59
+ function* boom(): Saga<void> {
60
+ yield fx.state.wait(() => true);
61
+ throw new Error('nope');
62
+ }
63
+ function* fine(): Saga<string> {
64
+ yield fx.state.wait(() => true);
65
+ return 'ok';
66
+ }
67
+ await expect(rt.run(boom(), { lane: { kind: 'serial', key: 'k' } })).rejects.toThrow('nope');
68
+ expect(await rt.run(fine(), { lane: { kind: 'serial', key: 'k' } })).toBe('ok');
69
+ logs.length = 0;
70
+ await rt.dispatch({ type: 'Materialize', hash: 'missing' });
71
+ await rt.dispatch({ type: 'ReadMembership', hashes: [] });
72
+ expect(logs).toEqual([]);
73
+ });
74
+ });
75
+
76
+ describe('Runtime state hooks', () => {
77
+ it('dirty queries are materialized after the debounce, once per burst', async () => {
78
+ const { a, rt } = make(
79
+ { local: { select: async () => [{ id: 'thing:1' }] } },
80
+ buildState([buildEntry({ def: { hash: 'q', plan: { table: 'thing' } as any } })], R.reloadViews(new Map([['v', ['thing:1']]])))
81
+ );
82
+ const seen: unknown[] = [];
83
+ rt.subscribe('q', (rows) => seen.push(rows));
84
+ rt.update(R.markDirty(['q']));
85
+ rt.update(R.markDirty(['q']));
86
+ expect(a.timers.pending.has('mat:q')).toBe(true);
87
+ a.timers.fire('mat:q');
88
+ await tick();
89
+ await tick();
90
+ expect(seen).toEqual([[{ id: 'thing:1' }]]);
91
+ expect(rt.state.dirty.has('q')).toBe(false);
92
+ });
93
+ it('materializes the first change at once, and holds the next one to the window', async () => {
94
+ const { a, rt } = make(
95
+ { local: { select: async () => [{ id: 'thing:1' }] } },
96
+ buildState([buildEntry({ def: { hash: 'q', plan: { table: 'thing' } as any } })], R.reloadViews(new Map([['v', ['thing:1']]])))
97
+ );
98
+ rt.update(R.markDirty(['q']));
99
+ expect(a.timers.pending.get('mat:q')?.ms).toBe(0);
100
+ a.timers.fire('mat:q');
101
+ await tick();
102
+ await tick();
103
+ a.advance(2);
104
+ rt.update(R.markDirty(['q']));
105
+ expect(a.timers.pending.get('mat:q')?.ms).toBe(3);
106
+ a.timers.fire('mat:q');
107
+ await tick();
108
+ await tick();
109
+ a.advance(50);
110
+ rt.update(R.markDirty(['q']));
111
+ expect(a.timers.pending.get('mat:q')?.ms).toBe(0);
112
+ });
113
+ it('the bucket-switch window: a re-seeded query never reads "known and idle" before its rows are painted', async () => {
114
+ // A sign-in: the query was registered in the anon bucket (cold, nothing
115
+ // painted), then the switch re-seeds it from the user's durable view.
116
+ // Before the fix the re-seed announced authority with `records` still
117
+ // empty, so a binding's `isSettled() && !row` held for a beat and the app
118
+ // created a row the user already had.
119
+ const { a, rt } = make(
120
+ {
121
+ local: {
122
+ getById: async () => ({ ids: [['thing:1', 1]], confirmed: true }),
123
+ select: async (plan: any) => (plan.ids ?? []).map((id: any) => ({ id: `${id.table}:${id.id}` })),
124
+ },
125
+ },
126
+ { ...buildState([buildEntry({ def: { hash: 'q', plan: { table: 'thing' } as any } })]), sessionId: 's' }
127
+ );
128
+ // What a binding derives `isSettled()` and `data()` from.
129
+ let known = false;
130
+ let idle = false;
131
+ let rows: unknown[] = [];
132
+ const verdicts: string[] = [];
133
+ const look = () => {
134
+ if (known && idle) verdicts.push(rows.length > 0 ? 'settled with rows' : 'settled empty');
135
+ };
136
+ rt.subscribe('q', (r) => ((rows = r), look()), { immediate: true });
137
+ rt.subscribeAuthority('q', (k) => ((known = k), look()), { immediate: true });
138
+ rt.subscribeStatus('q', (st) => ((idle = st === 'idle'), look()), { immediate: true });
139
+
140
+ await rt.run(rebindQueries());
141
+ expect(rt.state.queries.get('q')!.lifecycle).toMatchObject({ phase: 'cached', fetchDepth: 0, answered: false });
142
+ expect(idle).toBe(true);
143
+ expect(known).toBe(false);
144
+
145
+ a.timers.fire('mat:q');
146
+ await tick();
147
+ await tick();
148
+ expect(rows).toEqual([{ id: 'thing:1' }]);
149
+ expect(known).toBe(true);
150
+ expect(verdicts).toEqual(['settled with rows']);
151
+ });
152
+ it('status and authority subscriptions fire on transitions, immediate delivers the current value', async () => {
153
+ const { rt } = make({}, buildState([buildEntry({ def: { hash: 'q' } })]));
154
+ const statuses: string[] = [];
155
+ const auths: boolean[] = [];
156
+ const offS = rt.subscribeStatus('q', (s) => statuses.push(s), { immediate: true });
157
+ const offA = rt.subscribeAuthority('q', (k) => auths.push(k), { immediate: true });
158
+ const offA2 = rt.subscribeAuthority('q', () => auths.push(false));
159
+ rt.update(R.applyLifecycle('q', { type: 'fetch-begin' }));
160
+ rt.update(R.applyLifecycle('q', { type: 'fetch-begin' }));
161
+ rt.update(R.applyLifecycle('q', { type: 'fetch-end' }));
162
+ rt.update(R.applyLifecycle('q', { type: 'fetch-end' }));
163
+ rt.emit({ type: 'query:authority', hash: 'q', known: true });
164
+ expect(statuses).toEqual(['idle', 'fetching', 'idle']);
165
+ expect(auths).toEqual([false, true, false]);
166
+ offA2();
167
+ offA();
168
+ rt.emit({ type: 'query:authority', hash: 'q', known: false });
169
+ expect(auths).toHaveLength(3);
170
+ offS();
171
+ rt.update(R.applyLifecycle('q', { type: 'fetch-begin' }));
172
+ expect(statuses).toHaveLength(3);
173
+ const missing: string[] = [];
174
+ rt.subscribeStatus('zz', (s) => missing.push(s), { immediate: true });
175
+ rt.subscribeAuthority('zz', () => missing.push('x'), { immediate: true });
176
+ expect(missing).toEqual([]);
177
+ });
178
+ it('notifies health on a transport change, and only on a real move', () => {
179
+ const { rt } = make();
180
+ const seen: string[] = [];
181
+ rt.on('health:changed', (e) => seen.push(e.type === 'health:changed' ? e.health.connection : '?'));
182
+ rt.update(R.setConnection('connected'));
183
+ rt.update(R.setConnection('connected'));
184
+ rt.update(R.setConnection('reconnecting'));
185
+ expect(seen).toEqual(['connected', 'reconnecting']);
186
+ // A health snapshot rebuilt with the same values must not re-notify.
187
+ rt.update(R.setHealth({ ...rt.state.sync.health }));
188
+ expect(seen).toHaveLength(2);
189
+ rt.update(R.setHealth({ ...rt.state.sync.health, status: 'degraded' }));
190
+ expect(seen).toHaveLength(3);
191
+ });
192
+ it('activity, listeners, records subscription bookkeeping, waitFor and dispose', async () => {
193
+ const { a, rt } = make({}, buildState([buildEntry({ def: { hash: 'q' } })]));
194
+ const events: string[] = [];
195
+ const off = rt.on('activity:changed', (e) => events.push(JSON.stringify(e)));
196
+ rt.on('*', (e) => events.push(`*${e.type}`));
197
+ rt.update(R.applyLifecycle('q', { type: 'fetch-begin' }));
198
+ expect(events).toEqual(['{"type":"activity:changed","fetching":1,"pending":0}', '*activity:changed']);
199
+ off();
200
+ const rows: unknown[] = [];
201
+ const unsub = rt.subscribe('q', (r) => rows.push(r), { immediate: true });
202
+ expect(rows).toEqual([[]]);
203
+ expect(rt.state.queries.get('q')!.subscribers).toBe(1);
204
+ rt.emit({ type: 'query:records', hash: 'q', records: [{ id: 1 }] });
205
+ expect(rows).toHaveLength(2);
206
+ unsub();
207
+ unsub();
208
+ expect(rt.state.queries.get('q')!.subscribers).toBe(0);
209
+ expect(rt.state.queries.get('q')!.lastSubscriberLeftAt).toBe(1_700_000_000_000);
210
+ rt.emit({ type: 'log', level: 'warn', message: 'x' });
211
+ rt.subscribe('q', () => {
212
+ throw new Error('subscriber bug');
213
+ });
214
+ rt.emit({ type: 'query:records', hash: 'q', records: [] });
215
+ expect(logs.at(-1)).toMatchObject({ error: new Error('subscriber bug') });
216
+ await expect(rt.waitFor(() => true)).resolves.toBeUndefined();
217
+ const ctrl = new AbortController();
218
+ ctrl.abort();
219
+ await expect(rt.waitFor(() => false, ctrl.signal)).rejects.toThrow('aborted');
220
+ const ctrl2 = new AbortController();
221
+ const p = rt.waitFor(() => false, ctrl2.signal);
222
+ ctrl2.abort();
223
+ await expect(p).rejects.toThrow('aborted');
224
+ await expect(
225
+ rt.waitFor(() => {
226
+ throw new Error('bad selector');
227
+ })
228
+ ).rejects.toThrow('bad selector');
229
+ let calls = 0;
230
+ const throwingLater = rt.waitFor(() => {
231
+ calls++;
232
+ if (calls > 1) throw new Error('bad selector later');
233
+ return false;
234
+ });
235
+ rt.update(R.setFailedCount(1));
236
+ await expect(throwingLater).rejects.toThrow('bad selector later');
237
+ const pending = rt.waitFor(() => false);
238
+ a.timers.pending.clear();
239
+ rt.update(R.markDirty(['q']));
240
+ expect(a.timers.pending.has('mat:q')).toBe(true);
241
+ rt.dispose();
242
+ await expect(pending).rejects.toThrow('disposed');
243
+ expect(a.timers.pending.size).toBe(0);
244
+ await rt.dispatch({ type: 'PollTick' });
245
+ expect(rt.update(R.setFailedCount(1))).toBeUndefined();
246
+ });
247
+ it('a disposed runtime ignores late timer fires', async () => {
248
+ const { a, rt } = make({}, buildState([buildEntry({ def: { hash: 'q' } })]));
249
+ rt.update(R.markDirty(['q']));
250
+ const fire = a.timers.pending.get('mat:q')!.fire;
251
+ rt.dispose();
252
+ fire();
253
+ await tick();
254
+ expect(rt.state.dirty.has('q')).toBe(true);
255
+ });
256
+ });
257
+
258
+ describe('Runtime sign-up', () => {
259
+ it('the page ends up owning one remote row per query when its registration races the auth flip', async () => {
260
+ // threads-e2e, canary.350: the list mounts as the sign-up resolves and
261
+ // registers under the anonymous salt on a socket already signed in; the
262
+ // flip re-keys it while that request is out. The fake server keeps the
263
+ // `_00_query` rows the way `fn::query::register` / `unsubscribe` do.
264
+ const rows = new Set<string>();
265
+ let land!: () => void;
266
+ const held = new Promise<void>((r) => (land = r));
267
+ let first = true;
268
+ const ok = (result: unknown) => ({ status: 'OK' as const, result });
269
+ let bucket = 'anon';
270
+ const { a, rt } = make(
271
+ {
272
+ remote: {
273
+ queryResponses: async (sql: string, vars?: Record<string, any>) => {
274
+ if (sql.startsWith('fn::query::register($config)')) {
275
+ rows.add(String(vars!.config.id));
276
+ if (first) {
277
+ first = false;
278
+ await held;
279
+ }
280
+ return [ok(null), ok([]), ok({ rowCount: 0, state: 'ready' }), ok([])];
281
+ }
282
+ if (sql.includes('fn::query::unsubscribe')) {
283
+ for (const [k, v] of Object.entries(vars ?? {})) if (k.startsWith('id')) rows.delete(String(v));
284
+ return [];
285
+ }
286
+ return [];
287
+ },
288
+ },
289
+ services: {
290
+ 'auth.sessionAuthId': () => 'user:u1',
291
+ 'auth.access': () => 'account',
292
+ 'local.currentBucketId': () => bucket,
293
+ 'local.beginSwitch': () => () => undefined,
294
+ 'local.switchStore': async (target: string) => void (bucket = target),
295
+ 'local.usesSurqlSchema': () => false,
296
+ 'auth.currentUser': () => null,
297
+ 'remote.sessionIds': () => ['s1'],
298
+ },
299
+ },
300
+ { ...buildState(), sessionId: 'anon-salt', saltUserId: null, bucketId: 'anon', primed: true }
301
+ );
302
+ const hash = await rt.run(registerLocal(env, { tableName: 'thread', surql: 'SELECT * FROM thread', params: {}, ttl: '10m' }));
303
+ await tick();
304
+ expect(rows.size).toBe(1);
305
+ const anonId = [...rows][0];
306
+
307
+ await rt.dispatch({ type: 'AuthFlip', userId: 'user:u1' });
308
+ land();
309
+ for (let i = 0; i < 10; i++) {
310
+ await tick();
311
+ a.timers.fire(`register:${hash}`);
312
+ }
313
+
314
+ const entry = rt.state.queries.get(hash)!;
315
+ expect(String(entry.def.id)).not.toBe(anonId);
316
+ expect([...rows]).toEqual([String(entry.def.id)]);
317
+ expect(entry.lifecycle.remote).toBe('registered');
318
+ });
319
+ });