@spooky-sync/core 0.0.1-canary.26 → 0.0.1-canary.261

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 (231) hide show
  1. package/AGENTS.md +78 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2355 -81
  4. package/dist/index.js +13247 -2595
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +331 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +793 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +187 -0
  23. package/src/boot/bucket-switch.saga.ts +131 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +96 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +386 -0
  35. package/src/client/sp00ky-client.test.ts +264 -0
  36. package/src/client/sp00ky-client.ts +507 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +61 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +161 -0
  44. package/src/kernel/events.ts +56 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +148 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +9 -2
  53. package/src/modules/auth/impersonation-banner.ts +112 -0
  54. package/src/modules/auth/impersonation.test.ts +282 -0
  55. package/src/modules/auth/impersonation.ts +179 -0
  56. package/src/modules/auth/index.ts +344 -27
  57. package/src/modules/crdt/crdt-field.ts +294 -0
  58. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  59. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  60. package/src/modules/crdt/index.ts +463 -0
  61. package/src/modules/crdt/loro-loader.ts +25 -0
  62. package/src/modules/devtools/flags.ts +349 -0
  63. package/src/modules/devtools/index.ts +529 -47
  64. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  65. package/src/modules/devtools/state-shape.test.ts +149 -0
  66. package/src/modules/devtools/storage-info.test.ts +79 -0
  67. package/src/modules/devtools/storage-info.ts +168 -0
  68. package/src/modules/devtools/versions.test.ts +74 -0
  69. package/src/modules/devtools/versions.ts +110 -0
  70. package/src/modules/feature-flag/index.test.ts +251 -0
  71. package/src/modules/feature-flag/index.ts +316 -0
  72. package/src/modules/ref-tables.test.ts +91 -0
  73. package/src/modules/ref-tables.ts +88 -0
  74. package/src/mutation/jobs.test.ts +25 -0
  75. package/src/mutation/jobs.ts +41 -0
  76. package/src/mutation/mutation-id.test.ts +37 -0
  77. package/src/mutation/mutation-id.ts +35 -0
  78. package/src/mutation/push.saga.test.ts +285 -0
  79. package/src/mutation/push.saga.ts +215 -0
  80. package/src/mutation/rows.test.ts +201 -0
  81. package/src/mutation/rows.ts +377 -0
  82. package/src/mutation/tray.saga.test.ts +63 -0
  83. package/src/mutation/tray.saga.ts +48 -0
  84. package/src/mutation/write.saga.test.ts +163 -0
  85. package/src/mutation/write.saga.ts +166 -0
  86. package/src/otel/index.ts +13 -10
  87. package/src/query/env.test.ts +23 -0
  88. package/src/query/env.ts +56 -0
  89. package/src/query/fetch.saga.test.ts +156 -0
  90. package/src/query/fetch.saga.ts +115 -0
  91. package/src/query/hash.test.ts +14 -0
  92. package/src/query/hash.ts +18 -0
  93. package/src/query/id-set-plan.test.ts +132 -0
  94. package/src/query/lifecycle.saga.test.ts +122 -0
  95. package/src/query/lifecycle.saga.ts +101 -0
  96. package/src/query/materialize.saga.test.ts +82 -0
  97. package/src/query/materialize.saga.ts +75 -0
  98. package/src/query/materialize.test.ts +50 -0
  99. package/src/query/materialize.ts +44 -0
  100. package/src/query/membership.saga.test.ts +317 -0
  101. package/src/query/membership.saga.ts +262 -0
  102. package/src/query/membership.test.ts +118 -0
  103. package/src/query/membership.ts +171 -0
  104. package/src/query/register.saga.test.ts +293 -0
  105. package/src/query/register.saga.ts +226 -0
  106. package/src/query/render-set.test.ts +36 -0
  107. package/src/query/render-set.ts +60 -0
  108. package/src/query/sql.test.ts +62 -0
  109. package/src/query/sql.ts +79 -0
  110. package/src/query/window-query.test.ts +70 -0
  111. package/src/query/window-query.ts +195 -0
  112. package/src/services/blobs/blob-cache.test.ts +359 -0
  113. package/src/services/blobs/blob-cache.ts +603 -0
  114. package/src/services/blobs/blob-manifest.ts +227 -0
  115. package/src/services/blobs/blob-store.test.ts +77 -0
  116. package/src/services/blobs/blob-store.ts +359 -0
  117. package/src/services/blobs/blob.fixture.ts +90 -0
  118. package/src/services/blobs/index.ts +70 -0
  119. package/src/services/database/cache-engine.ts +199 -0
  120. package/src/services/database/connection-supervisor.test.ts +318 -0
  121. package/src/services/database/connection-supervisor.ts +422 -0
  122. package/src/services/database/database.query-timeout.test.ts +140 -0
  123. package/src/services/database/database.responses.test.ts +43 -0
  124. package/src/services/database/database.ts +145 -62
  125. package/src/services/database/engine-factory.ts +33 -0
  126. package/src/services/database/errors.ts +34 -0
  127. package/src/services/database/events/index.ts +2 -1
  128. package/src/services/database/index.ts +7 -0
  129. package/src/services/database/local-migrator.ts +30 -27
  130. package/src/services/database/local.test.ts +64 -0
  131. package/src/services/database/local.ts +491 -67
  132. package/src/services/database/plan-render.test.ts +159 -0
  133. package/src/services/database/plan-render.ts +108 -0
  134. package/src/services/database/relation-resolver.test.ts +467 -0
  135. package/src/services/database/relation-resolver.ts +0 -0
  136. package/src/services/database/remote.ts +222 -15
  137. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  138. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  139. package/src/services/database/sqlite-cache-engine.ts +1366 -0
  140. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  141. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  142. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  143. package/src/services/database/sqlite-lock-verify.ts +45 -0
  144. package/src/services/database/sqlite-open.test.ts +150 -0
  145. package/src/services/database/sqlite-open.ts +164 -0
  146. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  147. package/src/services/database/sqlite-plan-sql.ts +153 -0
  148. package/src/services/database/sqlite-projection.test.ts +121 -0
  149. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  150. package/src/services/database/sqlite-select.test.ts +246 -0
  151. package/src/services/database/sqlite-select.ts +131 -0
  152. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  153. package/src/services/database/sqlite-transport.ts +224 -0
  154. package/src/services/database/sqlite-worker.ts +437 -0
  155. package/src/services/database/surql-translate.ts +416 -0
  156. package/src/services/database/surreal-cache-engine.ts +161 -0
  157. package/src/services/logger/index.ts +3 -2
  158. package/src/services/persistence/localstorage.ts +2 -2
  159. package/src/services/persistence/resilient.ts +11 -4
  160. package/src/services/persistence/surrealdb.ts +10 -10
  161. package/src/services/stream-processor/index.ts +796 -84
  162. package/src/services/stream-processor/permissions.test.ts +47 -0
  163. package/src/services/stream-processor/permissions.ts +53 -0
  164. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  165. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  166. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  167. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  168. package/src/services/stream-processor/wasm-types.ts +59 -3
  169. package/src/services/tabs/broker-client.ts +283 -0
  170. package/src/services/tabs/broker.test.ts +327 -0
  171. package/src/services/tabs/coordinator.test.ts +365 -0
  172. package/src/services/tabs/coordinator.ts +633 -0
  173. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  174. package/src/services/tabs/leader-locks.ts +75 -0
  175. package/src/services/tabs/protocol.ts +262 -0
  176. package/src/services/tabs/support.ts +36 -0
  177. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  178. package/src/sp00ky.ts +5 -0
  179. package/src/state/client-state.ts +191 -0
  180. package/src/state/lifecycle.test.ts +118 -0
  181. package/src/state/lifecycle.ts +86 -0
  182. package/src/state/reducers.test.ts +280 -0
  183. package/src/state/reducers.ts +488 -0
  184. package/src/state/selectors.test.ts +149 -0
  185. package/src/state/selectors.ts +184 -0
  186. package/src/sync/connection.saga.test.ts +86 -0
  187. package/src/sync/connection.saga.ts +85 -0
  188. package/src/sync/live.saga.test.ts +178 -0
  189. package/src/sync/live.saga.ts +107 -0
  190. package/src/sync/policy.health.test.ts +56 -0
  191. package/src/sync/policy.test.ts +659 -0
  192. package/src/sync/policy.ts +502 -0
  193. package/src/sync/poll.saga.test.ts +51 -0
  194. package/src/sync/poll.saga.ts +36 -0
  195. package/src/sync/tabs.saga.test.ts +74 -0
  196. package/src/sync/tabs.saga.ts +93 -0
  197. package/src/testing/adapters.ts +133 -0
  198. package/src/testing/build.ts +63 -0
  199. package/src/testing/fake-services.ts +34 -0
  200. package/src/testing/run-pure.test.ts +92 -0
  201. package/src/testing/run-pure.ts +128 -0
  202. package/src/testing/services.ts +17 -0
  203. package/src/types.ts +591 -14
  204. package/src/utils/blurhash.ts +90 -0
  205. package/src/utils/error-classification.test.ts +54 -0
  206. package/src/utils/error-classification.ts +25 -0
  207. package/src/utils/index.ts +79 -13
  208. package/src/utils/parser.test.ts +49 -120
  209. package/src/utils/parser.ts +32 -2
  210. package/src/utils/semver.test.ts +32 -0
  211. package/src/utils/semver.ts +30 -0
  212. package/src/utils/surql.ts +30 -18
  213. package/src/utils/utils.test.ts +2 -1
  214. package/src/utils/withRetry.test.ts +1 -1
  215. package/tsdown.config.ts +86 -1
  216. package/vitest.config.ts +48 -0
  217. package/src/modules/cache/index.ts +0 -241
  218. package/src/modules/cache/types.ts +0 -19
  219. package/src/modules/data/data.test.ts +0 -58
  220. package/src/modules/data/index.ts +0 -879
  221. package/src/modules/sync/engine.ts +0 -173
  222. package/src/modules/sync/events/index.ts +0 -77
  223. package/src/modules/sync/index.ts +0 -3
  224. package/src/modules/sync/queue/index.ts +0 -2
  225. package/src/modules/sync/queue/queue-down.ts +0 -89
  226. package/src/modules/sync/queue/queue-up.ts +0 -223
  227. package/src/modules/sync/scheduler.ts +0 -84
  228. package/src/modules/sync/sync.ts +0 -421
  229. package/src/modules/sync/utils.test.ts +0 -311
  230. package/src/modules/sync/utils.ts +0 -167
  231. package/src/spooky.ts +0 -395
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import {
4
+ decideMembershipOutcome,
5
+ dedupeRecordVersions,
6
+ isResolvedBefore,
7
+ metaFromRow,
8
+ parseViewRow,
9
+ snapshotFromSingle,
10
+ snapshotsFromBatch,
11
+ suspectHashes,
12
+ } from './membership';
13
+
14
+ describe('durable view row', () => {
15
+ it('parses and gates resolved-before', () => {
16
+ expect(parseViewRow(null)).toBeNull();
17
+ expect(parseViewRow({ ids: 'nope' })).toBeNull();
18
+ expect(parseViewRow({ ids: [['t:1', 1]], confirmed: true })).toEqual({ ids: [['t:1', 1]], confirmed: true });
19
+ expect(parseViewRow({ ids: [] })).toEqual({ ids: [], confirmed: false });
20
+ expect(isResolvedBefore(null)).toBe(false);
21
+ expect(isResolvedBefore({ ids: [], confirmed: false })).toBe(false);
22
+ expect(isResolvedBefore({ ids: [], confirmed: true })).toBe(true);
23
+ expect(isResolvedBefore({ ids: [['t:1', 1]], confirmed: false })).toBe(true);
24
+ });
25
+ });
26
+
27
+ describe('metaFromRow / dedupe', () => {
28
+ it('folds the row-count select', () => {
29
+ expect(metaFromRow(null)).toEqual({ present: false, rowCount: null, state: null });
30
+ expect(metaFromRow({ rowCount: 3, state: 'ready' })).toEqual({ present: true, rowCount: 3, state: 'ready' });
31
+ expect(metaFromRow({ rowCount: null, state: 'weird' })).toEqual({ present: true, rowCount: null, state: null });
32
+ expect(metaFromRow({ state: 'materializing' })).toEqual({ present: true, rowCount: null, state: 'materializing' });
33
+ });
34
+ it('keeps the highest version per id', () => {
35
+ const single: [string, number][] = [['t:1', 1]];
36
+ expect(dedupeRecordVersions(single)).toBe(single);
37
+ const clean: [string, number][] = [['t:1', 1], ['t:2', 1]];
38
+ expect(dedupeRecordVersions(clean)).toBe(clean);
39
+ expect(dedupeRecordVersions([['t:1', 1], ['t:1', 3], ['t:1', 2]])).toEqual([['t:1', 3]]);
40
+ });
41
+ });
42
+
43
+ describe('decideMembershipOutcome', () => {
44
+ const ready0 = { present: true, rowCount: 0, state: 'ready' as const };
45
+ it('non-empty or verified removal is always applied', () => {
46
+ expect(decideMembershipOutcome({ phase: 'cold', held: 0, remoteArray: [['t:1', 1]] })).toBe('applied');
47
+ expect(decideMembershipOutcome({ phase: 'live', held: 2, remoteArray: [], verifiedRemoval: true })).toBe('applied');
48
+ });
49
+ it('empty with no readable row: view-lost when holding, ignored when cold and empty', () => {
50
+ expect(decideMembershipOutcome({ phase: 'cold', held: 0, remoteArray: [] })).toBe('ignored');
51
+ expect(decideMembershipOutcome({ phase: 'cold', held: 0, remoteArray: [], meta: { present: false, rowCount: null, state: null } })).toBe('ignored');
52
+ expect(decideMembershipOutcome({ phase: 'cold', held: 2, remoteArray: [] })).toBe('view-lost');
53
+ expect(decideMembershipOutcome({ phase: 'cached', held: 0, remoteArray: [] })).toBe('view-lost');
54
+ expect(decideMembershipOutcome({ phase: 'live', held: 1, remoteArray: [], meta: { present: false, rowCount: null, state: null } })).toBe('view-lost');
55
+ });
56
+ it('empty with a present row: applied only when ready and zero rows', () => {
57
+ expect(decideMembershipOutcome({ phase: 'cold', held: 0, remoteArray: [], meta: ready0 })).toBe('applied');
58
+ expect(decideMembershipOutcome({ phase: 'cold', held: 0, remoteArray: [], meta: { present: true, rowCount: 0, state: null } })).toBe('applied');
59
+ expect(decideMembershipOutcome({ phase: 'cold', held: 0, remoteArray: [], meta: { present: true, rowCount: 0, state: 'materializing' } })).toBe('ignored');
60
+ expect(decideMembershipOutcome({ phase: 'live', held: 3, remoteArray: [], meta: { present: true, rowCount: 3, state: 'ready' } })).toBe('ignored');
61
+ expect(decideMembershipOutcome({ phase: 'live', held: 3, remoteArray: [], meta: { present: true, rowCount: null, state: 'ready' } })).toBe('ignored');
62
+ });
63
+ });
64
+
65
+ describe('snapshots', () => {
66
+ const q1 = new RecordId('_00_query', 'a');
67
+ const q2 = new RecordId('_00_query', 'b');
68
+ const out = (id: string) => new RecordId('thing', id);
69
+ it('single: edges, meta, children; null when the primary select is not an array', () => {
70
+ expect(snapshotFromSingle(null, null, null)).toBeNull();
71
+ const snap = snapshotFromSingle(
72
+ [{ out: out('1'), version: 1 }, { out: out('1'), version: 2 }],
73
+ { rowCount: 1, state: 'ready' },
74
+ [{ out: out('c'), version: 1 }]
75
+ );
76
+ expect(snap).toEqual({
77
+ primary: [['thing:1', 2]],
78
+ subquery: [['thing:c', 1]],
79
+ meta: { present: true, rowCount: 1, state: 'ready' },
80
+ });
81
+ expect(snapshotFromSingle([], null, null)!.subquery).toEqual([]);
82
+ });
83
+ it('batch: splits by hash and parent, fills meta, marks missing rows absent', () => {
84
+ const hashById = new Map([['_00_query:a', 'ha'], ['_00_query:b', 'hb']]);
85
+ expect(snapshotsFromBatch(null, null, hashById).size).toBe(0);
86
+ const snaps = snapshotsFromBatch(
87
+ [
88
+ { in: q1, out: out('1'), version: 1 },
89
+ { in: q1, out: out('1'), version: 1 },
90
+ { in: q1, out: out('c'), version: 1, parent: q1 },
91
+ { in: new RecordId('_00_query', 'zz'), out: out('9'), version: 1 },
92
+ ],
93
+ [{ id: q1, rowCount: 1, state: 'ready' }, null, { rowCount: 5 }, { id: new RecordId('_00_query', 'zz') }],
94
+ hashById
95
+ );
96
+ expect(snaps.get('ha')).toEqual({
97
+ primary: [['thing:1', 1]],
98
+ subquery: [['thing:c', 1]],
99
+ meta: { present: true, rowCount: 1, state: 'ready' },
100
+ });
101
+ expect(snaps.get('hb')).toEqual({ primary: [], subquery: [], meta: { present: false, rowCount: null, state: null } });
102
+ expect(snapshotsFromBatch([], null, hashById).get('ha')!.meta.present).toBe(false);
103
+ });
104
+ it('suspectHashes flags held queries with a missing row or a silently empty batch', () => {
105
+ const snaps = new Map([
106
+ ['missing', { primary: [], subquery: [], meta: { present: false, rowCount: null, state: null } }],
107
+ ['empty', { primary: [], subquery: [], meta: { present: true, rowCount: 4, state: 'ready' as const } }],
108
+ ['fine', { primary: [], subquery: [], meta: { present: true, rowCount: 0, state: 'ready' as const } }],
109
+ ['unheld', { primary: [], subquery: [], meta: { present: false, rowCount: null, state: null } }],
110
+ ]);
111
+ const held = new Map([['missing', 2], ['empty', 4], ['fine', 1], ['unheld', 0]]);
112
+ expect(suspectHashes(snaps, held, 0)).toEqual(['missing', 'empty']);
113
+ expect(suspectHashes(snaps, held, 1)).toEqual(['missing']);
114
+ const nullCount = new Map([['q', { primary: [], subquery: [], meta: { present: true, rowCount: null, state: 'ready' as const } }]]);
115
+ expect(suspectHashes(nullCount, new Map([['q', 3]]), 0)).toEqual([]);
116
+ expect(suspectHashes(nullCount, new Map(), 0)).toEqual([]);
117
+ });
118
+ });
@@ -0,0 +1,171 @@
1
+ import type { RecordId } from 'surrealdb';
2
+ import type { MembershipOutcome, RecordVersionArray, ServerViewMeta } from '../types';
3
+ import type { QueryPhase } from '../state/lifecycle';
4
+ import { encodeRecordId } from '../utils/index';
5
+
6
+ /** The durable `_00_view` row: the last server-confirmed membership. */
7
+ export interface DurableView {
8
+ ids: RecordVersionArray;
9
+ confirmed: boolean;
10
+ }
11
+
12
+ export function parseViewRow(row: unknown): DurableView | null {
13
+ if (!row || typeof row !== 'object') return null;
14
+ const ids = (row as { ids?: unknown }).ids;
15
+ if (!Array.isArray(ids)) return null;
16
+ return { ids: ids as RecordVersionArray, confirmed: (row as { confirmed?: unknown }).confirmed === true };
17
+ }
18
+
19
+ /**
20
+ * "Resolved before": the server answered this query on this device at some
21
+ * point. An unconfirmed empty row cannot be told apart from one written before
22
+ * the marker existed, so it does not count.
23
+ */
24
+ export const isResolvedBefore = (view: DurableView | null): boolean =>
25
+ view !== null && (view.ids.length > 0 || view.confirmed);
26
+
27
+ /** The `{ rowCount, state }` object the row-count selects return per row. */
28
+ export interface QueryMetaRow {
29
+ id?: RecordId<string>;
30
+ rowCount?: number | null;
31
+ state?: string | null;
32
+ }
33
+
34
+ export function metaFromRow(row: QueryMetaRow | null | undefined): ServerViewMeta {
35
+ if (!row || typeof row !== 'object') return { present: false, rowCount: null, state: null };
36
+ const state = row.state === 'materializing' || row.state === 'ready' ? row.state : null;
37
+ return { present: true, rowCount: typeof row.rowCount === 'number' ? row.rowCount : null, state };
38
+ }
39
+
40
+ /** Collapse duplicate `(id, version)` pairs to one per id, keeping the highest version. */
41
+ export function dedupeRecordVersions(pairs: RecordVersionArray): RecordVersionArray {
42
+ if (pairs.length < 2) return pairs;
43
+ const best = new Map<string, number>();
44
+ for (const [id, version] of pairs) {
45
+ const prev = best.get(id);
46
+ if (prev === undefined || version > prev) best.set(id, version);
47
+ }
48
+ if (best.size === pairs.length) return pairs;
49
+ return Array.from(best.entries());
50
+ }
51
+
52
+ export interface MembershipDecisionInput {
53
+ phase: QueryPhase;
54
+ /** Number of ids currently held as membership. */
55
+ held: number;
56
+ remoteArray: RecordVersionArray;
57
+ meta?: ServerViewMeta;
58
+ verifiedRemoval?: boolean;
59
+ }
60
+
61
+ /**
62
+ * Whether a server id-set may be applied. A NON-EMPTY set is always the
63
+ * server's answer. An EMPTY set is believed only when the server stands
64
+ * behind it: the `_00_query` row is present, `ready` (or pre-`state`), and
65
+ * reports zero rows. A missing row while we hold membership is a lost view.
66
+ */
67
+ export function decideMembershipOutcome(input: MembershipDecisionInput): MembershipOutcome {
68
+ const { phase, held, remoteArray, meta, verifiedRemoval } = input;
69
+ if (remoteArray.length > 0 || verifiedRemoval) return 'applied';
70
+ if (!meta || !meta.present) {
71
+ return held > 0 || phase !== 'cold' ? 'view-lost' : 'ignored';
72
+ }
73
+ const knownEmpty = meta.rowCount === 0 && meta.state !== 'materializing';
74
+ return knownEmpty ? 'applied' : 'ignored';
75
+ }
76
+
77
+ // ---- list_ref snapshots ------------------------------------------------------
78
+
79
+ /**
80
+ * A statement that did not answer (an ERR, or no result at all). Kept apart
81
+ * from a `null` result, which is the server saying "no such row": reading a
82
+ * failed statement as a missing row turned every timeout under load into a
83
+ * lost view and re-registered it, over and over (whitepawn, 2026-09-16).
84
+ */
85
+ export const FAILED: unique symbol = Symbol('statement failed');
86
+ export type Answer<T> = T | null | typeof FAILED;
87
+
88
+ export interface ListRefEdgeRow {
89
+ in: RecordId<string>;
90
+ out: RecordId<string>;
91
+ version: number;
92
+ parent?: unknown;
93
+ }
94
+
95
+ export interface ListRefSnapshot {
96
+ primary: RecordVersionArray;
97
+ subquery: RecordVersionArray;
98
+ meta: ServerViewMeta;
99
+ }
100
+
101
+ const toPairs = (rows: Array<{ out: RecordId<string>; version: number }> | null | undefined): RecordVersionArray =>
102
+ dedupeRecordVersions(Array.isArray(rows) ? rows.map((r) => [encodeRecordId(r.out), r.version]) : []);
103
+
104
+ /**
105
+ * Fold the single-query statement batch (edges, meta, children) into a
106
+ * snapshot. `null` when any of the three did not answer: half an answer is
107
+ * not one, and a missing meta would read as a lost view.
108
+ */
109
+ export function snapshotFromSingle(
110
+ items: Answer<Array<{ out: RecordId<string>; version: number }>>,
111
+ metaRow: Answer<QueryMetaRow>,
112
+ children: Answer<Array<{ out: RecordId<string>; version: number }>>
113
+ ): ListRefSnapshot | null {
114
+ if (!Array.isArray(items) || metaRow === FAILED || children === FAILED) return null;
115
+ return { primary: toPairs(items), subquery: toPairs(children), meta: metaFromRow(metaRow) };
116
+ }
117
+
118
+ /**
119
+ * Fold the many-query statement batch into one snapshot per hash. Every hash
120
+ * in `hashById` gets a snapshot; a query whose row did not come back reads
121
+ * `present: false`. Empty when either statement did not answer.
122
+ */
123
+ export function snapshotsFromBatch(
124
+ edges: Answer<ListRefEdgeRow[]>,
125
+ counts: Answer<Array<QueryMetaRow | null>>,
126
+ hashById: ReadonlyMap<string, string>
127
+ ): Map<string, ListRefSnapshot> {
128
+ const out = new Map<string, ListRefSnapshot>();
129
+ if (!Array.isArray(edges) || counts === FAILED) return out;
130
+ for (const hash of hashById.values()) {
131
+ out.set(hash, { primary: [], subquery: [], meta: { present: false, rowCount: null, state: null } });
132
+ }
133
+ for (const row of edges) {
134
+ const hash = hashById.get(encodeRecordId(row.in));
135
+ if (!hash) continue;
136
+ const snapshot = out.get(hash)!;
137
+ const pair: [string, number] = [encodeRecordId(row.out), row.version];
138
+ if (row.parent == null) snapshot.primary.push(pair);
139
+ else snapshot.subquery.push(pair);
140
+ }
141
+ for (const snapshot of out.values()) {
142
+ snapshot.primary = dedupeRecordVersions(snapshot.primary);
143
+ snapshot.subquery = dedupeRecordVersions(snapshot.subquery);
144
+ }
145
+ for (const count of Array.isArray(counts) ? counts : []) {
146
+ if (!count || !count.id) continue;
147
+ const hash = hashById.get(encodeRecordId(count.id));
148
+ if (!hash) continue;
149
+ out.get(hash)!.meta = metaFromRow(count);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * A batch that must not be believed at face value: a query we hold rows for
156
+ * whose `_00_query` row did not come back, or no edge at all while the server
157
+ * still reports rows for a held query. Such hashes are re-read one at a time.
158
+ */
159
+ export function suspectHashes(
160
+ snapshots: ReadonlyMap<string, ListRefSnapshot>,
161
+ heldCounts: ReadonlyMap<string, number>,
162
+ edgeCount: number
163
+ ): string[] {
164
+ const out: string[] = [];
165
+ for (const [hash, snapshot] of snapshots) {
166
+ const held = heldCounts.get(hash) ?? 0;
167
+ if (held === 0) continue;
168
+ if (!snapshot.meta.present || (edgeCount === 0 && (snapshot.meta.rowCount ?? 0) > 0)) out.push(hash);
169
+ }
170
+ return out;
171
+ }
@@ -0,0 +1,293 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure, sha256Hex } from '../testing/run-pure';
4
+ import { buildEntry, buildState } from '../testing/build';
5
+ import { defaultEnv } from './env';
6
+ import { queryHashInput } from './hash';
7
+ import { ensureRegistered, isNotAllowlisted, registerLocal, registerRemote } from './register.saga';
8
+ import * as R from '../state/reducers';
9
+ import { emptyState } from '../state/client-state';
10
+ import type { StatementResult } from '../kernel/effects';
11
+
12
+ const env = defaultEnv({ tables: [{ name: 'thing', columns: {} }] } as any);
13
+ const input = { tableName: 'thing', surql: 'SELECT * FROM thing', params: {}, ttl: '10m' as const };
14
+ const sspOk = () => ({ localArray: [['thing:9', 1]], timings: { parseMs: 1, planMs: 1, snapshotMs: 1, wallMs: 1 } });
15
+ const okStmt = (result: unknown): StatementResult => ({ status: 'OK', result });
16
+
17
+ describe('registerLocal', () => {
18
+ it('cold: no view row -> cold entry, SSP view, EnsureRegistered', async () => {
19
+ const state = { ...emptyState({ tabId: 't' }), sessionId: 'sess' };
20
+ const out = await runPure(registerLocal(env, input), {
21
+ state,
22
+ handlers: { 'local.getById': () => null, 'ssp.register': sspOk },
23
+ });
24
+ const hash = await sha256Hex(queryHashInput(input, 'sess'));
25
+ expect(out.result).toBe(hash);
26
+ const entry = out.state.queries.get(hash)!;
27
+ expect(entry.lifecycle.phase).toBe('cold');
28
+ expect(entry.remoteArray).toEqual([]);
29
+ expect(entry.localArray).toEqual([['thing:9', 1]]);
30
+ expect(entry.def.ttlMs).toBe(600_000);
31
+ expect(entry.telemetry.registrationTimings.wallMs).toBe(1);
32
+ expect(out.state.dirty.has(hash)).toBe(true);
33
+ expect(out.state.registering.size).toBe(0);
34
+ expect(out.dispatched).toEqual([{ type: 'EnsureRegistered' }]);
35
+ const reg = out.log.find((e) => e.kind === 'ssp.register');
36
+ expect(reg).toMatchObject({ plan: { queryHash: hash, tableName: 'thing', ttl: '10m' } });
37
+ });
38
+
39
+ it('cached: a durable view row seeds membership; unconfirmed empty stays cold; a failing read is cold', async () => {
40
+ const cached = await runPure(registerLocal(env, input), {
41
+ handlers: { 'local.getById': () => ({ ids: [['thing:1', 2]], confirmed: false }), 'ssp.register': sspOk },
42
+ });
43
+ const e1 = cached.state.queries.get(cached.result)!;
44
+ expect(e1.lifecycle.phase).toBe('cached');
45
+ expect(e1.remoteArray).toEqual([['thing:1', 2]]);
46
+ const confirmedEmpty = await runPure(registerLocal(env, input), {
47
+ handlers: { 'local.getById': () => ({ ids: [], confirmed: true }), 'ssp.register': sspOk },
48
+ });
49
+ expect(confirmedEmpty.state.queries.get(confirmedEmpty.result)!.lifecycle.phase).toBe('cached');
50
+ const unconfirmed = await runPure(registerLocal(env, input), {
51
+ handlers: { 'local.getById': () => ({ ids: [], confirmed: false }), 'ssp.register': sspOk },
52
+ });
53
+ expect(unconfirmed.state.queries.get(unconfirmed.result)!.lifecycle.phase).toBe('cold');
54
+ const failing = await runPure(registerLocal(env, input), {
55
+ handlers: {
56
+ 'local.getById': () => {
57
+ throw new Error('no table');
58
+ },
59
+ 'ssp.register': sspOk,
60
+ },
61
+ });
62
+ expect(failing.state.queries.get(failing.result)!.lifecycle.phase).toBe('cold');
63
+ });
64
+
65
+ it('dedupes: active returns immediately, pending waits for the other registration', async () => {
66
+ const hash = await sha256Hex(queryHashInput(input, null));
67
+ const active = buildState([buildEntry({ def: { hash } })]);
68
+ const a = await runPure(registerLocal(env, input), { state: active });
69
+ expect(a.result).toBe(hash);
70
+ expect(a.log.filter((e) => e.kind === 'ssp.register')).toHaveLength(0);
71
+ const pending = R.beginRegistering(hash)(emptyState({ tabId: 't' }));
72
+ const p = await runPure(registerLocal(env, input), {
73
+ state: pending,
74
+ handlers: { 'state.wait': (_e, ctx) => void (ctx.state = R.endRegistering(hash)(ctx.state)) },
75
+ });
76
+ expect(p.result).toBe(hash);
77
+ expect(p.log.some((e) => e.kind === 'state.wait')).toBe(true);
78
+ });
79
+
80
+ it('a failing SSP registration clears the in-flight marker and rethrows', async () => {
81
+ await expect(
82
+ runPure(registerLocal(env, input), {
83
+ handlers: {
84
+ 'local.getById': () => null,
85
+ 'ssp.register': () => {
86
+ throw new Error('wasm down');
87
+ },
88
+ },
89
+ })
90
+ ).rejects.toThrow('wasm down');
91
+ });
92
+ });
93
+
94
+ describe('ensureRegistered', () => {
95
+ it('dispatches RegisterRemote for every unregistered query only', async () => {
96
+ const s = buildState([buildEntry({ def: { hash: 'a' } }), buildEntry({ def: { hash: 'b' }, lifecycle: { remote: 'registered' } })]);
97
+ const out = await runPure(ensureRegistered(env), { state: s });
98
+ expect(out.dispatched).toEqual([{ type: 'RegisterRemote', hash: 'a' }]);
99
+ const none = await runPure(ensureRegistered(env), { state: buildState() });
100
+ expect(none.dispatched).toEqual([]);
101
+ });
102
+ it('with requireAuth: probes $auth.id, retries on a timer, gives up after the budget, skips when anonymous', async () => {
103
+ const s = { ...buildState([buildEntry({ def: { hash: 'a' } })]), userId: 'user:1' };
104
+ const authed = await runPure(ensureRegistered(env, { requireAuth: true }), {
105
+ state: s,
106
+ handlers: { 'remote.query': () => [okStmt('user:1')] },
107
+ });
108
+ expect(authed.dispatched).toEqual([{ type: 'RegisterRemote', hash: 'a' }]);
109
+ const notYet = await runPure(ensureRegistered(env, { requireAuth: true, attempt: 2 }), {
110
+ state: s,
111
+ handlers: { 'remote.query': () => [okStmt(null)] },
112
+ });
113
+ expect(notYet.dispatched).toEqual([]);
114
+ expect(notYet.timers.get('ensure-registered')).toEqual({ ms: 500, event: { type: 'EnsureRegistered', requireAuth: true, attempt: 3 } });
115
+ const thrown = await runPure(ensureRegistered(env, { requireAuth: true }), {
116
+ state: s,
117
+ handlers: {
118
+ 'remote.query': () => {
119
+ throw new Error('offline');
120
+ },
121
+ },
122
+ });
123
+ expect(thrown.timers.has('ensure-registered')).toBe(true);
124
+ const exhausted = await runPure(ensureRegistered(env, { requireAuth: true, attempt: 9 }), {
125
+ state: s,
126
+ handlers: { 'remote.query': () => [okStmt(null)] },
127
+ });
128
+ expect(exhausted.timers.size).toBe(0);
129
+ expect(exhausted.emitted).toEqual([expect.objectContaining({ type: 'log', level: 'warn' })]);
130
+ const anon = await runPure(ensureRegistered(env, { requireAuth: true }), { state: { ...s, userId: null } });
131
+ expect(anon.dispatched).toEqual([{ type: 'RegisterRemote', hash: 'a' }]);
132
+ });
133
+ });
134
+
135
+ describe('registerRemote', () => {
136
+ const entry = buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'cold' } });
137
+ const happy = () => [
138
+ okStmt(null),
139
+ okStmt([{ out: new RecordId('thing', '1'), version: 1 }]),
140
+ okStmt({ rowCount: 1, state: 'ready' }),
141
+ okStmt([{ out: new RecordId('child', 'c'), version: 1 }]),
142
+ ];
143
+ it('registers, applies membership + children, flips registered, fetch-begin/end balanced', async () => {
144
+ const out = await runPure(registerRemote(env, 'a'), {
145
+ state: buildState([entry]),
146
+ handlers: { 'remote.query': happy, 'local.upsert': () => undefined },
147
+ });
148
+ const e = out.state.queries.get('a')!;
149
+ expect(e.lifecycle).toMatchObject({ phase: 'live', remote: 'registered', fetchDepth: 0 });
150
+ expect(e.remoteArray).toEqual([['thing:1', 1]]);
151
+ expect(e.subqueryRemoteArray).toEqual([['child:c', 1]]);
152
+ expect(e.serverState).toBe('ready');
153
+ const req = out.log.find((x) => x.kind === 'remote.query') as any;
154
+ expect(req.sql.startsWith('fn::query::register($config);')).toBe(true);
155
+ expect(req.vars.config).toMatchObject({ surql: entry.def.surql, ttl: '10m' });
156
+ expect(out.dispatched).toEqual([{ type: 'FetchRows' }, { type: 'FetchRows' }, { type: 'SyncOutcome', ok: true }]);
157
+ expect(out.emitted).toContainEqual({ type: 'query:authority', hash: 'a', known: true });
158
+ });
159
+ it('guards: missing entry, already registered / failed, registering without retry', async () => {
160
+ const none = await runPure(registerRemote(env, 'zzz'), { state: buildState([]) });
161
+ expect(none.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
162
+ for (const remote of ['registered', 'failed', 'registering'] as const) {
163
+ const out = await runPure(registerRemote(env, 'a'), { state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote } })]) });
164
+ expect(out.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
165
+ }
166
+ const retry = await runPure(registerRemote(env, 'a', true), {
167
+ state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registering' } })]),
168
+ handlers: { 'remote.query': happy, 'local.upsert': () => undefined },
169
+ });
170
+ expect(retry.state.queries.get('a')!.lifecycle.remote).toBe('registered');
171
+ });
172
+ it('materializing answer schedules the re-read ladder', async () => {
173
+ const out = await runPure(registerRemote(env, 'a'), {
174
+ state: buildState([entry]),
175
+ handlers: { 'remote.query': () => [okStmt(null), okStmt([]), okStmt({ rowCount: 3, state: 'materializing' }), okStmt([])] },
176
+ });
177
+ expect(out.state.membershipDirty.has('a')).toBe(true);
178
+ expect(out.state.membershipReread.get('a')).toBe(1);
179
+ expect(out.timers.get('membership')).toEqual({ ms: 150, event: { type: 'ReadDirtyMembership' } });
180
+ expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ phase: 'cold', remote: 'registered' });
181
+ });
182
+ it('errors: transport failure backs off, statement errors count as failures, budget exhausts to failed', async () => {
183
+ const boom = () => {
184
+ throw new Error('socket closed');
185
+ };
186
+ const first = await runPure(registerRemote(env, 'a'), { state: buildState([entry]), handlers: { 'remote.query': boom } });
187
+ expect(first.state.queries.get('a')!.lifecycle).toMatchObject({ remote: 'registering', fetchDepth: 0 });
188
+ expect(first.state.queries.get('a')!.registerAttempts).toBe(1);
189
+ expect(first.timers.get('register:a')).toEqual({ ms: 1000, event: { type: 'RegisterRemote', hash: 'a', retry: true } });
190
+ expect(first.dispatched).toContainEqual({ type: 'SyncOutcome', ok: false, error: new Error('socket closed') });
191
+ const errStmt = await runPure(registerRemote(env, 'a'), {
192
+ state: buildState([entry]),
193
+ handlers: { 'remote.query': () => [{ status: 'ERR', error: 'denied' }] },
194
+ });
195
+ expect(errStmt.state.queries.get('a')!.registerAttempts).toBe(1);
196
+ // The register statement answered but the read-back did not: the query
197
+ // IS registered, and its membership is read again rather than guessed.
198
+ for (const readBack of [
199
+ [okStmt(null), { status: 'ERR', error: 'no table' }, okStmt(null), okStmt(null)],
200
+ [okStmt(null), okStmt('nope'), okStmt(null), okStmt(null)],
201
+ ] as StatementResult[][]) {
202
+ const out = await runPure(registerRemote(env, 'a'), { state: buildState([entry]), handlers: { 'remote.query': () => readBack } });
203
+ const e = out.state.queries.get('a')!;
204
+ expect(e.registerAttempts).toBe(0);
205
+ expect(e.lifecycle).toMatchObject({ remote: 'registered', fetchDepth: 0 });
206
+ expect(out.state.membershipDirty.has('a')).toBe(true);
207
+ expect(out.timers.get('membership')).toEqual({ ms: 50, event: { type: 'ReadDirtyMembership' } });
208
+ expect(out.dispatched).toContainEqual({ type: 'SyncOutcome', ok: false, error: 'registration read-back failed' });
209
+ }
210
+ const empty = await runPure(registerRemote(env, 'a'), { state: buildState([entry]), handlers: { 'remote.query': () => [] } });
211
+ expect(empty.state.queries.get('a')!.registerAttempts).toBe(1);
212
+ const last = await runPure(registerRemote(env, 'a'), {
213
+ state: buildState([buildEntry({ def: { hash: 'a' }, registerAttempts: 2 })]),
214
+ handlers: { 'remote.query': boom },
215
+ });
216
+ expect(last.state.queries.get('a')!.lifecycle.remote).toBe('failed');
217
+ expect(last.timers.size).toBe(0);
218
+ });
219
+ it('not allowlisted: 403/Forbidden/not_allowlisted fails after ONE attempt with an error log, no retry timer', async () => {
220
+ for (const text of ['HTTP 403 Forbidden', 'There was an error processing a remote HTTP request: {"error":"not_allowlisted"}', 'status 403']) {
221
+ const out = await runPure(registerRemote(env, 'a'), {
222
+ state: buildState([entry]),
223
+ handlers: { 'remote.query': () => [{ status: 'ERR', error: text }] },
224
+ });
225
+ const e = out.state.queries.get('a')!;
226
+ expect(e.lifecycle).toMatchObject({ remote: 'failed', phase: 'cold', fetchDepth: 0 });
227
+ expect(e.registerAttempts).toBe(0);
228
+ expect(out.timers.size).toBe(0);
229
+ expect(out.emitted).toContainEqual(
230
+ expect.objectContaining({
231
+ type: 'log',
232
+ level: 'error',
233
+ message: 'query refused: not allowlisted',
234
+ data: expect.objectContaining({ hash: 'a', table: entry.def.tableName, surql: entry.def.surql, params: [] }),
235
+ })
236
+ );
237
+ expect(out.dispatched).toContainEqual(expect.objectContaining({ type: 'SyncOutcome', ok: false }));
238
+ }
239
+ const thrown = await runPure(registerRemote(env, 'a'), {
240
+ state: buildState([entry]),
241
+ handlers: {
242
+ 'remote.query': () => {
243
+ throw new Error('Forbidden');
244
+ },
245
+ },
246
+ });
247
+ expect(thrown.state.queries.get('a')!.lifecycle.remote).toBe('failed');
248
+ expect(thrown.timers.size).toBe(0);
249
+ expect(isNotAllowlisted(new Error('socket closed'))).toBe(false);
250
+ expect(isNotAllowlisted('HTTP 4030')).toBe(false);
251
+ });
252
+
253
+ it('an ERR meta or children statement applies nothing and re-reads, instead of reading as a lost row', async () => {
254
+ const lostBefore = buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'view-lost' }, remoteArray: [['thing:1', 1]], viewLostCount: 1 });
255
+ for (const [meta, kids] of [
256
+ [{ status: 'ERR', error: 'meta' }, okStmt([])],
257
+ [okStmt({ rowCount: 1, state: 'ready' }), { status: 'ERR', error: 'kids' }],
258
+ ] as StatementResult[][]) {
259
+ const out = await runPure(registerRemote(env, 'a'), {
260
+ state: buildState([lostBefore]),
261
+ handlers: { 'remote.query': () => [okStmt(null), okStmt([{ out: new RecordId('thing', '1'), version: 1 }]), meta, kids] },
262
+ });
263
+ const e = out.state.queries.get('a')!;
264
+ expect(e.lifecycle).toMatchObject({ phase: 'view-lost', remote: 'registered' });
265
+ expect(e.viewLostCount).toBe(1);
266
+ expect(out.dispatched.filter((d) => d.type === 'EnsureRegistered')).toEqual([]);
267
+ expect(out.state.membershipDirty.has('a')).toBe(true);
268
+ }
269
+ });
270
+ it('stops when the entry disappears mid-flight', async () => {
271
+ const out = await runPure(registerRemote(env, 'a'), {
272
+ state: buildState([entry]),
273
+ handlers: {
274
+ 'remote.query': (_e, ctx) => {
275
+ ctx.state = R.removeQuery('a')(ctx.state);
276
+ return happy();
277
+ },
278
+ },
279
+ });
280
+ expect(out.state.queries.has('a')).toBe(false);
281
+ expect(out.dispatched).toEqual([]);
282
+ const gone = await runPure(registerRemote(env, 'a'), {
283
+ state: buildState([entry]),
284
+ handlers: {
285
+ 'remote.query': (_e, ctx) => {
286
+ ctx.state = R.removeQuery('a')(ctx.state);
287
+ throw new Error('x');
288
+ },
289
+ },
290
+ });
291
+ expect(gone.dispatched).toEqual([]);
292
+ });
293
+ });