@spooky-sync/core 0.0.1-canary.31 → 0.0.1-canary.310

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 (257) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2026 -137
  4. package/dist/index.js +14956 -2434
  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 +1765 -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 +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -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 +370 -0
  46. package/src/client/sp00ky-client.test.ts +303 -0
  47. package/src/client/sp00ky-client.ts +586 -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 +57 -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 +746 -95
  77. package/src/modules/devtools/mutations.test.ts +92 -0
  78. package/src/modules/devtools/mutations.ts +160 -0
  79. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  80. package/src/modules/devtools/state-shape.test.ts +263 -0
  81. package/src/modules/devtools/storage-info.test.ts +79 -0
  82. package/src/modules/devtools/storage-info.ts +168 -0
  83. package/src/modules/devtools/versions.test.ts +74 -0
  84. package/src/modules/devtools/versions.ts +110 -0
  85. package/src/modules/feature-flag/index.test.ts +251 -0
  86. package/src/modules/feature-flag/index.ts +316 -0
  87. package/src/modules/ref-tables.test.ts +91 -0
  88. package/src/modules/ref-tables.ts +88 -0
  89. package/src/modules/web-push/index.test.ts +824 -0
  90. package/src/modules/web-push/index.ts +1027 -0
  91. package/src/mutation/jobs.test.ts +25 -0
  92. package/src/mutation/jobs.ts +41 -0
  93. package/src/mutation/mutation-id.test.ts +37 -0
  94. package/src/mutation/mutation-id.ts +35 -0
  95. package/src/mutation/pending-writes.integration.test.ts +175 -0
  96. package/src/mutation/push.saga.test.ts +285 -0
  97. package/src/mutation/push.saga.ts +217 -0
  98. package/src/mutation/rows.test.ts +231 -0
  99. package/src/mutation/rows.ts +427 -0
  100. package/src/mutation/tray.saga.test.ts +63 -0
  101. package/src/mutation/tray.saga.ts +48 -0
  102. package/src/mutation/write.saga.test.ts +316 -0
  103. package/src/mutation/write.saga.ts +268 -0
  104. package/src/otel/index.ts +10 -7
  105. package/src/pure.ts +65 -0
  106. package/src/push/types.ts +174 -0
  107. package/src/query/env.test.ts +23 -0
  108. package/src/query/env.ts +56 -0
  109. package/src/query/fetch.saga.test.ts +199 -0
  110. package/src/query/fetch.saga.ts +120 -0
  111. package/src/query/hash.test.ts +14 -0
  112. package/src/query/hash.ts +18 -0
  113. package/src/query/id-set-plan.test.ts +132 -0
  114. package/src/query/lifecycle.saga.test.ts +274 -0
  115. package/src/query/lifecycle.saga.ts +159 -0
  116. package/src/query/materialize.saga.test.ts +115 -0
  117. package/src/query/materialize.saga.ts +84 -0
  118. package/src/query/materialize.test.ts +58 -0
  119. package/src/query/materialize.ts +57 -0
  120. package/src/query/membership.saga.test.ts +350 -0
  121. package/src/query/membership.saga.ts +278 -0
  122. package/src/query/membership.test.ts +118 -0
  123. package/src/query/membership.ts +201 -0
  124. package/src/query/register.saga.test.ts +293 -0
  125. package/src/query/register.saga.ts +226 -0
  126. package/src/query/render-set.test.ts +47 -0
  127. package/src/query/render-set.ts +71 -0
  128. package/src/query/sql.test.ts +63 -0
  129. package/src/query/sql.ts +93 -0
  130. package/src/query/window-query.test.ts +70 -0
  131. package/src/query/window-query.ts +195 -0
  132. package/src/services/blobs/blob-cache.test.ts +359 -0
  133. package/src/services/blobs/blob-cache.ts +603 -0
  134. package/src/services/blobs/blob-manifest.ts +227 -0
  135. package/src/services/blobs/blob-store.test.ts +77 -0
  136. package/src/services/blobs/blob-store.ts +359 -0
  137. package/src/services/blobs/blob.fixture.ts +90 -0
  138. package/src/services/blobs/index.ts +70 -0
  139. package/src/services/database/cache-engine.ts +199 -0
  140. package/src/services/database/connection-supervisor.test.ts +318 -0
  141. package/src/services/database/connection-supervisor.ts +422 -0
  142. package/src/services/database/database.query-timeout.test.ts +140 -0
  143. package/src/services/database/database.responses.test.ts +43 -0
  144. package/src/services/database/database.ts +146 -61
  145. package/src/services/database/engine-factory.ts +33 -0
  146. package/src/services/database/errors.ts +34 -0
  147. package/src/services/database/events/index.ts +2 -1
  148. package/src/services/database/index.ts +7 -0
  149. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  150. package/src/services/database/local-migrator.integration.test.ts +110 -0
  151. package/src/services/database/local-migrator.ts +59 -16
  152. package/src/services/database/local.test.ts +64 -0
  153. package/src/services/database/local.ts +487 -63
  154. package/src/services/database/plan-render.test.ts +159 -0
  155. package/src/services/database/plan-render.ts +108 -0
  156. package/src/services/database/relation-resolver.test.ts +467 -0
  157. package/src/services/database/relation-resolver.ts +0 -0
  158. package/src/services/database/remote.ts +213 -6
  159. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  160. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  161. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  162. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  163. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  164. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  165. package/src/services/database/sqlite-lock-verify.ts +45 -0
  166. package/src/services/database/sqlite-open.test.ts +150 -0
  167. package/src/services/database/sqlite-open.ts +164 -0
  168. package/src/services/database/sqlite-plan-sql.test.ts +139 -0
  169. package/src/services/database/sqlite-plan-sql.ts +179 -0
  170. package/src/services/database/sqlite-projection.test.ts +121 -0
  171. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  172. package/src/services/database/sqlite-select.test.ts +246 -0
  173. package/src/services/database/sqlite-select.ts +131 -0
  174. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  175. package/src/services/database/sqlite-transport.ts +224 -0
  176. package/src/services/database/sqlite-worker.ts +437 -0
  177. package/src/services/database/surql-translate.ts +416 -0
  178. package/src/services/database/surreal-cache-engine.ts +161 -0
  179. package/src/services/logger/index.test.ts +132 -0
  180. package/src/services/logger/index.ts +189 -7
  181. package/src/services/persistence/localstorage.ts +2 -2
  182. package/src/services/persistence/resilient.ts +10 -3
  183. package/src/services/persistence/surrealdb.ts +4 -4
  184. package/src/services/stream-processor/index.ts +779 -67
  185. package/src/services/stream-processor/permissions.test.ts +47 -0
  186. package/src/services/stream-processor/permissions.ts +53 -0
  187. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  188. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  189. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  190. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  191. package/src/services/stream-processor/wasm-types.ts +58 -2
  192. package/src/services/tabs/broker-client.ts +283 -0
  193. package/src/services/tabs/broker.test.ts +327 -0
  194. package/src/services/tabs/coordinator.test.ts +386 -0
  195. package/src/services/tabs/coordinator.ts +638 -0
  196. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  197. package/src/services/tabs/leader-locks.ts +75 -0
  198. package/src/services/tabs/protocol.ts +262 -0
  199. package/src/services/tabs/support.ts +36 -0
  200. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  201. package/src/sp00ky.ts +5 -395
  202. package/src/state/client-state.ts +200 -0
  203. package/src/state/lifecycle.test.ts +118 -0
  204. package/src/state/lifecycle.ts +86 -0
  205. package/src/state/reducers.test.ts +304 -0
  206. package/src/state/reducers.ts +526 -0
  207. package/src/state/selectors.test.ts +167 -0
  208. package/src/state/selectors.ts +216 -0
  209. package/src/sw/index.test.ts +582 -0
  210. package/src/sw/index.ts +607 -0
  211. package/src/sw/store.ts +96 -0
  212. package/src/sync/connection.saga.test.ts +86 -0
  213. package/src/sync/connection.saga.ts +85 -0
  214. package/src/sync/live.saga.test.ts +178 -0
  215. package/src/sync/live.saga.ts +107 -0
  216. package/src/sync/policy.health.test.ts +56 -0
  217. package/src/sync/policy.test.ts +659 -0
  218. package/src/sync/policy.ts +502 -0
  219. package/src/sync/poll.saga.test.ts +51 -0
  220. package/src/sync/poll.saga.ts +36 -0
  221. package/src/sync/tabs.saga.test.ts +78 -0
  222. package/src/sync/tabs.saga.ts +96 -0
  223. package/src/testing/adapters.ts +133 -0
  224. package/src/testing/build.ts +63 -0
  225. package/src/testing/fake-services.ts +48 -0
  226. package/src/testing/run-pure.test.ts +92 -0
  227. package/src/testing/run-pure.ts +128 -0
  228. package/src/testing/services.ts +17 -0
  229. package/src/types.ts +624 -10
  230. package/src/utils/blurhash.ts +90 -0
  231. package/src/utils/edges.ts +39 -0
  232. package/src/utils/error-classification.test.ts +54 -0
  233. package/src/utils/error-classification.ts +25 -0
  234. package/src/utils/index.ts +78 -12
  235. package/src/utils/parser.test.ts +67 -102
  236. package/src/utils/parser.ts +64 -2
  237. package/src/utils/semver.test.ts +32 -0
  238. package/src/utils/semver.ts +30 -0
  239. package/src/utils/sha256.ts +15 -0
  240. package/src/utils/surql.ts +30 -18
  241. package/src/utils/utils.test.ts +3 -2
  242. package/tsdown.config.ts +117 -3
  243. package/vitest.config.ts +48 -0
  244. package/src/modules/cache/index.ts +0 -241
  245. package/src/modules/cache/types.ts +0 -19
  246. package/src/modules/data/data.test.ts +0 -58
  247. package/src/modules/data/index.ts +0 -879
  248. package/src/modules/sync/engine.ts +0 -173
  249. package/src/modules/sync/events/index.ts +0 -77
  250. package/src/modules/sync/index.ts +0 -3
  251. package/src/modules/sync/queue/index.ts +0 -2
  252. package/src/modules/sync/queue/queue-down.ts +0 -89
  253. package/src/modules/sync/queue/queue-up.ts +0 -223
  254. package/src/modules/sync/scheduler.ts +0 -84
  255. package/src/modules/sync/sync.ts +0 -421
  256. package/src/modules/sync/utils.test.ts +0 -311
  257. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure } from '../testing/run-pure';
4
+ import { fakeServices } from '../testing/services';
5
+ import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
6
+ import * as R from '../state/reducers';
7
+ import { defaultEnv } from '../query/env';
8
+ import { boot, migrateWindowToView, pageHide, primeCircuit, startRemote, versionsPrimed, warmBlobs } from './boot.saga';
9
+
10
+ const env = defaultEnv({ tables: [] } as any);
11
+
12
+ describe('boot', () => {
13
+ it('solo: opens the hinted bucket, provisions, starts the SSP, restores the session, mirrors the outbox, flips localReady, hands off to the network', async () => {
14
+ const svc = fakeServices({
15
+ 'hint.read': () => 'u1',
16
+ 'local.usesSurqlSchema': () => true,
17
+ 'auth.restoreSession': async () => 'user:u1',
18
+ 'auth.sessionAuthId': () => 'user:u1',
19
+ 'auth.access': () => 'account',
20
+ });
21
+ const out = await runPure(boot(env, { sharedTabs: false }), {
22
+ handlers: {
23
+ service: svc.handler,
24
+ 'local.query': (e: any) =>
25
+ e.sql.startsWith('SELECT count()') ? [[{ count: 1 }]] : e.sql === 'SELECT * FROM _00_view' ? [[{ id: '_00_view:k', ids: [['t:1', 1]] }]] : [],
26
+ },
27
+ });
28
+ expect(svc.names()).toEqual([
29
+ 'hint.read',
30
+ 'local.connect',
31
+ 'local.usesSurqlSchema',
32
+ 'migrator.provision',
33
+ 'ssp.init',
34
+ 'ssp.setPermissions',
35
+ 'auth.restoreSession',
36
+ 'auth.sessionAuthId',
37
+ 'crdt.setSessionId',
38
+ 'auth.access',
39
+ 'ssp.setSessionAuth',
40
+ 'window.attach',
41
+ 'features.init',
42
+ 'releases.init',
43
+ ]);
44
+ expect(svc.calls[1]).toEqual(['local.connect', ['u1']]);
45
+ expect(out.state).toMatchObject({ bucketId: 'u1', userId: 'user:u1', saltUserId: 'user:u1', sessionId: 'salt-1', localReady: true });
46
+ expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'AdoptPendingWrites', 'LifecycleTick', 'StartRemote']);
47
+ // The view index is loaded before anything can paint; the first GC waits out the boot.
48
+ expect(out.state.views).toEqual(new Map([['k', ['t:1']]]));
49
+ expect(out.timers.get('gc')).toEqual({ ms: 30_000, event: { type: 'GcTick' } });
50
+ expect(svc.names()).not.toContain('remote.connect');
51
+ });
52
+ it('shared tabs: takes the role from the coordinator, or falls back to solo; schemaless engines skip provisioning; anonymous skips session auth', async () => {
53
+ const leader = fakeServices({ 'hint.read': () => null, 'tabs.start': async () => 'leader', 'local.usesSurqlSchema': () => false, 'auth.restoreSession': async () => null, 'auth.sessionAuthId': () => null });
54
+ const out = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: leader.handler, 'local.query': () => [] } });
55
+ expect(out.state.tabRole).toBe('leader');
56
+ expect(out.state.bucketId).toBe('anon');
57
+ expect(leader.names()).not.toContain('local.connect');
58
+ expect(leader.names()).not.toContain('migrator.provision');
59
+ expect(leader.names()).not.toContain('ssp.setSessionAuth');
60
+ const fallback = fakeServices({
61
+ 'hint.read': () => 'x',
62
+ 'tabs.start': async () => {
63
+ throw new Error('no broker');
64
+ },
65
+ 'local.usesSurqlSchema': () => false,
66
+ 'auth.restoreSession': async () => null,
67
+ 'auth.sessionAuthId': () => null,
68
+ });
69
+ const fb = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: fallback.handler, 'local.query': () => [] } });
70
+ expect(fallback.calls).toContainEqual(['local.connect', ['x']]);
71
+ expect(fb.emitted).toContainEqual(expect.objectContaining({ level: 'warn' }));
72
+ });
73
+ });
74
+
75
+ describe('migrateWindowToView', () => {
76
+ it('copies legacy rows once, skips when _00_view has rows, tolerates errors and junk', async () => {
77
+ const upserts: any[] = [];
78
+ await runPure(migrateWindowToView(), {
79
+ handlers: {
80
+ 'local.query': (e: any) =>
81
+ e.sql.startsWith('SELECT count()')
82
+ ? [[]]
83
+ : [[{ id: '_00_window:k1', ids: [['t:1', 1]], confirmed: true, updatedAt: 5 }, { id: new RecordId('_00_window', 'k2'), ids: [] }, { id: 'x', ids: 'bad' }, { ids: [] }]],
84
+ 'local.upsert': (e: any) => void upserts.push(e),
85
+ },
86
+ });
87
+ expect(upserts).toEqual([
88
+ { kind: 'local.upsert', table: '_00_view', id: new RecordId('_00_view', 'k1'), data: { ids: [['t:1', 1]], confirmed: true, updatedAt: 5 }, mode: 'replace' },
89
+ { kind: 'local.upsert', table: '_00_view', id: new RecordId('_00_view', 'k2'), data: { ids: [], confirmed: false, updatedAt: 0 }, mode: 'replace' },
90
+ ]);
91
+ const skipped = await runPure(migrateWindowToView(), { handlers: { 'local.query': () => [[{ count: 3 }]] } });
92
+ expect(skipped.log.filter((e) => e.kind === 'local.query')).toHaveLength(1);
93
+ const failing = await runPure(migrateWindowToView(), {
94
+ handlers: {
95
+ 'local.query': () => {
96
+ throw new Error('no table');
97
+ },
98
+ },
99
+ });
100
+ expect(failing.emitted).toEqual([expect.objectContaining({ level: 'debug' })]);
101
+ });
102
+ });
103
+
104
+ describe('startRemote / primeCircuit / versionsPrimed / pageHide / warmBlobs', () => {
105
+ it('startRemote tolerates connect and auth failures and starts the loops', async () => {
106
+ const svc = fakeServices({
107
+ 'remote.connect': async () => {
108
+ throw new Error('offline');
109
+ },
110
+ 'auth.init': async () => {
111
+ throw new Error('401');
112
+ },
113
+ });
114
+ const out = await runPure(startRemote(), { handlers: { service: svc.handler } });
115
+ expect(svc.names()).toEqual(['remote.connect', 'supervisor.start', 'auth.init']);
116
+ expect(out.emitted.filter((e) => e.type === 'log')).toHaveLength(2);
117
+ expect(out.dispatched.map((d) => d.type)).toEqual(['EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
118
+ const ok = await runPure(startRemote(), { handlers: { service: fakeServices().handler } });
119
+ expect(ok.emitted).toEqual([]);
120
+ });
121
+ it('primeCircuit passes outbox ids and always flips primed', async () => {
122
+ const svc = fakeServices({ 'ssp.prime': async () => undefined });
123
+ const s = buildState([], R.outboxReplace([buildOutboxItem({ recordId: 'thing:1' })]));
124
+ const out = await runPure(primeCircuit(), { state: s, handlers: { service: svc.handler } });
125
+ expect(svc.calls).toEqual([['ssp.prime', [['thing:1']]]]);
126
+ expect(out.state.primed).toBe(true);
127
+ const failing = fakeServices({
128
+ 'ssp.prime': async () => {
129
+ throw new Error('snapshot');
130
+ },
131
+ });
132
+ const f = await runPure(primeCircuit(), { state: buildState(), handlers: { service: failing.handler } });
133
+ expect(f.state.primed).toBe(true);
134
+ expect(f.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
135
+ });
136
+ it('versionsPrimed records versions; pageHide releases registered views; warmBlobs is best-effort', async () => {
137
+ const v = await runPure(versionsPrimed([['thing:1', 3]]), { state: buildState() });
138
+ expect(v.state.versions.get('thing:1')).toBe(3);
139
+ const svc = fakeServices();
140
+ const s = buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } }), buildEntry({ def: { hash: 'b' } })]);
141
+ await runPure(pageHide(), { state: s, handlers: { service: svc.handler } });
142
+ expect(svc.calls).toEqual([['remote.releaseViews', [[new RecordId('_00_query', 'a')]]]]);
143
+ const none = fakeServices();
144
+ await runPure(pageHide(), { state: buildState(), handlers: { service: none.handler } });
145
+ expect(none.calls).toEqual([]);
146
+ const blobs = fakeServices({
147
+ 'blobs.start': async () => {
148
+ throw new Error('opfs');
149
+ },
150
+ });
151
+ const w = await runPure(warmBlobs(), { state: { ...buildState(), bucketId: 'u9' }, handlers: { service: blobs.handler } });
152
+ expect(blobs.calls).toEqual([['blobs.start', ['u9']]]);
153
+ expect(w.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
154
+ const anon = fakeServices({ 'blobs.start': async () => undefined });
155
+ await runPure(warmBlobs(), { state: buildState(), handlers: { service: anon.handler } });
156
+ expect(anon.calls).toEqual([['blobs.start', ['anon']]]);
157
+ });
158
+ });
@@ -0,0 +1,131 @@
1
+ import type { Saga } from '../kernel/saga';
2
+ import { fx } from '../kernel/effects';
3
+ import type { ClientState } from '../state/client-state';
4
+ import * as R from '../state/reducers';
5
+ import { ANON_USER_ID } from '../modules/ref-tables';
6
+ import type { SagaEnv } from '../query/env';
7
+ import * as sql from '../query/sql';
8
+ import { loadOutbox } from '../mutation/push.saga';
9
+ import { loadViews } from '../query/lifecycle.saga';
10
+ import { GC_BOOT_DELAY_MS } from '../kernel/constants';
11
+
12
+ export interface BootOptions {
13
+ /** A shared-tabs coordinator is available: ask it for a role instead of opening the store alone. */
14
+ sharedTabs: boolean;
15
+ }
16
+
17
+ /**
18
+ * Local boot. Everything awaited here is network-free: the store opens,
19
+ * the schema provisions, the SSP starts, the session is restored from the
20
+ * cached token, the outbox is mirrored. `localReady` flips at the end and
21
+ * the network half runs in the background (`startRemote`).
22
+ */
23
+ export function* boot(env: SagaEnv, opts: BootOptions): Saga<void> {
24
+ const bucket = ((yield fx.service('hint.read')) as string | null) ?? ANON_USER_ID;
25
+ let shared = false;
26
+ if (opts.sharedTabs) {
27
+ try {
28
+ const role = (yield fx.service('tabs.start', bucket)) as ClientState['tabRole'];
29
+ yield fx.state.update(R.setTabRole(role));
30
+ shared = true;
31
+ } catch (error) {
32
+ yield fx.emit({ type: 'log', level: 'warn', message: 'shared tabs unavailable; booting solo', data: { error } });
33
+ }
34
+ }
35
+ if (!shared) yield fx.service('local.connect', bucket);
36
+ yield fx.state.update(R.setIdentity({ bucketId: bucket }));
37
+ if ((yield fx.service('local.usesSurqlSchema')) as boolean) yield fx.service('migrator.provision');
38
+ yield* migrateWindowToView();
39
+ yield* loadViews();
40
+ yield fx.dispatch({ type: 'WarmBlobs' });
41
+ yield fx.service('ssp.init');
42
+ yield fx.service('ssp.setPermissions');
43
+ yield fx.dispatch({ type: 'PrimeCircuit' });
44
+
45
+ const restoredUserId = (yield fx.service('auth.restoreSession')) as string | null;
46
+ const salt = (yield fx.id('salt')) as string;
47
+ const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
48
+ yield fx.state.update(R.setIdentity({ sessionId: salt, userId: restoredUserId, saltUserId: authId }));
49
+ yield fx.service('crdt.setSessionId', salt);
50
+ if (restoredUserId) yield fx.service('ssp.setSessionAuth', authId, (yield fx.service('auth.access')) as string | null);
51
+
52
+ yield* loadOutbox(env);
53
+ yield fx.service('window.attach');
54
+ yield fx.service('features.init');
55
+ yield fx.service('releases.init');
56
+ yield fx.dispatch({ type: 'LifecycleTick' });
57
+ yield fx.timer.set('gc', GC_BOOT_DELAY_MS, { type: 'GcTick' });
58
+ yield fx.state.update(R.setIdentity({ localReady: true }));
59
+ yield fx.dispatch({ type: 'StartRemote' });
60
+ }
61
+
62
+ /** One-time copy of the legacy `_00_window` rows into `_00_view`. */
63
+ export function* migrateWindowToView(): Saga<void> {
64
+ try {
65
+ const countRes = (yield fx.local.query(sql.countViewRows())) as unknown[];
66
+ const countRow = Array.isArray(countRes?.[0]) ? (countRes[0] as Array<{ count?: number }>)[0] : undefined;
67
+ if ((countRow?.count ?? 0) > 0) return;
68
+ const legacy = (yield fx.local.query(sql.readLegacyViewRows())) as unknown[];
69
+ const rows = Array.isArray(legacy?.[0]) ? (legacy[0] as Array<Record<string, unknown>>) : [];
70
+ for (const row of rows) {
71
+ const key = typeof row.id === 'string' ? row.id.slice(row.id.indexOf(':') + 1) : String((row.id as { id?: unknown })?.id ?? '');
72
+ if (!key || !Array.isArray(row.ids)) continue;
73
+ yield fx.local.upsert(sql.VIEW_TABLE, sql.viewRecordId(key), { ids: row.ids, confirmed: row.confirmed === true, updatedAt: row.updatedAt ?? 0 }, 'replace');
74
+ }
75
+ } catch (error) {
76
+ yield fx.emit({ type: 'log', level: 'debug', message: 'window -> view migration skipped', data: { error } });
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The network half of boot: connect, verify the restored session, then let
82
+ * membership, LIVE, the poll and the outbox start. Every step is best-effort.
83
+ */
84
+ export function* startRemote(): Saga<void> {
85
+ try {
86
+ yield fx.service('remote.connect');
87
+ } catch (error) {
88
+ yield fx.emit({ type: 'log', level: 'warn', message: 'remote connect failed; running from the local store', data: { error } });
89
+ }
90
+ yield fx.service('supervisor.start');
91
+ try {
92
+ yield fx.service('auth.init');
93
+ } catch (error) {
94
+ yield fx.emit({ type: 'log', level: 'warn', message: 'auth verification failed; keeping the restored session', data: { error } });
95
+ }
96
+ yield fx.dispatch({ type: 'EnsureRegistered' });
97
+ yield fx.dispatch({ type: 'LiveStart' });
98
+ yield fx.dispatch({ type: 'PollTick' });
99
+ yield fx.dispatch({ type: 'Drain' });
100
+ }
101
+
102
+ /** Fill the circuit from the local store; `primed` flips even on failure so fetches never hang. */
103
+ export function* primeCircuit(): Saga<void> {
104
+ const pending = (yield fx.state.read((s) => s.outbox.map((i) => i.recordId))) as string[];
105
+ try {
106
+ yield fx.service('ssp.prime', pending);
107
+ } catch (error) {
108
+ yield fx.emit({ type: 'log', level: 'warn', message: 'circuit prime failed; starting empty', data: { error } });
109
+ } finally {
110
+ yield fx.state.update(R.setIdentity({ primed: true }));
111
+ }
112
+ }
113
+
114
+ export function* versionsPrimed(entries: Array<readonly [string, number]>): Saga<void> {
115
+ yield fx.state.update(R.setVersions(entries));
116
+ }
117
+
118
+ /** Hand this tab's views back to the server when the page goes away for good. */
119
+ export function* pageHide(): Saga<void> {
120
+ const ids = (yield fx.state.read((s) => [...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered').map((e) => e.def.id))) as unknown[];
121
+ if (ids.length > 0) yield fx.service('remote.releaseViews', ids);
122
+ }
123
+
124
+ export function* warmBlobs(): Saga<void> {
125
+ const bucket = ((yield fx.state.read((s) => s.bucketId)) as string | null) ?? ANON_USER_ID;
126
+ try {
127
+ yield fx.service('blobs.start', bucket);
128
+ } catch (error) {
129
+ yield fx.emit({ type: 'log', level: 'warn', message: 'blob cache failed to start', data: { error } });
130
+ }
131
+ }
@@ -0,0 +1,187 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure } from '../testing/run-pure';
4
+ import { fakeServices } from '../testing/services';
5
+ import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
6
+ import * as R from '../state/reducers';
7
+ import { defaultEnv } from '../query/env';
8
+ import { bucketSwitch, dropImpersonatedBucket, rebindQueries } from './bucket-switch.saga';
9
+
10
+ const env = defaultEnv({ tables: [] } as any);
11
+ const base = (over: Partial<ReturnType<typeof buildState>> = {}) => ({ ...buildState(), pendingBucket: 'u2', bucketId: 'u1', ...over });
12
+
13
+ describe('bucketSwitch', () => {
14
+ it('steps aside when superseded or already on target (releasing the gate)', async () => {
15
+ const released: number[] = [];
16
+ const svc = fakeServices({ 'local.currentBucketId': () => 'u1' });
17
+ await runPure(bucketSwitch(env, 'u2', () => void released.push(1)), { state: { ...base(), pendingBucket: 'u3' }, handlers: { service: svc.handler } });
18
+ await runPure(bucketSwitch(env, 'u1', () => void released.push(2)), { state: base({ pendingBucket: 'u1' }), handlers: { service: svc.handler } });
19
+ expect(released).toEqual([1, 2]);
20
+ expect(svc.names()).toEqual(['local.currentBucketId', 'local.currentBucketId']);
21
+ });
22
+ it('solo: drain, swap, provision, blobs, SSP reset, prime, token, rebind, outbox, re-register', async () => {
23
+ const svc = fakeServices({
24
+ 'local.currentBucketId': () => 'u1',
25
+ 'local.beginSwitch': () => () => undefined,
26
+ 'local.usesSurqlSchema': () => true,
27
+ 'auth.sessionAuthId': () => 'user:u2',
28
+ 'auth.access': () => 'account',
29
+ 'auth.token': () => 'jwt',
30
+ });
31
+ const s = base();
32
+ const withQ = R.compose(
33
+ R.putQuery(buildEntry({ def: { hash: 'a', viewKey: 'vk', surql: 'SELECT * FROM thing' }, lifecycle: { phase: 'live', remote: 'registered' }, remoteArray: [['t:1', 1]] })),
34
+ R.outboxReplace([buildOutboxItem()]),
35
+ R.setVersions([['t:1', 1]]),
36
+ R.markMembershipDirty(['a'])
37
+ )({ ...s, sessionId: 'sess2' });
38
+ const out = await runPure(bucketSwitch(env, 'u2'), {
39
+ state: withQ,
40
+ handlers: {
41
+ service: svc.handler,
42
+ 'local.getById': () => ({ ids: [['t:9', 1]], confirmed: true }),
43
+ 'ssp.register': () => ({ localArray: [['t:9', 1]], timings: {} }),
44
+ 'local.query': () => [],
45
+ },
46
+ });
47
+ expect(svc.names()).toEqual([
48
+ 'local.currentBucketId',
49
+ 'local.beginSwitch',
50
+ 'crdt.closeAll',
51
+ 'local.switchStore',
52
+ 'local.usesSurqlSchema',
53
+ 'migrator.provision',
54
+ 'blobs.setNamespace',
55
+ 'ssp.reset',
56
+ 'ssp.setPermissions',
57
+ 'auth.sessionAuthId',
58
+ 'auth.access',
59
+ 'ssp.setSessionAuth',
60
+ 'auth.consumeEndedImpersonation',
61
+ 'auth.token',
62
+ 'persistence.set',
63
+ ]);
64
+ expect(out.log.filter((e) => e.kind === 'timer.clear').map((e) => (e as any).key)).toEqual(['poll', 'outbox', 'membership', 'fetch', 'ack-prune']);
65
+ expect(out.state).toMatchObject({ bucketId: 'u2', primed: false, outbox: [] });
66
+ expect(out.state.versions.size).toBe(0);
67
+ expect(out.state.membershipDirty.size).toBe(0);
68
+ const e = out.state.queries.get('a')!;
69
+ expect(e.lifecycle).toMatchObject({ phase: 'cached', remote: 'unregistered' });
70
+ expect(e.remoteArray).toEqual([['t:9', 1]]);
71
+ expect(e.localArray).toEqual([['t:9', 1]]);
72
+ expect(String(e.def.id.table)).toBe('_00_query');
73
+ // PollTick is load-bearing: the switch cleared the poll timer, and only the
74
+ // tick re-arms it, so a sign-in without it leaves the client on LIVE alone.
75
+ expect(out.dispatched.map((d) => d.type)).toEqual(['PrimeCircuit', 'AdoptPendingWrites', 'EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
76
+ });
77
+ it('shared tabs: moves namespaces, falls back to solo on failure; blob clear on sign-out; failures are logged and the gate always reopens', async () => {
78
+ let gateReleased = false;
79
+ const moved = fakeServices({
80
+ 'local.currentBucketId': () => 'u1',
81
+ 'tabs.moveToBucket': async () => 'follower',
82
+ 'auth.sessionAuthId': () => null,
83
+ 'auth.access': () => null,
84
+ 'auth.token': () => null,
85
+ });
86
+ const out = await runPure(bucketSwitch(env, 'u2', () => void (gateReleased = true)), {
87
+ state: { ...base(), tabRole: 'leader' },
88
+ handlers: { service: moved.handler, 'local.query': () => [] },
89
+ });
90
+ expect(out.state.tabRole).toBe('follower');
91
+ expect(gateReleased).toBe(true);
92
+ expect(moved.names()).not.toContain('local.switchStore');
93
+ const failing = fakeServices({
94
+ 'local.currentBucketId': () => 'u1',
95
+ 'local.beginSwitch': () => () => undefined,
96
+ 'tabs.moveToBucket': async () => {
97
+ throw new Error('broker');
98
+ },
99
+ 'auth.sessionAuthId': () => null,
100
+ 'auth.access': () => null,
101
+ 'auth.token': () => 'jwt',
102
+ 'persistence.set': async () => {
103
+ throw new Error('kv');
104
+ },
105
+ 'blobs.setNamespace': async () => {
106
+ throw new Error('opfs');
107
+ },
108
+ });
109
+ const fb = await runPure(bucketSwitch(env, 'anon', null, { clearBlobsOnSignOut: true }), {
110
+ state: { ...base({ pendingBucket: 'anon' }), tabRole: 'leader' },
111
+ handlers: { service: failing.handler, 'local.query': () => [] },
112
+ });
113
+ expect(fb.state.tabRole).toBe('solo');
114
+ expect(failing.names()).toContain('local.beginSwitch');
115
+ expect(failing.names()).toContain('ssp.setPersistence');
116
+ expect(failing.names()).toContain('blobs.clear');
117
+ expect(fb.emitted.filter((e) => e.type === 'log')).toHaveLength(3);
118
+ });
119
+ it('a failing swap still reopens the gate and rethrows', async () => {
120
+ let released = false;
121
+ const svc = fakeServices({
122
+ 'local.currentBucketId': () => 'u1',
123
+ 'local.switchStore': async () => {
124
+ throw new Error('disk');
125
+ },
126
+ });
127
+ await expect(runPure(bucketSwitch(env, 'u2', () => void (released = true)), { state: base(), handlers: { service: svc.handler } })).rejects.toThrow('disk');
128
+ expect(released).toBe(true);
129
+ });
130
+ });
131
+
132
+ describe('dropImpersonatedBucket', () => {
133
+ it('drops the bucket it just left when that was the impersonated user', async () => {
134
+ const svc = fakeServices({ 'auth.consumeEndedImpersonation': () => 'user:bob' });
135
+ await runPure(dropImpersonatedBucket('bob'), { state: buildState(), handlers: { service: svc.handler } });
136
+ expect(svc.calls).toEqual([
137
+ ['auth.consumeEndedImpersonation', []],
138
+ ['local.dropBucket', ['bob']],
139
+ ]);
140
+ });
141
+ it('leaves every other bucket alone', async () => {
142
+ for (const [ended, left] of [[null, 'bob'], ['user:bob', 'alice'], ['user:bob', 'anon']] as const) {
143
+ const svc = fakeServices({ 'auth.consumeEndedImpersonation': () => ended });
144
+ await runPure(dropImpersonatedBucket(left), { state: buildState(), handlers: { service: svc.handler } });
145
+ expect(svc.names()).toEqual(['auth.consumeEndedImpersonation']);
146
+ }
147
+ });
148
+ it('logs a failed delete instead of failing the switch', async () => {
149
+ const svc = fakeServices({
150
+ 'auth.consumeEndedImpersonation': () => 'user:bob',
151
+ 'local.dropBucket': async () => {
152
+ throw new Error('handles still open');
153
+ },
154
+ });
155
+ const out = await runPure(dropImpersonatedBucket('bob'), { state: buildState(), handlers: { service: svc.handler } });
156
+ expect(out.emitted.filter((e) => e.type === 'log')).toHaveLength(1);
157
+ });
158
+ });
159
+
160
+ describe('rebindQueries', () => {
161
+ it('re-seeds from the new store, rebuilds the local view (tolerating failures), emits authority flips', async () => {
162
+ const s = R.compose(
163
+ R.putQuery(buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live' } })),
164
+ R.putQuery(buildEntry({ def: { hash: 'b' }, lifecycle: { phase: 'cold' } }))
165
+ )({ ...buildState(), sessionId: 's' });
166
+ const out = await runPure(rebindQueries(), {
167
+ state: s,
168
+ handlers: {
169
+ 'local.getById': (e: any) => {
170
+ if (String((e.id as RecordId).id) === 'view-b') return { ids: [['t:1', 1]], confirmed: true };
171
+ throw new Error('missing');
172
+ },
173
+ 'ssp.register': (e: any) => {
174
+ if (e.plan.queryHash === 'a') throw new Error('wasm');
175
+ return { localArray: [['t:1', 1]], timings: {} };
176
+ },
177
+ },
178
+ });
179
+ expect(out.state.queries.get('a')!.lifecycle.phase).toBe('cold');
180
+ expect(out.state.queries.get('b')!.lifecycle.phase).toBe('cached');
181
+ expect(out.emitted.filter((e) => e.type === 'query:authority')).toEqual([
182
+ { type: 'query:authority', hash: 'a', known: false },
183
+ { type: 'query:authority', hash: 'b', known: true },
184
+ ]);
185
+ expect(out.emitted.some((e) => e.type === 'log')).toBe(true);
186
+ });
187
+ });
@@ -0,0 +1,133 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type { Saga } from '../kernel/saga';
3
+ import type { RegisterResult } from '../kernel/effects';
4
+ import { fx } from '../kernel/effects';
5
+ import type { ClientState, QueryEntry } from '../state/client-state';
6
+ import { seedLifecycle, isAuthoritative } from '../state/lifecycle';
7
+ import * as R from '../state/reducers';
8
+ import { ANON_USER_ID, bucketIdForUser } from '../modules/ref-tables';
9
+ import type { SagaEnv } from '../query/env';
10
+ import { queryHashInput } from '../query/hash';
11
+ import { isResolvedBefore, parseViewRow } from '../query/membership';
12
+ import * as sql from '../query/sql';
13
+ import { loadViews } from '../query/lifecycle.saga';
14
+ import { loadOutbox } from '../mutation/push.saga';
15
+
16
+ export interface BucketSwitchOptions {
17
+ clearBlobsOnSignOut: boolean;
18
+ }
19
+
20
+ /**
21
+ * Move the client to another user's local store: drain, swap, rebind.
22
+ * Runs on the serial `bucket` lane; a switch that wakes up to a newer
23
+ * `pendingBucket` steps aside. Every active query keeps its hash and is
24
+ * re-seeded from the new store's `_00_view` rows, then re-registered.
25
+ */
26
+ export function* bucketSwitch(env: SagaEnv, target: string, release: (() => void) | null = null, opts: BucketSwitchOptions = { clearBlobsOnSignOut: false }): Saga<void> {
27
+ const state = (yield fx.state.read((s) => s)) as ClientState;
28
+ const current = (yield fx.service('local.currentBucketId')) as string;
29
+ if (state.pendingBucket !== target || current === target) {
30
+ release?.();
31
+ return;
32
+ }
33
+ const gate = release ?? ((yield fx.service('local.beginSwitch')) as () => void);
34
+ for (const key of ['poll', 'outbox', 'membership', 'fetch', 'ack-prune']) yield fx.timer.clear(key);
35
+ yield fx.state.update(R.clearBucketState());
36
+ yield fx.service('crdt.closeAll', false);
37
+ try {
38
+ if (state.tabRole !== 'solo') {
39
+ try {
40
+ const role = (yield fx.service('tabs.moveToBucket', target)) as ClientState['tabRole'];
41
+ yield fx.state.update(R.setTabRole(role));
42
+ } catch (error) {
43
+ yield fx.emit({ type: 'log', level: 'warn', message: 'shared-tabs bucket move failed; switching solo', data: { error } });
44
+ yield fx.state.update(R.setTabRole('solo'));
45
+ yield fx.service('ssp.setPersistence', true);
46
+ yield fx.service('local.switchStore', target);
47
+ }
48
+ } else {
49
+ yield fx.service('local.switchStore', target);
50
+ if ((yield fx.service('local.usesSurqlSchema')) as boolean) yield fx.service('migrator.provision');
51
+ }
52
+ try {
53
+ if (opts.clearBlobsOnSignOut && target === ANON_USER_ID && current !== ANON_USER_ID) yield fx.service('blobs.clear');
54
+ yield fx.service('blobs.setNamespace', target);
55
+ } catch (error) {
56
+ yield fx.emit({ type: 'log', level: 'warn', message: 'blob cache bucket switch failed', data: { error } });
57
+ }
58
+ yield fx.service('ssp.reset');
59
+ yield fx.service('ssp.setPermissions');
60
+ yield fx.service('ssp.setSessionAuth', (yield fx.service('auth.sessionAuthId')) as string | null, (yield fx.service('auth.access')) as string | null);
61
+ yield fx.state.update(R.setIdentity({ bucketId: target }));
62
+ yield fx.dispatch({ type: 'PrimeCircuit' });
63
+ } finally {
64
+ gate();
65
+ }
66
+ yield* dropImpersonatedBucket(current);
67
+ const token = (yield fx.service('auth.token')) as string | null;
68
+ if (token) {
69
+ try {
70
+ yield fx.service('persistence.set', 'sp00ky_auth_token', token);
71
+ } catch (error) {
72
+ yield fx.emit({ type: 'log', level: 'warn', message: 'failed to re-persist the auth token', data: { error } });
73
+ }
74
+ }
75
+ yield* loadViews();
76
+ yield* rebindQueries();
77
+ yield* loadOutbox(env);
78
+ yield fx.dispatch({ type: 'EnsureRegistered' });
79
+ yield fx.dispatch({ type: 'LiveStart' });
80
+ // The switch cleared the poll timer, and only the tick itself re-arms it: a
81
+ // sign-in (auth flip -> bucket switch) otherwise left the client running on
82
+ // LIVE alone for the rest of the session, so any membership change LIVE did
83
+ // not deliver was never noticed.
84
+ yield fx.dispatch({ type: 'PollTick' });
85
+ yield fx.dispatch({ type: 'Drain' });
86
+ }
87
+
88
+ /**
89
+ * An impersonation just ended and this switch left its bucket: delete that
90
+ * user's local data from the admin's device. Best-effort; a failure only
91
+ * leaves rows the server already let this admin read.
92
+ */
93
+ export function* dropImpersonatedBucket(left: string): Saga<void> {
94
+ const ended = (yield fx.service('auth.consumeEndedImpersonation')) as string | null;
95
+ if (!ended || left === ANON_USER_ID || bucketIdForUser(ended) !== left) return;
96
+ try {
97
+ yield fx.service('local.dropBucket', left);
98
+ } catch (error) {
99
+ yield fx.emit({ type: 'log', level: 'warn', message: 'could not delete the impersonated bucket', data: { bucket: left, error } });
100
+ }
101
+ }
102
+
103
+ /** Re-seed every active query from the new store and rebuild its SSP view. */
104
+ export function* rebindQueries(): Saga<void> {
105
+ const [entries, sessionId] = (yield fx.state.read((s) => [[...s.queries.values()], s.sessionId])) as [QueryEntry[], string | null];
106
+ for (const entry of entries) {
107
+ let view: ReturnType<typeof parseViewRow> = null;
108
+ try {
109
+ view = parseViewRow(yield fx.local.getById(sql.VIEW_TABLE, sql.viewRecordId(entry.def.viewKey)));
110
+ } catch {
111
+ view = null;
112
+ }
113
+ const hash = (yield fx.hash(queryHashInput({ surql: entry.def.surql, params: entry.def.params as Record<string, unknown> }, sessionId))) as string;
114
+ let localArray: QueryEntry['localArray'] = [];
115
+ try {
116
+ const reg = (yield fx.ssp.register({
117
+ queryHash: entry.def.hash,
118
+ surql: entry.def.surql,
119
+ params: entry.def.params as Record<string, unknown>,
120
+ ttl: entry.def.ttl,
121
+ tableName: entry.def.tableName,
122
+ })) as RegisterResult;
123
+ localArray = reg.localArray;
124
+ } catch (error) {
125
+ yield fx.emit({ type: 'log', level: 'warn', message: 'local view rebuild failed after bucket switch', data: { hash: entry.def.hash, error } });
126
+ }
127
+ const lifecycle = seedLifecycle(isResolvedBefore(view));
128
+ yield fx.state.update(R.rebindQuery(entry.def.hash, { id: new RecordId('_00_query', hash), lifecycle, remoteArray: view?.ids ?? [], localArray }));
129
+ if (isAuthoritative(entry.lifecycle) !== isAuthoritative(lifecycle)) {
130
+ yield fx.emit({ type: 'query:authority', hash: entry.def.hash, known: isAuthoritative(lifecycle) });
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { runPure, sha256Hex } from '../testing/run-pure';
3
+ import { buildEntry, buildState } from '../testing/build';
4
+ import * as R from '../state/reducers';
5
+ import { defaultEnv } from '../query/env';
6
+ import { queryHashInput } from '../query/hash';
7
+ import { preload, PreloadFailedError } from './preload.saga';
8
+
9
+ const env = defaultEnv({ tables: [{ name: 'thing', columns: {} }] } as any);
10
+ const input = { tableName: 'thing', surql: 'SELECT * FROM thing', params: {}, ttl: '10m' as const };
11
+ const sspOk = () => ({ localArray: [], timings: { parseMs: 0, planMs: 0, snapshotMs: 0, wallMs: 0 } });
12
+
13
+ describe('preload', () => {
14
+ it('resolved before: returns without waiting and with no remote effect', async () => {
15
+ const out = await runPure(preload(env, input), {
16
+ handlers: { 'local.getById': () => ({ ids: [['thing:1', 1]], confirmed: true }), 'ssp.register': sspOk },
17
+ });
18
+ expect(out.result.waited).toBe(false);
19
+ expect(out.log.filter((e) => e.kind === 'remote.query')).toHaveLength(0);
20
+ expect(out.log.filter((e) => e.kind === 'state.wait')).toHaveLength(0);
21
+ });
22
+ it('never resolved: waits for settled (membership + bodies + first render), or fails when the registration failed', async () => {
23
+ const hash = await sha256Hex(queryHashInput(input, null));
24
+ const settleIt = (ctx: any) => {
25
+ ctx.state = R.compose(
26
+ R.commitMembership(hash, [['thing:1', 1]], true),
27
+ R.setVersions([['thing:1', 1]]),
28
+ R.setRecords(hash, [{ id: 'thing:1' }], true, 1)
29
+ )(ctx.state);
30
+ };
31
+ const out = await runPure(preload(env, input), {
32
+ handlers: { 'local.getById': () => null, 'ssp.register': sspOk, 'state.wait': (_e, ctx) => settleIt(ctx) },
33
+ });
34
+ expect(out.result).toEqual({ hash, waited: true });
35
+ const failed = runPure(preload(env, input), {
36
+ handlers: {
37
+ 'local.getById': () => null,
38
+ 'ssp.register': sspOk,
39
+ 'state.wait': (_e, ctx) => void (ctx.state = R.applyLifecycle(hash, { type: 'remote-failed' })(ctx.state)),
40
+ },
41
+ });
42
+ await expect(failed).rejects.toBeInstanceOf(PreloadFailedError);
43
+ const gone = runPure(preload(env, input), {
44
+ handlers: { 'local.getById': () => null, 'ssp.register': sspOk, 'state.wait': (_e, ctx) => void (ctx.state = R.removeQuery(hash)(ctx.state)) },
45
+ });
46
+ await expect(gone).rejects.toThrow(/could not settle/);
47
+ const already = buildState([buildEntry({ def: { hash }, lifecycle: { phase: 'cached' } })]);
48
+ const cached = await runPure(preload(env, input), { state: already });
49
+ expect(cached.result.waited).toBe(false);
50
+ });
51
+ });