@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
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
5
+ import { RecordId, Surreal } from 'surrealdb';
6
+ import { landChunk } from '../../query/fetch.saga';
7
+ import { defaultEnv } from '../../query/env';
8
+ import { runPure } from '../../testing/run-pure';
9
+ import { buildState } from '../../testing/build';
10
+ import type { Vars } from '../../kernel/effects';
11
+ import type { SealedQuery } from '../../utils/surql';
12
+ import { SqliteCacheEngine } from './sqlite-cache-engine';
13
+ import { stubTransport } from './sqlite-transport.fixture';
14
+ import type { Row } from './cache-engine';
15
+
16
+ /**
17
+ * Integration: a field cleared on the server (`UPDATE broadcast:x UNSET club`)
18
+ * is ABSENT from the body the server returns, and the body is landed with
19
+ * MERGE, which keeps every key it does not name. These land real bodies
20
+ * through `landChunk` and run the exact transaction it emits on BOTH local
21
+ * engines: a real in-memory SQLite (the worker's @sqlite.org/sqlite-wasm
22
+ * build) behind the SqliteCacheEngine, and a real SurrealDB-WASM `mem://`
23
+ * store provisioned with a typed schema, as the local migrator does.
24
+ *
25
+ * On each: the cleared optional field is gone, local `_00_*` bookkeeping
26
+ * survives (still a MERGE, not a REPLACE), and a required field the body
27
+ * lacks is kept rather than cleared.
28
+ */
29
+
30
+ const env = defaultEnv({
31
+ tables: [
32
+ {
33
+ name: 'broadcast',
34
+ columns: {
35
+ title: { type: 'string', optional: false },
36
+ secret: { type: 'string', optional: false },
37
+ club: { type: 'string', recordId: true, optional: true },
38
+ note: { type: 'string', optional: true },
39
+ },
40
+ },
41
+ ],
42
+ } as any);
43
+
44
+ type Exec = (query: SealedQuery<unknown>, vars: Vars) => Promise<unknown>;
45
+
46
+ /** Land `rows` at `version` through the real saga, executing its write with `exec`. */
47
+ async function land(
48
+ exec: Exec,
49
+ rows: Array<Record<string, unknown>>,
50
+ version: number
51
+ ): Promise<void> {
52
+ const ids = rows.map((r) => String(r.id));
53
+ const out = await runPure(
54
+ landChunk(env, ids, rows, new Map(ids.map((id) => [id, version] as const)), buildState(), 0),
55
+ {
56
+ handlers: {
57
+ 'local.execute': (e: any) => exec(e.query, e.vars),
58
+ 'ssp.ingest': () => undefined,
59
+ },
60
+ }
61
+ );
62
+ expect(out.emitted).not.toContainEqual(expect.objectContaining({ message: 'body write failed' }));
63
+ expect(out.result).toBe(true);
64
+ }
65
+
66
+ const x = new RecordId('broadcast', 'x');
67
+ const fresh = new RecordId('broadcast', 'fresh');
68
+ const club = new RecordId('club', 'c1');
69
+
70
+ const noop = () => {};
71
+
72
+ // ==================== SQLite ====================
73
+
74
+ describe('landed body clears an absent optional field: SQLite engine', () => {
75
+ let db: any;
76
+
77
+ function realEngine(): SqliteCacheEngine {
78
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
79
+ logger.child = () => logger;
80
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, logger);
81
+ stubTransport(engine, (type, payload: any) => {
82
+ switch (type) {
83
+ case 'open':
84
+ return { persisted: true };
85
+ case 'exec':
86
+ return {
87
+ rows: db.exec({
88
+ sql: payload.sql,
89
+ bind: payload.bind,
90
+ rowMode: 'object',
91
+ returnValue: 'resultRows',
92
+ }),
93
+ };
94
+ case 'run':
95
+ db.exec({ sql: payload.sql, bind: payload.bind });
96
+ return {};
97
+ case 'batch':
98
+ for (const stmt of payload as { sql: string; bind?: unknown[] }[])
99
+ db.exec({ sql: stmt.sql, bind: stmt.bind });
100
+ return {};
101
+ default:
102
+ return {};
103
+ }
104
+ });
105
+ return engine;
106
+ }
107
+
108
+ beforeAll(async () => {
109
+ const sqlite3: any = await sqlite3InitModule();
110
+ db = new sqlite3.oo1.DB(':memory:', 'c');
111
+ });
112
+
113
+ afterAll(() => db?.close());
114
+
115
+ it('removes the cleared key on the batched fast path, keeps _00_* and required fields', async () => {
116
+ const engine = realEngine();
117
+ await engine.connect('anon');
118
+ const exec: Exec = (q, v) => engine.execute(q, v);
119
+
120
+ await land(exec, [{ id: x, title: 't1', secret: 's', club, note: 'n' }], 1);
121
+ await engine.upsert('broadcast', x, { _00_local: 'keep' }, 'merge');
122
+ const before = (await engine.getById('broadcast', x)) as Row;
123
+ expect(before.club).toBeDefined();
124
+
125
+ // The server unset `club`; `secret` is hidden from this reader (required,
126
+ // so its absence is not a clear).
127
+ await land(exec, [{ id: x, title: 't2', note: 'n' }], 2);
128
+ const after = (await engine.getById('broadcast', x)) as Row;
129
+ expect('club' in after).toBe(false);
130
+ expect(after).toMatchObject({
131
+ id: 'broadcast:x',
132
+ title: 't2',
133
+ note: 'n',
134
+ secret: 's',
135
+ _00_local: 'keep',
136
+ _00_rv: 2,
137
+ });
138
+
139
+ // A fresh row stores no placeholder for the cleared key.
140
+ await land(exec, [{ id: fresh, title: 'f', secret: 's' }], 1);
141
+ const inserted = (await engine.getById('broadcast', fresh)) as Row;
142
+ expect(Object.keys(inserted).toSorted()).toEqual(['_00_rv', 'id', 'secret', 'title']);
143
+ });
144
+
145
+ it('removes the cleared key on the single-statement merge path too', async () => {
146
+ const engine = realEngine();
147
+ await engine.connect('anon');
148
+
149
+ const y = new RecordId('broadcast', 'y');
150
+ await engine.query('UPSERT ONLY $id MERGE $c', {
151
+ id: y,
152
+ c: { club, note: 'n2', _00_local: 'keep' },
153
+ });
154
+ expect((await engine.getById('broadcast', y))?.club).toBeDefined();
155
+ await engine.query('UPSERT ONLY $id MERGE $c', { id: y, c: { club: undefined, note: 'n3' } });
156
+ const row = (await engine.getById('broadcast', y)) as Row;
157
+ expect('club' in row).toBe(false);
158
+ expect(row.note).toBe('n3');
159
+ expect(row._00_local).toBe('keep');
160
+ });
161
+ });
162
+
163
+ // ==================== SurrealDB-WASM ====================
164
+
165
+ describe('landed body clears an absent optional field: SurrealDB-WASM engine', () => {
166
+ let db: Surreal;
167
+
168
+ beforeAll(async () => {
169
+ // The wasm engine loads its binary with `fetch(new URL(..., import.meta.url))`,
170
+ // and Node's fetch has no `file:` scheme: serve that one read from disk.
171
+ const realFetch = globalThis.fetch;
172
+ vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
173
+ const url = input instanceof URL ? input : new URL(String(input));
174
+ if (url.protocol === 'file:') return new Response(await readFile(fileURLToPath(url)));
175
+ return realFetch(input as RequestInfo, init);
176
+ });
177
+ const { createWasmEngines } = await import('@surrealdb/wasm');
178
+ db = new Surreal({ engines: createWasmEngines() });
179
+ await db.connect('mem://');
180
+ await db.use({ namespace: 'n', database: 'd' });
181
+ await db.query(`
182
+ DEFINE TABLE club SCHEMAFULL;
183
+ DEFINE TABLE broadcast SCHEMAFULL;
184
+ DEFINE FIELD title ON broadcast TYPE string;
185
+ DEFINE FIELD secret ON broadcast TYPE string;
186
+ DEFINE FIELD club ON broadcast TYPE option<record<club>>;
187
+ DEFINE FIELD note ON broadcast TYPE option<string>;
188
+ DEFINE FIELD _00_rv ON broadcast TYPE option<int>;
189
+ DEFINE FIELD _00_local ON broadcast TYPE option<string>;
190
+ `);
191
+ }, 60_000);
192
+
193
+ afterAll(async () => {
194
+ await db?.close();
195
+ vi.unstubAllGlobals();
196
+ });
197
+
198
+ const exec: Exec = (q, v) => db.query(q.sql, v) as unknown as Promise<unknown>;
199
+ const read = async (id: RecordId<string>): Promise<Record<string, unknown>> => {
200
+ const [row] = (await db.query('SELECT * FROM ONLY $id', { id })) as [Record<string, unknown>];
201
+ return row;
202
+ };
203
+
204
+ it('the undefined clear reaches SurrealDB as NONE and the MERGE removes the field', async () => {
205
+ await land(exec, [{ id: x, title: 't1', secret: 's', club, note: 'n' }], 1);
206
+ await db.query("UPDATE $id SET _00_local = 'keep'", { id: x });
207
+ expect(String((await read(x)).club)).toBe('club:c1');
208
+
209
+ // Would fail the whole transaction if the clear were NULL (a coercion error
210
+ // on `option<record<club>>`) or named the required `secret` as NONE.
211
+ await land(exec, [{ id: x, title: 't2', note: 'n' }], 2);
212
+ const after = await read(x);
213
+ expect('club' in after).toBe(false);
214
+ expect(after).toMatchObject({
215
+ title: 't2',
216
+ note: 'n',
217
+ secret: 's',
218
+ _00_local: 'keep',
219
+ _00_rv: 2,
220
+ });
221
+
222
+ await land(exec, [{ id: fresh, title: 'f', secret: 's' }], 1);
223
+ expect(Object.keys(await read(fresh)).toSorted()).toEqual(['_00_rv', 'id', 'secret', 'title']);
224
+ });
225
+
226
+ it('why the clear is NONE on optional columns only: NULL and a required NONE both fail', async () => {
227
+ const z = new RecordId('broadcast', 'z');
228
+ await db.query('CREATE $id CONTENT $c', { id: z, c: { title: 't', secret: 's', club } });
229
+ await expect(
230
+ db.query('UPSERT ONLY $id MERGE $c', { id: z, c: { club: null } })
231
+ ).rejects.toThrow(/club/);
232
+ await expect(
233
+ db.query('UPSERT ONLY $id MERGE $c', { id: z, c: { secret: undefined } })
234
+ ).rejects.toThrow(/secret/);
235
+ });
236
+ });
@@ -6,6 +6,7 @@ import {
6
6
  renderOrderSql,
7
7
  renderWhereSql,
8
8
  reviveRow,
9
+ serializeMergeRow,
9
10
  serializeRow,
10
11
  project,
11
12
  projectedDataSql,
@@ -947,11 +948,12 @@ export class SqliteCacheEngine implements LocalStore {
947
948
  if (mode === 'merge') {
948
949
  // Merge in-SQL via json_patch (RFC7396 = MERGE semantics): on insert store
949
950
  // the row, on conflict shallow-merge. Serialize once, reuse for VALUES and
950
- // the patch. No read-modify-write round-trip. (RFC7396: null deletes key.)
951
- const full = serializeRow({ ...data, id: key });
951
+ // the patch. No read-modify-write round-trip. (RFC7396: null deletes key,
952
+ // which is how a key holding `undefined` clears the stored field.)
953
+ const { insert, patch } = serializeMergeRow({ ...data, id: key });
952
954
  await this.call('run', {
953
955
  sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
954
- bind: [key, full, full],
956
+ bind: [key, insert, patch],
955
957
  });
956
958
  return;
957
959
  }
@@ -1191,10 +1193,12 @@ export class SqliteCacheEngine implements LocalStore {
1191
1193
  // Serialize ONCE and reuse for both VALUES (fresh insert) and the
1192
1194
  // json_patch (merge). Patching with `id` is a harmless no-op set, so
1193
1195
  // the full row doubles as the delta — halves per-row stringify cost.
1194
- const full = serializeRow({ ...op.data, id: key });
1196
+ // A key holding `undefined` (a field cleared upstream) rides in the
1197
+ // delta as a `null` member, which json_patch removes.
1198
+ const { insert, patch } = serializeMergeRow({ ...op.data, id: key });
1195
1199
  stmts.push({
1196
1200
  sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
1197
- bind: [key, full, full],
1201
+ bind: [key, insert, patch],
1198
1202
  });
1199
1203
  } else {
1200
1204
  stmts.push({
@@ -6,6 +6,7 @@ import {
6
6
  renderOrderSql,
7
7
  scalar,
8
8
  serializeRow,
9
+ serializeMergeRow,
9
10
  reviveRow,
10
11
  project,
11
12
  } from './sqlite-plan-sql';
@@ -102,3 +103,37 @@ describe('project', () => {
102
103
  expect(project({ id: 'a', x: 1, y: 2 }, ['x', 'missing'])).toEqual({ id: 'a', x: 1 });
103
104
  });
104
105
  });
106
+
107
+ describe('serializeMergeRow', () => {
108
+ it('reuses one string for insert and patch when nothing is cleared', () => {
109
+ const row = { id: 't:1', a: 1, b: null };
110
+ const { insert, patch } = serializeMergeRow(row);
111
+ expect(insert).toBe(serializeRow(row));
112
+ expect(patch).toBe(insert);
113
+ });
114
+
115
+ // json_patch (RFC 7396) removes a member whose patch value is null; JSON drops
116
+ // an undefined one, which is how a cleared field used to survive the MERGE.
117
+ it('carries a top-level undefined as a null member in the patch only', () => {
118
+ const { insert, patch } = serializeMergeRow({ id: 't:1', a: 1, club: undefined, 'we"ird': undefined });
119
+ expect(JSON.parse(insert)).toStrictEqual({ id: 't:1', a: 1 });
120
+ expect(JSON.parse(patch)).toStrictEqual({ id: 't:1', a: 1, club: null, 'we"ird': null });
121
+ });
122
+
123
+ it('handles a row with nothing but cleared keys', () => {
124
+ const { insert, patch } = serializeMergeRow({ club: undefined });
125
+ expect(insert).toBe('{}');
126
+ expect(JSON.parse(patch)).toStrictEqual({ club: null });
127
+ });
128
+
129
+ it('leaves nested undefined dropped (only top-level keys clear)', () => {
130
+ const { patch } = serializeMergeRow({ id: 't:1', meta: { x: undefined, y: 1 } });
131
+ expect(JSON.parse(patch)).toStrictEqual({ id: 't:1', meta: { y: 1 } });
132
+ });
133
+
134
+ it('does not append to a root the replacer collapsed to a scalar', () => {
135
+ const { insert, patch } = serializeMergeRow({ tb: 'user', id: 'u1', gone: undefined });
136
+ expect(insert).toBe('"user:u1"');
137
+ expect(patch).toBe(insert);
138
+ });
139
+ });
@@ -70,6 +70,32 @@ export function serializeRow(row: Row): string {
70
70
  });
71
71
  }
72
72
 
73
+ /**
74
+ * Serialize a MERGE row ONCE for both halves of the merge upsert: the row a
75
+ * fresh INSERT stores and the RFC 7396 delta `json_patch` applies on conflict.
76
+ *
77
+ * A top-level own key holding `undefined` means "clear this field", which is
78
+ * what SurrealDB's MERGE does with it (the SDK sends it as NONE). JSON drops
79
+ * such a key, so a patch built by {@link serializeRow} alone never mentioned
80
+ * it and the stored value survived every merge: a field cleared on the server
81
+ * stayed set locally. The patch therefore carries each one as a `null` member,
82
+ * which `json_patch` removes; the insert row simply lacks it, exactly as
83
+ * before. Appended to the already-serialized text, so the common case costs no
84
+ * second stringify. Nested `undefined` stays dropped (never a clear here).
85
+ */
86
+ export function serializeMergeRow(row: Row): { insert: string; patch: string } {
87
+ const insert = serializeRow(row);
88
+ const cleared: string[] = [];
89
+ for (const key of Object.keys(row)) {
90
+ if (row[key] === undefined) cleared.push(`${JSON.stringify(key)}:null`);
91
+ }
92
+ // Not an object text (the replacer collapsed the root): nothing to append to.
93
+ if (cleared.length === 0 || insert[0] !== '{') return { insert, patch: insert };
94
+ const members = cleared.join(',');
95
+ const patch = insert === '{}' ? `{${members}}` : `${insert.slice(0, -1)},${members}}`;
96
+ return { insert, patch };
97
+ }
98
+
73
99
  export function reviveRow(json: string): Row {
74
100
  // Fast path: the per-key reviver is only needed to rebuild `Uint8Array`s from
75
101
  // `{__u8}` tags. Most rows (e.g. game bodies) have none — a plain parse avoids
@@ -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
+ });
@@ -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
- return { ...withArray, outbox, dirty: addAll(withArray.dirty, [hash]) };
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
+ });
@@ -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: {
@@ -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':
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { RecordId } from '@spooky-sync/query-builder';
3
- import { parseParams, parseQueryParams } from './parser';
3
+ import { absentOptionalColumns, parseParams, parseQueryParams } from './parser';
4
4
 
5
5
  const columns = {
6
6
  white: { recordId: true },
@@ -52,3 +52,39 @@ describe('parseParams', () => {
52
52
  expect(parsed).toEqual({ result: '1-0' });
53
53
  });
54
54
  });
55
+
56
+ describe('absentOptionalColumns', () => {
57
+ const schema = {
58
+ title: { type: 'string', optional: false },
59
+ club: { type: 'string', recordId: true, optional: true },
60
+ note: { type: 'string', optional: true },
61
+ _00_local: { type: 'string', optional: true },
62
+ id: { type: 'string', optional: true },
63
+ } as any;
64
+
65
+ // The bug: `UPDATE broadcast:x UNSET club` leaves no `club` key in the body,
66
+ // and a MERGE that does not name it keeps the stale local value.
67
+ it('names every optional column the row lacks, as undefined', () => {
68
+ const cleared = absentOptionalColumns(schema, { id: 'broadcast:x', title: 't', note: 'n' });
69
+ expect(cleared).toStrictEqual({ club: undefined });
70
+ expect(Object.prototype.hasOwnProperty.call(cleared, 'club')).toBe(true);
71
+ });
72
+
73
+ it('treats an explicit undefined (a decoded NONE) as absent', () => {
74
+ expect(absentOptionalColumns(schema, { title: 't', club: undefined, note: 'n' })).toStrictEqual(
75
+ { club: undefined }
76
+ );
77
+ });
78
+
79
+ it('never names a required column, id, or _00_* bookkeeping', () => {
80
+ expect(absentOptionalColumns(schema, {})).toStrictEqual({ club: undefined, note: undefined });
81
+ });
82
+
83
+ it('keeps a present value, even a falsy one', () => {
84
+ expect(absentOptionalColumns(schema, { club: null, note: '' })).toStrictEqual({});
85
+ });
86
+
87
+ it('is empty without a schema', () => {
88
+ expect(absentOptionalColumns(undefined, { a: 1 })).toStrictEqual({});
89
+ });
90
+ });
@@ -16,6 +16,38 @@ export function cleanRecord(
16
16
  return cleaned;
17
17
  }
18
18
 
19
+ /**
20
+ * The OPTIONAL schema columns a full server row does not carry, each mapped to
21
+ * `undefined`, for spreading into a body that is landed with MERGE.
22
+ *
23
+ * The server omits an optional field that is unset (`UPDATE t UNSET club`
24
+ * leaves no `club` key in `SELECT *` or the SSP body), and MERGE keeps every
25
+ * key the patch does not mention, so without this the local store held on to
26
+ * a cleared value forever. `undefined` is the one representation both engines
27
+ * read as "remove": the SurrealDB SDK encodes it as NONE, which MERGE removes
28
+ * (NULL would be a type error on an `option<record<...>>` field), and the
29
+ * SQLite engine turns it into the `null` member that `json_patch` removes (see
30
+ * `serializeMergeRow`).
31
+ *
32
+ * Required columns are left alone. A SCHEMAFULL server cannot hold NONE in
33
+ * one, so its absence means the row was not readable in full (a field
34
+ * permission), not that it was cleared, and NONE in a typed local store is a
35
+ * coercion error that would fail the whole chunk's transaction. `id` and the
36
+ * local-only `_00_*` bookkeeping are never touched.
37
+ */
38
+ export function absentOptionalColumns(
39
+ tableSchema: Record<string, ColumnSchema> | undefined,
40
+ record: Record<string, unknown>
41
+ ): Record<string, undefined> {
42
+ const cleared: Record<string, undefined> = {};
43
+ if (!tableSchema) return cleared;
44
+ for (const [key, column] of Object.entries(tableSchema)) {
45
+ if (key === 'id' || key.startsWith('_00_') || column?.optional !== true) continue;
46
+ if (record[key] === undefined) cleared[key] = undefined;
47
+ }
48
+ return cleared;
49
+ }
50
+
19
51
  /**
20
52
  * Parse a RECORD's fields against the table schema. Anything the schema does not
21
53
  * know is dropped, which is what keeps a stray field out of a write.