@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.
- package/AGENTS.md +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +306 -53
- package/dist/live.js +1 -1
- package/dist/pure.d.ts +9 -2
- package/dist/pure.js +2 -2
- package/dist/sqlite-open.js +28 -1
- package/dist/sqlite-worker.js +1 -1
- package/dist/worker-edges.js +10 -2
- package/package.json +3 -3
- package/skills/sp00ky-core/SKILL.md +9 -6
- package/src/boot/boot.saga.test.ts +9 -2
- package/src/boot/boot.saga.ts +4 -1
- package/src/boot/bucket-switch.saga.ts +2 -0
- package/src/client/runtime.test.ts +4 -1
- package/src/client/sp00ky-client.test.ts +8 -1
- package/src/kernel/constants.ts +15 -2
- package/src/query/fetch.saga.test.ts +44 -1
- package/src/query/fetch.saga.ts +7 -2
- package/src/query/lifecycle.saga.test.ts +169 -17
- package/src/query/lifecycle.saga.ts +86 -28
- package/src/query/materialize.saga.test.ts +36 -3
- package/src/query/materialize.saga.ts +15 -6
- package/src/query/materialize.test.ts +9 -1
- package/src/query/materialize.ts +14 -1
- package/src/query/membership.saga.test.ts +30 -2
- package/src/query/membership.saga.ts +18 -2
- package/src/query/membership.ts +30 -0
- package/src/query/render-set.test.ts +12 -1
- package/src/query/render-set.ts +11 -0
- package/src/query/sql.test.ts +2 -1
- package/src/query/sql.ts +15 -1
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/sqlite-cache-engine.ts +9 -5
- package/src/services/database/sqlite-plan-sql.test.ts +35 -0
- package/src/services/database/sqlite-plan-sql.ts +26 -0
- package/src/state/client-state.ts +7 -0
- package/src/state/reducers.test.ts +19 -0
- package/src/state/reducers.ts +41 -3
- package/src/state/selectors.test.ts +18 -0
- package/src/state/selectors.ts +32 -0
- package/src/sync/tabs.saga.test.ts +4 -0
- package/src/sync/tabs.saga.ts +3 -0
- package/src/utils/parser.test.ts +37 -1
- 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 {
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
28
|
-
const
|
|
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) =>
|
|
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
|
+
});
|
package/src/query/materialize.ts
CHANGED
|
@@ -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(
|
|
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
|
-
/**
|
|
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
|
|
package/src/query/membership.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/query/render-set.ts
CHANGED
|
@@ -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;
|
package/src/query/sql.test.ts
CHANGED
|
@@ -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
|
-
|
|
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`;
|