@spooky-sync/core 0.0.1-canary.206 → 0.0.1-canary.207
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 +54 -2
- package/dist/index.js +116 -28
- package/package.json +3 -3
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +26 -4
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/index.ts +25 -14
- package/src/modules/sync/sync.live-removal.test.ts +40 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +71 -6
- package/src/services/tabs/coordinator.test.ts +118 -2
- package/src/services/tabs/coordinator.ts +25 -0
- package/src/services/tabs/protocol.ts +17 -1
- package/src/sp00ky.ts +9 -11
package/dist/index.d.ts
CHANGED
|
@@ -537,15 +537,29 @@ declare class CacheModule implements StreamUpdateReceiver {
|
|
|
537
537
|
private versionLookups;
|
|
538
538
|
/** Shared-tabs leader: fan every committed ingest out to follower circuits.
|
|
539
539
|
* Fired AFTER the local tx (the rows are already in the shared store, so a
|
|
540
|
-
* follower only needs the circuit feed).
|
|
540
|
+
* follower only needs the circuit feed). A follower relays its own
|
|
541
|
+
* mutations to the leader the same way, see {@link setIngestRelay}. */
|
|
541
542
|
private ingestRelay;
|
|
543
|
+
/** See {@link setIngestRelay}. */
|
|
544
|
+
private relayLocalWritesOnly;
|
|
542
545
|
constructor(local: LocalStore, streamProcessor: StreamProcessorService, streamUpdateCallback: (update: StreamUpdate) => void, logger: Logger$1);
|
|
543
546
|
/**
|
|
544
547
|
* Implements StreamUpdateReceiver interface
|
|
545
548
|
* Called directly by StreamProcessor when views change
|
|
546
549
|
*/
|
|
547
550
|
onStreamUpdate(update: StreamUpdate): void;
|
|
548
|
-
|
|
551
|
+
/**
|
|
552
|
+
* Fan every committed ingest out to the other tabs. The leader relays
|
|
553
|
+
* everything (its sync fetches are the only copy the followers get). A
|
|
554
|
+
* follower relays with `localWritesOnly`: just the mutation path, which is
|
|
555
|
+
* the only thing it knows that the leader does not. Its sync-fetched
|
|
556
|
+
* batches are the leader's data coming back and must not be re-broadcast,
|
|
557
|
+
* or every follower registration would fan its whole working set to
|
|
558
|
+
* every tab.
|
|
559
|
+
*/
|
|
560
|
+
setIngestRelay(cb: ((tuples: CacheIngestTuple[]) => void) | null, opts?: {
|
|
561
|
+
localWritesOnly?: boolean;
|
|
562
|
+
}): void;
|
|
549
563
|
/**
|
|
550
564
|
* Shared-tabs follower: feed relayed tuples into THIS tab's circuit only.
|
|
551
565
|
* The rows are already in the shared store (the leader wrote them), so no
|
|
@@ -1035,6 +1049,15 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
1035
1049
|
* Rollback a failed optimistic update by restoring the previous record state
|
|
1036
1050
|
*/
|
|
1037
1051
|
rollbackUpdate(recordId: RecordId, tableName: string, beforeRecord: Record<string, unknown>): Promise<void>;
|
|
1052
|
+
/**
|
|
1053
|
+
* Force a re-materialize + notify of every active query on `tableName`.
|
|
1054
|
+
* Used after a DELETE landed in the local store (this tab's own, or one
|
|
1055
|
+
* relayed from another tab): the SSP may not emit a view update for a
|
|
1056
|
+
* DELETE ingest, and the re-materialize reads the store, which already
|
|
1057
|
+
* excludes the row. Each query is isolated so one failing re-materialize
|
|
1058
|
+
* can't stop the others.
|
|
1059
|
+
*/
|
|
1060
|
+
notifyTableQueries(tableName: string): Promise<void>;
|
|
1038
1061
|
/**
|
|
1039
1062
|
* Remove a record from all active query states and notify subscribers
|
|
1040
1063
|
*/
|
|
@@ -1085,6 +1108,14 @@ type FollowerToLeaderMessage = {
|
|
|
1085
1108
|
mutationId: string;
|
|
1086
1109
|
} | {
|
|
1087
1110
|
type: 'request-poll';
|
|
1111
|
+
}
|
|
1112
|
+
/** An optimistic write this follower committed to the SHARED store and
|
|
1113
|
+
* ingested into its own circuit. The leader ingests it (no DB write, the
|
|
1114
|
+
* row is already there) and fans it out to every OTHER follower as
|
|
1115
|
+
* `ingest-relay`, so a follower's write lands in every tab in one hop
|
|
1116
|
+
* instead of after the server round-trip. */ | {
|
|
1117
|
+
type: 'ingest';
|
|
1118
|
+
tuples: IngestTuple[];
|
|
1088
1119
|
};
|
|
1089
1120
|
type LeaderToFollowerMessage = {
|
|
1090
1121
|
type: 'db-ready';
|
|
@@ -1114,6 +1145,15 @@ type LeaderToFollowerMessage = {
|
|
|
1114
1145
|
recordId: string;
|
|
1115
1146
|
eventType: 'create' | 'update' | 'delete';
|
|
1116
1147
|
error: string;
|
|
1148
|
+
}
|
|
1149
|
+
/** The leader's drain pushed a mutation and deleted its outbox row from the
|
|
1150
|
+
* SHARED store. Every follower starts its settled-write grace so a row it
|
|
1151
|
+
* was rendering as a pending write does not blink out before its
|
|
1152
|
+
* `_00_list_ref` membership arrives. */ | {
|
|
1153
|
+
type: 'mutation-settled';
|
|
1154
|
+
mutationId: string;
|
|
1155
|
+
recordId: string;
|
|
1156
|
+
eventType: 'create' | 'update' | 'delete';
|
|
1117
1157
|
};
|
|
1118
1158
|
//#endregion
|
|
1119
1159
|
//#region src/services/tabs/coordinator.d.ts
|
|
@@ -1150,6 +1190,11 @@ declare class SyncForwarder {
|
|
|
1150
1190
|
unbind(): void;
|
|
1151
1191
|
private post;
|
|
1152
1192
|
mutationEnqueued(mutationId: string): void;
|
|
1193
|
+
/** An optimistic write this tab just ingested. Deliberately NOT queued while
|
|
1194
|
+
* detached: a new leader primes its circuit from the shared store, which
|
|
1195
|
+
* already holds the row, and replaying a stale tuple at it later would put
|
|
1196
|
+
* an older `_00_rv` in its version memo. */
|
|
1197
|
+
ingest(tuples: IngestTuple[]): void;
|
|
1153
1198
|
requestPoll(): void;
|
|
1154
1199
|
}
|
|
1155
1200
|
//#endregion
|
|
@@ -1381,6 +1426,13 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
1381
1426
|
private onMutationDropped;
|
|
1382
1427
|
/** A forwarded outbox row from a follower: load + drain it. Idempotent. */
|
|
1383
1428
|
enqueueForwardedMutation(mutationId: string): Promise<void>;
|
|
1429
|
+
/**
|
|
1430
|
+
* Tuples another tab already committed to the shared store: feed them to
|
|
1431
|
+
* THIS tab's circuit (no local write). A DELETE additionally forces a
|
|
1432
|
+
* re-materialize of the table's queries, exactly as the writing tab does
|
|
1433
|
+
* for itself, because the SSP may not emit a view update for it.
|
|
1434
|
+
*/
|
|
1435
|
+
private applyRelayedIngest;
|
|
1384
1436
|
/** A relayed `_00_list_ref` LIVE event: resolve against THIS tab's queries
|
|
1385
1437
|
* and run the exact same handling the LIVE subscription would have. */
|
|
1386
1438
|
private applyRelayedListRefChange;
|
package/dist/index.js
CHANGED
|
@@ -4531,15 +4531,7 @@ var DataModule = class DataModule {
|
|
|
4531
4531
|
Category: "sp00ky-client::DataModule::delete"
|
|
4532
4532
|
}, "SSP delete-ingest failed; relying on query re-materialize to reflect the delete");
|
|
4533
4533
|
}
|
|
4534
|
-
|
|
4535
|
-
await this.notifyQuerySynced(queryHash);
|
|
4536
|
-
} catch (err) {
|
|
4537
|
-
this.logger.error({
|
|
4538
|
-
err,
|
|
4539
|
-
queryHash,
|
|
4540
|
-
Category: "sp00ky-client::DataModule::delete"
|
|
4541
|
-
}, "notifyQuerySynced failed after delete");
|
|
4542
|
-
}
|
|
4534
|
+
await this.notifyTableQueries(tableName);
|
|
4543
4535
|
const mutationEvent = {
|
|
4544
4536
|
type: "delete",
|
|
4545
4537
|
mutation_id: mutationId,
|
|
@@ -4610,6 +4602,26 @@ var DataModule = class DataModule {
|
|
|
4610
4602
|
}
|
|
4611
4603
|
}
|
|
4612
4604
|
/**
|
|
4605
|
+
* Force a re-materialize + notify of every active query on `tableName`.
|
|
4606
|
+
* Used after a DELETE landed in the local store (this tab's own, or one
|
|
4607
|
+
* relayed from another tab): the SSP may not emit a view update for a
|
|
4608
|
+
* DELETE ingest, and the re-materialize reads the store, which already
|
|
4609
|
+
* excludes the row. Each query is isolated so one failing re-materialize
|
|
4610
|
+
* can't stop the others.
|
|
4611
|
+
*/
|
|
4612
|
+
async notifyTableQueries(tableName) {
|
|
4613
|
+
for (const [queryHash, queryState] of this.activeQueries) if (queryState.config.tableName === tableName) try {
|
|
4614
|
+
await this.notifyQuerySynced(queryHash);
|
|
4615
|
+
} catch (err) {
|
|
4616
|
+
this.logger.error({
|
|
4617
|
+
err,
|
|
4618
|
+
queryHash,
|
|
4619
|
+
tableName,
|
|
4620
|
+
Category: "sp00ky-client::DataModule::notifyTableQueries"
|
|
4621
|
+
}, "notifyQuerySynced failed after delete");
|
|
4622
|
+
}
|
|
4623
|
+
}
|
|
4624
|
+
/**
|
|
4613
4625
|
* Remove a record from all active query states and notify subscribers
|
|
4614
4626
|
*/
|
|
4615
4627
|
removeRecordFromQueries(recordId) {
|
|
@@ -6134,8 +6146,14 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6134
6146
|
switch (msg.type) {
|
|
6135
6147
|
case "sync-hello": break;
|
|
6136
6148
|
case "mutation-enqueued":
|
|
6149
|
+
this.listRefIdleStreak = 0;
|
|
6137
6150
|
this.enqueueForwardedMutation(msg.mutationId);
|
|
6138
6151
|
break;
|
|
6152
|
+
case "ingest":
|
|
6153
|
+
this.applyRelayedIngest(msg.tuples);
|
|
6154
|
+
hub.relayIngest(msg.tuples, tabId);
|
|
6155
|
+
this.listRefIdleStreak = 0;
|
|
6156
|
+
break;
|
|
6139
6157
|
case "request-poll":
|
|
6140
6158
|
this.listRefIdleStreak = 0;
|
|
6141
6159
|
break;
|
|
@@ -6180,6 +6198,12 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6180
6198
|
this.killRefLiveQuery();
|
|
6181
6199
|
forwarder.onLeaderMessage = (msg) => {
|
|
6182
6200
|
switch (msg.type) {
|
|
6201
|
+
case "ingest-relay":
|
|
6202
|
+
this.applyRelayedIngest(msg.tuples);
|
|
6203
|
+
break;
|
|
6204
|
+
case "mutation-settled":
|
|
6205
|
+
this.dataModule.noteWriteSettled(msg.recordId, msg.eventType);
|
|
6206
|
+
break;
|
|
6183
6207
|
case "list-ref-change":
|
|
6184
6208
|
this.applyRelayedListRefChange(msg).catch((err) => {
|
|
6185
6209
|
this.logger.error({
|
|
@@ -6225,6 +6249,24 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6225
6249
|
if (this.tabRole !== "leader") return;
|
|
6226
6250
|
await this.upQueue.enqueueFromDatabase(mutationId);
|
|
6227
6251
|
}
|
|
6252
|
+
/**
|
|
6253
|
+
* Tuples another tab already committed to the shared store: feed them to
|
|
6254
|
+
* THIS tab's circuit (no local write). A DELETE additionally forces a
|
|
6255
|
+
* re-materialize of the table's queries, exactly as the writing tab does
|
|
6256
|
+
* for itself, because the SSP may not emit a view update for it.
|
|
6257
|
+
*/
|
|
6258
|
+
applyRelayedIngest(tuples) {
|
|
6259
|
+
this.cache.applyRelayedIngest(tuples);
|
|
6260
|
+
const deletedTables = /* @__PURE__ */ new Set();
|
|
6261
|
+
for (const t of tuples) if (t.op === "DELETE") deletedTables.add(t.table);
|
|
6262
|
+
for (const table of deletedTables) this.dataModule.notifyTableQueries(table).catch((err) => {
|
|
6263
|
+
this.logger.warn({
|
|
6264
|
+
err,
|
|
6265
|
+
table,
|
|
6266
|
+
Category: "sp00ky-client::Sp00kySync::applyRelayedIngest"
|
|
6267
|
+
}, "Re-materialize after relayed delete failed");
|
|
6268
|
+
});
|
|
6269
|
+
}
|
|
6228
6270
|
/** A relayed `_00_list_ref` LIVE event: resolve against THIS tab's queries
|
|
6229
6271
|
* and run the exact same handling the LIVE subscription would have. */
|
|
6230
6272
|
async applyRelayedListRefChange(msg) {
|
|
@@ -6617,8 +6659,20 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6617
6659
|
}, "Live update is being processed");
|
|
6618
6660
|
const diff = createDiffFromDbOp(action, recordId, version, localArray);
|
|
6619
6661
|
const hash = extractIdPart(existing.config.id);
|
|
6620
|
-
if (existing.config.membershipKnown
|
|
6621
|
-
const
|
|
6662
|
+
if (existing.config.membershipKnown) {
|
|
6663
|
+
const membershipDiff = action === "DELETE" ? {
|
|
6664
|
+
added: [],
|
|
6665
|
+
updated: [],
|
|
6666
|
+
removed: [recordId]
|
|
6667
|
+
} : {
|
|
6668
|
+
added: [{
|
|
6669
|
+
id: recordId,
|
|
6670
|
+
version
|
|
6671
|
+
}],
|
|
6672
|
+
updated: [],
|
|
6673
|
+
removed: []
|
|
6674
|
+
};
|
|
6675
|
+
const next = applyRecordVersionDiff(existing.config.remoteArray ?? [], membershipDiff);
|
|
6622
6676
|
if (!recordVersionArraysEqual(next, existing.config.remoteArray ?? [])) await this.dataModule.updateQueryRemoteArray(hash, next);
|
|
6623
6677
|
}
|
|
6624
6678
|
await this.runSyncForQuery(hash, diff);
|
|
@@ -6737,7 +6791,14 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6737
6791
|
* vanish, and return, while every other client showed it throughout.
|
|
6738
6792
|
*/
|
|
6739
6793
|
handleMutationSettled(event) {
|
|
6740
|
-
|
|
6794
|
+
const recordId = encodeRecordId(event.record_id);
|
|
6795
|
+
this.dataModule.noteWriteSettled(recordId, event.type);
|
|
6796
|
+
this.hub?.broadcast({
|
|
6797
|
+
type: "mutation-settled",
|
|
6798
|
+
mutationId: encodeRecordId(event.mutation_id),
|
|
6799
|
+
recordId,
|
|
6800
|
+
eventType: event.type
|
|
6801
|
+
});
|
|
6741
6802
|
}
|
|
6742
6803
|
async handleRollback(event, error) {
|
|
6743
6804
|
const recordId = encodeRecordId(event.record_id);
|
|
@@ -7353,8 +7414,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7353
7414
|
|
|
7354
7415
|
//#endregion
|
|
7355
7416
|
//#region src/modules/devtools/index.ts
|
|
7356
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7357
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7417
|
+
const CORE_VERSION = "0.0.1-canary.207";
|
|
7418
|
+
const WASM_VERSION = "0.0.1-canary.207";
|
|
7358
7419
|
const SURREAL_VERSION = "3.0.3";
|
|
7359
7420
|
var DevToolsService = class DevToolsService {
|
|
7360
7421
|
eventsHistory = [];
|
|
@@ -8883,8 +8944,11 @@ var CacheModule = class {
|
|
|
8883
8944
|
versionLookups = {};
|
|
8884
8945
|
/** Shared-tabs leader: fan every committed ingest out to follower circuits.
|
|
8885
8946
|
* Fired AFTER the local tx (the rows are already in the shared store, so a
|
|
8886
|
-
* follower only needs the circuit feed).
|
|
8947
|
+
* follower only needs the circuit feed). A follower relays its own
|
|
8948
|
+
* mutations to the leader the same way, see {@link setIngestRelay}. */
|
|
8887
8949
|
ingestRelay = null;
|
|
8950
|
+
/** See {@link setIngestRelay}. */
|
|
8951
|
+
relayLocalWritesOnly = false;
|
|
8888
8952
|
constructor(local, streamProcessor, streamUpdateCallback, logger) {
|
|
8889
8953
|
this.local = local;
|
|
8890
8954
|
this.streamProcessor = streamProcessor;
|
|
@@ -8904,8 +8968,18 @@ var CacheModule = class {
|
|
|
8904
8968
|
}, "Stream update received");
|
|
8905
8969
|
this.streamUpdateCallback(update);
|
|
8906
8970
|
}
|
|
8907
|
-
|
|
8971
|
+
/**
|
|
8972
|
+
* Fan every committed ingest out to the other tabs. The leader relays
|
|
8973
|
+
* everything (its sync fetches are the only copy the followers get). A
|
|
8974
|
+
* follower relays with `localWritesOnly`: just the mutation path, which is
|
|
8975
|
+
* the only thing it knows that the leader does not. Its sync-fetched
|
|
8976
|
+
* batches are the leader's data coming back and must not be re-broadcast,
|
|
8977
|
+
* or every follower registration would fan its whole working set to
|
|
8978
|
+
* every tab.
|
|
8979
|
+
*/
|
|
8980
|
+
setIngestRelay(cb, opts = {}) {
|
|
8908
8981
|
this.ingestRelay = cb;
|
|
8982
|
+
this.relayLocalWritesOnly = opts.localWritesOnly === true;
|
|
8909
8983
|
}
|
|
8910
8984
|
/**
|
|
8911
8985
|
* Shared-tabs follower: feed relayed tuples into THIS tab's circuit only.
|
|
@@ -8996,7 +9070,7 @@ var CacheModule = class {
|
|
|
8996
9070
|
});
|
|
8997
9071
|
const ingested = this.streamProcessor.ingestMany(bulk);
|
|
8998
9072
|
for (const t of ingested) this.versionLookups[t.id] = versionOf.get(t.id) ?? 0;
|
|
8999
|
-
if (ingested.length > 0) this.ingestRelay?.(ingested);
|
|
9073
|
+
if (ingested.length > 0 && (!this.relayLocalWritesOnly || skipDbInsert)) this.ingestRelay?.(ingested);
|
|
9000
9074
|
this.logger.debug({
|
|
9001
9075
|
count: records.length,
|
|
9002
9076
|
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
@@ -9037,7 +9111,7 @@ var CacheModule = class {
|
|
|
9037
9111
|
id,
|
|
9038
9112
|
record: recordData
|
|
9039
9113
|
}]);
|
|
9040
|
-
this.ingestRelay?.([{
|
|
9114
|
+
if (!this.relayLocalWritesOnly || skipDbDelete) this.ingestRelay?.([{
|
|
9041
9115
|
table,
|
|
9042
9116
|
op: "DELETE",
|
|
9043
9117
|
id,
|
|
@@ -10617,6 +10691,19 @@ var SyncForwarder = class {
|
|
|
10617
10691
|
mutationId
|
|
10618
10692
|
});
|
|
10619
10693
|
}
|
|
10694
|
+
/** An optimistic write this tab just ingested. Deliberately NOT queued while
|
|
10695
|
+
* detached: a new leader primes its circuit from the shared store, which
|
|
10696
|
+
* already holds the row, and replaying a stale tuple at it later would put
|
|
10697
|
+
* an older `_00_rv` in its version memo. */
|
|
10698
|
+
ingest(tuples) {
|
|
10699
|
+
if (!this.port) return;
|
|
10700
|
+
try {
|
|
10701
|
+
this.port.postMessage({
|
|
10702
|
+
type: "ingest",
|
|
10703
|
+
tuples
|
|
10704
|
+
});
|
|
10705
|
+
} catch {}
|
|
10706
|
+
}
|
|
10620
10707
|
requestPoll() {
|
|
10621
10708
|
this.post({ type: "request-poll" });
|
|
10622
10709
|
}
|
|
@@ -10865,6 +10952,8 @@ var TabsCoordinator = class {
|
|
|
10865
10952
|
const forwarder = this.forwarder;
|
|
10866
10953
|
await new Promise((resolve) => {
|
|
10867
10954
|
let adopted = false;
|
|
10955
|
+
let attached = false;
|
|
10956
|
+
const pending = [];
|
|
10868
10957
|
const previousHandler = forwarder.onLeaderMessage;
|
|
10869
10958
|
forwarder.onLeaderMessage = (msg) => {
|
|
10870
10959
|
if (msg.type === "db-ready" && !adopted) {
|
|
@@ -10875,11 +10964,18 @@ var TabsCoordinator = class {
|
|
|
10875
10964
|
leadershipId: msg.leadershipId
|
|
10876
10965
|
}).then(() => {
|
|
10877
10966
|
this.deps.hooks.becomeSyncFollower(forwarder);
|
|
10967
|
+
attached = true;
|
|
10968
|
+
const backlog = pending.splice(0);
|
|
10969
|
+
for (const m of backlog) forwarder.onLeaderMessage?.(m);
|
|
10878
10970
|
this.setRole("follower");
|
|
10879
10971
|
resolve();
|
|
10880
10972
|
});
|
|
10881
10973
|
return;
|
|
10882
10974
|
}
|
|
10975
|
+
if (!attached) {
|
|
10976
|
+
pending.push(msg);
|
|
10977
|
+
return;
|
|
10978
|
+
}
|
|
10883
10979
|
previousHandler?.(msg);
|
|
10884
10980
|
};
|
|
10885
10981
|
forwarder.rebind(syncPort);
|
|
@@ -12303,7 +12399,7 @@ var Sp00kyClient = class {
|
|
|
12303
12399
|
return new TabsCoordinator({
|
|
12304
12400
|
tabId,
|
|
12305
12401
|
fingerprint: computeTabsFingerprint({
|
|
12306
|
-
coreVersion: "0.0.1-canary.
|
|
12402
|
+
coreVersion: "0.0.1-canary.207",
|
|
12307
12403
|
schemaHash: hash53(this.config.schemaSurql),
|
|
12308
12404
|
endpoint: this.config.database.endpoint ?? "",
|
|
12309
12405
|
namespace: this.config.database.namespace,
|
|
@@ -12330,18 +12426,10 @@ var Sp00kyClient = class {
|
|
|
12330
12426
|
resumeSyncLeaderDuties: () => this.sync.resumeLeaderDuties(),
|
|
12331
12427
|
becomeSyncFollower: (forwarder) => {
|
|
12332
12428
|
this.streamProcessor.setPersistenceEnabled(false);
|
|
12333
|
-
this.cache.setIngestRelay(
|
|
12429
|
+
this.cache.setIngestRelay((tuples) => forwarder.ingest(tuples), { localWritesOnly: true });
|
|
12334
12430
|
this.sync.setTabContext("follower", tabId);
|
|
12335
12431
|
this.dataModule.setTabId(tabId);
|
|
12336
12432
|
this.sync.demoteToFollower(forwarder);
|
|
12337
|
-
const inner = forwarder.onLeaderMessage;
|
|
12338
|
-
forwarder.onLeaderMessage = (msg) => {
|
|
12339
|
-
if (msg.type === "ingest-relay") {
|
|
12340
|
-
this.cache.applyRelayedIngest(msg.tuples);
|
|
12341
|
-
return;
|
|
12342
|
-
}
|
|
12343
|
-
inner?.(msg);
|
|
12344
|
-
};
|
|
12345
12433
|
},
|
|
12346
12434
|
becomeSyncSolo: () => {
|
|
12347
12435
|
this.streamProcessor.setPersistenceEnabled(true);
|
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.207",
|
|
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.207",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.207",
|
|
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,95 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { CacheModule } from './index';
|
|
4
|
+
|
|
5
|
+
// The ingest relay is how one tab's circuit learns what another tab wrote.
|
|
6
|
+
// The leader relays every ingest (its sync fetches are the only copy the
|
|
7
|
+
// followers get); a follower relays with `localWritesOnly`, just its mutation
|
|
8
|
+
// path, because its sync-fetched batches are the leader's data coming back.
|
|
9
|
+
|
|
10
|
+
function makeLogger(): any {
|
|
11
|
+
const logger: any = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {} };
|
|
12
|
+
logger.child = () => logger;
|
|
13
|
+
return logger;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setup() {
|
|
17
|
+
const local: any = { epoch: 1, execute: vi.fn(async () => {}), query: vi.fn(async () => []) };
|
|
18
|
+
const ssp: any = {
|
|
19
|
+
addReceiver: vi.fn(),
|
|
20
|
+
ingestMany: vi.fn((records: unknown[]) => records),
|
|
21
|
+
};
|
|
22
|
+
const cache = new CacheModule(local, ssp, () => {}, makeLogger());
|
|
23
|
+
const relay = vi.fn();
|
|
24
|
+
return { cache, local, ssp, relay };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rec = (id: string, version: number) => ({
|
|
28
|
+
table: 'thread',
|
|
29
|
+
op: 'CREATE' as const,
|
|
30
|
+
record: { id: new RecordId('thread', id), title: 't' },
|
|
31
|
+
version,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('CacheModule ingest relay', () => {
|
|
35
|
+
it('applyRelayedIngest updates the version memo and never re-relays', () => {
|
|
36
|
+
const { cache, ssp, relay } = setup();
|
|
37
|
+
cache.setIngestRelay(relay);
|
|
38
|
+
|
|
39
|
+
cache.applyRelayedIngest([
|
|
40
|
+
{ table: 'thread', op: 'CREATE', id: 'thread:a', record: { id: 'thread:a', _00_rv: 4 } },
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
expect(ssp.ingestMany).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(cache.lookup('thread:a')).toBe(4);
|
|
45
|
+
expect(relay).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('localWritesOnly relays the mutation path (save + delete)', async () => {
|
|
49
|
+
const { cache, local, relay } = setup();
|
|
50
|
+
cache.setIngestRelay(relay, { localWritesOnly: true });
|
|
51
|
+
|
|
52
|
+
await cache.save(rec('a', 1), true);
|
|
53
|
+
expect(local.execute).not.toHaveBeenCalled();
|
|
54
|
+
expect(relay).toHaveBeenCalledTimes(1);
|
|
55
|
+
expect(relay.mock.calls[0][0][0]).toMatchObject({ table: 'thread', op: 'CREATE', id: 'thread:a' });
|
|
56
|
+
|
|
57
|
+
const before = { id: 'thread:a', title: 't' };
|
|
58
|
+
await cache.delete('thread', 'thread:a', true, before);
|
|
59
|
+
expect(relay).toHaveBeenCalledTimes(2);
|
|
60
|
+
expect(relay.mock.calls[1][0]).toEqual([
|
|
61
|
+
{ table: 'thread', op: 'DELETE', id: 'thread:a', record: before },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('localWritesOnly stays silent for sync-fetched batches', async () => {
|
|
66
|
+
const { cache, local, relay } = setup();
|
|
67
|
+
cache.setIngestRelay(relay, { localWritesOnly: true });
|
|
68
|
+
|
|
69
|
+
await cache.saveBatch([rec('a', 1), rec('b', 1)], false);
|
|
70
|
+
expect(local.execute).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(relay).not.toHaveBeenCalled();
|
|
72
|
+
|
|
73
|
+
await cache.delete('thread', 'thread:a', false, {});
|
|
74
|
+
expect(relay).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('an unscoped relay fires for every ingest', async () => {
|
|
78
|
+
const { cache, relay } = setup();
|
|
79
|
+
cache.setIngestRelay(relay);
|
|
80
|
+
|
|
81
|
+
await cache.saveBatch([rec('a', 1)], false);
|
|
82
|
+
await cache.save(rec('b', 1), true);
|
|
83
|
+
await cache.delete('thread', 'thread:a', false, {});
|
|
84
|
+
|
|
85
|
+
expect(relay).toHaveBeenCalledTimes(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('setIngestRelay(null) stops relaying', async () => {
|
|
89
|
+
const { cache, relay } = setup();
|
|
90
|
+
cache.setIngestRelay(relay, { localWritesOnly: true });
|
|
91
|
+
cache.setIngestRelay(null);
|
|
92
|
+
await cache.save(rec('a', 1), true);
|
|
93
|
+
expect(relay).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -33,8 +33,11 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
33
33
|
private versionLookups: Record<string, number> = {};
|
|
34
34
|
/** Shared-tabs leader: fan every committed ingest out to follower circuits.
|
|
35
35
|
* Fired AFTER the local tx (the rows are already in the shared store, so a
|
|
36
|
-
* follower only needs the circuit feed).
|
|
36
|
+
* follower only needs the circuit feed). A follower relays its own
|
|
37
|
+
* mutations to the leader the same way, see {@link setIngestRelay}. */
|
|
37
38
|
private ingestRelay: ((tuples: CacheIngestTuple[]) => void) | null = null;
|
|
39
|
+
/** See {@link setIngestRelay}. */
|
|
40
|
+
private relayLocalWritesOnly = false;
|
|
38
41
|
|
|
39
42
|
constructor(
|
|
40
43
|
private local: LocalStore,
|
|
@@ -64,8 +67,21 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
64
67
|
this.streamUpdateCallback(update);
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Fan every committed ingest out to the other tabs. The leader relays
|
|
72
|
+
* everything (its sync fetches are the only copy the followers get). A
|
|
73
|
+
* follower relays with `localWritesOnly`: just the mutation path, which is
|
|
74
|
+
* the only thing it knows that the leader does not. Its sync-fetched
|
|
75
|
+
* batches are the leader's data coming back and must not be re-broadcast,
|
|
76
|
+
* or every follower registration would fan its whole working set to
|
|
77
|
+
* every tab.
|
|
78
|
+
*/
|
|
79
|
+
setIngestRelay(
|
|
80
|
+
cb: ((tuples: CacheIngestTuple[]) => void) | null,
|
|
81
|
+
opts: { localWritesOnly?: boolean } = {}
|
|
82
|
+
): void {
|
|
68
83
|
this.ingestRelay = cb;
|
|
84
|
+
this.relayLocalWritesOnly = opts.localWritesOnly === true;
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
/**
|
|
@@ -197,7 +213,11 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
197
213
|
// failed mid-batch is neither "known" here nor fanned out to followers.
|
|
198
214
|
const ingested = this.streamProcessor.ingestMany(bulk);
|
|
199
215
|
for (const t of ingested) this.versionLookups[t.id] = versionOf.get(t.id) ?? 0;
|
|
200
|
-
|
|
216
|
+
// `skipDbInsert` is exactly the mutation path (the tx already wrote the
|
|
217
|
+
// row); sync-fetched batches pass false.
|
|
218
|
+
if (ingested.length > 0 && (!this.relayLocalWritesOnly || skipDbInsert)) {
|
|
219
|
+
this.ingestRelay?.(ingested as CacheIngestTuple[]);
|
|
220
|
+
}
|
|
201
221
|
|
|
202
222
|
this.logger.debug(
|
|
203
223
|
{ count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
|
|
@@ -239,7 +259,9 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
239
259
|
// 2. Ingest deletion into DBSP (pass record data so predicates can be matched)
|
|
240
260
|
delete this.versionLookups[id];
|
|
241
261
|
this.streamProcessor.ingestMany([{ table, op: 'DELETE', id, record: recordData }]);
|
|
242
|
-
this.
|
|
262
|
+
if (!this.relayLocalWritesOnly || skipDbDelete) {
|
|
263
|
+
this.ingestRelay?.([{ table, op: 'DELETE', id, record: recordData }]);
|
|
264
|
+
}
|
|
243
265
|
|
|
244
266
|
this.logger.debug(
|
|
245
267
|
{ table, id, Category: 'sp00ky-client::CacheModule::delete' },
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { DataModule } from './index';
|
|
4
|
+
|
|
5
|
+
// A DELETE that landed in the local store (this tab's own, or one relayed
|
|
6
|
+
// from another tab) needs a forced re-materialize of the table's queries: the
|
|
7
|
+
// SSP may not emit a view update for it.
|
|
8
|
+
|
|
9
|
+
function makeLogger(): any {
|
|
10
|
+
const logger: any = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {} };
|
|
11
|
+
logger.child = () => logger;
|
|
12
|
+
return logger;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const schema = { tables: [{ name: 'comment', columns: {} }, { name: 'thread', columns: {} }] } as any;
|
|
16
|
+
|
|
17
|
+
function state(hash: string, tableName: string): any {
|
|
18
|
+
return {
|
|
19
|
+
config: { id: new RecordId('_00_query', hash), tableName, localArray: [], remoteArray: [] },
|
|
20
|
+
records: [],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('DataModule.notifyTableQueries', () => {
|
|
25
|
+
it('re-materializes only the queries on that table, isolating failures', async () => {
|
|
26
|
+
const local: any = { epoch: 1, query: vi.fn(async () => [[]]) };
|
|
27
|
+
const dm = new DataModule({ saveBatch: async () => {} } as any, local, schema, makeLogger(), 100);
|
|
28
|
+
(dm as any).activeQueries.set('c1', state('c1', 'comment'));
|
|
29
|
+
(dm as any).activeQueries.set('c2', state('c2', 'comment'));
|
|
30
|
+
(dm as any).activeQueries.set('t1', state('t1', 'thread'));
|
|
31
|
+
const notified: string[] = [];
|
|
32
|
+
dm.notifyQuerySynced = vi.fn(async (hash: string) => {
|
|
33
|
+
notified.push(hash);
|
|
34
|
+
if (hash === 'c1') throw new Error('boom');
|
|
35
|
+
}) as any;
|
|
36
|
+
|
|
37
|
+
await dm.notifyTableQueries('comment');
|
|
38
|
+
|
|
39
|
+
expect(notified.sort()).toEqual(['c1', 'c2']);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -1929,20 +1929,8 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1929
1929
|
}
|
|
1930
1930
|
|
|
1931
1931
|
// DBSP may not emit view updates for DELETE ops — manually notify all queries
|
|
1932
|
-
// that reference this table.
|
|
1933
|
-
|
|
1934
|
-
for (const [queryHash, queryState] of this.activeQueries) {
|
|
1935
|
-
if (queryState.config.tableName === tableName) {
|
|
1936
|
-
try {
|
|
1937
|
-
await this.notifyQuerySynced(queryHash);
|
|
1938
|
-
} catch (err) {
|
|
1939
|
-
this.logger.error(
|
|
1940
|
-
{ err, queryHash, Category: 'sp00ky-client::DataModule::delete' },
|
|
1941
|
-
'notifyQuerySynced failed after delete'
|
|
1942
|
-
);
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
1945
|
-
}
|
|
1932
|
+
// that reference this table.
|
|
1933
|
+
await this.notifyTableQueries(tableName);
|
|
1946
1934
|
|
|
1947
1935
|
// Emit mutation event
|
|
1948
1936
|
const mutationEvent: DeleteEvent = {
|
|
@@ -2034,6 +2022,29 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
2034
2022
|
}
|
|
2035
2023
|
}
|
|
2036
2024
|
|
|
2025
|
+
/**
|
|
2026
|
+
* Force a re-materialize + notify of every active query on `tableName`.
|
|
2027
|
+
* Used after a DELETE landed in the local store (this tab's own, or one
|
|
2028
|
+
* relayed from another tab): the SSP may not emit a view update for a
|
|
2029
|
+
* DELETE ingest, and the re-materialize reads the store, which already
|
|
2030
|
+
* excludes the row. Each query is isolated so one failing re-materialize
|
|
2031
|
+
* can't stop the others.
|
|
2032
|
+
*/
|
|
2033
|
+
async notifyTableQueries(tableName: string): Promise<void> {
|
|
2034
|
+
for (const [queryHash, queryState] of this.activeQueries) {
|
|
2035
|
+
if (queryState.config.tableName === tableName) {
|
|
2036
|
+
try {
|
|
2037
|
+
await this.notifyQuerySynced(queryHash);
|
|
2038
|
+
} catch (err) {
|
|
2039
|
+
this.logger.error(
|
|
2040
|
+
{ err, queryHash, tableName, Category: 'sp00ky-client::DataModule::notifyTableQueries' },
|
|
2041
|
+
'notifyQuerySynced failed after delete'
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2037
2048
|
/**
|
|
2038
2049
|
* Remove a record from all active query states and notify subscribers
|
|
2039
2050
|
*/
|
|
@@ -110,6 +110,46 @@ describe('LIVE list_ref removal → membership', () => {
|
|
|
110
110
|
]);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
// Every tab that ingested a write optimistically (its own, or one relayed
|
|
114
|
+
// from another tab) already holds the row at the server's version, so the
|
|
115
|
+
// fetch diff is empty. Membership still has to be recorded, or the row lives
|
|
116
|
+
// on the settled-write grace alone until the poll catches it.
|
|
117
|
+
it('adds membership for a row the circuit already holds at the server version', async () => {
|
|
118
|
+
const { queryState, updateQueryRemoteArray, sync, live } = makeSync();
|
|
119
|
+
queryState.config.localArray = [
|
|
120
|
+
['thread:a', 1],
|
|
121
|
+
['thread:b', 1],
|
|
122
|
+
['thread:c', 1],
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
await live('CREATE', 'c', 1);
|
|
126
|
+
|
|
127
|
+
expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
|
|
128
|
+
['thread:a', 1],
|
|
129
|
+
['thread:b', 1],
|
|
130
|
+
['thread:c', 1],
|
|
131
|
+
]);
|
|
132
|
+
// …without a refetch: the diff handed to the sync is empty.
|
|
133
|
+
expect((sync as any).runSyncForQuery).toHaveBeenCalledWith('h1', {
|
|
134
|
+
added: [],
|
|
135
|
+
updated: [],
|
|
136
|
+
removed: [],
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('records a bumped version on UPDATE without rewriting an unchanged list', async () => {
|
|
141
|
+
const { updateQueryRemoteArray, live } = makeSync();
|
|
142
|
+
|
|
143
|
+
await live('UPDATE', 'b', 1);
|
|
144
|
+
expect(updateQueryRemoteArray).not.toHaveBeenCalled();
|
|
145
|
+
|
|
146
|
+
await live('UPDATE', 'b', 2);
|
|
147
|
+
expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
|
|
148
|
+
['thread:a', 1],
|
|
149
|
+
['thread:b', 2],
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
113
153
|
it('leaves membership alone while it is still unknown', async () => {
|
|
114
154
|
// Nothing authoritative has arrived yet, so there is no list to amend —
|
|
115
155
|
// registration will supply the whole thing shortly.
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { Sp00kySync } from './sync';
|
|
4
|
+
import type { IngestTuple } from '../../services/tabs/protocol';
|
|
5
|
+
|
|
6
|
+
// Shared-tabs sync roles. A follower's optimistic write used to enter only
|
|
7
|
+
// its own circuit: the leader and every other follower learned of it after the
|
|
8
|
+
// server round-trip, through a LIVE event SurrealDB v3 sometimes drops. Now the
|
|
9
|
+
// follower posts the ingested tuples to the leader, which feeds its own circuit
|
|
10
|
+
// and fans them out (excluding the origin), and the leader tells every follower
|
|
11
|
+
// when a push settled so the row does not blink out of the render set.
|
|
12
|
+
|
|
13
|
+
function makeLogger(): any {
|
|
14
|
+
const logger: any = {
|
|
15
|
+
child: () => logger,
|
|
16
|
+
debug: () => {},
|
|
17
|
+
info: () => {},
|
|
18
|
+
warn: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
trace: () => {},
|
|
21
|
+
};
|
|
22
|
+
return logger;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const tuple = (op: IngestTuple['op'], id = 'thread:c'): IngestTuple => ({
|
|
26
|
+
table: 'thread',
|
|
27
|
+
op,
|
|
28
|
+
id,
|
|
29
|
+
record: { id, _00_rv: 1 },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function makeSync() {
|
|
33
|
+
const queryId = new RecordId('_00_query', 'h1');
|
|
34
|
+
const queryState: any = {
|
|
35
|
+
config: {
|
|
36
|
+
id: queryId,
|
|
37
|
+
localArray: [['thread:a', 1]],
|
|
38
|
+
remoteArray: [['thread:a', 1]],
|
|
39
|
+
membershipKnown: true,
|
|
40
|
+
membershipKey: 'stable-key',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
const updateQueryRemoteArray = vi.fn(async (_h: string, next: any) => {
|
|
44
|
+
queryState.config.remoteArray = next;
|
|
45
|
+
});
|
|
46
|
+
const dataModule: any = {
|
|
47
|
+
getQueryById: vi.fn((id: RecordId) => (String(id.id) === 'h1' ? queryState : undefined)),
|
|
48
|
+
getQueryByHash: vi.fn().mockReturnValue(queryState),
|
|
49
|
+
updateQueryRemoteArray,
|
|
50
|
+
notifyQuerySynced: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
notifyTableQueries: vi.fn().mockResolvedValue(undefined),
|
|
52
|
+
noteWriteSettled: vi.fn(),
|
|
53
|
+
getActiveQueryHashes: () => ['h1'],
|
|
54
|
+
getPendingRecordIds: async () => ({ writes: new Set(), deletes: new Set() }),
|
|
55
|
+
};
|
|
56
|
+
const cache: any = { applyRelayedIngest: vi.fn() };
|
|
57
|
+
const sync = new Sp00kySync(
|
|
58
|
+
{} as any,
|
|
59
|
+
{ query: vi.fn() } as any,
|
|
60
|
+
cache,
|
|
61
|
+
dataModule,
|
|
62
|
+
{} as any,
|
|
63
|
+
makeLogger()
|
|
64
|
+
);
|
|
65
|
+
(sync as any).runSyncForQuery = vi.fn().mockResolvedValue(undefined);
|
|
66
|
+
(sync as any).upQueue.enqueueFromDatabase = vi.fn().mockResolvedValue(undefined);
|
|
67
|
+
(sync as any).scheduler.enqueueMutation = vi.fn();
|
|
68
|
+
|
|
69
|
+
const hub: any = {
|
|
70
|
+
onFollowerMessage: null,
|
|
71
|
+
relayIngest: vi.fn(),
|
|
72
|
+
broadcast: vi.fn(),
|
|
73
|
+
sendTo: vi.fn(),
|
|
74
|
+
};
|
|
75
|
+
const forwarder: any = {
|
|
76
|
+
onLeaderMessage: null,
|
|
77
|
+
mutationEnqueued: vi.fn(),
|
|
78
|
+
ingest: vi.fn(),
|
|
79
|
+
};
|
|
80
|
+
return { sync, cache, dataModule, hub, forwarder, queryState, updateQueryRemoteArray };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('shared-tabs leader', () => {
|
|
84
|
+
beforeEach(() => vi.clearAllMocks());
|
|
85
|
+
|
|
86
|
+
it('applies a follower ingest to its own circuit and relays it to the other followers', () => {
|
|
87
|
+
const { sync, cache, hub } = makeSync();
|
|
88
|
+
sync.promoteToLeader(hub);
|
|
89
|
+
const tuples = [tuple('CREATE')];
|
|
90
|
+
|
|
91
|
+
hub.onFollowerMessage('tab-2', { type: 'ingest', tuples });
|
|
92
|
+
|
|
93
|
+
expect(cache.applyRelayedIngest).toHaveBeenCalledWith(tuples);
|
|
94
|
+
expect(hub.relayIngest).toHaveBeenCalledWith(tuples, 'tab-2');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('re-materializes the table queries for a relayed DELETE', async () => {
|
|
98
|
+
const { sync, dataModule, hub } = makeSync();
|
|
99
|
+
sync.promoteToLeader(hub);
|
|
100
|
+
|
|
101
|
+
hub.onFollowerMessage('tab-2', { type: 'ingest', tuples: [tuple('DELETE')] });
|
|
102
|
+
await Promise.resolve();
|
|
103
|
+
|
|
104
|
+
expect(dataModule.notifyTableQueries).toHaveBeenCalledWith('thread');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('does not re-materialize for a relayed CREATE/UPDATE (the stream update covers it)', async () => {
|
|
108
|
+
const { sync, dataModule, hub } = makeSync();
|
|
109
|
+
sync.promoteToLeader(hub);
|
|
110
|
+
|
|
111
|
+
hub.onFollowerMessage('tab-2', { type: 'ingest', tuples: [tuple('UPDATE')] });
|
|
112
|
+
await Promise.resolve();
|
|
113
|
+
|
|
114
|
+
expect(dataModule.notifyTableQueries).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('treats a follower write as activity for the poll backoff', () => {
|
|
118
|
+
const { sync, hub } = makeSync();
|
|
119
|
+
sync.promoteToLeader(hub);
|
|
120
|
+
|
|
121
|
+
(sync as any).listRefIdleStreak = 7;
|
|
122
|
+
hub.onFollowerMessage('tab-2', { type: 'ingest', tuples: [tuple('CREATE')] });
|
|
123
|
+
expect((sync as any).listRefIdleStreak).toBe(0);
|
|
124
|
+
|
|
125
|
+
(sync as any).listRefIdleStreak = 7;
|
|
126
|
+
hub.onFollowerMessage('tab-2', {
|
|
127
|
+
type: 'mutation-enqueued',
|
|
128
|
+
mutationId: '_00_pending_mutations:1_0001_tab-2',
|
|
129
|
+
});
|
|
130
|
+
expect((sync as any).listRefIdleStreak).toBe(0);
|
|
131
|
+
expect((sync as any).upQueue.enqueueFromDatabase).toHaveBeenCalledWith(
|
|
132
|
+
'_00_pending_mutations:1_0001_tab-2'
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('notes a settled mutation locally and broadcasts it to every follower', () => {
|
|
137
|
+
const { sync, dataModule, hub } = makeSync();
|
|
138
|
+
sync.promoteToLeader(hub);
|
|
139
|
+
|
|
140
|
+
(sync as any).handleMutationSettled({
|
|
141
|
+
type: 'create',
|
|
142
|
+
mutation_id: new RecordId('_00_pending_mutations', '1_0001_tab-2'),
|
|
143
|
+
record_id: new RecordId('thread', 'c'),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(dataModule.noteWriteSettled).toHaveBeenCalledWith('thread:c', 'create');
|
|
147
|
+
expect(hub.broadcast).toHaveBeenCalledWith({
|
|
148
|
+
type: 'mutation-settled',
|
|
149
|
+
mutationId: '_00_pending_mutations:1_0001_tab-2',
|
|
150
|
+
recordId: 'thread:c',
|
|
151
|
+
eventType: 'create',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('solo: a settled mutation is noted locally with no hub to broadcast on', () => {
|
|
156
|
+
const { sync, dataModule } = makeSync();
|
|
157
|
+
(sync as any).handleMutationSettled({
|
|
158
|
+
type: 'update',
|
|
159
|
+
mutation_id: new RecordId('_00_pending_mutations', '1_0001_solo'),
|
|
160
|
+
record_id: new RecordId('thread', 'c'),
|
|
161
|
+
});
|
|
162
|
+
expect(dataModule.noteWriteSettled).toHaveBeenCalledWith('thread:c', 'update');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('shared-tabs follower', () => {
|
|
167
|
+
beforeEach(() => vi.clearAllMocks());
|
|
168
|
+
|
|
169
|
+
it('starts the local settled-write grace on mutation-settled', () => {
|
|
170
|
+
const { sync, dataModule, forwarder } = makeSync();
|
|
171
|
+
sync.demoteToFollower(forwarder);
|
|
172
|
+
|
|
173
|
+
forwarder.onLeaderMessage({
|
|
174
|
+
type: 'mutation-settled',
|
|
175
|
+
mutationId: '_00_pending_mutations:1_0001_tab-1',
|
|
176
|
+
recordId: 'thread:c',
|
|
177
|
+
eventType: 'delete',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(dataModule.noteWriteSettled).toHaveBeenCalledWith('thread:c', 'delete');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('feeds a relayed ingest to the local circuit', async () => {
|
|
184
|
+
const { sync, cache, dataModule, forwarder } = makeSync();
|
|
185
|
+
sync.demoteToFollower(forwarder);
|
|
186
|
+
const tuples = [tuple('DELETE')];
|
|
187
|
+
|
|
188
|
+
forwarder.onLeaderMessage({ type: 'ingest-relay', tuples, leadershipId: 1, seq: 1 });
|
|
189
|
+
await Promise.resolve();
|
|
190
|
+
|
|
191
|
+
expect(cache.applyRelayedIngest).toHaveBeenCalledWith(tuples);
|
|
192
|
+
expect(dataModule.notifyTableQueries).toHaveBeenCalledWith('thread');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('applies a relayed list_ref change for one of its own queries', async () => {
|
|
196
|
+
const { sync, forwarder, updateQueryRemoteArray } = makeSync();
|
|
197
|
+
sync.demoteToFollower(forwarder);
|
|
198
|
+
|
|
199
|
+
forwarder.onLeaderMessage({
|
|
200
|
+
type: 'list-ref-change',
|
|
201
|
+
action: 'CREATE',
|
|
202
|
+
queryId: '_00_query:h1',
|
|
203
|
+
recordId: 'thread:c',
|
|
204
|
+
version: 1,
|
|
205
|
+
parent: false,
|
|
206
|
+
});
|
|
207
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
208
|
+
|
|
209
|
+
expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
|
|
210
|
+
['thread:a', 1],
|
|
211
|
+
['thread:c', 1],
|
|
212
|
+
]);
|
|
213
|
+
expect((sync as any).runSyncForQuery).toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('ignores a relayed list_ref change for another tab query', async () => {
|
|
217
|
+
const { sync, forwarder, updateQueryRemoteArray } = makeSync();
|
|
218
|
+
sync.demoteToFollower(forwarder);
|
|
219
|
+
|
|
220
|
+
forwarder.onLeaderMessage({
|
|
221
|
+
type: 'list-ref-change',
|
|
222
|
+
action: 'CREATE',
|
|
223
|
+
queryId: '_00_query:foreign',
|
|
224
|
+
recordId: 'thread:c',
|
|
225
|
+
version: 1,
|
|
226
|
+
parent: false,
|
|
227
|
+
});
|
|
228
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
229
|
+
|
|
230
|
+
expect(updateQueryRemoteArray).not.toHaveBeenCalled();
|
|
231
|
+
expect((sync as any).runSyncForQuery).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('forwards mutation ids to the leader instead of queueing locally', async () => {
|
|
235
|
+
const { sync, forwarder } = makeSync();
|
|
236
|
+
sync.demoteToFollower(forwarder);
|
|
237
|
+
|
|
238
|
+
await sync.enqueueMutation([
|
|
239
|
+
{
|
|
240
|
+
type: 'create',
|
|
241
|
+
mutation_id: new RecordId('_00_pending_mutations', '1_0001_tab-2'),
|
|
242
|
+
record_id: new RecordId('thread', 'c'),
|
|
243
|
+
} as any,
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
expect(forwarder.mutationEnqueued).toHaveBeenCalledWith('_00_pending_mutations:1_0001_tab-2');
|
|
247
|
+
expect((sync as any).scheduler.enqueueMutation).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
});
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
|
|
44
44
|
import { mutationOwnerTabId } from '../data/mutation-id';
|
|
45
45
|
import type { LeaderSyncHub, SyncForwarder } from '../../services/tabs/coordinator';
|
|
46
|
+
import type { IngestTuple } from '../../services/tabs/protocol';
|
|
46
47
|
import { parseRecordIdString } from '../../utils/index';
|
|
47
48
|
|
|
48
49
|
/**
|
|
@@ -562,13 +563,24 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
562
563
|
this.forwarder = null;
|
|
563
564
|
this.leaderDutiesInFlight = null;
|
|
564
565
|
hub.onFollowerMessage = (tabId, msg) => {
|
|
565
|
-
void tabId;
|
|
566
566
|
switch (msg.type) {
|
|
567
567
|
case 'sync-hello':
|
|
568
568
|
break;
|
|
569
569
|
case 'mutation-enqueued':
|
|
570
|
+
// A write is activity: snap the poll back to its base cadence so the
|
|
571
|
+
// membership for it lands fast even if LIVE drops the event.
|
|
572
|
+
this.listRefIdleStreak = 0;
|
|
570
573
|
void this.enqueueForwardedMutation(msg.mutationId);
|
|
571
574
|
break;
|
|
575
|
+
case 'ingest':
|
|
576
|
+
// A follower's optimistic write. The row is already in the shared
|
|
577
|
+
// store; feed this tab's circuit and fan it out to every OTHER
|
|
578
|
+
// follower, so the write shows up everywhere in one hop instead of
|
|
579
|
+
// after the server round-trip (which also depends on LIVE delivery).
|
|
580
|
+
this.applyRelayedIngest(msg.tuples);
|
|
581
|
+
hub.relayIngest(msg.tuples, tabId);
|
|
582
|
+
this.listRefIdleStreak = 0;
|
|
583
|
+
break;
|
|
572
584
|
case 'request-poll':
|
|
573
585
|
this.listRefIdleStreak = 0;
|
|
574
586
|
break;
|
|
@@ -617,6 +629,17 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
617
629
|
void this.killRefLiveQuery();
|
|
618
630
|
forwarder.onLeaderMessage = (msg) => {
|
|
619
631
|
switch (msg.type) {
|
|
632
|
+
case 'ingest-relay':
|
|
633
|
+
this.applyRelayedIngest(msg.tuples);
|
|
634
|
+
break;
|
|
635
|
+
case 'mutation-settled':
|
|
636
|
+
// The leader pushed a write and deleted its outbox row from the
|
|
637
|
+
// shared store. Without this the row would leave this tab's render
|
|
638
|
+
// set (it is in neither membership nor pending writes) until the
|
|
639
|
+
// relayed `_00_list_ref` event lands: the blink the leader itself
|
|
640
|
+
// is already protected from by `handleMutationSettled`.
|
|
641
|
+
this.dataModule.noteWriteSettled(msg.recordId, msg.eventType);
|
|
642
|
+
break;
|
|
620
643
|
case 'list-ref-change':
|
|
621
644
|
void this.applyRelayedListRefChange(msg).catch((err) => {
|
|
622
645
|
this.logger.error(
|
|
@@ -633,8 +656,8 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
633
656
|
});
|
|
634
657
|
break;
|
|
635
658
|
default:
|
|
636
|
-
// db-ready
|
|
637
|
-
//
|
|
659
|
+
// db-ready is consumed by the coordinator's attach handshake before
|
|
660
|
+
// the sync handler is installed.
|
|
638
661
|
break;
|
|
639
662
|
}
|
|
640
663
|
};
|
|
@@ -673,6 +696,26 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
673
696
|
await this.upQueue.enqueueFromDatabase(mutationId);
|
|
674
697
|
}
|
|
675
698
|
|
|
699
|
+
/**
|
|
700
|
+
* Tuples another tab already committed to the shared store: feed them to
|
|
701
|
+
* THIS tab's circuit (no local write). A DELETE additionally forces a
|
|
702
|
+
* re-materialize of the table's queries, exactly as the writing tab does
|
|
703
|
+
* for itself, because the SSP may not emit a view update for it.
|
|
704
|
+
*/
|
|
705
|
+
private applyRelayedIngest(tuples: IngestTuple[]): void {
|
|
706
|
+
this.cache.applyRelayedIngest(tuples);
|
|
707
|
+
const deletedTables = new Set<string>();
|
|
708
|
+
for (const t of tuples) if (t.op === 'DELETE') deletedTables.add(t.table);
|
|
709
|
+
for (const table of deletedTables) {
|
|
710
|
+
void this.dataModule.notifyTableQueries(table).catch((err) => {
|
|
711
|
+
this.logger.warn(
|
|
712
|
+
{ err, table, Category: 'sp00ky-client::Sp00kySync::applyRelayedIngest' },
|
|
713
|
+
'Re-materialize after relayed delete failed'
|
|
714
|
+
);
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
676
719
|
/** A relayed `_00_list_ref` LIVE event: resolve against THIS tab's queries
|
|
677
720
|
* and run the exact same handling the LIVE subscription would have. */
|
|
678
721
|
private async applyRelayedListRefChange(msg: {
|
|
@@ -1282,8 +1325,19 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1282
1325
|
// until the next poll tick, which is up to 5s of showing a deleted row. This
|
|
1283
1326
|
// also persists the durable `_00_window` mirror, so the removal survives a
|
|
1284
1327
|
// reload with no network.
|
|
1285
|
-
|
|
1286
|
-
|
|
1328
|
+
//
|
|
1329
|
+
// Derived from the raw action, NOT from `diff`: `createDiffFromDbOp` is
|
|
1330
|
+
// empty when the circuit already holds the row at this version, which is
|
|
1331
|
+
// every tab that ingested the write optimistically (its own, or one
|
|
1332
|
+
// relayed from another tab). The fetch is rightly skipped then, but the
|
|
1333
|
+
// membership still has to be recorded, or the row lives on the
|
|
1334
|
+
// settled-write grace alone until the poll catches it.
|
|
1335
|
+
if (existing.config.membershipKnown) {
|
|
1336
|
+
const membershipDiff: RecordVersionDiff =
|
|
1337
|
+
action === 'DELETE'
|
|
1338
|
+
? { added: [], updated: [], removed: [recordId] }
|
|
1339
|
+
: { added: [{ id: recordId, version }], updated: [], removed: [] };
|
|
1340
|
+
const next = applyRecordVersionDiff(existing.config.remoteArray ?? [], membershipDiff);
|
|
1287
1341
|
if (!recordVersionArraysEqual(next, existing.config.remoteArray ?? [])) {
|
|
1288
1342
|
await this.dataModule.updateQueryRemoteArray(hash, next);
|
|
1289
1343
|
}
|
|
@@ -1437,7 +1491,18 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1437
1491
|
* vanish, and return, while every other client showed it throughout.
|
|
1438
1492
|
*/
|
|
1439
1493
|
private handleMutationSettled(event: UpEvent): void {
|
|
1440
|
-
|
|
1494
|
+
const recordId = encodeRecordId(event.record_id);
|
|
1495
|
+
this.dataModule.noteWriteSettled(recordId, event.type);
|
|
1496
|
+
// Shared-tabs: the outbox row just left the SHARED store, so every
|
|
1497
|
+
// follower rendering the row as a pending write has the same gap. All of
|
|
1498
|
+
// them, not just the owner: any tab whose query matched the row was
|
|
1499
|
+
// showing it through `pendingWrites`.
|
|
1500
|
+
this.hub?.broadcast({
|
|
1501
|
+
type: 'mutation-settled',
|
|
1502
|
+
mutationId: encodeRecordId(event.mutation_id),
|
|
1503
|
+
recordId,
|
|
1504
|
+
eventType: event.type,
|
|
1505
|
+
});
|
|
1441
1506
|
}
|
|
1442
1507
|
|
|
1443
1508
|
private async handleRollback(event: UpEvent, error: Error): Promise<void> {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { handleConnect, __resetBrokerForTests } from './tabs-broker-worker';
|
|
3
|
-
import { installBrokerGlobals, installFakeLocks } from './fake-ports.fixture';
|
|
4
|
-
import {
|
|
3
|
+
import { fakeChannel, flush, installBrokerGlobals, installFakeLocks } from './fake-ports.fixture';
|
|
4
|
+
import {
|
|
5
|
+
TabsCoordinator,
|
|
6
|
+
SyncForwarder,
|
|
7
|
+
type CoordinatorHooks,
|
|
8
|
+
type LeaderSyncHub,
|
|
9
|
+
} from './coordinator';
|
|
5
10
|
import type { StorageHealth } from '../../types';
|
|
6
11
|
import type { LeaderToFollowerMessage } from './protocol';
|
|
7
12
|
|
|
@@ -156,6 +161,117 @@ describe('TabsCoordinator integration', () => {
|
|
|
156
161
|
await a.coordinator.stop();
|
|
157
162
|
});
|
|
158
163
|
|
|
164
|
+
// A follower's optimistic write must reach the leader (which ingests it and
|
|
165
|
+
// fans it out) in one hop. Before this, only the mutation id was forwarded
|
|
166
|
+
// and every other tab waited on the server round-trip + LIVE delivery.
|
|
167
|
+
it('forwards a follower ingest to the leader hub tagged with the origin tab', async () => {
|
|
168
|
+
const a = makeCoordinator('tab-1');
|
|
169
|
+
await a.coordinator.start('anon');
|
|
170
|
+
const b = makeCoordinator('tab-2');
|
|
171
|
+
await b.coordinator.start('anon');
|
|
172
|
+
|
|
173
|
+
const seen: { tabId: string; msg: unknown }[] = [];
|
|
174
|
+
a.log.hub!.onFollowerMessage = (tabId, msg) => seen.push({ tabId, msg });
|
|
175
|
+
const tuples = [{ table: 'game', op: 'CREATE' as const, id: 'game:9', record: { id: 'game:9' } }];
|
|
176
|
+
b.log.forwarder!.ingest(tuples);
|
|
177
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
178
|
+
|
|
179
|
+
const ingest = seen.filter((s) => (s.msg as { type: string }).type === 'ingest');
|
|
180
|
+
expect(ingest).toHaveLength(1);
|
|
181
|
+
expect(ingest[0].tabId).toBe('tab-2');
|
|
182
|
+
expect((ingest[0].msg as { tuples: unknown }).tuples).toEqual(tuples);
|
|
183
|
+
await b.coordinator.stop();
|
|
184
|
+
await a.coordinator.stop();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('fan-out with exceptTabId skips the origin follower and reaches the others', async () => {
|
|
188
|
+
const a = makeCoordinator('tab-1');
|
|
189
|
+
await a.coordinator.start('anon');
|
|
190
|
+
const b = makeCoordinator('tab-2');
|
|
191
|
+
await b.coordinator.start('anon');
|
|
192
|
+
const c = makeCoordinator('tab-3');
|
|
193
|
+
await c.coordinator.start('anon');
|
|
194
|
+
|
|
195
|
+
const seenB: LeaderToFollowerMessage[] = [];
|
|
196
|
+
const seenC: LeaderToFollowerMessage[] = [];
|
|
197
|
+
b.log.forwarder!.onLeaderMessage = (msg) => seenB.push(msg);
|
|
198
|
+
c.log.forwarder!.onLeaderMessage = (msg) => seenC.push(msg);
|
|
199
|
+
a.log.hub!.relayIngest(
|
|
200
|
+
[{ table: 'game', op: 'CREATE', id: 'game:9', record: { id: 'game:9' } }],
|
|
201
|
+
'tab-2'
|
|
202
|
+
);
|
|
203
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
204
|
+
|
|
205
|
+
expect(seenB.filter((m) => m.type === 'ingest-relay')).toHaveLength(0);
|
|
206
|
+
expect(seenC.filter((m) => m.type === 'ingest-relay')).toHaveLength(1);
|
|
207
|
+
await c.coordinator.stop();
|
|
208
|
+
await b.coordinator.stop();
|
|
209
|
+
await a.coordinator.stop();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Unlike a mutation notify, an ingest is NOT queued across a leaderless
|
|
213
|
+
// window: the next leader primes its circuit from the shared store, which
|
|
214
|
+
// already holds the row, and a stale replay would put an older `_00_rv` in
|
|
215
|
+
// its version memo.
|
|
216
|
+
it('drops an ingest while detached instead of queueing it', async () => {
|
|
217
|
+
const forwarder = new SyncForwarder('tab-x');
|
|
218
|
+
forwarder.ingest([{ table: 'game', op: 'CREATE', id: 'game:1', record: { id: 'game:1' } }]);
|
|
219
|
+
|
|
220
|
+
const { port1, port2 } = fakeChannel();
|
|
221
|
+
const seen: { type: string }[] = [];
|
|
222
|
+
port2.onmessage = (ev) => {
|
|
223
|
+
seen.push(ev.data as { type: string });
|
|
224
|
+
};
|
|
225
|
+
forwarder.rebind(port1 as unknown as MessagePort);
|
|
226
|
+
await flush();
|
|
227
|
+
|
|
228
|
+
expect(seen.map((m) => m.type)).toEqual(['sync-hello']);
|
|
229
|
+
forwarder.unbind();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Relay traffic that lands between `db-ready` and the sync hooks being
|
|
233
|
+
// installed (the store adopt is async) used to hit a null handler on a
|
|
234
|
+
// first attach and vanish.
|
|
235
|
+
it('buffers leader messages that land before the follower hooks are installed', async () => {
|
|
236
|
+
const a = makeCoordinator('tab-1');
|
|
237
|
+
await a.coordinator.start('anon');
|
|
238
|
+
|
|
239
|
+
let adoptStartedResolve!: () => void;
|
|
240
|
+
const adoptStartedP = new Promise<void>((r) => {
|
|
241
|
+
adoptStartedResolve = r;
|
|
242
|
+
});
|
|
243
|
+
let releaseAdopt!: () => void;
|
|
244
|
+
const adoptGate = new Promise<void>((r) => {
|
|
245
|
+
releaseAdopt = r;
|
|
246
|
+
});
|
|
247
|
+
const seen: LeaderToFollowerMessage[] = [];
|
|
248
|
+
const b = makeCoordinator('tab-2', {
|
|
249
|
+
async adoptAttached() {
|
|
250
|
+
adoptStartedResolve();
|
|
251
|
+
await adoptGate;
|
|
252
|
+
},
|
|
253
|
+
becomeSyncFollower(forwarder) {
|
|
254
|
+
forwarder.onLeaderMessage = (msg) => seen.push(msg);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
const started = b.coordinator.start('anon');
|
|
258
|
+
await adoptStartedP;
|
|
259
|
+
a.log.hub!.broadcast({
|
|
260
|
+
type: 'mutation-settled',
|
|
261
|
+
mutationId: '_00_pending_mutations:1_0001_tab-1',
|
|
262
|
+
recordId: 'game:1',
|
|
263
|
+
eventType: 'create',
|
|
264
|
+
});
|
|
265
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
266
|
+
expect(seen).toHaveLength(0);
|
|
267
|
+
|
|
268
|
+
releaseAdopt();
|
|
269
|
+
await started;
|
|
270
|
+
expect(seen.map((m) => m.type)).toEqual(['mutation-settled']);
|
|
271
|
+
await b.coordinator.stop();
|
|
272
|
+
await a.coordinator.stop();
|
|
273
|
+
});
|
|
274
|
+
|
|
159
275
|
it('promotes the follower when the leader stops (failover)', async () => {
|
|
160
276
|
const a = makeCoordinator('tab-1');
|
|
161
277
|
await a.coordinator.start('anon');
|
|
@@ -201,6 +201,18 @@ export class SyncForwarder {
|
|
|
201
201
|
mutationEnqueued(mutationId: string): void {
|
|
202
202
|
this.post({ type: 'mutation-enqueued', mutationId });
|
|
203
203
|
}
|
|
204
|
+
/** An optimistic write this tab just ingested. Deliberately NOT queued while
|
|
205
|
+
* detached: a new leader primes its circuit from the shared store, which
|
|
206
|
+
* already holds the row, and replaying a stale tuple at it later would put
|
|
207
|
+
* an older `_00_rv` in its version memo. */
|
|
208
|
+
ingest(tuples: IngestTuple[]): void {
|
|
209
|
+
if (!this.port) return;
|
|
210
|
+
try {
|
|
211
|
+
this.port.postMessage({ type: 'ingest', tuples });
|
|
212
|
+
} catch {
|
|
213
|
+
/* dead port; broker re-mints */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
204
216
|
requestPoll(): void {
|
|
205
217
|
this.post({ type: 'request-poll' });
|
|
206
218
|
}
|
|
@@ -545,6 +557,12 @@ export class TabsCoordinator {
|
|
|
545
557
|
const forwarder = this.forwarder;
|
|
546
558
|
await new Promise<void>((resolve) => {
|
|
547
559
|
let adopted = false;
|
|
560
|
+
let attached = false;
|
|
561
|
+
// Relay traffic that lands between `db-ready` and the sync hooks being
|
|
562
|
+
// installed (the store adopt is async). Held and replayed, not dropped:
|
|
563
|
+
// on a first attach there is no previous handler, so an ingest-relay or
|
|
564
|
+
// settled notice in that window used to vanish.
|
|
565
|
+
const pending: LeaderToFollowerMessage[] = [];
|
|
548
566
|
const previousHandler = forwarder.onLeaderMessage;
|
|
549
567
|
forwarder.onLeaderMessage = (msg) => {
|
|
550
568
|
if (msg.type === 'db-ready' && !adopted) {
|
|
@@ -557,11 +575,18 @@ export class TabsCoordinator {
|
|
|
557
575
|
})
|
|
558
576
|
.then(() => {
|
|
559
577
|
this.deps.hooks.becomeSyncFollower(forwarder);
|
|
578
|
+
attached = true;
|
|
579
|
+
const backlog = pending.splice(0);
|
|
580
|
+
for (const m of backlog) forwarder.onLeaderMessage?.(m);
|
|
560
581
|
this.setRole('follower');
|
|
561
582
|
resolve();
|
|
562
583
|
});
|
|
563
584
|
return;
|
|
564
585
|
}
|
|
586
|
+
if (!attached) {
|
|
587
|
+
pending.push(msg);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
565
590
|
previousHandler?.(msg);
|
|
566
591
|
};
|
|
567
592
|
forwarder.rebind(syncPort);
|
|
@@ -215,7 +215,13 @@ export type FollowerToLeaderMessage =
|
|
|
215
215
|
* leader should drain it. Idempotent; a new leader's loadFromDatabase is
|
|
216
216
|
* the backstop for a notify lost in a failover window. */
|
|
217
217
|
| { type: 'mutation-enqueued'; mutationId: string }
|
|
218
|
-
| { type: 'request-poll' }
|
|
218
|
+
| { type: 'request-poll' }
|
|
219
|
+
/** An optimistic write this follower committed to the SHARED store and
|
|
220
|
+
* ingested into its own circuit. The leader ingests it (no DB write, the
|
|
221
|
+
* row is already there) and fans it out to every OTHER follower as
|
|
222
|
+
* `ingest-relay`, so a follower's write lands in every tab in one hop
|
|
223
|
+
* instead of after the server round-trip. */
|
|
224
|
+
| { type: 'ingest'; tuples: IngestTuple[] };
|
|
219
225
|
|
|
220
226
|
export type LeaderToFollowerMessage =
|
|
221
227
|
| { type: 'db-ready'; leadershipId: number; bucketId: string; storageHealth: StorageHealth }
|
|
@@ -239,4 +245,14 @@ export type LeaderToFollowerMessage =
|
|
|
239
245
|
recordId: string;
|
|
240
246
|
eventType: 'create' | 'update' | 'delete';
|
|
241
247
|
error: string;
|
|
248
|
+
}
|
|
249
|
+
/** The leader's drain pushed a mutation and deleted its outbox row from the
|
|
250
|
+
* SHARED store. Every follower starts its settled-write grace so a row it
|
|
251
|
+
* was rendering as a pending write does not blink out before its
|
|
252
|
+
* `_00_list_ref` membership arrives. */
|
|
253
|
+
| {
|
|
254
|
+
type: 'mutation-settled';
|
|
255
|
+
mutationId: string;
|
|
256
|
+
recordId: string;
|
|
257
|
+
eventType: 'create' | 'update' | 'delete';
|
|
242
258
|
};
|
package/src/sp00ky.ts
CHANGED
|
@@ -673,20 +673,18 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
673
673
|
resumeSyncLeaderDuties: () => this.sync.resumeLeaderDuties(),
|
|
674
674
|
becomeSyncFollower: (forwarder) => {
|
|
675
675
|
this.streamProcessor.setPersistenceEnabled(false);
|
|
676
|
-
|
|
676
|
+
// A follower's own mutations go to the leader, which ingests them and
|
|
677
|
+
// fans them out to the other followers: one hop, no server round-trip.
|
|
678
|
+
// Only the mutation path relays; this tab's sync fetches are the
|
|
679
|
+
// leader's data coming back and must not be re-broadcast.
|
|
680
|
+
this.cache.setIngestRelay((tuples) => forwarder.ingest(tuples), {
|
|
681
|
+
localWritesOnly: true,
|
|
682
|
+
});
|
|
677
683
|
this.sync.setTabContext('follower', tabId);
|
|
678
684
|
this.dataModule.setTabId(tabId);
|
|
685
|
+
// Installs the whole syncPort handler: ingest-relay, list_ref relay,
|
|
686
|
+
// settled writes, rollbacks.
|
|
679
687
|
this.sync.demoteToFollower(forwarder);
|
|
680
|
-
// demoteToFollower installed the sync-level handler (list_ref relay,
|
|
681
|
-
// rollbacks); layer the cache-level ingest relay in front of it.
|
|
682
|
-
const inner = forwarder.onLeaderMessage;
|
|
683
|
-
forwarder.onLeaderMessage = (msg) => {
|
|
684
|
-
if (msg.type === 'ingest-relay') {
|
|
685
|
-
this.cache.applyRelayedIngest(msg.tuples);
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
inner?.(msg);
|
|
689
|
-
};
|
|
690
688
|
},
|
|
691
689
|
becomeSyncSolo: () => {
|
|
692
690
|
this.streamProcessor.setPersistenceEnabled(true);
|