@spooky-sync/core 0.0.1-canary.326 → 0.0.1-canary.327

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.326",
3
+ "version": "0.0.1-canary.327",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,8 +75,8 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.326",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.326",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.327",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.327",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
@@ -603,7 +603,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
603
603
  }
604
604
 
605
605
  /** Close a `@crdt` field. The last close resolves once its unsent edits
606
- * are queued in the outbox. */
606
+ * are queued in the outbox; offline, once they are saved on the device. */
607
607
  closeCrdtField(table: string, recordId: string, field: string): Promise<void> {
608
608
  return this.services.crdt.close(table, recordId, field);
609
609
  }
@@ -25,16 +25,26 @@ class World {
25
25
  peers = new Map<string, string>();
26
26
  published = new Map<string, { rev: number; snapshot: Uint8Array; upto?: unknown; state_hash?: string }>();
27
27
  online = true;
28
+ /** Offline remote calls never answer (queued behind a reconnect) instead of failing. */
29
+ hang = false;
30
+ /** A slow device store: each local write takes this long (real timers). */
31
+ storeDelayMs = 0;
28
32
  private watchers = new Set<{ table: string; where: Record<string, unknown>; cb: (rows: any[]) => void }>();
29
33
 
30
34
  client(user: string, store = new Map<string, any>(), logger: any = silent) {
31
35
  const local = {
32
36
  getById: async (_t: string, id: any) => store.get(String(id)) ?? null,
33
- upsert: async (_t: string, id: any, data: any) => void store.set(String(id), structuredClone(data)),
37
+ upsert: async (_t: string, id: any, data: any) => {
38
+ const copy = structuredClone(data);
39
+ if (this.storeDelayMs) await new Promise((resolve) => setTimeout(resolve, this.storeDelayMs));
40
+ store.set(String(id), copy);
41
+ },
34
42
  } as any;
35
43
  const remote = {
36
44
  subscribeConnection: () => () => {},
45
+ getStatus: () => (this.online ? 'connected' : 'disconnected'),
37
46
  query: async (sql: string, vars: any) => {
47
+ if (!this.online && this.hang) return new Promise(() => {});
38
48
  if (!this.online) throw new Error('not connected');
39
49
  if (sql.startsWith('CREATE')) {
40
50
  const owner = this.peers.get(vars.peer);
@@ -186,6 +196,44 @@ describe('CRDT fields', () => {
186
196
  expect(world.peers.get(oldPeer)).toBe('user:a');
187
197
  });
188
198
 
199
+ it('open, adopt a draft and close offline without waiting on the server', async () => {
200
+ const world = new World();
201
+ world.online = false;
202
+ world.hang = true;
203
+ const store = new Map<string, any>();
204
+ const a = world.client('user:a', store);
205
+ const draft = await a.manager.createDraft('doc', 'body');
206
+ draft.getDoc().getText('body').insert(0, 'written offline');
207
+ draft.getDoc().commit();
208
+
209
+ let done = false;
210
+ void (async () => {
211
+ const f = await a.manager.open('doc', 'doc:1', 'body', draft);
212
+ expect(text(f)).toBe('written offline');
213
+ await a.manager.close('doc', 'doc:1', 'body');
214
+ done = true;
215
+ })();
216
+ await realWait();
217
+ expect(done).toBe(true);
218
+ expect(world.log).toHaveLength(0);
219
+ // Kept on the device, to go out from the next open.
220
+ expect([...store.values()].some((row) => row.snapshot)).toBe(true);
221
+ });
222
+
223
+ it('a reopen right after a close starts from what the close saved', async () => {
224
+ vi.useRealTimers();
225
+ const world = new World();
226
+ world.storeDelayMs = 30;
227
+ const a = world.client('user:a');
228
+ const draft = await a.manager.createDraft('doc', 'body');
229
+ draft.getDoc().getText('body').insert(0, 'from the form');
230
+ draft.getDoc().commit();
231
+ await a.manager.open('doc', 'doc:1', 'body', draft);
232
+ void a.manager.close('doc', 'doc:1', 'body');
233
+ const reopened = await a.manager.open('doc', 'doc:1', 'body');
234
+ expect(text(reopened)).toBe('from the form');
235
+ });
236
+
189
237
  it('never import a row that fails the acceptance rule', async () => {
190
238
  const world = new World();
191
239
  const b = world.client('user:b');
@@ -100,15 +100,18 @@ const MAX_WAIT_MS = 2000;
100
100
  const PERSIST_DEBOUNCE_MS = 400;
101
101
  const PRESENCE_THROTTLE_MS = 250;
102
102
  const CLAIM_RETRY_MS = 5000;
103
- /** How long an open of a field with nothing local waits for the server's
104
- * first answer before handing out the (still empty) document. */
105
- const FIRST_ANSWER_TIMEOUT_MS = 4000;
103
+ /** Longest an open or close waits on the server (the first answer for a
104
+ * field with nothing local, the last edits going out) before going on
105
+ * without it. Offline it does not wait at all. */
106
+ const SERVER_WAIT_MS = 4000;
106
107
  /** Pause after a fold is noticed before checking the published document. */
107
108
  const VERIFY_DELAY_MS = 1000;
108
109
 
109
110
  export class CrdtManager {
110
111
  private sessions = new Map<string, Session>();
111
112
  private opening = new Map<string, Promise<Session>>();
113
+ /** Closes still saving their field's local state, by session key. */
114
+ private saving = new Map<string, Promise<void>>();
112
115
  private ports: CrdtPorts | null = null;
113
116
  private logger: Logger;
114
117
  private peer: `${number}` = randomPeer();
@@ -178,7 +181,7 @@ export class CrdtManager {
178
181
  session = await inFlight;
179
182
  session.refs++;
180
183
  } else {
181
- const pending = this.start(key, table, recordId, field, kind, cursor).finally(() => this.opening.delete(key));
184
+ const pending = this.start(key, table, recordId, field, kind, cursor, !!draft).finally(() => this.opening.delete(key));
182
185
  this.opening.set(key, pending);
183
186
  session = await pending;
184
187
  }
@@ -209,7 +212,8 @@ export class CrdtManager {
209
212
  }
210
213
 
211
214
  /** Close one open. The last close of a field resolves once its unsent
212
- * edits are in the outbox (or are left for a later session, offline). */
215
+ * edits are in the outbox, or once they are saved on the device when the
216
+ * server does not answer in time (offline: at once). */
213
217
  close(table: string, recordId: string, field: string): Promise<void> {
214
218
  const s = this.sessions.get(`${table}|${recordId}|${field}`);
215
219
  if (!s || --s.refs > 0) return Promise.resolve();
@@ -245,13 +249,18 @@ export class CrdtManager {
245
249
  recordId: string,
246
250
  field: string,
247
251
  kind: CrdtKind,
248
- cursor: boolean
252
+ cursor: boolean,
253
+ /** Opened with a draft: the record was just created here, so the server
254
+ * has nothing for it yet and the open does not wait on it. */
255
+ fresh = false
249
256
  ): Promise<Session> {
250
257
  if (!this.ports) throw new Error('CrdtManager: not attached to a client');
251
258
  const loro = await loadLoro();
252
259
  const doc = new loro.LoroDoc();
253
260
  doc.setPeerId(this.peer);
254
261
 
262
+ // A close of this field may still be saving: read what it saves.
263
+ await this.saving.get(key);
255
264
  const local = await this.readLocal(recordId, field);
256
265
  if (local.snapshot) this.tryImport(doc, local.snapshot, key);
257
266
 
@@ -288,8 +297,9 @@ export class CrdtManager {
288
297
 
289
298
  // Claimed now, so the first edits do not wait on the round trip.
290
299
  void this.ensureClaimed();
291
- // First open on this device: start from the published document.
292
- if (!local.snapshot) await this.refetchCanonical(session);
300
+ // First open on this device: start from the published document. Offline
301
+ // it is read once the connection is back instead of holding the open.
302
+ if (!local.snapshot && !fresh) await this.serverWait(this.refetchCanonical(session));
293
303
 
294
304
  session.disposers.push(
295
305
  doc.subscribeLocalUpdates(() => {
@@ -315,9 +325,7 @@ export class CrdtManager {
315
325
  // two clients doing that at once keep only one of them (with whatever was
316
326
  // typed into the other). With nothing local, hand the document out only
317
327
  // once the server's rows for it are in.
318
- if (!session.handle.hasContent()) {
319
- await Promise.race([log.settled, delay(FIRST_ANSWER_TIMEOUT_MS)]);
320
- }
328
+ if (!session.handle.hasContent() && !fresh) await this.serverWait(log.settled);
321
329
  await session.ingesting;
322
330
  // Edits a previous session made but never sent (closed tab, offline).
323
331
  this.scheduleFlush(session, 0);
@@ -340,11 +348,25 @@ export class CrdtManager {
340
348
  if (s.verifyTimer) clearTimeout(s.verifyTimer);
341
349
  if (s.presenceSent) void this.dropPresence(s);
342
350
  if (flush) {
343
- await this.persist(s);
344
- await this.flush(s);
351
+ const saved = this.persist(s);
352
+ this.saving.set(s.key, saved);
353
+ await saved;
354
+ if (this.saving.get(s.key) === saved) this.saving.delete(s.key);
355
+ await this.serverWait(this.flush(s));
345
356
  }
346
357
  }
347
358
 
359
+ /**
360
+ * Wait for server-bound `work`, at most {@link SERVER_WAIT_MS}, and not at
361
+ * all while disconnected: a remote call made then queues behind the
362
+ * reconnect for up to the query timeout, and an offline "new thread" form
363
+ * must not hang on it. The work itself goes on either way.
364
+ */
365
+ private serverWait(work: Promise<unknown>): Promise<unknown> {
366
+ if (this.remote.getStatus() !== 'connected') return Promise.resolve();
367
+ return Promise.race([work, delay(SERVER_WAIT_MS)]);
368
+ }
369
+
348
370
  // --- receiving ---------------------------------------------------
349
371
 
350
372
  private async ingest(s: Session, rows: Record<string, unknown>[]): Promise<void> {
@@ -59,12 +59,12 @@ describe('buildIdSetPlan', () => {
59
59
  describe('buildIdSetSurql', () => {
60
60
  it('rewrites a non-offset query to the id-set source, keeping ORDER BY', () => {
61
61
  const out = buildIdSetSurql('SELECT * FROM thread WHERE done = false ORDER BY title ASC LIMIT 50;');
62
- expect(out?.query).toBe('SELECT * FROM $__win ORDER BY title ASC');
62
+ expect(out?.query).toBe('SELECT * FROM (SELECT * FROM $__win) ORDER BY title ASC');
63
63
  });
64
64
 
65
65
  it('preserves the projection', () => {
66
66
  const out = buildIdSetSurql('SELECT id, title FROM thread WHERE done = false;');
67
- expect(out?.query).toBe('SELECT id, title FROM $__win');
67
+ expect(out?.query).toBe('SELECT id, title FROM (SELECT * FROM $__win)');
68
68
  });
69
69
 
70
70
  it('returns null when there is no top-level FROM to replace', () => {
@@ -5,7 +5,7 @@ import { ACK_GRACE_MS, GC_CHUNK, GC_INTERVAL_MS, TTL_HEARTBEAT_FRACTION, VIEW_RE
5
5
  import type { IngestRecord } from '../services/stream-processor/index';
6
6
  import type { ClientState, QueryEntry, TabRole } from '../state/client-state';
7
7
  import * as R from '../state/reducers';
8
- import { evictable, retained, shortestTtlMs } from '../state/selectors';
8
+ import { activelyHeld, evictable, retained, shortestTtlMs } from '../state/selectors';
9
9
  import type { SagaEnv } from './env';
10
10
  import { parseViewIndex, type ViewIndexRow } from './membership';
11
11
  import * as sql from './sql';
@@ -125,6 +125,33 @@ export function* gcTick(): Saga<void> {
125
125
  }
126
126
  }
127
127
 
128
+ /**
129
+ * Forget rows the server just took out of `hash` (its membership or its
130
+ * subquery children) when nothing live still holds them: delete the local
131
+ * body and the in-browser circuit's copy, and re-render the query.
132
+ *
133
+ * Primary rows already stop rendering once they leave membership, but
134
+ * subquery children are rendered from the local store (a thread's comments
135
+ * are read back by the relation), so a comment deleted on another device
136
+ * stayed on screen until the hourly GC or a reload. Only a leader or solo tab
137
+ * deletes, and a leader relays it to its followers, as the GC does.
138
+ */
139
+ export function* forgetDeparted(hash: string, departed: string[]): Saga<void> {
140
+ if (departed.length === 0) return;
141
+ const [gone, role] = (yield fx.state.read((s) => {
142
+ const held = activelyHeld(s);
143
+ return [departed.filter((id) => !id.startsWith('_00_') && s.versions.has(id) && !held.has(id)), s.tabRole];
144
+ })) as [string[], TabRole];
145
+ if (gone.length === 0 || role === 'follower') return;
146
+ const settled = (yield fx.all(gone.map((id) => fx.local.delete(tableOf(id), id)))) as Settled[];
147
+ const done = gone.filter((_, j) => settled[j].ok);
148
+ if (done.length === 0) return;
149
+ const records: IngestRecord[] = done.map((id) => ({ table: tableOf(id), op: 'DELETE', id, record: {} }));
150
+ yield fx.state.update(R.compose(R.deleteVersions(done), R.markDirty([hash])));
151
+ yield fx.ssp.ingest(records);
152
+ if (role === 'leader') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records } });
153
+ }
154
+
128
155
  /**
129
156
  * Delete every body nothing retains, `GC_CHUNK` at a time. Each chunk is
130
157
  * re-checked against fresh state (a membership committed meanwhile may name
@@ -15,7 +15,7 @@ describe('materializeEffect', () => {
15
15
  });
16
16
  it('surql + ids: id-set rewrite bound to __win; unparseable surql falls back to the raw query', () => {
17
17
  const e = materializeEffect({ surql: 'SELECT * FROM thing WHERE a = $a ORDER BY b LIMIT 3', params: { a: 1 } }, ['thing:1']);
18
- expect(e).toMatchObject({ kind: 'local.query', sql: 'SELECT * FROM $__win ORDER BY b' });
18
+ expect(e).toMatchObject({ kind: 'local.query', sql: 'SELECT * FROM (SELECT * FROM $__win) ORDER BY b' });
19
19
  if (e.kind !== 'local.query') throw new Error();
20
20
  expect(e.vars).toEqual({ a: 1, __win: [new RecordId('thing', '1')] });
21
21
  const raw = materializeEffect({ surql: 'RETURN 1', params: {} }, ['thing:1']);
@@ -147,6 +147,40 @@ describe('applySubqueryChildren', () => {
147
147
  });
148
148
  });
149
149
 
150
+ describe('rows the server takes out of a query', () => {
151
+ it('a child nothing else holds is forgotten locally and the query re-renders; a held one stays', async () => {
152
+ const s = buildState(
153
+ [
154
+ buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live' }, remoteArray: [['t:1', 1]], subqueryRemoteArray: [['c:1', 1], ['c:9', 1]] }),
155
+ buildEntry({ def: { hash: 'b' }, lifecycle: { phase: 'live' }, remoteArray: [['c:9', 1]] }),
156
+ ],
157
+ R.setVersions([['t:1', 1], ['c:1', 1], ['c:9', 1]])
158
+ );
159
+ const out = await runPure(applySubqueryChildren('a', []), {
160
+ state: { ...s, dirty: new Set<string>() },
161
+ handlers: { 'local.upsert': () => undefined, 'local.delete': () => undefined, 'ssp.ingest': () => undefined },
162
+ });
163
+ expect(out.log.filter((e) => e.kind === 'local.delete')).toEqual([expect.objectContaining({ table: 'c', id: 'c:1' })]);
164
+ expect(out.log.find((e) => e.kind === 'ssp.ingest')).toEqual(
165
+ expect.objectContaining({ records: [{ table: 'c', op: 'DELETE', id: 'c:1', record: {} }] })
166
+ );
167
+ expect(out.state.versions.has('c:1')).toBe(false);
168
+ expect(out.state.versions.has('c:9')).toBe(true);
169
+ expect(out.state.dirty.has('a')).toBe(true);
170
+ });
171
+ it('a row with a pending local write is kept, and a follower tab never deletes', async () => {
172
+ const base = buildState(
173
+ [buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live' }, remoteArray: [['t:1', 1]], subqueryRemoteArray: [['c:1', 1]] })],
174
+ R.setVersions([['t:1', 1], ['c:1', 1]])
175
+ );
176
+ const handlers = { 'local.upsert': () => undefined, 'local.delete': () => undefined, 'ssp.ingest': () => undefined };
177
+ const pending = { ...base, outbox: [buildOutboxItem({ recordId: 'c:1' })] };
178
+ expect((await runPure(applySubqueryChildren('a', []), { state: pending, handlers })).log.some((e) => e.kind === 'local.delete')).toBe(false);
179
+ const follower = { ...base, tabRole: 'follower' as const };
180
+ expect((await runPure(applySubqueryChildren('a', []), { state: follower, handlers })).log.some((e) => e.kind === 'local.delete')).toBe(false);
181
+ });
182
+ });
183
+
150
184
  describe('readMembership', () => {
151
185
  const qid = (h: string) => new RecordId('_00_query', h);
152
186
  const out1 = (id: string, v = 1) => ({ out: new RecordId('thing', id), version: v });
@@ -13,6 +13,8 @@ import type { ClientState, QueryEntry } from '../state/client-state';
13
13
  import { isAuthoritative } from '../state/lifecycle';
14
14
  import * as R from '../state/reducers';
15
15
  import { encodeRecordId } from '../utils/index';
16
+ import { departedIds } from '../state/selectors';
17
+ import { forgetDeparted } from './lifecycle.saga';
16
18
  import { planListRefPollChunks, recordVersionArraysEqual } from '../sync/policy';
17
19
  import type { SagaEnv } from './env';
18
20
  import { listRefTable } from './env';
@@ -86,6 +88,7 @@ export function* applyMembership(
86
88
  }
87
89
  const wasAuthoritative = isAuthoritative(entry.lifecycle);
88
90
  yield fx.state.update(R.commitMembership(hash, remoteArray, meta?.present ?? true));
91
+ yield* forgetDeparted(hash, departedIds(entry.remoteArray, remoteArray));
89
92
  if (!wasAuthoritative) yield fx.emit({ type: 'query:authority', hash, known: true });
90
93
  const now = (yield fx.now()) as number;
91
94
  try {
@@ -126,6 +129,7 @@ export function* applySubqueryChildren(hash: QueryHash, children: RecordVersionA
126
129
  const entry = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
127
130
  if (!entry || recordVersionArraysEqual(entry.subqueryRemoteArray, children)) return;
128
131
  yield fx.state.update(R.setSubqueryRemoteArray(hash, children));
132
+ yield* forgetDeparted(hash, departedIds(entry.subqueryRemoteArray, children));
129
133
  if (entry.lifecycle.phase !== 'cold') {
130
134
  try {
131
135
  yield fx.local.upsert(sql.VIEW_TABLE, sql.viewRecordId(entry.def.viewKey), { children }, 'merge');
@@ -12,9 +12,9 @@ describe('scanner edge cases', () => {
12
12
  expect(buildWindowMaterialization('SELECT * FROM t LIMIT 5 START')).toBeNull();
13
13
  });
14
14
  it('matches a keyword at the very start and at the end of the string', () => {
15
- expect(buildWindowMaterialization('FROM t LIMIT 5 START 5')).toEqual({ query: ' FROM $__win' });
15
+ expect(buildWindowMaterialization('FROM t LIMIT 5 START 5')).toEqual({ query: ' FROM (SELECT * FROM $__win)' });
16
16
  expect(buildWindowMaterialization('SELECT * FROM t ORDER BY a LIMIT 5 START 5 ORDER')).toEqual({
17
- query: 'SELECT * FROM $__win ORDER BY a',
17
+ query: 'SELECT * FROM (SELECT * FROM $__win) ORDER BY a',
18
18
  });
19
19
  });
20
20
  });
@@ -25,7 +25,7 @@ describe('buildWindowMaterialization', () => {
25
25
  'SELECT * FROM game WHERE database = $database ORDER BY sort_index asc, date desc LIMIT 30 START 30;';
26
26
  const r = buildWindowMaterialization(surql);
27
27
  expect(r).not.toBeNull();
28
- expect(r!.query).toBe('SELECT * FROM $__win ORDER BY sort_index asc, date desc');
28
+ expect(r!.query).toBe('SELECT * FROM (SELECT * FROM $__win) ORDER BY sort_index asc, date desc');
29
29
  });
30
30
 
31
31
  it('returns null for START 0 (offset-free windows still work via the normal re-query)', () => {
@@ -44,7 +44,7 @@ describe('buildWindowMaterialization', () => {
44
44
  it('preserves a custom projection', () => {
45
45
  const surql = 'SELECT id, white, black FROM game ORDER BY date desc LIMIT 30 START 60;';
46
46
  expect(buildWindowMaterialization(surql)!.query).toBe(
47
- 'SELECT id, white, black FROM $__win ORDER BY date desc'
47
+ 'SELECT id, white, black FROM (SELECT * FROM $__win) ORDER BY date desc'
48
48
  );
49
49
  });
50
50
 
@@ -58,13 +58,13 @@ describe('buildWindowMaterialization', () => {
58
58
  'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments ' +
59
59
  'FROM game ORDER BY sort_index asc LIMIT 30 START 90;';
60
60
  expect(buildWindowMaterialization(surql)!.query).toBe(
61
- 'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments FROM $__win ORDER BY sort_index asc'
61
+ 'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments FROM (SELECT * FROM $__win) ORDER BY sort_index asc'
62
62
  );
63
63
  });
64
64
 
65
65
  it('does not treat a START inside a string literal as the offset', () => {
66
66
  const surql = "SELECT * FROM game WHERE note = 'LIMIT 30 START 30' ORDER BY date desc LIMIT 30 START 30;";
67
67
  const r = buildWindowMaterialization(surql);
68
- expect(r!.query).toBe('SELECT * FROM $__win ORDER BY date desc');
68
+ expect(r!.query).toBe('SELECT * FROM (SELECT * FROM $__win) ORDER BY date desc');
69
69
  });
70
70
  });
@@ -70,7 +70,20 @@ function renderIdSetSelect(surql: string, kw: TopLevelClauses, idsParam: string)
70
70
  orderBy = ' ' + surql.slice(orderByIndex, Math.min(...ends)).trim();
71
71
  }
72
72
 
73
- return `${selectClause} FROM $${idsParam}${orderBy}`;
73
+ return `${selectClause} FROM ${idSetSource(selectClause, idsParam, orderBy !== '')}${orderBy}`;
74
+ }
75
+
76
+ /**
77
+ * The FROM source for an id-set read. The in-browser SurrealDB (wasm, 3.x)
78
+ * reads bare record ids without loading them: a field list over them throws
79
+ * ("Specify a database to use") and an ORDER BY is silently ignored, while a
80
+ * plain `SELECT *` works. Those read from `(SELECT * FROM $ids)` instead, the
81
+ * same rows as documents. The SQLite translator accepts both shapes.
82
+ */
83
+ export function idSetSource(selectClause: string, idsParam: string, ordered = false): string {
84
+ return !ordered && /^SELECT\s+(VALUE\s+)?\*/i.test(selectClause.trim())
85
+ ? `$${idsParam}`
86
+ : `(SELECT * FROM $${idsParam})`;
74
87
  }
75
88
 
76
89
  /**
@@ -556,6 +556,17 @@ describe('create() tx result shaping (fast path parity)', () => {
556
556
  expect(outboxRow.mutationType).toBe('create');
557
557
  expect(outboxRow.data).toEqual(payload);
558
558
  });
559
+
560
+ // A raw query with a field list materializes from its id-set as
561
+ // `FROM (SELECT * FROM $__win)` (the wasm SurrealDB throws on a field list
562
+ // over bare record ids). This engine reads both shapes the same way.
563
+ it('reads a field list over a wrapped id-set like the bare one', () => {
564
+ const ids = ['_00_user_feature:a', '_00_user_feature:b'];
565
+ const bare = translateSurql('SELECT key, variant FROM $__win', { __win: ids }).ops[0];
566
+ const wrapped = translateSurql('SELECT key, variant FROM (SELECT * FROM $__win)', { __win: ids }).ops[0];
567
+ expect(wrapped).toEqual(bare);
568
+ expect(wrapped).toMatchObject({ kind: 'selectByIds', ids });
569
+ });
559
570
  });
560
571
 
561
572
 
@@ -193,13 +193,14 @@ function translateStatement(stmt: string, vars: Record<string, unknown>): SqlOp
193
193
  return { kind: 'getById', id: idOperand(m[3], vars), select: projFields(m[2], !!m[1]), value };
194
194
  }
195
195
 
196
- // SELECT [VALUE] <proj> FROM $arrayParam
197
- m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+\$(\w+)$/i.exec(head);
196
+ // SELECT [VALUE] <proj> FROM $arrayParam, or FROM (SELECT * FROM $arrayParam)
197
+ // (the id-set read's shape for a field list, see `idSetSource`).
198
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+(?:\$(\w+)|\(\s*SELECT\s+\*\s+FROM\s+\$(\w+)\s*\))$/i.exec(head);
198
199
  if (m) {
199
200
  const value = m[1] ? m[2].trim() : undefined;
200
201
  return {
201
202
  kind: 'selectByIds',
202
- ids: (vars[m[3]] as unknown[]) ?? [],
203
+ ids: (vars[m[3] ?? m[4]] as unknown[]) ?? [],
203
204
  select: projFields(m[2], !!m[1]),
204
205
  value,
205
206
  limit,
@@ -69,7 +69,9 @@ export class SurrealCacheEngine extends LocalDatabaseService implements LocalCac
69
69
  if (ids.length === 0) return [];
70
70
  const projection =
71
71
  opts?.select && opts.select.length > 0 ? ['id', ...opts.select].join(', ') : '*';
72
- let sql = `SELECT ${projection} FROM $__ids`;
72
+ // As documents: over bare record ids the wasm engine throws on a field
73
+ // list and ignores ORDER BY (see `idSetSource`).
74
+ let sql = `SELECT ${projection} FROM (SELECT * FROM $__ids)`;
73
75
  if (opts?.orderBy && opts.orderBy.length > 0) {
74
76
  sql += ` ORDER BY ${opts.orderBy.map(([f, d]) => `${f} ${d}`).join(', ')}`;
75
77
  }
@@ -79,6 +79,30 @@ export function retained(s: ClientState): (id: string) => boolean {
79
79
  return (id) => id.startsWith('_00_') || known.has(id) || held.has(id);
80
80
  }
81
81
 
82
+ /**
83
+ * Ids something live still needs: a mounted query's membership or subquery
84
+ * children, or a local write not yet settled. Unlike `retained`, durable view
85
+ * rows of queries nobody runs right now do not count: a row the server just
86
+ * took out of a live query must stop showing even if an old view named it.
87
+ */
88
+ export function activelyHeld(s: ClientState): ReadonlySet<string> {
89
+ const held = new Set<string>();
90
+ for (const e of s.queries.values()) {
91
+ for (const [id] of e.remoteArray) held.add(id);
92
+ for (const [id] of e.subqueryRemoteArray) held.add(id);
93
+ }
94
+ for (const item of s.outbox) held.add(item.recordId);
95
+ for (const write of s.pendingWrites.values()) held.add(write.recordId);
96
+ return held;
97
+ }
98
+
99
+ /** Ids of `before` that `after` no longer names. */
100
+ export function departedIds(before: RecordVersionArray, after: RecordVersionArray): string[] {
101
+ if (before.length === 0) return [];
102
+ const kept = new Set(after.map(([id]) => id));
103
+ return before.filter(([id]) => !kept.has(id)).map(([id]) => id);
104
+ }
105
+
82
106
  export const pendingDeleteIds = (s: ClientState): ReadonlySet<string> =>
83
107
  new Set(s.outbox.filter((i) => i.type === 'delete').map((i) => i.recordId));
84
108