@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,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,201 @@
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
+ /** One `_00_view` row as the view index and the GC see it. */
20
+ export interface ViewIndexRow {
21
+ key: string;
22
+ /** Members and subquery children: what the row vouches for. */
23
+ ids: string[];
24
+ /** Last time a session resolved the query (0 when the row predates the field). */
25
+ updatedAt: number;
26
+ }
27
+
28
+ const pairIds = (pairs: unknown): string[] =>
29
+ Array.isArray(pairs) ? pairs.filter((p): p is [string, number] => Array.isArray(p) && typeof p[0] === 'string').map((p) => p[0]) : [];
30
+
31
+ /** `SELECT * FROM _00_view` rows, keyed by view key; junk rows are skipped. */
32
+ export function parseViewIndex(rows: unknown): ViewIndexRow[] {
33
+ if (!Array.isArray(rows)) return [];
34
+ const out: ViewIndexRow[] = [];
35
+ for (const row of rows as Array<Record<string, unknown> | null>) {
36
+ if (!row || typeof row !== 'object' || !Array.isArray(row.ids)) continue;
37
+ const id = row.id;
38
+ const key = typeof id === 'string' ? id.slice(id.indexOf(':') + 1) : String((id as { id?: unknown } | undefined)?.id ?? '');
39
+ if (!key) continue;
40
+ out.push({
41
+ key,
42
+ ids: [...new Set([...pairIds(row.ids), ...pairIds(row.children)])],
43
+ updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0,
44
+ });
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * "Resolved before": the server answered this query on this device at some
51
+ * point. An unconfirmed empty row cannot be told apart from one written before
52
+ * the marker existed, so it does not count.
53
+ */
54
+ export const isResolvedBefore = (view: DurableView | null): boolean =>
55
+ view !== null && (view.ids.length > 0 || view.confirmed);
56
+
57
+ /** The `{ rowCount, state }` object the row-count selects return per row. */
58
+ export interface QueryMetaRow {
59
+ id?: RecordId<string>;
60
+ rowCount?: number | null;
61
+ state?: string | null;
62
+ }
63
+
64
+ export function metaFromRow(row: QueryMetaRow | null | undefined): ServerViewMeta {
65
+ if (!row || typeof row !== 'object') return { present: false, rowCount: null, state: null };
66
+ const state = row.state === 'materializing' || row.state === 'ready' ? row.state : null;
67
+ return { present: true, rowCount: typeof row.rowCount === 'number' ? row.rowCount : null, state };
68
+ }
69
+
70
+ /** Collapse duplicate `(id, version)` pairs to one per id, keeping the highest version. */
71
+ export function dedupeRecordVersions(pairs: RecordVersionArray): RecordVersionArray {
72
+ if (pairs.length < 2) return pairs;
73
+ const best = new Map<string, number>();
74
+ for (const [id, version] of pairs) {
75
+ const prev = best.get(id);
76
+ if (prev === undefined || version > prev) best.set(id, version);
77
+ }
78
+ if (best.size === pairs.length) return pairs;
79
+ return Array.from(best.entries());
80
+ }
81
+
82
+ export interface MembershipDecisionInput {
83
+ phase: QueryPhase;
84
+ /** Number of ids currently held as membership. */
85
+ held: number;
86
+ remoteArray: RecordVersionArray;
87
+ meta?: ServerViewMeta;
88
+ verifiedRemoval?: boolean;
89
+ }
90
+
91
+ /**
92
+ * Whether a server id-set may be applied. A NON-EMPTY set is always the
93
+ * server's answer. An EMPTY set is believed only when the server stands
94
+ * behind it: the `_00_query` row is present, `ready` (or pre-`state`), and
95
+ * reports zero rows. A missing row while we hold membership is a lost view.
96
+ */
97
+ export function decideMembershipOutcome(input: MembershipDecisionInput): MembershipOutcome {
98
+ const { phase, held, remoteArray, meta, verifiedRemoval } = input;
99
+ if (remoteArray.length > 0 || verifiedRemoval) return 'applied';
100
+ if (!meta || !meta.present) {
101
+ return held > 0 || phase !== 'cold' ? 'view-lost' : 'ignored';
102
+ }
103
+ const knownEmpty = meta.rowCount === 0 && meta.state !== 'materializing';
104
+ return knownEmpty ? 'applied' : 'ignored';
105
+ }
106
+
107
+ // ---- list_ref snapshots ------------------------------------------------------
108
+
109
+ /**
110
+ * A statement that did not answer (an ERR, or no result at all). Kept apart
111
+ * from a `null` result, which is the server saying "no such row": reading a
112
+ * failed statement as a missing row turned every timeout under load into a
113
+ * lost view and re-registered it, over and over (whitepawn, 2026-09-16).
114
+ */
115
+ export const FAILED: unique symbol = Symbol('statement failed');
116
+ export type Answer<T> = T | null | typeof FAILED;
117
+
118
+ export interface ListRefEdgeRow {
119
+ in: RecordId<string>;
120
+ out: RecordId<string>;
121
+ version: number;
122
+ parent?: unknown;
123
+ }
124
+
125
+ export interface ListRefSnapshot {
126
+ primary: RecordVersionArray;
127
+ subquery: RecordVersionArray;
128
+ meta: ServerViewMeta;
129
+ }
130
+
131
+ const toPairs = (rows: Array<{ out: RecordId<string>; version: number }> | null | undefined): RecordVersionArray =>
132
+ dedupeRecordVersions(Array.isArray(rows) ? rows.map((r) => [encodeRecordId(r.out), r.version]) : []);
133
+
134
+ /**
135
+ * Fold the single-query statement batch (edges, meta, children) into a
136
+ * snapshot. `null` when any of the three did not answer: half an answer is
137
+ * not one, and a missing meta would read as a lost view.
138
+ */
139
+ export function snapshotFromSingle(
140
+ items: Answer<Array<{ out: RecordId<string>; version: number }>>,
141
+ metaRow: Answer<QueryMetaRow>,
142
+ children: Answer<Array<{ out: RecordId<string>; version: number }>>
143
+ ): ListRefSnapshot | null {
144
+ if (!Array.isArray(items) || metaRow === FAILED || children === FAILED) return null;
145
+ return { primary: toPairs(items), subquery: toPairs(children), meta: metaFromRow(metaRow) };
146
+ }
147
+
148
+ /**
149
+ * Fold the many-query statement batch into one snapshot per hash. Every hash
150
+ * in `hashById` gets a snapshot; a query whose row did not come back reads
151
+ * `present: false`. Empty when either statement did not answer.
152
+ */
153
+ export function snapshotsFromBatch(
154
+ edges: Answer<ListRefEdgeRow[]>,
155
+ counts: Answer<Array<QueryMetaRow | null>>,
156
+ hashById: ReadonlyMap<string, string>
157
+ ): Map<string, ListRefSnapshot> {
158
+ const out = new Map<string, ListRefSnapshot>();
159
+ if (!Array.isArray(edges) || counts === FAILED) return out;
160
+ for (const hash of hashById.values()) {
161
+ out.set(hash, { primary: [], subquery: [], meta: { present: false, rowCount: null, state: null } });
162
+ }
163
+ for (const row of edges) {
164
+ const hash = hashById.get(encodeRecordId(row.in));
165
+ if (!hash) continue;
166
+ const snapshot = out.get(hash)!;
167
+ const pair: [string, number] = [encodeRecordId(row.out), row.version];
168
+ if (row.parent == null) snapshot.primary.push(pair);
169
+ else snapshot.subquery.push(pair);
170
+ }
171
+ for (const snapshot of out.values()) {
172
+ snapshot.primary = dedupeRecordVersions(snapshot.primary);
173
+ snapshot.subquery = dedupeRecordVersions(snapshot.subquery);
174
+ }
175
+ for (const count of Array.isArray(counts) ? counts : []) {
176
+ if (!count || !count.id) continue;
177
+ const hash = hashById.get(encodeRecordId(count.id));
178
+ if (!hash) continue;
179
+ out.get(hash)!.meta = metaFromRow(count);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /**
185
+ * A batch that must not be believed at face value: a query we hold rows for
186
+ * whose `_00_query` row did not come back, or no edge at all while the server
187
+ * still reports rows for a held query. Such hashes are re-read one at a time.
188
+ */
189
+ export function suspectHashes(
190
+ snapshots: ReadonlyMap<string, ListRefSnapshot>,
191
+ heldCounts: ReadonlyMap<string, number>,
192
+ edgeCount: number
193
+ ): string[] {
194
+ const out: string[] = [];
195
+ for (const [hash, snapshot] of snapshots) {
196
+ const held = heldCounts.get(hash) ?? 0;
197
+ if (held === 0) continue;
198
+ if (!snapshot.meta.present || (edgeCount === 0 && (snapshot.meta.rowCount ?? 0) > 0)) out.push(hash);
199
+ }
200
+ return out;
201
+ }
@@ -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
+ });