@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 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.327";
2192
- const WASM_VERSION = "0.0.1-canary.327";
2193
- const SURREAL_VERSION = "3.0.3";
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.327",
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): () => 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.327",
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.327",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.327",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.328",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.328",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
- "@surrealdb/wasm": "^3.0.3",
81
+ "@surrealdb/wasm": "^3.1.0",
82
+ "@surrealdb/wasm-native": "^3.3.4",
82
83
  "blurhash": "^2.0.5",
83
84
  "fast-json-patch": "^3.1.1",
84
85
  "loro-crdt": "1.11.0",
@@ -23,6 +23,7 @@ const ENTRIES = ['pure.js', 'live.js', 'sw.js'];
23
23
  const FORBIDDEN_IMPORTS = [
24
24
  '@spooky-sync/ssp-wasm',
25
25
  '@surrealdb/wasm',
26
+ '@surrealdb/wasm-native',
26
27
  '@sqlite.org/sqlite-wasm',
27
28
  'loro-crdt',
28
29
  'pino',
@@ -47,7 +47,7 @@ See [references/config.md](references/config.md) for all configuration options.
47
47
  Key fields:
48
48
  - `database.endpoint` — SurrealDB WebSocket URL
49
49
  - `database.namespace` / `database.database` — SurrealDB namespace and database
50
- - `database.store` — `'indexeddb'` (persistent) or `'memory'` (transient)
50
+ - `database.store` — `'memory'` (transient, the default) or `'indexeddb'` (persistent; needs `@surrealdb/wasm` >= 3.1.0, and Vite apps exclude `@surrealdb/wasm-native` from `optimizeDeps`)
51
51
  - `schema` — The `SchemaStructure` object (use `as const satisfies SchemaStructure`)
52
52
  - `schemaSurql` — The compiled SURQL schema string for local DB provisioning
53
53
  - `logLevel` — `'debug' | 'info' | 'warn' | 'error'`
@@ -9,7 +9,7 @@ interface Sp00kyConfig<S extends SchemaStructure> {
9
9
  namespace: string;
10
10
  /** SurrealDB database name */
11
11
  database: string;
12
- /** Local store type: 'memory' (transient) or 'indexeddb' (persistent) */
12
+ /** Local store type: 'memory' (transient, default) or 'indexeddb' (persistent) */
13
13
  store?: 'memory' | 'indexeddb';
14
14
  /** Authentication token */
15
15
  token?: string;
@@ -78,6 +78,41 @@ describe('Sp00kyClient facade', () => {
78
78
  expect(client.liveRetryCount).toBe(0);
79
79
  });
80
80
 
81
+ it('a warm boot opens the hinted bucket alone; no anon switch races it', async () => {
82
+ // A reload of a signed-in tab: the hint names the user's bucket and the
83
+ // store takes a while to open (IndexedDB). The auth service's signed-out
84
+ // answer at construction used to dispatch a flip to anon, which switched
85
+ // stores and provisioned while boot was still connecting.
86
+ let bucket = 'anon';
87
+ let opened!: () => void;
88
+ const { client, a, services } = makeClient({
89
+ services: {
90
+ 'local.connect': (id: string) => {
91
+ bucket = id;
92
+ return new Promise<void>((r) => (opened = r));
93
+ },
94
+ 'local.currentBucketId': () => bucket,
95
+ 'local.beginSwitch': () => () => {},
96
+ 'local.switchStore': async (id: string) => void (bucket = id),
97
+ },
98
+ });
99
+ const init = client.init();
100
+ await flush();
101
+ opened();
102
+ await init;
103
+ await flush();
104
+ expect(a.calls.filter(([n]) => n === 'service.local.connect').map(([, args]) => args)).toEqual([['u1']]);
105
+ expect(a.names()).not.toContain('service.local.beginSwitch');
106
+ expect(a.names()).not.toContain('service.local.switchStore');
107
+ expect(a.names()).not.toContain('service.hint.write');
108
+ expect(client.state.bucketId).toBe('u1');
109
+
110
+ // A real auth change still flips.
111
+ services.authListeners.forEach((cb) => cb(null));
112
+ await flush();
113
+ expect(a.calls.filter(([n]) => n === 'service.local.switchStore').map(([, args]) => args)).toEqual([['anon']]);
114
+ });
115
+
81
116
  it('queryRaw registers locally and returns the hash before any remote work; subscribers paint from the local store', async () => {
82
117
  // Boot loads the view index: `thing:1` is vouched for, `thing:2` is an orphan and stays hidden.
83
118
  const stored = (sql: string) =>
@@ -223,7 +223,13 @@ export class Sp00kyClient<S extends SchemaStructure> {
223
223
  const receiver: StreamUpdateReceiver = { onStreamUpdate: (update) => void this.runtime.dispatch({ type: 'StreamUpdate', update }) };
224
224
  s.streamProcessor.addReceiver(receiver);
225
225
  s.connectionSupervisor.subscribe((state) => void this.runtime.dispatch({ type: 'ConnectionChanged', state }));
226
- s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }));
226
+ // Changes only. The immediate answer here is always "signed out" (nothing
227
+ // is restored before `init()`), and boot owns the first identity: it opens
228
+ // the bucket the hint names and announces the restored session itself. As
229
+ // a flip, that answer switched to the anon bucket while boot was still
230
+ // opening the user's, so two clients provisioned at once, boot failed, and
231
+ // a persistent store never got past the provider's fallback on reload.
232
+ s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }), { immediate: false });
227
233
  // Whoever is looking at the page should be able to tell, and leave. The
228
234
  // app can restyle this, replace it (`mode: 'custom'`, with a fallback if
229
235
  // its own never renders) or take the job over entirely (`mode: 'none'`).
@@ -56,6 +56,20 @@ describe('restoreSessionFromToken', () => {
56
56
  expect(seen).toContain('user:abc');
57
57
  });
58
58
 
59
+ it('reports only changes to a subscriber that opts out of the immediate call', async () => {
60
+ const { auth } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
61
+ const all: (string | null)[] = [];
62
+ const changes: (string | null)[] = [];
63
+ auth.subscribe((uid) => all.push(uid));
64
+ auth.subscribe((uid) => changes.push(uid), { immediate: false });
65
+ expect(all).toEqual([null]);
66
+ expect(changes).toEqual([]);
67
+
68
+ await auth.restoreSessionFromToken();
69
+
70
+ expect(changes).toEqual(['user:abc']);
71
+ });
72
+
59
73
  it('returns null when there is no token, and when the token carries no id', async () => {
60
74
  expect(await makeAuth().auth.restoreSessionFromToken()).toBeNull();
61
75
  const noId = makeAuth({ token: jwt({ AC: 'account' }) });
@@ -150,10 +150,10 @@ export class AuthService<S extends SchemaStructure> {
150
150
  /**
151
151
  * Subscribe to auth state changes.
152
152
  * callback is called immediately with current value and whenever validation status changes.
153
+ * `immediate: false` skips that first call: only changes are reported.
153
154
  */
154
- subscribe(cb: (userId: string | null) => void): () => void {
155
- // Immediate callback
156
- cb(this.announcedUserId());
155
+ subscribe(cb: (userId: string | null) => void, opts: { immediate?: boolean } = {}): () => void {
156
+ if (opts.immediate !== false) cb(this.announcedUserId());
157
157
 
158
158
  const id = this.events.subscribe(AuthEventTypes.AuthStateChanged, (event) => {
159
159
  cb(event.payload);
@@ -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
- auth: { subscribe: (cb: (u: string | null) => void) => (authListeners.push(cb), noop), subscribeImpersonation: () => noop, onBeforeSignOut: () => noop, currentUser: null, isAuthenticated: false, token: null, impersonation: null, eventSystem: { subscribe: noop } } as any,
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',