@spooky-sync/core 0.0.1-canary.24 → 0.0.1-canary.241

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 (228) hide show
  1. package/AGENTS.md +77 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2176 -80
  4. package/dist/index.js +12557 -2588
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +322 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +747 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +158 -0
  23. package/src/boot/bucket-switch.saga.ts +115 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +94 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +349 -0
  35. package/src/client/sp00ky-client.test.ts +241 -0
  36. package/src/client/sp00ky-client.ts +482 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +54 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +159 -0
  44. package/src/kernel/events.ts +55 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +143 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +2 -1
  53. package/src/modules/auth/index.ts +138 -25
  54. package/src/modules/crdt/crdt-field.ts +294 -0
  55. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  56. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  57. package/src/modules/crdt/index.ts +463 -0
  58. package/src/modules/crdt/loro-loader.ts +25 -0
  59. package/src/modules/devtools/flags.ts +349 -0
  60. package/src/modules/devtools/index.ts +459 -47
  61. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  62. package/src/modules/devtools/state-shape.test.ts +149 -0
  63. package/src/modules/devtools/storage-info.test.ts +79 -0
  64. package/src/modules/devtools/storage-info.ts +168 -0
  65. package/src/modules/devtools/versions.test.ts +74 -0
  66. package/src/modules/devtools/versions.ts +110 -0
  67. package/src/modules/feature-flag/index.test.ts +251 -0
  68. package/src/modules/feature-flag/index.ts +316 -0
  69. package/src/modules/ref-tables.test.ts +91 -0
  70. package/src/modules/ref-tables.ts +88 -0
  71. package/src/mutation/jobs.test.ts +25 -0
  72. package/src/mutation/jobs.ts +41 -0
  73. package/src/mutation/mutation-id.test.ts +37 -0
  74. package/src/mutation/mutation-id.ts +35 -0
  75. package/src/mutation/push.saga.test.ts +285 -0
  76. package/src/mutation/push.saga.ts +215 -0
  77. package/src/mutation/rows.test.ts +201 -0
  78. package/src/mutation/rows.ts +377 -0
  79. package/src/mutation/tray.saga.test.ts +63 -0
  80. package/src/mutation/tray.saga.ts +48 -0
  81. package/src/mutation/write.saga.test.ts +163 -0
  82. package/src/mutation/write.saga.ts +166 -0
  83. package/src/otel/index.ts +13 -10
  84. package/src/query/env.test.ts +23 -0
  85. package/src/query/env.ts +56 -0
  86. package/src/query/fetch.saga.test.ts +156 -0
  87. package/src/query/fetch.saga.ts +109 -0
  88. package/src/query/hash.test.ts +14 -0
  89. package/src/query/hash.ts +18 -0
  90. package/src/query/id-set-plan.test.ts +132 -0
  91. package/src/query/lifecycle.saga.test.ts +122 -0
  92. package/src/query/lifecycle.saga.ts +101 -0
  93. package/src/query/materialize.saga.test.ts +82 -0
  94. package/src/query/materialize.saga.ts +75 -0
  95. package/src/query/materialize.test.ts +50 -0
  96. package/src/query/materialize.ts +44 -0
  97. package/src/query/membership.saga.test.ts +246 -0
  98. package/src/query/membership.saga.ts +208 -0
  99. package/src/query/membership.test.ts +118 -0
  100. package/src/query/membership.ts +158 -0
  101. package/src/query/register.saga.test.ts +251 -0
  102. package/src/query/register.saga.ts +190 -0
  103. package/src/query/render-set.test.ts +36 -0
  104. package/src/query/render-set.ts +60 -0
  105. package/src/query/sql.test.ts +62 -0
  106. package/src/query/sql.ts +79 -0
  107. package/src/query/window-query.test.ts +70 -0
  108. package/src/query/window-query.ts +195 -0
  109. package/src/services/blobs/blob-cache.test.ts +359 -0
  110. package/src/services/blobs/blob-cache.ts +603 -0
  111. package/src/services/blobs/blob-manifest.ts +227 -0
  112. package/src/services/blobs/blob-store.test.ts +77 -0
  113. package/src/services/blobs/blob-store.ts +359 -0
  114. package/src/services/blobs/blob.fixture.ts +90 -0
  115. package/src/services/blobs/index.ts +70 -0
  116. package/src/services/database/cache-engine.ts +193 -0
  117. package/src/services/database/connection-supervisor.test.ts +318 -0
  118. package/src/services/database/connection-supervisor.ts +422 -0
  119. package/src/services/database/database.query-timeout.test.ts +140 -0
  120. package/src/services/database/database.responses.test.ts +43 -0
  121. package/src/services/database/database.ts +145 -62
  122. package/src/services/database/engine-factory.ts +33 -0
  123. package/src/services/database/errors.ts +34 -0
  124. package/src/services/database/events/index.ts +2 -1
  125. package/src/services/database/index.ts +7 -0
  126. package/src/services/database/local-migrator.ts +30 -27
  127. package/src/services/database/local.test.ts +64 -0
  128. package/src/services/database/local.ts +484 -67
  129. package/src/services/database/plan-render.test.ts +159 -0
  130. package/src/services/database/plan-render.ts +108 -0
  131. package/src/services/database/relation-resolver.test.ts +467 -0
  132. package/src/services/database/relation-resolver.ts +0 -0
  133. package/src/services/database/remote.ts +222 -15
  134. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  135. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  136. package/src/services/database/sqlite-cache-engine.ts +1349 -0
  137. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  138. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  139. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  140. package/src/services/database/sqlite-lock-verify.ts +45 -0
  141. package/src/services/database/sqlite-open.test.ts +150 -0
  142. package/src/services/database/sqlite-open.ts +164 -0
  143. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  144. package/src/services/database/sqlite-plan-sql.ts +138 -0
  145. package/src/services/database/sqlite-projection.test.ts +99 -0
  146. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  147. package/src/services/database/sqlite-select.test.ts +246 -0
  148. package/src/services/database/sqlite-select.ts +131 -0
  149. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  150. package/src/services/database/sqlite-transport.ts +224 -0
  151. package/src/services/database/sqlite-worker.ts +437 -0
  152. package/src/services/database/surql-translate.ts +416 -0
  153. package/src/services/database/surreal-cache-engine.ts +161 -0
  154. package/src/services/logger/index.ts +3 -2
  155. package/src/services/persistence/localstorage.ts +2 -2
  156. package/src/services/persistence/resilient.ts +11 -4
  157. package/src/services/persistence/surrealdb.ts +10 -10
  158. package/src/services/stream-processor/index.ts +796 -84
  159. package/src/services/stream-processor/permissions.test.ts +47 -0
  160. package/src/services/stream-processor/permissions.ts +53 -0
  161. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  162. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  163. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  164. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  165. package/src/services/stream-processor/wasm-types.ts +59 -3
  166. package/src/services/tabs/broker-client.ts +283 -0
  167. package/src/services/tabs/broker.test.ts +327 -0
  168. package/src/services/tabs/coordinator.test.ts +365 -0
  169. package/src/services/tabs/coordinator.ts +633 -0
  170. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  171. package/src/services/tabs/leader-locks.ts +75 -0
  172. package/src/services/tabs/protocol.ts +262 -0
  173. package/src/services/tabs/support.ts +36 -0
  174. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  175. package/src/sp00ky.ts +5 -0
  176. package/src/state/client-state.ts +184 -0
  177. package/src/state/lifecycle.test.ts +118 -0
  178. package/src/state/lifecycle.ts +86 -0
  179. package/src/state/reducers.test.ts +280 -0
  180. package/src/state/reducers.ts +476 -0
  181. package/src/state/selectors.test.ts +149 -0
  182. package/src/state/selectors.ts +184 -0
  183. package/src/sync/connection.saga.test.ts +86 -0
  184. package/src/sync/connection.saga.ts +85 -0
  185. package/src/sync/live.saga.test.ts +69 -0
  186. package/src/sync/live.saga.ts +49 -0
  187. package/src/sync/policy.health.test.ts +56 -0
  188. package/src/sync/policy.test.ts +659 -0
  189. package/src/sync/policy.ts +502 -0
  190. package/src/sync/poll.saga.test.ts +51 -0
  191. package/src/sync/poll.saga.ts +36 -0
  192. package/src/sync/tabs.saga.test.ts +74 -0
  193. package/src/sync/tabs.saga.ts +92 -0
  194. package/src/testing/adapters.ts +133 -0
  195. package/src/testing/build.ts +61 -0
  196. package/src/testing/fake-services.ts +34 -0
  197. package/src/testing/run-pure.test.ts +92 -0
  198. package/src/testing/run-pure.ts +128 -0
  199. package/src/testing/services.ts +17 -0
  200. package/src/types.ts +550 -14
  201. package/src/utils/blurhash.ts +90 -0
  202. package/src/utils/error-classification.test.ts +54 -0
  203. package/src/utils/error-classification.ts +25 -0
  204. package/src/utils/index.ts +79 -13
  205. package/src/utils/parser.test.ts +49 -120
  206. package/src/utils/parser.ts +32 -2
  207. package/src/utils/semver.test.ts +32 -0
  208. package/src/utils/semver.ts +30 -0
  209. package/src/utils/surql.ts +30 -18
  210. package/src/utils/utils.test.ts +2 -1
  211. package/src/utils/withRetry.test.ts +1 -1
  212. package/tsdown.config.ts +86 -1
  213. package/vitest.config.ts +48 -0
  214. package/src/modules/cache/index.ts +0 -241
  215. package/src/modules/cache/types.ts +0 -19
  216. package/src/modules/data/data.test.ts +0 -58
  217. package/src/modules/data/index.ts +0 -864
  218. package/src/modules/sync/engine.ts +0 -166
  219. package/src/modules/sync/events/index.ts +0 -77
  220. package/src/modules/sync/index.ts +0 -3
  221. package/src/modules/sync/queue/index.ts +0 -2
  222. package/src/modules/sync/queue/queue-down.ts +0 -89
  223. package/src/modules/sync/queue/queue-up.ts +0 -223
  224. package/src/modules/sync/scheduler.ts +0 -84
  225. package/src/modules/sync/sync.ts +0 -409
  226. package/src/modules/sync/utils.test.ts +0 -311
  227. package/src/modules/sync/utils.ts +0 -170
  228. package/src/spooky.ts +0 -395
@@ -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,122 @@
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 } 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('gcTick', () => {
79
+ it('deletes bodies no view names and no outbox item touches; keeps _00_ rows; reschedules', async () => {
80
+ const s = buildState(
81
+ [],
82
+ R.setVersions([['thing:keep', 1], ['thing:gone', 1], ['thing:pending', 1], ['_00_view:x', 1], ['thing:failed', 1]]),
83
+ R.outboxReplace([buildOutboxItem({ recordId: 'thing:pending' })])
84
+ );
85
+ const deleted: string[] = [];
86
+ const out = await runPure(gcTick(), {
87
+ state: s,
88
+ handlers: {
89
+ 'local.query': () => [[{ ids: [['thing:keep', 1]] }, { ids: 'bad' }]],
90
+ 'local.delete': (e: any) => {
91
+ deleted.push(e.id);
92
+ if (e.id === 'thing:failed') throw new Error('busy');
93
+ },
94
+ 'ssp.ingest': () => undefined,
95
+ },
96
+ });
97
+ expect(deleted).toEqual(['thing:gone', 'thing:failed']);
98
+ expect([...out.state.versions.keys()]).toEqual(['thing:keep', 'thing:pending', '_00_view:x', 'thing:failed']);
99
+ const ingest = out.log.find((e) => e.kind === 'ssp.ingest') as any;
100
+ expect(ingest.records).toEqual([{ table: 'thing', op: 'DELETE', id: 'thing:gone', record: {} }]);
101
+ expect(out.timers.get('gc')!.event).toEqual({ type: 'GcTick' });
102
+ const failing = await runPure(gcTick(), {
103
+ state: s,
104
+ handlers: {
105
+ 'local.query': () => {
106
+ throw new Error('no table');
107
+ },
108
+ },
109
+ });
110
+ expect(failing.emitted).toEqual([expect.objectContaining({ message: 'orphan gc failed' })]);
111
+ expect(failing.timers.has('gc')).toBe(true);
112
+ const nothing = await runPure(gcTick(), { state: buildState(), handlers: { 'local.query': () => [] } });
113
+ expect(nothing.log.filter((e) => e.kind === 'ssp.ingest')).toHaveLength(0);
114
+ });
115
+ });
116
+
117
+ describe('evictQuery', () => {
118
+ it('is a no-op for unknown hashes', async () => {
119
+ const out = await runPure(evictQuery('zz'), { state: buildState() });
120
+ expect(out.emitted).toEqual([]);
121
+ });
122
+ });
@@ -0,0 +1,101 @@
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_INTERVAL_MS, TTL_HEARTBEAT_FRACTION } from '../kernel/constants';
5
+ import type { ClientState, QueryEntry } from '../state/client-state';
6
+ import * as R from '../state/reducers';
7
+ import { evictable, shortestTtlMs } from '../state/selectors';
8
+ import type { SagaEnv } from './env';
9
+ import * as sql from './sql';
10
+
11
+ /**
12
+ * One tick for every query in state: evict the ones nobody has watched for a
13
+ * ttl, heartbeat the rest in one request, notice reclaimed rows. Reschedules
14
+ * itself at half the shortest ttl.
15
+ */
16
+ export function* lifecycleTick(env: SagaEnv): Saga<void> {
17
+ const now = (yield fx.now()) as number;
18
+ const state = (yield fx.state.read((s) => s)) as ClientState;
19
+ for (const hash of evictable(state, now)) yield* evictQuery(hash);
20
+ const remaining = (yield fx.state.read((s) => [...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered'))) as QueryEntry[];
21
+ if (remaining.length > 0) {
22
+ const beat = sql.heartbeatBatch(remaining.map((e) => e.def.id));
23
+ try {
24
+ const results = (yield fx.remote.query(beat.sql, beat.vars, env.remoteTimeoutMs)) as StatementResult[];
25
+ let reclaimed = 0;
26
+ for (let i = 0; i < remaining.length; i++) {
27
+ const r = results[i];
28
+ if (r?.status === 'OK' && sql.heartbeatRowGone(r.result)) {
29
+ reclaimed++;
30
+ yield fx.state.update(R.applyLifecycle(remaining[i].def.hash, { type: 'remote-dropped' }));
31
+ }
32
+ }
33
+ yield fx.state.update(R.stampHeartbeat(remaining.map((e) => e.def.hash), now));
34
+ if (reclaimed > 0) {
35
+ yield fx.emit({ type: 'log', level: 'warn', message: 'query rows reclaimed by the server; re-registering', data: { reclaimed } });
36
+ yield fx.dispatch({ type: 'EnsureRegistered' });
37
+ }
38
+ yield fx.dispatch({ type: 'SyncOutcome', ok: true });
39
+ } catch (error) {
40
+ yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
41
+ }
42
+ }
43
+ const ttl = (yield fx.state.read((s) => shortestTtlMs(s))) as number | null;
44
+ yield fx.timer.set('lifecycle', Math.floor((ttl ?? env.defaultTtlMs) * TTL_HEARTBEAT_FRACTION), { type: 'LifecycleTick' });
45
+ }
46
+
47
+ /** Free a query's local view and forget it. The server row expires by TTL. */
48
+ export function* evictQuery(hash: string): Saga<void> {
49
+ const exists = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
50
+ if (!exists) return;
51
+ try {
52
+ yield fx.ssp.unregister(hash);
53
+ } catch (error) {
54
+ yield fx.emit({ type: 'log', level: 'debug', message: 'unregister failed', data: { hash, error } });
55
+ }
56
+ yield fx.state.update(R.removeQuery(hash));
57
+ yield fx.emit({ type: 'query:evicted', hash });
58
+ }
59
+
60
+ /** Drop acked outbox items membership never named within the grace window. */
61
+ export function* ackPrune(): Saga<void> {
62
+ const now = (yield fx.now()) as number;
63
+ yield fx.state.update(R.outboxPruneAcked(now, ACK_GRACE_MS));
64
+ const stillAcked = (yield fx.state.read((s) => s.outbox.some((i) => i.status === 'acked'))) as boolean;
65
+ if (stillAcked) yield fx.timer.set('ack-prune', ACK_GRACE_MS, { type: 'AckPrune' });
66
+ }
67
+
68
+ /**
69
+ * Weekly orphan collection: bodies the store holds that no `_00_view` row
70
+ * names and no outbox item touches are invisible; delete them in chunks.
71
+ */
72
+ export function* gcTick(): Saga<void> {
73
+ try {
74
+ const views = (yield fx.local.query(`SELECT ids FROM ${sql.VIEW_TABLE}`)) as unknown[];
75
+ const rows = Array.isArray(views?.[0]) ? (views[0] as Array<{ ids?: unknown }>) : [];
76
+ const keep = new Set<string>();
77
+ for (const row of rows) {
78
+ if (!Array.isArray(row.ids)) continue;
79
+ for (const pair of row.ids as Array<[string, number]>) keep.add(pair[0]);
80
+ }
81
+ const state = (yield fx.state.read((s) => s)) as ClientState;
82
+ for (const item of state.outbox) keep.add(item.recordId);
83
+ const orphans = [...state.versions.keys()].filter((id) => !keep.has(id) && !id.startsWith('_00_'));
84
+ const deletes = orphans.map((id) => {
85
+ const table = id.slice(0, id.indexOf(':'));
86
+ return fx.local.delete(table, id);
87
+ });
88
+ for (let i = 0; i < deletes.length; i += 200) {
89
+ const settled = (yield fx.all(deletes.slice(i, i + 200))) as Settled[];
90
+ const done = orphans.slice(i, i + 200).filter((_, j) => settled[j].ok);
91
+ yield fx.state.update(R.deleteVersions(done));
92
+ if (done.length > 0) {
93
+ yield fx.ssp.ingest(done.map((id) => ({ table: id.slice(0, id.indexOf(':')), op: 'DELETE', id, record: {} })));
94
+ }
95
+ }
96
+ yield fx.emit({ type: 'log', level: 'info', message: 'orphan gc done', data: { removed: orphans.length } });
97
+ } catch (error) {
98
+ yield fx.emit({ type: 'log', level: 'warn', message: 'orphan gc failed', data: { error } });
99
+ }
100
+ yield fx.timer.set('gc', GC_INTERVAL_MS, { type: 'GcTick' });
101
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { runPure } from '../testing/run-pure';
3
+ import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
4
+ import * as R from '../state/reducers';
5
+ import { materialize, streamUpdate } from './materialize.saga';
6
+
7
+ describe('materialize', () => {
8
+ const plan = { table: 'thing' } as any;
9
+ it('no entry: nothing', async () => {
10
+ const out = await runPure(materialize('zz'), { state: buildState() });
11
+ expect(out.log.filter((e) => e.kind !== 'state.read')).toHaveLength(0);
12
+ });
13
+ it('cold: predicate scan through the plan; rows land, dirt clears, subscribers hear once', async () => {
14
+ const s = R.markDirty(['a'])(buildState([buildEntry({ def: { hash: 'a', plan } })]));
15
+ const out = await runPure(materialize('a'), { state: s, handlers: { 'local.select': (e: any) => (e.plan.ids ? [] : [{ id: 'thing:1' }]) } });
16
+ const en = out.state.queries.get('a')!;
17
+ expect(en.records).toEqual([{ id: 'thing:1' }]);
18
+ expect(en.lifecycle.notified).toBe(true);
19
+ expect(en.telemetry.updateCount).toBe(1);
20
+ expect(en.telemetry.lastUpdatedAt).toBe(1_700_000_000_000);
21
+ expect(out.state.dirty.has('a')).toBe(false);
22
+ expect(out.emitted).toEqual([{ type: 'query:records', hash: 'a', records: [{ id: 'thing:1' }] }]);
23
+ const again = await runPure(materialize('a'), { state: out.state, handlers: { 'local.select': () => [{ id: 'thing:1' }] } });
24
+ expect(again.emitted).toEqual([]);
25
+ expect(again.state.queries.get('a')!.telemetry.updateCount).toBe(1);
26
+ });
27
+ it('cold window renders the SSP local window; live renders membership with the overlay', async () => {
28
+ const win = buildState([buildEntry({ def: { hash: 'w', plan: { ...plan, offset: 5 }, surql: 'SELECT * FROM thing LIMIT 5 START 5' }, localArray: [['thing:7', 1]] })]);
29
+ const w = await runPure(materialize('w'), { state: win, handlers: { 'local.select': (e: any) => e.plan.ids.map((id: any) => ({ id: `${id.table}:${id.id}` })) } });
30
+ expect(w.state.queries.get('w')!.records).toEqual([{ id: 'thing:7' }]);
31
+ const live = buildState(
32
+ [buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'live' }, remoteArray: [['thing:2', 1], ['thing:1', 1]], localArray: [['thing:9', 1]] })],
33
+ R.outboxReplace([buildOutboxItem({ id: 'c', type: 'create', recordId: 'thing:9' }), buildOutboxItem({ id: 'd', type: 'delete', recordId: 'thing:2' })])
34
+ );
35
+ const l = await runPure(materialize('a'), { state: live, handlers: { 'local.select': (e: any) => e.plan.ids.map((id: any) => ({ id: `${id.table}:${id.id}` })) } });
36
+ expect(l.state.queries.get('a')!.records).toEqual([{ id: 'thing:1' }, { id: 'thing:9' }]);
37
+ const ordered = buildState([buildEntry({ def: { hash: 'o', plan: { ...plan, orderBy: [['a', 'asc']] } }, lifecycle: { phase: 'live' }, remoteArray: [['thing:2', 1], ['thing:1', 1]] })]);
38
+ const o = await runPure(materialize('o'), { state: ordered, handlers: { 'local.select': (e: any) => e.plan.ids.map((id: any) => ({ id: `${id.table}:${id.id}` })) } });
39
+ expect(o.state.queries.get('o')!.records).toEqual([{ id: 'thing:2' }, { id: 'thing:1' }]);
40
+ });
41
+ it('a failing read counts an error and clears dirt; an entry removed mid-read is left alone', async () => {
42
+ const s = R.markDirty(['a'])(buildState([buildEntry({ def: { hash: 'a', plan } })]));
43
+ const fail = await runPure(materialize('a'), {
44
+ state: s,
45
+ handlers: {
46
+ 'local.select': () => {
47
+ throw new Error('locked');
48
+ },
49
+ },
50
+ });
51
+ expect(fail.state.queries.get('a')!.telemetry.errorCount).toBe(1);
52
+ expect(fail.state.dirty.has('a')).toBe(false);
53
+ expect(fail.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
54
+ const gone = await runPure(materialize('a'), {
55
+ state: s,
56
+ handlers: {
57
+ 'local.select': (_e, ctx) => {
58
+ ctx.state = R.removeQuery('a')(ctx.state);
59
+ return [];
60
+ },
61
+ },
62
+ });
63
+ expect(gone.emitted).toEqual([]);
64
+ });
65
+ });
66
+
67
+ describe('streamUpdate', () => {
68
+ it('takes the local id-set (dirtying the query) and records ingest timings; unknown hashes are ignored', async () => {
69
+ const s = buildState([buildEntry({ def: { hash: 'a' } })]);
70
+ const out = await runPure(streamUpdate({ queryHash: 'a', localArray: [['thing:1', 1]], op: 'CREATE', materializationTimeMs: 4, storeApplyMs: 1, circuitStepMs: 2 }), { state: s });
71
+ const en = out.state.queries.get('a')!;
72
+ expect(en.localArray).toEqual([['thing:1', 1]]);
73
+ expect(out.state.dirty.has('a')).toBe(true);
74
+ expect(en.telemetry.lastIngestLatencyMs).toBe(4);
75
+ expect(en.telemetry.materializationSamples).toEqual([4]);
76
+ expect(en.telemetry.phaseLast).toEqual({ sspStoreApply: 1, sspCircuitStep: 2 });
77
+ const bare = await runPure(streamUpdate({ queryHash: 'a', localArray: [] }), { state: s });
78
+ expect(bare.state.queries.get('a')!.telemetry.lastIngestLatencyMs).toBeNull();
79
+ const unknown = await runPure(streamUpdate({ queryHash: 'zz', localArray: [] }), { state: s });
80
+ expect(unknown.log.filter((e) => e.kind === 'state.update')).toHaveLength(0);
81
+ });
82
+ });
@@ -0,0 +1,75 @@
1
+ import type { QueryHash } from '../types';
2
+ import type { StreamUpdate } from '../services/stream-processor/index';
3
+ import type { Saga } from '../kernel/saga';
4
+ import { fx } from '../kernel/effects';
5
+ import type { QueryEntry } from '../state/client-state';
6
+ import * as R from '../state/reducers';
7
+ import { overlay } from '../state/selectors';
8
+ import type { Overlay } from '../state/selectors';
9
+ import { isWindowed, materializeEffect, rowsEqual, rowsFromResult } from './materialize';
10
+ import { buildRenderIds, resolveMembership } from './render-set';
11
+
12
+ /**
13
+ * Render one query from state: membership (or a cold scan), the outbox
14
+ * overlay, one local read, then notify subscribers if the rows changed. The
15
+ * only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
16
+ * query is dirty.
17
+ */
18
+ export function* materialize(hash: QueryHash): Saga<void> {
19
+ const [entry, ov] = (yield fx.state.read((s) => [s.queries.get(hash), overlay(s)])) as [QueryEntry | undefined, Overlay];
20
+ if (!entry) return;
21
+ const t0 = (yield fx.now()) as number;
22
+ const isWindow = isWindowed(entry.def.surql);
23
+ const membership = resolveMembership({
24
+ phase: entry.lifecycle.phase,
25
+ remoteArray: entry.remoteArray,
26
+ localArray: entry.localArray,
27
+ isWindow,
28
+ });
29
+ const ids = membership
30
+ ? buildRenderIds(membership, entry.localArray, ov, {
31
+ hasExplicitOrder: (entry.def.plan?.orderBy?.length ?? 0) > 0,
32
+ isWindow,
33
+ })
34
+ : null;
35
+ const effect = materializeEffect(entry.def, ids);
36
+ let rows: Record<string, unknown>[];
37
+ try {
38
+ rows = rowsFromResult(effect, yield effect);
39
+ } catch (error) {
40
+ yield fx.state.update(R.compose(R.recordError(hash), R.clearDirty(hash)));
41
+ yield fx.emit({ type: 'log', level: 'warn', message: 'materialize failed', data: { hash, error } });
42
+ return;
43
+ }
44
+ const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
45
+ if (!current) return;
46
+ const t1 = (yield fx.now()) as number;
47
+ const changed = !rowsEqual(rows, current.records);
48
+ yield fx.state.update(
49
+ R.compose(R.setRecords(hash, rows, changed, t1 - t0), changed ? R.stampUpdated(hash, t1) : (s) => s)
50
+ );
51
+ if (changed) yield fx.emit({ type: 'query:records', hash, records: rows });
52
+ }
53
+
54
+ /**
55
+ * A view update from the in-browser SSP: the query's local id-set moved.
56
+ * State takes the array (which dirties the query); the ingest timings feed
57
+ * telemetry. Synthetic updates no longer exist: re-materialization is dirt.
58
+ */
59
+ export function* streamUpdate(update: StreamUpdate): Saga<void> {
60
+ const hash = update.queryHash;
61
+ const exists = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
62
+ if (!exists) return;
63
+ const phases: Array<[string, number | undefined]> = [
64
+ ['sspStoreApply', update.storeApplyMs],
65
+ ['sspCircuitStep', update.circuitStepMs],
66
+ ['sspTransform', update.transformMs],
67
+ ];
68
+ yield fx.state.update(
69
+ R.compose(
70
+ R.setLocalArray(hash, update.localArray),
71
+ typeof update.materializationTimeMs === 'number' ? R.recordIngest(hash, update.materializationTimeMs) : (s) => s,
72
+ ...phases.filter((p): p is [string, number] => typeof p[1] === 'number').map(([phase, ms]) => R.recordPhase(hash, phase, ms))
73
+ )
74
+ );
75
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { isWindowed, materializeEffect, rowsEqual, rowsFromResult } from './materialize';
4
+
5
+ describe('materializeEffect', () => {
6
+ const plan = { table: 'thing', where: [{ field: 'a', op: '=', value: 1 }] } as any;
7
+ it('plan + ids: id-set plan select without where/limit/offset', () => {
8
+ const e = materializeEffect({ surql: 'SELECT * FROM thing', params: { a: 1 }, plan: { ...plan, limit: 5, offset: 2 } }, ['thing:1']);
9
+ expect(e.kind).toBe('local.select');
10
+ if (e.kind !== 'local.select') throw new Error();
11
+ expect(e.plan.ids).toEqual([new RecordId('thing', '1')]);
12
+ expect(e.plan.where).toBeUndefined();
13
+ expect(e.plan.limit).toBeUndefined();
14
+ expect(e.params).toEqual({ a: 1 });
15
+ });
16
+ it('surql + ids: id-set rewrite bound to __win; unparseable surql falls back to the raw query', () => {
17
+ const e = materializeEffect({ surql: 'SELECT * FROM thing WHERE a = $a ORDER BY b LIMIT 3', params: { a: 1 } }, ['thing:1']);
18
+ expect(e).toMatchObject({ kind: 'local.query', sql: 'SELECT * FROM $__win ORDER BY b' });
19
+ if (e.kind !== 'local.query') throw new Error();
20
+ expect(e.vars).toEqual({ a: 1, __win: [new RecordId('thing', '1')] });
21
+ const raw = materializeEffect({ surql: 'RETURN 1', params: {} }, ['thing:1']);
22
+ expect(raw).toMatchObject({ kind: 'local.query', sql: 'RETURN 1' });
23
+ });
24
+ it('null ids: predicate scan via plan or raw surql', () => {
25
+ expect(materializeEffect({ surql: 's', params: {}, plan }, null)).toMatchObject({ kind: 'local.select', plan });
26
+ expect(materializeEffect({ surql: 'SELECT * FROM thing', params: { x: 1 } }, null)).toMatchObject({
27
+ kind: 'local.query',
28
+ sql: 'SELECT * FROM thing',
29
+ vars: { x: 1 },
30
+ });
31
+ });
32
+ it('rowsFromResult reads both effect shapes; rowsEqual compares structurally', () => {
33
+ const sel = materializeEffect({ surql: 's', params: {}, plan }, null);
34
+ const q = materializeEffect({ surql: 'SELECT * FROM thing', params: {} }, null);
35
+ expect(rowsFromResult(sel, [{ id: 1 }])).toEqual([{ id: 1 }]);
36
+ expect(rowsFromResult(sel, undefined)).toEqual([]);
37
+ expect(rowsFromResult(q, [[{ id: 1 }]])).toEqual([{ id: 1 }]);
38
+ expect(rowsFromResult(q, [null])).toEqual([]);
39
+ expect(rowsFromResult(q, 'x')).toEqual([]);
40
+ const rows = [{ id: 1 }];
41
+ expect(rowsEqual(rows, rows)).toBe(true);
42
+ expect(rowsEqual(rows, [{ id: 1 }])).toBe(true);
43
+ expect(rowsEqual(rows, [{ id: 2 }])).toBe(false);
44
+ expect(rowsEqual(rows, [])).toBe(false);
45
+ });
46
+ it('isWindowed', () => {
47
+ expect(isWindowed('SELECT * FROM t LIMIT 10 START 10')).toBe(true);
48
+ expect(isWindowed('SELECT * FROM t LIMIT 10')).toBe(false);
49
+ });
50
+ });
@@ -0,0 +1,44 @@
1
+ import type { QueryPlan } from '@spooky-sync/query-builder';
2
+ import type { Effect } from '../kernel/effects';
3
+ import { fx } from '../kernel/effects';
4
+ import { parseRecordIdString } from '../utils/index';
5
+ import { buildIdSetPlan, buildIdSetSurql, buildWindowMaterialization } from './window-query';
6
+
7
+ export interface MaterializeSource {
8
+ surql: string;
9
+ params: Record<string, unknown>;
10
+ plan?: QueryPlan;
11
+ }
12
+
13
+ export const isWindowed = (surql: string): boolean => buildWindowMaterialization(surql) !== null;
14
+
15
+ /**
16
+ * The one read that materializes a query. `ids` is the render set (membership
17
+ * with the overlay applied); `null` means "no membership yet, scan the local
18
+ * store with the query's own predicate".
19
+ */
20
+ export function materializeEffect(src: MaterializeSource, ids: string[] | null): Effect {
21
+ if (ids !== null) {
22
+ const parsed = ids.map((id) => parseRecordIdString(id));
23
+ if (src.plan) return fx.local.select(buildIdSetPlan(src.plan, parsed), src.params);
24
+ const idSet = buildIdSetSurql(src.surql);
25
+ if (idSet) return fx.local.query(idSet.query, { ...src.params, __win: parsed });
26
+ return fx.local.query(src.surql, src.params);
27
+ }
28
+ if (src.plan) return fx.local.select(src.plan, src.params);
29
+ return fx.local.query(src.surql, src.params);
30
+ }
31
+
32
+ /** Rows out of either read effect's result. */
33
+ export function rowsFromResult(effect: Effect, result: unknown): Record<string, unknown>[] {
34
+ if (effect.kind === 'local.select') return (result as Record<string, unknown>[] | undefined) ?? [];
35
+ const first = Array.isArray(result) ? result[0] : undefined;
36
+ return Array.isArray(first) ? (first as Record<string, unknown>[]) : [];
37
+ }
38
+
39
+ /** Cheap structural equality for the "did the rows change" check. */
40
+ export function rowsEqual(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean {
41
+ if (a === b) return true;
42
+ if (a.length !== b.length) return false;
43
+ return JSON.stringify(a) === JSON.stringify(b);
44
+ }