@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/AGENTS.md +5 -3
- package/dist/index.d.ts +12 -2
- package/dist/index.js +461 -344
- package/dist/types.d.ts +10 -1
- package/package.json +5 -4
- package/scripts/check-worker-entries.mjs +1 -0
- package/skills/sp00ky-core/SKILL.md +1 -1
- package/skills/sp00ky-core/references/config.md +1 -1
- package/src/client/sp00ky-client.test.ts +35 -0
- package/src/client/sp00ky-client.ts +8 -2
- package/src/modules/auth/auth.local-first.test.ts +14 -0
- package/src/modules/auth/index.ts +3 -3
- 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 +57 -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/database.ts +10 -0
- package/src/services/database/local.test.ts +86 -0
- package/src/services/database/local.ts +22 -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.ts +24 -0
- package/src/testing/fake-services.ts +16 -1
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
|
|
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.
|
|
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.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
|
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",
|
|
@@ -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` — `'
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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,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
|
|
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
|