@spooky-sync/core 0.0.1-canary.238 → 0.0.1-canary.239

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
@@ -23,7 +23,7 @@ Local mutations are applied optimistically, ingested into a DBSP layer that driv
23
23
 
24
24
  ## Key exports (`src/index.ts`)
25
25
 
26
- - `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, idOrSelector)`, `query(table, opts?)`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `signOut()`. Plus `pendingMutationCount` and `subscribeToPendingMutations(cb)`.
26
+ - `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, idOrSelector)`, `query(table, opts?)`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `signOut()`. Plus `pendingMutationCount` / `subscribeToPendingMutations(cb)` (outbox depth) and `fetchingQueryCount` / `subscribeToFetchActivity(cb)` (queries mid-fetch, aggregate) for traffic indicators.
27
27
  - `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.
28
28
  - `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`.
29
29
  - `AuthService` — token management, sign-in/sign-out events.
package/dist/index.d.ts CHANGED
@@ -739,6 +739,12 @@ declare class DataModule<S extends SchemaStructure> {
739
739
  private debounceTimers;
740
740
  private pendingStreamUpdates;
741
741
  private fetchDepth;
742
+ /**
743
+ * Observers of the AGGREGATE fetch activity: how many queries are mid-fetch
744
+ * right now. What a "downloading" indicator in the app chrome hangs off,
745
+ * instead of subscribing to every query's status one by one.
746
+ */
747
+ private activitySubscriptions;
742
748
  private logger;
743
749
  /**
744
750
  * Optional observer notified whenever a query's fetch status changes.
@@ -848,6 +854,18 @@ declare class DataModule<S extends SchemaStructure> {
848
854
  beginFetching(queryHash: string): void;
849
855
  /** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
850
856
  endFetching(queryHash: string): void;
857
+ /** How many queries are mid-fetch right now (see {@link subscribeActivity}). */
858
+ get fetchingQueryCount(): number;
859
+ /**
860
+ * Observe the number of queries currently fetching. Fires on every change of
861
+ * that number (a query entering or leaving its outermost fetch cycle), and
862
+ * synchronously with the current value when `immediate` is set. One
863
+ * subscription per indicator, however many queries the page holds.
864
+ */
865
+ subscribeActivity(callback: (fetching: number) => void, options?: {
866
+ immediate?: boolean;
867
+ }): () => void;
868
+ private notifyActivity;
851
869
  /**
852
870
  * Subscribe to mutations (for sync)
853
871
  */
@@ -2715,6 +2733,15 @@ declare class Sp00kyClient<S extends SchemaStructure> {
2715
2733
  * suite can guard the pre-emptive path against regression. */
2716
2734
  get liveRetryCount(): number;
2717
2735
  subscribeToPendingMutations(cb: (count: number) => void): () => void;
2736
+ /** Number of queries mid-fetch right now. See {@link subscribeToFetchActivity}. */
2737
+ get fetchingQueryCount(): number;
2738
+ /**
2739
+ * Observe how many queries are fetching from the server. Fires immediately
2740
+ * with the current count and again on every change, so one subscription can
2741
+ * drive a "downloading" indicator for the whole app. Pair with
2742
+ * {@link subscribeToPendingMutations} for the "uploading" half.
2743
+ */
2744
+ subscribeToFetchActivity(cb: (fetching: number) => void): () => void;
2718
2745
  /** Current sync-health snapshot. See {@link Sp00kyConfig.syncHealth}. */
2719
2746
  get syncHealth(): SyncHealth;
2720
2747
  /**
package/dist/index.js CHANGED
@@ -148,7 +148,12 @@ const NETWORK_ERROR_PATTERNS = [
148
148
  "econnreset",
149
149
  "enotfound",
150
150
  "epipe",
151
- "abort"
151
+ "abort",
152
+ "transaction conflict",
153
+ "resource busy",
154
+ "can be retried",
155
+ "specify a namespace",
156
+ "specify a database"
152
157
  ];
153
158
  function classifySyncError(error) {
154
159
  const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
@@ -3558,6 +3563,12 @@ var DataModule = class DataModule {
3558
3563
  debounceTimers = /* @__PURE__ */ new Map();
3559
3564
  pendingStreamUpdates = /* @__PURE__ */ new Map();
3560
3565
  fetchDepth = /* @__PURE__ */ new Map();
3566
+ /**
3567
+ * Observers of the AGGREGATE fetch activity: how many queries are mid-fetch
3568
+ * right now. What a "downloading" indicator in the app chrome hangs off,
3569
+ * instead of subscribing to every query's status one by one.
3570
+ */
3571
+ activitySubscriptions = /* @__PURE__ */ new Set();
3561
3572
  logger;
3562
3573
  /**
3563
3574
  * Optional observer notified whenever a query's fetch status changes.
@@ -3775,7 +3786,10 @@ var DataModule = class DataModule {
3775
3786
  beginFetching(queryHash) {
3776
3787
  const depth = this.fetchDepth.get(queryHash) ?? 0;
3777
3788
  this.fetchDepth.set(queryHash, depth + 1);
3778
- if (depth === 0) this.setQueryStatus(queryHash, "fetching");
3789
+ if (depth === 0) {
3790
+ this.setQueryStatus(queryHash, "fetching");
3791
+ this.notifyActivity();
3792
+ }
3779
3793
  }
3780
3794
  /** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
3781
3795
  endFetching(queryHash) {
@@ -3783,10 +3797,40 @@ var DataModule = class DataModule {
3783
3797
  if (depth <= 1) {
3784
3798
  this.fetchDepth.delete(queryHash);
3785
3799
  this.setQueryStatus(queryHash, "idle");
3800
+ this.notifyActivity();
3786
3801
  return;
3787
3802
  }
3788
3803
  this.fetchDepth.set(queryHash, depth - 1);
3789
3804
  }
3805
+ /** How many queries are mid-fetch right now (see {@link subscribeActivity}). */
3806
+ get fetchingQueryCount() {
3807
+ return this.fetchDepth.size;
3808
+ }
3809
+ /**
3810
+ * Observe the number of queries currently fetching. Fires on every change of
3811
+ * that number (a query entering or leaving its outermost fetch cycle), and
3812
+ * synchronously with the current value when `immediate` is set. One
3813
+ * subscription per indicator, however many queries the page holds.
3814
+ */
3815
+ subscribeActivity(callback, options = {}) {
3816
+ this.activitySubscriptions.add(callback);
3817
+ if (options.immediate) callback(this.fetchDepth.size);
3818
+ return () => {
3819
+ this.activitySubscriptions.delete(callback);
3820
+ };
3821
+ }
3822
+ notifyActivity() {
3823
+ if (this.activitySubscriptions.size === 0) return;
3824
+ const n = this.fetchDepth.size;
3825
+ for (const cb of this.activitySubscriptions) try {
3826
+ cb(n);
3827
+ } catch (err) {
3828
+ this.logger.warn({
3829
+ err,
3830
+ Category: "sp00ky-client::DataModule::notifyActivity"
3831
+ }, "Activity subscriber threw");
3832
+ }
3833
+ }
3790
3834
  /**
3791
3835
  * Subscribe to mutations (for sync)
3792
3836
  */
@@ -4612,7 +4656,9 @@ var DataModule = class DataModule {
4612
4656
  for (const timer of this.debounceTimers.values()) clearTimeout(timer);
4613
4657
  this.debounceTimers.clear();
4614
4658
  this.pendingStreamUpdates.clear();
4659
+ const wasFetching = this.fetchDepth.size > 0;
4615
4660
  this.fetchDepth.clear();
4661
+ if (wasFetching) this.notifyActivity();
4616
4662
  for (const queryState of this.activeQueries.values()) if (queryState.ttlTimer) {
4617
4663
  clearTimeout(queryState.ttlTimer);
4618
4664
  queryState.ttlTimer = null;
@@ -8341,8 +8387,8 @@ function selfAllowlistedVariant(flag, userId) {
8341
8387
 
8342
8388
  //#endregion
8343
8389
  //#region src/modules/devtools/index.ts
8344
- const CORE_VERSION = "0.0.1-canary.238";
8345
- const WASM_VERSION = "0.0.1-canary.238";
8390
+ const CORE_VERSION = "0.0.1-canary.239";
8391
+ const WASM_VERSION = "0.0.1-canary.239";
8346
8392
  const SURREAL_VERSION = "3.0.3";
8347
8393
  var DevToolsService = class DevToolsService {
8348
8394
  eventsHistory = [];
@@ -13232,6 +13278,19 @@ var Sp00kyClient = class {
13232
13278
  subscribeToPendingMutations(cb) {
13233
13279
  return this.sync.subscribeToPendingMutations(cb);
13234
13280
  }
13281
+ /** Number of queries mid-fetch right now. See {@link subscribeToFetchActivity}. */
13282
+ get fetchingQueryCount() {
13283
+ return this.dataModule.fetchingQueryCount;
13284
+ }
13285
+ /**
13286
+ * Observe how many queries are fetching from the server. Fires immediately
13287
+ * with the current count and again on every change, so one subscription can
13288
+ * drive a "downloading" indicator for the whole app. Pair with
13289
+ * {@link subscribeToPendingMutations} for the "uploading" half.
13290
+ */
13291
+ subscribeToFetchActivity(cb) {
13292
+ return this.dataModule.subscribeActivity(cb, { immediate: true });
13293
+ }
13235
13294
  /** Current sync-health snapshot. See {@link Sp00kyConfig.syncHealth}. */
13236
13295
  get syncHealth() {
13237
13296
  return this.sync.syncHealth;
@@ -13358,7 +13417,7 @@ var Sp00kyClient = class {
13358
13417
  return new TabsCoordinator({
13359
13418
  tabId,
13360
13419
  fingerprint: computeTabsFingerprint({
13361
- coreVersion: "0.0.1-canary.238",
13420
+ coreVersion: "0.0.1-canary.239",
13362
13421
  schemaHash: hash53(this.config.schemaSurql),
13363
13422
  endpoint: this.config.database.endpoint ?? "",
13364
13423
  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.238",
3
+ "version": "0.0.1-canary.239",
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.238",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.238",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.239",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.239",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+ import type { QueryState } from '../../types';
5
+
6
+ /**
7
+ * The aggregate fetch-activity channel (`subscribeActivity`): one number, how
8
+ * many queries are inside their outermost fetch cycle, so an app-chrome
9
+ * "downloading" indicator needs a single subscription instead of one per query.
10
+ */
11
+
12
+ function makeLogger(): any {
13
+ const noop = () => {};
14
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
15
+ logger.child = () => logger;
16
+ return logger;
17
+ }
18
+
19
+ function makeDataModule(): DataModule<any> {
20
+ return new DataModule({} as any, {} as any, { tables: [] } as any, makeLogger(), 100);
21
+ }
22
+
23
+ function makeQueryState(hash: string): QueryState {
24
+ return {
25
+ config: {
26
+ id: new RecordId('_00_query', hash),
27
+ surql: 'SELECT * FROM user',
28
+ params: {},
29
+ localArray: [],
30
+ remoteArray: [],
31
+ ttl: '10m',
32
+ lastActiveAt: new Date(),
33
+ tableName: 'user',
34
+ },
35
+ records: [],
36
+ ttlTimer: null,
37
+ ttlDurationMs: 0,
38
+ updateCount: 0,
39
+ lastUpdatedAt: null,
40
+ materializationSamples: [],
41
+ lastIngestLatencyMs: null,
42
+ errorCount: 0,
43
+ status: 'idle',
44
+ phaseSamples: {},
45
+ phaseLast: {},
46
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
47
+ };
48
+ }
49
+
50
+ describe('DataModule fetch activity', () => {
51
+ it('emits the current count immediately and on every change', () => {
52
+ const dm = makeDataModule();
53
+ (dm as any).activeQueries.set('a', makeQueryState('a'));
54
+ (dm as any).activeQueries.set('b', makeQueryState('b'));
55
+ const seen: number[] = [];
56
+ dm.subscribeActivity((n) => seen.push(n), { immediate: true });
57
+ expect(seen).toEqual([0]);
58
+
59
+ dm.beginFetching('a');
60
+ dm.beginFetching('b');
61
+ expect(seen).toEqual([0, 1, 2]);
62
+ expect(dm.fetchingQueryCount).toBe(2);
63
+
64
+ dm.endFetching('a');
65
+ dm.endFetching('b');
66
+ expect(seen).toEqual([0, 1, 2, 1, 0]);
67
+ });
68
+
69
+ it('counts a query once however deep its fetch cycles nest', () => {
70
+ const dm = makeDataModule();
71
+ (dm as any).activeQueries.set('a', makeQueryState('a'));
72
+ const seen: number[] = [];
73
+ dm.subscribeActivity((n) => seen.push(n));
74
+ dm.beginFetching('a');
75
+ dm.beginFetching('a'); // overlapping poll round on the same hash
76
+ dm.endFetching('a');
77
+ expect(seen).toEqual([1], 'the inner cycle is silent');
78
+ dm.endFetching('a');
79
+ expect(seen).toEqual([1, 0]);
80
+ });
81
+
82
+ it('stops notifying after unsubscribe and survives a throwing subscriber', () => {
83
+ const dm = makeDataModule();
84
+ (dm as any).activeQueries.set('a', makeQueryState('a'));
85
+ const bad = vi.fn(() => {
86
+ throw new Error('boom');
87
+ });
88
+ const good = vi.fn();
89
+ dm.subscribeActivity(bad);
90
+ const off = dm.subscribeActivity(good);
91
+ dm.beginFetching('a');
92
+ expect(good).toHaveBeenCalledWith(1);
93
+ off();
94
+ dm.endFetching('a');
95
+ expect(good).toHaveBeenCalledTimes(1);
96
+ expect(bad).toHaveBeenCalledTimes(2);
97
+ });
98
+
99
+ it('drops to zero when a bucket switch quiesces in-flight fetches', () => {
100
+ const dm = makeDataModule();
101
+ (dm as any).activeQueries.set('a', makeQueryState('a'));
102
+ const seen: number[] = [];
103
+ dm.subscribeActivity((n) => seen.push(n));
104
+ dm.beginFetching('a');
105
+ dm.quiesce();
106
+ expect(seen).toEqual([1, 0]);
107
+ expect(dm.fetchingQueryCount).toBe(0);
108
+ });
109
+ });
@@ -119,6 +119,12 @@ export class DataModule<S extends SchemaStructure> {
119
119
  // back to `idle` only on 1→0, so an inner cycle finishing can't emit a
120
120
  // premature idle mid-registration.
121
121
  private fetchDepth: Map<QueryHash, number> = new Map();
122
+ /**
123
+ * Observers of the AGGREGATE fetch activity: how many queries are mid-fetch
124
+ * right now. What a "downloading" indicator in the app chrome hangs off,
125
+ * instead of subscribing to every query's status one by one.
126
+ */
127
+ private activitySubscriptions: Set<(fetching: number) => void> = new Set();
122
128
  private logger: Logger;
123
129
  /**
124
130
  * Optional observer notified whenever a query's fetch status changes.
@@ -446,6 +452,7 @@ export class DataModule<S extends SchemaStructure> {
446
452
  this.fetchDepth.set(queryHash, depth + 1);
447
453
  if (depth === 0) {
448
454
  this.setQueryStatus(queryHash, 'fetching');
455
+ this.notifyActivity();
449
456
  }
450
457
  }
451
458
 
@@ -455,11 +462,49 @@ export class DataModule<S extends SchemaStructure> {
455
462
  if (depth <= 1) {
456
463
  this.fetchDepth.delete(queryHash);
457
464
  this.setQueryStatus(queryHash, 'idle');
465
+ this.notifyActivity();
458
466
  return;
459
467
  }
460
468
  this.fetchDepth.set(queryHash, depth - 1);
461
469
  }
462
470
 
471
+ /** How many queries are mid-fetch right now (see {@link subscribeActivity}). */
472
+ get fetchingQueryCount(): number {
473
+ return this.fetchDepth.size;
474
+ }
475
+
476
+ /**
477
+ * Observe the number of queries currently fetching. Fires on every change of
478
+ * that number (a query entering or leaving its outermost fetch cycle), and
479
+ * synchronously with the current value when `immediate` is set. One
480
+ * subscription per indicator, however many queries the page holds.
481
+ */
482
+ subscribeActivity(
483
+ callback: (fetching: number) => void,
484
+ options: { immediate?: boolean } = {}
485
+ ): () => void {
486
+ this.activitySubscriptions.add(callback);
487
+ if (options.immediate) callback(this.fetchDepth.size);
488
+ return () => {
489
+ this.activitySubscriptions.delete(callback);
490
+ };
491
+ }
492
+
493
+ private notifyActivity(): void {
494
+ if (this.activitySubscriptions.size === 0) return;
495
+ const n = this.fetchDepth.size;
496
+ for (const cb of this.activitySubscriptions) {
497
+ try {
498
+ cb(n);
499
+ } catch (err) {
500
+ this.logger.warn(
501
+ { err, Category: 'sp00ky-client::DataModule::notifyActivity' },
502
+ 'Activity subscriber threw'
503
+ );
504
+ }
505
+ }
506
+ }
507
+
463
508
  /**
464
509
  * Subscribe to mutations (for sync)
465
510
  */
@@ -1664,7 +1709,9 @@ export class DataModule<S extends SchemaStructure> {
1664
1709
  }
1665
1710
  this.debounceTimers.clear();
1666
1711
  this.pendingStreamUpdates.clear();
1712
+ const wasFetching = this.fetchDepth.size > 0;
1667
1713
  this.fetchDepth.clear();
1714
+ if (wasFetching) this.notifyActivity();
1668
1715
  for (const queryState of this.activeQueries.values()) {
1669
1716
  if (queryState.ttlTimer) {
1670
1717
  clearTimeout(queryState.ttlTimer);
package/src/sp00ky.ts CHANGED
@@ -401,6 +401,21 @@ export class Sp00kyClient<S extends SchemaStructure> {
401
401
  return this.sync.subscribeToPendingMutations(cb);
402
402
  }
403
403
 
404
+ /** Number of queries mid-fetch right now. See {@link subscribeToFetchActivity}. */
405
+ get fetchingQueryCount(): number {
406
+ return this.dataModule.fetchingQueryCount;
407
+ }
408
+
409
+ /**
410
+ * Observe how many queries are fetching from the server. Fires immediately
411
+ * with the current count and again on every change, so one subscription can
412
+ * drive a "downloading" indicator for the whole app. Pair with
413
+ * {@link subscribeToPendingMutations} for the "uploading" half.
414
+ */
415
+ subscribeToFetchActivity(cb: (fetching: number) => void): () => void {
416
+ return this.dataModule.subscribeActivity(cb, { immediate: true });
417
+ }
418
+
404
419
  /** Current sync-health snapshot. See {@link Sp00kyConfig.syncHealth}. */
405
420
  get syncHealth(): SyncHealth {
406
421
  return this.sync.syncHealth;
@@ -29,6 +29,16 @@ describe('classifySyncError', () => {
29
29
  expect(classifySyncError(new Error(message))).toBe('network');
30
30
  });
31
31
 
32
+ it.each([
33
+ // SurrealDB optimistic-concurrency conflict: retryable by definition.
34
+ 'Transaction conflict: Resource busy: . This transaction can be retried',
35
+ // Session state not re-applied yet after the SDK's reconnect handshake.
36
+ 'Specify a namespace to use',
37
+ 'Specify a database to use',
38
+ ])('classifies transient server state %j as network (retry, never rollback)', (message) => {
39
+ expect(classifySyncError(new Error(message))).toBe('network');
40
+ });
41
+
32
42
  it.each([
33
43
  'Permission denied',
34
44
  'There was a problem with the database: record already exists',
@@ -19,6 +19,24 @@ const NETWORK_ERROR_PATTERNS = [
19
19
  'enotfound',
20
20
  'epipe',
21
21
  'abort',
22
+ // Transient server-side states that are NOT the mutation's fault and clear
23
+ // on their own, so the outbox must retry them rather than roll the write
24
+ // back. Seen 2026-09-08 on whitepawn: a 1200-game import lost 752 games and
25
+ // ~1500 player_name rows because every queued CREATE that hit one of these
26
+ // during a DB stall + WebSocket reconnect was classified `application`,
27
+ // rolled back locally and discarded - silent data loss.
28
+ //
29
+ // - SurrealDB optimistic-concurrency conflicts: "Transaction conflict:
30
+ // Resource busy: . This transaction can be retried".
31
+ // - A socket whose namespace/identity has not been re-applied yet after the
32
+ // SDK's own reconnect handshake: "Specify a namespace to use" /
33
+ // "Specify a database to use". The statement is fine; the session is not
34
+ // ready.
35
+ 'transaction conflict',
36
+ 'resource busy',
37
+ 'can be retried',
38
+ 'specify a namespace',
39
+ 'specify a database',
22
40
  ];
23
41
 
24
42
  export function classifySyncError(error: unknown): 'network' | 'application' {