@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 +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.js +64 -5
- package/package.json +3 -3
- package/src/modules/data/data.activity.test.ts +109 -0
- package/src/modules/data/index.ts +47 -0
- package/src/sp00ky.ts +15 -0
- package/src/utils/error-classification.test.ts +10 -0
- package/src/utils/error-classification.ts +18 -0
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`
|
|
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)
|
|
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.
|
|
8345
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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.
|
|
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.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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' {
|