@peers-app/peers-sdk 0.25.8 → 0.25.10

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.
@@ -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"),
@@ -93,3 +93,25 @@ export declare const identityToolIds: {
93
93
  readonly signOut: "00muc9mlzxwdm2yj5p5wkjvz7";
94
94
  readonly registerWithPeersServices: "00muc9mlzx07q98l3f9x49o10";
95
95
  };
96
+ /**
97
+ * Contract id for the local-only System Local Folders surface (`system.fs`).
98
+ * Scoped folder grants, directory listing, and host-side indexing. Not available
99
+ * over device-to-device connections; block exchange is a separate path.
100
+ */
101
+ export declare const localFoldersContractId = "00mukk9dwb1mjtd845kftuazm";
102
+ /** Stable IDs for tools in System Local Folders v1. */
103
+ export declare const localFoldersToolIds: {
104
+ readonly requestGrant: "00mukk9dwdcv4f99ydwxao97f";
105
+ readonly listGrants: "00mukk9dwdq119xmvz4ap8aum";
106
+ readonly revokeGrant: "00mukk9dwdezt5sbjviq1n1sp";
107
+ readonly list: "00mukk9dwd6mxscrv9eeaanai";
108
+ readonly stat: "00mukk9dwdon2y2487clcg67k";
109
+ readonly readRange: "00mukk9dwdih6mh8rrofph80o";
110
+ readonly index: "00mukk9dwdm01bxg3hrby6pli";
111
+ readonly getFileBlocks: "00mumw1pmmf3y2ujfdc99h4ib";
112
+ readonly getJob: "00mukk9dwd1c1f51xjysssgtv";
113
+ readonly cancelJob: "00mukk9dweizvif4ghp6kfa92";
114
+ readonly move: "00mukk9dwer8wsw5eiwis8tzm";
115
+ readonly trash: "00mukk9dwejbuehqzszpyxefx";
116
+ readonly purgeTrash: "00mukk9dwe6co8gvuy44kmuzd";
117
+ };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.identityToolIds = exports.identityContractId = exports.invitesToolIds = exports.invitesContractId = exports.toolAccessControlToolIds = exports.toolAccessControlContractId = exports.networkControlContractId = exports.networkDiagnosticsContractId = exports.deviceOperationsContractId = exports.logsContractId = exports.peersSystemPackageId = exports.peersCorePublishPublicKey = exports.peersCorePackageRepoUrl = exports.peersCorePackageId = exports.createGroupToolId = exports.queryDatabaseToolId = exports.runWorkflowToolId = exports.defaultSendMessageToolId = exports.defaultAssistantRunnerToolId = exports.adminAssistantId = exports.openCodeAssistantId = exports.defaultAssistantId = exports.peersRootUserId = void 0;
3
+ exports.localFoldersToolIds = exports.localFoldersContractId = exports.identityToolIds = exports.identityContractId = exports.invitesToolIds = exports.invitesContractId = exports.toolAccessControlToolIds = exports.toolAccessControlContractId = exports.networkControlContractId = exports.networkDiagnosticsContractId = exports.deviceOperationsContractId = exports.logsContractId = exports.peersSystemPackageId = exports.peersCorePublishPublicKey = exports.peersCorePackageRepoUrl = exports.peersCorePackageId = exports.createGroupToolId = exports.queryDatabaseToolId = exports.runWorkflowToolId = exports.defaultSendMessageToolId = exports.defaultAssistantRunnerToolId = exports.adminAssistantId = exports.openCodeAssistantId = exports.defaultAssistantId = exports.peersRootUserId = void 0;
4
4
  /** Stable user ID that owns built-in Peers system records. */
5
5
  exports.peersRootUserId = "000peers0user000000000001";
6
6
  /** Default assistant used when no user-selected assistant is available. */
@@ -97,3 +97,25 @@ exports.identityToolIds = {
97
97
  signOut: "00muc9mlzxwdm2yj5p5wkjvz7",
98
98
  registerWithPeersServices: "00muc9mlzx07q98l3f9x49o10",
99
99
  };
100
+ /**
101
+ * Contract id for the local-only System Local Folders surface (`system.fs`).
102
+ * Scoped folder grants, directory listing, and host-side indexing. Not available
103
+ * over device-to-device connections; block exchange is a separate path.
104
+ */
105
+ exports.localFoldersContractId = "00mukk9dwb1mjtd845kftuazm";
106
+ /** Stable IDs for tools in System Local Folders v1. */
107
+ exports.localFoldersToolIds = {
108
+ requestGrant: "00mukk9dwdcv4f99ydwxao97f",
109
+ listGrants: "00mukk9dwdq119xmvz4ap8aum",
110
+ revokeGrant: "00mukk9dwdezt5sbjviq1n1sp",
111
+ list: "00mukk9dwd6mxscrv9eeaanai",
112
+ stat: "00mukk9dwdon2y2487clcg67k",
113
+ readRange: "00mukk9dwdih6mh8rrofph80o",
114
+ index: "00mukk9dwdm01bxg3hrby6pli",
115
+ getFileBlocks: "00mumw1pmmf3y2ujfdc99h4ib",
116
+ getJob: "00mukk9dwd1c1f51xjysssgtv",
117
+ cancelJob: "00mukk9dweizvif4ghp6kfa92",
118
+ move: "00mukk9dwer8wsw5eiwis8tzm",
119
+ trash: "00mukk9dwejbuehqzszpyxefx",
120
+ purgeTrash: "00mukk9dwe6co8gvuy44kmuzd",
121
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.25.8",
3
+ "version": "0.25.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"