@peers-app/peers-sdk 0.25.7 → 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.
@@ -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's change tracking for an expensive full sync.",
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 to reset."),
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) => void;
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
  /**
@@ -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;
@@ -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
  });
@@ -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"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.25.7",
3
+ "version": "0.25.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"