@peers-app/peers-sdk 0.25.8 → 0.25.9
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/contracts/system/network-contracts.d.ts +81 -1
- package/dist/contracts/system/network-contracts.js +55 -2
- package/dist/data/change-tracking.d.ts +5 -0
- package/dist/data/change-tracking.js +18 -0
- package/dist/data/orm/sql-db.d.ts +3 -1
- package/dist/data/orm/table.d.ts +19 -0
- package/dist/data/orm/table.js +18 -0
- package/dist/data/persistent-vars.conflict.test.d.ts +1 -0
- package/dist/data/persistent-vars.conflict.test.js +126 -0
- package/dist/data/persistent-vars.d.ts +13 -0
- package/dist/data/persistent-vars.js +53 -0
- package/dist/data/sync-recovery-queue.d.ts +77 -0
- package/dist/data/sync-recovery-queue.js +128 -1
- package/dist/data/sync-recovery-queue.test.js +78 -1
- package/dist/rpc-types.d.ts +0 -3
- package/dist/rpc-types.js +0 -3
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ export declare const networkDiagnosticsToolIds: {
|
|
|
17
17
|
readonly getGroupDetails: "00ms1yb50yuru8h1fg4t9na3e";
|
|
18
18
|
readonly getDisconnectedDevices: "00ms1yb50y3jpoby5zomltahr";
|
|
19
19
|
readonly queryRemoteLogs: "00ms1yb50y2fbfnth22yz52eu";
|
|
20
|
+
readonly getSyncRecoveryEntries: "00mujw2tgbpoohxvwd23u9moz";
|
|
20
21
|
};
|
|
21
22
|
/** Stable IDs for tools in System Network Control v1. */
|
|
22
23
|
export declare const networkControlToolIds: {
|
|
@@ -26,6 +27,9 @@ export declare const networkControlToolIds: {
|
|
|
26
27
|
readonly deleteDevice: "00ms1yb50y5mc448nuavlex86";
|
|
27
28
|
readonly syncGroupNow: "00ms1yb50ylnqlra26jx232py";
|
|
28
29
|
readonly resetChangeTracking: "00ms1yb50yfuackpl1w37hmxc";
|
|
30
|
+
readonly retrySyncRecovery: "00mujw2tgcelvpg556n836ah7";
|
|
31
|
+
readonly resolveSyncRecovery: "00mujw2tgciywn8krykc6gmwx";
|
|
32
|
+
readonly resetDeviceSyncInfo: "00mujw2tgc5lrezqrw4oneaot";
|
|
29
33
|
};
|
|
30
34
|
/** Overall status shown by the shell's compact network indicator. */
|
|
31
35
|
export type NetworkStatusState = "offline" | "syncing" | "synced" | "error";
|
|
@@ -51,6 +55,11 @@ export interface INetworkStatusSummary {
|
|
|
51
55
|
disconnectedContextCount: number;
|
|
52
56
|
/** Sync contexts that currently have at least one live SyncGroup connection. */
|
|
53
57
|
connectedContextCount: number;
|
|
58
|
+
/**
|
|
59
|
+
* Recovery-queue entries that are parked or have been retrying long enough
|
|
60
|
+
* that an operator should look at them. Zero means the indicator stays green.
|
|
61
|
+
*/
|
|
62
|
+
stuckRecoveryCount: number;
|
|
54
63
|
lastSuccessfulSyncAt?: number;
|
|
55
64
|
errors: INetworkSyncError[];
|
|
56
65
|
}
|
|
@@ -176,6 +185,56 @@ export interface INetworkControlResult {
|
|
|
176
185
|
success: boolean;
|
|
177
186
|
message: string;
|
|
178
187
|
}
|
|
188
|
+
/** How an operator resolves one stuck recovery-queue entry. */
|
|
189
|
+
export type SyncRecoveryResolution = "keepLocal" | "deleteEverywhere" | "ignoreLocal";
|
|
190
|
+
/** One recovery-queue entry prepared for the Sync Recovery screen. */
|
|
191
|
+
export interface INetworkSyncRecoveryEntry {
|
|
192
|
+
dataContextId: string;
|
|
193
|
+
dataContextName: string;
|
|
194
|
+
syncRecoveryId: string;
|
|
195
|
+
tableName: string;
|
|
196
|
+
recordId: string;
|
|
197
|
+
firstFailedAt: number;
|
|
198
|
+
lastFailedAt: number;
|
|
199
|
+
attemptCount: number;
|
|
200
|
+
latestError: string;
|
|
201
|
+
status: "retrying" | "parked";
|
|
202
|
+
parkedReason?: "schema" | "unique-conflict" | "unresolvable";
|
|
203
|
+
stuck: boolean;
|
|
204
|
+
/** True when this device has a row that "Keep this device's version" can snapshot. */
|
|
205
|
+
localRowExists: boolean;
|
|
206
|
+
/** Other record id holding the same unique key, when the failure is a unique conflict. */
|
|
207
|
+
conflictingRecordId?: string;
|
|
208
|
+
/**
|
|
209
|
+
* Record id Keep will tombstone.
|
|
210
|
+
* The other stored row when both exist, or this queued id when only the other row is stored.
|
|
211
|
+
*/
|
|
212
|
+
tombstoneRecordId?: string;
|
|
213
|
+
/**
|
|
214
|
+
* Short label taken from the row Keep will snapshot, or from queued history when nothing is stored.
|
|
215
|
+
*/
|
|
216
|
+
recordSummary?: string;
|
|
217
|
+
/**
|
|
218
|
+
* Pretty-printed row Keep will snapshot, with secret values hidden.
|
|
219
|
+
* Dates and other special values use the change-history encoding (`__DATE`, and so on).
|
|
220
|
+
*/
|
|
221
|
+
keptRecordPreview?: string;
|
|
222
|
+
/**
|
|
223
|
+
* Pretty-printed queued history when it differs from {@link keptRecordPreview}.
|
|
224
|
+
* Uses the same secret hiding and change-history encoding.
|
|
225
|
+
*/
|
|
226
|
+
queuedRecordPreview?: string;
|
|
227
|
+
/**
|
|
228
|
+
* Pretty-printed stored record, with secret values hidden.
|
|
229
|
+
* Prefers the row Keep will snapshot, then queued history.
|
|
230
|
+
*/
|
|
231
|
+
recordPreview?: string;
|
|
232
|
+
/**
|
|
233
|
+
* Author time of the change-tracking row that failed to materialize.
|
|
234
|
+
* Hybrid-logical-clock milliseconds. Omitted when no change row is stored.
|
|
235
|
+
*/
|
|
236
|
+
changeCreatedAt?: number;
|
|
237
|
+
}
|
|
179
238
|
/** Typed consumer proxy for System Network Diagnostics v1. */
|
|
180
239
|
export interface INetworkDiagnosticsConsumer extends IContractConsumerBase {
|
|
181
240
|
tools: {
|
|
@@ -207,6 +266,11 @@ export interface INetworkDiagnosticsConsumer extends IContractConsumerBase {
|
|
|
207
266
|
deviceId: string;
|
|
208
267
|
query?: INetworkRemoteLogsQuery;
|
|
209
268
|
}): Promise<INetworkRemoteLogsResult>;
|
|
269
|
+
getSyncRecoveryEntries(input?: {
|
|
270
|
+
dataContextId?: string;
|
|
271
|
+
}): Promise<{
|
|
272
|
+
entries: INetworkSyncRecoveryEntry[];
|
|
273
|
+
}>;
|
|
210
274
|
};
|
|
211
275
|
observables: {
|
|
212
276
|
status: Observable<INetworkStatusSummary>;
|
|
@@ -228,8 +292,24 @@ export interface INetworkControlConsumer extends IContractConsumerBase {
|
|
|
228
292
|
syncGroupNow(input: {
|
|
229
293
|
groupId: string;
|
|
230
294
|
}): Promise<INetworkControlResult>;
|
|
231
|
-
resetChangeTracking(input
|
|
295
|
+
resetChangeTracking(input?: {
|
|
296
|
+
dataContextId?: string;
|
|
297
|
+
}): Promise<INetworkControlResult>;
|
|
298
|
+
resetDeviceSyncInfo(input?: {
|
|
299
|
+
dataContextId?: string;
|
|
300
|
+
}): Promise<INetworkControlResult>;
|
|
301
|
+
retrySyncRecovery(input: {
|
|
302
|
+
dataContextId: string;
|
|
303
|
+
tableName?: string;
|
|
304
|
+
recordId?: string;
|
|
305
|
+
syncRecoveryId?: string;
|
|
306
|
+
}): Promise<INetworkControlResult>;
|
|
307
|
+
resolveSyncRecovery(input: {
|
|
232
308
|
dataContextId: string;
|
|
309
|
+
tableName: string;
|
|
310
|
+
recordId: string;
|
|
311
|
+
resolution: SyncRecoveryResolution;
|
|
312
|
+
syncRecoveryId?: string;
|
|
233
313
|
}): Promise<INetworkControlResult>;
|
|
234
314
|
};
|
|
235
315
|
}
|
|
@@ -19,6 +19,7 @@ exports.networkDiagnosticsToolIds = {
|
|
|
19
19
|
getGroupDetails: "00ms1yb50yuru8h1fg4t9na3e",
|
|
20
20
|
getDisconnectedDevices: "00ms1yb50y3jpoby5zomltahr",
|
|
21
21
|
queryRemoteLogs: "00ms1yb50y2fbfnth22yz52eu",
|
|
22
|
+
getSyncRecoveryEntries: "00mujw2tgbpoohxvwd23u9moz",
|
|
22
23
|
};
|
|
23
24
|
/** Stable IDs for tools in System Network Control v1. */
|
|
24
25
|
exports.networkControlToolIds = {
|
|
@@ -28,6 +29,9 @@ exports.networkControlToolIds = {
|
|
|
28
29
|
deleteDevice: "00ms1yb50y5mc448nuavlex86",
|
|
29
30
|
syncGroupNow: "00ms1yb50ylnqlra26jx232py",
|
|
30
31
|
resetChangeTracking: "00ms1yb50yfuackpl1w37hmxc",
|
|
32
|
+
retrySyncRecovery: "00mujw2tgcelvpg556n836ah7",
|
|
33
|
+
resolveSyncRecovery: "00mujw2tgciywn8krykc6gmwx",
|
|
34
|
+
resetDeviceSyncInfo: "00mujw2tgc5lrezqrw4oneaot",
|
|
31
35
|
};
|
|
32
36
|
const field = (name, type, description, opts = {}) => ({ name, type, description, ...opts });
|
|
33
37
|
let diagnosticsDefinition;
|
|
@@ -106,6 +110,18 @@ function getNetworkDiagnosticsContractDefinition() {
|
|
|
106
110
|
field("queriedAt", field_type_1.FieldType.number, "Local completion timestamp."),
|
|
107
111
|
],
|
|
108
112
|
},
|
|
113
|
+
{
|
|
114
|
+
name: "getSyncRecoveryEntries",
|
|
115
|
+
usageDescription: "List records whose synchronized changes could not be materialized.",
|
|
116
|
+
inputFields: [
|
|
117
|
+
field("dataContextId", field_type_1.FieldType.id, "Optional personal or group context ID.", {
|
|
118
|
+
optional: true,
|
|
119
|
+
}),
|
|
120
|
+
],
|
|
121
|
+
outputFields: [
|
|
122
|
+
field("entries", field_type_1.FieldType.object, "Recovery queue entries.", { isArray: true }),
|
|
123
|
+
],
|
|
124
|
+
},
|
|
109
125
|
],
|
|
110
126
|
observables: [
|
|
111
127
|
{
|
|
@@ -166,9 +182,46 @@ function getNetworkControlContractDefinition() {
|
|
|
166
182
|
},
|
|
167
183
|
{
|
|
168
184
|
name: "resetChangeTracking",
|
|
169
|
-
usageDescription: "Reset one data context
|
|
185
|
+
usageDescription: "Reset change tracking for one data context, or every local context when omitted.",
|
|
186
|
+
inputFields: [
|
|
187
|
+
field("dataContextId", field_type_1.FieldType.id, "Personal or group context ID to reset.", {
|
|
188
|
+
optional: true,
|
|
189
|
+
}),
|
|
190
|
+
],
|
|
191
|
+
outputFields: resultFields,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: "resetDeviceSyncInfo",
|
|
195
|
+
usageDescription: "Clear device sync checkpoints for one data context, or every local context when omitted.",
|
|
196
|
+
inputFields: [
|
|
197
|
+
field("dataContextId", field_type_1.FieldType.id, "Personal or group context ID to reset.", {
|
|
198
|
+
optional: true,
|
|
199
|
+
}),
|
|
200
|
+
],
|
|
201
|
+
outputFields: resultFields,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "retrySyncRecovery",
|
|
205
|
+
usageDescription: "Retry one recovery entry, or every entry in a data context.",
|
|
206
|
+
inputFields: [
|
|
207
|
+
field("dataContextId", field_type_1.FieldType.id, "Personal or group context ID."),
|
|
208
|
+
field("tableName", field_type_1.FieldType.string, "Table of one entry to retry.", { optional: true }),
|
|
209
|
+
field("recordId", field_type_1.FieldType.string, "Record id of one entry to retry.", {
|
|
210
|
+
optional: true,
|
|
211
|
+
}),
|
|
212
|
+
field("syncRecoveryId", field_type_1.FieldType.id, "Queue row to retry. When set, a replaced row is not retried.", { optional: true }),
|
|
213
|
+
],
|
|
214
|
+
outputFields: resultFields,
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "resolveSyncRecovery",
|
|
218
|
+
usageDescription: "Resolve one recovery entry by snapshotting this device, tombstoning the record, or ignoring it locally.",
|
|
170
219
|
inputFields: [
|
|
171
|
-
field("dataContextId", field_type_1.FieldType.id, "Personal or group context ID
|
|
220
|
+
field("dataContextId", field_type_1.FieldType.id, "Personal or group context ID."),
|
|
221
|
+
field("tableName", field_type_1.FieldType.string, "Table of the queued record."),
|
|
222
|
+
field("recordId", field_type_1.FieldType.string, "Record id of the queued record."),
|
|
223
|
+
field("resolution", field_type_1.FieldType.string, "keepLocal, deleteEverywhere, or ignoreLocal."),
|
|
224
|
+
field("syncRecoveryId", field_type_1.FieldType.id, "Queue row to resolve. When set, a replaced row is left unchanged.", { optional: true }),
|
|
172
225
|
],
|
|
173
226
|
outputFields: resultFields,
|
|
174
227
|
},
|
|
@@ -133,6 +133,11 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
|
133
133
|
* than `supersededAt`. Used after a root snapshot, delete, or restore.
|
|
134
134
|
*/
|
|
135
135
|
markAllPriorChangesSuperseded(tableName: string, recordId: string, supersededAt: number): Promise<void>;
|
|
136
|
+
/**
|
|
137
|
+
* Synchronous form of {@link markAllPriorChangesSuperseded} for an open
|
|
138
|
+
* writer transaction. The table must already be initialized.
|
|
139
|
+
*/
|
|
140
|
+
markAllPriorChangesSupersededSync(tableName: string, recordId: string, supersededAt: number): void;
|
|
136
141
|
/**
|
|
137
142
|
* Batch update supersededAt timestamps for multiple changes.
|
|
138
143
|
* More efficient than updating one at a time.
|
|
@@ -236,6 +236,24 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
236
236
|
supersededAt IS NULL
|
|
237
237
|
`, { tableName, recordId, supersededAt });
|
|
238
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Synchronous form of {@link markAllPriorChangesSuperseded} for an open
|
|
241
|
+
* writer transaction. The table must already be initialized.
|
|
242
|
+
*/
|
|
243
|
+
markAllPriorChangesSupersededSync(tableName, recordId, supersededAt) {
|
|
244
|
+
if (!this.db.execSync) {
|
|
245
|
+
throw new Error("Recovery writes require a synchronous database");
|
|
246
|
+
}
|
|
247
|
+
this.db.execSync(`
|
|
248
|
+
UPDATE "${this.tableName}"
|
|
249
|
+
SET supersededAt = $supersededAt
|
|
250
|
+
WHERE
|
|
251
|
+
tableName = $tableName AND
|
|
252
|
+
recordId = $recordId AND
|
|
253
|
+
createdAt < $supersededAt AND
|
|
254
|
+
supersededAt IS NULL
|
|
255
|
+
`, { tableName, recordId, supersededAt });
|
|
256
|
+
}
|
|
239
257
|
/**
|
|
240
258
|
* Batch update supersededAt timestamps for multiple changes.
|
|
241
259
|
* More efficient than updating one at a time.
|
|
@@ -5,7 +5,9 @@ export interface ISqlDb {
|
|
|
5
5
|
changes: number;
|
|
6
6
|
}>;
|
|
7
7
|
close: () => Promise<void>;
|
|
8
|
-
execSync?: (sql: string, params?: any) =>
|
|
8
|
+
execSync?: (sql: string, params?: any) => {
|
|
9
|
+
changes: number;
|
|
10
|
+
};
|
|
9
11
|
getSync?: (sql: string, params?: any) => any;
|
|
10
12
|
allSync?: (sql: string, params?: any) => any[];
|
|
11
13
|
/**
|
package/dist/data/orm/table.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ import { type DataFilter, type ICursorIterable, type IDataQueryParams, type IDat
|
|
|
4
4
|
import { type IDataChangedEvent } from "./subscribable.data-source";
|
|
5
5
|
import type { ITableDependencies } from "./table-dependencies";
|
|
6
6
|
import { type ITableMetaData } from "./types";
|
|
7
|
+
/**
|
|
8
|
+
* ORM table over a data source, including change notifications and validation.
|
|
9
|
+
*
|
|
10
|
+
* Subclasses override {@link Table.resolveUniqueConflict} when a unique index can be
|
|
11
|
+
* settled from the two competing records alone. The base implementation declines so
|
|
12
|
+
* sync leaves the conflict queued instead of deleting either row.
|
|
13
|
+
*/
|
|
7
14
|
export declare class Table<T extends {
|
|
8
15
|
[key: string]: any;
|
|
9
16
|
}> implements IDataSource<T> {
|
|
@@ -37,7 +44,19 @@ export declare class Table<T extends {
|
|
|
37
44
|
update(record: T): Promise<T>;
|
|
38
45
|
delete(idOrRecord: string | T): Promise<void>;
|
|
39
46
|
initRecord(data?: Partial<T>): T;
|
|
47
|
+
/**
|
|
48
|
+
* Choose which record should own a contested unique key.
|
|
49
|
+
*
|
|
50
|
+
* Return undefined to leave the conflict queued for an operator. Overrides must be a
|
|
51
|
+
* pure function of the two records so every device computes the same winner.
|
|
52
|
+
* `loserId` is the primary key of the record that should be tombstoned.
|
|
53
|
+
*/
|
|
54
|
+
resolveUniqueConflict(_incoming: T, _existing: T): {
|
|
55
|
+
winner: T;
|
|
56
|
+
loserId: string;
|
|
57
|
+
} | undefined;
|
|
40
58
|
initDoc(record: T): import("./doc").IDoc<T>;
|
|
41
59
|
getDoc(id: string): import("./doc").IDoc<T>;
|
|
42
60
|
}
|
|
61
|
+
/** Bootstrap icon class for a table, falling back to a question mark. */
|
|
43
62
|
export declare function getIconClassName(table: Table<any>): string;
|
package/dist/data/orm/table.js
CHANGED
|
@@ -13,6 +13,13 @@ const doc_1 = require("./doc");
|
|
|
13
13
|
const sql_data_source_1 = require("./sql.data-source");
|
|
14
14
|
const subscribable_data_source_1 = require("./subscribable.data-source");
|
|
15
15
|
const types_1 = require("./types");
|
|
16
|
+
/**
|
|
17
|
+
* ORM table over a data source, including change notifications and validation.
|
|
18
|
+
*
|
|
19
|
+
* Subclasses override {@link Table.resolveUniqueConflict} when a unique index can be
|
|
20
|
+
* settled from the two competing records alone. The base implementation declines so
|
|
21
|
+
* sync leaves the conflict queued instead of deleting either row.
|
|
22
|
+
*/
|
|
16
23
|
class Table {
|
|
17
24
|
tableName;
|
|
18
25
|
primaryKeyName;
|
|
@@ -241,6 +248,16 @@ class Table {
|
|
|
241
248
|
}
|
|
242
249
|
return { ...defaults, ...data };
|
|
243
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Choose which record should own a contested unique key.
|
|
253
|
+
*
|
|
254
|
+
* Return undefined to leave the conflict queued for an operator. Overrides must be a
|
|
255
|
+
* pure function of the two records so every device computes the same winner.
|
|
256
|
+
* `loserId` is the primary key of the record that should be tombstoned.
|
|
257
|
+
*/
|
|
258
|
+
resolveUniqueConflict(_incoming, _existing) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
244
261
|
initDoc(record) {
|
|
245
262
|
return (0, doc_1.initDoc)(record, this);
|
|
246
263
|
}
|
|
@@ -252,6 +269,7 @@ class Table {
|
|
|
252
269
|
}
|
|
253
270
|
}
|
|
254
271
|
exports.Table = Table;
|
|
272
|
+
/** Bootstrap icon class for a table, falling back to a question mark. */
|
|
255
273
|
function getIconClassName(table) {
|
|
256
274
|
return table.metaData.iconClassName ?? "bi bi-question";
|
|
257
275
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const originalClientFlag = process.env.PEERS_IS_CLIENT;
|
|
4
|
+
function createEventRegistry() {
|
|
5
|
+
return {
|
|
6
|
+
getEmitter: jest.fn().mockReturnValue({
|
|
7
|
+
event: { subscribe: jest.fn() },
|
|
8
|
+
emit: jest.fn(),
|
|
9
|
+
}),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function pvar(overrides) {
|
|
13
|
+
return {
|
|
14
|
+
name: "shared-name",
|
|
15
|
+
scope: "user",
|
|
16
|
+
value: { value: overrides.persistentVarId },
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (originalClientFlag === undefined) {
|
|
22
|
+
delete process.env.PEERS_IS_CLIENT;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
process.env.PEERS_IS_CLIENT = originalClientFlag;
|
|
26
|
+
}
|
|
27
|
+
jest.resetModules();
|
|
28
|
+
});
|
|
29
|
+
describe("PersistentVars unique-name conflict", () => {
|
|
30
|
+
async function table() {
|
|
31
|
+
delete process.env.PEERS_IS_CLIENT;
|
|
32
|
+
jest.resetModules();
|
|
33
|
+
let loaded;
|
|
34
|
+
await jest.isolateModulesAsync(async () => {
|
|
35
|
+
jest.doMock("../context/user-context-singleton", () => ({
|
|
36
|
+
getTableContainer: jest.fn(),
|
|
37
|
+
getUserContext: jest.fn(),
|
|
38
|
+
}));
|
|
39
|
+
const { PersistentVarsTable, persistentVarsMetaData } = await Promise.resolve().then(() => require("./persistent-vars"));
|
|
40
|
+
const { deterministicPvarId } = await Promise.resolve().then(() => require("../utils"));
|
|
41
|
+
loaded = {
|
|
42
|
+
deterministicPvarId,
|
|
43
|
+
vars: new PersistentVarsTable(persistentVarsMetaData, {
|
|
44
|
+
dataSource: {},
|
|
45
|
+
eventRegistry: createEventRegistry(),
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
if (!loaded) {
|
|
50
|
+
throw new Error("PersistentVarsTable failed to load");
|
|
51
|
+
}
|
|
52
|
+
return loaded;
|
|
53
|
+
}
|
|
54
|
+
it("keeps the deterministic id and the newer value regardless of argument order", async () => {
|
|
55
|
+
const { deterministicPvarId, vars } = await table();
|
|
56
|
+
const legacy = pvar({
|
|
57
|
+
persistentVarId: "00mriijkhq94msfe9m2j6quwp",
|
|
58
|
+
value: { value: "from-legacy" },
|
|
59
|
+
modifiedAt: 20,
|
|
60
|
+
scope: "groupUser",
|
|
61
|
+
isSecret: true,
|
|
62
|
+
});
|
|
63
|
+
const deterministic = pvar({
|
|
64
|
+
persistentVarId: deterministicPvarId(legacy.name),
|
|
65
|
+
value: { value: "from-deterministic" },
|
|
66
|
+
modifiedAt: 10,
|
|
67
|
+
});
|
|
68
|
+
const forward = vars.resolveUniqueConflict(legacy, deterministic);
|
|
69
|
+
const reverse = vars.resolveUniqueConflict(deterministic, legacy);
|
|
70
|
+
expect(forward).toEqual(reverse);
|
|
71
|
+
expect(forward).toEqual({
|
|
72
|
+
loserId: legacy.persistentVarId,
|
|
73
|
+
winner: expect.objectContaining({
|
|
74
|
+
persistentVarId: deterministic.persistentVarId,
|
|
75
|
+
name: legacy.name,
|
|
76
|
+
scope: "groupUser",
|
|
77
|
+
isSecret: true,
|
|
78
|
+
value: { value: "from-legacy" },
|
|
79
|
+
modifiedAt: 20,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
it("keeps the identity winner's value when modifiedAt ties", async () => {
|
|
84
|
+
const { deterministicPvarId, vars } = await table();
|
|
85
|
+
const legacy = pvar({
|
|
86
|
+
persistentVarId: "00mriijkhq94msfe9m2j6quwp",
|
|
87
|
+
value: { value: "legacy-value" },
|
|
88
|
+
modifiedAt: 5,
|
|
89
|
+
});
|
|
90
|
+
const deterministic = pvar({
|
|
91
|
+
persistentVarId: deterministicPvarId(legacy.name),
|
|
92
|
+
value: { value: "deterministic-value" },
|
|
93
|
+
modifiedAt: 5,
|
|
94
|
+
});
|
|
95
|
+
expect(vars.resolveUniqueConflict(legacy, deterministic)?.winner.value).toEqual({
|
|
96
|
+
value: "deterministic-value",
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
it("uses the smaller id when neither record is deterministic", async () => {
|
|
100
|
+
const { vars } = await table();
|
|
101
|
+
const larger = pvar({
|
|
102
|
+
persistentVarId: "00mriijkhq94msfe9m2j6quwz",
|
|
103
|
+
value: { value: "larger-newer" },
|
|
104
|
+
modifiedAt: 9,
|
|
105
|
+
});
|
|
106
|
+
const smaller = pvar({
|
|
107
|
+
persistentVarId: "00mriijkhq94msfe9m2j6quwa",
|
|
108
|
+
value: { value: "smaller-older" },
|
|
109
|
+
modifiedAt: 1,
|
|
110
|
+
});
|
|
111
|
+
expect(vars.resolveUniqueConflict(larger, smaller)).toEqual({
|
|
112
|
+
loserId: larger.persistentVarId,
|
|
113
|
+
winner: expect.objectContaining({
|
|
114
|
+
persistentVarId: smaller.persistentVarId,
|
|
115
|
+
value: { value: "larger-newer" },
|
|
116
|
+
modifiedAt: 9,
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
it("declines when the records are not two ids for one name", async () => {
|
|
121
|
+
const { vars } = await table();
|
|
122
|
+
const row = pvar({ persistentVarId: "00mriijkhq94msfe9m2j6quwp" });
|
|
123
|
+
expect(vars.resolveUniqueConflict(row, row)).toBeUndefined();
|
|
124
|
+
expect(vars.resolveUniqueConflict(row, pvar({ persistentVarId: "00mriijkhq94msfe9m2j6quwq", name: "other" }))).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -70,6 +70,19 @@ export declare class PersistentVarsTable extends Table<IPersistentVar> {
|
|
|
70
70
|
* deterministic record already exists, it wins and the duplicate legacy row is removed.
|
|
71
71
|
*/
|
|
72
72
|
migrateLegacyId(persistentVar: IPersistentVar): Promise<IPersistentVar>;
|
|
73
|
+
/**
|
|
74
|
+
* Settle two rows that share a `name`.
|
|
75
|
+
*
|
|
76
|
+
* The deterministic id owns the name when either row has one; otherwise the
|
|
77
|
+
* lexicographically smaller id does. The value, scope, and secret flag come from
|
|
78
|
+
* the row with the greater `modifiedAt` (missing counts as 0). A tie keeps the
|
|
79
|
+
* identity winner's value. Both devices see the same two change records, so they
|
|
80
|
+
* pick the same winner and the loser can be tombstoned.
|
|
81
|
+
*/
|
|
82
|
+
resolveUniqueConflict(incoming: IPersistentVar, existing: IPersistentVar): {
|
|
83
|
+
winner: IPersistentVar;
|
|
84
|
+
loserId: string;
|
|
85
|
+
} | undefined;
|
|
73
86
|
/** Return a stored variable's raw value by name. */
|
|
74
87
|
getPersistentVarValue<T>(name: string): Promise<T | undefined>;
|
|
75
88
|
}
|
|
@@ -91,6 +91,20 @@ exports.persistentVarsMetaData = {
|
|
|
91
91
|
iconClassName: "bi bi-braces",
|
|
92
92
|
indexes: [{ fields: ["name"], unique: true }],
|
|
93
93
|
};
|
|
94
|
+
/**
|
|
95
|
+
* Pick the record id that should own a duplicated persistent-variable name.
|
|
96
|
+
*
|
|
97
|
+
* A deterministic id beats a legacy random id. When neither (or both) is
|
|
98
|
+
* deterministic, the lexicographically smaller id wins.
|
|
99
|
+
*/
|
|
100
|
+
function persistentVarIdentityWinner(left, right) {
|
|
101
|
+
const leftDeterministic = (0, utils_1.isDeterministicPvarId)(left.persistentVarId);
|
|
102
|
+
const rightDeterministic = (0, utils_1.isDeterministicPvarId)(right.persistentVarId);
|
|
103
|
+
if (leftDeterministic !== rightDeterministic) {
|
|
104
|
+
return leftDeterministic ? left : right;
|
|
105
|
+
}
|
|
106
|
+
return left.persistentVarId < right.persistentVarId ? left : right;
|
|
107
|
+
}
|
|
94
108
|
/** Persistent-variable table with deterministic IDs and secret-value handling. */
|
|
95
109
|
let PersistentVarsTable = (() => {
|
|
96
110
|
let _classSuper = table_1.Table;
|
|
@@ -156,6 +170,45 @@ let PersistentVarsTable = (() => {
|
|
|
156
170
|
throw error;
|
|
157
171
|
}
|
|
158
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Settle two rows that share a `name`.
|
|
175
|
+
*
|
|
176
|
+
* The deterministic id owns the name when either row has one; otherwise the
|
|
177
|
+
* lexicographically smaller id does. The value, scope, and secret flag come from
|
|
178
|
+
* the row with the greater `modifiedAt` (missing counts as 0). A tie keeps the
|
|
179
|
+
* identity winner's value. Both devices see the same two change records, so they
|
|
180
|
+
* pick the same winner and the loser can be tombstoned.
|
|
181
|
+
*/
|
|
182
|
+
resolveUniqueConflict(incoming, existing) {
|
|
183
|
+
if (incoming.name !== existing.name) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
if (incoming.persistentVarId === existing.persistentVarId) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
const identity = persistentVarIdentityWinner(incoming, existing);
|
|
190
|
+
const other = identity === incoming ? existing : incoming;
|
|
191
|
+
const identityModified = identity.modifiedAt ?? 0;
|
|
192
|
+
const otherModified = other.modifiedAt ?? 0;
|
|
193
|
+
const valueSource = otherModified > identityModified ? other : identity;
|
|
194
|
+
const winner = {
|
|
195
|
+
persistentVarId: identity.persistentVarId,
|
|
196
|
+
name: identity.name,
|
|
197
|
+
scope: valueSource.scope,
|
|
198
|
+
value: valueSource.value,
|
|
199
|
+
modifiedAt: Math.max(identityModified, otherModified) || undefined,
|
|
200
|
+
};
|
|
201
|
+
if (valueSource.description !== undefined) {
|
|
202
|
+
winner.description = valueSource.description;
|
|
203
|
+
}
|
|
204
|
+
if (valueSource.userCreated !== undefined) {
|
|
205
|
+
winner.userCreated = valueSource.userCreated;
|
|
206
|
+
}
|
|
207
|
+
if (valueSource.isSecret !== undefined) {
|
|
208
|
+
winner.isSecret = valueSource.isSecret;
|
|
209
|
+
}
|
|
210
|
+
return { winner, loserId: other.persistentVarId };
|
|
211
|
+
}
|
|
159
212
|
/** Return a stored variable's raw value by name. */
|
|
160
213
|
async getPersistentVarValue(name) {
|
|
161
214
|
const pVars = await this.list({ name });
|
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { type ISqlDb, type ITableMetaData, SQLDataSource } from "./orm";
|
|
8
|
+
/** How many attempts mark a still-retrying entry as stuck. */
|
|
9
|
+
export declare const SYNC_RECOVERY_STUCK_ATTEMPTS = 5;
|
|
10
|
+
/** How long a still-retrying entry must have been failing before it is stuck. */
|
|
11
|
+
export declare const SYNC_RECOVERY_STUCK_AGE_MS: number;
|
|
12
|
+
/** Why automatic recovery stopped asking peers for an entry. */
|
|
13
|
+
export declare const syncRecoveryParkedReasons: readonly ["schema", "unique-conflict", "unresolvable"];
|
|
14
|
+
/** Why automatic recovery stopped asking peers for an entry. */
|
|
15
|
+
export type SyncRecoveryParkedReason = (typeof syncRecoveryParkedReasons)[number];
|
|
8
16
|
/** One recorded materialization failure for a synchronized record. */
|
|
9
17
|
export declare const syncRecoveryFailureSchema: z.ZodObject<{
|
|
10
18
|
error: z.ZodString;
|
|
@@ -40,6 +48,16 @@ export declare const syncRecoveryEntrySchema: z.ZodObject<{
|
|
|
40
48
|
erroredAt: number;
|
|
41
49
|
sourceDeviceId?: string | undefined;
|
|
42
50
|
}>, "many">;
|
|
51
|
+
/** Set when peer retry has stopped. Absent while the entry is still retrying. */
|
|
52
|
+
parkedAt: z.ZodOptional<z.ZodNumber>;
|
|
53
|
+
parkedReason: z.ZodOptional<z.ZodEnum<["schema", "unique-conflict", "unresolvable"]>>;
|
|
54
|
+
/** Earliest time a retrying entry should ask peers again. */
|
|
55
|
+
nextAttemptAt: z.ZodOptional<z.ZodNumber>;
|
|
56
|
+
/**
|
|
57
|
+
* How many due peer sweeps have failed to clear this entry.
|
|
58
|
+
* Local only. Backoff uses this instead of the materialization attempt count.
|
|
59
|
+
*/
|
|
60
|
+
sweepCount: z.ZodOptional<z.ZodNumber>;
|
|
43
61
|
}, "strip", z.ZodTypeAny, {
|
|
44
62
|
tableName: string;
|
|
45
63
|
recordId: string;
|
|
@@ -52,6 +70,10 @@ export declare const syncRecoveryEntrySchema: z.ZodObject<{
|
|
|
52
70
|
erroredAt: number;
|
|
53
71
|
sourceDeviceId?: string | undefined;
|
|
54
72
|
}[];
|
|
73
|
+
parkedAt?: number | undefined;
|
|
74
|
+
parkedReason?: "schema" | "unique-conflict" | "unresolvable" | undefined;
|
|
75
|
+
nextAttemptAt?: number | undefined;
|
|
76
|
+
sweepCount?: number | undefined;
|
|
55
77
|
}, {
|
|
56
78
|
tableName: string;
|
|
57
79
|
recordId: string;
|
|
@@ -64,11 +86,29 @@ export declare const syncRecoveryEntrySchema: z.ZodObject<{
|
|
|
64
86
|
erroredAt: number;
|
|
65
87
|
sourceDeviceId?: string | undefined;
|
|
66
88
|
}[];
|
|
89
|
+
parkedAt?: number | undefined;
|
|
90
|
+
parkedReason?: "schema" | "unique-conflict" | "unresolvable" | undefined;
|
|
91
|
+
nextAttemptAt?: number | undefined;
|
|
92
|
+
sweepCount?: number | undefined;
|
|
67
93
|
}>;
|
|
68
94
|
/** One recorded materialization failure for a synchronized record. */
|
|
69
95
|
export type ISyncRecoveryFailure = z.infer<typeof syncRecoveryFailureSchema>;
|
|
70
96
|
/** Durable local recovery state for one synchronized record. */
|
|
71
97
|
export type ISyncRecoveryEntry = z.infer<typeof syncRecoveryEntrySchema>;
|
|
98
|
+
/**
|
|
99
|
+
* True when an operator should look at this entry.
|
|
100
|
+
*
|
|
101
|
+
* Parked entries are stuck immediately. Retrying entries are stuck after
|
|
102
|
+
* {@link SYNC_RECOVERY_STUCK_ATTEMPTS} attempts spanning {@link SYNC_RECOVERY_STUCK_AGE_MS}.
|
|
103
|
+
*/
|
|
104
|
+
export declare function isStuckSyncRecoveryEntry(entry: Pick<ISyncRecoveryEntry, "parkedAt" | "attemptCount" | "firstFailedAt">, now?: number): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* When the earliest not-yet-stuck entry will cross the stuck age, if any will.
|
|
107
|
+
*
|
|
108
|
+
* Parked entries are already stuck. Entries below {@link SYNC_RECOVERY_STUCK_ATTEMPTS}
|
|
109
|
+
* do not become stuck by time alone.
|
|
110
|
+
*/
|
|
111
|
+
export declare function nextStuckSyncRecoveryAt(entries: Array<Pick<ISyncRecoveryEntry, "parkedAt" | "attemptCount" | "firstFailedAt">>, now?: number): number | undefined;
|
|
72
112
|
/** Input used to append a materialization failure to the recovery queue. */
|
|
73
113
|
export interface IRecordSyncFailure {
|
|
74
114
|
tableName: string;
|
|
@@ -106,7 +146,44 @@ export declare class SyncRecoveryQueueTable extends SQLDataSource<ISyncRecoveryE
|
|
|
106
146
|
* The queue table must be initialized before this method is called.
|
|
107
147
|
*/
|
|
108
148
|
clearRecordSync(tableName: string, recordId: string): void;
|
|
149
|
+
/**
|
|
150
|
+
* Delete one queue row only when its attempt count still matches.
|
|
151
|
+
*
|
|
152
|
+
* A failure recorded after the caller chose this generation changes
|
|
153
|
+
* `attemptCount`, so the delete matches nothing and throws. Call this inside
|
|
154
|
+
* the same transaction as the table write so that throw rolls the write back.
|
|
155
|
+
*/
|
|
156
|
+
clearGenerationSync(syncRecoveryId: string, attemptCount: number): void;
|
|
109
157
|
private mergeFailure;
|
|
158
|
+
/**
|
|
159
|
+
* Stop peer retry for an entry without removing it.
|
|
160
|
+
*
|
|
161
|
+
* Writes only the park columns. A failure recorded in the same window keeps
|
|
162
|
+
* its attempt count and history. Parking the same reason again keeps the
|
|
163
|
+
* original `parkedAt`.
|
|
164
|
+
*/
|
|
165
|
+
park(tableName: string, recordId: string, reason: SyncRecoveryParkedReason): Promise<void>;
|
|
166
|
+
/**
|
|
167
|
+
* Return an entry to peer retry.
|
|
168
|
+
*
|
|
169
|
+
* Clears only the park marker and any scheduled next attempt.
|
|
170
|
+
*/
|
|
171
|
+
unpark(tableName: string, recordId: string): Promise<void>;
|
|
172
|
+
/**
|
|
173
|
+
* Schedule the next peer retry for an entry that is still queued.
|
|
174
|
+
*
|
|
175
|
+
* Writes only `nextAttemptAt`.
|
|
176
|
+
*/
|
|
177
|
+
setNextAttempt(tableName: string, recordId: string, nextAttemptAt: number): Promise<void>;
|
|
178
|
+
/**
|
|
179
|
+
* Count one due peer sweep that did not clear the entry.
|
|
180
|
+
*
|
|
181
|
+
* Writes only `sweepCount` and returns the new value. Returns undefined when
|
|
182
|
+
* the row was removed before the update.
|
|
183
|
+
*/
|
|
184
|
+
noteUnresolvedSweep(tableName: string, recordId: string): Promise<number | undefined>;
|
|
185
|
+
/** Apply a column-level update without rewriting the rest of the queue row. */
|
|
186
|
+
private patchEntry;
|
|
110
187
|
}
|
|
111
188
|
/** Return the single SyncRecoveryQueue table associated with a database instance. */
|
|
112
189
|
export declare function syncRecoveryQueueTable(db: ISqlDb): SyncRecoveryQueueTable;
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* separately in ChangeTrackingV2.
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
-
exports.SyncRecoveryQueueTable = exports.syncRecoveryQueueMetaData = exports.syncRecoveryEntrySchema = exports.syncRecoveryFailureSchema = void 0;
|
|
8
|
+
exports.SyncRecoveryQueueTable = exports.syncRecoveryQueueMetaData = exports.syncRecoveryEntrySchema = exports.syncRecoveryFailureSchema = exports.syncRecoveryParkedReasons = exports.SYNC_RECOVERY_STUCK_AGE_MS = exports.SYNC_RECOVERY_STUCK_ATTEMPTS = void 0;
|
|
9
|
+
exports.isStuckSyncRecoveryEntry = isStuckSyncRecoveryEntry;
|
|
10
|
+
exports.nextStuckSyncRecoveryAt = nextStuckSyncRecoveryAt;
|
|
9
11
|
exports.syncRecoveryQueueTable = syncRecoveryQueueTable;
|
|
10
12
|
const zod_1 = require("zod");
|
|
11
13
|
const zod_types_1 = require("../types/zod-types");
|
|
@@ -13,6 +15,12 @@ const utils_1 = require("../utils");
|
|
|
13
15
|
const orm_1 = require("./orm");
|
|
14
16
|
const MAX_FAILURE_HISTORY = 10;
|
|
15
17
|
const MAX_ERROR_LENGTH = 1_000;
|
|
18
|
+
/** How many attempts mark a still-retrying entry as stuck. */
|
|
19
|
+
exports.SYNC_RECOVERY_STUCK_ATTEMPTS = 5;
|
|
20
|
+
/** How long a still-retrying entry must have been failing before it is stuck. */
|
|
21
|
+
exports.SYNC_RECOVERY_STUCK_AGE_MS = 10 * 60 * 1000;
|
|
22
|
+
/** Why automatic recovery stopped asking peers for an entry. */
|
|
23
|
+
exports.syncRecoveryParkedReasons = ["schema", "unique-conflict", "unresolvable"];
|
|
16
24
|
/** One recorded materialization failure for a synchronized record. */
|
|
17
25
|
exports.syncRecoveryFailureSchema = zod_1.z.object({
|
|
18
26
|
error: zod_1.z.string(),
|
|
@@ -28,7 +36,50 @@ exports.syncRecoveryEntrySchema = zod_1.z.object({
|
|
|
28
36
|
lastFailedAt: zod_1.z.number(),
|
|
29
37
|
attemptCount: zod_1.z.number(),
|
|
30
38
|
failures: zod_1.z.array(exports.syncRecoveryFailureSchema),
|
|
39
|
+
/** Set when peer retry has stopped. Absent while the entry is still retrying. */
|
|
40
|
+
parkedAt: zod_1.z.number().optional(),
|
|
41
|
+
parkedReason: zod_1.z.enum(exports.syncRecoveryParkedReasons).optional(),
|
|
42
|
+
/** Earliest time a retrying entry should ask peers again. */
|
|
43
|
+
nextAttemptAt: zod_1.z.number().optional(),
|
|
44
|
+
/**
|
|
45
|
+
* How many due peer sweeps have failed to clear this entry.
|
|
46
|
+
* Local only. Backoff uses this instead of the materialization attempt count.
|
|
47
|
+
*/
|
|
48
|
+
sweepCount: zod_1.z.number().optional(),
|
|
31
49
|
});
|
|
50
|
+
/**
|
|
51
|
+
* True when an operator should look at this entry.
|
|
52
|
+
*
|
|
53
|
+
* Parked entries are stuck immediately. Retrying entries are stuck after
|
|
54
|
+
* {@link SYNC_RECOVERY_STUCK_ATTEMPTS} attempts spanning {@link SYNC_RECOVERY_STUCK_AGE_MS}.
|
|
55
|
+
*/
|
|
56
|
+
function isStuckSyncRecoveryEntry(entry, now = Date.now()) {
|
|
57
|
+
if (entry.parkedAt != null) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return (entry.attemptCount >= exports.SYNC_RECOVERY_STUCK_ATTEMPTS &&
|
|
61
|
+
now - entry.firstFailedAt >= exports.SYNC_RECOVERY_STUCK_AGE_MS);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* When the earliest not-yet-stuck entry will cross the stuck age, if any will.
|
|
65
|
+
*
|
|
66
|
+
* Parked entries are already stuck. Entries below {@link SYNC_RECOVERY_STUCK_ATTEMPTS}
|
|
67
|
+
* do not become stuck by time alone.
|
|
68
|
+
*/
|
|
69
|
+
function nextStuckSyncRecoveryAt(entries, now = Date.now()) {
|
|
70
|
+
let earliest;
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (entry.parkedAt != null || entry.attemptCount < exports.SYNC_RECOVERY_STUCK_ATTEMPTS) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const due = entry.firstFailedAt + exports.SYNC_RECOVERY_STUCK_AGE_MS;
|
|
76
|
+
if (due <= now) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
earliest = earliest == null ? due : Math.min(earliest, due);
|
|
80
|
+
}
|
|
81
|
+
return earliest;
|
|
82
|
+
}
|
|
32
83
|
/** Metadata for the local-only SyncRecoveryQueue table. */
|
|
33
84
|
exports.syncRecoveryQueueMetaData = {
|
|
34
85
|
name: "SyncRecoveryQueue",
|
|
@@ -114,6 +165,22 @@ class SyncRecoveryQueueTable extends orm_1.SQLDataSource {
|
|
|
114
165
|
recordId,
|
|
115
166
|
]);
|
|
116
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Delete one queue row only when its attempt count still matches.
|
|
170
|
+
*
|
|
171
|
+
* A failure recorded after the caller chose this generation changes
|
|
172
|
+
* `attemptCount`, so the delete matches nothing and throws. Call this inside
|
|
173
|
+
* the same transaction as the table write so that throw rolls the write back.
|
|
174
|
+
*/
|
|
175
|
+
clearGenerationSync(syncRecoveryId, attemptCount) {
|
|
176
|
+
if (!this.db.execSync) {
|
|
177
|
+
throw new Error("Synchronous database operations are required for recovery queue updates");
|
|
178
|
+
}
|
|
179
|
+
const result = this.db.execSync(`DELETE FROM "${this.tableName}" WHERE syncRecoveryId = ? AND attemptCount = ?`, [syncRecoveryId, attemptCount]);
|
|
180
|
+
if (result.changes !== 1) {
|
|
181
|
+
throw new Error("Recovery entry changed");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
117
184
|
mergeFailure(existing, input) {
|
|
118
185
|
const erroredAt = (0, utils_1.getTimestamp)();
|
|
119
186
|
const failure = {
|
|
@@ -129,8 +196,68 @@ class SyncRecoveryQueueTable extends orm_1.SQLDataSource {
|
|
|
129
196
|
lastFailedAt: erroredAt,
|
|
130
197
|
attemptCount: (existing?.attemptCount ?? 0) + 1,
|
|
131
198
|
failures: [...(existing?.failures ?? []), failure].slice(-MAX_FAILURE_HISTORY),
|
|
199
|
+
parkedAt: existing?.parkedAt,
|
|
200
|
+
parkedReason: existing?.parkedReason,
|
|
201
|
+
nextAttemptAt: existing?.nextAttemptAt,
|
|
132
202
|
};
|
|
133
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Stop peer retry for an entry without removing it.
|
|
206
|
+
*
|
|
207
|
+
* Writes only the park columns. A failure recorded in the same window keeps
|
|
208
|
+
* its attempt count and history. Parking the same reason again keeps the
|
|
209
|
+
* original `parkedAt`.
|
|
210
|
+
*/
|
|
211
|
+
async park(tableName, recordId, reason) {
|
|
212
|
+
await this.patchEntry(`UPDATE "${this.tableName}"
|
|
213
|
+
SET parkedAt = COALESCE(parkedAt, ?), parkedReason = ?
|
|
214
|
+
WHERE tableName = ? AND recordId = ? AND IFNULL(parkedReason, '') != ?`, [(0, utils_1.getTimestamp)(), reason, tableName, recordId, reason]);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Return an entry to peer retry.
|
|
218
|
+
*
|
|
219
|
+
* Clears only the park marker and any scheduled next attempt.
|
|
220
|
+
*/
|
|
221
|
+
async unpark(tableName, recordId) {
|
|
222
|
+
await this.patchEntry(`UPDATE "${this.tableName}"
|
|
223
|
+
SET parkedAt = NULL, parkedReason = NULL, nextAttemptAt = NULL
|
|
224
|
+
WHERE tableName = ? AND recordId = ?`, [tableName, recordId]);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Schedule the next peer retry for an entry that is still queued.
|
|
228
|
+
*
|
|
229
|
+
* Writes only `nextAttemptAt`.
|
|
230
|
+
*/
|
|
231
|
+
async setNextAttempt(tableName, recordId, nextAttemptAt) {
|
|
232
|
+
await this.patchEntry(`UPDATE "${this.tableName}"
|
|
233
|
+
SET nextAttemptAt = ?
|
|
234
|
+
WHERE tableName = ? AND recordId = ?`, [nextAttemptAt, tableName, recordId]);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Count one due peer sweep that did not clear the entry.
|
|
238
|
+
*
|
|
239
|
+
* Writes only `sweepCount` and returns the new value. Returns undefined when
|
|
240
|
+
* the row was removed before the update.
|
|
241
|
+
*/
|
|
242
|
+
async noteUnresolvedSweep(tableName, recordId) {
|
|
243
|
+
await this.patchEntry(`UPDATE "${this.tableName}"
|
|
244
|
+
SET sweepCount = IFNULL(sweepCount, 0) + 1
|
|
245
|
+
WHERE tableName = ? AND recordId = ?`, [tableName, recordId]);
|
|
246
|
+
const entry = await this.findOne({ tableName, recordId });
|
|
247
|
+
return entry?.sweepCount;
|
|
248
|
+
}
|
|
249
|
+
/** Apply a column-level update without rewriting the rest of the queue row. */
|
|
250
|
+
async patchEntry(sql, params) {
|
|
251
|
+
await this.initTable();
|
|
252
|
+
if (this.db.runInTransaction && this.db.execSync) {
|
|
253
|
+
const execSync = this.db.execSync.bind(this.db);
|
|
254
|
+
this.db.runInTransaction(() => {
|
|
255
|
+
execSync(sql, params);
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
await this.db.exec(sql, params);
|
|
260
|
+
}
|
|
134
261
|
}
|
|
135
262
|
exports.SyncRecoveryQueueTable = SyncRecoveryQueueTable;
|
|
136
263
|
const recoveryQueuesByDb = new WeakMap();
|
|
@@ -21,7 +21,8 @@ class DBHarness {
|
|
|
21
21
|
return this.sqlite.prepare(sql).all(params);
|
|
22
22
|
}
|
|
23
23
|
execSync(sql, params = []) {
|
|
24
|
-
this.sqlite.prepare(sql).run(params);
|
|
24
|
+
const result = this.sqlite.prepare(sql).run(params);
|
|
25
|
+
return { changes: result.changes };
|
|
25
26
|
}
|
|
26
27
|
runInTransaction(fn) {
|
|
27
28
|
return this.sqlite.transaction(fn)();
|
|
@@ -135,4 +136,80 @@ describe("SyncRecoveryQueueTable", () => {
|
|
|
135
136
|
db.runInTransaction(() => queue.clearRecordSync("Notes", "note-1"));
|
|
136
137
|
await expect(queue.count()).resolves.toBe(0);
|
|
137
138
|
});
|
|
139
|
+
it("parks, unparks, and schedules the next attempt without dropping the entry", async () => {
|
|
140
|
+
await queue.recordFailure({
|
|
141
|
+
tableName: "Notes",
|
|
142
|
+
recordId: "note-1",
|
|
143
|
+
error: new Error("Validation on insert failed"),
|
|
144
|
+
});
|
|
145
|
+
await queue.park("Notes", "note-1", "schema");
|
|
146
|
+
await queue.park("Notes", "note-1", "schema");
|
|
147
|
+
const parked = await queue.findOne({ tableName: "Notes", recordId: "note-1" });
|
|
148
|
+
expect(parked?.parkedReason).toBe("schema");
|
|
149
|
+
expect(parked?.parkedAt).toEqual(expect.any(Number));
|
|
150
|
+
await queue.recordFailure({
|
|
151
|
+
tableName: "Notes",
|
|
152
|
+
recordId: "note-1",
|
|
153
|
+
error: new Error("Validation on insert failed again"),
|
|
154
|
+
});
|
|
155
|
+
const afterFailure = await queue.findOne({ tableName: "Notes", recordId: "note-1" });
|
|
156
|
+
expect(afterFailure).toMatchObject({
|
|
157
|
+
parkedReason: "schema",
|
|
158
|
+
parkedAt: parked?.parkedAt,
|
|
159
|
+
attemptCount: 2,
|
|
160
|
+
});
|
|
161
|
+
await queue.setNextAttempt("Notes", "note-1", 50);
|
|
162
|
+
await queue.unpark("Notes", "note-1");
|
|
163
|
+
const unparked = await queue.findOne({ tableName: "Notes", recordId: "note-1" });
|
|
164
|
+
expect(unparked?.parkedAt).toBeUndefined();
|
|
165
|
+
expect(unparked?.parkedReason).toBeUndefined();
|
|
166
|
+
expect(unparked?.nextAttemptAt).toBeUndefined();
|
|
167
|
+
await expect(queue.count()).resolves.toBe(1);
|
|
168
|
+
});
|
|
169
|
+
it("keeps attempt history when a failure is recorded during park", async () => {
|
|
170
|
+
await queue.recordFailure({
|
|
171
|
+
tableName: "Notes",
|
|
172
|
+
recordId: "note-1",
|
|
173
|
+
error: new Error("first"),
|
|
174
|
+
});
|
|
175
|
+
const originalExecSync = db.execSync.bind(db);
|
|
176
|
+
let injected = false;
|
|
177
|
+
db.execSync = (sql, params) => {
|
|
178
|
+
if (!injected && String(sql).includes("COALESCE(parkedAt")) {
|
|
179
|
+
injected = true;
|
|
180
|
+
queue.recordFailureSync({
|
|
181
|
+
tableName: "Notes",
|
|
182
|
+
recordId: "note-1",
|
|
183
|
+
error: new Error("second"),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return originalExecSync(sql, params);
|
|
187
|
+
};
|
|
188
|
+
await queue.park("Notes", "note-1", "schema");
|
|
189
|
+
const entry = await queue.findOne({ tableName: "Notes", recordId: "note-1" });
|
|
190
|
+
expect(entry?.attemptCount).toBe(2);
|
|
191
|
+
expect(entry?.failures.map((failure) => failure.error)).toEqual(["first", "second"]);
|
|
192
|
+
expect(entry?.parkedReason).toBe("schema");
|
|
193
|
+
expect(entry?.parkedAt).toEqual(expect.any(Number));
|
|
194
|
+
db.execSync = originalExecSync;
|
|
195
|
+
});
|
|
196
|
+
it("reports the earliest future stuck transition", () => {
|
|
197
|
+
const now = 1_000_000;
|
|
198
|
+
const due = now - sync_recovery_queue_1.SYNC_RECOVERY_STUCK_AGE_MS + 5_000;
|
|
199
|
+
expect((0, sync_recovery_queue_1.nextStuckSyncRecoveryAt)([
|
|
200
|
+
{
|
|
201
|
+
parkedAt: now,
|
|
202
|
+
attemptCount: sync_recovery_queue_1.SYNC_RECOVERY_STUCK_ATTEMPTS,
|
|
203
|
+
firstFailedAt: now - sync_recovery_queue_1.SYNC_RECOVERY_STUCK_AGE_MS,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
attemptCount: sync_recovery_queue_1.SYNC_RECOVERY_STUCK_ATTEMPTS - 1,
|
|
207
|
+
firstFailedAt: now - sync_recovery_queue_1.SYNC_RECOVERY_STUCK_AGE_MS,
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
attemptCount: sync_recovery_queue_1.SYNC_RECOVERY_STUCK_ATTEMPTS,
|
|
211
|
+
firstFailedAt: due,
|
|
212
|
+
},
|
|
213
|
+
], now)).toBe(due + sync_recovery_queue_1.SYNC_RECOVERY_STUCK_AGE_MS);
|
|
214
|
+
});
|
|
138
215
|
});
|
package/dist/rpc-types.d.ts
CHANGED
|
@@ -93,9 +93,6 @@ export declare const rpcServerCalls: {
|
|
|
93
93
|
mimeType?: string;
|
|
94
94
|
}>;
|
|
95
95
|
injectUIBundle: (uiBundleFileId: string) => Promise<void>;
|
|
96
|
-
resetAllDeviceSyncInfo: () => Promise<void>;
|
|
97
|
-
/** Clear change tracking and rebuild from current rows for the default data context (same as Data Explorer). */
|
|
98
|
-
resetChangeTracking: () => Promise<void>;
|
|
99
96
|
deleteLocalDatabase: () => Promise<void>;
|
|
100
97
|
/** Returns info about the pre-migration data directory, or `null` if already cleaned up. */
|
|
101
98
|
getOldDataDirInfo: () => Promise<{
|
package/dist/rpc-types.js
CHANGED
|
@@ -36,9 +36,6 @@ exports.rpcServerCalls = {
|
|
|
36
36
|
saveFile: rpcStub("saveFile"),
|
|
37
37
|
// Inject UI bundle directly into WebView (bypasses slow postMessage for large strings)
|
|
38
38
|
injectUIBundle: rpcStub("injectUIBundle"),
|
|
39
|
-
resetAllDeviceSyncInfo: rpcStub("resetAllDeviceSyncInfo"),
|
|
40
|
-
/** Clear change tracking and rebuild from current rows for the default data context (same as Data Explorer). */
|
|
41
|
-
resetChangeTracking: rpcStub("resetChangeTracking"),
|
|
42
39
|
deleteLocalDatabase: rpcStub("deleteLocalDatabase"),
|
|
43
40
|
/** Returns info about the pre-migration data directory, or `null` if already cleaned up. */
|
|
44
41
|
getOldDataDirInfo: rpcStub("getOldDataDirInfo"),
|