@spooky-sync/core 0.0.1-canary.155 → 0.0.1-canary.157
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.d.ts +31 -0
- package/dist/index.js +154 -24
- package/dist/types.d.ts +30 -0
- package/package.json +3 -3
- package/src/modules/devtools/index.ts +51 -14
- package/src/modules/devtools/notify-throttle.test.ts +149 -0
- package/src/modules/devtools/storage-info.ts +27 -0
- package/src/services/stream-processor/index.ts +146 -20
- package/src/services/stream-processor/stream-processor.reset.test.ts +114 -2
- package/src/services/stream-processor/wasm-types.ts +5 -0
- package/src/sp00ky.ts +80 -40
- package/src/types.ts +23 -0
package/dist/index.d.ts
CHANGED
|
@@ -140,6 +140,11 @@ declare class StreamProcessorService {
|
|
|
140
140
|
private stateKeySuffix;
|
|
141
141
|
private stateGeneration;
|
|
142
142
|
private persistState;
|
|
143
|
+
private persistCircuit;
|
|
144
|
+
private checkpointMs;
|
|
145
|
+
private checkpointTimer;
|
|
146
|
+
private snapshotDirty;
|
|
147
|
+
private pagehideHandler;
|
|
143
148
|
constructor(events: EventSystem<StreamProcessorEvents>, db: LocalStore, persistenceClient: PersistenceClient, logger: Logger);
|
|
144
149
|
/**
|
|
145
150
|
* Add a receiver for stream updates.
|
|
@@ -200,8 +205,34 @@ declare class StreamProcessorService {
|
|
|
200
205
|
* afterwards (a fresh circuit default-denies every table).
|
|
201
206
|
*/
|
|
202
207
|
reset(): Promise<void>;
|
|
208
|
+
/**
|
|
209
|
+
* Release the wasm circuit and stop checkpointing. Call when the client is
|
|
210
|
+
* torn down; a recreated client (provider remount, HMR) would otherwise stack
|
|
211
|
+
* one full circuit per instance.
|
|
212
|
+
*/
|
|
213
|
+
dispose(): void;
|
|
214
|
+
/**
|
|
215
|
+
* Explicitly run the wasm-bindgen destructor. Guarded: stale wasm builds may
|
|
216
|
+
* not expose `free`, and a double free must not take the app down.
|
|
217
|
+
*/
|
|
218
|
+
private freeProcessor;
|
|
203
219
|
/** Toggle circuit-state persistence (shared-tabs follower/leader role). */
|
|
204
220
|
setPersistenceEnabled(enabled: boolean): void;
|
|
221
|
+
/**
|
|
222
|
+
* Opt into snapshot persistence (`persistCircuit`). Off by default: see the
|
|
223
|
+
* `persistCircuit` field comment for why per-ingest snapshots were removed.
|
|
224
|
+
* Must be called before `init()` for a snapshot to be restored at boot.
|
|
225
|
+
*/
|
|
226
|
+
configureCircuitPersistence(enabled: boolean, checkpointMs?: number): void;
|
|
227
|
+
/**
|
|
228
|
+
* Record that the circuit changed. Cheap and O(1), the expensive snapshot is
|
|
229
|
+
* deferred to the checkpoint timer, and skipped entirely when
|
|
230
|
+
* `persistCircuit` is off (the default).
|
|
231
|
+
*/
|
|
232
|
+
private markSnapshotDirty;
|
|
233
|
+
private startCheckpoints;
|
|
234
|
+
/** Stop checkpointing and drop the `pagehide` listener. */
|
|
235
|
+
stopCheckpoints(): void;
|
|
205
236
|
loadState(): Promise<void>;
|
|
206
237
|
/**
|
|
207
238
|
* Seed per-table `select` permission predicates ({ [table]: whereText }).
|
package/dist/index.js
CHANGED
|
@@ -5694,16 +5694,20 @@ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
|
|
|
5694
5694
|
|
|
5695
5695
|
//#endregion
|
|
5696
5696
|
//#region src/modules/devtools/index.ts
|
|
5697
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
5698
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
5697
|
+
const CORE_VERSION = "0.0.1-canary.157";
|
|
5698
|
+
const WASM_VERSION = "0.0.1-canary.157";
|
|
5699
5699
|
const SURREAL_VERSION = "3.0.3";
|
|
5700
|
-
var DevToolsService = class {
|
|
5700
|
+
var DevToolsService = class DevToolsService {
|
|
5701
5701
|
eventsHistory = [];
|
|
5702
5702
|
eventIdCounter = 0;
|
|
5703
5703
|
version = CORE_VERSION;
|
|
5704
5704
|
backendInfo = emptyBackendInfo();
|
|
5705
5705
|
enabled = false;
|
|
5706
|
-
|
|
5706
|
+
static NOTIFY_MIN_INTERVAL_MS = 250;
|
|
5707
|
+
notifyTimer = null;
|
|
5708
|
+
lastNotifyAt = 0;
|
|
5709
|
+
/** Shared-tabs snapshot for the panel, wired by Sp00kyClient whenever the
|
|
5710
|
+
* feature was REQUESTED (so an inactive/degraded tab still reports why). */
|
|
5707
5711
|
tabsInfoProvider = null;
|
|
5708
5712
|
setTabsInfoProvider(provider) {
|
|
5709
5713
|
this.tabsInfoProvider = provider;
|
|
@@ -5925,6 +5929,7 @@ var DevToolsService = class {
|
|
|
5925
5929
|
status: "unknown",
|
|
5926
5930
|
fallback: false
|
|
5927
5931
|
},
|
|
5932
|
+
tabs: this.tabsInfoProvider?.() ?? null,
|
|
5928
5933
|
browser: {},
|
|
5929
5934
|
opfs: {
|
|
5930
5935
|
supported: false,
|
|
@@ -5969,9 +5974,30 @@ var DevToolsService = class {
|
|
|
5969
5974
|
return { granted: false };
|
|
5970
5975
|
}
|
|
5971
5976
|
}
|
|
5977
|
+
/**
|
|
5978
|
+
* Request a state push. Coalesced (see {@link NOTIFY_MIN_INTERVAL_MS}): the
|
|
5979
|
+
* first call after an idle period pushes straight away so the panel stays
|
|
5980
|
+
* responsive, and any calls during the window collapse into ONE trailing push
|
|
5981
|
+
* that serializes the state as of the flush, not as of the request. Callers
|
|
5982
|
+
* stay fire-and-forget.
|
|
5983
|
+
*/
|
|
5972
5984
|
notifyDevTools() {
|
|
5973
5985
|
if (!this.enabled) return;
|
|
5974
|
-
if (typeof window
|
|
5986
|
+
if (typeof window === "undefined") return;
|
|
5987
|
+
if (this.notifyTimer !== null) return;
|
|
5988
|
+
const waited = Date.now() - this.lastNotifyAt;
|
|
5989
|
+
if (waited >= DevToolsService.NOTIFY_MIN_INTERVAL_MS) {
|
|
5990
|
+
this.flushNotify();
|
|
5991
|
+
return;
|
|
5992
|
+
}
|
|
5993
|
+
this.notifyTimer = setTimeout(() => {
|
|
5994
|
+
this.notifyTimer = null;
|
|
5995
|
+
if (this.enabled) this.flushNotify();
|
|
5996
|
+
}, DevToolsService.NOTIFY_MIN_INTERVAL_MS - waited);
|
|
5997
|
+
}
|
|
5998
|
+
flushNotify() {
|
|
5999
|
+
this.lastNotifyAt = Date.now();
|
|
6000
|
+
window.postMessage({
|
|
5975
6001
|
type: "SP00KY_STATE_CHANGED",
|
|
5976
6002
|
source: "sp00ky-devtools-page",
|
|
5977
6003
|
state: this.getState()
|
|
@@ -6296,6 +6322,14 @@ var AuthService = class {
|
|
|
6296
6322
|
|
|
6297
6323
|
//#endregion
|
|
6298
6324
|
//#region src/services/stream-processor/index.ts
|
|
6325
|
+
/**
|
|
6326
|
+
* Read a circuit snapshot out of the pre-`persistCircuit` persisted shape
|
|
6327
|
+
* (`[[{ state }]]`, a raw SurrealDB result). Returns null for anything else.
|
|
6328
|
+
*/
|
|
6329
|
+
function extractLegacyState(result) {
|
|
6330
|
+
if (Array.isArray(result) && Array.isArray(result[0]) && typeof result[0][0]?.state === "string") return result[0][0].state;
|
|
6331
|
+
return null;
|
|
6332
|
+
}
|
|
6299
6333
|
var StreamProcessorService = class {
|
|
6300
6334
|
logger;
|
|
6301
6335
|
processor;
|
|
@@ -6310,6 +6344,11 @@ var StreamProcessorService = class {
|
|
|
6310
6344
|
stateKeySuffix = "";
|
|
6311
6345
|
stateGeneration = 0;
|
|
6312
6346
|
persistState = true;
|
|
6347
|
+
persistCircuit = false;
|
|
6348
|
+
checkpointMs = 3e4;
|
|
6349
|
+
checkpointTimer = null;
|
|
6350
|
+
snapshotDirty = false;
|
|
6351
|
+
pagehideHandler = null;
|
|
6313
6352
|
constructor(events, db, persistenceClient, logger) {
|
|
6314
6353
|
this.events = events;
|
|
6315
6354
|
this.db = db;
|
|
@@ -6390,7 +6429,7 @@ var StreamProcessorService = class {
|
|
|
6390
6429
|
const buffered = Array.from(this.batchBuffer.values());
|
|
6391
6430
|
this.batchBuffer.clear();
|
|
6392
6431
|
if (buffered.length > 0) this.dispatchUpdates(buffered);
|
|
6393
|
-
this.
|
|
6432
|
+
this.markSnapshotDirty();
|
|
6394
6433
|
}
|
|
6395
6434
|
/**
|
|
6396
6435
|
* Initialize the WASM module and processor.
|
|
@@ -6434,19 +6473,99 @@ var StreamProcessorService = class {
|
|
|
6434
6473
|
this.stateGeneration++;
|
|
6435
6474
|
this.batching = false;
|
|
6436
6475
|
this.batchBuffer.clear();
|
|
6476
|
+
this.snapshotDirty = false;
|
|
6477
|
+
const previous = this.processor;
|
|
6437
6478
|
this.processor = new Sp00kyProcessor();
|
|
6479
|
+
this.freeProcessor(previous);
|
|
6438
6480
|
this.logger.info({ Category: "sp00ky-client::StreamProcessorService::reset" }, "Stream processor reset (fresh circuit)");
|
|
6439
6481
|
}
|
|
6482
|
+
/**
|
|
6483
|
+
* Release the wasm circuit and stop checkpointing. Call when the client is
|
|
6484
|
+
* torn down; a recreated client (provider remount, HMR) would otherwise stack
|
|
6485
|
+
* one full circuit per instance.
|
|
6486
|
+
*/
|
|
6487
|
+
dispose() {
|
|
6488
|
+
this.stopCheckpoints();
|
|
6489
|
+
const previous = this.processor;
|
|
6490
|
+
this.processor = void 0;
|
|
6491
|
+
this.isInitialized = false;
|
|
6492
|
+
this.batching = false;
|
|
6493
|
+
this.batchBuffer.clear();
|
|
6494
|
+
this.receivers = [];
|
|
6495
|
+
this.freeProcessor(previous);
|
|
6496
|
+
}
|
|
6497
|
+
/**
|
|
6498
|
+
* Explicitly run the wasm-bindgen destructor. Guarded: stale wasm builds may
|
|
6499
|
+
* not expose `free`, and a double free must not take the app down.
|
|
6500
|
+
*/
|
|
6501
|
+
freeProcessor(processor) {
|
|
6502
|
+
if (!processor || typeof processor.free !== "function") return;
|
|
6503
|
+
try {
|
|
6504
|
+
processor.free();
|
|
6505
|
+
} catch (e) {
|
|
6506
|
+
this.logger.debug({
|
|
6507
|
+
error: e,
|
|
6508
|
+
Category: "sp00ky-client::StreamProcessorService::freeProcessor"
|
|
6509
|
+
}, "Failed to free previous wasm circuit");
|
|
6510
|
+
}
|
|
6511
|
+
}
|
|
6440
6512
|
/** Toggle circuit-state persistence (shared-tabs follower/leader role). */
|
|
6441
6513
|
setPersistenceEnabled(enabled) {
|
|
6442
6514
|
this.persistState = enabled;
|
|
6515
|
+
if (!enabled) this.stopCheckpoints();
|
|
6516
|
+
}
|
|
6517
|
+
/**
|
|
6518
|
+
* Opt into snapshot persistence (`persistCircuit`). Off by default: see the
|
|
6519
|
+
* `persistCircuit` field comment for why per-ingest snapshots were removed.
|
|
6520
|
+
* Must be called before `init()` for a snapshot to be restored at boot.
|
|
6521
|
+
*/
|
|
6522
|
+
configureCircuitPersistence(enabled, checkpointMs) {
|
|
6523
|
+
this.persistCircuit = enabled;
|
|
6524
|
+
if (checkpointMs && checkpointMs > 0) this.checkpointMs = checkpointMs;
|
|
6525
|
+
if (!enabled) this.stopCheckpoints();
|
|
6526
|
+
}
|
|
6527
|
+
/**
|
|
6528
|
+
* Record that the circuit changed. Cheap and O(1), the expensive snapshot is
|
|
6529
|
+
* deferred to the checkpoint timer, and skipped entirely when
|
|
6530
|
+
* `persistCircuit` is off (the default).
|
|
6531
|
+
*/
|
|
6532
|
+
markSnapshotDirty() {
|
|
6533
|
+
if (!this.persistCircuit || !this.persistState) return;
|
|
6534
|
+
this.snapshotDirty = true;
|
|
6535
|
+
this.startCheckpoints();
|
|
6536
|
+
}
|
|
6537
|
+
startCheckpoints() {
|
|
6538
|
+
if (this.checkpointTimer) return;
|
|
6539
|
+
this.checkpointTimer = setInterval(() => {
|
|
6540
|
+
if (!this.snapshotDirty) return;
|
|
6541
|
+
this.snapshotDirty = false;
|
|
6542
|
+
this.saveState();
|
|
6543
|
+
}, this.checkpointMs);
|
|
6544
|
+
if (typeof window !== "undefined" && !this.pagehideHandler) {
|
|
6545
|
+
this.pagehideHandler = () => {
|
|
6546
|
+
if (!this.snapshotDirty) return;
|
|
6547
|
+
this.snapshotDirty = false;
|
|
6548
|
+
this.saveState();
|
|
6549
|
+
};
|
|
6550
|
+
window.addEventListener("pagehide", this.pagehideHandler);
|
|
6551
|
+
}
|
|
6552
|
+
}
|
|
6553
|
+
/** Stop checkpointing and drop the `pagehide` listener. */
|
|
6554
|
+
stopCheckpoints() {
|
|
6555
|
+
if (this.checkpointTimer) {
|
|
6556
|
+
clearInterval(this.checkpointTimer);
|
|
6557
|
+
this.checkpointTimer = null;
|
|
6558
|
+
}
|
|
6559
|
+
if (this.pagehideHandler && typeof window !== "undefined") window.removeEventListener("pagehide", this.pagehideHandler);
|
|
6560
|
+
this.pagehideHandler = null;
|
|
6561
|
+
this.snapshotDirty = false;
|
|
6443
6562
|
}
|
|
6444
6563
|
async loadState() {
|
|
6445
|
-
if (!this.processor || !this.persistState) return;
|
|
6564
|
+
if (!this.processor || !this.persistState || !this.persistCircuit) return;
|
|
6446
6565
|
try {
|
|
6447
6566
|
const result = await this.persistenceClient.get(this.stateKey());
|
|
6448
|
-
|
|
6449
|
-
|
|
6567
|
+
const state = typeof result === "string" ? result : extractLegacyState(result);
|
|
6568
|
+
if (state) {
|
|
6450
6569
|
this.logger.info({
|
|
6451
6570
|
stateLength: state.length,
|
|
6452
6571
|
Category: "sp00ky-client::StreamProcessorService::loadState"
|
|
@@ -6500,7 +6619,7 @@ var StreamProcessorService = class {
|
|
|
6500
6619
|
}, "Session auth context updated");
|
|
6501
6620
|
}
|
|
6502
6621
|
async saveState() {
|
|
6503
|
-
if (!this.processor || !this.persistState) return;
|
|
6622
|
+
if (!this.processor || !this.persistState || !this.persistCircuit) return;
|
|
6504
6623
|
const generation = this.stateGeneration;
|
|
6505
6624
|
try {
|
|
6506
6625
|
if (typeof this.processor.save_state === "function") {
|
|
@@ -6559,7 +6678,7 @@ var StreamProcessorService = class {
|
|
|
6559
6678
|
}));
|
|
6560
6679
|
this.notifyUpdates(updates);
|
|
6561
6680
|
}
|
|
6562
|
-
if (!this.batching) this.
|
|
6681
|
+
if (!this.batching) this.markSnapshotDirty();
|
|
6563
6682
|
return rawUpdates;
|
|
6564
6683
|
} catch (e) {
|
|
6565
6684
|
this.logger.error({
|
|
@@ -6612,7 +6731,7 @@ var StreamProcessorService = class {
|
|
|
6612
6731
|
snapshotMs: initialUpdate.timing_snapshot_ms ?? 0
|
|
6613
6732
|
}
|
|
6614
6733
|
};
|
|
6615
|
-
this.
|
|
6734
|
+
this.markSnapshotDirty();
|
|
6616
6735
|
this.logger.debug({
|
|
6617
6736
|
queryHash: queryPlan.queryHash,
|
|
6618
6737
|
surql: queryPlan.surql,
|
|
@@ -6635,7 +6754,7 @@ var StreamProcessorService = class {
|
|
|
6635
6754
|
if (!this.processor) return;
|
|
6636
6755
|
try {
|
|
6637
6756
|
this.processor.unregister_view(queryHash);
|
|
6638
|
-
this.
|
|
6757
|
+
this.markSnapshotDirty();
|
|
6639
6758
|
} catch (e) {
|
|
6640
6759
|
this.logger.error({
|
|
6641
6760
|
error: e,
|
|
@@ -8757,6 +8876,7 @@ var Sp00kyClient = class {
|
|
|
8757
8876
|
else this.persistenceClient = config.persistenceClient;
|
|
8758
8877
|
this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
|
|
8759
8878
|
this.streamProcessor = new StreamProcessorService(new EventSystem(["stream_update"]), this.local, this.persistenceClient, logger);
|
|
8879
|
+
this.streamProcessor.configureCircuitPersistence(config.persistCircuit ?? false, config.circuitCheckpointMs);
|
|
8760
8880
|
this.migrator = new LocalMigrator(this.local, logger);
|
|
8761
8881
|
this.cache = new CacheModule(this.local, this.streamProcessor, (update) => {
|
|
8762
8882
|
this.dataModule.onStreamUpdate(update);
|
|
@@ -8784,24 +8904,33 @@ var Sp00kyClient = class {
|
|
|
8784
8904
|
this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
|
|
8785
8905
|
this.streamProcessor.addReceiver(this.devTools);
|
|
8786
8906
|
this.setupCallbacks();
|
|
8787
|
-
if (tabsSupport.supported)
|
|
8788
|
-
|
|
8907
|
+
if (tabsSupport.supported) this.tabsCoordinator = this.buildTabsCoordinator();
|
|
8908
|
+
else if (this.config.sharedTabs) this.logger.info({
|
|
8909
|
+
reason: tabsSupport.reason,
|
|
8910
|
+
Category: "sp00ky-client::Sp00kyClient::tabs"
|
|
8911
|
+
}, "sharedTabs requested but unsupported here; running solo");
|
|
8912
|
+
if (this.config.sharedTabs) {
|
|
8913
|
+
const unsupportedReason = tabsSupport.supported ? void 0 : tabsSupport.reason;
|
|
8789
8914
|
this.devTools.setTabsInfoProvider(() => {
|
|
8790
|
-
if (!this.sharedActive || !this.tabsCoordinator) return null;
|
|
8791
8915
|
const c = this.tabsCoordinator;
|
|
8916
|
+
if (!this.sharedActive || !c) return {
|
|
8917
|
+
active: false,
|
|
8918
|
+
reason: unsupportedReason ?? "fell-back"
|
|
8919
|
+
};
|
|
8920
|
+
const hub = c.syncHub;
|
|
8792
8921
|
return {
|
|
8922
|
+
active: true,
|
|
8793
8923
|
role: c.role,
|
|
8794
8924
|
tabId: c.tabId,
|
|
8795
8925
|
leadershipId: c.leadershipId,
|
|
8796
|
-
leaderTabId: c.leaderTabId,
|
|
8797
|
-
|
|
8798
|
-
|
|
8926
|
+
leaderTabId: c.role === "leader" ? c.tabId : c.leaderTabId,
|
|
8927
|
+
...hub ? {
|
|
8928
|
+
followers: hub.followerCount,
|
|
8929
|
+
relayedBatches: hub.relayedBatches
|
|
8930
|
+
} : {}
|
|
8799
8931
|
};
|
|
8800
8932
|
});
|
|
8801
|
-
}
|
|
8802
|
-
reason: tabsSupport.reason,
|
|
8803
|
-
Category: "sp00ky-client::Sp00kyClient::tabs"
|
|
8804
|
-
}, "sharedTabs requested but unsupported here; running solo");
|
|
8933
|
+
}
|
|
8805
8934
|
}
|
|
8806
8935
|
/** The shared-tabs role machinery, wired to this client's modules. */
|
|
8807
8936
|
buildTabsCoordinator() {
|
|
@@ -8810,7 +8939,7 @@ var Sp00kyClient = class {
|
|
|
8810
8939
|
return new TabsCoordinator({
|
|
8811
8940
|
tabId,
|
|
8812
8941
|
fingerprint: computeTabsFingerprint({
|
|
8813
|
-
coreVersion: "0.0.1-canary.
|
|
8942
|
+
coreVersion: "0.0.1-canary.157",
|
|
8814
8943
|
schemaHash: hash53(this.config.schemaSurql),
|
|
8815
8944
|
endpoint: this.config.database.endpoint ?? "",
|
|
8816
8945
|
namespace: this.config.database.namespace,
|
|
@@ -9106,6 +9235,7 @@ var Sp00kyClient = class {
|
|
|
9106
9235
|
if (this.tabsCoordinator) await this.tabsCoordinator.stop();
|
|
9107
9236
|
await this.local.close();
|
|
9108
9237
|
await this.remote.close();
|
|
9238
|
+
this.streamProcessor.dispose();
|
|
9109
9239
|
}
|
|
9110
9240
|
/**
|
|
9111
9241
|
* Subscribe to a feature flag for the current user. Returns a
|
package/dist/types.d.ts
CHANGED
|
@@ -199,6 +199,13 @@ interface EngineStorageDiagnostics {
|
|
|
199
199
|
}[];
|
|
200
200
|
error?: string;
|
|
201
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Shared-tabs coordination state. Reported ONLY when `sharedTabs: true` was
|
|
204
|
+
* configured (apps that never asked for it get `null`, so the panel shows
|
|
205
|
+
* nothing). `active: false` with a `reason` is itself the useful signal: the
|
|
206
|
+
* app asked to share one store and this tab is not, so it owns or contends for
|
|
207
|
+
* the OPFS pool alone.
|
|
208
|
+
*/
|
|
202
209
|
//#endregion
|
|
203
210
|
//#region src/services/database/cache-engine.d.ts
|
|
204
211
|
/**
|
|
@@ -488,6 +495,29 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
488
495
|
* Inspect via `window.__00__.getState().database.tabs` and `__sqliteStats`.
|
|
489
496
|
*/
|
|
490
497
|
sharedTabs?: boolean;
|
|
498
|
+
/**
|
|
499
|
+
* Persist the in-browser SSP circuit (store + view caches) as a snapshot so a
|
|
500
|
+
* reload can restore it instead of re-materializing. Default `false`, and
|
|
501
|
+
* that default is deliberate.
|
|
502
|
+
*
|
|
503
|
+
* The circuit is DERIVED state: the durable local store (OPFS SQLite) is the
|
|
504
|
+
* source of truth, and every first paint already reads row bodies from it
|
|
505
|
+
* (`DataManager.createNewQuery` / `materializeRecords`) using the circuit only
|
|
506
|
+
* for row identity and ordering. A snapshot buys nothing on reload while
|
|
507
|
+
* costing a full deep clone of every row of every ingested table plus a JSON
|
|
508
|
+
* encode of the result, `Circuit::save` in the Rust core, mirroring the
|
|
509
|
+
* server's rule in `ssp-node`: *never per-ingest*.
|
|
510
|
+
*
|
|
511
|
+
* When enabled, snapshots are written on a checkpoint interval
|
|
512
|
+
* ({@link circuitCheckpointMs}) and on `pagehide`, never per ingest or per
|
|
513
|
+
* query registration. Enable only for a workload that has measured a win.
|
|
514
|
+
*/
|
|
515
|
+
persistCircuit?: boolean;
|
|
516
|
+
/**
|
|
517
|
+
* Checkpoint interval in milliseconds for {@link persistCircuit}. Defaults to
|
|
518
|
+
* 30000. Ignored when `persistCircuit` is off.
|
|
519
|
+
*/
|
|
520
|
+
circuitCheckpointMs?: number;
|
|
491
521
|
/** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel). */
|
|
492
522
|
otelTransmit?: PinoTransmit;
|
|
493
523
|
/**
|
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.157",
|
|
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.157",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.157",
|
|
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",
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
parseBackendInfo,
|
|
23
23
|
UNAVAILABLE,
|
|
24
24
|
} from './versions';
|
|
25
|
-
import { walkOpfs, type StorageInfo } from './storage-info';
|
|
25
|
+
import { walkOpfs, type SharedTabsInfo, type StorageInfo } from './storage-info';
|
|
26
26
|
|
|
27
27
|
// Real bundled frontend versions, injected at build time by tsdown's
|
|
28
28
|
// version-define plugin (see tsdown.config.ts). The `typeof` guard keeps these
|
|
@@ -51,10 +51,23 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
51
51
|
// (the on-demand GET_STATE pull) still works before the push channel turns on.
|
|
52
52
|
private enabled = false;
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
// A state push serializes EVERY active query's full record set (see
|
|
55
|
+
// `getActiveQueries`) and postMessage clones it again, so its cost scales with
|
|
56
|
+
// the whole client dataset — and it is triggered per event, including one per
|
|
57
|
+
// local DB query (`DATABASE_LOCAL_QUERY` → `logEvent`). Unthrottled, a single
|
|
58
|
+
// page load's few hundred local queries turn a handful of MB of rows into GBs
|
|
59
|
+
// of short-lived large-object garbage and OOM the renderer (V8
|
|
60
|
+
// "young object promotion failed"). Coalesce instead: push immediately when
|
|
61
|
+
// idle, then at most once per window, always serializing the LATEST state.
|
|
62
|
+
private static readonly NOTIFY_MIN_INTERVAL_MS = 250;
|
|
63
|
+
private notifyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
64
|
+
private lastNotifyAt = 0;
|
|
65
|
+
|
|
66
|
+
/** Shared-tabs snapshot for the panel, wired by Sp00kyClient whenever the
|
|
67
|
+
* feature was REQUESTED (so an inactive/degraded tab still reports why). */
|
|
68
|
+
private tabsInfoProvider: (() => SharedTabsInfo | null) | null = null;
|
|
69
|
+
|
|
70
|
+
setTabsInfoProvider(provider: () => SharedTabsInfo | null): void {
|
|
58
71
|
this.tabsInfoProvider = provider;
|
|
59
72
|
}
|
|
60
73
|
|
|
@@ -357,6 +370,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
357
370
|
bucketId: this.databaseService.currentBucketId,
|
|
358
371
|
},
|
|
359
372
|
health: this.databaseService.storageHealth ?? { status: 'unknown', fallback: false },
|
|
373
|
+
tabs: this.tabsInfoProvider?.() ?? null,
|
|
360
374
|
browser: {},
|
|
361
375
|
opfs: { supported: false, entries: [], totalBytes: 0, truncated: false },
|
|
362
376
|
};
|
|
@@ -406,19 +420,42 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
406
420
|
}
|
|
407
421
|
}
|
|
408
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Request a state push. Coalesced (see {@link NOTIFY_MIN_INTERVAL_MS}): the
|
|
425
|
+
* first call after an idle period pushes straight away so the panel stays
|
|
426
|
+
* responsive, and any calls during the window collapse into ONE trailing push
|
|
427
|
+
* that serializes the state as of the flush, not as of the request. Callers
|
|
428
|
+
* stay fire-and-forget.
|
|
429
|
+
*/
|
|
409
430
|
private notifyDevTools() {
|
|
410
431
|
// No consumer attached → no getState() serialization, no postMessage broadcast.
|
|
411
432
|
if (!this.enabled) return;
|
|
412
|
-
if (typeof window
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
);
|
|
433
|
+
if (typeof window === 'undefined') return;
|
|
434
|
+
// A trailing push is already queued; it will carry this change too.
|
|
435
|
+
if (this.notifyTimer !== null) return;
|
|
436
|
+
|
|
437
|
+
const waited = Date.now() - this.lastNotifyAt;
|
|
438
|
+
if (waited >= DevToolsService.NOTIFY_MIN_INTERVAL_MS) {
|
|
439
|
+
this.flushNotify();
|
|
440
|
+
return;
|
|
421
441
|
}
|
|
442
|
+
this.notifyTimer = setTimeout(() => {
|
|
443
|
+
this.notifyTimer = null;
|
|
444
|
+
// Still gated on `enabled`: the panel may have disconnected while queued.
|
|
445
|
+
if (this.enabled) this.flushNotify();
|
|
446
|
+
}, DevToolsService.NOTIFY_MIN_INTERVAL_MS - waited);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private flushNotify() {
|
|
450
|
+
this.lastNotifyAt = Date.now();
|
|
451
|
+
window.postMessage(
|
|
452
|
+
{
|
|
453
|
+
type: 'SP00KY_STATE_CHANGED',
|
|
454
|
+
source: 'sp00ky-devtools-page',
|
|
455
|
+
state: this.getState(),
|
|
456
|
+
},
|
|
457
|
+
'*'
|
|
458
|
+
);
|
|
422
459
|
}
|
|
423
460
|
|
|
424
461
|
private serializeForDevTools(data: any, seen = new WeakSet<object>()): any {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { DevToolsService } from './index';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A state push serializes EVERY active query's full record set and postMessage
|
|
7
|
+
* clones it again, so its cost scales with the whole client dataset. It is
|
|
8
|
+
* requested per event — including one per local DB query — so without
|
|
9
|
+
* coalescing a page load's few hundred local queries turn a few MB of rows into
|
|
10
|
+
* GBs of short-lived large-object garbage and OOM the renderer. These tests pin
|
|
11
|
+
* the coalescing, not the payload.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function harness(recordCount = 500) {
|
|
15
|
+
const posted: any[] = [];
|
|
16
|
+
const listeners: ((e: any) => void)[] = [];
|
|
17
|
+
const fakeWindow: any = {
|
|
18
|
+
postMessage: (msg: any) => posted.push(msg),
|
|
19
|
+
addEventListener: (_type: string, cb: (e: any) => void) => listeners.push(cb),
|
|
20
|
+
dispatchEvent: () => true,
|
|
21
|
+
};
|
|
22
|
+
fakeWindow.self = fakeWindow;
|
|
23
|
+
vi.stubGlobal('window', fakeWindow);
|
|
24
|
+
vi.stubGlobal('CustomEvent', class {
|
|
25
|
+
type: string;
|
|
26
|
+
constructor(type: string) {
|
|
27
|
+
this.type = type;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const noop = () => {};
|
|
32
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
33
|
+
logger.child = () => logger;
|
|
34
|
+
|
|
35
|
+
const local: any = {
|
|
36
|
+
query: async () => [],
|
|
37
|
+
getConfig: () => ({ store: 'memory' }),
|
|
38
|
+
currentBucketId: 'anon',
|
|
39
|
+
storageHealth: { status: 'memory', fallback: false },
|
|
40
|
+
};
|
|
41
|
+
const remote: any = { query: async () => [] };
|
|
42
|
+
const auth: any = {
|
|
43
|
+
isAuthenticated: false,
|
|
44
|
+
currentUser: undefined,
|
|
45
|
+
eventSystem: { subscribe: noop },
|
|
46
|
+
};
|
|
47
|
+
// One query holding a lot of rows: the thing whose repeated serialization is
|
|
48
|
+
// what actually blows the heap.
|
|
49
|
+
const records = Array.from({ length: recordCount }, (_, i) => ({ id: `game:${i}`, pgn: 'x' }));
|
|
50
|
+
const dataManager: any = {
|
|
51
|
+
getActiveQueries: () => [
|
|
52
|
+
{
|
|
53
|
+
config: { id: new RecordId('_00_query', 'q1'), params: {} },
|
|
54
|
+
status: 'idle',
|
|
55
|
+
records,
|
|
56
|
+
updateCount: 1,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
phaseTimings: () => ({}),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const service = new DevToolsService(local, remote, logger, { tables: [] } as any, auth, dataManager);
|
|
63
|
+
// Announce a consumer, exactly like the extension's page-script does.
|
|
64
|
+
for (const cb of listeners) {
|
|
65
|
+
cb({ source: fakeWindow, data: { type: 'SP00KY_DEVTOOLS_CONNECT' } });
|
|
66
|
+
}
|
|
67
|
+
const statePushes = () => posted.filter((m) => m.type === 'SP00KY_STATE_CHANGED');
|
|
68
|
+
return { service, statePushes, posted };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
vi.useRealTimers();
|
|
73
|
+
vi.unstubAllGlobals();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('DevToolsService state-push coalescing', () => {
|
|
77
|
+
it('pushes once immediately on connect', () => {
|
|
78
|
+
vi.useFakeTimers();
|
|
79
|
+
const { statePushes } = harness();
|
|
80
|
+
expect(statePushes().length).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('collapses a burst of per-query events into ONE trailing push', () => {
|
|
84
|
+
vi.useFakeTimers();
|
|
85
|
+
const { service, statePushes } = harness();
|
|
86
|
+
const before = statePushes().length;
|
|
87
|
+
|
|
88
|
+
// What a page load looks like: hundreds of LOCAL_QUERY events, each of which
|
|
89
|
+
// used to serialize the entire query state.
|
|
90
|
+
for (let i = 0; i < 400; i++) {
|
|
91
|
+
(service as any).logEvent('LOCAL_QUERY', { query: 'SELECT * FROM game', vars: {} });
|
|
92
|
+
}
|
|
93
|
+
// Nothing extra yet — the burst is queued, not serialized 400 times.
|
|
94
|
+
expect(statePushes().length).toBe(before);
|
|
95
|
+
|
|
96
|
+
vi.advanceTimersByTime(300);
|
|
97
|
+
expect(statePushes().length).toBe(before + 1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('still pushes again once the window has passed', () => {
|
|
101
|
+
vi.useFakeTimers();
|
|
102
|
+
const { service, statePushes } = harness();
|
|
103
|
+
const before = statePushes().length;
|
|
104
|
+
|
|
105
|
+
(service as any).logEvent('A', {});
|
|
106
|
+
vi.advanceTimersByTime(300);
|
|
107
|
+
expect(statePushes().length).toBe(before + 1);
|
|
108
|
+
|
|
109
|
+
// An event arriving right after that flush is still inside the window, so it
|
|
110
|
+
// queues rather than pushing again...
|
|
111
|
+
(service as any).logEvent('B', {});
|
|
112
|
+
expect(statePushes().length).toBe(before + 1);
|
|
113
|
+
vi.advanceTimersByTime(300);
|
|
114
|
+
expect(statePushes().length).toBe(before + 2);
|
|
115
|
+
|
|
116
|
+
// ...but once the tab has been idle past the window, the next event pushes
|
|
117
|
+
// straight away, so the panel never waits on a quiet app.
|
|
118
|
+
vi.advanceTimersByTime(1000);
|
|
119
|
+
(service as any).logEvent('C', {});
|
|
120
|
+
expect(statePushes().length).toBe(before + 3);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('drops a queued push when the consumer disconnects mid-window', () => {
|
|
124
|
+
vi.useFakeTimers();
|
|
125
|
+
const { service, statePushes, posted } = harness();
|
|
126
|
+
const before = statePushes().length;
|
|
127
|
+
|
|
128
|
+
(service as any).logEvent('A', {});
|
|
129
|
+
(service as any).enabled = false; // panel closed while the push was queued
|
|
130
|
+
vi.advanceTimersByTime(300);
|
|
131
|
+
expect(statePushes().length).toBe(before);
|
|
132
|
+
expect(posted.some((m) => m.type === 'SP00KY_STATE_CHANGED' && m.state === undefined)).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('serializes the LATEST state at flush time, not at request time', () => {
|
|
136
|
+
vi.useFakeTimers();
|
|
137
|
+
const { service, statePushes } = harness();
|
|
138
|
+
|
|
139
|
+
(service as any).logEvent('FIRST', {});
|
|
140
|
+
(service as any).logEvent('SECOND', {});
|
|
141
|
+
vi.advanceTimersByTime(300);
|
|
142
|
+
|
|
143
|
+
const last = statePushes().at(-1);
|
|
144
|
+
const types = last.state.eventsHistory.map((e: any) => e.eventType);
|
|
145
|
+
// Both events of the coalesced window are present in the single push.
|
|
146
|
+
expect(types).toContain('FIRST');
|
|
147
|
+
expect(types).toContain('SECOND');
|
|
148
|
+
});
|
|
149
|
+
});
|