@spooky-sync/core 0.0.1-canary.326 → 0.0.1-canary.328

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -556,6 +556,17 @@ describe('create() tx result shaping (fast path parity)', () => {
556
556
  expect(outboxRow.mutationType).toBe('create');
557
557
  expect(outboxRow.data).toEqual(payload);
558
558
  });
559
+
560
+ // A raw query with a field list materializes from its id-set as
561
+ // `FROM (SELECT * FROM $__win)` (the wasm SurrealDB throws on a field list
562
+ // over bare record ids). This engine reads both shapes the same way.
563
+ it('reads a field list over a wrapped id-set like the bare one', () => {
564
+ const ids = ['_00_user_feature:a', '_00_user_feature:b'];
565
+ const bare = translateSurql('SELECT key, variant FROM $__win', { __win: ids }).ops[0];
566
+ const wrapped = translateSurql('SELECT key, variant FROM (SELECT * FROM $__win)', { __win: ids }).ops[0];
567
+ expect(wrapped).toEqual(bare);
568
+ expect(wrapped).toMatchObject({ kind: 'selectByIds', ids });
569
+ });
559
570
  });
560
571
 
561
572
 
@@ -193,13 +193,14 @@ function translateStatement(stmt: string, vars: Record<string, unknown>): SqlOp
193
193
  return { kind: 'getById', id: idOperand(m[3], vars), select: projFields(m[2], !!m[1]), value };
194
194
  }
195
195
 
196
- // SELECT [VALUE] <proj> FROM $arrayParam
197
- m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+\$(\w+)$/i.exec(head);
196
+ // SELECT [VALUE] <proj> FROM $arrayParam, or FROM (SELECT * FROM $arrayParam)
197
+ // (the id-set read's shape for a field list, see `idSetSource`).
198
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+(?:\$(\w+)|\(\s*SELECT\s+\*\s+FROM\s+\$(\w+)\s*\))$/i.exec(head);
198
199
  if (m) {
199
200
  const value = m[1] ? m[2].trim() : undefined;
200
201
  return {
201
202
  kind: 'selectByIds',
202
- ids: (vars[m[3]] as unknown[]) ?? [],
203
+ ids: (vars[m[3] ?? m[4]] as unknown[]) ?? [],
203
204
  select: projFields(m[2], !!m[1]),
204
205
  value,
205
206
  limit,
@@ -69,7 +69,9 @@ export class SurrealCacheEngine extends LocalDatabaseService implements LocalCac
69
69
  if (ids.length === 0) return [];
70
70
  const projection =
71
71
  opts?.select && opts.select.length > 0 ? ['id', ...opts.select].join(', ') : '*';
72
- let sql = `SELECT ${projection} FROM $__ids`;
72
+ // As documents: over bare record ids the wasm engine throws on a field
73
+ // list and ignores ORDER BY (see `idSetSource`).
74
+ let sql = `SELECT ${projection} FROM (SELECT * FROM $__ids)`;
73
75
  if (opts?.orderBy && opts.orderBy.length > 0) {
74
76
  sql += ` ORDER BY ${opts.orderBy.map(([f, d]) => `${f} ${d}`).join(', ')}`;
75
77
  }
@@ -79,6 +79,30 @@ export function retained(s: ClientState): (id: string) => boolean {
79
79
  return (id) => id.startsWith('_00_') || known.has(id) || held.has(id);
80
80
  }
81
81
 
82
+ /**
83
+ * Ids something live still needs: a mounted query's membership or subquery
84
+ * children, or a local write not yet settled. Unlike `retained`, durable view
85
+ * rows of queries nobody runs right now do not count: a row the server just
86
+ * took out of a live query must stop showing even if an old view named it.
87
+ */
88
+ export function activelyHeld(s: ClientState): ReadonlySet<string> {
89
+ const held = new Set<string>();
90
+ for (const e of s.queries.values()) {
91
+ for (const [id] of e.remoteArray) held.add(id);
92
+ for (const [id] of e.subqueryRemoteArray) held.add(id);
93
+ }
94
+ for (const item of s.outbox) held.add(item.recordId);
95
+ for (const write of s.pendingWrites.values()) held.add(write.recordId);
96
+ return held;
97
+ }
98
+
99
+ /** Ids of `before` that `after` no longer names. */
100
+ export function departedIds(before: RecordVersionArray, after: RecordVersionArray): string[] {
101
+ if (before.length === 0) return [];
102
+ const kept = new Set(after.map(([id]) => id));
103
+ return before.filter(([id]) => !kept.has(id)).map(([id]) => id);
104
+ }
105
+
82
106
  export const pendingDeleteIds = (s: ClientState): ReadonlySet<string> =>
83
107
  new Set(s.outbox.filter((i) => i.type === 'delete').map((i) => i.recordId));
84
108
 
@@ -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',