@spooky-sync/core 0.0.1-canary.29 → 0.0.1-canary.291

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 +14941 -2433
  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 +82 -0
  30. package/src/boot/auth-flip.saga.ts +62 -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 +750 -96
  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 +75 -9
  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 +2 -1
  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,132 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { QueryPlan } from '@spooky-sync/query-builder';
3
+ import { buildIdSetPlan, buildIdSetSurql, buildWindowMaterializationPlan } from './window-query';
4
+ import { executeSelect, type SelectDb } from '../services/database/sqlite-select';
5
+ import type { Row } from '../services/database/cache-engine';
6
+
7
+ describe('buildWindowMaterializationPlan', () => {
8
+ it('returns null without an offset and an id-set plan with one', () => {
9
+ const plan: QueryPlan = { table: 't', where: [{ field: 'a', op: '=', value: 1 } as any], limit: 5 };
10
+ expect(buildWindowMaterializationPlan(plan, ['t:1'])).toBeNull();
11
+ expect(buildWindowMaterializationPlan({ ...plan, offset: 0 }, ['t:1'])).toBeNull();
12
+ const win = buildWindowMaterializationPlan({ ...plan, offset: 5 }, ['t:1']);
13
+ expect(win).toMatchObject({ ids: ['t:1'], where: undefined, limit: undefined, offset: undefined });
14
+ });
15
+ });
16
+
17
+ // `buildIdSetPlan` is what makes membership-authoritative rendering possible on
18
+ // any local engine: it turns a query's plan into "these exact ids, ordered,
19
+ // relations intact". Dropping `where` is the point — re-applying the predicate
20
+ // against stale local bodies is what let removed rows keep rendering.
21
+
22
+ const BASE: QueryPlan = {
23
+ table: 'thread',
24
+ where: [{ field: 'done', op: '=', value: false }],
25
+ limit: 50,
26
+ offset: 0,
27
+ orderBy: [['title', 'asc']],
28
+ relations: [
29
+ { alias: 'comments', table: 'comment', cardinality: 'many', foreignKeyField: 'thread' },
30
+ ],
31
+ } as any;
32
+
33
+ describe('buildIdSetPlan', () => {
34
+ it('binds the id-set and drops where/limit/offset, keeping orderBy + relations', () => {
35
+ const out = buildIdSetPlan(BASE, ['thread:A']);
36
+ expect(out.ids).toEqual(['thread:A']);
37
+ expect(out.where).toBeUndefined();
38
+ expect(out.limit).toBeUndefined();
39
+ expect(out.offset).toBeUndefined();
40
+ expect(out.orderBy).toEqual([['title', 'asc']]);
41
+ expect(out.relations).toEqual(BASE.relations);
42
+ expect(out.table).toBe('thread');
43
+ });
44
+
45
+ it('applies to non-offset plans, unlike the windowed-only helper', () => {
46
+ // The windowed helper deliberately bails so the old caller kept its scan;
47
+ // membership rendering needs the same rewrite for every query.
48
+ expect(buildWindowMaterializationPlan(BASE, ['thread:A'])).toBeNull();
49
+ expect(buildIdSetPlan(BASE, ['thread:A']).ids).toEqual(['thread:A']);
50
+ });
51
+
52
+ it('does not mutate the input plan', () => {
53
+ buildIdSetPlan(BASE, ['thread:A']);
54
+ expect(BASE.where).toEqual([{ field: 'done', op: '=', value: false }]);
55
+ expect(BASE.limit).toBe(50);
56
+ });
57
+ });
58
+
59
+ describe('buildIdSetSurql', () => {
60
+ it('rewrites a non-offset query to the id-set source, keeping ORDER BY', () => {
61
+ const out = buildIdSetSurql('SELECT * FROM thread WHERE done = false ORDER BY title ASC LIMIT 50;');
62
+ expect(out?.query).toBe('SELECT * FROM $__win ORDER BY title ASC');
63
+ });
64
+
65
+ it('preserves the projection', () => {
66
+ const out = buildIdSetSurql('SELECT id, title FROM thread WHERE done = false;');
67
+ expect(out?.query).toBe('SELECT id, title FROM $__win');
68
+ });
69
+
70
+ it('returns null when there is no top-level FROM to replace', () => {
71
+ expect(buildIdSetSurql('RETURN true;')).toBeNull();
72
+ });
73
+ });
74
+
75
+ // ---- Engine parity -------------------------------------------------------
76
+ //
77
+ // Both local engines short-circuit to `selectByIds` when `plan.ids` is set. This
78
+ // pins that the id-set plan really does filter AND still resolves `.related()`
79
+ // on the SQLite engine (the `where`-dropping path the SurrealDB engine shares).
80
+
81
+ const FIXTURE: Record<string, Row[]> = {
82
+ thread: [
83
+ { id: 'thread:A', title: 'a thread', done: false },
84
+ { id: 'thread:B', title: 'b thread', done: false },
85
+ ],
86
+ comment: [
87
+ { id: 'comment:1', thread: 'thread:A', body: 'on A' },
88
+ { id: 'comment:2', thread: 'thread:B', body: 'on B' },
89
+ ],
90
+ };
91
+
92
+ function makeSelectDb(): SelectDb {
93
+ const respond = (sql: string, bind: unknown[] = []): { data: string }[] => {
94
+ const table = /FROM "([^"]+)"/.exec(sql)?.[1];
95
+ let rows = table ? (FIXTURE[table] ?? []) : [];
96
+ const rel = /WHERE json_extract\(data, '\$\.([A-Za-z0-9_.]+)'\) IN/.exec(sql);
97
+ if (rel) rows = rows.filter((r) => bind.includes(r[rel[1]]));
98
+ else if (/WHERE id IN/.test(sql)) rows = rows.filter((r) => bind.includes(r.id));
99
+ return rows.map((r) => ({ data: JSON.stringify(r) }));
100
+ };
101
+ return {
102
+ exec: (sql, bind) => respond(sql, bind ?? []),
103
+ run: () => {},
104
+ knownTables: new Set<string>(),
105
+ };
106
+ }
107
+
108
+ describe('id-set plan on the SQLite engine', () => {
109
+ it('returns only the id-set even though both bodies match the predicate', async () => {
110
+ const { rows } = await executeSelect(
111
+ buildIdSetPlan(BASE, ['thread:A']),
112
+ {},
113
+ makeSelectDb()
114
+ );
115
+ expect(rows.map((r) => r.id)).toEqual(['thread:A']);
116
+ });
117
+
118
+ it('still resolves .related() children for the id-set rows', async () => {
119
+ const { rows, relationFetches } = await executeSelect(
120
+ buildIdSetPlan(BASE, ['thread:A']),
121
+ {},
122
+ makeSelectDb()
123
+ );
124
+ expect(relationFetches).toBe(1);
125
+ expect((rows[0] as any).comments.map((c: Row) => c.id)).toEqual(['comment:1']);
126
+ });
127
+
128
+ it('renders nothing for an empty id-set (known-and-empty membership)', async () => {
129
+ const { rows } = await executeSelect(buildIdSetPlan(BASE, []), {}, makeSelectDb());
130
+ expect(rows).toEqual([]);
131
+ });
132
+ });
@@ -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
+ }