@spooky-sync/core 0.0.1-canary.283 → 0.0.1-canary.287

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 (45) hide show
  1. package/AGENTS.md +1 -0
  2. package/dist/index.d.ts +6 -0
  3. package/dist/index.js +306 -53
  4. package/dist/live.js +1 -1
  5. package/dist/pure.d.ts +9 -2
  6. package/dist/pure.js +2 -2
  7. package/dist/sqlite-open.js +28 -1
  8. package/dist/sqlite-worker.js +1 -1
  9. package/dist/worker-edges.js +10 -2
  10. package/package.json +3 -3
  11. package/skills/sp00ky-core/SKILL.md +9 -6
  12. package/src/boot/boot.saga.test.ts +9 -2
  13. package/src/boot/boot.saga.ts +4 -1
  14. package/src/boot/bucket-switch.saga.ts +2 -0
  15. package/src/client/runtime.test.ts +4 -1
  16. package/src/client/sp00ky-client.test.ts +8 -1
  17. package/src/kernel/constants.ts +15 -2
  18. package/src/query/fetch.saga.test.ts +44 -1
  19. package/src/query/fetch.saga.ts +7 -2
  20. package/src/query/lifecycle.saga.test.ts +169 -17
  21. package/src/query/lifecycle.saga.ts +86 -28
  22. package/src/query/materialize.saga.test.ts +36 -3
  23. package/src/query/materialize.saga.ts +15 -6
  24. package/src/query/materialize.test.ts +9 -1
  25. package/src/query/materialize.ts +14 -1
  26. package/src/query/membership.saga.test.ts +30 -2
  27. package/src/query/membership.saga.ts +18 -2
  28. package/src/query/membership.ts +30 -0
  29. package/src/query/render-set.test.ts +12 -1
  30. package/src/query/render-set.ts +11 -0
  31. package/src/query/sql.test.ts +2 -1
  32. package/src/query/sql.ts +15 -1
  33. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  34. package/src/services/database/sqlite-cache-engine.ts +9 -5
  35. package/src/services/database/sqlite-plan-sql.test.ts +35 -0
  36. package/src/services/database/sqlite-plan-sql.ts +26 -0
  37. package/src/state/client-state.ts +7 -0
  38. package/src/state/reducers.test.ts +19 -0
  39. package/src/state/reducers.ts +41 -3
  40. package/src/state/selectors.test.ts +18 -0
  41. package/src/state/selectors.ts +32 -0
  42. package/src/sync/tabs.saga.test.ts +4 -0
  43. package/src/sync/tabs.saga.ts +3 -0
  44. package/src/utils/parser.test.ts +37 -1
  45. package/src/utils/parser.ts +32 -0
@@ -1,11 +1,13 @@
1
1
  import type { Saga } from '../kernel/saga';
2
2
  import type { Settled, StatementResult } from '../kernel/effects';
3
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';
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';
6
7
  import * as R from '../state/reducers';
7
- import { evictable, shortestTtlMs } from '../state/selectors';
8
+ import { evictable, retained, shortestTtlMs } from '../state/selectors';
8
9
  import type { SagaEnv } from './env';
10
+ import { parseViewIndex, type ViewIndexRow } from './membership';
9
11
  import * as sql from './sql';
10
12
 
11
13
  /**
@@ -65,37 +67,93 @@ export function* ackPrune(): Saga<void> {
65
67
  if (stillAcked) yield fx.timer.set('ack-prune', ACK_GRACE_MS, { type: 'AckPrune' });
66
68
  }
67
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
+
68
92
  /**
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.
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.
71
99
  */
72
100
  export function* gcTick(): Saga<void> {
101
+ yield fx.state.wait((s) => s.primed);
73
102
  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]);
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));
80
116
  }
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 } });
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 } });
97
121
  } catch (error) {
98
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;
99
154
  }
100
- yield fx.timer.set('gc', GC_INTERVAL_MS, { type: 'GcTick' });
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;
101
159
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
2
3
  import { runPure } from '../testing/run-pure';
3
4
  import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
4
5
  import * as R from '../state/reducers';
@@ -10,8 +11,9 @@ describe('materialize', () => {
10
11
  const out = await runPure(materialize('zz'), { state: buildState() });
11
12
  expect(out.log.filter((e) => e.kind !== 'state.read')).toHaveLength(0);
12
13
  });
14
+ const vouch = (...ids: string[]) => R.reloadViews(new Map([['other-view', ids]]));
13
15
  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 } })]));
16
+ const s = R.markDirty(['a'])(buildState([buildEntry({ def: { hash: 'a', plan } })], vouch('thing:1')));
15
17
  const out = await runPure(materialize('a'), { state: s, handlers: { 'local.select': (e: any) => (e.plan.ids ? [] : [{ id: 'thing:1' }]) } });
16
18
  const en = out.state.queries.get('a')!;
17
19
  expect(en.records).toEqual([{ id: 'thing:1' }]);
@@ -24,8 +26,39 @@ describe('materialize', () => {
24
26
  expect(again.emitted).toEqual([]);
25
27
  expect(again.state.queries.get('a')!.telemetry.updateCount).toBe(1);
26
28
  });
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
+ it('cold: a body no view vouches for (an orphan) does not paint; vouched, optimistic, _00_ and id-less rows do', async () => {
30
+ const s = buildState(
31
+ [buildEntry({ def: { hash: 'a', plan } })],
32
+ vouch('thing:1', 'thing:4'),
33
+ R.outboxReplace([buildOutboxItem({ id: 'c', type: 'create', recordId: 'thing:3' })])
34
+ );
35
+ const scanned = [
36
+ { id: 'thing:1' },
37
+ { id: 'thing:2' },
38
+ { id: 'thing:3' },
39
+ { id: '_00_user_feature:f' },
40
+ { name: 'no id' },
41
+ { id: new RecordId('thing', '4') },
42
+ { id: new RecordId('thing', '5') },
43
+ ];
44
+ const out = await runPure(materialize('a'), { state: s, handlers: { 'local.select': () => scanned } });
45
+ expect(out.state.queries.get('a')!.records).toEqual([
46
+ { id: 'thing:1' },
47
+ { id: 'thing:3' },
48
+ { id: '_00_user_feature:f' },
49
+ { name: 'no id' },
50
+ { id: new RecordId('thing', '4') },
51
+ ]);
52
+ // Once the server has answered, membership decides and the index is not consulted.
53
+ const live = buildState([buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'live' }, remoteArray: [['thing:2', 1]] })]);
54
+ const l = await runPure(materialize('a'), { state: live, handlers: { 'local.select': (e: any) => e.plan.ids.map((id: any) => ({ id: `${id.table}:${id.id}` })) } });
55
+ expect(l.state.queries.get('a')!.records).toEqual([{ id: 'thing:2' }]);
56
+ });
57
+ it('cold window renders the SSP local window (orphans dropped); live renders membership with the overlay', async () => {
58
+ const win = buildState(
59
+ [buildEntry({ def: { hash: 'w', plan: { ...plan, offset: 5 }, surql: 'SELECT * FROM thing LIMIT 5 START 5' }, localArray: [['thing:7', 1], ['thing:8', 1]] })],
60
+ vouch('thing:7')
61
+ );
29
62
  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
63
  expect(w.state.queries.get('w')!.records).toEqual([{ id: 'thing:7' }]);
31
64
  const live = buildState(
@@ -4,26 +4,34 @@ import type { Saga } from '../kernel/saga';
4
4
  import { fx } from '../kernel/effects';
5
5
  import type { QueryEntry } from '../state/client-state';
6
6
  import * as R from '../state/reducers';
7
- import { overlay } from '../state/selectors';
7
+ import { overlay, vouched } from '../state/selectors';
8
8
  import type { Overlay } from '../state/selectors';
9
- import { isWindowed, materializeEffect, rowsEqual, rowsFromResult } from './materialize';
10
- import { buildRenderIds, resolveMembership } from './render-set';
9
+ import { isWindowed, materializeEffect, rowKey, rowsEqual, rowsFromResult } from './materialize';
10
+ import { buildRenderIds, paintsCold, resolveMembership } from './render-set';
11
+
12
+ const NONE: ReadonlySet<string> = new Set();
11
13
 
12
14
  /**
13
15
  * Render one query from state: membership (or a cold scan), the outbox
14
16
  * overlay, one local read, then notify subscribers if the rows changed. The
15
17
  * only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
16
- * query is dirty.
18
+ * query is dirty. Before the server has answered (`cold`) only bodies some
19
+ * durable view vouches for paint (see `paintsCold`).
17
20
  */
18
21
  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];
22
+ const [entry, ov, known] = (yield fx.state.read((s) => {
23
+ const e = s.queries.get(hash);
24
+ return [e, overlay(s), e?.lifecycle.phase === 'cold' ? vouched(s) : NONE];
25
+ })) as [QueryEntry | undefined, Overlay, ReadonlySet<string>];
20
26
  if (!entry) return;
21
27
  const t0 = (yield fx.now()) as number;
22
28
  const isWindow = isWindowed(entry.def.surql);
29
+ const paints = (id: string | null) => id === null || paintsCold(id, known, ov);
30
+ const coldWindow = entry.lifecycle.phase === 'cold' && isWindow;
23
31
  const membership = resolveMembership({
24
32
  phase: entry.lifecycle.phase,
25
33
  remoteArray: entry.remoteArray,
26
- localArray: entry.localArray,
34
+ localArray: coldWindow ? entry.localArray.filter(([id]) => paints(id)) : entry.localArray,
27
35
  isWindow,
28
36
  });
29
37
  const ids = membership
@@ -36,6 +44,7 @@ export function* materialize(hash: QueryHash): Saga<void> {
36
44
  let rows: Record<string, unknown>[];
37
45
  try {
38
46
  rows = rowsFromResult(effect, yield effect);
47
+ if (ids === null) rows = rows.filter((row) => paints(rowKey(row)));
39
48
  } catch (error) {
40
49
  yield fx.state.update(R.compose(R.recordError(hash), R.clearDirty(hash)));
41
50
  yield fx.emit({ type: 'log', level: 'warn', message: 'materialize failed', data: { hash, error } });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { RecordId } from 'surrealdb';
3
- import { isWindowed, materializeEffect, rowsEqual, rowsFromResult } from './materialize';
3
+ import { isWindowed, materializeEffect, rowKey, rowsEqual, rowsFromResult } from './materialize';
4
4
 
5
5
  describe('materializeEffect', () => {
6
6
  const plan = { table: 'thing', where: [{ field: 'a', op: '=', value: 1 }] } as any;
@@ -48,3 +48,11 @@ describe('materializeEffect', () => {
48
48
  expect(isWindowed('SELECT * FROM t LIMIT 10')).toBe(false);
49
49
  });
50
50
  });
51
+
52
+ describe('rowKey', () => {
53
+ it('reads a string id (SQLite) or a RecordId (SurrealDB); null when the row carries none', () => {
54
+ expect(rowKey({ id: 'thing:1' })).toBe('thing:1');
55
+ expect(rowKey({ id: new RecordId('thing', '2') })).toBe('thing:2');
56
+ for (const row of [{ name: 'x' }, { id: null }, { id: { other: 1 } }, null, 5]) expect(rowKey(row)).toBeNull();
57
+ });
58
+ });
@@ -1,7 +1,8 @@
1
1
  import type { QueryPlan } from '@spooky-sync/query-builder';
2
+ import type { RecordId } from 'surrealdb';
2
3
  import type { Effect } from '../kernel/effects';
3
4
  import { fx } from '../kernel/effects';
4
- import { parseRecordIdString } from '../utils/index';
5
+ import { encodeRecordId, parseRecordIdString } from '../utils/index';
5
6
  import { buildIdSetPlan, buildIdSetSurql, buildWindowMaterialization } from './window-query';
6
7
 
7
8
  export interface MaterializeSource {
@@ -36,6 +37,18 @@ export function rowsFromResult(effect: Effect, result: unknown): Record<string,
36
37
  return Array.isArray(first) ? (first as Record<string, unknown>[]) : [];
37
38
  }
38
39
 
40
+ /**
41
+ * A row's encoded record id: a string on the SQLite engine, a `RecordId` on
42
+ * the SurrealDB one. `null` for a row that carries none (a projection
43
+ * without `id`, a `SELECT VALUE`).
44
+ */
45
+ export function rowKey(row: unknown): string | null {
46
+ const id = row !== null && typeof row === 'object' ? (row as { id?: unknown }).id : undefined;
47
+ if (typeof id === 'string') return id;
48
+ if (id !== null && typeof id === 'object' && 'table' in id && 'id' in id) return encodeRecordId(id as RecordId<string>);
49
+ return null;
50
+ }
51
+
39
52
  /** Cheap structural equality for the "did the rows change" check. */
40
53
  export function rowsEqual(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean {
41
54
  if (a === b) return true;
@@ -74,7 +74,7 @@ describe('applyMembership', () => {
74
74
  });
75
75
  it('applied: commits, writes the view row, flips authority once, releases acked items, asks for bodies', async () => {
76
76
  const s = buildState(
77
- [buildEntry({ def: { hash: 'a', viewKey: 'vk' }, lifecycle: { phase: 'cold' } })],
77
+ [buildEntry({ def: { hash: 'a', viewKey: 'vk' }, lifecycle: { phase: 'cold' }, subqueryRemoteArray: [['c:1', 1]] })],
78
78
  R.outboxReplace([buildOutboxItem({ id: 'm', recordId: 't:1', status: 'acked', ackedAt: 1 })])
79
79
  );
80
80
  const out = await runPure(applyMembership('a', [['t:1', 1]], ready(1)), { state: s, now: 42, handlers: { 'local.upsert': () => undefined } });
@@ -86,9 +86,11 @@ describe('applyMembership', () => {
86
86
  kind: 'local.upsert',
87
87
  table: '_00_view',
88
88
  id: new RecordId('_00_view', 'vk'),
89
- data: { ids: [['t:1', 1]], confirmed: true, updatedAt: 42 },
89
+ data: { ids: [['t:1', 1]], children: [['c:1', 1]], confirmed: true, updatedAt: 42 },
90
90
  mode: 'replace',
91
91
  });
92
+ // The index vouches for what the row now holds: members and children.
93
+ expect(out.state.views.get('vk')).toEqual(['t:1', 'c:1']);
92
94
  expect(out.dispatched).toEqual([{ type: 'FetchRows' }]);
93
95
  const second = await runPure(applyMembership('a', [['t:1', 2]], ready(1)), {
94
96
  state: out.state,
@@ -116,6 +118,32 @@ describe('applySubqueryChildren', () => {
116
118
  const out = await runPure(applySubqueryChildren('a', [['c:2', 1]]), { state: s });
117
119
  expect(out.state.queries.get('a')!.subqueryRemoteArray).toEqual([['c:2', 1]]);
118
120
  expect(out.dispatched).toEqual([{ type: 'FetchRows' }]);
121
+ // Cold: no `_00_view` row to record them in, and the index is untouched.
122
+ expect(out.log.filter((e) => e.kind === 'local.upsert')).toEqual([]);
123
+ expect(out.state.views.size).toBe(0);
124
+ });
125
+ it('a query with a view row records the children there and in the index; a failed write is logged', async () => {
126
+ const s = buildState([buildEntry({ def: { hash: 'a', viewKey: 'vk' }, lifecycle: { phase: 'live' }, remoteArray: [['t:1', 1]] })]);
127
+ const out = await runPure(applySubqueryChildren('a', [['c:2', 1]]), { state: s, handlers: { 'local.upsert': () => undefined } });
128
+ expect(out.log.find((e) => e.kind === 'local.upsert')).toEqual({
129
+ kind: 'local.upsert',
130
+ table: '_00_view',
131
+ id: new RecordId('_00_view', 'vk'),
132
+ data: { children: [['c:2', 1]] },
133
+ mode: 'merge',
134
+ });
135
+ expect(out.state.views.get('vk')).toEqual(['t:1', 'c:2']);
136
+ expect(out.dispatched).toEqual([{ type: 'FetchRows' }]);
137
+ const failed = await runPure(applySubqueryChildren('a', [['c:3', 1]]), {
138
+ state: s,
139
+ handlers: {
140
+ 'local.upsert': () => {
141
+ throw new Error('disk');
142
+ },
143
+ },
144
+ });
145
+ expect(failed.emitted).toEqual([expect.objectContaining({ level: 'debug', message: 'view row write failed' })]);
146
+ expect(failed.dispatched).toEqual([{ type: 'FetchRows' }]);
119
147
  });
120
148
  });
121
149
 
@@ -89,7 +89,12 @@ export function* applyMembership(
89
89
  if (!wasAuthoritative) yield fx.emit({ type: 'query:authority', hash, known: true });
90
90
  const now = (yield fx.now()) as number;
91
91
  try {
92
- yield fx.local.upsert(sql.VIEW_TABLE, sql.viewRecordId(entry.def.viewKey), sql.viewRow(remoteArray, true, now), 'replace');
92
+ yield fx.local.upsert(
93
+ sql.VIEW_TABLE,
94
+ sql.viewRecordId(entry.def.viewKey),
95
+ sql.viewRow(remoteArray, entry.subqueryRemoteArray, true, now),
96
+ 'replace'
97
+ );
93
98
  } catch (err) {
94
99
  yield fx.emit({ type: 'log', level: 'debug', message: 'view row write failed', data: { hash, err } });
95
100
  }
@@ -112,11 +117,22 @@ export function* recoverLostView(hash: QueryHash): Saga<void> {
112
117
  yield fx.dispatch({ type: 'RegisterRemote', hash });
113
118
  }
114
119
 
115
- /** Replace the subquery child set; bodies follow through the fetch plan. */
120
+ /**
121
+ * Replace the subquery child set; bodies follow through the fetch plan. A
122
+ * query with a `_00_view` row (past `cold`) records the children there too,
123
+ * so the GC keeps their bodies and a cold scan may paint them.
124
+ */
116
125
  export function* applySubqueryChildren(hash: QueryHash, children: RecordVersionArray): Saga<void> {
117
126
  const entry = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
118
127
  if (!entry || recordVersionArraysEqual(entry.subqueryRemoteArray, children)) return;
119
128
  yield fx.state.update(R.setSubqueryRemoteArray(hash, children));
129
+ if (entry.lifecycle.phase !== 'cold') {
130
+ try {
131
+ yield fx.local.upsert(sql.VIEW_TABLE, sql.viewRecordId(entry.def.viewKey), { children }, 'merge');
132
+ } catch (err) {
133
+ yield fx.emit({ type: 'log', level: 'debug', message: 'view row write failed', data: { hash, err } });
134
+ }
135
+ }
120
136
  yield fx.dispatch({ type: 'FetchRows' });
121
137
  }
122
138
 
@@ -16,6 +16,36 @@ export function parseViewRow(row: unknown): DurableView | null {
16
16
  return { ids: ids as RecordVersionArray, confirmed: (row as { confirmed?: unknown }).confirmed === true };
17
17
  }
18
18
 
19
+ /** One `_00_view` row as the view index and the GC see it. */
20
+ export interface ViewIndexRow {
21
+ key: string;
22
+ /** Members and subquery children: what the row vouches for. */
23
+ ids: string[];
24
+ /** Last time a session resolved the query (0 when the row predates the field). */
25
+ updatedAt: number;
26
+ }
27
+
28
+ const pairIds = (pairs: unknown): string[] =>
29
+ Array.isArray(pairs) ? pairs.filter((p): p is [string, number] => Array.isArray(p) && typeof p[0] === 'string').map((p) => p[0]) : [];
30
+
31
+ /** `SELECT * FROM _00_view` rows, keyed by view key; junk rows are skipped. */
32
+ export function parseViewIndex(rows: unknown): ViewIndexRow[] {
33
+ if (!Array.isArray(rows)) return [];
34
+ const out: ViewIndexRow[] = [];
35
+ for (const row of rows as Array<Record<string, unknown> | null>) {
36
+ if (!row || typeof row !== 'object' || !Array.isArray(row.ids)) continue;
37
+ const id = row.id;
38
+ const key = typeof id === 'string' ? id.slice(id.indexOf(':') + 1) : String((id as { id?: unknown } | undefined)?.id ?? '');
39
+ if (!key) continue;
40
+ out.push({
41
+ key,
42
+ ids: [...new Set([...pairIds(row.ids), ...pairIds(row.children)])],
43
+ updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0,
44
+ });
45
+ }
46
+ return out;
47
+ }
48
+
19
49
  /**
20
50
  * "Resolved before": the server answered this query on this device at some
21
51
  * point. An unconfirmed empty row cannot be told apart from one written before
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { buildRenderIds, resolveMembership } from './render-set';
2
+ import { buildRenderIds, paintsCold, resolveMembership } from './render-set';
3
3
 
4
4
  const ov = (writes: string[] = [], deletes: string[] = []) => ({ writes: new Set(writes), deletes: new Set(deletes) });
5
5
 
@@ -34,3 +34,14 @@ describe('buildRenderIds', () => {
34
34
  expect(buildRenderIds(m, [], ov(), { hasExplicitOrder: false, isWindow: true })).toEqual(['t:b', 't:a']);
35
35
  });
36
36
  });
37
+
38
+ describe('paintsCold', () => {
39
+ it('only vouched bodies, optimistic writes and bookkeeping paint before the server answers', () => {
40
+ const vouched = new Set(['t:1']);
41
+ expect(paintsCold('t:1', vouched, ov())).toBe(true);
42
+ expect(paintsCold('t:2', vouched, ov(['t:2']))).toBe(true);
43
+ expect(paintsCold('_00_user_feature:f', vouched, ov())).toBe(true);
44
+ expect(paintsCold('t:3', vouched, ov([], ['t:3']))).toBe(false);
45
+ expect(paintsCold('t:3', vouched, ov())).toBe(false);
46
+ });
47
+ });
@@ -21,6 +21,17 @@ export function resolveMembership(input: RenderInput): RecordVersionArray | null
21
21
  return null;
22
22
  }
23
23
 
24
+ /**
25
+ * Whether a body may paint before the server has answered (`cold`). One no
26
+ * durable view vouches for and no optimistic write touches is an orphan:
27
+ * deleted upstream while no view watched it, or dropped from every view since.
28
+ * The predicate scan would otherwise resurrect it until the server answers,
29
+ * and indefinitely offline.
30
+ */
31
+ export function paintsCold(id: string, vouched: ReadonlySet<string>, overlay: Overlay): boolean {
32
+ return id.startsWith('_00_') || vouched.has(id) || overlay.writes.has(id);
33
+ }
34
+
24
35
  export interface RenderOptions {
25
36
  hasExplicitOrder: boolean;
26
37
  isWindow: boolean;
@@ -33,7 +33,8 @@ describe('query sql builders (golden against the previous builders)', () => {
33
33
  });
34
34
  it('view rows and ids', () => {
35
35
  expect(sql.viewRecordId('k')).toEqual(new RecordId('_00_view', 'k'));
36
- expect(sql.viewRow([['t:1', 1]], true, 5)).toEqual({ ids: [['t:1', 1]], confirmed: true, updatedAt: 5 });
36
+ expect(sql.viewRow([['t:1', 1]], [['c:1', 2]], true, 5)).toEqual({ ids: [['t:1', 1]], children: [['c:1', 2]], confirmed: true, updatedAt: 5 });
37
+ expect(sql.readViewRows()).toBe('SELECT * FROM _00_view');
37
38
  expect(sql.readLegacyViewRows()).toBe('SELECT * FROM _00_window');
38
39
  expect(sql.countViewRows()).toContain('_00_view');
39
40
  expect(sql.bodySelect()).toBe('SELECT * FROM $ids');
package/src/query/sql.ts CHANGED
@@ -11,12 +11,26 @@ export const LEGACY_VIEW_TABLE = '_00_window';
11
11
 
12
12
  export const viewRecordId = (viewKey: string): RecordId<string> => new RecordId(VIEW_TABLE, viewKey);
13
13
 
14
- export const viewRow = (ids: RecordVersionArray, confirmed: boolean, now: number): Record<string, unknown> => ({
14
+ /**
15
+ * The durable `_00_view` row: the confirmed membership plus the subquery
16
+ * children, which the GC must keep and a cold scan may paint as much as the
17
+ * members.
18
+ */
19
+ export const viewRow = (
20
+ ids: RecordVersionArray,
21
+ children: RecordVersionArray,
22
+ confirmed: boolean,
23
+ now: number
24
+ ): Record<string, unknown> => ({
15
25
  ids,
26
+ children,
16
27
  confirmed,
17
28
  updatedAt: now,
18
29
  });
19
30
 
31
+ /** Every `_00_view` row: the durable view index and the GC read it. */
32
+ export const readViewRows = (): string => `SELECT * FROM ${VIEW_TABLE}`;
33
+
20
34
  /** Copy every legacy `_00_window` row into `_00_view` once. */
21
35
  export const readLegacyViewRows = (): string => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
22
36
  export const countViewRows = (): string => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;