@spooky-sync/core 0.0.1-canary.176 → 0.0.1-canary.178

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.js CHANGED
@@ -1998,16 +1998,16 @@ var BaseTransport = class {
1998
1998
  }
1999
1999
  });
2000
2000
  }
2001
- failAll(reason) {
2001
+ failAll(reason, err) {
2002
2002
  if (this.pending.size === 0) return;
2003
- const err = this.makeError(reason);
2004
- for (const [, p] of this.pending) p.reject(err);
2003
+ const e = err ?? this.makeError(reason);
2004
+ for (const [, p] of this.pending) p.reject(e);
2005
2005
  this.pending.clear();
2006
2006
  }
2007
- close(reason = "closed") {
2007
+ close(reason = "closed", err) {
2008
2008
  if (this.closed) return;
2009
2009
  this.closed = true;
2010
- this.failAll(reason);
2010
+ this.failAll(reason, err);
2011
2011
  }
2012
2012
  };
2013
2013
  var WorkerSqliteTransport = class extends BaseTransport {
@@ -2071,9 +2071,9 @@ var WorkerSqliteTransport = class extends BaseTransport {
2071
2071
  shutdown() {
2072
2072
  return this.call("shutdown").then(() => void 0);
2073
2073
  }
2074
- close(reason = "closed") {
2074
+ close(reason = "closed", err) {
2075
2075
  if (this.closed) return;
2076
- super.close(reason);
2076
+ super.close(reason, err);
2077
2077
  this.worker.terminate();
2078
2078
  }
2079
2079
  };
@@ -2092,10 +2092,10 @@ var PortSqliteTransport = class extends BaseTransport {
2092
2092
  markDead(reason) {
2093
2093
  this.dead(reason);
2094
2094
  }
2095
- dead(reason) {
2095
+ dead(reason, err) {
2096
2096
  if (this.closed) return;
2097
2097
  this.closed = true;
2098
- this.failAll(reason);
2098
+ this.failAll(reason, err);
2099
2099
  try {
2100
2100
  this.port.close();
2101
2101
  } catch {}
@@ -2107,8 +2107,8 @@ var PortSqliteTransport = class extends BaseTransport {
2107
2107
  makeError(reason) {
2108
2108
  return new BrokerPortClosedError(reason);
2109
2109
  }
2110
- close(reason = "closed") {
2111
- this.dead(reason);
2110
+ close(reason = "closed", err) {
2111
+ this.dead(reason, err);
2112
2112
  }
2113
2113
  };
2114
2114
 
@@ -2304,6 +2304,28 @@ var SqliteCacheEngine = class {
2304
2304
  this.opQueue = result.then(() => void 0, () => void 0);
2305
2305
  return result;
2306
2306
  }
2307
+ /**
2308
+ * Ops dispatched to the worker and not yet answered. Role transitions run on
2309
+ * their own chain (see {@link transitionChain}), so they can start while the
2310
+ * opQueue still has an op at the worker; tearing the transport down under it
2311
+ * would reject that op — and its caller may be a query whose only fetch this
2312
+ * was. {@link drainInFlight} lets a deliberate teardown wait them out.
2313
+ */
2314
+ inFlightCalls = /* @__PURE__ */ new Set();
2315
+ /** Wait for dispatched ops to answer before a deliberate transport teardown.
2316
+ * Bounded: a wedged worker must not block the role change forever. */
2317
+ async drainInFlight(timeoutMs = 2e3) {
2318
+ if (this.inFlightCalls.size === 0) return;
2319
+ const settled = Promise.all([...this.inFlightCalls].map((p) => p.catch(() => void 0)));
2320
+ let timer;
2321
+ try {
2322
+ await Promise.race([settled, new Promise((resolve) => {
2323
+ timer = setTimeout(resolve, timeoutMs);
2324
+ })]);
2325
+ } finally {
2326
+ if (timer) clearTimeout(timer);
2327
+ }
2328
+ }
2307
2329
  rawCall(type, payload) {
2308
2330
  if (!this.transport) throw new Error("SqliteCacheEngine: not connected");
2309
2331
  const s = getStats();
@@ -2317,7 +2339,17 @@ var SqliteCacheEngine = class {
2317
2339
  s.maxInFlight = Math.max(s.maxInFlight, s.inFlight);
2318
2340
  if (this.transport.kind === "port") s.proxiedOps = (s.proxiedOps ?? 0) + 1;
2319
2341
  const sentAt = performance.now();
2342
+ let settle;
2343
+ const tracked = new Promise((res) => {
2344
+ settle = res;
2345
+ });
2346
+ this.inFlightCalls.add(tracked);
2347
+ const finish = () => {
2348
+ this.inFlightCalls.delete(tracked);
2349
+ settle();
2350
+ };
2320
2351
  return this.transport.call(type, payload).then((v) => {
2352
+ finish();
2321
2353
  s.inFlight--;
2322
2354
  const wt = v?.wt;
2323
2355
  if (typeof wt === "number") {
@@ -2326,6 +2358,7 @@ var SqliteCacheEngine = class {
2326
2358
  }
2327
2359
  return v;
2328
2360
  }, (e) => {
2361
+ finish();
2329
2362
  s.inFlight--;
2330
2363
  throw e;
2331
2364
  });
@@ -2471,12 +2504,13 @@ var SqliteCacheEngine = class {
2471
2504
  this.closeRoleGate();
2472
2505
  return this.storageHealthValue;
2473
2506
  }
2507
+ if (this.transport) await this.drainInFlight();
2474
2508
  if (this.hadStore) this.storeEpoch++;
2475
2509
  if (this.transport) {
2476
2510
  try {
2477
2511
  if (this.transport.kind === "worker") await this.rawCall("close");
2478
2512
  } catch {}
2479
- this.transport.close("adopting ownership");
2513
+ this.transport.close("adopting ownership", new BrokerPortClosedError("adopting ownership"));
2480
2514
  this.transport = null;
2481
2515
  }
2482
2516
  await this.openInternal(bucketId, {
@@ -2499,8 +2533,9 @@ var SqliteCacheEngine = class {
2499
2533
  async adoptAttached(dbPort, snapshot, onPortDead) {
2500
2534
  this.roleLabel = "follower";
2501
2535
  return this.chainTransition(async () => {
2536
+ if (this.transport) await this.drainInFlight();
2502
2537
  if (this.hadStore) this.storeEpoch++;
2503
- this.transport?.close("adopting leader port");
2538
+ this.transport?.close("adopting leader port", new BrokerPortClosedError("adopting leader port"));
2504
2539
  this.transport = new PortSqliteTransport(dbPort, onPortDead, this.logger);
2505
2540
  this.bucketId = snapshot.bucketId;
2506
2541
  this.knownTables.clear();
@@ -2523,10 +2558,11 @@ var SqliteCacheEngine = class {
2523
2558
  async releaseOwnership() {
2524
2559
  await this.chainTransition(async () => {
2525
2560
  if (this.transport) {
2561
+ await this.drainInFlight();
2526
2562
  try {
2527
2563
  if (this.transport.kind === "worker") await this.transport.shutdown();
2528
2564
  } catch {}
2529
- this.transport.close("ownership released");
2565
+ this.transport.close("ownership released", new BrokerPortClosedError("ownership released"));
2530
2566
  this.transport = null;
2531
2567
  }
2532
2568
  this.storeEpoch++;
@@ -6892,8 +6928,8 @@ function selfAllowlistedVariant(flag, userId) {
6892
6928
 
6893
6929
  //#endregion
6894
6930
  //#region src/modules/devtools/index.ts
6895
- const CORE_VERSION = "0.0.1-canary.176";
6896
- const WASM_VERSION = "0.0.1-canary.176";
6931
+ const CORE_VERSION = "0.0.1-canary.178";
6932
+ const WASM_VERSION = "0.0.1-canary.178";
6897
6933
  const SURREAL_VERSION = "3.0.3";
6898
6934
  var DevToolsService = class DevToolsService {
6899
6935
  eventsHistory = [];
@@ -11324,7 +11360,7 @@ var Sp00kyClient = class {
11324
11360
  return new TabsCoordinator({
11325
11361
  tabId,
11326
11362
  fingerprint: computeTabsFingerprint({
11327
- coreVersion: "0.0.1-canary.176",
11363
+ coreVersion: "0.0.1-canary.178",
11328
11364
  schemaHash: hash53(this.config.schemaSurql),
11329
11365
  endpoint: this.config.database.endpoint ?? "",
11330
11366
  namespace: this.config.database.namespace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.176",
3
+ "version": "0.0.1-canary.178",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.176",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.176",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.178",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.178",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "fast-json-patch": "^3.1.1",
@@ -33,6 +33,9 @@ export interface HeartbeatSample {
33
33
  export interface HeartbeatInfo {
34
34
  enabled: boolean;
35
35
  stale: boolean;
36
+ /** Nothing to measure right now (e.g. no ready SSPs mid-bootstrap). */
37
+ blocked?: boolean;
38
+ blocked_reason?: string | null;
36
39
  last_e2e_ms: number | null;
37
40
  last_ok_epoch_ms: number | null;
38
41
  consecutive_failures: number;
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
2
2
  import { RecordId } from 'surrealdb';
3
3
  import { pureWriteOpResult, SqliteCacheEngine } from './sqlite-cache-engine';
4
4
  import { stubTransport } from './sqlite-transport.fixture';
5
+ import { BrokerPortClosedError } from './sqlite-transport';
5
6
  import { translateSurql } from './surql-translate';
6
7
  import type { SqlOp } from './surql-translate';
7
8
  import { surql } from '../../utils/surql';
@@ -330,6 +331,99 @@ describe('SqliteCacheEngine role modes', () => {
330
331
  expect(engine.storageHealth.role).toBe('leader');
331
332
  });
332
333
 
334
+ /**
335
+ * A transport with the REAL pending-map semantics (the shared fixture never
336
+ * rejects on close, which would make the drain test vacuous): `exec` parks
337
+ * until `flush()`, and `close` rejects whatever is still parked.
338
+ */
339
+ function deferredTransport() {
340
+ const parked: Array<{ resolve: (v: any) => void; reject: (e: unknown) => void }> = [];
341
+ let closed = false;
342
+ const transport: any = {
343
+ kind: 'worker',
344
+ get connected() {
345
+ return !closed;
346
+ },
347
+ call(type: string) {
348
+ if (closed) return Promise.reject(new Error('SQLite worker crashed: transport closed'));
349
+ if (type === 'open') return Promise.resolve({ persisted: true });
350
+ if (type !== 'exec') return Promise.resolve({});
351
+ return new Promise((resolve, reject) => parked.push({ resolve, reject }));
352
+ },
353
+ shutdown: () => Promise.resolve(),
354
+ failAll() {},
355
+ close(reason = 'closed', err?: Error) {
356
+ if (closed) return;
357
+ closed = true;
358
+ const e = err ?? new Error(`SQLite worker crashed: ${reason}`);
359
+ for (const p of parked.splice(0)) p.reject(e);
360
+ },
361
+ flush() {
362
+ for (const p of parked.splice(0)) p.resolve({ rows: [] });
363
+ },
364
+ get parkedCount() {
365
+ return parked.length;
366
+ },
367
+ };
368
+ return transport;
369
+ }
370
+
371
+ // Regression (WhitePawn first-login): the bucket switch runs
372
+ // moveToBucket → teardownLeader → releaseOwnership, which lives on the
373
+ // transition chain, NOT the opQueue — so a query's fetch can be sitting at
374
+ // the worker when ownership is released. Tearing the transport down under it
375
+ // rejected that fetch with "SQLite worker crashed: ownership released", which
376
+ // nothing retries (and nobody caught: an unhandled rejection in the console).
377
+ it('releaseOwnership waits for in-flight ops instead of killing them', async () => {
378
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger(), {
379
+ shared: true,
380
+ });
381
+ const transport = deferredTransport();
382
+ (engine as any).createTransport = () => transport;
383
+ await engine.adoptOwner('anon', {
384
+ workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
385
+ allowMemoryFallback: false,
386
+ resumeHeld: false,
387
+ });
388
+
389
+ const read = engine.getById('_00_query', 'h1');
390
+ await new Promise((r) => setTimeout(r, 0));
391
+ expect(transport.parkedCount).toBe(1);
392
+
393
+ const released = engine.releaseOwnership();
394
+ await new Promise((r) => setTimeout(r, 0));
395
+ // Still parked: the teardown is waiting on it rather than failing it.
396
+ expect(transport.parkedCount).toBe(1);
397
+ transport.flush();
398
+
399
+ await released;
400
+ await expect(read).resolves.toBeNull();
401
+ });
402
+
403
+ it('fails an undrainable op as a retryable transport loss, not a crash', async () => {
404
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger(), {
405
+ shared: true,
406
+ });
407
+ const transport = deferredTransport();
408
+ (engine as any).createTransport = () => transport;
409
+ await engine.adoptOwner('anon', {
410
+ workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
411
+ allowMemoryFallback: false,
412
+ resumeHeld: false,
413
+ });
414
+
415
+ const read = engine.getById('_00_query', 'h1');
416
+ const caught = read.catch((e: unknown) => e);
417
+ await new Promise((r) => setTimeout(r, 0));
418
+ // The op never answers: the drain times out and the teardown proceeds.
419
+ (engine as any).drainInFlight = () => Promise.resolve();
420
+ await engine.releaseOwnership();
421
+
422
+ const err = await caught;
423
+ expect(err).toBeInstanceOf(BrokerPortClosedError);
424
+ expect((err as Error).message).toContain('ownership released');
425
+ });
426
+
333
427
  it('bumps the epoch on promotion after having had a store (fences in-flight chains)', async () => {
334
428
  const { engine } = makeSharedEngine();
335
429
  await engine.adoptOwner('anon', {
@@ -262,6 +262,33 @@ export class SqliteCacheEngine implements LocalStore {
262
262
  return result;
263
263
  }
264
264
 
265
+ /**
266
+ * Ops dispatched to the worker and not yet answered. Role transitions run on
267
+ * their own chain (see {@link transitionChain}), so they can start while the
268
+ * opQueue still has an op at the worker; tearing the transport down under it
269
+ * would reject that op — and its caller may be a query whose only fetch this
270
+ * was. {@link drainInFlight} lets a deliberate teardown wait them out.
271
+ */
272
+ private inFlightCalls = new Set<Promise<unknown>>();
273
+
274
+ /** Wait for dispatched ops to answer before a deliberate transport teardown.
275
+ * Bounded: a wedged worker must not block the role change forever. */
276
+ private async drainInFlight(timeoutMs = 2_000): Promise<void> {
277
+ if (this.inFlightCalls.size === 0) return;
278
+ const settled = Promise.all([...this.inFlightCalls].map((p) => p.catch(() => undefined)));
279
+ let timer: ReturnType<typeof setTimeout> | undefined;
280
+ try {
281
+ await Promise.race([
282
+ settled,
283
+ new Promise<void>((resolve) => {
284
+ timer = setTimeout(resolve, timeoutMs);
285
+ }),
286
+ ]);
287
+ } finally {
288
+ if (timer) clearTimeout(timer);
289
+ }
290
+ }
291
+
265
292
  private rawCall<T = any>(type: string, payload?: unknown): Promise<T> {
266
293
  if (!this.transport) throw new Error('SqliteCacheEngine: not connected');
267
294
  // --- instrumentation: live, inspectable via `globalThis.__sqliteStats` ---
@@ -276,8 +303,21 @@ export class SqliteCacheEngine implements LocalStore {
276
303
  s.maxInFlight = Math.max(s.maxInFlight, s.inFlight);
277
304
  if (this.transport.kind === 'port') s.proxiedOps = (s.proxiedOps ?? 0) + 1;
278
305
  const sentAt = performance.now();
279
- return this.transport.call<T>(type, payload).then(
306
+ // Track the dispatch, not the returned promise: attaching a handler to
307
+ // `result` would mark it handled and swallow the unhandled-rejection
308
+ // reports that surface a caller which forgot to catch.
309
+ let settle!: () => void;
310
+ const tracked = new Promise<void>((res) => {
311
+ settle = res;
312
+ });
313
+ this.inFlightCalls.add(tracked);
314
+ const finish = () => {
315
+ this.inFlightCalls.delete(tracked);
316
+ settle();
317
+ };
318
+ const result = this.transport.call<T>(type, payload).then(
280
319
  (v: T) => {
320
+ finish();
281
321
  s.inFlight--;
282
322
  // Split the round-trip: `wt` is time inside the worker's handler,
283
323
  // the remainder is postMessage + scheduling overhead.
@@ -289,10 +329,12 @@ export class SqliteCacheEngine implements LocalStore {
289
329
  return v;
290
330
  },
291
331
  (e: unknown) => {
332
+ finish();
292
333
  s.inFlight--;
293
334
  throw e;
294
335
  }
295
336
  );
337
+ return result;
296
338
  }
297
339
 
298
340
  /**
@@ -514,6 +556,7 @@ export class SqliteCacheEngine implements LocalStore {
514
556
  this.closeRoleGate();
515
557
  return this.storageHealthValue;
516
558
  }
559
+ if (this.transport) await this.drainInFlight();
517
560
  if (this.hadStore) this.storeEpoch++;
518
561
  if (this.transport) {
519
562
  try {
@@ -521,7 +564,7 @@ export class SqliteCacheEngine implements LocalStore {
521
564
  } catch {
522
565
  /* ignore */
523
566
  }
524
- this.transport.close('adopting ownership');
567
+ this.transport.close('adopting ownership', new BrokerPortClosedError('adopting ownership'));
525
568
  this.transport = null;
526
569
  }
527
570
  await this.openInternal(bucketId, {
@@ -555,8 +598,12 @@ export class SqliteCacheEngine implements LocalStore {
555
598
  ): Promise<void> {
556
599
  this.roleLabel = 'follower';
557
600
  return this.chainTransition(async () => {
601
+ if (this.transport) await this.drainInFlight();
558
602
  if (this.hadStore) this.storeEpoch++;
559
- this.transport?.close('adopting leader port');
603
+ this.transport?.close(
604
+ 'adopting leader port',
605
+ new BrokerPortClosedError('adopting leader port')
606
+ );
560
607
  this.transport = new PortSqliteTransport(dbPort, onPortDead, this.logger);
561
608
  this.bucketId = snapshot.bucketId;
562
609
  // The shared store exists and is seeded; mirror the owner's bookkeeping.
@@ -578,6 +625,11 @@ export class SqliteCacheEngine implements LocalStore {
578
625
  async releaseOwnership(): Promise<void> {
579
626
  await this.chainTransition(async () => {
580
627
  if (this.transport) {
628
+ // Let ops already at the worker answer first. Without this they die
629
+ // with the transport — a bucket switch (moveToBucket → teardownLeader)
630
+ // runs while the opQueue may still have a query's fetch in flight, and
631
+ // that fetch's caller is not necessarily prepared to retry.
632
+ await this.drainInFlight();
581
633
  try {
582
634
  if (this.transport.kind === 'worker') {
583
635
  await (this.transport as WorkerSqliteTransport).shutdown();
@@ -585,7 +637,13 @@ export class SqliteCacheEngine implements LocalStore {
585
637
  } catch {
586
638
  /* worker may already be fenced/dead */
587
639
  }
588
- this.transport.close('ownership released');
640
+ // Anything still pending (drain timed out) is a deliberate teardown,
641
+ // not a crash: fail it the way a follower's lost port does, so the
642
+ // error text is honest and callers can treat it as retryable.
643
+ this.transport.close(
644
+ 'ownership released',
645
+ new BrokerPortClosedError('ownership released')
646
+ );
589
647
  this.transport = null;
590
648
  }
591
649
  this.storeEpoch++;
@@ -22,8 +22,10 @@ export interface SqliteTransport {
22
22
  call<T = unknown>(type: string, payload?: unknown): Promise<T>;
23
23
  /** Reject every pending request with `reason`. Safe to call repeatedly. */
24
24
  failAll(reason: string): void;
25
- /** failAll + release the underlying channel. Terminal. */
26
- close(reason?: string): void;
25
+ /** failAll + release the underlying channel. Terminal. `err` overrides the
26
+ * transport's own error shape — used for a deliberate teardown (role change)
27
+ * so callers see a retryable "transport lost", not "the worker crashed". */
28
+ close(reason?: string, err?: Error): void;
27
29
  }
28
30
 
29
31
  /** Thrown into pending follower calls when the leader (or its port) goes away.
@@ -81,17 +83,17 @@ abstract class BaseTransport implements SqliteTransport {
81
83
  });
82
84
  }
83
85
 
84
- failAll(reason: string): void {
86
+ failAll(reason: string, err?: Error): void {
85
87
  if (this.pending.size === 0) return;
86
- const err = this.makeError(reason);
87
- for (const [, p] of this.pending) p.reject(err);
88
+ const e = err ?? this.makeError(reason);
89
+ for (const [, p] of this.pending) p.reject(e);
88
90
  this.pending.clear();
89
91
  }
90
92
 
91
- close(reason = 'closed'): void {
93
+ close(reason = 'closed', err?: Error): void {
92
94
  if (this.closed) return;
93
95
  this.closed = true;
94
- this.failAll(reason);
96
+ this.failAll(reason, err);
95
97
  }
96
98
  }
97
99
 
@@ -166,9 +168,9 @@ export class WorkerSqliteTransport extends BaseTransport {
166
168
  return this.call('shutdown').then(() => undefined);
167
169
  }
168
170
 
169
- close(reason = 'closed'): void {
171
+ close(reason = 'closed', err?: Error): void {
170
172
  if (this.closed) return;
171
- super.close(reason);
173
+ super.close(reason, err);
172
174
  this.worker.terminate();
173
175
  }
174
176
  }
@@ -193,10 +195,10 @@ export class PortSqliteTransport extends BaseTransport {
193
195
  this.dead(reason);
194
196
  }
195
197
 
196
- private dead(reason: string): void {
198
+ private dead(reason: string, err?: Error): void {
197
199
  if (this.closed) return;
198
200
  this.closed = true;
199
- this.failAll(reason);
201
+ this.failAll(reason, err);
200
202
  try {
201
203
  this.port.close();
202
204
  } catch {
@@ -213,7 +215,7 @@ export class PortSqliteTransport extends BaseTransport {
213
215
  return new BrokerPortClosedError(reason);
214
216
  }
215
217
 
216
- close(reason = 'closed'): void {
217
- this.dead(reason);
218
+ close(reason = 'closed', err?: Error): void {
219
+ this.dead(reason, err);
218
220
  }
219
221
  }