@spooky-sync/core 0.0.1-canary.102 → 0.0.1-canary.104
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/index.d.ts +180 -3
- package/dist/index.js +2212 -1620
- package/dist/types.d.ts +15 -4
- package/package.json +3 -3
- package/src/modules/cache/index.ts +35 -2
- package/src/modules/crdt/crdt-field.ts +9 -2
- package/src/modules/crdt/index.ts +7 -2
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.status.test.ts +143 -2
- package/src/modules/data/index.ts +229 -13
- package/src/modules/devtools/index.ts +8 -5
- package/src/modules/ref-tables.test.ts +26 -1
- package/src/modules/ref-tables.ts +19 -0
- package/src/modules/sync/queue/queue-down.ts +6 -0
- package/src/modules/sync/queue/queue-up.ts +14 -0
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +33 -4
- package/src/modules/sync/sync.ts +76 -5
- package/src/services/database/local-migrator.ts +6 -6
- package/src/services/database/local.test.ts +31 -0
- package/src/services/database/local.ts +298 -72
- package/src/services/stream-processor/index.ts +48 -2
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/sp00ky.auth-order.test.ts +27 -0
- package/src/sp00ky.ts +132 -2
- package/src/types.ts +15 -4
|
@@ -6,50 +6,88 @@ import type { Logger } from '../logger/index';
|
|
|
6
6
|
import { AbstractDatabaseService } from './database';
|
|
7
7
|
import { createDatabaseEventSystem, DatabaseEventTypes } from './events/index';
|
|
8
8
|
import { encodeRecordId } from '../../utils/index';
|
|
9
|
+
import { ANON_USER_ID } from '../../modules/ref-tables';
|
|
10
|
+
import type { SealedQuery } from '../../utils/surql';
|
|
11
|
+
|
|
12
|
+
/** Thrown when a query carries an `epoch` from before a bucket switch. The
|
|
13
|
+
* caller's chain read from the previous user's store; its write must be
|
|
14
|
+
* dropped, not applied to the new bucket. */
|
|
15
|
+
export class StaleEpochError extends Error {
|
|
16
|
+
constructor() {
|
|
17
|
+
super('Local store epoch changed (bucket switch); stale write dropped');
|
|
18
|
+
this.name = 'StaleEpochError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Store URL for a local bucket. One IndexedDB store per user (`anon` for
|
|
23
|
+
* signed-out) so cached rows never leak across accounts on a shared device. */
|
|
24
|
+
export function bucketStoreUrl(bucketId: string): string {
|
|
25
|
+
return `indxdb://${bucketStoreName(bucketId)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The IndexedDB database name SurrealDB-WASM derives from the store URL. */
|
|
29
|
+
export function bucketStoreName(bucketId: string): string {
|
|
30
|
+
return `sp00ky-${bucketId}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createLocalSurrealClient(logger: Logger): Surreal {
|
|
34
|
+
return new Surreal({
|
|
35
|
+
codecOptions: {
|
|
36
|
+
valueDecodeVisitor(value) {
|
|
37
|
+
if (value instanceof RecordId) {
|
|
38
|
+
return encodeRecordId(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (value instanceof DateTime) {
|
|
42
|
+
return value.toDate();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return value;
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
engines: applyDiagnostics(
|
|
49
|
+
createWasmWorkerEngines(),
|
|
50
|
+
({ key, type, phase, ...other }: Diagnostic) => {
|
|
51
|
+
if (phase === 'progress' || phase === 'after') {
|
|
52
|
+
logger.trace(
|
|
53
|
+
{
|
|
54
|
+
...other,
|
|
55
|
+
key,
|
|
56
|
+
type,
|
|
57
|
+
phase,
|
|
58
|
+
service: 'surrealdb:local',
|
|
59
|
+
Category: 'sp00ky-client::LocalDatabaseService::diagnostics',
|
|
60
|
+
},
|
|
61
|
+
`Local SurrealDB diagnostics captured ${type}:${phase}`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
9
68
|
|
|
10
69
|
export class LocalDatabaseService extends AbstractDatabaseService {
|
|
11
70
|
private config: Sp00kyConfig<any>['database'];
|
|
12
71
|
protected eventType = DatabaseEventTypes.LocalQuery;
|
|
13
72
|
|
|
73
|
+
/** Bucket currently open. Set by `connect`/`switchStore`. */
|
|
74
|
+
private bucketId: string = ANON_USER_ID;
|
|
75
|
+
/**
|
|
76
|
+
* Monotonic store generation. Bumped on every `switchStore`. Async chains
|
|
77
|
+
* that read from the store, await something remote, and then write back
|
|
78
|
+
* (sync poll, SSP stream updates) capture this at chain start and drop
|
|
79
|
+
* their write when it no longer matches — a stale-epoch write would land
|
|
80
|
+
* another user's data in the new bucket.
|
|
81
|
+
*/
|
|
82
|
+
private storeEpoch = 0;
|
|
83
|
+
/** Gate that `query()`/`execute()` await; closed for the switch window. */
|
|
84
|
+
private gate: Promise<void> = Promise.resolve();
|
|
85
|
+
/** The incoming client while a switch is in flight (for unload cleanup). */
|
|
86
|
+
private pendingSwitchClient: Surreal | null = null;
|
|
87
|
+
|
|
14
88
|
constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
|
|
15
89
|
const events = createDatabaseEventSystem();
|
|
16
|
-
super(
|
|
17
|
-
new Surreal({
|
|
18
|
-
codecOptions: {
|
|
19
|
-
valueDecodeVisitor(value) {
|
|
20
|
-
if (value instanceof RecordId) {
|
|
21
|
-
return encodeRecordId(value);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (value instanceof DateTime) {
|
|
25
|
-
return value.toDate();
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return value;
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
engines: applyDiagnostics(
|
|
32
|
-
createWasmWorkerEngines(),
|
|
33
|
-
({ key, type, phase, ...other }: Diagnostic) => {
|
|
34
|
-
if (phase === 'progress' || phase === 'after') {
|
|
35
|
-
logger.trace(
|
|
36
|
-
{
|
|
37
|
-
...other,
|
|
38
|
-
key,
|
|
39
|
-
type,
|
|
40
|
-
phase,
|
|
41
|
-
service: 'surrealdb:local',
|
|
42
|
-
Category: 'sp00ky-client::LocalDatabaseService::diagnostics',
|
|
43
|
-
},
|
|
44
|
-
`Local SurrealDB diagnostics captured ${type}:${phase}`
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
),
|
|
49
|
-
}),
|
|
50
|
-
logger,
|
|
51
|
-
events
|
|
52
|
-
);
|
|
90
|
+
super(createLocalSurrealClient(logger), logger, events);
|
|
53
91
|
this.config = config;
|
|
54
92
|
}
|
|
55
93
|
|
|
@@ -57,19 +95,153 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
57
95
|
return this.config;
|
|
58
96
|
}
|
|
59
97
|
|
|
60
|
-
|
|
98
|
+
get currentBucketId(): string {
|
|
99
|
+
return this.bucketId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get epoch(): number {
|
|
103
|
+
return this.storeEpoch;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Close the query gate for a bucket switch. Every `query()`/`execute()`
|
|
108
|
+
* issued after this waits until the returned release fn runs — so work
|
|
109
|
+
* triggered mid-switch (sibling auth subscribers registering queries)
|
|
110
|
+
* lands on the NEW bucket instead of racing the swap. The migrator uses
|
|
111
|
+
* `queryUngated()` to provision the new bucket while the gate is closed.
|
|
112
|
+
*/
|
|
113
|
+
beginSwitch(): () => void {
|
|
114
|
+
let release!: () => void;
|
|
115
|
+
this.gate = new Promise<void>((resolve) => {
|
|
116
|
+
release = resolve;
|
|
117
|
+
});
|
|
118
|
+
return release;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
override async query<T extends unknown[]>(
|
|
122
|
+
query: string,
|
|
123
|
+
vars?: Record<string, unknown>,
|
|
124
|
+
opts?: { epoch?: number }
|
|
125
|
+
): Promise<T> {
|
|
126
|
+
await this.gate;
|
|
127
|
+
// A write whose async chain started before a bucket switch must not land
|
|
128
|
+
// in the new bucket — that would be another user's data. Callers on such
|
|
129
|
+
// chains pass the epoch they captured at chain start; mismatches throw.
|
|
130
|
+
if (opts?.epoch !== undefined && opts.epoch !== this.storeEpoch) {
|
|
131
|
+
throw new StaleEpochError();
|
|
132
|
+
}
|
|
133
|
+
return super.query(query, vars);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
override async execute<T>(
|
|
137
|
+
query: SealedQuery<T>,
|
|
138
|
+
vars?: Record<string, unknown>,
|
|
139
|
+
opts?: { epoch?: number }
|
|
140
|
+
): Promise<T> {
|
|
141
|
+
const raw = await this.query<unknown[]>(query.sql, vars, opts);
|
|
142
|
+
return query.extract(raw);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Gate-bypassing query — ONLY for the switch path itself (schema
|
|
146
|
+
* provisioning must run while the gate is closed, or it deadlocks). */
|
|
147
|
+
queryUngated<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T> {
|
|
148
|
+
return super.query(query, vars);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async connect(bucketId: string = ANON_USER_ID): Promise<void> {
|
|
61
152
|
const { namespace, database } = this.getConfig();
|
|
62
153
|
const store = this.getConfig().store ?? 'memory';
|
|
63
|
-
|
|
154
|
+
this.bucketId = bucketId;
|
|
155
|
+
const storeUrl = store === 'memory' ? 'mem://' : bucketStoreUrl(bucketId);
|
|
64
156
|
this.logger.info(
|
|
65
157
|
{ namespace, database, storeUrl, Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
66
158
|
'Connecting to local database'
|
|
67
159
|
);
|
|
68
160
|
|
|
69
161
|
this.registerUnloadClose();
|
|
162
|
+
await this.openWithRecovery(this.client, storeUrl, namespace, database, bucketId, store);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Switch the local store to another user's bucket. Opens the NEW bucket on a
|
|
167
|
+
* second client first (with the same 3-tier recovery), then atomically swaps
|
|
168
|
+
* `this.client` and closes the old one — a failed open never leaves the
|
|
169
|
+
* service on a dead client. Bumps the store epoch so in-flight old-bucket
|
|
170
|
+
* async chains can detect they're stale.
|
|
171
|
+
*
|
|
172
|
+
* Callers own the drain/rebind choreography (close the gate, quiesce sync +
|
|
173
|
+
* timers BEFORE calling this; re-provision + rebind AFTER).
|
|
174
|
+
*/
|
|
175
|
+
async switchStore(bucketId: string): Promise<void> {
|
|
176
|
+
if (bucketId === this.bucketId) return;
|
|
177
|
+
const { namespace, database } = this.getConfig();
|
|
178
|
+
const store = this.getConfig().store ?? 'memory';
|
|
179
|
+
this.storeEpoch++;
|
|
70
180
|
|
|
181
|
+
if (store === 'memory') {
|
|
182
|
+
// mem:// has no per-user persistence; close + reopen the same client
|
|
183
|
+
// yields a fresh empty store, which is exactly the reset we want.
|
|
184
|
+
try {
|
|
185
|
+
await this.client.close();
|
|
186
|
+
} catch {
|
|
187
|
+
/* ignore */
|
|
188
|
+
}
|
|
189
|
+
await this.openStore(this.client, 'mem://', namespace, database);
|
|
190
|
+
this.bucketId = bucketId;
|
|
191
|
+
this.logger.info(
|
|
192
|
+
{ bucketId, Category: 'sp00ky-client::LocalDatabaseService::switchStore' },
|
|
193
|
+
'Reset in-memory local store for bucket switch'
|
|
194
|
+
);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const next = createLocalSurrealClient(this.logger);
|
|
199
|
+
this.pendingSwitchClient = next;
|
|
71
200
|
try {
|
|
72
|
-
await this.
|
|
201
|
+
await this.openWithRecovery(
|
|
202
|
+
next,
|
|
203
|
+
bucketStoreUrl(bucketId),
|
|
204
|
+
namespace,
|
|
205
|
+
database,
|
|
206
|
+
bucketId,
|
|
207
|
+
store
|
|
208
|
+
);
|
|
209
|
+
} finally {
|
|
210
|
+
this.pendingSwitchClient = null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const old = this.client;
|
|
214
|
+
this.client = next;
|
|
215
|
+
this.bucketId = bucketId;
|
|
216
|
+
try {
|
|
217
|
+
await old.close();
|
|
218
|
+
} catch {
|
|
219
|
+
/* best-effort — the old store's handle is released on unload regardless */
|
|
220
|
+
}
|
|
221
|
+
this.logger.info(
|
|
222
|
+
{ bucketId, Category: 'sp00ky-client::LocalDatabaseService::switchStore' },
|
|
223
|
+
'Switched local store bucket'
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Open `storeUrl` on `client` with tiered recovery:
|
|
229
|
+
* tier 1 retries the same store (transient idb-handle races — preserves the
|
|
230
|
+
* cache), tier 2 drops THIS bucket's IndexedDB store and reconnects fresh,
|
|
231
|
+
* tier 3 falls back to `mem://` for the session. Only ever drops the bucket
|
|
232
|
+
* being opened — other users' buckets hold their own caches AND un-pushed
|
|
233
|
+
* mutation outboxes, which must survive another bucket's corruption.
|
|
234
|
+
*/
|
|
235
|
+
private async openWithRecovery(
|
|
236
|
+
client: Surreal,
|
|
237
|
+
storeUrl: string,
|
|
238
|
+
namespace: string,
|
|
239
|
+
database: string,
|
|
240
|
+
bucketId: string,
|
|
241
|
+
store: string
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
try {
|
|
244
|
+
await this.openStore(client, storeUrl, namespace, database);
|
|
73
245
|
this.logger.info(
|
|
74
246
|
{ Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
75
247
|
'Connected to local database'
|
|
@@ -101,13 +273,13 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
101
273
|
// the cache on every reload, making warm loads as slow as cold ones.
|
|
102
274
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
103
275
|
try {
|
|
104
|
-
await
|
|
276
|
+
await client.close();
|
|
105
277
|
} catch {
|
|
106
278
|
/* ignore */
|
|
107
279
|
}
|
|
108
280
|
await delay(150 * attempt);
|
|
109
281
|
try {
|
|
110
|
-
await this.openStore(storeUrl, namespace, database);
|
|
282
|
+
await this.openStore(client, storeUrl, namespace, database);
|
|
111
283
|
this.logger.info(
|
|
112
284
|
{ attempt, Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
113
285
|
'Connected to local database on retry (cache preserved)'
|
|
@@ -121,18 +293,18 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
121
293
|
}
|
|
122
294
|
}
|
|
123
295
|
|
|
124
|
-
// Tier 2 — the store is genuinely unopenable; drop
|
|
125
|
-
// This loses the cache (re-syncs from the server), so it's
|
|
126
|
-
// before in-memory.
|
|
296
|
+
// Tier 2 — the store is genuinely unopenable; drop THIS bucket and reconnect
|
|
297
|
+
// fresh. This loses the bucket's cache (re-syncs from the server), so it's
|
|
298
|
+
// the last resort before in-memory.
|
|
127
299
|
try {
|
|
128
|
-
await
|
|
300
|
+
await client.close();
|
|
129
301
|
} catch {
|
|
130
302
|
/* ignore — closing a half-open connection is best-effort */
|
|
131
303
|
}
|
|
132
|
-
await dropLocalIndexedDbStores(this.logger);
|
|
304
|
+
await dropLocalIndexedDbStores(this.logger, bucketStoreName(bucketId));
|
|
133
305
|
|
|
134
306
|
try {
|
|
135
|
-
await this.openStore(storeUrl, namespace, database);
|
|
307
|
+
await this.openStore(client, storeUrl, namespace, database);
|
|
136
308
|
this.logger.info(
|
|
137
309
|
{ Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
138
310
|
'Reconnected to local database after clearing the corrupt store'
|
|
@@ -146,11 +318,11 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
146
318
|
'Local store still failing after clear; falling back to in-memory'
|
|
147
319
|
);
|
|
148
320
|
try {
|
|
149
|
-
await
|
|
321
|
+
await client.close();
|
|
150
322
|
} catch {
|
|
151
323
|
/* ignore */
|
|
152
324
|
}
|
|
153
|
-
await this.openStore('mem://', namespace, database);
|
|
325
|
+
await this.openStore(client, 'mem://', namespace, database);
|
|
154
326
|
this.logger.warn(
|
|
155
327
|
{ Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
156
328
|
'Connected to local database (in-memory fallback)'
|
|
@@ -169,6 +341,7 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
169
341
|
* reload, making warm loads as slow as cold ones. `pagehide` is the reliable
|
|
170
342
|
* unload signal (fires on bfcache + normal navigation); `close()` is async but
|
|
171
343
|
* the WASM worker initiates the IndexedDB connection teardown synchronously.
|
|
344
|
+
* Also closes a mid-switch incoming client so its fresh handle doesn't linger.
|
|
172
345
|
*/
|
|
173
346
|
private registerUnloadClose(): void {
|
|
174
347
|
if (this.unloadCloseRegistered || typeof window === 'undefined') return;
|
|
@@ -179,22 +352,32 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
179
352
|
} catch {
|
|
180
353
|
/* best-effort */
|
|
181
354
|
}
|
|
355
|
+
try {
|
|
356
|
+
void this.pendingSwitchClient?.close();
|
|
357
|
+
} catch {
|
|
358
|
+
/* best-effort */
|
|
359
|
+
}
|
|
182
360
|
};
|
|
183
361
|
window.addEventListener('pagehide', close);
|
|
184
362
|
window.addEventListener('beforeunload', close);
|
|
185
363
|
}
|
|
186
364
|
|
|
187
|
-
private async openStore(
|
|
365
|
+
private async openStore(
|
|
366
|
+
client: Surreal,
|
|
367
|
+
storeUrl: string,
|
|
368
|
+
namespace: string,
|
|
369
|
+
database: string
|
|
370
|
+
): Promise<void> {
|
|
188
371
|
this.logger.debug(
|
|
189
372
|
{ storeUrl, Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
190
373
|
'[LocalDatabaseService] Calling client.connect'
|
|
191
374
|
);
|
|
192
|
-
await
|
|
375
|
+
await client.connect(storeUrl, {});
|
|
193
376
|
this.logger.debug(
|
|
194
377
|
{ namespace, database, Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
195
378
|
'[LocalDatabaseService] client.connect returned. Calling client.use'
|
|
196
379
|
);
|
|
197
|
-
await
|
|
380
|
+
await client.use({ namespace, database });
|
|
198
381
|
this.logger.debug(
|
|
199
382
|
{ Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
200
383
|
'[LocalDatabaseService] client.use returned'
|
|
@@ -218,34 +401,25 @@ export function isLocalStoreOpenError(err: unknown): boolean {
|
|
|
218
401
|
);
|
|
219
402
|
}
|
|
220
403
|
|
|
221
|
-
/** Best-effort delete of
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
|
|
404
|
+
/** Best-effort delete of ONE bucket's IndexedDB store(s). SurrealDB-WASM backs
|
|
405
|
+
* `indxdb://<name>` with one or more IndexedDB databases whose names include
|
|
406
|
+
* `<name>`. Scoped to the given store name — never wipe by the bare `sp00ky`
|
|
407
|
+
* substring, that would take every user's bucket (and their un-pushed mutation
|
|
408
|
+
* outboxes) down with one corrupt store. Resolves even on error/blocked so
|
|
409
|
+
* startup can proceed. No-op outside a browser. Exported for unit tests. */
|
|
410
|
+
export async function dropLocalIndexedDbStores(logger: Logger, storeName: string): Promise<void> {
|
|
226
411
|
if (typeof indexedDB === 'undefined') return;
|
|
227
|
-
const remove = (name: string): Promise<void> =>
|
|
228
|
-
new Promise((resolve) => {
|
|
229
|
-
try {
|
|
230
|
-
const req = indexedDB.deleteDatabase(name);
|
|
231
|
-
req.onsuccess = () => resolve();
|
|
232
|
-
req.onerror = () => resolve();
|
|
233
|
-
req.onblocked = () => resolve();
|
|
234
|
-
} catch {
|
|
235
|
-
resolve();
|
|
236
|
-
}
|
|
237
|
-
});
|
|
238
412
|
try {
|
|
239
413
|
let names: string[] = [];
|
|
240
414
|
if (typeof indexedDB.databases === 'function') {
|
|
241
415
|
const dbs = await indexedDB.databases();
|
|
242
416
|
names = dbs
|
|
243
417
|
.map((d) => d.name)
|
|
244
|
-
.filter((n): n is string => !!n && n
|
|
418
|
+
.filter((n): n is string => !!n && matchesBucketStore(n, storeName));
|
|
245
419
|
}
|
|
246
420
|
// Fall back to the known store name if enumeration is unavailable/empty.
|
|
247
|
-
if (names.length === 0) names = [
|
|
248
|
-
await Promise.all(names.map(
|
|
421
|
+
if (names.length === 0) names = [storeName];
|
|
422
|
+
await Promise.all(names.map(deleteIndexedDb));
|
|
249
423
|
logger.info(
|
|
250
424
|
{ names, Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
251
425
|
'Cleared local IndexedDB store(s)'
|
|
@@ -257,3 +431,55 @@ async function dropLocalIndexedDbStores(logger: Logger): Promise<void> {
|
|
|
257
431
|
);
|
|
258
432
|
}
|
|
259
433
|
}
|
|
434
|
+
|
|
435
|
+
/** True when idb database `name` belongs to the bucket store `storeName` —
|
|
436
|
+
* exact match or a derived name (`<storeName>`, `<storeName>-*`, `*<storeName>*`
|
|
437
|
+
* with a non-alphanumeric boundary so `sp00ky-abc` never matches
|
|
438
|
+
* `sp00ky-abcdef`'s store). Exported for unit tests. */
|
|
439
|
+
export function matchesBucketStore(name: string, storeName: string): boolean {
|
|
440
|
+
const lower = name.toLowerCase();
|
|
441
|
+
const target = storeName.toLowerCase();
|
|
442
|
+
const idx = lower.indexOf(target);
|
|
443
|
+
if (idx === -1) return false;
|
|
444
|
+
const after = lower[idx + target.length];
|
|
445
|
+
return after === undefined || !/[a-z0-9_]/.test(after);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Nuke EVERY sp00ky local bucket on this device (manual full reset only —
|
|
449
|
+
* the automated corruption recovery is scoped to one bucket). */
|
|
450
|
+
export async function dropAllSp00kyIndexedDbStores(logger: Logger): Promise<void> {
|
|
451
|
+
if (typeof indexedDB === 'undefined') return;
|
|
452
|
+
try {
|
|
453
|
+
let names: string[] = [];
|
|
454
|
+
if (typeof indexedDB.databases === 'function') {
|
|
455
|
+
const dbs = await indexedDB.databases();
|
|
456
|
+
names = dbs
|
|
457
|
+
.map((d) => d.name)
|
|
458
|
+
.filter((n): n is string => !!n && n.toLowerCase().includes('sp00ky'));
|
|
459
|
+
}
|
|
460
|
+
if (names.length === 0) names = ['sp00ky'];
|
|
461
|
+
await Promise.all(names.map(deleteIndexedDb));
|
|
462
|
+
logger.info(
|
|
463
|
+
{ names, Category: 'sp00ky-client::LocalDatabaseService::reset' },
|
|
464
|
+
'Cleared ALL sp00ky IndexedDB stores'
|
|
465
|
+
);
|
|
466
|
+
} catch (e) {
|
|
467
|
+
logger.warn(
|
|
468
|
+
{ err: e, Category: 'sp00ky-client::LocalDatabaseService::reset' },
|
|
469
|
+
'Failed to enumerate/clear IndexedDB; proceeding anyway'
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function deleteIndexedDb(name: string): Promise<void> {
|
|
475
|
+
return new Promise((resolve) => {
|
|
476
|
+
try {
|
|
477
|
+
const req = indexedDB.deleteDatabase(name);
|
|
478
|
+
req.onsuccess = () => resolve();
|
|
479
|
+
req.onerror = () => resolve();
|
|
480
|
+
req.onblocked = () => resolve();
|
|
481
|
+
} catch {
|
|
482
|
+
resolve();
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
}
|
|
@@ -75,6 +75,16 @@ export class StreamProcessorService {
|
|
|
75
75
|
// rejecting `$auth`-gated tables; the predicate just degrades to its public
|
|
76
76
|
// branch. Set via `setSessionAuth` on every auth state change.
|
|
77
77
|
private sessionAuth: { authId: string; access: string } = { authId: '', access: '' };
|
|
78
|
+
// Persisted-state key suffix (the local bucket id) so each user's circuit
|
|
79
|
+
// snapshot lands in their own key. Only matters for localStorage-backed
|
|
80
|
+
// persistence — the surrealdb persistence client already writes into the
|
|
81
|
+
// per-user bucket itself.
|
|
82
|
+
private stateKeySuffix = '';
|
|
83
|
+
// Bumped by `reset()`. A `saveState` captured before a reset must not persist
|
|
84
|
+
// the OLD processor's circuit (holding the previous user's rows) after the
|
|
85
|
+
// switch — the fire-and-forget calls in `ingest`/`flushCoalescing` can land
|
|
86
|
+
// late.
|
|
87
|
+
private stateGeneration = 0;
|
|
78
88
|
|
|
79
89
|
constructor(
|
|
80
90
|
public events: EventSystem<StreamProcessorEvents>,
|
|
@@ -226,10 +236,42 @@ export class StreamProcessorService {
|
|
|
226
236
|
}
|
|
227
237
|
}
|
|
228
238
|
|
|
239
|
+
/** Route the persisted circuit snapshot to a per-bucket key. */
|
|
240
|
+
setStateKeySuffix(bucketId: string) {
|
|
241
|
+
this.stateKeySuffix = bucketId;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private stateKey(): string {
|
|
245
|
+
return this.stateKeySuffix
|
|
246
|
+
? `_00_stream_processor_state:${this.stateKeySuffix}`
|
|
247
|
+
: '_00_stream_processor_state';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Drop the current WASM processor and start a fresh, empty circuit. Used on
|
|
252
|
+
* local-bucket switches: the old circuit holds the previous user's rows AND
|
|
253
|
+
* views registered with the previous `$auth` context, so neither may survive.
|
|
254
|
+
* Deliberately does NOT `loadState()` — a persisted snapshot references views
|
|
255
|
+
* under a dead sessionId salt; the DataModule rebind re-registers every live
|
|
256
|
+
* view against this fresh processor. Caller must re-seed `setPermissions`
|
|
257
|
+
* afterwards (a fresh circuit default-denies every table).
|
|
258
|
+
*/
|
|
259
|
+
async reset(): Promise<void> {
|
|
260
|
+
if (!this.isInitialized) return;
|
|
261
|
+
this.stateGeneration++;
|
|
262
|
+
this.batching = false;
|
|
263
|
+
this.batchBuffer.clear();
|
|
264
|
+
this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
|
|
265
|
+
this.logger.info(
|
|
266
|
+
{ Category: 'sp00ky-client::StreamProcessorService::reset' },
|
|
267
|
+
'Stream processor reset (fresh circuit)'
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
229
271
|
async loadState() {
|
|
230
272
|
if (!this.processor) return;
|
|
231
273
|
try {
|
|
232
|
-
const result = await this.persistenceClient.get(
|
|
274
|
+
const result = await this.persistenceClient.get(this.stateKey());
|
|
233
275
|
|
|
234
276
|
// Check if we have a valid result from the query
|
|
235
277
|
if (
|
|
@@ -319,12 +361,16 @@ export class StreamProcessorService {
|
|
|
319
361
|
|
|
320
362
|
async saveState() {
|
|
321
363
|
if (!this.processor) return;
|
|
364
|
+
const generation = this.stateGeneration;
|
|
322
365
|
try {
|
|
323
366
|
// Assuming processor has a save_state method that returns the state string/bytes
|
|
324
367
|
if (typeof this.processor.save_state === 'function') {
|
|
325
368
|
const state = this.processor.save_state();
|
|
369
|
+
// A reset raced this snapshot — persisting it would write the previous
|
|
370
|
+
// user's circuit into the new bucket's key. Drop it.
|
|
371
|
+
if (generation !== this.stateGeneration) return;
|
|
326
372
|
if (state) {
|
|
327
|
-
await this.persistenceClient.set(
|
|
373
|
+
await this.persistenceClient.set(this.stateKey(), state);
|
|
328
374
|
this.logger.trace(
|
|
329
375
|
{ Category: 'sp00ky-client::StreamProcessorService::saveState' },
|
|
330
376
|
'State saved'
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// `StreamProcessorService.reset()` is the SSP half of a local-bucket switch:
|
|
4
|
+
// the old circuit holds the previous user's rows and views registered with the
|
|
5
|
+
// previous `$auth`, so reset must swap in a FRESH processor (no state load),
|
|
6
|
+
// and a `saveState` racing the reset must not persist the old circuit into the
|
|
7
|
+
// new bucket's key.
|
|
8
|
+
|
|
9
|
+
const processorInstances: any[] = [];
|
|
10
|
+
|
|
11
|
+
vi.mock('@spooky-sync/ssp-wasm', () => ({
|
|
12
|
+
default: vi.fn(async () => {}),
|
|
13
|
+
Sp00kyProcessor: vi.fn(() => {
|
|
14
|
+
const instance = {
|
|
15
|
+
ingest: vi.fn(() => []),
|
|
16
|
+
register_view: vi.fn(() => ({ query_id: 'q1', result_data: [] })),
|
|
17
|
+
unregister_view: vi.fn(),
|
|
18
|
+
set_permissions: vi.fn(),
|
|
19
|
+
save_state: vi.fn(() => 'state-bytes'),
|
|
20
|
+
load_state: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
processorInstances.push(instance);
|
|
23
|
+
return instance;
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { StreamProcessorService } from './index';
|
|
28
|
+
|
|
29
|
+
const silentLogger = {
|
|
30
|
+
child: () => silentLogger,
|
|
31
|
+
trace: () => {},
|
|
32
|
+
debug: () => {},
|
|
33
|
+
info: () => {},
|
|
34
|
+
warn: () => {},
|
|
35
|
+
error: () => {},
|
|
36
|
+
} as any;
|
|
37
|
+
|
|
38
|
+
function makeService() {
|
|
39
|
+
const persisted: Record<string, unknown> = {};
|
|
40
|
+
const persistence = {
|
|
41
|
+
set: vi.fn(async (key: string, value: unknown) => {
|
|
42
|
+
persisted[key] = value;
|
|
43
|
+
}),
|
|
44
|
+
get: vi.fn(async () => null),
|
|
45
|
+
remove: vi.fn(async () => {}),
|
|
46
|
+
};
|
|
47
|
+
const service = new StreamProcessorService({} as any, {} as any, persistence as any, silentLogger);
|
|
48
|
+
return { service, persistence, persisted };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
processorInstances.length = 0;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('StreamProcessorService.reset', () => {
|
|
56
|
+
it('swaps in a fresh processor without loading persisted state', async () => {
|
|
57
|
+
const { service } = makeService();
|
|
58
|
+
await service.init();
|
|
59
|
+
expect(processorInstances).toHaveLength(1);
|
|
60
|
+
const first = processorInstances[0];
|
|
61
|
+
|
|
62
|
+
await service.reset();
|
|
63
|
+
expect(processorInstances).toHaveLength(2);
|
|
64
|
+
const second = processorInstances[1];
|
|
65
|
+
// Fresh circuit: nothing loaded into it (a persisted snapshot references
|
|
66
|
+
// views under a dead sessionId salt and the previous user's data).
|
|
67
|
+
expect(second.load_state).not.toHaveBeenCalled();
|
|
68
|
+
|
|
69
|
+
// New registrations land on the fresh processor, not the old circuit.
|
|
70
|
+
service.registerQueryPlan({
|
|
71
|
+
queryHash: 'q1',
|
|
72
|
+
surql: 'SELECT * FROM thing;',
|
|
73
|
+
params: {},
|
|
74
|
+
ttl: '10m',
|
|
75
|
+
lastActiveAt: new Date(),
|
|
76
|
+
localArray: [],
|
|
77
|
+
remoteArray: [],
|
|
78
|
+
meta: { tableName: 'thing' },
|
|
79
|
+
} as any);
|
|
80
|
+
expect(second.register_view).toHaveBeenCalled();
|
|
81
|
+
expect(first.register_view).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('routes persisted state to the per-bucket key', async () => {
|
|
85
|
+
const { service, persistence } = makeService();
|
|
86
|
+
await service.init();
|
|
87
|
+
service.setStateKeySuffix('u1');
|
|
88
|
+
await service.saveState();
|
|
89
|
+
expect(persistence.set).toHaveBeenCalledWith('_00_stream_processor_state:u1', 'state-bytes');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('drops a saveState that raced a reset (old circuit never persists into the new key)', async () => {
|
|
93
|
+
const { service, persistence } = makeService();
|
|
94
|
+
await service.init();
|
|
95
|
+
// The snapshot is taken, then a reset lands before the persist step —
|
|
96
|
+
// simulated by bumping the generation from inside save_state().
|
|
97
|
+
processorInstances[0].save_state.mockImplementation(() => {
|
|
98
|
+
void service.reset();
|
|
99
|
+
return 'stale-circuit';
|
|
100
|
+
});
|
|
101
|
+
await service.saveState();
|
|
102
|
+
expect(persistence.set).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
});
|