@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331

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 +84 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2080 -142
  4. package/dist/index.js +16009 -2480
  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 +373 -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 +1857 -38
  19. package/dist/worker-edges.js +480 -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 +32 -7
  28. package/skills/sp00ky-core/references/config.md +7 -1
  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 +203 -0
  32. package/src/boot/boot.saga.ts +146 -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 +80 -0
  36. package/src/boot/preload.saga.ts +40 -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 +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +74 -0
  46. package/src/client/services.ts +434 -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 +167 -0
  56. package/src/kernel/events.ts +57 -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/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +476 -22
  72. package/src/modules/crdt/acceptance.ts +75 -0
  73. package/src/modules/crdt/bytes.ts +50 -0
  74. package/src/modules/crdt/crdt-field.ts +125 -0
  75. package/src/modules/crdt/crdt.test.ts +469 -0
  76. package/src/modules/crdt/hash.ts +103 -0
  77. package/src/modules/crdt/index.ts +757 -0
  78. package/src/modules/crdt/loro-loader.ts +25 -0
  79. package/src/modules/crdt/mirror.ts +37 -0
  80. package/src/modules/devtools/flags.ts +349 -0
  81. package/src/modules/devtools/index.ts +746 -95
  82. package/src/modules/devtools/mutations.test.ts +92 -0
  83. package/src/modules/devtools/mutations.ts +160 -0
  84. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  85. package/src/modules/devtools/state-shape.test.ts +263 -0
  86. package/src/modules/devtools/storage-info.test.ts +79 -0
  87. package/src/modules/devtools/storage-info.ts +168 -0
  88. package/src/modules/devtools/versions.test.ts +74 -0
  89. package/src/modules/devtools/versions.ts +110 -0
  90. package/src/modules/feature-flag/index.test.ts +298 -0
  91. package/src/modules/feature-flag/index.ts +316 -0
  92. package/src/modules/ref-tables.test.ts +93 -0
  93. package/src/modules/ref-tables.ts +91 -0
  94. package/src/modules/web-push/index.test.ts +824 -0
  95. package/src/modules/web-push/index.ts +1027 -0
  96. package/src/mutation/jobs.test.ts +25 -0
  97. package/src/mutation/jobs.ts +41 -0
  98. package/src/mutation/mutation-id.test.ts +37 -0
  99. package/src/mutation/mutation-id.ts +35 -0
  100. package/src/mutation/pending-writes.integration.test.ts +175 -0
  101. package/src/mutation/push.saga.test.ts +301 -0
  102. package/src/mutation/push.saga.ts +225 -0
  103. package/src/mutation/rows.test.ts +231 -0
  104. package/src/mutation/rows.ts +427 -0
  105. package/src/mutation/tray.saga.test.ts +63 -0
  106. package/src/mutation/tray.saga.ts +48 -0
  107. package/src/mutation/write.saga.test.ts +316 -0
  108. package/src/mutation/write.saga.ts +268 -0
  109. package/src/otel/index.ts +10 -7
  110. package/src/pure.ts +66 -0
  111. package/src/push/types.ts +174 -0
  112. package/src/query/anon-query-isolation.integration.test.ts +527 -0
  113. package/src/query/env.test.ts +23 -0
  114. package/src/query/env.ts +65 -0
  115. package/src/query/fetch.saga.test.ts +199 -0
  116. package/src/query/fetch.saga.ts +120 -0
  117. package/src/query/hash.test.ts +14 -0
  118. package/src/query/hash.ts +18 -0
  119. package/src/query/id-set-plan.test.ts +132 -0
  120. package/src/query/lifecycle.saga.test.ts +274 -0
  121. package/src/query/lifecycle.saga.ts +186 -0
  122. package/src/query/list-ref-live.integration.test.ts +203 -0
  123. package/src/query/materialize.saga.test.ts +115 -0
  124. package/src/query/materialize.saga.ts +84 -0
  125. package/src/query/materialize.test.ts +58 -0
  126. package/src/query/materialize.ts +57 -0
  127. package/src/query/membership.saga.test.ts +407 -0
  128. package/src/query/membership.saga.ts +282 -0
  129. package/src/query/membership.test.ts +118 -0
  130. package/src/query/membership.ts +201 -0
  131. package/src/query/register.saga.test.ts +311 -0
  132. package/src/query/register.saga.ts +235 -0
  133. package/src/query/render-set.test.ts +47 -0
  134. package/src/query/render-set.ts +71 -0
  135. package/src/query/sql.test.ts +118 -0
  136. package/src/query/sql.ts +157 -0
  137. package/src/query/window-query.test.ts +70 -0
  138. package/src/query/window-query.ts +208 -0
  139. package/src/services/blobs/blob-cache.test.ts +359 -0
  140. package/src/services/blobs/blob-cache.ts +603 -0
  141. package/src/services/blobs/blob-manifest.ts +227 -0
  142. package/src/services/blobs/blob-store.test.ts +77 -0
  143. package/src/services/blobs/blob-store.ts +359 -0
  144. package/src/services/blobs/blob.fixture.ts +90 -0
  145. package/src/services/blobs/index.ts +70 -0
  146. package/src/services/database/cache-engine.ts +209 -0
  147. package/src/services/database/connection-supervisor.test.ts +318 -0
  148. package/src/services/database/connection-supervisor.ts +422 -0
  149. package/src/services/database/database.query-timeout.test.ts +140 -0
  150. package/src/services/database/database.responses.test.ts +43 -0
  151. package/src/services/database/database.ts +156 -61
  152. package/src/services/database/engine-factory.ts +33 -0
  153. package/src/services/database/errors.ts +34 -0
  154. package/src/services/database/events/index.ts +2 -1
  155. package/src/services/database/index.ts +7 -0
  156. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  157. package/src/services/database/local-migrator.integration.test.ts +110 -0
  158. package/src/services/database/local-migrator.ts +61 -16
  159. package/src/services/database/local.test.ts +150 -0
  160. package/src/services/database/local.ts +508 -63
  161. package/src/services/database/plan-render.test.ts +159 -0
  162. package/src/services/database/plan-render.ts +108 -0
  163. package/src/services/database/relation-resolver.test.ts +484 -0
  164. package/src/services/database/relation-resolver.ts +0 -0
  165. package/src/services/database/remote.ts +285 -6
  166. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  167. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  168. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  169. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  170. package/src/services/database/sqlite-column-reset.ts +91 -0
  171. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  172. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  173. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  174. package/src/services/database/sqlite-lock-verify.ts +45 -0
  175. package/src/services/database/sqlite-open.test.ts +150 -0
  176. package/src/services/database/sqlite-open.ts +164 -0
  177. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  178. package/src/services/database/sqlite-plan-sql.ts +196 -0
  179. package/src/services/database/sqlite-projection.test.ts +121 -0
  180. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  181. package/src/services/database/sqlite-select.test.ts +261 -0
  182. package/src/services/database/sqlite-select.ts +131 -0
  183. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  184. package/src/services/database/sqlite-transport.ts +224 -0
  185. package/src/services/database/sqlite-worker.ts +437 -0
  186. package/src/services/database/surql-translate.ts +417 -0
  187. package/src/services/database/surreal-cache-engine.ts +163 -0
  188. package/src/services/logger/index.test.ts +132 -0
  189. package/src/services/logger/index.ts +189 -7
  190. package/src/services/persistence/localstorage.ts +2 -2
  191. package/src/services/persistence/resilient.ts +10 -3
  192. package/src/services/persistence/surrealdb.ts +4 -4
  193. package/src/services/stream-processor/index.ts +806 -67
  194. package/src/services/stream-processor/permissions.test.ts +47 -0
  195. package/src/services/stream-processor/permissions.ts +53 -0
  196. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  197. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  198. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  199. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  200. package/src/services/stream-processor/wasm-types.ts +58 -2
  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 -395
  211. package/src/state/client-state.ts +208 -0
  212. package/src/state/lifecycle.test.ts +141 -0
  213. package/src/state/lifecycle.ts +99 -0
  214. package/src/state/reducers.test.ts +306 -0
  215. package/src/state/reducers.ts +531 -0
  216. package/src/state/selectors.test.ts +188 -0
  217. package/src/state/selectors.ts +258 -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 +178 -0
  224. package/src/sync/live.saga.ts +107 -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 +633 -10
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +39 -0
  241. package/src/utils/error-classification.test.ts +54 -0
  242. package/src/utils/error-classification.ts +25 -0
  243. package/src/utils/index.ts +89 -12
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +64 -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 +29 -18
  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 -879
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -77
  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 -89
  262. package/src/modules/sync/queue/queue-up.ts +0 -223
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -421
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,135 @@
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, buildState } from '../testing/build';
6
+ import * as R from '../state/reducers';
7
+ import { defaultEnv } from '../query/env';
8
+ import { authFlip, persistVerifiedUser } from './auth-flip.saga';
9
+ import { queryHashInput } from '../query/hash';
10
+ import { sha256Hex } from '../utils/sha256';
11
+
12
+ const env = defaultEnv({ tables: [{ name: 'user', columns: { id: {}, email_verified: {} } }] } as any);
13
+
14
+ describe('authFlip', () => {
15
+ it('sets identity and $auth, rotates the salt for a new principal before the bucket work, writes the hint, switches, persists the user row', async () => {
16
+ const released: string[] = [];
17
+ const svc = fakeServices({
18
+ 'auth.sessionAuthId': () => 'user:abc',
19
+ 'auth.access': () => 'account',
20
+ 'local.currentBucketId': () => 'anon',
21
+ 'local.beginSwitch': () => () => void released.push('gate'),
22
+ 'local.usesSurqlSchema': () => false,
23
+ 'auth.token': () => 'jwt',
24
+ 'auth.currentUser': () => ({ id: new RecordId('user', 'abc'), email_verified: true }),
25
+ });
26
+ const state = { ...buildState(), sessionId: 'old', saltUserId: null };
27
+ const out = await runPure(authFlip(env, 'user:abc'), {
28
+ state,
29
+ handlers: { service: svc.handler, 'local.query': () => [], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
30
+ });
31
+ const names = svc.names();
32
+ expect(names.slice(0, 6)).toEqual(['auth.sessionAuthId', 'auth.access', 'ssp.setSessionAuth', 'crdt.setSessionId', 'hint.write', 'local.currentBucketId']);
33
+ expect(svc.calls[3]).toEqual(['crdt.setSessionId', ['salt-1']]);
34
+ expect(svc.calls[4]).toEqual(['hint.write', ['abc']]);
35
+ expect(names).toContain('local.switchStore');
36
+ expect(released).toEqual(['gate']);
37
+ expect(out.state).toMatchObject({ userId: 'user:abc', saltUserId: 'user:abc', sessionId: 'salt-1', bucketId: 'abc' });
38
+ expect(out.state.versions.get('user:abc')).toBe(1);
39
+ });
40
+ it("re-keys active queries under the new principal's salt, so no remote id names the previous principal's view", async () => {
41
+ const svc = fakeServices({
42
+ 'auth.sessionAuthId': () => 'user:target',
43
+ 'auth.access': () => 'account',
44
+ 'local.currentBucketId': () => 'admin',
45
+ 'local.beginSwitch': () => () => undefined,
46
+ 'local.usesSurqlSchema': () => false,
47
+ 'auth.token': () => 'jwt',
48
+ 'auth.currentUser': () => null,
49
+ });
50
+ const def = { surql: 'SELECT * FROM notification', params: {} };
51
+ const before = await sha256Hex(queryHashInput(def, 'admin-salt'));
52
+ const state = R.putQuery(
53
+ buildEntry({ def: { hash: 'q', ...def, id: new RecordId('_00_query', before) }, lifecycle: { phase: 'live', remote: 'registered' } })
54
+ )({ ...buildState(), sessionId: 'admin-salt', saltUserId: 'user:admin', bucketId: 'admin' });
55
+ const out = await runPure(authFlip(env, 'user:target'), {
56
+ state,
57
+ handlers: {
58
+ service: svc.handler,
59
+ 'local.getById': () => null,
60
+ 'ssp.register': () => ({ localArray: [], timings: {} }),
61
+ 'local.query': () => [],
62
+ },
63
+ });
64
+ const id = String(out.state.queries.get('q')!.def.id.id);
65
+ expect(out.state.sessionId).toBe('salt-1');
66
+ expect(id).toBe(await sha256Hex(queryHashInput(def, 'salt-1')));
67
+ expect(id).not.toBe(before);
68
+ });
69
+ it('a new principal on the same bucket still re-keys its queries', async () => {
70
+ const svc = fakeServices({
71
+ 'auth.sessionAuthId': () => 'user:b',
72
+ 'auth.access': () => 'account',
73
+ 'local.currentBucketId': () => 'anon',
74
+ 'auth.currentUser': () => null,
75
+ });
76
+ const def = { surql: 'SELECT * FROM thing', params: {} };
77
+ const state = R.putQuery(buildEntry({ def: { hash: 'q', ...def }, lifecycle: { phase: 'live', remote: 'registered' } }))({
78
+ ...buildState(),
79
+ sessionId: 'old',
80
+ saltUserId: 'user:a',
81
+ bucketId: 'anon',
82
+ });
83
+ const out = await runPure(authFlip(env, null), {
84
+ state,
85
+ handlers: { service: svc.handler, 'local.getById': () => null, 'ssp.register': () => ({ localArray: [], timings: {} }) },
86
+ });
87
+ expect(svc.names()).not.toContain('local.switchStore');
88
+ expect(String(out.state.queries.get('q')!.def.id.id)).toBe(await sha256Hex(queryHashInput(def, 'salt-1')));
89
+ expect(out.dispatched.map((d) => d.type)).toContain('EnsureRegistered');
90
+ });
91
+ it('same principal on the same bucket: no switch, no salt rotation', async () => {
92
+ const svc = fakeServices({
93
+ 'auth.sessionAuthId': () => 'user:abc',
94
+ 'auth.access': () => 'account',
95
+ 'local.currentBucketId': () => 'abc',
96
+ 'auth.currentUser': () => null,
97
+ });
98
+ const state = { ...buildState(), sessionId: 'keep', saltUserId: 'user:abc', bucketId: 'abc' };
99
+ const out = await runPure(authFlip(env, 'user:abc'), { state, handlers: { service: svc.handler } });
100
+ expect(svc.names()).not.toContain('local.switchStore');
101
+ expect(svc.names()).not.toContain('local.beginSwitch');
102
+ expect(out.state.sessionId).toBe('keep');
103
+ });
104
+ });
105
+
106
+ describe('persistVerifiedUser', () => {
107
+ it('skips when there is no verified row, an unknown table, or a bare id; writes otherwise and logs failures', async () => {
108
+ for (const row of [null, { id: 'user:x', a: 1 }, { id: new RecordId('user', 'x') }, { id: new RecordId('nope', 'x'), a: 1 }]) {
109
+ const svc = fakeServices({ 'auth.currentUser': () => row as any });
110
+ const out = await runPure(persistVerifiedUser(env), { state: buildState(), handlers: { service: svc.handler } });
111
+ expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(0);
112
+ }
113
+ const svc = fakeServices({ 'auth.currentUser': () => ({ id: new RecordId('user', 'x'), email_verified: true, junk: 1 }) });
114
+ const s = buildState([buildEntry({ def: { hash: 'q', tableName: 'user' } })], R.setVersions([['user:x', 4]]));
115
+ const out = await runPure(persistVerifiedUser(env), {
116
+ state: s,
117
+ handlers: {
118
+ service: svc.handler,
119
+ 'local.execute': (e: any) => expect(e.vars.content0).toEqual({ email_verified: true, _00_rv: 4 }),
120
+ 'ssp.ingest': (e: any) => expect(e.records[0].record.junk).toBeUndefined(),
121
+ },
122
+ });
123
+ expect(out.state.dirty.has('q')).toBe(true);
124
+ const failing = await runPure(persistVerifiedUser(env), {
125
+ state: s,
126
+ handlers: {
127
+ service: svc.handler,
128
+ 'local.execute': () => {
129
+ throw new Error('locked');
130
+ },
131
+ },
132
+ });
133
+ expect(failing.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
134
+ });
135
+ });
@@ -0,0 +1,77 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type { Saga } from '../kernel/saga';
3
+ import { fx } from '../kernel/effects';
4
+ import * as R from '../state/reducers';
5
+ import { bucketIdForUser } from '../modules/ref-tables';
6
+ import type { SagaEnv } from '../query/env';
7
+ import { columnsFor } from '../query/env';
8
+ import * as sql from '../query/sql';
9
+ import { encodeRecordId } from '../utils/index';
10
+ import { cleanRecord } from '../utils/parser';
11
+ import { bucketSwitch, rebindQueries } from './bucket-switch.saga';
12
+
13
+ /**
14
+ * The signed-in principal changed (sign-in, sign-out, boot verification).
15
+ * Identity first (routing and `$auth` for the local SSP), then the salt if
16
+ * the principal really changed, then the bucket, then the verified user row.
17
+ *
18
+ * The salt goes before the bucket because the switch re-keys every active
19
+ * query (`rebindQueries`) from the salt it finds. A query's remote id hashes
20
+ * the salt but not the principal, so re-keyed under the old salt it named the
21
+ * view the previous principal still held, and the SSP refused it as another
22
+ * identity's (409 `auth_mismatch`) until that view's TTL ran out. On whitepawn
23
+ * every impersonation start, stop and token lapse refused a tab's queries for
24
+ * the 10 minutes of that TTL, each refusal a failed sync round, so the rail
25
+ * read "Can't reach the server" the whole time.
26
+ */
27
+ export function* authFlip(env: SagaEnv, userId: string | null): Saga<void> {
28
+ const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
29
+ const access = (yield fx.service('auth.access')) as string | null;
30
+ yield fx.state.update(R.setIdentity({ userId }));
31
+ yield fx.service('ssp.setSessionAuth', authId, access);
32
+ const saltUserId = (yield fx.state.read((s) => s.saltUserId)) as string | null;
33
+ const resalted = authId !== saltUserId;
34
+ if (resalted) {
35
+ const salt = (yield fx.id('salt')) as string;
36
+ yield fx.state.update(R.setIdentity({ sessionId: salt, saltUserId: authId }));
37
+ yield fx.service('crdt.setSessionId', salt);
38
+ }
39
+ const target = bucketIdForUser(userId);
40
+ yield fx.service('hint.write', target);
41
+ yield fx.state.update(R.setIdentity({ pendingBucket: target }));
42
+ const current = (yield fx.service('local.currentBucketId')) as string;
43
+ const release = current === target ? null : ((yield fx.service('local.beginSwitch')) as () => void);
44
+ yield* bucketSwitch(env, target, release);
45
+ // No switch means nothing re-keyed the queries under the new salt.
46
+ if (resalted && current === target) {
47
+ yield* rebindQueries();
48
+ yield fx.dispatch({ type: 'EnsureRegistered' });
49
+ }
50
+ yield* persistVerifiedUser(env);
51
+ }
52
+
53
+ /**
54
+ * Write the user's own row, as the server just returned it, into the local
55
+ * store so `user` queries see `email_verified` and friends without waiting
56
+ * for membership. A field refresh, not a version claim.
57
+ */
58
+ export function* persistVerifiedUser(env: SagaEnv): Saga<void> {
59
+ const row = (yield fx.service('auth.currentUser')) as Record<string, unknown> | null;
60
+ if (!row || !(row.id instanceof RecordId) || Object.keys(row).length <= 1) return;
61
+ const rid = row.id as RecordId<string>;
62
+ const table = rid.table.name;
63
+ const columns = columnsFor(env, table);
64
+ if (!columns) return;
65
+ const id = encodeRecordId(rid);
66
+ const version = ((yield fx.state.read((s) => s.versions.get(id))) as number | undefined) || 1;
67
+ const cleaned = cleanRecord(columns as never, row);
68
+ const { id: _id, ...content } = cleaned;
69
+ try {
70
+ const tx = sql.upsertBodiesTx([{ id: rid, content: { ...content, _00_rv: version } }]);
71
+ yield fx.local.execute(tx.query, tx.vars);
72
+ yield fx.ssp.ingest([{ table, op: 'UPDATE', id, record: { ...cleaned, _00_rv: version } }]);
73
+ yield fx.state.update(R.compose(R.setVersions([[id, version]]), R.markTableDirty(table)));
74
+ } catch (error) {
75
+ yield fx.emit({ type: 'log', level: 'warn', message: 'could not persist the verified user row', data: { error } });
76
+ }
77
+ }
@@ -0,0 +1,203 @@
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
+ 'salt.take',
38
+ 'crdt.setSessionId',
39
+ 'auth.access',
40
+ 'ssp.setSessionAuth',
41
+ 'window.attach',
42
+ 'features.init',
43
+ 'releases.init',
44
+ ]);
45
+ expect(svc.calls[1]).toEqual(['local.connect', ['u1']]);
46
+ expect(out.state).toMatchObject({
47
+ bucketId: 'u1',
48
+ userId: 'user:u1',
49
+ saltUserId: 'user:u1',
50
+ sessionId: 'salt-1',
51
+ ownerSecret: 'owner-2',
52
+ localReady: true,
53
+ });
54
+ expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'AdoptPendingWrites', 'LifecycleTick', 'StartRemote']);
55
+ // The view index is loaded before anything can paint; the first GC waits out the boot.
56
+ expect(out.state.views).toEqual(new Map([['k', ['t:1']]]));
57
+ expect(out.timers.get('gc')).toEqual({ ms: 30_000, event: { type: 'GcTick' } });
58
+ expect(svc.names()).not.toContain('remote.connect');
59
+ });
60
+ it('a reload keeps the salt its tab left for the same principal, and the owner secret its views answer to', async () => {
61
+ const svc = fakeServices({
62
+ 'hint.read': () => 'u1',
63
+ 'auth.restoreSession': async () => 'user:u1',
64
+ 'auth.sessionAuthId': () => 'user:u1',
65
+ 'salt.take': () => ({ salt: 'kept-salt', owner: 'kept-owner' }),
66
+ });
67
+ const out = await runPure(boot(env, { sharedTabs: false }), { handlers: { service: svc.handler, 'local.query': () => [] } });
68
+ expect(svc.calls).toContainEqual(['salt.take', ['user:u1']]);
69
+ expect(svc.calls).toContainEqual(['crdt.setSessionId', ['kept-salt']]);
70
+ expect(out.state).toMatchObject({ sessionId: 'kept-salt', saltUserId: 'user:u1', ownerSecret: 'kept-owner' });
71
+ expect(out.log.filter((e) => e.kind === 'id')).toEqual([]);
72
+ });
73
+ it('a salt left by a page that kept no owner secret gets a fresh one', async () => {
74
+ const svc = fakeServices({
75
+ 'hint.read': () => null,
76
+ 'auth.restoreSession': async () => null,
77
+ 'auth.sessionAuthId': () => null,
78
+ 'salt.take': () => ({ salt: 'kept-salt', owner: null }),
79
+ });
80
+ const out = await runPure(boot(env, { sharedTabs: false }), { handlers: { service: svc.handler, 'local.query': () => [] } });
81
+ expect(out.state).toMatchObject({ sessionId: 'kept-salt', ownerSecret: 'owner-1' });
82
+ });
83
+ it('shared tabs: takes the role from the coordinator, or falls back to solo; schemaless engines skip provisioning; anonymous skips session auth', async () => {
84
+ const leader = fakeServices({ 'hint.read': () => null, 'tabs.start': async () => 'leader', 'local.usesSurqlSchema': () => false, 'auth.restoreSession': async () => null, 'auth.sessionAuthId': () => null });
85
+ const out = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: leader.handler, 'local.query': () => [] } });
86
+ expect(out.state.tabRole).toBe('leader');
87
+ expect(out.state.bucketId).toBe('anon');
88
+ expect(leader.names()).not.toContain('local.connect');
89
+ expect(leader.names()).not.toContain('migrator.provision');
90
+ expect(leader.names()).not.toContain('ssp.setSessionAuth');
91
+ const fallback = fakeServices({
92
+ 'hint.read': () => 'x',
93
+ 'tabs.start': async () => {
94
+ throw new Error('no broker');
95
+ },
96
+ 'local.usesSurqlSchema': () => false,
97
+ 'auth.restoreSession': async () => null,
98
+ 'auth.sessionAuthId': () => null,
99
+ });
100
+ const fb = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: fallback.handler, 'local.query': () => [] } });
101
+ expect(fallback.calls).toContainEqual(['local.connect', ['x']]);
102
+ expect(fb.emitted).toContainEqual(expect.objectContaining({ level: 'warn' }));
103
+ });
104
+ });
105
+
106
+ describe('migrateWindowToView', () => {
107
+ it('copies legacy rows once, skips when _00_view has rows, tolerates errors and junk', async () => {
108
+ const upserts: any[] = [];
109
+ await runPure(migrateWindowToView(), {
110
+ handlers: {
111
+ 'local.query': (e: any) =>
112
+ e.sql.startsWith('SELECT count()')
113
+ ? [[]]
114
+ : [[{ id: '_00_window:k1', ids: [['t:1', 1]], confirmed: true, updatedAt: 5 }, { id: new RecordId('_00_window', 'k2'), ids: [] }, { id: 'x', ids: 'bad' }, { ids: [] }]],
115
+ 'local.upsert': (e: any) => void upserts.push(e),
116
+ },
117
+ });
118
+ expect(upserts).toEqual([
119
+ { kind: 'local.upsert', table: '_00_view', id: new RecordId('_00_view', 'k1'), data: { ids: [['t:1', 1]], confirmed: true, updatedAt: 5 }, mode: 'replace' },
120
+ { kind: 'local.upsert', table: '_00_view', id: new RecordId('_00_view', 'k2'), data: { ids: [], confirmed: false, updatedAt: 0 }, mode: 'replace' },
121
+ ]);
122
+ const skipped = await runPure(migrateWindowToView(), { handlers: { 'local.query': () => [[{ count: 3 }]] } });
123
+ expect(skipped.log.filter((e) => e.kind === 'local.query')).toHaveLength(1);
124
+ const failing = await runPure(migrateWindowToView(), {
125
+ handlers: {
126
+ 'local.query': () => {
127
+ throw new Error('no table');
128
+ },
129
+ },
130
+ });
131
+ expect(failing.emitted).toEqual([expect.objectContaining({ level: 'debug' })]);
132
+ });
133
+ });
134
+
135
+ describe('startRemote / primeCircuit / versionsPrimed / pageHide / warmBlobs', () => {
136
+ it('startRemote tolerates connect and auth failures and starts the loops', async () => {
137
+ const svc = fakeServices({
138
+ 'remote.connect': async () => {
139
+ throw new Error('offline');
140
+ },
141
+ 'auth.init': async () => {
142
+ throw new Error('401');
143
+ },
144
+ });
145
+ const out = await runPure(startRemote(), { handlers: { service: svc.handler } });
146
+ expect(svc.names()).toEqual(['remote.connect', 'supervisor.start', 'auth.init']);
147
+ expect(out.emitted.filter((e) => e.type === 'log')).toHaveLength(2);
148
+ expect(out.dispatched.map((d) => d.type)).toEqual(['EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
149
+ const ok = await runPure(startRemote(), { handlers: { service: fakeServices().handler } });
150
+ expect(ok.emitted).toEqual([]);
151
+ });
152
+ it('primeCircuit passes outbox ids and always flips primed', async () => {
153
+ const svc = fakeServices({ 'ssp.prime': async () => undefined });
154
+ const s = buildState([], R.outboxReplace([buildOutboxItem({ recordId: 'thing:1' })]));
155
+ const out = await runPure(primeCircuit(), { state: s, handlers: { service: svc.handler } });
156
+ expect(svc.calls).toEqual([['ssp.prime', [['thing:1']]]]);
157
+ expect(out.state.primed).toBe(true);
158
+ const failing = fakeServices({
159
+ 'ssp.prime': async () => {
160
+ throw new Error('snapshot');
161
+ },
162
+ });
163
+ const f = await runPure(primeCircuit(), { state: buildState(), handlers: { service: failing.handler } });
164
+ expect(f.state.primed).toBe(true);
165
+ expect(f.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
166
+ });
167
+ it('pageHide leaves the salt and owner secret for a reload before releasing views', async () => {
168
+ const svc = fakeServices();
169
+ const s = {
170
+ ...buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } })]),
171
+ sessionId: 'salt-1',
172
+ saltUserId: 'user:u1',
173
+ ownerSecret: 'owner-1',
174
+ };
175
+ await runPure(pageHide(), { state: s, handlers: { service: svc.handler } });
176
+ expect(svc.calls).toEqual([
177
+ ['salt.leave', ['salt-1', 'user:u1', 'owner-1']],
178
+ ['remote.releaseViews', [[new RecordId('_00_query', 'a')]]],
179
+ ]);
180
+ });
181
+ it('versionsPrimed records versions; pageHide releases registered views; warmBlobs is best-effort', async () => {
182
+ const v = await runPure(versionsPrimed([['thing:1', 3]]), { state: buildState() });
183
+ expect(v.state.versions.get('thing:1')).toBe(3);
184
+ const svc = fakeServices();
185
+ const s = buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } }), buildEntry({ def: { hash: 'b' } })]);
186
+ await runPure(pageHide(), { state: s, handlers: { service: svc.handler } });
187
+ expect(svc.calls).toEqual([['remote.releaseViews', [[new RecordId('_00_query', 'a')]]]]);
188
+ const none = fakeServices();
189
+ await runPure(pageHide(), { state: buildState(), handlers: { service: none.handler } });
190
+ expect(none.calls).toEqual([]);
191
+ const blobs = fakeServices({
192
+ 'blobs.start': async () => {
193
+ throw new Error('opfs');
194
+ },
195
+ });
196
+ const w = await runPure(warmBlobs(), { state: { ...buildState(), bucketId: 'u9' }, handlers: { service: blobs.handler } });
197
+ expect(blobs.calls).toEqual([['blobs.start', ['u9']]]);
198
+ expect(w.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
199
+ const anon = fakeServices({ 'blobs.start': async () => undefined });
200
+ await runPure(warmBlobs(), { state: buildState(), handlers: { service: anon.handler } });
201
+ expect(anon.calls).toEqual([['blobs.start', ['anon']]]);
202
+ });
203
+ });
@@ -0,0 +1,146 @@
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 authId = (yield fx.service('auth.sessionAuthId')) as string | null;
47
+ // A reload keeps the salt its tab had, so registrations land on warm views,
48
+ // and the owner secret those views answer to.
49
+ const left = (yield fx.service('salt.take', authId)) as { salt: string; owner: string | null } | null;
50
+ const salt = (left?.salt ?? (yield fx.id('salt'))) as string;
51
+ const ownerSecret = (left?.owner ?? (yield fx.id('owner'))) as string;
52
+ yield fx.state.update(R.setIdentity({ sessionId: salt, userId: restoredUserId, saltUserId: authId, ownerSecret }));
53
+ yield fx.service('crdt.setSessionId', salt);
54
+ if (restoredUserId) yield fx.service('ssp.setSessionAuth', authId, (yield fx.service('auth.access')) as string | null);
55
+
56
+ yield* loadOutbox(env);
57
+ yield fx.service('window.attach');
58
+ yield fx.service('features.init');
59
+ yield fx.service('releases.init');
60
+ yield fx.dispatch({ type: 'LifecycleTick' });
61
+ yield fx.timer.set('gc', GC_BOOT_DELAY_MS, { type: 'GcTick' });
62
+ yield fx.state.update(R.setIdentity({ localReady: true }));
63
+ yield fx.dispatch({ type: 'StartRemote' });
64
+ }
65
+
66
+ /** One-time copy of the legacy `_00_window` rows into `_00_view`. */
67
+ export function* migrateWindowToView(): Saga<void> {
68
+ try {
69
+ const countRes = (yield fx.local.query(sql.countViewRows())) as unknown[];
70
+ const countRow = Array.isArray(countRes?.[0]) ? (countRes[0] as Array<{ count?: number }>)[0] : undefined;
71
+ if ((countRow?.count ?? 0) > 0) return;
72
+ const legacy = (yield fx.local.query(sql.readLegacyViewRows())) as unknown[];
73
+ const rows = Array.isArray(legacy?.[0]) ? (legacy[0] as Array<Record<string, unknown>>) : [];
74
+ for (const row of rows) {
75
+ const key = typeof row.id === 'string' ? row.id.slice(row.id.indexOf(':') + 1) : String((row.id as { id?: unknown })?.id ?? '');
76
+ if (!key || !Array.isArray(row.ids)) continue;
77
+ yield fx.local.upsert(sql.VIEW_TABLE, sql.viewRecordId(key), { ids: row.ids, confirmed: row.confirmed === true, updatedAt: row.updatedAt ?? 0 }, 'replace');
78
+ }
79
+ } catch (error) {
80
+ yield fx.emit({ type: 'log', level: 'debug', message: 'window -> view migration skipped', data: { error } });
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The network half of boot: connect, verify the restored session, then let
86
+ * membership, LIVE, the poll and the outbox start. Every step is best-effort.
87
+ */
88
+ export function* startRemote(): Saga<void> {
89
+ try {
90
+ yield fx.service('remote.connect');
91
+ } catch (error) {
92
+ yield fx.emit({ type: 'log', level: 'warn', message: 'remote connect failed; running from the local store', data: { error } });
93
+ }
94
+ yield fx.service('supervisor.start');
95
+ try {
96
+ yield fx.service('auth.init');
97
+ } catch (error) {
98
+ yield fx.emit({ type: 'log', level: 'warn', message: 'auth verification failed; keeping the restored session', data: { error } });
99
+ }
100
+ yield fx.dispatch({ type: 'EnsureRegistered' });
101
+ yield fx.dispatch({ type: 'LiveStart' });
102
+ yield fx.dispatch({ type: 'PollTick' });
103
+ yield fx.dispatch({ type: 'Drain' });
104
+ }
105
+
106
+ /** Fill the circuit from the local store; `primed` flips even on failure so fetches never hang. */
107
+ export function* primeCircuit(): Saga<void> {
108
+ const pending = (yield fx.state.read((s) => s.outbox.map((i) => i.recordId))) as string[];
109
+ try {
110
+ yield fx.service('ssp.prime', pending);
111
+ } catch (error) {
112
+ yield fx.emit({ type: 'log', level: 'warn', message: 'circuit prime failed; starting empty', data: { error } });
113
+ } finally {
114
+ yield fx.state.update(R.setIdentity({ primed: true }));
115
+ }
116
+ }
117
+
118
+ export function* versionsPrimed(entries: Array<readonly [string, number]>): Saga<void> {
119
+ yield fx.state.update(R.setVersions(entries));
120
+ }
121
+
122
+ /**
123
+ * The page is going away for good: leave the salt for a reload of this tab,
124
+ * then hand this tab's views back to the server. The release is deferred
125
+ * server-side (`fn::query::leave`), so a reload that re-registers within the
126
+ * grace finds its views still warm.
127
+ */
128
+ export function* pageHide(): Saga<void> {
129
+ const [salt, saltUserId, ownerSecret, ids] = (yield fx.state.read((s) => [
130
+ s.sessionId,
131
+ s.saltUserId,
132
+ s.ownerSecret,
133
+ [...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered').map((e) => e.def.id),
134
+ ])) as [string | null, string | null, string | null, unknown[]];
135
+ if (salt) yield fx.service('salt.leave', salt, saltUserId, ownerSecret);
136
+ if (ids.length > 0) yield fx.service('remote.releaseViews', ids);
137
+ }
138
+
139
+ export function* warmBlobs(): Saga<void> {
140
+ const bucket = ((yield fx.state.read((s) => s.bucketId)) as string | null) ?? ANON_USER_ID;
141
+ try {
142
+ yield fx.service('blobs.start', bucket);
143
+ } catch (error) {
144
+ yield fx.emit({ type: 'log', level: 'warn', message: 'blob cache failed to start', data: { error } });
145
+ }
146
+ }