@spooky-sync/core 0.0.1-canary.204 → 0.0.1-canary.205
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 +148 -40
- package/dist/index.js +601 -173
- package/dist/tabs-broker-worker.js +51 -13
- package/dist/types.d.ts +52 -13
- package/package.json +3 -3
- package/src/modules/cache/index.ts +21 -4
- package/src/modules/devtools/index.ts +3 -1
- package/src/modules/sync/engine.ts +76 -58
- package/src/modules/sync/sync.ts +65 -8
- package/src/services/database/cache-engine.ts +33 -0
- package/src/services/database/sqlite-cache-engine.test.ts +58 -0
- package/src/services/database/sqlite-cache-engine.ts +71 -1
- package/src/services/database/surreal-cache-engine.ts +20 -0
- package/src/services/stream-processor/index.ts +471 -225
- package/src/services/stream-processor/stream-processor.batch.test.ts +3 -13
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +77 -67
- package/src/services/stream-processor/wasm-types.ts +32 -1
- package/src/services/tabs/broker.test.ts +49 -0
- package/src/services/tabs/coordinator.test.ts +6 -1
- package/src/services/tabs/coordinator.ts +35 -3
- package/src/services/tabs/tabs-broker-worker.ts +66 -12
- package/src/sp00ky.ts +54 -8
- package/src/types.ts +21 -13
|
@@ -53,7 +53,12 @@ export interface CoordinatorHooks {
|
|
|
53
53
|
exposeClientPort(clientId: string, port: MessagePort): Promise<void>;
|
|
54
54
|
removeClientPort(clientId: string): Promise<void>;
|
|
55
55
|
/** Sync layer role changes (implemented in the sync module). */
|
|
56
|
-
|
|
56
|
+
/** Synchronous leader WIRING. Must not block: the role is published and
|
|
57
|
+
* `leader-ready` is sent right after it returns. */
|
|
58
|
+
becomeSyncLeader(hub: LeaderSyncHub): void;
|
|
59
|
+
/** The blocking half of the leader handover (outbox drain, LIVE restart),
|
|
60
|
+
* run in the background once the tab is already a working leader. */
|
|
61
|
+
resumeSyncLeaderDuties(): Promise<void>;
|
|
57
62
|
becomeSyncFollower(forwarder: SyncForwarder): void;
|
|
58
63
|
becomeSyncSolo(): void;
|
|
59
64
|
/** Current storage health, for db-ready sent to late-joining followers. */
|
|
@@ -208,6 +213,10 @@ export class TabsCoordinator {
|
|
|
208
213
|
leadershipId = 0;
|
|
209
214
|
leaderTabId: TabId | null = null;
|
|
210
215
|
brokerRole: 'pending' | TabRole = 'pending';
|
|
216
|
+
/** Wall time from `become-leader` to `leader-ready` on the last promotion.
|
|
217
|
+
* Reported in the DevTools role-change event: a slow store adopt (OPFS pool
|
|
218
|
+
* contention after the previous leader died) shows up here as seconds. */
|
|
219
|
+
lastPromotionMs: number | null = null;
|
|
211
220
|
private fingerprint: string;
|
|
212
221
|
private bucketId: string;
|
|
213
222
|
private broker: TabBrokerClient;
|
|
@@ -285,7 +294,12 @@ export class TabsCoordinator {
|
|
|
285
294
|
if (this.role !== role) {
|
|
286
295
|
this.role = role;
|
|
287
296
|
this.deps.logger.info(
|
|
288
|
-
{
|
|
297
|
+
{
|
|
298
|
+
role,
|
|
299
|
+
leadershipId: this.leadershipId,
|
|
300
|
+
promotionMs: role === 'leader' ? this.lastPromotionMs : undefined,
|
|
301
|
+
Category: 'sp00ky-client::TabsCoordinator',
|
|
302
|
+
},
|
|
289
303
|
'Tab role changed'
|
|
290
304
|
);
|
|
291
305
|
for (const cb of this.roleListeners) cb(role);
|
|
@@ -379,6 +393,7 @@ export class TabsCoordinator {
|
|
|
379
393
|
if (this.closed) return;
|
|
380
394
|
if (msg.leadershipId <= this.leadershipId && !msg.resumeHeld) return;
|
|
381
395
|
const previousRole = this.role;
|
|
396
|
+
const startedAt = Date.now();
|
|
382
397
|
try {
|
|
383
398
|
if (previousRole === 'follower') this.teardownFollower('promoted');
|
|
384
399
|
this.leadershipId = msg.leadershipId;
|
|
@@ -408,7 +423,16 @@ export class TabsCoordinator {
|
|
|
408
423
|
});
|
|
409
424
|
void health;
|
|
410
425
|
this.hub = new LeaderSyncHub(msg.leadershipId, this.deps.logger);
|
|
411
|
-
|
|
426
|
+
// Owning the store IS being the leader: it unparks this tab's ops and is
|
|
427
|
+
// everything a follower's ports need. Publish the role and tell the
|
|
428
|
+
// broker BEFORE any sync work. Waiting on the outbox drain and the LIVE
|
|
429
|
+
// restart here once left a tab that owned the OPFS pool but never sent
|
|
430
|
+
// `leader-ready`, so the broker minted no follower ports and re-elected
|
|
431
|
+
// no one (`assignFollowerPorts` and `electIfNeeded` both bail on that
|
|
432
|
+
// state) and the whole namespace stayed leaderless until a reload. Those
|
|
433
|
+
// duties are best-effort anyway: the list_ref poll covers a missing LIVE.
|
|
434
|
+
this.deps.hooks.becomeSyncLeader(this.hub);
|
|
435
|
+
this.lastPromotionMs = Date.now() - startedAt;
|
|
412
436
|
this.setRole('leader');
|
|
413
437
|
this.broker.send({
|
|
414
438
|
type: 'leader-ready',
|
|
@@ -416,6 +440,14 @@ export class TabsCoordinator {
|
|
|
416
440
|
bucketId: this.bucketId,
|
|
417
441
|
leadershipId: msg.leadershipId,
|
|
418
442
|
});
|
|
443
|
+
// Off the promotion chain on purpose: a hang here must not block the
|
|
444
|
+
// next role transition (demote, bucket switch) for the life of the tab.
|
|
445
|
+
void this.deps.hooks.resumeSyncLeaderDuties().catch((e) => {
|
|
446
|
+
this.deps.logger.error(
|
|
447
|
+
{ err: e, Category: 'sp00ky-client::TabsCoordinator' },
|
|
448
|
+
'Leader duties failed after promotion'
|
|
449
|
+
);
|
|
450
|
+
});
|
|
419
451
|
} catch (e) {
|
|
420
452
|
const reason = e instanceof Error ? e.message : String(e);
|
|
421
453
|
this.deps.logger.error(
|
|
@@ -33,6 +33,13 @@ import type {
|
|
|
33
33
|
// ---- constants duplicated from protocol.ts (keep in sync) -------------------
|
|
34
34
|
const PING_INTERVAL_MS = 5000;
|
|
35
35
|
const PONG_TIMEOUT_MS = 15_000;
|
|
36
|
+
/** How long a tab has to answer `become-leader` with `leader-ready`. A tab
|
|
37
|
+
* that hangs mid-promotion sends neither `leader-ready` nor `leader-failed`,
|
|
38
|
+
* and keeps answering pings, so nothing else here would notice: the
|
|
39
|
+
* namespace would keep a leader that is not `ready`, mint no follower ports,
|
|
40
|
+
* and refuse to re-elect (`electIfNeeded` bails while `ns.leader` is set)
|
|
41
|
+
* until every tab was reloaded. Treat the silence as a failed promotion. */
|
|
42
|
+
const PROMOTION_TIMEOUT_MS = 20_000;
|
|
36
43
|
const FORCE_TAKEOVER_TIMEOUT_MS = 1000;
|
|
37
44
|
const LEADER_FAILURE_BACKOFF_MS = 1000;
|
|
38
45
|
const FAILED_CYCLES_BEFORE_MEMORY = 3;
|
|
@@ -72,6 +79,8 @@ interface Namespace {
|
|
|
72
79
|
* lock (granted = the leader tab died; locks release instantly on death,
|
|
73
80
|
* unlike the pong timeout). */
|
|
74
81
|
tabLockMonitor: AbortController | null;
|
|
82
|
+
/** Deadline for the pending `become-leader` to report `leader-ready`. */
|
|
83
|
+
promotionTimer: ReturnType<typeof setTimeout> | null;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
const brokerInstanceId =
|
|
@@ -116,6 +125,7 @@ function getNamespace(fingerprint: string, bucketId: string): Namespace {
|
|
|
116
125
|
electing: false,
|
|
117
126
|
attachRetry: new Map(),
|
|
118
127
|
tabLockMonitor: null,
|
|
128
|
+
promotionTimer: null,
|
|
119
129
|
};
|
|
120
130
|
namespaces.set(key, ns);
|
|
121
131
|
}
|
|
@@ -165,6 +175,7 @@ function clearLeader(
|
|
|
165
175
|
const leader = ns.leader;
|
|
166
176
|
if (!leader) return null;
|
|
167
177
|
ns.leader = null;
|
|
178
|
+
clearPromotionTimer(ns);
|
|
168
179
|
ns.tabLockMonitor?.abort();
|
|
169
180
|
ns.tabLockMonitor = null;
|
|
170
181
|
const tab = ns.tabs.get(leader.tabId);
|
|
@@ -188,6 +199,54 @@ function clearLeader(
|
|
|
188
199
|
return { tabId: leader.tabId, leadershipId: leader.leadershipId, workerLock };
|
|
189
200
|
}
|
|
190
201
|
|
|
202
|
+
function clearPromotionTimer(ns: Namespace): void {
|
|
203
|
+
if (ns.promotionTimer) clearTimeout(ns.promotionTimer);
|
|
204
|
+
ns.promotionTimer = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A promotion did not work out: back the tab off, clear the leadership, and
|
|
209
|
+
* re-elect. Shared by the tab's own `leader-failed` report and by the
|
|
210
|
+
* {@link PROMOTION_TIMEOUT_MS} deadline, so a tab that hangs mid-promotion
|
|
211
|
+
* costs the namespace the same 1s backoff as one that fails loudly, instead of
|
|
212
|
+
* owning it forever.
|
|
213
|
+
*
|
|
214
|
+
* Every reason counts toward `failedCycles`. Gating that on 'opfs-unavailable'
|
|
215
|
+
* left any other recurring failure (e.g. a tab lock nobody frees) looping at
|
|
216
|
+
* the backoff interval forever, with allowMemoryFallback never granted, so
|
|
217
|
+
* every tab timed out in start() and booted solo - each one then contending
|
|
218
|
+
* for the OPFS pool on its own, which is exactly what shared-tabs exists to
|
|
219
|
+
* stop.
|
|
220
|
+
*/
|
|
221
|
+
function failPromotion(
|
|
222
|
+
ns: Namespace,
|
|
223
|
+
tabId: string,
|
|
224
|
+
leadershipId: number,
|
|
225
|
+
opts: { timedOut: boolean }
|
|
226
|
+
): void {
|
|
227
|
+
if (ns.leader?.tabId !== tabId || ns.leader.leadershipId !== leadershipId) return;
|
|
228
|
+
ns.failedCycles += 1;
|
|
229
|
+
ns.failedUntil.set(tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
|
|
230
|
+
const previous = clearLeader(ns, { demote: false, removeTab: false });
|
|
231
|
+
const tab = ns.tabs.get(tabId);
|
|
232
|
+
if (tab) tab.heldLeadership = null;
|
|
233
|
+
// A tab that reported `leader-failed` has already given back its tab lock and
|
|
234
|
+
// any worker it opened, so there is nothing for the next candidate to wait
|
|
235
|
+
// on. A tab that went silent may have opened the OPFS pool and never told us,
|
|
236
|
+
// so hand the next election the lock name it would be holding: the probe in
|
|
237
|
+
// `waitForPreviousLeaderLocks` returns immediately when it is free, and
|
|
238
|
+
// steals it when it is not. `heldLeadership` is deliberately NOT used for
|
|
239
|
+
// this - it means "confirmed leader" and drives the broker-restart resume.
|
|
240
|
+
const stranded =
|
|
241
|
+
opts.timedOut && previous
|
|
242
|
+
? {
|
|
243
|
+
...previous,
|
|
244
|
+
workerLock: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${leadershipId}`,
|
|
245
|
+
}
|
|
246
|
+
: previous;
|
|
247
|
+
electIfNeeded(ns, stranded);
|
|
248
|
+
}
|
|
249
|
+
|
|
191
250
|
/**
|
|
192
251
|
* Fast crash detection: queue a Web Lock request on the leader's tab lock.
|
|
193
252
|
* Locks release the instant their holder's tab dies, so being GRANTED means
|
|
@@ -277,6 +336,11 @@ function electIfNeeded(ns: Namespace, previous: ClearedLeader | null = null): vo
|
|
|
277
336
|
allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
|
|
278
337
|
resumeHeld,
|
|
279
338
|
});
|
|
339
|
+
clearPromotionTimer(ns);
|
|
340
|
+
ns.promotionTimer = setTimeout(() => {
|
|
341
|
+
ns.promotionTimer = null;
|
|
342
|
+
failPromotion(ns, candidate.tabId, leadershipId, { timedOut: true });
|
|
343
|
+
}, PROMOTION_TIMEOUT_MS);
|
|
280
344
|
} finally {
|
|
281
345
|
ns.electing = false;
|
|
282
346
|
}
|
|
@@ -489,6 +553,7 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
|
|
|
489
553
|
}
|
|
490
554
|
ns.leader.ready = true;
|
|
491
555
|
ns.failedCycles = 0;
|
|
556
|
+
clearPromotionTimer(ns);
|
|
492
557
|
startTabLockMonitor(ns, msg.leadershipId);
|
|
493
558
|
const tab = ns.tabs.get(msg.tabId);
|
|
494
559
|
if (tab) {
|
|
@@ -511,18 +576,7 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
|
|
|
511
576
|
break;
|
|
512
577
|
}
|
|
513
578
|
case 'leader-failed': {
|
|
514
|
-
|
|
515
|
-
// Every reason counts. Gating this on 'opfs-unavailable' left any other
|
|
516
|
-
// recurring failure (e.g. a tab lock nobody frees) looping at the backoff
|
|
517
|
-
// interval forever, with allowMemoryFallback never granted, so every tab
|
|
518
|
-
// timed out in start() and booted solo — each one then contending for the
|
|
519
|
-
// OPFS pool on its own, which is exactly what shared-tabs exists to stop.
|
|
520
|
-
ns.failedCycles += 1;
|
|
521
|
-
ns.failedUntil.set(msg.tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
|
|
522
|
-
const previous = clearLeader(ns, { demote: false, removeTab: false });
|
|
523
|
-
const tab = ns.tabs.get(msg.tabId);
|
|
524
|
-
if (tab) tab.heldLeadership = null;
|
|
525
|
-
electIfNeeded(ns, previous);
|
|
579
|
+
failPromotion(ns, msg.tabId, msg.leadershipId, { timedOut: false });
|
|
526
580
|
break;
|
|
527
581
|
}
|
|
528
582
|
case 'follower-port-attached': {
|
package/src/sp00ky.ts
CHANGED
|
@@ -492,15 +492,16 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
492
492
|
this.streamProcessor = new StreamProcessorService(
|
|
493
493
|
new EventSystem(['stream_update']),
|
|
494
494
|
this.local,
|
|
495
|
-
this.persistenceClient,
|
|
496
495
|
logger
|
|
497
496
|
);
|
|
498
|
-
// Circuit snapshots are
|
|
499
|
-
//
|
|
497
|
+
// Circuit snapshots are checkpointed, never per-ingest, and on by default
|
|
498
|
+
// where the store can hold them (the OPFS SQLite engine). See
|
|
499
|
+
// `persistCircuit` / `circuitProjection` in types.ts.
|
|
500
500
|
this.streamProcessor.configureCircuitPersistence(
|
|
501
|
-
config.persistCircuit ??
|
|
501
|
+
config.persistCircuit ?? config.localEngine === 'sqlite',
|
|
502
502
|
config.circuitCheckpointMs
|
|
503
503
|
);
|
|
504
|
+
this.streamProcessor.configureProjection(config.circuitProjection ?? true);
|
|
504
505
|
this.migrator = new LocalMigrator(this.local, logger);
|
|
505
506
|
|
|
506
507
|
this.cache = new CacheModule(
|
|
@@ -560,6 +561,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
560
561
|
connectionSupervisor: this.connectionSupervisor,
|
|
561
562
|
}
|
|
562
563
|
);
|
|
564
|
+
this.sync.setPrimeGate(() => this.streamProcessor.whenPrimed());
|
|
563
565
|
|
|
564
566
|
// Initialize feature flags. Reuses the down-queue to register SSP plans
|
|
565
567
|
// on `_00_user_feature` and the auth subscription to re-register handles
|
|
@@ -661,13 +663,14 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
661
663
|
onLeaderLost: (reason) => engine.onLeaderLost(reason),
|
|
662
664
|
exposeClientPort: (clientId, port) => engine.exposeClientPort(clientId, port),
|
|
663
665
|
removeClientPort: (clientId) => engine.removeClientPort(clientId),
|
|
664
|
-
becomeSyncLeader:
|
|
666
|
+
becomeSyncLeader: (hub) => {
|
|
665
667
|
this.streamProcessor.setPersistenceEnabled(true);
|
|
666
668
|
this.cache.setIngestRelay((tuples) => hub.relayIngest(tuples));
|
|
667
669
|
this.sync.setTabContext('leader', tabId);
|
|
668
670
|
this.dataModule.setTabId(tabId);
|
|
669
|
-
|
|
671
|
+
this.sync.promoteToLeader(hub);
|
|
670
672
|
},
|
|
673
|
+
resumeSyncLeaderDuties: () => this.sync.resumeLeaderDuties(),
|
|
671
674
|
becomeSyncFollower: (forwarder) => {
|
|
672
675
|
this.streamProcessor.setPersistenceEnabled(false);
|
|
673
676
|
this.cache.setIngestRelay(null);
|
|
@@ -812,6 +815,16 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
812
815
|
// the current one instead of frozen at whatever boot saw.
|
|
813
816
|
this.tabsCoordinator.onRoleChange((role) => {
|
|
814
817
|
this.sharedActive = role !== 'solo';
|
|
818
|
+
// Surface the transition in DevTools. Reconstructing a failover from
|
|
819
|
+
// the `database.tabs` snapshot alone is guesswork after the fact; a
|
|
820
|
+
// discrete event gives `get_events` the leader handover directly.
|
|
821
|
+
this.devTools.logEvent('TABS_ROLE_CHANGED', {
|
|
822
|
+
role,
|
|
823
|
+
tabId: this.tabsCoordinator?.tabId,
|
|
824
|
+
leadershipId: this.tabsCoordinator?.leadershipId,
|
|
825
|
+
leaderTabId: this.tabsCoordinator?.leaderTabId,
|
|
826
|
+
promotionMs: this.tabsCoordinator?.lastPromotionMs,
|
|
827
|
+
});
|
|
815
828
|
});
|
|
816
829
|
try {
|
|
817
830
|
const role = await this.tabsCoordinator.start(bootBucket);
|
|
@@ -862,11 +875,14 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
862
875
|
}
|
|
863
876
|
})();
|
|
864
877
|
|
|
865
|
-
this.streamProcessor.setStateKeySuffix(bootBucket);
|
|
866
878
|
await this.streamProcessor.init();
|
|
867
879
|
// Seed table `select` permissions from the schema before any query is
|
|
868
880
|
// registered — otherwise the SSP default-denies every non-`_00_` table.
|
|
869
881
|
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
882
|
+
// Fill the circuit from the local store in the background (snapshot +
|
|
883
|
+
// reconcile, or every cached row). Not awaited: first paint reads the
|
|
884
|
+
// store directly; sync waits on `whenPrimed` before its first diff.
|
|
885
|
+
void this.primeCircuit();
|
|
870
886
|
this.logger.debug(
|
|
871
887
|
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
872
888
|
'StreamProcessor initialized'
|
|
@@ -1179,10 +1195,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1179
1195
|
'Blob cache bucket switch failed'
|
|
1180
1196
|
);
|
|
1181
1197
|
}
|
|
1182
|
-
this.streamProcessor.setStateKeySuffix(target);
|
|
1183
1198
|
await this.streamProcessor.reset();
|
|
1184
1199
|
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
1185
1200
|
this.cache.clearVersionLookups();
|
|
1201
|
+
void this.primeCircuit();
|
|
1186
1202
|
// Preload dedup is per-bucket: the `_00_preload` markers + cached rows it
|
|
1187
1203
|
// guards live in the local store we just swapped away from. Keeping the
|
|
1188
1204
|
// hashes would make `preload()` skip warming the NEW bucket (its store is
|
|
@@ -1226,6 +1242,8 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1226
1242
|
// Flush the blob manifest while the local store is still open — the flush
|
|
1227
1243
|
// writes `_00_blob` rows through it — and revoke every object URL.
|
|
1228
1244
|
await this.blobs.close().catch(() => {});
|
|
1245
|
+
// Last chance to persist the circuit while the store is still ours.
|
|
1246
|
+
await this.streamProcessor.checkpoint('close').catch(() => {});
|
|
1229
1247
|
// Leaving the broker first hands leadership to another tab (and releases
|
|
1230
1248
|
// the OPFS handles via the worker shutdown) before the store closes.
|
|
1231
1249
|
if (this.tabsCoordinator) await this.tabsCoordinator.stop();
|
|
@@ -1633,6 +1651,34 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1633
1651
|
* the former to `encodeRecordId` reads `.table` off a string and throws
|
|
1634
1652
|
* during boot.
|
|
1635
1653
|
*/
|
|
1654
|
+
/**
|
|
1655
|
+
* Prime the in-browser circuit from the local store. Builds the context the
|
|
1656
|
+
* stream processor needs: every synced table (the app schema plus the
|
|
1657
|
+
* server-written meta tables that sync down), a schema hash so a snapshot
|
|
1658
|
+
* projected under another schema is not trusted, and the ids whose local
|
|
1659
|
+
* `_00_rv` was bumped by an unsettled mutation.
|
|
1660
|
+
*/
|
|
1661
|
+
private async primeCircuit(): Promise<void> {
|
|
1662
|
+
const tables = [
|
|
1663
|
+
...this.config.schema.tables.map((t) => t.name),
|
|
1664
|
+
'_00_user_feature',
|
|
1665
|
+
'_00_app_release',
|
|
1666
|
+
];
|
|
1667
|
+
let pendingIds = new Set<string>();
|
|
1668
|
+
try {
|
|
1669
|
+
const pending = await this.dataModule.getPendingRecordIds();
|
|
1670
|
+
pendingIds = new Set([...pending.writes, ...pending.deletes]);
|
|
1671
|
+
} catch {
|
|
1672
|
+
/* no outbox yet: nothing is pending */
|
|
1673
|
+
}
|
|
1674
|
+
await this.streamProcessor.primeFromLocal({
|
|
1675
|
+
tables,
|
|
1676
|
+
schemaHash: String(hash53(this.config.schemaSurql)),
|
|
1677
|
+
pendingIds,
|
|
1678
|
+
onVersions: (_table, entries) => this.cache.primeVersions(entries),
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1636
1682
|
private sessionAuthId(): string | null {
|
|
1637
1683
|
const id = this.auth.currentUser?.id;
|
|
1638
1684
|
if (!id) return null;
|
package/src/types.ts
CHANGED
|
@@ -205,21 +205,19 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
205
205
|
*/
|
|
206
206
|
sharedTabs?: boolean;
|
|
207
207
|
/**
|
|
208
|
-
* Persist the in-browser SSP circuit
|
|
209
|
-
* reload
|
|
210
|
-
*
|
|
208
|
+
* Persist the in-browser SSP circuit's store as a snapshot in the local
|
|
209
|
+
* store, so a reload restores it and only steps in what changed since.
|
|
210
|
+
* Defaults to `true` under `localEngine: 'sqlite'` (the OPFS-backed store
|
|
211
|
+
* can hold the bytes) and `false` otherwise.
|
|
211
212
|
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* costing a full deep clone of every row of every ingested table plus a JSON
|
|
217
|
-
* encode of the result, `Circuit::save` in the Rust core, mirroring the
|
|
218
|
-
* server's rule in `ssp-node`: *never per-ingest*.
|
|
213
|
+
* Without a snapshot the circuit is primed by reading every cached row back
|
|
214
|
+
* out of the local store; either way a reload never re-downloads the working
|
|
215
|
+
* set, which is what an empty circuit used to force: every id in the
|
|
216
|
+
* server's list_ref classified as missing.
|
|
219
217
|
*
|
|
220
|
-
*
|
|
221
|
-
* ({@link circuitCheckpointMs}) and
|
|
222
|
-
*
|
|
218
|
+
* Snapshots are store-only (views are re-registered per session) and written
|
|
219
|
+
* on a checkpoint interval ({@link circuitCheckpointMs}) and when the page
|
|
220
|
+
* goes hidden, never per ingest or per query registration.
|
|
223
221
|
*/
|
|
224
222
|
persistCircuit?: boolean;
|
|
225
223
|
/**
|
|
@@ -227,6 +225,16 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
227
225
|
* 30000. Ignored when `persistCircuit` is off.
|
|
228
226
|
*/
|
|
229
227
|
circuitCheckpointMs?: number;
|
|
228
|
+
/**
|
|
229
|
+
* Keep only the fields registered queries evaluate (filter predicates, join
|
|
230
|
+
* keys, sort keys, plus `id`/`_00_rv`) per row in the in-browser circuit.
|
|
231
|
+
* Default `true`. The circuit only ever reads those fields; bodies are
|
|
232
|
+
* rendered from the local store. Measured on 7700 rows with 20 KB bodies:
|
|
233
|
+
* the wasm heap peak went from ~500 MB to ~24 MB and the snapshot from
|
|
234
|
+
* 156 MB to 1.2 MB. A query that evaluates a field earlier rows were kept
|
|
235
|
+
* without has that field merged in from the local store on registration.
|
|
236
|
+
*/
|
|
237
|
+
circuitProjection?: boolean;
|
|
230
238
|
/** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel). */
|
|
231
239
|
otelTransmit?: PinoTransmit;
|
|
232
240
|
/**
|