@spooky-sync/core 0.0.1-canary.35 → 0.0.1-canary.351

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