@spooky-sync/core 0.0.1-canary.158 → 0.0.1-canary.159
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 +126 -21
- package/package.json +3 -3
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +62 -0
- package/src/modules/sync/queue/queue-up.ts +85 -7
- package/src/modules/sync/sync.ts +146 -38
- package/src/utils/surql.ts +14 -11
package/dist/index.d.ts
CHANGED
|
@@ -802,6 +802,12 @@ interface Sp00kySyncOptions {
|
|
|
802
802
|
* Defaults to `3`.
|
|
803
803
|
*/
|
|
804
804
|
degradeAfterConsecutiveFailures?: number;
|
|
805
|
+
/**
|
|
806
|
+
* Max time a single mutation push may take before it is treated as a network
|
|
807
|
+
* failure and retried. Guards against an RPC that never settles wedging the
|
|
808
|
+
* up-queue for the session. Defaults to 30000; `0` disables the timeout.
|
|
809
|
+
*/
|
|
810
|
+
pushTimeoutMs?: number;
|
|
805
811
|
}
|
|
806
812
|
/**
|
|
807
813
|
* The main synchronization engine for Sp00ky.
|
|
@@ -849,6 +855,8 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
849
855
|
get pendingMutationCount(): number;
|
|
850
856
|
subscribeToPendingMutations(cb: (count: number) => void): () => void;
|
|
851
857
|
private readonly degradeAfterFailures;
|
|
858
|
+
/** Per-push RPC deadline; see {@link withPushTimeout}. */
|
|
859
|
+
private readonly pushTimeoutMs;
|
|
852
860
|
private consecutiveSyncFailures;
|
|
853
861
|
private syncHealthStatus;
|
|
854
862
|
private lastSyncErrorKind;
|
|
@@ -901,6 +909,16 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
901
909
|
* leader; everything else (registration, per-query sync, poll) runs
|
|
902
910
|
* against this tab's own remote session as usual. */
|
|
903
911
|
demoteToFollower(forwarder: SyncForwarder): void;
|
|
912
|
+
/**
|
|
913
|
+
* A pending mutation was discarded because it can never be sent.
|
|
914
|
+
*
|
|
915
|
+
* This is a lost write, so it must not stay invisible. Every failure in this
|
|
916
|
+
* chain used to be a `logger.error` an app running `logLevel: 'fatal'` never
|
|
917
|
+
* shows, which is how an outbox could sit undrained for hours with the UI
|
|
918
|
+
* reporting nothing. Surfaces as a rollback event (the mutation will never
|
|
919
|
+
* apply, which is what a subscriber needs to know) and degrades sync health.
|
|
920
|
+
*/
|
|
921
|
+
private onMutationDropped;
|
|
904
922
|
/** A forwarded outbox row from a follower: load + drain it. Idempotent. */
|
|
905
923
|
enqueueForwardedMutation(mutationId: string): Promise<void>;
|
|
906
924
|
/** A relayed `_00_list_ref` LIVE event: resolve against THIS tab's queries
|
|
@@ -1002,6 +1020,19 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
1002
1020
|
* @param event The DownEvent to enqueue.
|
|
1003
1021
|
*/
|
|
1004
1022
|
enqueueDownEvent(event: DownEvent): void;
|
|
1023
|
+
/**
|
|
1024
|
+
* Bound a mutation push so it always settles.
|
|
1025
|
+
*
|
|
1026
|
+
* `SyncScheduler.syncUp` early-returns while `isSyncingUp` is true, and that
|
|
1027
|
+
* flag only clears in the `finally` of the drain loop. A push whose RPC never
|
|
1028
|
+
* settles (socket dropped mid-flight, response lost) therefore wedges the
|
|
1029
|
+
* up-queue for the rest of the session: no retry, no error, no further
|
|
1030
|
+
* mutation ever sent. A timeout turns that into an ordinary network failure,
|
|
1031
|
+
* which `UpQueue.next` re-queues for the next trigger. The message deliberately
|
|
1032
|
+
* contains "timed out" so `classifySyncError` treats it as `network` and
|
|
1033
|
+
* retries rather than rolling the mutation back.
|
|
1034
|
+
*/
|
|
1035
|
+
private withPushTimeout;
|
|
1005
1036
|
private processUpEvent;
|
|
1006
1037
|
private handleRollback;
|
|
1007
1038
|
private processDownEvent;
|
package/dist/index.js
CHANGED
|
@@ -297,7 +297,7 @@ const surql = {
|
|
|
297
297
|
},
|
|
298
298
|
createMutation(t, mutationIdVar, recordIdVar, dataVar, beforeRecordVar) {
|
|
299
299
|
switch (t) {
|
|
300
|
-
case "create": return `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
|
|
300
|
+
case "create": return dataVar ? `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}, data = $${dataVar}` : `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
|
|
301
301
|
case "update": {
|
|
302
302
|
let stmt = `CREATE ONLY $${mutationIdVar} SET mutationType = 'update', recordId = $${recordIdVar}, data = $${dataVar}`;
|
|
303
303
|
if (beforeRecordVar) stmt += `, beforeRecord = $${beforeRecordVar}`;
|
|
@@ -3886,12 +3886,46 @@ var UpQueue = class {
|
|
|
3886
3886
|
get events() {
|
|
3887
3887
|
return this._events;
|
|
3888
3888
|
}
|
|
3889
|
-
constructor(local, logger) {
|
|
3889
|
+
constructor(local, logger, onDropped) {
|
|
3890
3890
|
this.local = local;
|
|
3891
|
+
this.onDropped = onDropped;
|
|
3891
3892
|
this._events = createSyncQueueEventSystem();
|
|
3892
3893
|
this.logger = logger.child({ service: "UpQueue" });
|
|
3893
3894
|
this.debouncedMutations = /* @__PURE__ */ new Map();
|
|
3894
3895
|
}
|
|
3896
|
+
/**
|
|
3897
|
+
* Discard an outbox row that can never be replayed and report it.
|
|
3898
|
+
*
|
|
3899
|
+
* Silently skipping such a row leaves it in the store to be re-read on every
|
|
3900
|
+
* boot; leaving it QUEUED is worse, since `next()` re-queues it on failure and
|
|
3901
|
+
* one unsendable row then blocks every later mutation for the whole app. A
|
|
3902
|
+
* lost write must also be loud: this is the only signal a caller gets.
|
|
3903
|
+
*/
|
|
3904
|
+
async discardUnreplayable(row, reason) {
|
|
3905
|
+
const mutationId = typeof row?.id === "string" ? row.id : encodeRecordId(row?.id);
|
|
3906
|
+
this.logger.error({
|
|
3907
|
+
mutationId,
|
|
3908
|
+
recordId: row?.recordId,
|
|
3909
|
+
mutationType: row?.mutationType,
|
|
3910
|
+
reason,
|
|
3911
|
+
Category: "sp00ky-client::UpQueue::discardUnreplayable"
|
|
3912
|
+
}, "Discarding an unsendable pending mutation");
|
|
3913
|
+
try {
|
|
3914
|
+
await this.local.query(`DELETE $mutation_id`, { mutation_id: parseRecordIdString(mutationId) });
|
|
3915
|
+
} catch (error) {
|
|
3916
|
+
this.logger.error({
|
|
3917
|
+
error,
|
|
3918
|
+
mutationId,
|
|
3919
|
+
Category: "sp00ky-client::UpQueue::discardUnreplayable"
|
|
3920
|
+
}, "Failed to delete an unsendable pending mutation");
|
|
3921
|
+
}
|
|
3922
|
+
this.onDropped?.({
|
|
3923
|
+
mutationId,
|
|
3924
|
+
recordId: row?.recordId,
|
|
3925
|
+
mutationType: row?.mutationType,
|
|
3926
|
+
reason
|
|
3927
|
+
});
|
|
3928
|
+
}
|
|
3895
3929
|
get size() {
|
|
3896
3930
|
return this.queue.length;
|
|
3897
3931
|
}
|
|
@@ -4011,20 +4045,38 @@ var UpQueue = class {
|
|
|
4011
4045
|
if (this.queue.some((e) => encodeUpEventId(e) === mutationId)) return;
|
|
4012
4046
|
try {
|
|
4013
4047
|
const [records] = await this.local.query(`SELECT * FROM $mutation_ids`, { mutation_ids: [parseRecordIdString(mutationId)] });
|
|
4014
|
-
const
|
|
4015
|
-
if (
|
|
4048
|
+
const row = Array.isArray(records) ? records[0] : void 0;
|
|
4049
|
+
if (!row) return;
|
|
4050
|
+
const event = rowToUpEvent(row, this.logger);
|
|
4051
|
+
if (event) {
|
|
4052
|
+
this.addToQueue(event);
|
|
4053
|
+
return;
|
|
4054
|
+
}
|
|
4055
|
+
await this.discardUnreplayable(row, "forwarded mutation is not replayable");
|
|
4016
4056
|
} catch (error) {
|
|
4017
4057
|
this.logger.error({
|
|
4018
4058
|
error,
|
|
4019
4059
|
mutationId,
|
|
4020
4060
|
Category: "sp00ky-client::UpQueue::enqueueFromDatabase"
|
|
4021
4061
|
}, "Failed to load a forwarded mutation");
|
|
4062
|
+
this.onDropped?.({
|
|
4063
|
+
mutationId,
|
|
4064
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
4065
|
+
});
|
|
4022
4066
|
}
|
|
4023
4067
|
}
|
|
4024
4068
|
async loadFromDatabase() {
|
|
4025
4069
|
try {
|
|
4026
4070
|
const [records] = await this.local.query(`SELECT * FROM _00_pending_mutations ORDER BY id ASC`);
|
|
4027
|
-
|
|
4071
|
+
const loaded = [];
|
|
4072
|
+
const unreplayable = [];
|
|
4073
|
+
for (const row of records) {
|
|
4074
|
+
const event = rowToUpEvent(row, this.logger);
|
|
4075
|
+
if (event) loaded.push(event);
|
|
4076
|
+
else unreplayable.push(row);
|
|
4077
|
+
}
|
|
4078
|
+
this.queue = loaded;
|
|
4079
|
+
for (const row of unreplayable) await this.discardUnreplayable(row, "pending mutation is not replayable");
|
|
4028
4080
|
} catch (error) {
|
|
4029
4081
|
this.logger.error({
|
|
4030
4082
|
error,
|
|
@@ -4040,13 +4092,15 @@ function encodeUpEventId(event) {
|
|
|
4040
4092
|
/** Materialize one `_00_pending_mutations` row into an UpEvent. */
|
|
4041
4093
|
function rowToUpEvent(r, logger) {
|
|
4042
4094
|
switch (r.mutationType) {
|
|
4043
|
-
case "create":
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4095
|
+
case "create":
|
|
4096
|
+
if (r.data === void 0 || r.data === null) return null;
|
|
4097
|
+
return {
|
|
4098
|
+
type: "create",
|
|
4099
|
+
mutation_id: parseRecordIdString(r.id),
|
|
4100
|
+
record_id: parseRecordIdString(r.recordId),
|
|
4101
|
+
data: r.data,
|
|
4102
|
+
tableName: extractTablePart(r.recordId)
|
|
4103
|
+
};
|
|
4050
4104
|
case "update": return {
|
|
4051
4105
|
type: "update",
|
|
4052
4106
|
mutation_id: parseRecordIdString(r.id),
|
|
@@ -4614,6 +4668,8 @@ var Sp00kySync = class Sp00kySync {
|
|
|
4614
4668
|
};
|
|
4615
4669
|
}
|
|
4616
4670
|
degradeAfterFailures;
|
|
4671
|
+
/** Per-push RPC deadline; see {@link withPushTimeout}. */
|
|
4672
|
+
pushTimeoutMs;
|
|
4617
4673
|
consecutiveSyncFailures = 0;
|
|
4618
4674
|
syncHealthStatus = "healthy";
|
|
4619
4675
|
lastSyncErrorKind;
|
|
@@ -4740,13 +4796,14 @@ var Sp00kySync = class Sp00kySync {
|
|
|
4740
4796
|
this.dataModule = dataModule;
|
|
4741
4797
|
this.schema = schema;
|
|
4742
4798
|
this.logger = logger.child({ service: "Sp00kySync" });
|
|
4743
|
-
this.upQueue = new UpQueue(this.local, this.logger);
|
|
4799
|
+
this.upQueue = new UpQueue(this.local, this.logger, (dropped) => this.onMutationDropped(dropped));
|
|
4744
4800
|
this.downQueue = new DownQueue(this.local, this.logger);
|
|
4745
4801
|
this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
|
|
4746
4802
|
this.scheduler = new SyncScheduler(this.upQueue, this.downQueue, this.processUpEvent.bind(this), this.processDownEvent.bind(this), this.logger, this.handleRollback.bind(this), this.recordSyncOutcome.bind(this));
|
|
4747
4803
|
this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
|
|
4748
4804
|
this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
|
|
4749
4805
|
this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
|
|
4806
|
+
this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 3e4);
|
|
4750
4807
|
}
|
|
4751
4808
|
/**
|
|
4752
4809
|
* Initializes the synchronization system.
|
|
@@ -4838,6 +4895,27 @@ var Sp00kySync = class Sp00kySync {
|
|
|
4838
4895
|
}
|
|
4839
4896
|
};
|
|
4840
4897
|
}
|
|
4898
|
+
/**
|
|
4899
|
+
* A pending mutation was discarded because it can never be sent.
|
|
4900
|
+
*
|
|
4901
|
+
* This is a lost write, so it must not stay invisible. Every failure in this
|
|
4902
|
+
* chain used to be a `logger.error` an app running `logLevel: 'fatal'` never
|
|
4903
|
+
* shows, which is how an outbox could sit undrained for hours with the UI
|
|
4904
|
+
* reporting nothing. Surfaces as a rollback event (the mutation will never
|
|
4905
|
+
* apply, which is what a subscriber needs to know) and degrades sync health.
|
|
4906
|
+
*/
|
|
4907
|
+
onMutationDropped(dropped) {
|
|
4908
|
+
this.logger.error({
|
|
4909
|
+
...dropped,
|
|
4910
|
+
Category: "sp00ky-client::Sp00kySync::onMutationDropped"
|
|
4911
|
+
}, "Dropped a pending mutation that can never be sent");
|
|
4912
|
+
this.recordSyncOutcome(false, /* @__PURE__ */ new Error(`dropped mutation: ${dropped.reason}`));
|
|
4913
|
+
this.events.emit(SyncEventTypes.MutationRolledBack, {
|
|
4914
|
+
eventType: dropped.mutationType ?? "update",
|
|
4915
|
+
recordId: dropped.recordId ?? dropped.mutationId,
|
|
4916
|
+
error: `dropped: ${dropped.reason}`
|
|
4917
|
+
});
|
|
4918
|
+
}
|
|
4841
4919
|
/** A forwarded outbox row from a follower: load + drain it. Idempotent. */
|
|
4842
4920
|
async enqueueForwardedMutation(mutationId) {
|
|
4843
4921
|
if (this.tabRole !== "leader") return;
|
|
@@ -5250,6 +5328,33 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5250
5328
|
enqueueDownEvent(event) {
|
|
5251
5329
|
this.scheduler.enqueueDownEvent(event);
|
|
5252
5330
|
}
|
|
5331
|
+
/**
|
|
5332
|
+
* Bound a mutation push so it always settles.
|
|
5333
|
+
*
|
|
5334
|
+
* `SyncScheduler.syncUp` early-returns while `isSyncingUp` is true, and that
|
|
5335
|
+
* flag only clears in the `finally` of the drain loop. A push whose RPC never
|
|
5336
|
+
* settles (socket dropped mid-flight, response lost) therefore wedges the
|
|
5337
|
+
* up-queue for the rest of the session: no retry, no error, no further
|
|
5338
|
+
* mutation ever sent. A timeout turns that into an ordinary network failure,
|
|
5339
|
+
* which `UpQueue.next` re-queues for the next trigger. The message deliberately
|
|
5340
|
+
* contains "timed out" so `classifySyncError` treats it as `network` and
|
|
5341
|
+
* retries rather than rolling the mutation back.
|
|
5342
|
+
*/
|
|
5343
|
+
withPushTimeout(promise, label) {
|
|
5344
|
+
if (!(this.pushTimeoutMs > 0)) return promise;
|
|
5345
|
+
return new Promise((resolve, reject) => {
|
|
5346
|
+
const timer = setTimeout(() => {
|
|
5347
|
+
reject(/* @__PURE__ */ new Error(`Mutation push timed out after ${this.pushTimeoutMs}ms (${label})`));
|
|
5348
|
+
}, this.pushTimeoutMs);
|
|
5349
|
+
promise.then((value) => {
|
|
5350
|
+
clearTimeout(timer);
|
|
5351
|
+
resolve(value);
|
|
5352
|
+
}, (err) => {
|
|
5353
|
+
clearTimeout(timer);
|
|
5354
|
+
reject(err);
|
|
5355
|
+
});
|
|
5356
|
+
});
|
|
5357
|
+
}
|
|
5253
5358
|
async processUpEvent(event) {
|
|
5254
5359
|
this.logger.debug({
|
|
5255
5360
|
event,
|
|
@@ -5263,20 +5368,20 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5263
5368
|
}));
|
|
5264
5369
|
const prefixedParams = Object.fromEntries(dataKeys.map(({ key, variable }) => [variable, event.data[key]]));
|
|
5265
5370
|
const query = surql.seal(surql.createSet("id", dataKeys));
|
|
5266
|
-
await this.remote.query(query, {
|
|
5371
|
+
await this.withPushTimeout(this.remote.query(query, {
|
|
5267
5372
|
id: event.record_id,
|
|
5268
5373
|
...prefixedParams
|
|
5269
|
-
});
|
|
5374
|
+
}), "create");
|
|
5270
5375
|
break;
|
|
5271
5376
|
}
|
|
5272
5377
|
case "update":
|
|
5273
|
-
await this.remote.query(`UPDATE $id MERGE $data`, {
|
|
5378
|
+
await this.withPushTimeout(this.remote.query(`UPDATE $id MERGE $data`, {
|
|
5274
5379
|
id: event.record_id,
|
|
5275
5380
|
data: event.data
|
|
5276
|
-
});
|
|
5381
|
+
}), "update");
|
|
5277
5382
|
break;
|
|
5278
5383
|
case "delete":
|
|
5279
|
-
await this.remote.query(`DELETE $id`, { id: event.record_id });
|
|
5384
|
+
await this.withPushTimeout(this.remote.query(`DELETE $id`, { id: event.record_id }), "delete");
|
|
5280
5385
|
break;
|
|
5281
5386
|
default:
|
|
5282
5387
|
this.logger.error({
|
|
@@ -5694,8 +5799,8 @@ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
|
|
|
5694
5799
|
|
|
5695
5800
|
//#endregion
|
|
5696
5801
|
//#region src/modules/devtools/index.ts
|
|
5697
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
5698
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
5802
|
+
const CORE_VERSION = "0.0.1-canary.159";
|
|
5803
|
+
const WASM_VERSION = "0.0.1-canary.159";
|
|
5699
5804
|
const SURREAL_VERSION = "3.0.3";
|
|
5700
5805
|
var DevToolsService = class DevToolsService {
|
|
5701
5806
|
eventsHistory = [];
|
|
@@ -8939,7 +9044,7 @@ var Sp00kyClient = class {
|
|
|
8939
9044
|
return new TabsCoordinator({
|
|
8940
9045
|
tabId,
|
|
8941
9046
|
fingerprint: computeTabsFingerprint({
|
|
8942
|
-
coreVersion: "0.0.1-canary.
|
|
9047
|
+
coreVersion: "0.0.1-canary.159",
|
|
8943
9048
|
schemaHash: hash53(this.config.schemaSurql),
|
|
8944
9049
|
endpoint: this.config.database.endpoint ?? "",
|
|
8945
9050
|
namespace: this.config.database.namespace,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.159",
|
|
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.159",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.159",
|
|
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",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { UpQueue } from './queue-up';
|
|
3
3
|
import { translateSurql } from '../../../services/database/surql-translate';
|
|
4
|
+
import { surql, classifySyncError } from '../../../utils/index';
|
|
4
5
|
|
|
5
6
|
function makeLogger(): any {
|
|
6
7
|
const noop = () => {};
|
|
@@ -57,6 +58,67 @@ describe('UpQueue.enqueueFromDatabase', () => {
|
|
|
57
58
|
// single RecordId where the engine's `selectByIds` lowering expects an array,
|
|
58
59
|
// so it threw, the surrounding catch swallowed it at `error` level, and every
|
|
59
60
|
// follower mutation was silently never pushed. Drive the real translator.
|
|
61
|
+
it('discards a create with no payload instead of queueing a guaranteed failure', async () => {
|
|
62
|
+
// `processUpEvent` does `Object.keys(event.data)`, so this row can never be
|
|
63
|
+
// sent. Queued, it sat at the HEAD and blocked every later mutation for the
|
|
64
|
+
// whole app: `next()` re-queues on failure, so the backlog never drained.
|
|
65
|
+
const dropped: any[] = [];
|
|
66
|
+
const rows: Record<string, unknown[]> = {
|
|
67
|
+
[ROW.id]: [{ id: ROW.id, mutationType: 'create', recordId: 'comment:c1' }],
|
|
68
|
+
};
|
|
69
|
+
const query = vi.fn(async (sql: string, vars?: Record<string, unknown>) => {
|
|
70
|
+
if (sql.startsWith('DELETE')) return [[]];
|
|
71
|
+
const ids = (vars?.mutation_ids as unknown[]) ?? [];
|
|
72
|
+
return [rows[String(ids[0] ?? '')] ?? []];
|
|
73
|
+
});
|
|
74
|
+
const queue = new UpQueue({ query } as any, makeLogger(), (d) => dropped.push(d));
|
|
75
|
+
|
|
76
|
+
await queue.enqueueFromDatabase(ROW.id);
|
|
77
|
+
|
|
78
|
+
expect(queue.size).toBe(0);
|
|
79
|
+
expect(dropped).toHaveLength(1);
|
|
80
|
+
// And the row is deleted, so it cannot re-poison the next boot.
|
|
81
|
+
expect(query.mock.calls.some(([sql]) => String(sql).startsWith('DELETE'))).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('loadFromDatabase drains the replayable rows even when one is unsendable', async () => {
|
|
85
|
+
const dropped: any[] = [];
|
|
86
|
+
const query = vi.fn(async (sql: string) => {
|
|
87
|
+
if (sql.startsWith('DELETE')) return [[]];
|
|
88
|
+
return [
|
|
89
|
+
[
|
|
90
|
+
{ id: '_00_pending_mutations:a', mutationType: 'create', recordId: 'comment:c1' },
|
|
91
|
+
{
|
|
92
|
+
id: '_00_pending_mutations:b',
|
|
93
|
+
mutationType: 'update',
|
|
94
|
+
recordId: 'game:g1',
|
|
95
|
+
data: { title: 'x' },
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
];
|
|
99
|
+
});
|
|
100
|
+
const queue = new UpQueue({ query } as any, makeLogger(), (d) => dropped.push(d));
|
|
101
|
+
|
|
102
|
+
await queue.loadFromDatabase();
|
|
103
|
+
|
|
104
|
+
// The good row still loads; the poison one is dropped and reported.
|
|
105
|
+
expect(queue.size).toBe(1);
|
|
106
|
+
expect(dropped).toHaveLength(1);
|
|
107
|
+
expect(dropped[0].mutationId).toBe('_00_pending_mutations:a');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('persists the payload for a create, so it can be replayed at all', () => {
|
|
111
|
+
// The create branch used to accept `dataVar` and ignore it, so the outbox
|
|
112
|
+
// row was the ONLY copy of a pending create and it carried no data.
|
|
113
|
+
const stmt = surql.createMutation('create', 'mid', 'id', 'data');
|
|
114
|
+
expect(stmt).toContain('data = $data');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('a push timeout classifies as network, so it retries instead of rolling back', () => {
|
|
118
|
+
const err = new Error('Mutation push timed out after 30000ms (create)');
|
|
119
|
+
expect(classifySyncError(err)).toBe('network');
|
|
120
|
+
});
|
|
121
|
+
|
|
60
122
|
it('emits a statement the SQLite engine can actually translate', async () => {
|
|
61
123
|
const { queue, query } = makeQueue({ [ROW.id]: [ROW] });
|
|
62
124
|
await queue.enqueueFromDatabase(ROW.id);
|
|
@@ -42,6 +42,19 @@ export type UpEvent = CreateEvent | UpdateEvent | DeleteEvent;
|
|
|
42
42
|
|
|
43
43
|
export type RollbackCallback = (event: UpEvent, error: Error) => Promise<void>;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A pending mutation that can never be sent, so it was discarded instead of
|
|
47
|
+
* being retried forever at the head of the queue.
|
|
48
|
+
*/
|
|
49
|
+
export type DroppedMutation = {
|
|
50
|
+
mutationId: string;
|
|
51
|
+
recordId?: string;
|
|
52
|
+
mutationType?: string;
|
|
53
|
+
reason: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type DroppedCallback = (dropped: DroppedMutation) => void;
|
|
57
|
+
|
|
45
58
|
export class UpQueue {
|
|
46
59
|
private queue: UpEvent[] = [];
|
|
47
60
|
private _events: SyncQueueEventSystem;
|
|
@@ -57,13 +70,52 @@ export class UpQueue {
|
|
|
57
70
|
|
|
58
71
|
constructor(
|
|
59
72
|
private local: LocalStore,
|
|
60
|
-
logger: Logger
|
|
73
|
+
logger: Logger,
|
|
74
|
+
private onDropped?: DroppedCallback
|
|
61
75
|
) {
|
|
62
76
|
this._events = createSyncQueueEventSystem();
|
|
63
77
|
this.logger = logger.child({ service: 'UpQueue' });
|
|
64
78
|
this.debouncedMutations = new Map();
|
|
65
79
|
}
|
|
66
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Discard an outbox row that can never be replayed and report it.
|
|
83
|
+
*
|
|
84
|
+
* Silently skipping such a row leaves it in the store to be re-read on every
|
|
85
|
+
* boot; leaving it QUEUED is worse, since `next()` re-queues it on failure and
|
|
86
|
+
* one unsendable row then blocks every later mutation for the whole app. A
|
|
87
|
+
* lost write must also be loud: this is the only signal a caller gets.
|
|
88
|
+
*/
|
|
89
|
+
private async discardUnreplayable(row: any, reason: string): Promise<void> {
|
|
90
|
+
const mutationId = typeof row?.id === 'string' ? row.id : encodeRecordId(row?.id);
|
|
91
|
+
this.logger.error(
|
|
92
|
+
{
|
|
93
|
+
mutationId,
|
|
94
|
+
recordId: row?.recordId,
|
|
95
|
+
mutationType: row?.mutationType,
|
|
96
|
+
reason,
|
|
97
|
+
Category: 'sp00ky-client::UpQueue::discardUnreplayable',
|
|
98
|
+
},
|
|
99
|
+
'Discarding an unsendable pending mutation'
|
|
100
|
+
);
|
|
101
|
+
try {
|
|
102
|
+
await this.local.query(`DELETE $mutation_id`, {
|
|
103
|
+
mutation_id: parseRecordIdString(mutationId),
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
this.logger.error(
|
|
107
|
+
{ error, mutationId, Category: 'sp00ky-client::UpQueue::discardUnreplayable' },
|
|
108
|
+
'Failed to delete an unsendable pending mutation'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
this.onDropped?.({
|
|
112
|
+
mutationId,
|
|
113
|
+
recordId: row?.recordId,
|
|
114
|
+
mutationType: row?.mutationType,
|
|
115
|
+
reason,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
67
119
|
get size(): number {
|
|
68
120
|
return this.queue.length;
|
|
69
121
|
}
|
|
@@ -209,14 +261,23 @@ export class UpQueue {
|
|
|
209
261
|
const [records] = await this.local.query<any>(`SELECT * FROM $mutation_ids`, {
|
|
210
262
|
mutation_ids: [parseRecordIdString(mutationId)],
|
|
211
263
|
});
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
264
|
+
const row = Array.isArray(records) ? records[0] : undefined;
|
|
265
|
+
if (!row) return;
|
|
266
|
+
const event = rowToUpEvent(row, this.logger);
|
|
267
|
+
if (event) {
|
|
268
|
+
this.addToQueue(event);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
await this.discardUnreplayable(row, 'forwarded mutation is not replayable');
|
|
215
272
|
} catch (error) {
|
|
216
273
|
this.logger.error(
|
|
217
274
|
{ error, mutationId, Category: 'sp00ky-client::UpQueue::enqueueFromDatabase' },
|
|
218
275
|
'Failed to load a forwarded mutation'
|
|
219
276
|
);
|
|
277
|
+
this.onDropped?.({
|
|
278
|
+
mutationId,
|
|
279
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
280
|
+
});
|
|
220
281
|
}
|
|
221
282
|
}
|
|
222
283
|
|
|
@@ -230,9 +291,20 @@ export class UpQueue {
|
|
|
230
291
|
`SELECT * FROM _00_pending_mutations ORDER BY id ASC`
|
|
231
292
|
);
|
|
232
293
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
294
|
+
const loaded: UpEvent[] = [];
|
|
295
|
+
const unreplayable: any[] = [];
|
|
296
|
+
for (const row of records as any[]) {
|
|
297
|
+
const event = rowToUpEvent(row, this.logger);
|
|
298
|
+
if (event) loaded.push(event);
|
|
299
|
+
else unreplayable.push(row);
|
|
300
|
+
}
|
|
301
|
+
this.queue = loaded;
|
|
302
|
+
// Drop them AFTER the queue is populated: one unsendable row must not
|
|
303
|
+
// stop the rest of the backlog from draining, and leaving it in the store
|
|
304
|
+
// would just re-poison the next boot.
|
|
305
|
+
for (const row of unreplayable) {
|
|
306
|
+
await this.discardUnreplayable(row, 'pending mutation is not replayable');
|
|
307
|
+
}
|
|
236
308
|
} catch (error) {
|
|
237
309
|
this.logger.error(
|
|
238
310
|
{ error, Category: 'sp00ky-client::UpQueue::loadFromDatabase' },
|
|
@@ -251,6 +323,12 @@ function encodeUpEventId(event: UpEvent): string {
|
|
|
251
323
|
function rowToUpEvent(r: any, logger: Logger): UpEvent | null {
|
|
252
324
|
switch (r.mutationType) {
|
|
253
325
|
case 'create':
|
|
326
|
+
// `processUpEvent` does `Object.keys(event.data)`, so a create with no
|
|
327
|
+
// payload throws before it reaches the network and can never succeed.
|
|
328
|
+
// Rows written before the create branch of `surql.createMutation`
|
|
329
|
+
// persisted `data` are exactly that, so refuse them here instead of
|
|
330
|
+
// queueing a guaranteed failure.
|
|
331
|
+
if (r.data === undefined || r.data === null) return null;
|
|
254
332
|
return {
|
|
255
333
|
type: 'create',
|
|
256
334
|
mutation_id: parseRecordIdString(r.id),
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
RecordVersionArray,
|
|
4
|
+
RecordVersionDiff,
|
|
5
|
+
SyncHealth,
|
|
6
|
+
SyncHealthStatus,
|
|
7
|
+
} from '../../types';
|
|
3
8
|
import { createSyncEventSystem, SyncEventTypes, SyncQueueEventTypes } from './events/index';
|
|
4
9
|
import type { Logger } from '../../services/logger/index';
|
|
5
|
-
import type { DownEvent, UpEvent} from './queue/index';
|
|
10
|
+
import type { DownEvent, UpEvent } from './queue/index';
|
|
6
11
|
import { DownQueue, UpQueue } from './queue/index';
|
|
7
12
|
import type { RecordId, Uuid } from 'surrealdb';
|
|
8
13
|
import {
|
|
@@ -20,7 +25,13 @@ import { SyncScheduler } from './scheduler';
|
|
|
20
25
|
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
21
26
|
import type { CacheModule } from '../cache/index';
|
|
22
27
|
import type { DataModule } from '../data/index';
|
|
23
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
classifySyncError,
|
|
30
|
+
encodeRecordId,
|
|
31
|
+
extractIdPart,
|
|
32
|
+
extractTablePart,
|
|
33
|
+
surql,
|
|
34
|
+
} from '../../utils/index';
|
|
24
35
|
import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
|
|
25
36
|
import { mutationOwnerTabId } from '../data/mutation-id';
|
|
26
37
|
import type { LeaderSyncHub, SyncForwarder } from '../../services/tabs/coordinator';
|
|
@@ -49,6 +60,12 @@ export interface Sp00kySyncOptions {
|
|
|
49
60
|
* Defaults to `3`.
|
|
50
61
|
*/
|
|
51
62
|
degradeAfterConsecutiveFailures?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Max time a single mutation push may take before it is treated as a network
|
|
65
|
+
* failure and retried. Guards against an RPC that never settles wedging the
|
|
66
|
+
* up-queue for the session. Defaults to 30000; `0` disables the timeout.
|
|
67
|
+
*/
|
|
68
|
+
pushTimeoutMs?: number;
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
/**
|
|
@@ -162,13 +179,11 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
162
179
|
}
|
|
163
180
|
|
|
164
181
|
subscribeToPendingMutations(cb: (count: number) => void): () => void {
|
|
165
|
-
const id1 = this.upQueue.events.subscribe(
|
|
166
|
-
|
|
167
|
-
(event) => cb(event.payload.queueSize)
|
|
182
|
+
const id1 = this.upQueue.events.subscribe(SyncQueueEventTypes.MutationEnqueued, (event) =>
|
|
183
|
+
cb(event.payload.queueSize)
|
|
168
184
|
);
|
|
169
|
-
const id2 = this.upQueue.events.subscribe(
|
|
170
|
-
|
|
171
|
-
(event) => cb(event.payload.queueSize)
|
|
185
|
+
const id2 = this.upQueue.events.subscribe(SyncQueueEventTypes.MutationDequeued, (event) =>
|
|
186
|
+
cb(event.payload.queueSize)
|
|
172
187
|
);
|
|
173
188
|
return () => {
|
|
174
189
|
this.upQueue.events.unsubscribe(id1);
|
|
@@ -180,6 +195,8 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
180
195
|
// `0` disables degraded reporting (config `syncHealth: false`). Resolved
|
|
181
196
|
// from config in Sp00kyClient and passed through the constructor options.
|
|
182
197
|
private readonly degradeAfterFailures: number;
|
|
198
|
+
/** Per-push RPC deadline; see {@link withPushTimeout}. */
|
|
199
|
+
private readonly pushTimeoutMs: number;
|
|
183
200
|
private consecutiveSyncFailures = 0;
|
|
184
201
|
private syncHealthStatus: SyncHealthStatus = 'healthy';
|
|
185
202
|
private lastSyncErrorKind: 'network' | 'application' | undefined;
|
|
@@ -297,7 +314,11 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
297
314
|
if (this.syncHealthStatus !== 'degraded') return;
|
|
298
315
|
this.selfHealAttempts++;
|
|
299
316
|
this.logger.debug(
|
|
300
|
-
{
|
|
317
|
+
{
|
|
318
|
+
attempt: this.selfHealAttempts,
|
|
319
|
+
delayMs: delay,
|
|
320
|
+
Category: 'sp00ky-client::Sp00kySync::selfHeal',
|
|
321
|
+
},
|
|
301
322
|
'Self-heal: re-driving sync while degraded'
|
|
302
323
|
);
|
|
303
324
|
try {
|
|
@@ -353,7 +374,9 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
353
374
|
options?: Sp00kySyncOptions
|
|
354
375
|
) {
|
|
355
376
|
this.logger = logger.child({ service: 'Sp00kySync' });
|
|
356
|
-
this.upQueue = new UpQueue(this.local, this.logger)
|
|
377
|
+
this.upQueue = new UpQueue(this.local, this.logger, (dropped) =>
|
|
378
|
+
this.onMutationDropped(dropped)
|
|
379
|
+
);
|
|
357
380
|
this.downQueue = new DownQueue(this.local, this.logger);
|
|
358
381
|
this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
|
|
359
382
|
this.scheduler = new SyncScheduler(
|
|
@@ -368,6 +391,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
368
391
|
this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
|
|
369
392
|
this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
|
|
370
393
|
this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
|
|
394
|
+
this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 30_000);
|
|
371
395
|
}
|
|
372
396
|
|
|
373
397
|
/**
|
|
@@ -483,6 +507,33 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
483
507
|
};
|
|
484
508
|
}
|
|
485
509
|
|
|
510
|
+
/**
|
|
511
|
+
* A pending mutation was discarded because it can never be sent.
|
|
512
|
+
*
|
|
513
|
+
* This is a lost write, so it must not stay invisible. Every failure in this
|
|
514
|
+
* chain used to be a `logger.error` an app running `logLevel: 'fatal'` never
|
|
515
|
+
* shows, which is how an outbox could sit undrained for hours with the UI
|
|
516
|
+
* reporting nothing. Surfaces as a rollback event (the mutation will never
|
|
517
|
+
* apply, which is what a subscriber needs to know) and degrades sync health.
|
|
518
|
+
*/
|
|
519
|
+
private onMutationDropped(dropped: {
|
|
520
|
+
mutationId: string;
|
|
521
|
+
recordId?: string;
|
|
522
|
+
mutationType?: string;
|
|
523
|
+
reason: string;
|
|
524
|
+
}): void {
|
|
525
|
+
this.logger.error(
|
|
526
|
+
{ ...dropped, Category: 'sp00ky-client::Sp00kySync::onMutationDropped' },
|
|
527
|
+
'Dropped a pending mutation that can never be sent'
|
|
528
|
+
);
|
|
529
|
+
this.recordSyncOutcome(false, new Error(`dropped mutation: ${dropped.reason}`));
|
|
530
|
+
this.events.emit(SyncEventTypes.MutationRolledBack, {
|
|
531
|
+
eventType: (dropped.mutationType as 'create' | 'update' | 'delete') ?? 'update',
|
|
532
|
+
recordId: dropped.recordId ?? dropped.mutationId,
|
|
533
|
+
error: `dropped: ${dropped.reason}`,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
486
537
|
/** A forwarded outbox row from a follower: load + drain it. Idempotent. */
|
|
487
538
|
public async enqueueForwardedMutation(mutationId: string): Promise<void> {
|
|
488
539
|
if (this.tabRole !== 'leader') return;
|
|
@@ -709,7 +760,11 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
709
760
|
reached = true;
|
|
710
761
|
}
|
|
711
762
|
this.logger.debug(
|
|
712
|
-
{
|
|
763
|
+
{
|
|
764
|
+
err: (err as Error)?.message ?? err,
|
|
765
|
+
hash,
|
|
766
|
+
Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries',
|
|
767
|
+
},
|
|
713
768
|
'Per-query list_ref poll failed'
|
|
714
769
|
);
|
|
715
770
|
}
|
|
@@ -742,10 +797,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
742
797
|
{ in: queryState.config.id }
|
|
743
798
|
);
|
|
744
799
|
if (!Array.isArray(items)) return false;
|
|
745
|
-
const fresh: RecordVersionArray = items.map((item) => [
|
|
746
|
-
encodeRecordId(item.out),
|
|
747
|
-
item.version,
|
|
748
|
-
]);
|
|
800
|
+
const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
749
801
|
// Capture which ids LEFT the query's window (present in the cached
|
|
750
802
|
// remoteArray, absent from `fresh`) BEFORE we overwrite remoteArray — these
|
|
751
803
|
// are cross-window deletes (or rows that scrolled out). They drive the
|
|
@@ -780,7 +832,11 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
780
832
|
await this.syncQuery(queryHash);
|
|
781
833
|
} catch (err) {
|
|
782
834
|
this.logger.info(
|
|
783
|
-
{
|
|
835
|
+
{
|
|
836
|
+
err: (err as Error)?.message ?? err,
|
|
837
|
+
queryHash,
|
|
838
|
+
Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery',
|
|
839
|
+
},
|
|
784
840
|
'syncQuery failed during poll'
|
|
785
841
|
);
|
|
786
842
|
}
|
|
@@ -789,7 +845,11 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
789
845
|
// poll too (idempotent — no-op when nothing changed).
|
|
790
846
|
await this.syncSubqueryChildren(queryHash).catch((err) => {
|
|
791
847
|
this.logger.info(
|
|
792
|
-
{
|
|
848
|
+
{
|
|
849
|
+
err: (err as Error)?.message ?? err,
|
|
850
|
+
queryHash,
|
|
851
|
+
Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery',
|
|
852
|
+
},
|
|
793
853
|
'Subquery child sync failed during poll'
|
|
794
854
|
);
|
|
795
855
|
});
|
|
@@ -803,7 +863,11 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
803
863
|
await this.dataModule.notifyQuerySynced(queryHash);
|
|
804
864
|
} catch (err) {
|
|
805
865
|
this.logger.info(
|
|
806
|
-
{
|
|
866
|
+
{
|
|
867
|
+
err: (err as Error)?.message ?? err,
|
|
868
|
+
queryHash,
|
|
869
|
+
Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery',
|
|
870
|
+
},
|
|
807
871
|
'notifyQuerySynced failed during poll-removal re-render'
|
|
808
872
|
);
|
|
809
873
|
}
|
|
@@ -833,7 +897,11 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
833
897
|
|
|
834
898
|
private async killRefLiveQuery(): Promise<void> {
|
|
835
899
|
if (this.liveQueryUnsubscribe) {
|
|
836
|
-
try {
|
|
900
|
+
try {
|
|
901
|
+
this.liveQueryUnsubscribe();
|
|
902
|
+
} catch {
|
|
903
|
+
/* ignore */
|
|
904
|
+
}
|
|
837
905
|
this.liveQueryUnsubscribe = null;
|
|
838
906
|
}
|
|
839
907
|
if (this.currentLiveQueryUuid !== null) {
|
|
@@ -903,9 +971,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
903
971
|
'Starting ref live queries'
|
|
904
972
|
);
|
|
905
973
|
|
|
906
|
-
const [queryUuid] = await this.remote.query<[Uuid]>(
|
|
907
|
-
`LIVE SELECT * FROM ${tableName}`
|
|
908
|
-
);
|
|
974
|
+
const [queryUuid] = await this.remote.query<[Uuid]>(`LIVE SELECT * FROM ${tableName}`);
|
|
909
975
|
this.currentLiveQueryUuid = queryUuid;
|
|
910
976
|
|
|
911
977
|
const live = await this.remote.getClient().liveOf(queryUuid);
|
|
@@ -1077,6 +1143,37 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1077
1143
|
this.scheduler.enqueueDownEvent(event);
|
|
1078
1144
|
}
|
|
1079
1145
|
|
|
1146
|
+
/**
|
|
1147
|
+
* Bound a mutation push so it always settles.
|
|
1148
|
+
*
|
|
1149
|
+
* `SyncScheduler.syncUp` early-returns while `isSyncingUp` is true, and that
|
|
1150
|
+
* flag only clears in the `finally` of the drain loop. A push whose RPC never
|
|
1151
|
+
* settles (socket dropped mid-flight, response lost) therefore wedges the
|
|
1152
|
+
* up-queue for the rest of the session: no retry, no error, no further
|
|
1153
|
+
* mutation ever sent. A timeout turns that into an ordinary network failure,
|
|
1154
|
+
* which `UpQueue.next` re-queues for the next trigger. The message deliberately
|
|
1155
|
+
* contains "timed out" so `classifySyncError` treats it as `network` and
|
|
1156
|
+
* retries rather than rolling the mutation back.
|
|
1157
|
+
*/
|
|
1158
|
+
private withPushTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
|
1159
|
+
if (!(this.pushTimeoutMs > 0)) return promise;
|
|
1160
|
+
return new Promise<T>((resolve, reject) => {
|
|
1161
|
+
const timer = setTimeout(() => {
|
|
1162
|
+
reject(new Error(`Mutation push timed out after ${this.pushTimeoutMs}ms (${label})`));
|
|
1163
|
+
}, this.pushTimeoutMs);
|
|
1164
|
+
promise.then(
|
|
1165
|
+
(value) => {
|
|
1166
|
+
clearTimeout(timer);
|
|
1167
|
+
resolve(value);
|
|
1168
|
+
},
|
|
1169
|
+
(err) => {
|
|
1170
|
+
clearTimeout(timer);
|
|
1171
|
+
reject(err);
|
|
1172
|
+
}
|
|
1173
|
+
);
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1080
1177
|
private async processUpEvent(event: UpEvent) {
|
|
1081
1178
|
this.logger.debug(
|
|
1082
1179
|
{ event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
|
|
@@ -1089,22 +1186,31 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1089
1186
|
dataKeys.map(({ key, variable }) => [variable, event.data[key]])
|
|
1090
1187
|
);
|
|
1091
1188
|
const query = surql.seal(surql.createSet('id', dataKeys));
|
|
1092
|
-
await this.
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1189
|
+
await this.withPushTimeout(
|
|
1190
|
+
this.remote.query(query, {
|
|
1191
|
+
id: event.record_id,
|
|
1192
|
+
...prefixedParams,
|
|
1193
|
+
}),
|
|
1194
|
+
'create'
|
|
1195
|
+
);
|
|
1096
1196
|
break;
|
|
1097
1197
|
}
|
|
1098
1198
|
case 'update':
|
|
1099
|
-
await this.
|
|
1100
|
-
id
|
|
1101
|
-
|
|
1102
|
-
|
|
1199
|
+
await this.withPushTimeout(
|
|
1200
|
+
this.remote.query(`UPDATE $id MERGE $data`, {
|
|
1201
|
+
id: event.record_id,
|
|
1202
|
+
data: event.data,
|
|
1203
|
+
}),
|
|
1204
|
+
'update'
|
|
1205
|
+
);
|
|
1103
1206
|
break;
|
|
1104
1207
|
case 'delete':
|
|
1105
|
-
await this.
|
|
1106
|
-
id
|
|
1107
|
-
|
|
1208
|
+
await this.withPushTimeout(
|
|
1209
|
+
this.remote.query(`DELETE $id`, {
|
|
1210
|
+
id: event.record_id,
|
|
1211
|
+
}),
|
|
1212
|
+
'delete'
|
|
1213
|
+
);
|
|
1108
1214
|
break;
|
|
1109
1215
|
default:
|
|
1110
1216
|
this.logger.error(
|
|
@@ -1118,9 +1224,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1118
1224
|
private async handleRollback(event: UpEvent, error: Error): Promise<void> {
|
|
1119
1225
|
const recordId = encodeRecordId(event.record_id);
|
|
1120
1226
|
const tableName =
|
|
1121
|
-
event.type === 'create' && event.tableName
|
|
1122
|
-
? event.tableName
|
|
1123
|
-
: extractTablePart(recordId);
|
|
1227
|
+
event.type === 'create' && event.tableName ? event.tableName : extractTablePart(recordId);
|
|
1124
1228
|
|
|
1125
1229
|
this.logger.warn(
|
|
1126
1230
|
{
|
|
@@ -1448,7 +1552,11 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1448
1552
|
// empty. Best-effort: never fail registration over it.
|
|
1449
1553
|
await this.syncSubqueryChildren(queryHash).catch((err) => {
|
|
1450
1554
|
this.logger.info(
|
|
1451
|
-
{
|
|
1555
|
+
{
|
|
1556
|
+
err: (err as Error)?.message ?? err,
|
|
1557
|
+
queryHash,
|
|
1558
|
+
Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
|
|
1559
|
+
},
|
|
1452
1560
|
'Subquery child sync failed during registration; poll will retry'
|
|
1453
1561
|
);
|
|
1454
1562
|
});
|
package/src/utils/surql.ts
CHANGED
|
@@ -91,17 +91,9 @@ export const surql: SurqlHelper = {
|
|
|
91
91
|
returnValues: ({ field: string; alias: string } | string)[]
|
|
92
92
|
) {
|
|
93
93
|
return `SELECT ${returnValues
|
|
94
|
-
.map((rv) =>
|
|
95
|
-
typeof rv === 'string'
|
|
96
|
-
? rv
|
|
97
|
-
: `${rv.field} as ${rv.alias}`
|
|
98
|
-
)
|
|
94
|
+
.map((rv) => (typeof rv === 'string' ? rv : `${rv.field} as ${rv.alias}`))
|
|
99
95
|
.join(',')} FROM ${table} WHERE ${whereVar
|
|
100
|
-
.map((wv) =>
|
|
101
|
-
typeof wv === 'string'
|
|
102
|
-
? `${wv} = $${wv}`
|
|
103
|
-
: `${wv.field} = $${wv.variable}`
|
|
104
|
-
)
|
|
96
|
+
.map((wv) => (typeof wv === 'string' ? `${wv} = $${wv}` : `${wv.field} = $${wv.variable}`))
|
|
105
97
|
.join(' AND ')}`;
|
|
106
98
|
},
|
|
107
99
|
|
|
@@ -172,7 +164,18 @@ export const surql: SurqlHelper = {
|
|
|
172
164
|
) {
|
|
173
165
|
switch (t) {
|
|
174
166
|
case 'create':
|
|
175
|
-
|
|
167
|
+
// `data` is REQUIRED here, not decorative. The outbox row is the only
|
|
168
|
+
// copy of a pending create once the in-memory UpEvent is gone (reload,
|
|
169
|
+
// or a shared-tabs follower whose row is replayed by the leader). This
|
|
170
|
+
// used to drop `dataVar` on the floor, so every replayed create arrived
|
|
171
|
+
// with `data: undefined` and `processUpEvent` threw on
|
|
172
|
+
// `Object.keys(event.data)` before it ever reached the network: the
|
|
173
|
+
// create was unsendable AND it sat at the head of the queue blocking
|
|
174
|
+
// every later mutation. The payload was simply never persisted, so
|
|
175
|
+
// those creates were unrecoverable.
|
|
176
|
+
return dataVar
|
|
177
|
+
? `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}, data = $${dataVar}`
|
|
178
|
+
: `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
|
|
176
179
|
case 'update': {
|
|
177
180
|
let stmt = `CREATE ONLY $${mutationIdVar} SET mutationType = 'update', recordId = $${recordIdVar}, data = $${dataVar}`;
|
|
178
181
|
if (beforeRecordVar) {
|