@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.321

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 (261) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2034 -137
  4. package/dist/index.js +15236 -2467
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +733 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +365 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +379 -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 +1821 -38
  19. package/dist/worker-edges.js +470 -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 +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 +180 -0
  32. package/src/boot/boot.saga.ts +142 -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.test.ts +60 -0
  46. package/src/client/services.ts +418 -0
  47. package/src/client/sp00ky-client.test.ts +303 -0
  48. package/src/client/sp00ky-client.ts +586 -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 +163 -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 +501 -0
  62. package/src/live/index.ts +1039 -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 +169 -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 +466 -19
  72. package/src/modules/crdt/crdt-field.ts +294 -0
  73. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  74. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  75. package/src/modules/crdt/index.ts +463 -0
  76. package/src/modules/crdt/loro-loader.ts +25 -0
  77. package/src/modules/devtools/flags.ts +349 -0
  78. package/src/modules/devtools/index.ts +746 -95
  79. package/src/modules/devtools/mutations.test.ts +92 -0
  80. package/src/modules/devtools/mutations.ts +160 -0
  81. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  82. package/src/modules/devtools/state-shape.test.ts +263 -0
  83. package/src/modules/devtools/storage-info.test.ts +79 -0
  84. package/src/modules/devtools/storage-info.ts +168 -0
  85. package/src/modules/devtools/versions.test.ts +74 -0
  86. package/src/modules/devtools/versions.ts +110 -0
  87. package/src/modules/feature-flag/index.test.ts +298 -0
  88. package/src/modules/feature-flag/index.ts +316 -0
  89. package/src/modules/ref-tables.test.ts +93 -0
  90. package/src/modules/ref-tables.ts +91 -0
  91. package/src/modules/web-push/index.test.ts +824 -0
  92. package/src/modules/web-push/index.ts +1027 -0
  93. package/src/mutation/jobs.test.ts +25 -0
  94. package/src/mutation/jobs.ts +41 -0
  95. package/src/mutation/mutation-id.test.ts +37 -0
  96. package/src/mutation/mutation-id.ts +35 -0
  97. package/src/mutation/pending-writes.integration.test.ts +175 -0
  98. package/src/mutation/push.saga.test.ts +285 -0
  99. package/src/mutation/push.saga.ts +217 -0
  100. package/src/mutation/rows.test.ts +231 -0
  101. package/src/mutation/rows.ts +427 -0
  102. package/src/mutation/tray.saga.test.ts +63 -0
  103. package/src/mutation/tray.saga.ts +48 -0
  104. package/src/mutation/write.saga.test.ts +316 -0
  105. package/src/mutation/write.saga.ts +268 -0
  106. package/src/otel/index.ts +10 -7
  107. package/src/pure.ts +66 -0
  108. package/src/push/types.ts +174 -0
  109. package/src/query/anon-query-isolation.integration.test.ts +337 -0
  110. package/src/query/env.test.ts +23 -0
  111. package/src/query/env.ts +56 -0
  112. package/src/query/fetch.saga.test.ts +199 -0
  113. package/src/query/fetch.saga.ts +120 -0
  114. package/src/query/hash.test.ts +14 -0
  115. package/src/query/hash.ts +18 -0
  116. package/src/query/id-set-plan.test.ts +132 -0
  117. package/src/query/lifecycle.saga.test.ts +274 -0
  118. package/src/query/lifecycle.saga.ts +159 -0
  119. package/src/query/list-ref-live.integration.test.ts +203 -0
  120. package/src/query/materialize.saga.test.ts +115 -0
  121. package/src/query/materialize.saga.ts +84 -0
  122. package/src/query/materialize.test.ts +58 -0
  123. package/src/query/materialize.ts +57 -0
  124. package/src/query/membership.saga.test.ts +350 -0
  125. package/src/query/membership.saga.ts +278 -0
  126. package/src/query/membership.test.ts +118 -0
  127. package/src/query/membership.ts +201 -0
  128. package/src/query/register.saga.test.ts +311 -0
  129. package/src/query/register.saga.ts +229 -0
  130. package/src/query/render-set.test.ts +47 -0
  131. package/src/query/render-set.ts +71 -0
  132. package/src/query/sql.test.ts +112 -0
  133. package/src/query/sql.ts +149 -0
  134. package/src/query/window-query.test.ts +70 -0
  135. package/src/query/window-query.ts +195 -0
  136. package/src/services/blobs/blob-cache.test.ts +359 -0
  137. package/src/services/blobs/blob-cache.ts +603 -0
  138. package/src/services/blobs/blob-manifest.ts +227 -0
  139. package/src/services/blobs/blob-store.test.ts +77 -0
  140. package/src/services/blobs/blob-store.ts +359 -0
  141. package/src/services/blobs/blob.fixture.ts +90 -0
  142. package/src/services/blobs/index.ts +70 -0
  143. package/src/services/database/cache-engine.ts +199 -0
  144. package/src/services/database/connection-supervisor.test.ts +318 -0
  145. package/src/services/database/connection-supervisor.ts +422 -0
  146. package/src/services/database/database.query-timeout.test.ts +140 -0
  147. package/src/services/database/database.responses.test.ts +43 -0
  148. package/src/services/database/database.ts +146 -61
  149. package/src/services/database/engine-factory.ts +33 -0
  150. package/src/services/database/errors.ts +34 -0
  151. package/src/services/database/events/index.ts +2 -1
  152. package/src/services/database/index.ts +7 -0
  153. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  154. package/src/services/database/local-migrator.integration.test.ts +110 -0
  155. package/src/services/database/local-migrator.ts +59 -16
  156. package/src/services/database/local.test.ts +64 -0
  157. package/src/services/database/local.ts +487 -63
  158. package/src/services/database/plan-render.test.ts +159 -0
  159. package/src/services/database/plan-render.ts +108 -0
  160. package/src/services/database/relation-resolver.test.ts +484 -0
  161. package/src/services/database/relation-resolver.ts +0 -0
  162. package/src/services/database/remote.ts +273 -6
  163. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  164. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  165. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  166. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  167. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  168. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  169. package/src/services/database/sqlite-lock-verify.ts +45 -0
  170. package/src/services/database/sqlite-open.test.ts +150 -0
  171. package/src/services/database/sqlite-open.ts +164 -0
  172. package/src/services/database/sqlite-plan-sql.test.ts +145 -0
  173. package/src/services/database/sqlite-plan-sql.ts +182 -0
  174. package/src/services/database/sqlite-projection.test.ts +121 -0
  175. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  176. package/src/services/database/sqlite-select.test.ts +261 -0
  177. package/src/services/database/sqlite-select.ts +131 -0
  178. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  179. package/src/services/database/sqlite-transport.ts +224 -0
  180. package/src/services/database/sqlite-worker.ts +437 -0
  181. package/src/services/database/surql-translate.ts +416 -0
  182. package/src/services/database/surreal-cache-engine.ts +161 -0
  183. package/src/services/logger/index.test.ts +132 -0
  184. package/src/services/logger/index.ts +189 -7
  185. package/src/services/persistence/localstorage.ts +2 -2
  186. package/src/services/persistence/resilient.ts +10 -3
  187. package/src/services/persistence/surrealdb.ts +4 -4
  188. package/src/services/stream-processor/index.ts +783 -68
  189. package/src/services/stream-processor/permissions.test.ts +47 -0
  190. package/src/services/stream-processor/permissions.ts +53 -0
  191. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  192. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  193. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  194. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  195. package/src/services/stream-processor/wasm-types.ts +58 -2
  196. package/src/services/tabs/broker-client.ts +283 -0
  197. package/src/services/tabs/broker.test.ts +327 -0
  198. package/src/services/tabs/coordinator.test.ts +386 -0
  199. package/src/services/tabs/coordinator.ts +638 -0
  200. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  201. package/src/services/tabs/leader-locks.ts +75 -0
  202. package/src/services/tabs/protocol.ts +262 -0
  203. package/src/services/tabs/support.ts +36 -0
  204. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  205. package/src/sp00ky.ts +5 -395
  206. package/src/state/client-state.ts +200 -0
  207. package/src/state/lifecycle.test.ts +141 -0
  208. package/src/state/lifecycle.ts +99 -0
  209. package/src/state/reducers.test.ts +304 -0
  210. package/src/state/reducers.ts +526 -0
  211. package/src/state/selectors.test.ts +175 -0
  212. package/src/state/selectors.ts +222 -0
  213. package/src/sw/index.test.ts +582 -0
  214. package/src/sw/index.ts +607 -0
  215. package/src/sw/store.ts +96 -0
  216. package/src/sync/connection.saga.test.ts +97 -0
  217. package/src/sync/connection.saga.ts +89 -0
  218. package/src/sync/live.saga.test.ts +178 -0
  219. package/src/sync/live.saga.ts +107 -0
  220. package/src/sync/policy.health.test.ts +56 -0
  221. package/src/sync/policy.test.ts +663 -0
  222. package/src/sync/policy.ts +515 -0
  223. package/src/sync/poll.saga.test.ts +51 -0
  224. package/src/sync/poll.saga.ts +36 -0
  225. package/src/sync/tabs.saga.test.ts +78 -0
  226. package/src/sync/tabs.saga.ts +96 -0
  227. package/src/testing/adapters.ts +133 -0
  228. package/src/testing/build.ts +63 -0
  229. package/src/testing/fake-services.ts +48 -0
  230. package/src/testing/run-pure.test.ts +92 -0
  231. package/src/testing/run-pure.ts +128 -0
  232. package/src/testing/services.ts +17 -0
  233. package/src/types.ts +624 -10
  234. package/src/utils/blurhash.ts +90 -0
  235. package/src/utils/edges.ts +39 -0
  236. package/src/utils/error-classification.test.ts +54 -0
  237. package/src/utils/error-classification.ts +25 -0
  238. package/src/utils/index.ts +78 -12
  239. package/src/utils/parser.test.ts +67 -102
  240. package/src/utils/parser.ts +64 -2
  241. package/src/utils/semver.test.ts +32 -0
  242. package/src/utils/semver.ts +30 -0
  243. package/src/utils/sha256.ts +15 -0
  244. package/src/utils/surql.ts +30 -18
  245. package/src/utils/utils.test.ts +3 -2
  246. package/tsdown.config.ts +117 -3
  247. package/vitest.config.ts +48 -0
  248. package/src/modules/cache/index.ts +0 -241
  249. package/src/modules/cache/types.ts +0 -19
  250. package/src/modules/data/data.test.ts +0 -58
  251. package/src/modules/data/index.ts +0 -879
  252. package/src/modules/sync/engine.ts +0 -173
  253. package/src/modules/sync/events/index.ts +0 -77
  254. package/src/modules/sync/index.ts +0 -3
  255. package/src/modules/sync/queue/index.ts +0 -2
  256. package/src/modules/sync/queue/queue-down.ts +0 -89
  257. package/src/modules/sync/queue/queue-up.ts +0 -223
  258. package/src/modules/sync/scheduler.ts +0 -84
  259. package/src/modules/sync/sync.ts +0 -421
  260. package/src/modules/sync/utils.test.ts +0 -311
  261. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,274 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure } from '../testing/run-pure';
4
+ import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
5
+ import * as R from '../state/reducers';
6
+ import { defaultEnv } from './env';
7
+ import { ackPrune, evictQuery, gcTick, lifecycleTick, loadViews } from './lifecycle.saga';
8
+ import type { StatementResult } from '../kernel/effects';
9
+
10
+ const env = defaultEnv({ tables: [] } as any);
11
+ const ok = (result: unknown): StatementResult => ({ status: 'OK', result });
12
+
13
+ describe('lifecycleTick', () => {
14
+ it('evicts idle queries (even when the SSP unregister throws), heartbeats the registered rest, reschedules at half the shortest ttl', async () => {
15
+ const now = 1_700_000_000_000;
16
+ const s = buildState([
17
+ buildEntry({ def: { hash: 'idle', ttlMs: 100 }, lastSubscriberLeftAt: now - 200, lifecycle: { remote: 'registered' } }),
18
+ buildEntry({ def: { hash: 'idle2', ttlMs: 100 }, lastSubscriberLeftAt: now - 200 }),
19
+ buildEntry({ def: { hash: 'kept', ttlMs: 1000, id: new RecordId('_00_query', 'kept') }, subscribers: 1, lifecycle: { remote: 'registered' } }),
20
+ buildEntry({ def: { hash: 'unreg', ttlMs: 500 }, subscribers: 1 }),
21
+ ]);
22
+ let unregisters = 0;
23
+ const out = await runPure(lifecycleTick(env), {
24
+ state: s,
25
+ now,
26
+ handlers: {
27
+ 'ssp.unregister': () => {
28
+ unregisters++;
29
+ if (unregisters === 1) throw new Error('gone');
30
+ },
31
+ 'remote.query': (e: any) => {
32
+ expect(e.sql).toBe('fn::query::heartbeat($id0)');
33
+ expect(e.vars.id0).toEqual(new RecordId('_00_query', 'kept'));
34
+ return [ok([{ id: 'x' }])];
35
+ },
36
+ },
37
+ });
38
+ expect([...out.state.queries.keys()]).toEqual(['kept', 'unreg']);
39
+ expect(out.emitted.filter((e) => e.type === 'query:evicted')).toHaveLength(2);
40
+ expect(out.state.queries.get('kept')!.lastHeartbeatAt).toBe(now);
41
+ expect(out.dispatched).toEqual([{ type: 'SyncOutcome', ok: true }]);
42
+ expect(out.timers.get('lifecycle')).toEqual({ ms: 250, event: { type: 'LifecycleTick' } });
43
+ });
44
+ it('a reclaimed row drops the registration and re-registers; a failed beat reports; empty state uses the default ttl', async () => {
45
+ const s = buildState([buildEntry({ def: { hash: 'a' }, subscribers: 1, lifecycle: { remote: 'registered' } })]);
46
+ const reclaimed = await runPure(lifecycleTick(env), { state: s, handlers: { 'remote.query': () => [ok([])] } });
47
+ expect(reclaimed.state.queries.get('a')!.lifecycle.remote).toBe('unregistered');
48
+ expect(reclaimed.dispatched).toEqual([{ type: 'EnsureRegistered' }, { type: 'SyncOutcome', ok: true }]);
49
+ const failed = await runPure(lifecycleTick(env), {
50
+ state: s,
51
+ handlers: {
52
+ 'remote.query': () => {
53
+ throw new Error('offline');
54
+ },
55
+ },
56
+ });
57
+ expect(failed.dispatched).toEqual([{ type: 'SyncOutcome', ok: false, error: new Error('offline') }]);
58
+ const empty = await runPure(lifecycleTick(env), { state: buildState() });
59
+ expect(empty.timers.get('lifecycle')!.ms).toBe(300_000);
60
+ });
61
+ });
62
+
63
+ describe('ackPrune', () => {
64
+ it('drops expired acked items and re-arms while some remain', async () => {
65
+ const now = 100_000;
66
+ const s = buildState([], R.outboxReplace([
67
+ buildOutboxItem({ id: 'old', status: 'acked', ackedAt: now - 31_000 }),
68
+ buildOutboxItem({ id: 'fresh', status: 'acked', ackedAt: now - 1000 }),
69
+ ]));
70
+ const out = await runPure(ackPrune(), { state: s, now });
71
+ expect(out.state.outbox.map((i) => i.id)).toEqual(['fresh']);
72
+ expect(out.timers.get('ack-prune')).toEqual({ ms: 30_000, event: { type: 'AckPrune' } });
73
+ const done = await runPure(ackPrune(), { state: out.state, now: now + 60_000 });
74
+ expect(done.timers.size).toBe(0);
75
+ });
76
+ });
77
+
78
+ describe('loadViews', () => {
79
+ it('fills the index from every _00_view row (members and children); a failed read leaves it empty', async () => {
80
+ const rows = [
81
+ { id: '_00_view:a', ids: [['t:1', 1]], children: [['c:1', 1], 'junk'] },
82
+ { id: new RecordId('_00_view', 'b'), ids: [] },
83
+ { id: '', ids: [] },
84
+ { ids: [['t:no-key', 1]] },
85
+ null,
86
+ ];
87
+ const out = await runPure(loadViews(), { state: buildState(), handlers: { 'local.query': () => [rows] } });
88
+ expect(out.state.views).toEqual(new Map([['a', ['t:1', 'c:1']], ['b', []]]));
89
+ const odd = await runPure(loadViews(), { state: buildState(), handlers: { 'local.query': () => undefined } });
90
+ expect(odd.state.views.size).toBe(0);
91
+ const failed = await runPure(loadViews(), {
92
+ state: buildState(),
93
+ handlers: {
94
+ 'local.query': () => {
95
+ throw new Error('no table');
96
+ },
97
+ },
98
+ });
99
+ expect(failed.state.views.size).toBe(0);
100
+ expect(failed.emitted).toEqual([expect.objectContaining({ level: 'warn', message: 'view index read failed' })]);
101
+ });
102
+ });
103
+
104
+ describe('gcTick', () => {
105
+ const day = 24 * 60 * 60 * 1000;
106
+ const now = 100 * day;
107
+ const ready = R.setIdentity({ primed: true, bucketId: 'u1' });
108
+ const deletedIds = (log: Array<{ kind: string }>) =>
109
+ log.filter((e: any) => e.kind === 'local.delete').map((e: any) => (e.table === '_00_view' ? `view:${e.id.id}` : e.id));
110
+
111
+ it('retires stale view rows, deletes the bodies nothing retains from the store and the circuit, re-fetches, reschedules', async () => {
112
+ const s = buildState(
113
+ [buildEntry({ def: { hash: 'h', viewKey: 'held' }, lifecycle: { phase: 'cached' }, remoteArray: [['thing:mem', 1]] })],
114
+ ready,
115
+ R.setVersions(
116
+ ['thing:keep', 'child:1', 'thing:stale', 'thing:held', 'thing:stuck', 'thing:mem', 'thing:gone', 'thing:pending', '_00_view:x', 'thing:failed'].map(
117
+ (id) => [id, 1] as const
118
+ )
119
+ ),
120
+ R.outboxReplace([buildOutboxItem({ recordId: 'thing:pending' })])
121
+ );
122
+ const rows = [
123
+ { id: '_00_view:fresh', ids: [['thing:keep', 1]], children: [['child:1', 1]], updatedAt: now - day },
124
+ // Nobody resolved this query for 15 days: the row is retired and stops vouching.
125
+ { id: new RecordId('_00_view', 'old'), ids: [['thing:stale', 1]], updatedAt: now - 15 * day },
126
+ // As old, but a query in state holds it.
127
+ { id: '_00_view:held', ids: [['thing:held', 1]] },
128
+ // As old, but its delete fails: it keeps vouching.
129
+ { id: '_00_view:stuck', ids: [['thing:stuck', 1]], updatedAt: 0 },
130
+ { ids: 'bad' },
131
+ ];
132
+ const out = await runPure(gcTick(), {
133
+ state: s,
134
+ now,
135
+ handlers: {
136
+ 'local.query': (e: any) => {
137
+ expect(e.sql).toBe('SELECT * FROM _00_view');
138
+ return [rows];
139
+ },
140
+ 'local.delete': (e: any) => {
141
+ if (e.id === 'thing:failed' || e.id?.id === 'stuck') throw new Error('busy');
142
+ },
143
+ 'ssp.ingest': () => undefined,
144
+ },
145
+ });
146
+ expect(deletedIds(out.log)).toEqual(['view:old', 'view:stuck', 'thing:stale', 'thing:gone', 'thing:failed']);
147
+ expect(out.state.views).toEqual(
148
+ new Map([
149
+ ['fresh', ['thing:keep', 'child:1']],
150
+ ['held', ['thing:held']],
151
+ ['stuck', ['thing:stuck']],
152
+ ])
153
+ );
154
+ expect([...out.state.versions.keys()]).toEqual(['thing:keep', 'child:1', 'thing:held', 'thing:stuck', 'thing:mem', 'thing:pending', '_00_view:x', 'thing:failed']);
155
+ const ingest = out.log.find((e) => e.kind === 'ssp.ingest') as any;
156
+ expect(ingest.records).toEqual([
157
+ { table: 'thing', op: 'DELETE', id: 'thing:stale', record: {} },
158
+ { table: 'thing', op: 'DELETE', id: 'thing:gone', record: {} },
159
+ ]);
160
+ // Solo: nobody to relay to.
161
+ expect(out.emitted.filter((e) => e.type === 'tabs:broadcast')).toEqual([]);
162
+ expect(out.emitted).toContainEqual(expect.objectContaining({ message: 'orphan gc done', data: { removed: 2, retiredViews: 1 } }));
163
+ expect(out.dispatched).toEqual([{ type: 'FetchRows' }]);
164
+ expect(out.timers.get('gc')).toEqual({ ms: 60 * 60 * 1000, event: { type: 'GcTick' } });
165
+ });
166
+
167
+ it('waits for the circuit prime (the versions); a leader relays its deletes to the other tabs', async () => {
168
+ const s = buildState([], R.setIdentity({ bucketId: 'u1' }), R.setTabRole('leader'), R.setVersions([['thing:gone', 1]]));
169
+ const out = await runPure(gcTick(), {
170
+ state: s,
171
+ handlers: {
172
+ 'state.wait': (e: any, ctx) => {
173
+ expect(e.until(ctx.state)).toBe(false);
174
+ ctx.state = R.setIdentity({ primed: true })(ctx.state);
175
+ },
176
+ 'local.query': () => [[]],
177
+ 'local.delete': () => undefined,
178
+ 'ssp.ingest': () => undefined,
179
+ },
180
+ });
181
+ expect(out.emitted).toContainEqual({
182
+ type: 'tabs:broadcast',
183
+ message: { type: 'ingest', records: [{ table: 'thing', op: 'DELETE', id: 'thing:gone', record: {} }] },
184
+ });
185
+ });
186
+
187
+ it('a follower only refreshes its index: no view row or body is deleted', async () => {
188
+ const s = buildState([], ready, R.setTabRole('follower'), R.setVersions([['thing:gone', 1]]));
189
+ const out = await runPure(gcTick(), {
190
+ state: s,
191
+ now,
192
+ handlers: { 'local.query': () => [[{ id: '_00_view:old', ids: [['thing:x', 1]], updatedAt: 0 }]] },
193
+ });
194
+ expect(out.state.views).toEqual(new Map([['old', ['thing:x']]]));
195
+ expect(deletedIds(out.log)).toEqual([]);
196
+ expect(out.state.versions.has('thing:gone')).toBe(true);
197
+ expect(out.timers.has('gc')).toBe(true);
198
+ });
199
+
200
+ it('each chunk is re-checked against fresh state, and the sweep stops when the bucket moves', async () => {
201
+ const ids = Array.from({ length: 250 }, (_, i) => `thing:o${i}`);
202
+ const s = buildState([], ready, R.setVersions(ids.map((id) => [id, 1] as const)));
203
+ const handlers = (onFirst: (ctx: any) => void) => ({
204
+ 'local.query': () => [[]],
205
+ 'local.delete': (e: any, ctx: any) => {
206
+ if (e.id === 'thing:o0') onFirst(ctx);
207
+ },
208
+ 'ssp.ingest': () => undefined,
209
+ });
210
+ // Meanwhile a query commits o210 and o220's body goes another way: neither is deleted.
211
+ const recheck = await runPure(gcTick(), {
212
+ state: s,
213
+ handlers: handlers((ctx) => {
214
+ ctx.state = R.compose(R.putQuery(buildEntry({ def: { hash: 'q' }, remoteArray: [['thing:o210', 1]] })), R.deleteVersions(['thing:o220']))(ctx.state);
215
+ }),
216
+ });
217
+ const removed = deletedIds(recheck.log);
218
+ expect(removed).toHaveLength(248);
219
+ expect(removed).not.toContain('thing:o210');
220
+ expect(removed).not.toContain('thing:o220');
221
+ expect(recheck.log.filter((e) => e.kind === 'ssp.ingest')).toHaveLength(2);
222
+ const moved = await runPure(gcTick(), { state: s, handlers: handlers((ctx) => (ctx.state = R.setIdentity({ bucketId: 'u2' })(ctx.state))) });
223
+ expect(deletedIds(moved.log)).toHaveLength(200);
224
+ expect(moved.timers.has('gc')).toBe(true);
225
+ });
226
+
227
+ it('failures are logged and the tick still reschedules; a sweep that removes nothing asks for nothing', async () => {
228
+ const s = buildState([], ready, R.setVersions([['thing:gone', 1]]));
229
+ const readFails = await runPure(gcTick(), {
230
+ state: s,
231
+ handlers: {
232
+ 'local.query': () => {
233
+ throw new Error('no table');
234
+ },
235
+ },
236
+ });
237
+ expect(readFails.emitted).toEqual([expect.objectContaining({ message: 'orphan gc failed' })]);
238
+ expect(readFails.timers.has('gc')).toBe(true);
239
+ const ingestFails = await runPure(gcTick(), {
240
+ state: s,
241
+ handlers: {
242
+ 'local.query': () => [[]],
243
+ 'local.delete': () => undefined,
244
+ 'ssp.ingest': () => {
245
+ throw new Error('wasm');
246
+ },
247
+ },
248
+ });
249
+ expect(ingestFails.emitted).toEqual([expect.objectContaining({ message: 'orphan gc failed' })]);
250
+ expect(ingestFails.timers.has('gc')).toBe(true);
251
+ const deleteFails = await runPure(gcTick(), {
252
+ state: s,
253
+ handlers: {
254
+ 'local.query': () => [[]],
255
+ 'local.delete': () => {
256
+ throw new Error('busy');
257
+ },
258
+ },
259
+ });
260
+ expect(deleteFails.log.filter((e) => e.kind === 'ssp.ingest')).toHaveLength(0);
261
+ expect(deleteFails.dispatched).toEqual([]);
262
+ expect(deleteFails.emitted).toContainEqual(expect.objectContaining({ message: 'orphan gc done', data: { removed: 0, retiredViews: 0 } }));
263
+ const nothing = await runPure(gcTick(), { state: buildState([], ready), handlers: { 'local.query': () => [] } });
264
+ expect(nothing.log.filter((e) => e.kind === 'local.delete')).toHaveLength(0);
265
+ expect(nothing.dispatched).toEqual([]);
266
+ });
267
+ });
268
+
269
+ describe('evictQuery', () => {
270
+ it('is a no-op for unknown hashes', async () => {
271
+ const out = await runPure(evictQuery('zz'), { state: buildState() });
272
+ expect(out.emitted).toEqual([]);
273
+ });
274
+ });
@@ -0,0 +1,159 @@
1
+ import type { Saga } from '../kernel/saga';
2
+ import type { Settled, StatementResult } from '../kernel/effects';
3
+ import { fx } from '../kernel/effects';
4
+ import { ACK_GRACE_MS, GC_CHUNK, GC_INTERVAL_MS, TTL_HEARTBEAT_FRACTION, VIEW_RETENTION_MS } from '../kernel/constants';
5
+ import type { IngestRecord } from '../services/stream-processor/index';
6
+ import type { ClientState, QueryEntry, TabRole } from '../state/client-state';
7
+ import * as R from '../state/reducers';
8
+ import { evictable, retained, shortestTtlMs } from '../state/selectors';
9
+ import type { SagaEnv } from './env';
10
+ import { parseViewIndex, type ViewIndexRow } from './membership';
11
+ import * as sql from './sql';
12
+
13
+ /**
14
+ * One tick for every query in state: evict the ones nobody has watched for a
15
+ * ttl, heartbeat the rest in one request, notice reclaimed rows. Reschedules
16
+ * itself at half the shortest ttl.
17
+ */
18
+ export function* lifecycleTick(env: SagaEnv): Saga<void> {
19
+ const now = (yield fx.now()) as number;
20
+ const state = (yield fx.state.read((s) => s)) as ClientState;
21
+ for (const hash of evictable(state, now)) yield* evictQuery(hash);
22
+ const remaining = (yield fx.state.read((s) => [...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered'))) as QueryEntry[];
23
+ if (remaining.length > 0) {
24
+ const beat = sql.heartbeatBatch(remaining.map((e) => e.def.id));
25
+ try {
26
+ const results = (yield fx.remote.query(beat.sql, beat.vars, env.remoteTimeoutMs)) as StatementResult[];
27
+ let reclaimed = 0;
28
+ for (let i = 0; i < remaining.length; i++) {
29
+ const r = results[i];
30
+ if (r?.status === 'OK' && sql.heartbeatRowGone(r.result)) {
31
+ reclaimed++;
32
+ yield fx.state.update(R.applyLifecycle(remaining[i].def.hash, { type: 'remote-dropped' }));
33
+ }
34
+ }
35
+ yield fx.state.update(R.stampHeartbeat(remaining.map((e) => e.def.hash), now));
36
+ if (reclaimed > 0) {
37
+ yield fx.emit({ type: 'log', level: 'warn', message: 'query rows reclaimed by the server; re-registering', data: { reclaimed } });
38
+ yield fx.dispatch({ type: 'EnsureRegistered' });
39
+ }
40
+ yield fx.dispatch({ type: 'SyncOutcome', ok: true });
41
+ } catch (error) {
42
+ yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
43
+ }
44
+ }
45
+ const ttl = (yield fx.state.read((s) => shortestTtlMs(s))) as number | null;
46
+ yield fx.timer.set('lifecycle', Math.floor((ttl ?? env.defaultTtlMs) * TTL_HEARTBEAT_FRACTION), { type: 'LifecycleTick' });
47
+ }
48
+
49
+ /** Free a query's local view and forget it. The server row expires by TTL. */
50
+ export function* evictQuery(hash: string): Saga<void> {
51
+ const exists = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
52
+ if (!exists) return;
53
+ try {
54
+ yield fx.ssp.unregister(hash);
55
+ } catch (error) {
56
+ yield fx.emit({ type: 'log', level: 'debug', message: 'unregister failed', data: { hash, error } });
57
+ }
58
+ yield fx.state.update(R.removeQuery(hash));
59
+ yield fx.emit({ type: 'query:evicted', hash });
60
+ }
61
+
62
+ /** Drop acked outbox items membership never named within the grace window. */
63
+ export function* ackPrune(): Saga<void> {
64
+ const now = (yield fx.now()) as number;
65
+ yield fx.state.update(R.outboxPruneAcked(now, ACK_GRACE_MS));
66
+ const stillAcked = (yield fx.state.read((s) => s.outbox.some((i) => i.status === 'acked'))) as boolean;
67
+ if (stillAcked) yield fx.timer.set('ack-prune', ACK_GRACE_MS, { type: 'AckPrune' });
68
+ }
69
+
70
+ /** Every `_00_view` row, parsed. Throws when the read fails. */
71
+ function* readViews(): Saga<ViewIndexRow[]> {
72
+ const res = (yield fx.local.query(sql.readViewRows())) as unknown[];
73
+ return parseViewIndex(Array.isArray(res) ? res[0] : undefined);
74
+ }
75
+
76
+ const indexOf = (rows: ViewIndexRow[]): Map<string, string[]> => new Map(rows.map((r) => [r.key, r.ids]));
77
+
78
+ /**
79
+ * Load the durable view index (boot, bucket switch). A failed read leaves it
80
+ * empty, which fails closed: cold scans paint nothing until the server answers.
81
+ */
82
+ export function* loadViews(): Saga<void> {
83
+ try {
84
+ yield fx.state.update(R.reloadViews(indexOf(yield* readViews())));
85
+ } catch (error) {
86
+ yield fx.emit({ type: 'log', level: 'warn', message: 'view index read failed', data: { error } });
87
+ }
88
+ }
89
+
90
+ const tableOf = (id: string): string => id.slice(0, id.indexOf(':'));
91
+
92
+ /**
93
+ * Orphan collection, `GC_BOOT_DELAY_MS` after boot and then every
94
+ * `GC_INTERVAL_MS`. Re-reads every `_00_view` row into the index, retires the
95
+ * rows no query has resolved for `VIEW_RETENTION_MS`, then deletes the bodies
96
+ * nothing retains (see `retained`) from the store and the circuit, a chunk at
97
+ * a time. Only a leader or solo tab deletes, and a leader relays the deletes
98
+ * so followers drop the rows too; a follower only refreshes its index.
99
+ */
100
+ export function* gcTick(): Saga<void> {
101
+ yield fx.state.wait((s) => s.primed);
102
+ try {
103
+ const rows = yield* readViews();
104
+ const [role, bucket, held] = (yield fx.state.read((s) => [
105
+ s.tabRole,
106
+ s.bucketId,
107
+ new Set([...s.queries.values()].map((e) => e.def.viewKey)),
108
+ ])) as [TabRole, string | null, Set<string>];
109
+ const now = (yield fx.now()) as number;
110
+ const expired = role === 'follower' ? [] : rows.filter((r) => !held.has(r.key) && now - r.updatedAt >= VIEW_RETENTION_MS);
111
+ const retired = new Set<string>();
112
+ for (let i = 0; i < expired.length; i += GC_CHUNK) {
113
+ const chunk = expired.slice(i, i + GC_CHUNK);
114
+ const settled = (yield fx.all(chunk.map((r) => fx.local.delete(sql.VIEW_TABLE, sql.viewRecordId(r.key))))) as Settled[];
115
+ chunk.forEach((r, j) => settled[j].ok && retired.add(r.key));
116
+ }
117
+ yield fx.state.update(R.reloadViews(indexOf(rows.filter((r) => !retired.has(r.key)))));
118
+ if (role === 'follower') return;
119
+ const removed = yield* collectOrphans(bucket, role === 'leader');
120
+ yield fx.emit({ type: 'log', level: 'info', message: 'orphan gc done', data: { removed, retiredViews: retired.size } });
121
+ } catch (error) {
122
+ yield fx.emit({ type: 'log', level: 'warn', message: 'orphan gc failed', data: { error } });
123
+ } finally {
124
+ yield fx.timer.set('gc', GC_INTERVAL_MS, { type: 'GcTick' });
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Delete every body nothing retains, `GC_CHUNK` at a time. Each chunk is
130
+ * re-checked against fresh state (a membership committed meanwhile may name
131
+ * an id again) and the sweep stops if the bucket moved under it.
132
+ */
133
+ function* collectOrphans(bucket: string | null, relay: boolean): Saga<number> {
134
+ const candidates = (yield fx.state.read((s) => {
135
+ const keep = retained(s);
136
+ return [...s.versions.keys()].filter((id) => !keep(id));
137
+ })) as string[];
138
+ let removed = 0;
139
+ for (let i = 0; i < candidates.length; i += GC_CHUNK) {
140
+ const chunk = (yield fx.state.read((s) => {
141
+ if (!s.primed || s.bucketId !== bucket) return null;
142
+ const keep = retained(s);
143
+ return candidates.slice(i, i + GC_CHUNK).filter((id) => s.versions.has(id) && !keep(id));
144
+ })) as string[] | null;
145
+ if (chunk === null) break;
146
+ const settled = (yield fx.all(chunk.map((id) => fx.local.delete(tableOf(id), id)))) as Settled[];
147
+ const done = chunk.filter((_, j) => settled[j].ok);
148
+ if (done.length === 0) continue;
149
+ const records: IngestRecord[] = done.map((id) => ({ table: tableOf(id), op: 'DELETE', id, record: {} }));
150
+ yield fx.state.update(R.deleteVersions(done));
151
+ yield fx.ssp.ingest(records);
152
+ if (relay) yield fx.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records } });
153
+ removed += done.length;
154
+ }
155
+ // A query may have committed a deleted id between its chunk's check and the
156
+ // delete; with its version gone, the fetch plan pulls it back.
157
+ if (removed > 0) yield fx.dispatch({ type: 'FetchRows' });
158
+ return removed;
159
+ }
@@ -0,0 +1,203 @@
1
+ import { Socket } from 'node:net';
2
+ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
3
+ import { Surreal, type Uuid } from 'surrealdb';
4
+ import { ANON_LIST_REF_TABLE } from '../modules/ref-tables';
5
+ import { liveEndpoint } from '../live/index';
6
+ import { hashOfEdge } from '../utils/edges';
7
+ import { listRefLive } from './sql';
8
+
9
+ /**
10
+ * Every signed-out session shares `_00_list_ref_anon`. Each one's LIVE must
11
+ * carry only its own edges, including after a reconnect, when its views' edges
12
+ * still name the socket that registered them.
13
+ *
14
+ * Runs against a real SurrealDB, because the filter rests on how SurrealDB
15
+ * evaluates a LIVE WHERE (`session::id()` in the subscriber's session, a bound
16
+ * param kept with the LIVE). Boot one first, e.g.:
17
+ * docker run -d --name spooky-surreal-it -p 18011:8000 \
18
+ * surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
19
+ *
20
+ * Needs SurrealDB 3.1 or later: 3.0.x sends no LIVE notification for a write
21
+ * that follows a `LET` in the same request, which is how the SSP writes every
22
+ * edge (and how these tests write them).
23
+ *
24
+ * Override the endpoint with SURREAL_IT_ENDPOINT. Skips if unreachable. The
25
+ * Dart twin is `packages/spooky_core/test/integration/anon_live_isolation_test.dart`.
26
+ */
27
+ const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
28
+ const NS = 'it';
29
+
30
+ // What the SSP and the CLI define (`ensure_anon_table`, `_00_query`).
31
+ const SCHEMA = `
32
+ DEFINE TABLE OVERWRITE item SCHEMALESS PERMISSIONS FOR select WHERE true;
33
+ DEFINE TABLE OVERWRITE _00_query SCHEMALESS
34
+ PERMISSIONS FOR select, create, update, delete
35
+ WHERE auth_id = 'anon' OR ($auth.id != NONE AND auth_id = <string>$auth.id);
36
+ DEFINE TABLE OVERWRITE _00_list_ref_anon SCHEMALESS
37
+ PERMISSIONS FOR select WHERE true, FOR create, update WHERE false, FOR delete WHERE false;
38
+ DEFINE FIELD OVERWRITE in ON TABLE _00_list_ref_anon TYPE record;
39
+ DEFINE FIELD OVERWRITE out ON TABLE _00_list_ref_anon TYPE record;
40
+ DEFINE FIELD OVERWRITE clientId ON TABLE _00_list_ref_anon TYPE string;
41
+ DEFINE FIELD OVERWRITE auth_id ON TABLE _00_list_ref_anon TYPE string DEFAULT '';
42
+ DEFINE FIELD OVERWRITE version ON TABLE _00_list_ref_anon TYPE int;
43
+ CREATE item:1 SET name = 'one';
44
+ CREATE item:2 SET name = 'two';
45
+ CREATE item:3 SET name = 'three';
46
+ `;
47
+
48
+ // The SSP's own statements (`build_edge_batch`): the edge takes the view's
49
+ // clientId at write time.
50
+ const VIEW =
51
+ "LET $from0 = type::record('_00_query', $q); LET $cid0 = (SELECT VALUE clientId FROM $from0 LIMIT 1)[0];";
52
+
53
+ interface Heard {
54
+ action: string;
55
+ value: Record<string, unknown>;
56
+ }
57
+
58
+ const waitFor = async (pred: () => boolean, ms = 3_000): Promise<void> => {
59
+ const deadline = Date.now() + ms;
60
+ while (!pred() && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25));
61
+ };
62
+ const settle = () => new Promise((r) => setTimeout(r, 300));
63
+
64
+ describe('anonymous list_ref LIVE (real SurrealDB)', () => {
65
+ let root: Surreal;
66
+ let database = '';
67
+ let reachable = false;
68
+ const open: Surreal[] = [];
69
+
70
+ // A plain TCP probe: the SDK never rejects a connect to a refused port.
71
+ beforeAll(async () => {
72
+ const url = new URL(endpoint);
73
+ reachable = await new Promise<boolean>((resolve) => {
74
+ const socket = new Socket();
75
+ const done = (ok: boolean) => {
76
+ socket.destroy();
77
+ resolve(ok);
78
+ };
79
+ socket.setTimeout(1_000, () => done(false));
80
+ socket.once('error', () => done(false));
81
+ socket.connect(Number(url.port || 80), url.hostname, () => done(true));
82
+ });
83
+ });
84
+
85
+ beforeEach(async () => {
86
+ if (!reachable) return;
87
+ database = `anon_live_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
88
+ root = new Surreal();
89
+ await root.connect(endpoint, { reconnect: false });
90
+ await root.signin({ username: 'root', password: 'root' });
91
+ await root.use({ namespace: NS, database });
92
+ await root.query(SCHEMA);
93
+ });
94
+
95
+ afterEach(async () => {
96
+ if (!reachable) return;
97
+ for (const db of open.splice(0)) await db.close().catch(() => undefined);
98
+ await root.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
99
+ await root.close();
100
+ });
101
+
102
+ /** A signed-out socket and its `session::id()`. */
103
+ async function guest(): Promise<[Surreal, string]> {
104
+ const db = new Surreal();
105
+ open.push(db);
106
+ await db.connect(endpoint, { reconnect: false });
107
+ await db.use({ namespace: NS, database });
108
+ const [sid] = (await db.query('RETURN <string>session::id()')) as [string];
109
+ return [db, sid];
110
+ }
111
+
112
+ /** Open the client's anonymous LIVE and collect what it hears. */
113
+ async function listen(db: Surreal, earlier: string[] = []): Promise<Heard[]> {
114
+ const stmt = listRefLive(ANON_LIST_REF_TABLE, { inlineBodies: true, sessionIds: earlier });
115
+ const [uuid] = (await db.query(stmt.sql, stmt.vars)) as [Uuid];
116
+ const live = await db.liveOf(uuid);
117
+ const got: Heard[] = [];
118
+ live.subscribe((message) => {
119
+ if (message.action !== 'KILLED')
120
+ got.push({ action: message.action, value: message.value as Record<string, unknown> });
121
+ });
122
+ return got;
123
+ }
124
+
125
+ const register = (q: string, sid: string) =>
126
+ root.query("UPSERT type::record('_00_query', $q) SET clientId = $sid, auth_id = 'anon'", {
127
+ q,
128
+ sid,
129
+ });
130
+ const relate = (q: string, item: string) =>
131
+ root.query(
132
+ `${VIEW} RELATE $from0->_00_list_ref_anon->${item} SET version = 1, clientId = $cid0, auth_id = 'anon'`,
133
+ { q }
134
+ );
135
+ const bump = (q: string, item: string, version: number, restamp = true) =>
136
+ root.query(
137
+ `${VIEW} UPDATE (SELECT VALUE id FROM ${item}<-_00_list_ref_anon WHERE in = $from0) SET version = ${version}${restamp ? ', clientId = $cid0 ?? clientId' : ''} RETURN NONE`,
138
+ { q }
139
+ );
140
+ const remove = (q: string, item: string) =>
141
+ root.query(
142
+ `${VIEW} DELETE (SELECT VALUE id FROM ${item}<-_00_list_ref_anon WHERE in = $from0)`,
143
+ { q }
144
+ );
145
+
146
+ const heard = (got: Heard[]) =>
147
+ got
148
+ .map((m) => `${m.action} ${hashOfEdge(m.value)} ${(m.value.out as { name?: string }).name}`)
149
+ .join(', ');
150
+
151
+ it("two anonymous sessions do not hear each other's edges", async (ctx) => {
152
+ if (!reachable) ctx.skip();
153
+ const [a, sidA] = await guest();
154
+ const [b, sidB] = await guest();
155
+ await register('qa', sidA);
156
+ await register('qb', sidB);
157
+ const gotA = await listen(a);
158
+ const gotB = await listen(b);
159
+
160
+ await relate('qa', 'item:1');
161
+ await relate('qb', 'item:2');
162
+ await bump('qa', 'item:1', 2);
163
+ await waitFor(() => gotA.length >= 2 && gotB.length >= 1);
164
+ await settle();
165
+
166
+ // The body still rides along (`FETCH out`).
167
+ expect(heard(gotA)).toBe('CREATE qa one, UPDATE qa one');
168
+ expect(heard(gotB)).toBe('CREATE qb two');
169
+ });
170
+
171
+ it('after a reconnect the new socket still hears the edges its old one registered, and a bystander hears none of it', async (ctx) => {
172
+ if (!reachable) ctx.skip();
173
+ const [a, sidA] = await guest();
174
+ const [b, sidB] = await guest();
175
+ await register('qa', sidA);
176
+ await register('qb', sidB);
177
+ await relate('qa', 'item:1');
178
+ await relate('qa', 'item:2');
179
+ const gotB = await listen(b);
180
+
181
+ // New socket, new session id. The view stays registered under the old one
182
+ // until the client re-registers it.
183
+ await a.close();
184
+ const [a2, sidA2] = await guest();
185
+ const gotA2 = await listen(a2, [sidA]);
186
+
187
+ // An SSP that does not restamp: the edge keeps naming the old socket.
188
+ await bump('qa', 'item:1', 2, false);
189
+ await waitFor(() => gotA2.length >= 1);
190
+ expect(heard(gotA2)).toBe('UPDATE qa one');
191
+
192
+ // Re-registered: the view, and from now on each edge it bumps, names the
193
+ // new socket. An edge nothing bumped still names the old one when it goes.
194
+ await register('qa', sidA2);
195
+ await bump('qa', 'item:1', 3);
196
+ await remove('qa', 'item:2');
197
+ await relate('qa', 'item:3');
198
+ await waitFor(() => gotA2.length >= 4);
199
+ await settle();
200
+ expect(heard(gotA2)).toBe('UPDATE qa one, UPDATE qa one, DELETE qa two, CREATE qa three');
201
+ expect(gotB).toEqual([]);
202
+ });
203
+ });