@spooky-sync/core 0.0.1-canary.325 → 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/AGENTS.md +2 -2
- package/dist/index.d.ts +12 -2
- package/dist/index.js +456 -353
- package/package.json +3 -3
- package/src/client/runtime.ts +4 -4
- package/src/client/sp00ky-client.ts +1 -1
- package/src/modules/crdt/crdt.test.ts +49 -1
- package/src/modules/crdt/index.ts +35 -13
- package/src/query/id-set-plan.test.ts +2 -2
- package/src/query/lifecycle.saga.ts +28 -1
- package/src/query/materialize.test.ts +1 -1
- package/src/query/membership.saga.test.ts +34 -0
- package/src/query/membership.saga.ts +4 -0
- package/src/query/window-query.test.ts +6 -6
- package/src/query/window-query.ts +14 -1
- package/src/services/database/sqlite-cache-engine.test.ts +11 -0
- package/src/services/database/surql-translate.ts +4 -3
- package/src/services/database/surreal-cache-engine.ts +3 -1
- package/src/state/selectors.test.ts +13 -0
- package/src/state/selectors.ts +37 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
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.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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",
|
package/src/client/runtime.ts
CHANGED
|
@@ -7,9 +7,9 @@ import type { Lane, Saga } from '../kernel/saga';
|
|
|
7
7
|
import { runSaga } from '../kernel/saga';
|
|
8
8
|
import type { ClientState } from '../state/client-state';
|
|
9
9
|
import { emptyState } from '../state/client-state';
|
|
10
|
-
import {
|
|
10
|
+
import { isAuthoritative } from '../state/lifecycle';
|
|
11
11
|
import * as R from '../state/reducers';
|
|
12
|
-
import { fetchingQueryCount, pendingMutationCount } from '../state/selectors';
|
|
12
|
+
import { fetchingQueryCount, pendingMutationCount, queryStatus } from '../state/selectors';
|
|
13
13
|
import type { SagaEnv } from '../query/env';
|
|
14
14
|
import { route } from './router';
|
|
15
15
|
|
|
@@ -152,7 +152,7 @@ export class Runtime {
|
|
|
152
152
|
for (const [hash, subs] of this.statusSubs) {
|
|
153
153
|
const entry = next.queries.get(hash);
|
|
154
154
|
if (!entry) continue;
|
|
155
|
-
const status =
|
|
155
|
+
const status = queryStatus(next, hash) ?? 'idle';
|
|
156
156
|
if (this.lastStatus.get(hash) === status) continue;
|
|
157
157
|
this.lastStatus.set(hash, status);
|
|
158
158
|
for (const cb of subs) this.safely(() => cb(status));
|
|
@@ -255,7 +255,7 @@ export class Runtime {
|
|
|
255
255
|
this.statusSubs.set(hash, set);
|
|
256
256
|
const entry = this.stateValue.queries.get(hash);
|
|
257
257
|
if (entry) {
|
|
258
|
-
const status =
|
|
258
|
+
const status = queryStatus(this.stateValue, hash) ?? 'idle';
|
|
259
259
|
this.lastStatus.set(hash, status);
|
|
260
260
|
if (options.immediate) this.safely(() => cb(status));
|
|
261
261
|
}
|
|
@@ -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) =>
|
|
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
|
-
/**
|
|
104
|
-
*
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -77,6 +77,19 @@ describe('needed / planFetch / settled', () => {
|
|
|
77
77
|
expect(S.settleFailed(s, 'a')).toBe(false);
|
|
78
78
|
expect(S.settleFailed(R.applyLifecycle('a', { type: 'remote-failed' })(s), 'a')).toBe(true);
|
|
79
79
|
});
|
|
80
|
+
it('queryStatus stays fetching until a live query is settled', () => {
|
|
81
|
+
const base = e('a', { lifecycle: { ...live, notified: true }, remoteArray: [['thing:1', 1]] });
|
|
82
|
+
const s = { ...buildState([base], R.setVersions([['thing:1', 1]])), dirty: new Set<string>() };
|
|
83
|
+
expect(S.queryStatus(s, 'a')).toBe('idle');
|
|
84
|
+
// Membership in, body still downloading (registration already ended its cycle).
|
|
85
|
+
expect(S.queryStatus(R.setVersions([['thing:1', 0]])(s), 'a')).toBe('fetching');
|
|
86
|
+
// Body landed, not yet shown to subscribers.
|
|
87
|
+
expect(S.queryStatus(R.markDirty(['a'])(s), 'a')).toBe('fetching');
|
|
88
|
+
// A cold query with no fetch in flight has nothing to wait for.
|
|
89
|
+
expect(S.queryStatus(buildState([e('c', { lifecycle: { phase: 'cold' } })]), 'c')).toBe('idle');
|
|
90
|
+
expect(S.queryStatus(R.applyLifecycle('a', { type: 'fetch-begin' })(s), 'a')).toBe('fetching');
|
|
91
|
+
expect(S.queryStatus(s, 'missing')).toBeUndefined();
|
|
92
|
+
});
|
|
80
93
|
it('fetchingQueryCount counts entries with fetch depth', () => {
|
|
81
94
|
const s = buildState([e('a', { lifecycle: { fetchDepth: 2 } }), e('b')]);
|
|
82
95
|
expect(S.fetchingQueryCount(s)).toBe(1);
|
package/src/state/selectors.ts
CHANGED
|
@@ -12,9 +12,21 @@ export const activeHashes = (s: ClientState): QueryHash[] => [...s.queries.keys(
|
|
|
12
12
|
export const hashesForTable = (s: ClientState, table: string): QueryHash[] =>
|
|
13
13
|
[...s.queries].filter(([, e]) => e.def.tableName === table).map(([h]) => h);
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The status subscribers see. `fetching` while a fetch cycle is in flight,
|
|
17
|
+
* and also while a live query is not yet `settled`: the server's membership
|
|
18
|
+
* is in but its rows are still downloading or not yet shown. Registration
|
|
19
|
+
* ends its cycle as soon as membership is applied and the body fetch starts
|
|
20
|
+
* its own a moment later, so the fetch count alone read `idle` in between,
|
|
21
|
+
* and a binding that settles on "membership known and idle" told the app a
|
|
22
|
+
* row did not exist while it was on its way (a by-id screen showed "not
|
|
23
|
+
* found" for a moment, or for as long as the bodies took).
|
|
24
|
+
*/
|
|
15
25
|
export const queryStatus = (s: ClientState, hash: QueryHash): QueryStatus | undefined => {
|
|
16
26
|
const e = s.queries.get(hash);
|
|
17
|
-
|
|
27
|
+
if (!e) return undefined;
|
|
28
|
+
if (deriveStatus(e.lifecycle) === 'fetching') return 'fetching';
|
|
29
|
+
return e.lifecycle.phase === 'live' && !settled(s, hash) ? 'fetching' : 'idle';
|
|
18
30
|
};
|
|
19
31
|
|
|
20
32
|
export interface Overlay {
|
|
@@ -67,6 +79,30 @@ export function retained(s: ClientState): (id: string) => boolean {
|
|
|
67
79
|
return (id) => id.startsWith('_00_') || known.has(id) || held.has(id);
|
|
68
80
|
}
|
|
69
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
|
+
|
|
70
106
|
export const pendingDeleteIds = (s: ClientState): ReadonlySet<string> =>
|
|
71
107
|
new Set(s.outbox.filter((i) => i.type === 'delete').map((i) => i.recordId));
|
|
72
108
|
|