@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.
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.localFolderBlocksMetaData = exports.localFolderFilesMetaData = exports.localFolderGrantsMetaData = exports.localFolderBlockRowSchema = exports.localFolderFileSchema = exports.localFolderGrantSchema = exports.localFolderGrantModeSchema = exports.localFolderBlockSchema = void 0;
4
+ exports.LocalFolderGrants = LocalFolderGrants;
5
+ exports.LocalFolderFiles = LocalFolderFiles;
6
+ exports.LocalFolderBlocks = LocalFolderBlocks;
7
+ const zod_1 = require("zod");
8
+ const user_context_singleton_1 = require("../context/user-context-singleton");
9
+ const zod_types_1 = require("../types/zod-types");
10
+ const table_definitions_system_1 = require("./orm/table-definitions.system");
11
+ const types_1 = require("./orm/types");
12
+ /** Content-defined block descriptor stored on an indexed file. */
13
+ exports.localFolderBlockSchema = zod_1.z.object({
14
+ hash: zod_1.z.string().describe("SHA-256 hex digest of the block bytes."),
15
+ size: zod_1.z.number().describe("Block length in bytes."),
16
+ });
17
+ /** Access mode recorded on a folder grant. */
18
+ exports.localFolderGrantModeSchema = zod_1.z.enum(["read", "readwrite"]);
19
+ /**
20
+ * Operator grant of one absolute folder to an optional package.
21
+ * Stored only on this device, in the personal database.
22
+ */
23
+ exports.localFolderGrantSchema = zod_1.z.object({
24
+ grantId: zod_types_1.zodPeerId.describe("Grant id."),
25
+ packageId: zod_types_1.zodPeerId
26
+ .optional()
27
+ .describe("Isolated package allowed to use this grant. Absent for operator-owned grants."),
28
+ groupId: zod_types_1.zodPeerId.describe("Group the folder is associated with."),
29
+ absolutePath: zod_1.z.string().describe("Absolute filesystem path of the granted root."),
30
+ mode: exports.localFolderGrantModeSchema.describe("Whether the grant allows writes."),
31
+ createdAt: zod_1.z.number().describe("Unix epoch milliseconds when the grant was created."),
32
+ revokedAt: zod_1.z
33
+ .number()
34
+ .optional()
35
+ .describe("Unix epoch milliseconds when the grant was revoked. Absent while active."),
36
+ });
37
+ /** Indexed file under a grant. One row per `(grantId, relPath)`. */
38
+ exports.localFolderFileSchema = zod_1.z.object({
39
+ fileIndexId: zod_types_1.zodPeerId.describe("Index row id."),
40
+ grantId: zod_types_1.zodPeerId.describe("Grant this file belongs to."),
41
+ relPath: zod_1.z.string().describe("Path relative to the grant root, `/`-separated, NFC."),
42
+ size: zod_1.z.number().describe("File size in bytes at index time."),
43
+ mtimeMs: zod_1.z.number().describe("File mtime in milliseconds at index time."),
44
+ inode: zod_1.z.string().describe("File identity string (`stat.ino`) at index time."),
45
+ blocks: zod_1.z.array(exports.localFolderBlockSchema).describe("Ordered content-defined blocks."),
46
+ });
47
+ /**
48
+ * One servable block location. The same hash may appear in several files;
49
+ * `blockHash` is indexed, not unique.
50
+ */
51
+ exports.localFolderBlockRowSchema = zod_1.z.object({
52
+ blockRowId: zod_types_1.zodPeerId.describe("Block-location row id."),
53
+ blockHash: zod_1.z.string().describe("SHA-256 hex digest of the block bytes."),
54
+ grantId: zod_types_1.zodPeerId.describe("Grant whose file contains the block."),
55
+ relPath: zod_1.z.string().describe("File path relative to the grant root."),
56
+ offset: zod_1.z.number().describe("Byte offset of the block within the file."),
57
+ length: zod_1.z.number().describe("Block length in bytes."),
58
+ mtimeMs: zod_1.z.number().describe("File mtime in milliseconds when the block was indexed."),
59
+ });
60
+ const grantMetaData = {
61
+ name: "LocalFolderGrants",
62
+ description: "Device-local scoped folder grants for System Local Folders.",
63
+ primaryKeyName: "grantId",
64
+ fields: (0, types_1.schemaToFields)(exports.localFolderGrantSchema),
65
+ localOnly: true,
66
+ indexes: [{ fields: ["packageId"] }, { fields: ["groupId"] }],
67
+ };
68
+ const fileMetaData = {
69
+ name: "LocalFolderFiles",
70
+ description: "Device-local file index for System Local Folders.",
71
+ primaryKeyName: "fileIndexId",
72
+ fields: (0, types_1.schemaToFields)(exports.localFolderFileSchema),
73
+ localOnly: true,
74
+ indexes: [{ fields: ["grantId", "relPath"], unique: true }, { fields: ["grantId"] }],
75
+ };
76
+ const blockMetaData = {
77
+ name: "LocalFolderBlocks",
78
+ description: "Device-local block locations for System Local Folders.",
79
+ primaryKeyName: "blockRowId",
80
+ fields: (0, types_1.schemaToFields)(exports.localFolderBlockRowSchema),
81
+ localOnly: true,
82
+ indexes: [{ fields: ["blockHash"] }, { fields: ["grantId", "relPath"] }],
83
+ };
84
+ (0, table_definitions_system_1.registerSystemTableDefinition)(grantMetaData, exports.localFolderGrantSchema);
85
+ (0, table_definitions_system_1.registerSystemTableDefinition)(fileMetaData, exports.localFolderFileSchema);
86
+ (0, table_definitions_system_1.registerSystemTableDefinition)(blockMetaData, exports.localFolderBlockRowSchema);
87
+ /** Table metadata for {@link LocalFolderGrants}. */
88
+ exports.localFolderGrantsMetaData = grantMetaData;
89
+ /** Table metadata for {@link LocalFolderFiles}. */
90
+ exports.localFolderFilesMetaData = fileMetaData;
91
+ /** Table metadata for {@link LocalFolderBlocks}. */
92
+ exports.localFolderBlocksMetaData = blockMetaData;
93
+ /**
94
+ * Device-local folder grants. Pass the personal data context; these rows do not sync.
95
+ *
96
+ * @param dataContext Context whose database holds the grants. Defaults to the current context.
97
+ */
98
+ function LocalFolderGrants(dataContext) {
99
+ return (0, user_context_singleton_1.getTableContainer)(dataContext).getTable(grantMetaData, exports.localFolderGrantSchema);
100
+ }
101
+ /**
102
+ * Device-local indexed files. Pass the personal data context; these rows do not sync.
103
+ *
104
+ * @param dataContext Context whose database holds the index. Defaults to the current context.
105
+ */
106
+ function LocalFolderFiles(dataContext) {
107
+ return (0, user_context_singleton_1.getTableContainer)(dataContext).getTable(fileMetaData, exports.localFolderFileSchema);
108
+ }
109
+ /**
110
+ * Device-local block locations. Pass the personal data context; these rows do not sync.
111
+ *
112
+ * @param dataContext Context whose database holds the index. Defaults to the current context.
113
+ */
114
+ function LocalFolderBlocks(dataContext) {
115
+ return (0, user_context_singleton_1.getTableContainer)(dataContext).getTable(blockMetaData, exports.localFolderBlockRowSchema);
116
+ }
@@ -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;