@spooky-sync/core 0.0.1-canary.327 → 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 +3 -1
- package/dist/index.js +36 -8
- 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 +7 -1
- package/src/modules/auth/auth.local-first.test.ts +14 -0
- package/src/modules/auth/index.ts +3 -3
- package/src/query/membership.saga.test.ts +23 -0
- 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/testing/fake-services.ts +16 -1
package/AGENTS.md
CHANGED
|
@@ -37,7 +37,7 @@ re-materialized from state, never patched in place.
|
|
|
37
37
|
- `FailedMutation` — a row of the `_00_failed_mutations` tray (`mutationType`, `recordId`, `data`, `beforeRecord`, `error`, `revert`).
|
|
38
38
|
- `BucketHandle` — file storage handle (`put`, `get`, `delete`, `exists`), plus the cache-aware `read`, `url`, `pin`/`unpin`, `evict`, `prefetch`. `get` is always remote; `read`/`url` go through the blob cache.
|
|
39
39
|
- `services/blobs/` — durable cache for bucket bytes: OPFS holds the files, `_00_blob` holds a manifest that reconcile rebuilds from disk (so a wiped local store costs metadata, not the offline cache). Nothing expires on a timer; eviction is LRU under a byte budget only, skipping pinned and on-screen entries. Config: `blobCache` in `types.ts`.
|
|
40
|
-
- `AuthService` — token management, sign-in/sign-out events.
|
|
40
|
+
- `AuthService` — token management, sign-in/sign-out events. `subscribe(cb, { immediate? })` calls `cb` at once with the current user unless `immediate: false`, then on every change.
|
|
41
41
|
- `CrdtManager`, `CrdtField`, `cursorColorFromName`, `CURSOR_COLORS`: collaborative `@crdt` fields on Loro. Client methods: `openCrdtField(table, recordId, field, draft?)` -> `Promise<CrdtField>` (every open of one field in a tab shares one `LoroDoc`; pair each with a close), `closeCrdtField(table, recordId, field)` -> `Promise<void>` (resolves once unsent edits are in the outbox; offline, once they are saved on the device, never waiting on the network), `createCrdtDraft(table, field)` -> a detached `CrdtField` for a record not created yet (`db.create` first, then pass it to `openCrdtField` as `draft`). `CrdtField`: `getDoc()`, `hasContent()`, `pushCursorState(bytes)`, `onCursorUpdate = cb` (a setter). Config: `crdt: true` preloads Loro, `crdtDebounceMs` (default 300, max wait 2 s). Design: `docs/crdt-v2.md` at the repo root.
|
|
42
42
|
- Types: `Sp00kyConfig` (client-solid wraps it as `SyncedDbConfig`), `QueryTimeToLive`, `PersistenceClient`, `StoreType`, `UpdateOptions`, `RunOptions`, `PreloadOptions`.
|
|
43
43
|
- Subpath: `@spooky-sync/core/otel` — `createOtelTransmit(endpoint)` for piping pino logs to OpenTelemetry.
|
|
@@ -57,6 +57,8 @@ re-materialized from state, never patched in place.
|
|
|
57
57
|
- **`@parent` columns are auto-populated.** Defined in the `.surql` schema as `record<user>` with the `-- @parent` annotation; never write them yourself — they're set from the auth context server-side.
|
|
58
58
|
- **`db.preload` is a registered query.** Resolved before on this device (a `_00_view` row exists) it returns at once; never resolved it waits until the server's membership and every body are local. A signed-out session without `enableAnonymousLiveQueries` is never sent membership, so its preload returns at once instead of blocking. It holds no subscriber, so it is evicted a ttl after registration unless a view mounts the same query. `refresh`/`staleTime` are ignored; pass `signal` to abort a cold wait.
|
|
59
59
|
- **A never-resolved query paints only vouched rows.** Before the server answers (`cold`), the local predicate scan shows a cached body only if some `_00_view` row names it (as a member or subquery child) or a write for it is in flight. A body no view names is an orphan (deleted upstream while nothing watched it): it stays hidden, and the GC deletes it 30 s after boot and then hourly. A `_00_view` row no query has resolved for 14 days is retired by the same sweep, so that query paints cold again.
|
|
60
|
+
- **`database.store` defaults to `'memory'`.** `'indexeddb'` keeps one IndexedDB database per user bucket (`sp00ky-<bucket>`) and needs `@surrealdb/wasm` >= 3.1.0: the 3.0.x engine opened the store but failed every write (`An IndexedDB error occured: idb error`), so each session fell back to memory. 3.1.0 ships the engine as `@surrealdb/wasm-native`; a Vite app lists it in `optimizeDeps.exclude` next to `@surrealdb/wasm`. A store that still cannot open after a drop runs the session in memory (error log `falling back to in-memory`). Under `localEngine: 'sqlite'`, `'indexeddb'` means OPFS.
|
|
61
|
+
- **Boot owns the first identity.** The facade subscribes to auth with `immediate: false`: boot opens the bucket the hint names and `auth.restoreSession` announces the user. `LocalDatabaseService.switchStore` also waits for an in-flight `connect`, and lets statements already past the gate finish before it closes a client: the 3.1 engine streams results from its worker, and a statement whose client closed under it stayed pending until its 30 s deadline with every later local statement queued behind it.
|
|
60
62
|
- **A rejected write is not lost.** Application errors roll the local row back (create/update/delete) and move the mutation to `_00_failed_mutations`; the tray API above lists, retries (as a fresh write) or discards it. Network errors keep the row queued.
|
|
61
63
|
|
|
62
64
|
## Module map (under `src/`)
|
package/dist/index.js
CHANGED
|
@@ -2188,9 +2188,9 @@ function trimHistory(history, cap) {
|
|
|
2188
2188
|
|
|
2189
2189
|
//#endregion
|
|
2190
2190
|
//#region src/modules/devtools/index.ts
|
|
2191
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2192
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2193
|
-
const SURREAL_VERSION = "3.0
|
|
2191
|
+
const CORE_VERSION = "0.0.1-canary.328";
|
|
2192
|
+
const WASM_VERSION = "0.0.1-canary.328";
|
|
2193
|
+
const SURREAL_VERSION = "3.1.0";
|
|
2194
2194
|
var DevToolsService = class DevToolsService {
|
|
2195
2195
|
version = CORE_VERSION;
|
|
2196
2196
|
backendInfo = emptyBackendInfo();
|
|
@@ -7848,6 +7848,15 @@ var AbstractDatabaseService = class {
|
|
|
7848
7848
|
if (next) next();
|
|
7849
7849
|
}
|
|
7850
7850
|
/**
|
|
7851
|
+
* Resolve once every statement issued before this call has settled: take
|
|
7852
|
+
* every slot (FIFO, so earlier statements go first), then hand them back.
|
|
7853
|
+
* Bounded by {@link queryTimeoutMs}, like the statements it waits on.
|
|
7854
|
+
*/
|
|
7855
|
+
async drainStatements() {
|
|
7856
|
+
for (let i = 0; i < this.maxConcurrentQueries; i++) await this.acquireQuerySlot();
|
|
7857
|
+
for (let i = 0; i < this.maxConcurrentQueries; i++) this.releaseQuerySlot();
|
|
7858
|
+
}
|
|
7859
|
+
/**
|
|
7851
7860
|
* Hook run right before a statement is sent, inside its slot. The remote
|
|
7852
7861
|
* service uses it to hold statements while a connect is in flight, so a
|
|
7853
7862
|
* query never runs on a socket that is open but has not had its namespace
|
|
@@ -8060,6 +8069,8 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
8060
8069
|
gate = Promise.resolve();
|
|
8061
8070
|
/** The incoming client while a switch is in flight (for unload cleanup). */
|
|
8062
8071
|
pendingSwitchClient = null;
|
|
8072
|
+
/** The open `connect()` started, until it settles. */
|
|
8073
|
+
opening = null;
|
|
8063
8074
|
constructor(config, logger) {
|
|
8064
8075
|
const events = createDatabaseEventSystem();
|
|
8065
8076
|
super(createBareSurrealClient(), logger, events);
|
|
@@ -8107,6 +8118,15 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
8107
8118
|
return super.query(query, vars);
|
|
8108
8119
|
}
|
|
8109
8120
|
async connect(bucketId = ANON_USER_ID) {
|
|
8121
|
+
const opening = this.openBucket(bucketId);
|
|
8122
|
+
this.opening = opening;
|
|
8123
|
+
try {
|
|
8124
|
+
await opening;
|
|
8125
|
+
} finally {
|
|
8126
|
+
if (this.opening === opening) this.opening = null;
|
|
8127
|
+
}
|
|
8128
|
+
}
|
|
8129
|
+
async openBucket(bucketId) {
|
|
8110
8130
|
const { namespace, database } = this.getConfig();
|
|
8111
8131
|
const store = this.getConfig().store ?? "memory";
|
|
8112
8132
|
this.bucketId = bucketId;
|
|
@@ -8129,14 +8149,20 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
8129
8149
|
* async chains can detect they're stale.
|
|
8130
8150
|
*
|
|
8131
8151
|
* Callers own the drain/rebind choreography (close the gate, quiesce sync +
|
|
8132
|
-
* timers BEFORE calling this; re-provision + rebind AFTER).
|
|
8152
|
+
* timers BEFORE calling this; re-provision + rebind AFTER). Statements that
|
|
8153
|
+
* passed the gate before it closed are let finish before the old client
|
|
8154
|
+
* closes: the engine streams results from its worker, and a client closed
|
|
8155
|
+
* under a statement leaves that statement pending until its deadline, with
|
|
8156
|
+
* every later local statement queued behind it.
|
|
8133
8157
|
*/
|
|
8134
8158
|
async switchStore(bucketId) {
|
|
8159
|
+
if (this.opening) await this.opening.catch(() => {});
|
|
8135
8160
|
if (bucketId === this.bucketId) return;
|
|
8136
8161
|
const { namespace, database } = this.getConfig();
|
|
8137
8162
|
const store = this.getConfig().store ?? "memory";
|
|
8138
8163
|
this.storeEpoch++;
|
|
8139
8164
|
if (store === "memory") {
|
|
8165
|
+
await this.drainStatements();
|
|
8140
8166
|
try {
|
|
8141
8167
|
await this.client.close();
|
|
8142
8168
|
} catch {}
|
|
@@ -8155,6 +8181,7 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
8155
8181
|
} finally {
|
|
8156
8182
|
this.pendingSwitchClient = null;
|
|
8157
8183
|
}
|
|
8184
|
+
await this.drainStatements();
|
|
8158
8185
|
const old = this.client;
|
|
8159
8186
|
this.client = next;
|
|
8160
8187
|
this.bucketId = bucketId;
|
|
@@ -11855,9 +11882,10 @@ var AuthService = class {
|
|
|
11855
11882
|
/**
|
|
11856
11883
|
* Subscribe to auth state changes.
|
|
11857
11884
|
* callback is called immediately with current value and whenever validation status changes.
|
|
11885
|
+
* `immediate: false` skips that first call: only changes are reported.
|
|
11858
11886
|
*/
|
|
11859
|
-
subscribe(cb) {
|
|
11860
|
-
cb(this.announcedUserId());
|
|
11887
|
+
subscribe(cb, opts = {}) {
|
|
11888
|
+
if (opts.immediate !== false) cb(this.announcedUserId());
|
|
11861
11889
|
const id = this.events.subscribe(AuthEventTypes.AuthStateChanged, (event) => {
|
|
11862
11890
|
cb(event.payload);
|
|
11863
11891
|
});
|
|
@@ -15364,7 +15392,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
15364
15392
|
return new TabsCoordinator({
|
|
15365
15393
|
tabId: s.tabId,
|
|
15366
15394
|
fingerprint: computeTabsFingerprint({
|
|
15367
|
-
coreVersion: "0.0.1-canary.
|
|
15395
|
+
coreVersion: "0.0.1-canary.328",
|
|
15368
15396
|
schemaHash: hash53(config.schemaSurql),
|
|
15369
15397
|
endpoint: config.database.endpoint ?? "",
|
|
15370
15398
|
namespace: config.database.namespace,
|
|
@@ -16134,7 +16162,7 @@ var Sp00kyClient = class {
|
|
|
16134
16162
|
s.auth.subscribe((userId) => void this.runtime.dispatch({
|
|
16135
16163
|
type: "AuthFlip",
|
|
16136
16164
|
userId
|
|
16137
|
-
}));
|
|
16165
|
+
}), { immediate: false });
|
|
16138
16166
|
this.impersonationBanner = new ImpersonationBanner(() => s.auth.stopImpersonating(), this.config.impersonationBanner);
|
|
16139
16167
|
const banner = this.impersonationBanner;
|
|
16140
16168
|
this.disposers.push(s.auth.subscribeImpersonation((info) => banner.update(info)), () => banner.unmount());
|
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'`).
|
|
@@ -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);
|
|
@@ -178,6 +178,29 @@ describe('rows the server takes out of a query', () => {
|
|
|
178
178
|
expect((await runPure(applySubqueryChildren('a', []), { state: pending, handlers })).log.some((e) => e.kind === 'local.delete')).toBe(false);
|
|
179
179
|
const follower = { ...base, tabRole: 'follower' as const };
|
|
180
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);
|
|
181
204
|
});
|
|
182
205
|
});
|
|
183
206
|
|
|
@@ -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
|
|
@@ -62,3 +62,89 @@ describe('matchesBucketStore', () => {
|
|
|
62
62
|
expect(matchesBucketStore('sp00ky', 'sp00ky-anon')).toBe(false);
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
// A bucket switch that lands while the boot open is still in flight (a reload
|
|
67
|
+
// of a signed-in tab, with IndexedDB slow to open) must wait for it. Run
|
|
68
|
+
// concurrently, the switch closed the client `connect` was opening and both
|
|
69
|
+
// provisioned a store at once, so boot never finished.
|
|
70
|
+
import { vi } from 'vitest';
|
|
71
|
+
import { Surreal } from 'surrealdb';
|
|
72
|
+
import { LocalDatabaseService } from './local';
|
|
73
|
+
|
|
74
|
+
vi.mock('@surrealdb/wasm', () => ({ createWasmWorkerEngines: () => ({}) }));
|
|
75
|
+
|
|
76
|
+
describe('LocalDatabaseService open and switch', () => {
|
|
77
|
+
it('a switch waits for the in-flight connect instead of closing the client it opens', async () => {
|
|
78
|
+
const opens: Array<{ url: string; done: () => void }> = [];
|
|
79
|
+
const closed: string[] = [];
|
|
80
|
+
const urlOf = new WeakMap<object, string>();
|
|
81
|
+
vi.spyOn(Surreal.prototype, 'connect').mockImplementation(function (this: Surreal, url: string | URL) {
|
|
82
|
+
urlOf.set(this, String(url));
|
|
83
|
+
return new Promise<true>((resolve) => opens.push({ url: String(url), done: () => resolve(true) }));
|
|
84
|
+
});
|
|
85
|
+
vi.spyOn(Surreal.prototype, 'use').mockResolvedValue({});
|
|
86
|
+
vi.spyOn(Surreal.prototype, 'close').mockImplementation(async function (this: Surreal) {
|
|
87
|
+
closed.push(urlOf.get(this) ?? 'placeholder');
|
|
88
|
+
return true as const;
|
|
89
|
+
});
|
|
90
|
+
const logger: any = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
91
|
+
logger.child = () => logger;
|
|
92
|
+
const local = new LocalDatabaseService({ namespace: 'n', database: 'd', store: 'indexeddb' } as any, logger);
|
|
93
|
+
|
|
94
|
+
const booting = local.connect('u1');
|
|
95
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
96
|
+
const switching = local.switchStore('anon');
|
|
97
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
98
|
+
expect(opens.map((o) => o.url)).toEqual(['indxdb://sp00ky-u1']);
|
|
99
|
+
expect(closed).toEqual([]);
|
|
100
|
+
|
|
101
|
+
opens[0].done();
|
|
102
|
+
await booting;
|
|
103
|
+
await vi.waitFor(() => expect(opens).toHaveLength(2));
|
|
104
|
+
expect(opens[1].url).toBe('indxdb://sp00ky-anon');
|
|
105
|
+
opens[1].done();
|
|
106
|
+
await switching;
|
|
107
|
+
expect(closed).toEqual(['indxdb://sp00ky-u1']);
|
|
108
|
+
expect(local.currentBucketId).toBe('anon');
|
|
109
|
+
vi.restoreAllMocks();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// The engine streams a statement's result from its worker; closing the
|
|
113
|
+
// client under it left the statement pending until its 30 s deadline, and
|
|
114
|
+
// every later local statement queued behind it (sign-in hung on "Loading").
|
|
115
|
+
it.each(['memory', 'indexeddb'] as const)('%s: a switch lets a statement in flight finish before it closes the client', async (store) => {
|
|
116
|
+
let answer!: (rows: unknown[]) => void;
|
|
117
|
+
const events: string[] = [];
|
|
118
|
+
vi.spyOn(Surreal.prototype, 'connect').mockResolvedValue(true);
|
|
119
|
+
vi.spyOn(Surreal.prototype, 'use').mockResolvedValue({});
|
|
120
|
+
vi.spyOn(Surreal.prototype, 'query').mockImplementation((() =>
|
|
121
|
+
new Promise((r) => {
|
|
122
|
+
answer = (rows) => {
|
|
123
|
+
events.push('answered');
|
|
124
|
+
r(rows);
|
|
125
|
+
};
|
|
126
|
+
})) as any);
|
|
127
|
+
vi.spyOn(Surreal.prototype, 'close').mockImplementation(async () => {
|
|
128
|
+
events.push('close');
|
|
129
|
+
return true as const;
|
|
130
|
+
});
|
|
131
|
+
const logger: any = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
132
|
+
logger.child = () => logger;
|
|
133
|
+
const local = new LocalDatabaseService({ namespace: 'n', database: 'd', store } as any, logger);
|
|
134
|
+
await local.connect('anon');
|
|
135
|
+
|
|
136
|
+
const reading = local.query('SELECT * FROM thing');
|
|
137
|
+
await vi.waitFor(() => expect(answer).toBeDefined());
|
|
138
|
+
const release = local.beginSwitch();
|
|
139
|
+
const switching = local.switchStore('u1');
|
|
140
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
141
|
+
expect(events).toEqual([]);
|
|
142
|
+
|
|
143
|
+
answer([[]]);
|
|
144
|
+
await reading;
|
|
145
|
+
await switching;
|
|
146
|
+
release();
|
|
147
|
+
expect(events).toEqual(['answered', 'close']);
|
|
148
|
+
vi.restoreAllMocks();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -104,6 +104,8 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
104
104
|
private gate: Promise<void> = Promise.resolve();
|
|
105
105
|
/** The incoming client while a switch is in flight (for unload cleanup). */
|
|
106
106
|
private pendingSwitchClient: Surreal | null = null;
|
|
107
|
+
/** The open `connect()` started, until it settles. */
|
|
108
|
+
private opening: Promise<void> | null = null;
|
|
107
109
|
|
|
108
110
|
constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
|
|
109
111
|
const events = createDatabaseEventSystem();
|
|
@@ -176,6 +178,16 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
async connect(bucketId: string = ANON_USER_ID): Promise<void> {
|
|
181
|
+
const opening = this.openBucket(bucketId);
|
|
182
|
+
this.opening = opening;
|
|
183
|
+
try {
|
|
184
|
+
await opening;
|
|
185
|
+
} finally {
|
|
186
|
+
if (this.opening === opening) this.opening = null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async openBucket(bucketId: string): Promise<void> {
|
|
179
191
|
const { namespace, database } = this.getConfig();
|
|
180
192
|
const store = this.getConfig().store ?? 'memory';
|
|
181
193
|
this.bucketId = bucketId;
|
|
@@ -201,9 +213,16 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
201
213
|
* async chains can detect they're stale.
|
|
202
214
|
*
|
|
203
215
|
* Callers own the drain/rebind choreography (close the gate, quiesce sync +
|
|
204
|
-
* timers BEFORE calling this; re-provision + rebind AFTER).
|
|
216
|
+
* timers BEFORE calling this; re-provision + rebind AFTER). Statements that
|
|
217
|
+
* passed the gate before it closed are let finish before the old client
|
|
218
|
+
* closes: the engine streams results from its worker, and a client closed
|
|
219
|
+
* under a statement leaves that statement pending until its deadline, with
|
|
220
|
+
* every later local statement queued behind it.
|
|
205
221
|
*/
|
|
206
222
|
async switchStore(bucketId: string): Promise<void> {
|
|
223
|
+
// Never overlap the boot open: the switch would close the client `connect`
|
|
224
|
+
// is still opening, and both would provision a store at once.
|
|
225
|
+
if (this.opening) await this.opening.catch(() => {});
|
|
207
226
|
if (bucketId === this.bucketId) return;
|
|
208
227
|
const { namespace, database } = this.getConfig();
|
|
209
228
|
const store = this.getConfig().store ?? 'memory';
|
|
@@ -212,6 +231,7 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
212
231
|
if (store === 'memory') {
|
|
213
232
|
// mem:// has no per-user persistence; close + reopen the same client
|
|
214
233
|
// yields a fresh empty store, which is exactly the reset we want.
|
|
234
|
+
await this.drainStatements();
|
|
215
235
|
try {
|
|
216
236
|
await this.client.close();
|
|
217
237
|
} catch {
|
|
@@ -241,6 +261,7 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
241
261
|
this.pendingSwitchClient = null;
|
|
242
262
|
}
|
|
243
263
|
|
|
264
|
+
await this.drainStatements();
|
|
244
265
|
const old = this.client;
|
|
245
266
|
this.client = next;
|
|
246
267
|
this.bucketId = bucketId;
|
|
@@ -38,7 +38,22 @@ export function fakeServiceBundle<S extends SchemaStructure>(over: Partial<Servi
|
|
|
38
38
|
streamProcessor: { addReceiver: (r: unknown) => void receivers.push(r), checkpoint: async () => undefined, dispose: noop } as any,
|
|
39
39
|
migrator: {} as any,
|
|
40
40
|
crdt: { attach: noop, setSessionId: noop, closeAll: noop, dispose: noop, open: async () => 'field', close: async () => {} } as any,
|
|
41
|
-
|
|
41
|
+
// Mirrors `AuthService.subscribe`: the current (signed-out) identity at
|
|
42
|
+
// once unless the subscriber opts out, then every change.
|
|
43
|
+
auth: {
|
|
44
|
+
subscribe: (cb: (u: string | null) => void, opts: { immediate?: boolean } = {}) => {
|
|
45
|
+
if (opts.immediate !== false) cb(null);
|
|
46
|
+
authListeners.push(cb);
|
|
47
|
+
return noop;
|
|
48
|
+
},
|
|
49
|
+
subscribeImpersonation: () => noop,
|
|
50
|
+
onBeforeSignOut: () => noop,
|
|
51
|
+
currentUser: null,
|
|
52
|
+
isAuthenticated: false,
|
|
53
|
+
token: null,
|
|
54
|
+
impersonation: null,
|
|
55
|
+
eventSystem: { subscribe: noop },
|
|
56
|
+
} as any,
|
|
42
57
|
tabs: null,
|
|
43
58
|
tabsUnsupportedReason: 'test',
|
|
44
59
|
tabId: 'tab-test',
|