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

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/dist/types.d.ts CHANGED
@@ -601,6 +601,12 @@ declare abstract class AbstractDatabaseService {
601
601
  /** FIFO slot acquisition, so `maxConcurrentQueries = 1` keeps strict order. */
602
602
  private acquireQuerySlot;
603
603
  private releaseQuerySlot;
604
+ /**
605
+ * Resolve once every statement issued before this call has settled: take
606
+ * every slot (FIFO, so earlier statements go first), then hand them back.
607
+ * Bounded by {@link queryTimeoutMs}, like the statements it waits on.
608
+ */
609
+ protected drainStatements(): Promise<void>;
604
610
  /**
605
611
  * Hook run right before a statement is sent, inside its slot. The remote
606
612
  * service uses it to hold statements while a connect is in flight, so a
@@ -851,8 +857,11 @@ declare class AuthService<S extends SchemaStructure> {
851
857
  /**
852
858
  * Subscribe to auth state changes.
853
859
  * callback is called immediately with current value and whenever validation status changes.
860
+ * `immediate: false` skips that first call: only changes are reported.
854
861
  */
855
- subscribe(cb: (userId: string | null) => void): () => void;
862
+ subscribe(cb: (userId: string | null) => void, opts?: {
863
+ immediate?: boolean;
864
+ }): () => void;
856
865
  /**
857
866
  * Run `hook` at the start of every sign-out, while the token and the socket
858
867
  * still belong to the leaving user (Web Push removes this device's
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.328",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,10 +75,11 @@
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.328",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.328",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
- "@surrealdb/wasm": "^3.0.3",
81
+ "@surrealdb/wasm": "^3.1.0",
82
+ "@surrealdb/wasm-native": "^3.3.4",
82
83
  "blurhash": "^2.0.5",
83
84
  "fast-json-patch": "^3.1.1",
84
85
  "loro-crdt": "1.11.0",
@@ -23,6 +23,7 @@ const ENTRIES = ['pure.js', 'live.js', 'sw.js'];
23
23
  const FORBIDDEN_IMPORTS = [
24
24
  '@spooky-sync/ssp-wasm',
25
25
  '@surrealdb/wasm',
26
+ '@surrealdb/wasm-native',
26
27
  '@sqlite.org/sqlite-wasm',
27
28
  'loro-crdt',
28
29
  'pino',
@@ -47,7 +47,7 @@ See [references/config.md](references/config.md) for all configuration options.
47
47
  Key fields:
48
48
  - `database.endpoint` — SurrealDB WebSocket URL
49
49
  - `database.namespace` / `database.database` — SurrealDB namespace and database
50
- - `database.store` — `'indexeddb'` (persistent) or `'memory'` (transient)
50
+ - `database.store` — `'memory'` (transient, the default) or `'indexeddb'` (persistent; needs `@surrealdb/wasm` >= 3.1.0, and Vite apps exclude `@surrealdb/wasm-native` from `optimizeDeps`)
51
51
  - `schema` — The `SchemaStructure` object (use `as const satisfies SchemaStructure`)
52
52
  - `schemaSurql` — The compiled SURQL schema string for local DB provisioning
53
53
  - `logLevel` — `'debug' | 'info' | 'warn' | 'error'`
@@ -9,7 +9,7 @@ interface Sp00kyConfig<S extends SchemaStructure> {
9
9
  namespace: string;
10
10
  /** SurrealDB database name */
11
11
  database: string;
12
- /** Local store type: 'memory' (transient) or 'indexeddb' (persistent) */
12
+ /** Local store type: 'memory' (transient, default) or 'indexeddb' (persistent) */
13
13
  store?: 'memory' | 'indexeddb';
14
14
  /** Authentication token */
15
15
  token?: string;
@@ -78,6 +78,41 @@ describe('Sp00kyClient facade', () => {
78
78
  expect(client.liveRetryCount).toBe(0);
79
79
  });
80
80
 
81
+ it('a warm boot opens the hinted bucket alone; no anon switch races it', async () => {
82
+ // A reload of a signed-in tab: the hint names the user's bucket and the
83
+ // store takes a while to open (IndexedDB). The auth service's signed-out
84
+ // answer at construction used to dispatch a flip to anon, which switched
85
+ // stores and provisioned while boot was still connecting.
86
+ let bucket = 'anon';
87
+ let opened!: () => void;
88
+ const { client, a, services } = makeClient({
89
+ services: {
90
+ 'local.connect': (id: string) => {
91
+ bucket = id;
92
+ return new Promise<void>((r) => (opened = r));
93
+ },
94
+ 'local.currentBucketId': () => bucket,
95
+ 'local.beginSwitch': () => () => {},
96
+ 'local.switchStore': async (id: string) => void (bucket = id),
97
+ },
98
+ });
99
+ const init = client.init();
100
+ await flush();
101
+ opened();
102
+ await init;
103
+ await flush();
104
+ expect(a.calls.filter(([n]) => n === 'service.local.connect').map(([, args]) => args)).toEqual([['u1']]);
105
+ expect(a.names()).not.toContain('service.local.beginSwitch');
106
+ expect(a.names()).not.toContain('service.local.switchStore');
107
+ expect(a.names()).not.toContain('service.hint.write');
108
+ expect(client.state.bucketId).toBe('u1');
109
+
110
+ // A real auth change still flips.
111
+ services.authListeners.forEach((cb) => cb(null));
112
+ await flush();
113
+ expect(a.calls.filter(([n]) => n === 'service.local.switchStore').map(([, args]) => args)).toEqual([['anon']]);
114
+ });
115
+
81
116
  it('queryRaw registers locally and returns the hash before any remote work; subscribers paint from the local store', async () => {
82
117
  // Boot loads the view index: `thing:1` is vouched for, `thing:2` is an orphan and stays hidden.
83
118
  const stored = (sql: string) =>
@@ -223,7 +223,13 @@ export class Sp00kyClient<S extends SchemaStructure> {
223
223
  const receiver: StreamUpdateReceiver = { onStreamUpdate: (update) => void this.runtime.dispatch({ type: 'StreamUpdate', update }) };
224
224
  s.streamProcessor.addReceiver(receiver);
225
225
  s.connectionSupervisor.subscribe((state) => void this.runtime.dispatch({ type: 'ConnectionChanged', state }));
226
- s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }));
226
+ // Changes only. The immediate answer here is always "signed out" (nothing
227
+ // is restored before `init()`), and boot owns the first identity: it opens
228
+ // the bucket the hint names and announces the restored session itself. As
229
+ // a flip, that answer switched to the anon bucket while boot was still
230
+ // opening the user's, so two clients provisioned at once, boot failed, and
231
+ // a persistent store never got past the provider's fallback on reload.
232
+ s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }), { immediate: false });
227
233
  // Whoever is looking at the page should be able to tell, and leave. The
228
234
  // app can restyle this, replace it (`mode: 'custom'`, with a fallback if
229
235
  // its own never renders) or take the job over entirely (`mode: 'none'`).
@@ -603,7 +609,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
603
609
  }
604
610
 
605
611
  /** Close a `@crdt` field. The last close resolves once its unsent edits
606
- * are queued in the outbox. */
612
+ * are queued in the outbox; offline, once they are saved on the device. */
607
613
  closeCrdtField(table: string, recordId: string, field: string): Promise<void> {
608
614
  return this.services.crdt.close(table, recordId, field);
609
615
  }
@@ -56,6 +56,20 @@ describe('restoreSessionFromToken', () => {
56
56
  expect(seen).toContain('user:abc');
57
57
  });
58
58
 
59
+ it('reports only changes to a subscriber that opts out of the immediate call', async () => {
60
+ const { auth } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
61
+ const all: (string | null)[] = [];
62
+ const changes: (string | null)[] = [];
63
+ auth.subscribe((uid) => all.push(uid));
64
+ auth.subscribe((uid) => changes.push(uid), { immediate: false });
65
+ expect(all).toEqual([null]);
66
+ expect(changes).toEqual([]);
67
+
68
+ await auth.restoreSessionFromToken();
69
+
70
+ expect(changes).toEqual(['user:abc']);
71
+ });
72
+
59
73
  it('returns null when there is no token, and when the token carries no id', async () => {
60
74
  expect(await makeAuth().auth.restoreSessionFromToken()).toBeNull();
61
75
  const noId = makeAuth({ token: jwt({ AC: 'account' }) });
@@ -150,10 +150,10 @@ export class AuthService<S extends SchemaStructure> {
150
150
  /**
151
151
  * Subscribe to auth state changes.
152
152
  * callback is called immediately with current value and whenever validation status changes.
153
+ * `immediate: false` skips that first call: only changes are reported.
153
154
  */
154
- subscribe(cb: (userId: string | null) => void): () => void {
155
- // Immediate callback
156
- cb(this.announcedUserId());
155
+ subscribe(cb: (userId: string | null) => void, opts: { immediate?: boolean } = {}): () => void {
156
+ if (opts.immediate !== false) cb(this.announcedUserId());
157
157
 
158
158
  const id = this.events.subscribe(AuthEventTypes.AuthStateChanged, (event) => {
159
159
  cb(event.payload);
@@ -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,63 @@ 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
+ const writing = {
182
+ ...base,
183
+ pendingWrites: new Map([['k', { key: 'k', table: 'c', recordId: 'c:1', data: {}, before: null, firstAt: 0, mirrorId: null }]]),
184
+ };
185
+ expect((await runPure(applySubqueryChildren('a', []), { state: writing, handlers })).log.some((e) => e.kind === 'local.delete')).toBe(false);
186
+ });
187
+ it('a leader relays the removal to its followers; a failed local delete keeps the row known', async () => {
188
+ const base = buildState(
189
+ [buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live' }, remoteArray: [['t:1', 1]], subqueryRemoteArray: [['c:1', 1]] })],
190
+ R.setVersions([['t:1', 1], ['c:1', 1]])
191
+ );
192
+ const ok = { 'local.upsert': () => undefined, 'local.delete': () => undefined, 'ssp.ingest': () => undefined };
193
+ const leader = await runPure(applySubqueryChildren('a', []), { state: { ...base, tabRole: 'leader' as const }, handlers: ok });
194
+ expect(leader.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'ingest', records: [{ table: 'c', op: 'DELETE', id: 'c:1', record: {} }] } });
195
+ const failing = {
196
+ ...ok,
197
+ 'local.delete': () => {
198
+ throw new Error('locked');
199
+ },
200
+ };
201
+ const out = await runPure(applySubqueryChildren('a', []), { state: base, handlers: failing });
202
+ expect(out.state.versions.has('c:1')).toBe(true);
203
+ expect(out.log.some((e) => e.kind === 'ssp.ingest')).toBe(false);
204
+ });
205
+ });
206
+
150
207
  describe('readMembership', () => {
151
208
  const qid = (h: string) => new RecordId('_00_query', h);
152
209
  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
  /**
@@ -86,6 +86,16 @@ export abstract class AbstractDatabaseService {
86
86
  if (next) next();
87
87
  }
88
88
 
89
+ /**
90
+ * Resolve once every statement issued before this call has settled: take
91
+ * every slot (FIFO, so earlier statements go first), then hand them back.
92
+ * Bounded by {@link queryTimeoutMs}, like the statements it waits on.
93
+ */
94
+ protected async drainStatements(): Promise<void> {
95
+ for (let i = 0; i < this.maxConcurrentQueries; i++) await this.acquireQuerySlot();
96
+ for (let i = 0; i < this.maxConcurrentQueries; i++) this.releaseQuerySlot();
97
+ }
98
+
89
99
  /**
90
100
  * Hook run right before a statement is sent, inside its slot. The remote
91
101
  * service uses it to hold statements while a connect is in flight, so a