@spooky-sync/core 0.0.1-canary.286 → 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 +270 -46
- package/dist/live.js +1 -1
- package/dist/pure.d.ts +9 -2
- package/dist/pure.js +2 -2
- package/dist/worker-edges.js +10 -2
- package/package.json +3 -3
- 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/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/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
|
@@ -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`;
|
|
@@ -126,6 +126,12 @@ export interface ClientState {
|
|
|
126
126
|
readonly membershipReread: ReadonlyMap<QueryHash, number>;
|
|
127
127
|
/** Local body versions (`_00_rv`) by encoded record id. */
|
|
128
128
|
readonly versions: ReadonlyMap<string, number>;
|
|
129
|
+
/**
|
|
130
|
+
* What each durable `_00_view` row vouches for, by view key: its members
|
|
131
|
+
* and subquery children. A body no row names is an orphan: it never paints
|
|
132
|
+
* before the server answers and the GC deletes it.
|
|
133
|
+
*/
|
|
134
|
+
readonly views: ReadonlyMap<string, ReadonlyArray<string>>;
|
|
129
135
|
readonly outbox: ReadonlyArray<OutboxItem>;
|
|
130
136
|
readonly pendingWrites: ReadonlyMap<string, PendingWrite>;
|
|
131
137
|
readonly failedCount: number;
|
|
@@ -169,6 +175,7 @@ export function emptyState(init: { tabId: string }): ClientState {
|
|
|
169
175
|
registering: new Set(),
|
|
170
176
|
membershipReread: new Map(),
|
|
171
177
|
versions: new Map(),
|
|
178
|
+
views: new Map(),
|
|
172
179
|
outbox: [],
|
|
173
180
|
pendingWrites: new Map(),
|
|
174
181
|
failedCount: 0,
|
|
@@ -281,3 +281,22 @@ describe('identity / connection / compose', () => {
|
|
|
281
281
|
expect(s6.failedCount).toBe(3);
|
|
282
282
|
});
|
|
283
283
|
});
|
|
284
|
+
|
|
285
|
+
describe('durable view index', () => {
|
|
286
|
+
it('commit and children keep a view key in step with its `_00_view` row; cold children are not indexed', () => {
|
|
287
|
+
let s = buildState([e('a', { def: { viewKey: 'va' }, lifecycle: { phase: 'cold' }, subqueryRemoteArray: [['c:1', 1]] })]);
|
|
288
|
+
s = R.setSubqueryRemoteArray('a', [['c:1', 1], ['c:2', 1]])(s);
|
|
289
|
+
expect(s.views.size).toBe(0);
|
|
290
|
+
s = R.commitMembership('a', [['t:1', 1], ['c:1', 1]], true)(s);
|
|
291
|
+
expect(s.views.get('va')).toEqual(['t:1', 'c:1', 'c:2']);
|
|
292
|
+
s = R.setSubqueryRemoteArray('a', [['c:3', 1]])(s);
|
|
293
|
+
expect(s.views.get('va')).toEqual(['t:1', 'c:1', 'c:3']);
|
|
294
|
+
expect(R.setSubqueryRemoteArray('zz', [['c:9', 1]])(s)).toBe(s);
|
|
295
|
+
expect(R.clearBucketState()(s).views.size).toBe(0);
|
|
296
|
+
});
|
|
297
|
+
it('a reload takes the store, except entries of queries in state', () => {
|
|
298
|
+
const s = R.compose(R.commitMembership('a', [['t:new', 1]], true))(buildState([e('a', { def: { viewKey: 'va' } }), e('b', { def: { viewKey: 'vb' } })]));
|
|
299
|
+
const reloaded = R.reloadViews(new Map([['va', ['t:old']], ['vb', ['t:b']], ['vc', ['t:c']]]))(s);
|
|
300
|
+
expect(reloaded.views).toEqual(new Map([['va', ['t:new']], ['vb', ['t:b']], ['vc', ['t:c']]]));
|
|
301
|
+
});
|
|
302
|
+
});
|
package/src/state/reducers.ts
CHANGED
|
@@ -90,6 +90,33 @@ export const setServerState =
|
|
|
90
90
|
* set names: membership has caught up with the write, the overlay's job is
|
|
91
91
|
* done (invariant I7).
|
|
92
92
|
*/
|
|
93
|
+
/** What a `_00_view` row holding these arrays vouches for. */
|
|
94
|
+
const vouchedIds = (members: RecordVersionArray, children: RecordVersionArray): string[] => [
|
|
95
|
+
...new Set([...members.map(([id]) => id), ...children.map(([id]) => id)]),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const withView = (s: ClientState, viewKey: string, ids: ReadonlyArray<string>): ClientState => {
|
|
99
|
+
const views = new Map(s.views);
|
|
100
|
+
views.set(viewKey, ids);
|
|
101
|
+
return { ...s, views };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Replace the durable view index with a fresh `_00_view` read (boot, bucket
|
|
106
|
+
* switch, GC). An entry of a query in state is kept: its commits update the
|
|
107
|
+
* index as they write the row, so it is never older than the read.
|
|
108
|
+
*/
|
|
109
|
+
export const reloadViews =
|
|
110
|
+
(fromStore: ReadonlyMap<string, ReadonlyArray<string>>): Reducer =>
|
|
111
|
+
(s) => {
|
|
112
|
+
const views = new Map(fromStore);
|
|
113
|
+
for (const e of s.queries.values()) {
|
|
114
|
+
const held = s.views.get(e.def.viewKey);
|
|
115
|
+
if (held) views.set(e.def.viewKey, held);
|
|
116
|
+
}
|
|
117
|
+
return { ...s, views };
|
|
118
|
+
};
|
|
119
|
+
|
|
93
120
|
export const commitMembership =
|
|
94
121
|
(hash: QueryHash, remoteArray: RecordVersionArray, present: boolean): Reducer =>
|
|
95
122
|
(s) => {
|
|
@@ -99,9 +126,11 @@ export const commitMembership =
|
|
|
99
126
|
lifecycle: transition(e.lifecycle, { type: 'membership-applied', present }),
|
|
100
127
|
}));
|
|
101
128
|
if (withArray === s) return s;
|
|
129
|
+
const entry = withArray.queries.get(hash)!;
|
|
102
130
|
const named = new Set(remoteArray.map(([id]) => id));
|
|
103
131
|
const outbox = withArray.outbox.filter((item) => !(item.status === 'acked' && named.has(item.recordId)));
|
|
104
|
-
|
|
132
|
+
const indexed = withView(withArray, entry.def.viewKey, vouchedIds(remoteArray, entry.subqueryRemoteArray));
|
|
133
|
+
return { ...indexed, outbox, dirty: addAll(withArray.dirty, [hash]) };
|
|
105
134
|
};
|
|
106
135
|
|
|
107
136
|
export const setLocalArray =
|
|
@@ -111,10 +140,18 @@ export const setLocalArray =
|
|
|
111
140
|
return next === s ? s : { ...next, dirty: addAll(next.dirty, [hash]) };
|
|
112
141
|
};
|
|
113
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Replace a query's subquery child set. A query past `cold` has a `_00_view`
|
|
145
|
+
* row, which records the children too (see `applySubqueryChildren`).
|
|
146
|
+
*/
|
|
114
147
|
export const setSubqueryRemoteArray =
|
|
115
148
|
(hash: QueryHash, subqueryRemoteArray: RecordVersionArray): Reducer =>
|
|
116
|
-
(s) =>
|
|
117
|
-
withEntry(s, hash, (e) => ({ ...e, subqueryRemoteArray }));
|
|
149
|
+
(s) => {
|
|
150
|
+
const next = withEntry(s, hash, (e) => ({ ...e, subqueryRemoteArray }));
|
|
151
|
+
const entry = next.queries.get(hash);
|
|
152
|
+
if (!entry || entry.lifecycle.phase === 'cold') return next;
|
|
153
|
+
return withView(next, entry.def.viewKey, vouchedIds(entry.remoteArray, subqueryRemoteArray));
|
|
154
|
+
};
|
|
118
155
|
|
|
119
156
|
export const setRecords =
|
|
120
157
|
(hash: QueryHash, records: ReadonlyArray<Row>, changed: boolean, materializeMs: number | null): Reducer =>
|
|
@@ -441,6 +478,7 @@ export const rebindQuery =
|
|
|
441
478
|
export const clearBucketState = (): Reducer => (s) => ({
|
|
442
479
|
...s,
|
|
443
480
|
versions: new Map(),
|
|
481
|
+
views: new Map(),
|
|
444
482
|
outbox: [],
|
|
445
483
|
pendingWrites: new Map(),
|
|
446
484
|
dirty: new Set(),
|
|
@@ -147,3 +147,21 @@ describe('phaseTimings', () => {
|
|
|
147
147
|
expect(t.registration.parseMs).toBeNull();
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
describe('view index selectors', () => {
|
|
152
|
+
it('vouched is the union of every view row, memoized on the index', () => {
|
|
153
|
+
const s = R.reloadViews(new Map([['v1', ['t:1', 't:2']], ['v2', ['t:2', 'c:1']]]))(buildState());
|
|
154
|
+
expect([...S.vouched(s)]).toEqual(['t:1', 't:2', 'c:1']);
|
|
155
|
+
expect(S.vouched({ ...s, dirty: new Set(['x']) })).toBe(S.vouched(s));
|
|
156
|
+
});
|
|
157
|
+
it('retained: vouched, held in memory (members, children), in the outbox or a debounced write, or bookkeeping', () => {
|
|
158
|
+
const s = R.compose(
|
|
159
|
+
R.reloadViews(new Map([['v', ['t:vouched']]])),
|
|
160
|
+
R.outboxReplace([buildOutboxItem({ recordId: 't:outbox' })]),
|
|
161
|
+
(st) => ({ ...st, pendingWrites: new Map([['k', { key: 'k', table: 't', recordId: 't:debounced', data: {}, before: null, firstAt: 0 }]]) })
|
|
162
|
+
)(buildState([e('a', { remoteArray: [['t:member', 1]], subqueryRemoteArray: [['t:child', 1]] })]));
|
|
163
|
+
const keep = S.retained(s);
|
|
164
|
+
for (const id of ['t:vouched', 't:member', 't:child', 't:outbox', 't:debounced', '_00_view:x']) expect(keep(id)).toBe(true);
|
|
165
|
+
expect(keep('t:orphan')).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
});
|
package/src/state/selectors.ts
CHANGED
|
@@ -35,6 +35,38 @@ export function overlay(s: ClientState): Overlay {
|
|
|
35
35
|
return { writes, deletes };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const vouchedCache = new WeakMap<ClientState['views'], ReadonlySet<string>>();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every id some durable `_00_view` row vouches for. Memoized on the index,
|
|
42
|
+
* which only changes when a view row does.
|
|
43
|
+
*/
|
|
44
|
+
export function vouched(s: ClientState): ReadonlySet<string> {
|
|
45
|
+
const hit = vouchedCache.get(s.views);
|
|
46
|
+
if (hit) return hit;
|
|
47
|
+
const out = new Set<string>();
|
|
48
|
+
for (const ids of s.views.values()) for (const id of ids) out.add(id);
|
|
49
|
+
vouchedCache.set(s.views, out);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Bodies the GC must keep: `_00_` bookkeeping, anything a durable view
|
|
55
|
+
* vouches for, anything a query in memory names (members or children), and
|
|
56
|
+
* anything a write is still in flight for.
|
|
57
|
+
*/
|
|
58
|
+
export function retained(s: ClientState): (id: string) => boolean {
|
|
59
|
+
const held = new Set<string>();
|
|
60
|
+
for (const e of s.queries.values()) {
|
|
61
|
+
for (const [id] of e.remoteArray) held.add(id);
|
|
62
|
+
for (const [id] of e.subqueryRemoteArray) held.add(id);
|
|
63
|
+
}
|
|
64
|
+
for (const item of s.outbox) held.add(item.recordId);
|
|
65
|
+
for (const write of s.pendingWrites.values()) held.add(write.recordId);
|
|
66
|
+
const known = vouched(s);
|
|
67
|
+
return (id) => id.startsWith('_00_') || known.has(id) || held.has(id);
|
|
68
|
+
}
|
|
69
|
+
|
|
38
70
|
export const pendingDeleteIds = (s: ClientState): ReadonlySet<string> =>
|
|
39
71
|
new Set(s.outbox.filter((i) => i.type === 'delete').map((i) => i.recordId));
|
|
40
72
|
|
|
@@ -43,6 +43,10 @@ describe('tabMessage', () => {
|
|
|
43
43
|
expect(out.state.versions.get('other:1')).toBe(0);
|
|
44
44
|
expect(out.state.versions.has('thing:1')).toBe(false);
|
|
45
45
|
expect(out.state.dirty.has('a')).toBe(true);
|
|
46
|
+
// A relayed delete (another tab's GC) may drop a body this tab names: the fetch plan decides.
|
|
47
|
+
expect(out.dispatched).toEqual([{ type: 'FetchRows' }]);
|
|
48
|
+
const writesOnly = await runPure(tabMessage(env, { ...msg, records: msg.records.filter((r) => r.op !== 'DELETE') }), { state: s(), handlers: { 'ssp.ingest': () => undefined } });
|
|
49
|
+
expect(writesOnly.dispatched).toEqual([]);
|
|
46
50
|
const failing = await runPure(tabMessage(env, msg), {
|
|
47
51
|
state: s(),
|
|
48
52
|
handlers: {
|
package/src/sync/tabs.saga.ts
CHANGED
|
@@ -60,6 +60,9 @@ export function* tabMessage(env: SagaEnv, raw: unknown): Saga<void> {
|
|
|
60
60
|
...[...new Set(msg.records.map((r) => r.table))].map((t) => R.markTableDirty(t))
|
|
61
61
|
)
|
|
62
62
|
);
|
|
63
|
+
// A body another tab deleted (its orphan GC) may still be one this tab
|
|
64
|
+
// names: with the version gone, the fetch plan pulls it back.
|
|
65
|
+
if (msg.records.some((r) => r.op === 'DELETE')) yield fx.dispatch({ type: 'FetchRows' });
|
|
63
66
|
return;
|
|
64
67
|
}
|
|
65
68
|
case 'membership-dirty':
|