@peers-app/peers-sdk 0.21.3 → 0.21.5

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.
@@ -18,6 +18,7 @@ export * from "./packages";
18
18
  export * from "./packages.utils";
19
19
  export * from "./peer-types";
20
20
  export * from "./persistent-vars";
21
+ export * from "./sync-recovery-queue";
21
22
  export * from "./table-definitions-table";
22
23
  export * from "./tool-tests";
23
24
  export * from "./tools";
@@ -34,6 +34,7 @@ __exportStar(require("./packages"), exports);
34
34
  __exportStar(require("./packages.utils"), exports);
35
35
  __exportStar(require("./peer-types"), exports);
36
36
  __exportStar(require("./persistent-vars"), exports);
37
+ __exportStar(require("./sync-recovery-queue"), exports);
37
38
  __exportStar(require("./table-definitions-table"), exports);
38
39
  __exportStar(require("./tool-tests"), exports);
39
40
  __exportStar(require("./tools"), exports);
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Local-only queue of synchronized records that failed to materialize into their
3
+ * target table. One entry per (tableName, recordId); change history is retained
4
+ * separately in ChangeTrackingV2.
5
+ */
6
+ import { z } from "zod";
7
+ import { type ISqlDb, type ITableMetaData, SQLDataSource } from "./orm";
8
+ /** One recorded materialization failure for a synchronized record. */
9
+ export declare const syncRecoveryFailureSchema: z.ZodObject<{
10
+ error: z.ZodString;
11
+ erroredAt: z.ZodNumber;
12
+ sourceDeviceId: z.ZodOptional<z.ZodString>;
13
+ }, "strip", z.ZodTypeAny, {
14
+ error: string;
15
+ erroredAt: number;
16
+ sourceDeviceId?: string | undefined;
17
+ }, {
18
+ error: string;
19
+ erroredAt: number;
20
+ sourceDeviceId?: string | undefined;
21
+ }>;
22
+ /** Durable local recovery state for one synchronized record. */
23
+ export declare const syncRecoveryEntrySchema: z.ZodObject<{
24
+ syncRecoveryId: z.ZodEffects<z.ZodString, string, string>;
25
+ tableName: z.ZodString;
26
+ recordId: z.ZodString;
27
+ firstFailedAt: z.ZodNumber;
28
+ lastFailedAt: z.ZodNumber;
29
+ attemptCount: z.ZodNumber;
30
+ failures: z.ZodArray<z.ZodObject<{
31
+ error: z.ZodString;
32
+ erroredAt: z.ZodNumber;
33
+ sourceDeviceId: z.ZodOptional<z.ZodString>;
34
+ }, "strip", z.ZodTypeAny, {
35
+ error: string;
36
+ erroredAt: number;
37
+ sourceDeviceId?: string | undefined;
38
+ }, {
39
+ error: string;
40
+ erroredAt: number;
41
+ sourceDeviceId?: string | undefined;
42
+ }>, "many">;
43
+ }, "strip", z.ZodTypeAny, {
44
+ tableName: string;
45
+ recordId: string;
46
+ syncRecoveryId: string;
47
+ firstFailedAt: number;
48
+ lastFailedAt: number;
49
+ attemptCount: number;
50
+ failures: {
51
+ error: string;
52
+ erroredAt: number;
53
+ sourceDeviceId?: string | undefined;
54
+ }[];
55
+ }, {
56
+ tableName: string;
57
+ recordId: string;
58
+ syncRecoveryId: string;
59
+ firstFailedAt: number;
60
+ lastFailedAt: number;
61
+ attemptCount: number;
62
+ failures: {
63
+ error: string;
64
+ erroredAt: number;
65
+ sourceDeviceId?: string | undefined;
66
+ }[];
67
+ }>;
68
+ /** One recorded materialization failure for a synchronized record. */
69
+ export type ISyncRecoveryFailure = z.infer<typeof syncRecoveryFailureSchema>;
70
+ /** Durable local recovery state for one synchronized record. */
71
+ export type ISyncRecoveryEntry = z.infer<typeof syncRecoveryEntrySchema>;
72
+ /** Input used to append a materialization failure to the recovery queue. */
73
+ export interface IRecordSyncFailure {
74
+ tableName: string;
75
+ recordId: string;
76
+ error: unknown;
77
+ sourceDeviceId?: string;
78
+ }
79
+ /** Metadata for the local-only SyncRecoveryQueue table. */
80
+ export declare const syncRecoveryQueueMetaData: ITableMetaData;
81
+ /**
82
+ * SQL-backed SyncRecoveryQueue table for a single database (personal or group).
83
+ *
84
+ * Constructed with a db handle like ChangeTrackingTable; not accessed via TableContainer.
85
+ * Prefer {@link syncRecoveryQueueTable} so callers share one instance per database.
86
+ */
87
+ export declare class SyncRecoveryQueueTable extends SQLDataSource<ISyncRecoveryEntry> {
88
+ constructor({ db }: {
89
+ db: ISqlDb;
90
+ });
91
+ /**
92
+ * Append a failure for a table/record, creating or updating the durable queue entry.
93
+ */
94
+ recordFailure(input: IRecordSyncFailure): Promise<void>;
95
+ /**
96
+ * Append a failure inside the caller's active transaction.
97
+ *
98
+ * The queue table must be initialized before this method is called.
99
+ */
100
+ recordFailureSync(input: IRecordSyncFailure): void;
101
+ /** Remove a successfully recovered record from the queue. */
102
+ clearRecord(tableName: string, recordId: string): Promise<void>;
103
+ /**
104
+ * Remove a successfully recovered record inside the caller's active transaction.
105
+ *
106
+ * The queue table must be initialized before this method is called.
107
+ */
108
+ clearRecordSync(tableName: string, recordId: string): void;
109
+ private mergeFailure;
110
+ }
111
+ /** Return the single SyncRecoveryQueue table associated with a database instance. */
112
+ export declare function syncRecoveryQueueTable(db: ISqlDb): SyncRecoveryQueueTable;
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ /**
3
+ * Local-only queue of synchronized records that failed to materialize into their
4
+ * target table. One entry per (tableName, recordId); change history is retained
5
+ * separately in ChangeTrackingV2.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.SyncRecoveryQueueTable = exports.syncRecoveryQueueMetaData = exports.syncRecoveryEntrySchema = exports.syncRecoveryFailureSchema = void 0;
9
+ exports.syncRecoveryQueueTable = syncRecoveryQueueTable;
10
+ const zod_1 = require("zod");
11
+ const zod_types_1 = require("../types/zod-types");
12
+ const utils_1 = require("../utils");
13
+ const orm_1 = require("./orm");
14
+ const MAX_FAILURE_HISTORY = 10;
15
+ const MAX_ERROR_LENGTH = 1_000;
16
+ /** One recorded materialization failure for a synchronized record. */
17
+ exports.syncRecoveryFailureSchema = zod_1.z.object({
18
+ error: zod_1.z.string(),
19
+ erroredAt: zod_1.z.number(),
20
+ sourceDeviceId: zod_1.z.string().optional(),
21
+ });
22
+ /** Durable local recovery state for one synchronized record. */
23
+ exports.syncRecoveryEntrySchema = zod_1.z.object({
24
+ syncRecoveryId: zod_types_1.zodPeerId,
25
+ tableName: zod_1.z.string(),
26
+ recordId: zod_1.z.string(),
27
+ firstFailedAt: zod_1.z.number(),
28
+ lastFailedAt: zod_1.z.number(),
29
+ attemptCount: zod_1.z.number(),
30
+ failures: zod_1.z.array(exports.syncRecoveryFailureSchema),
31
+ });
32
+ /** Metadata for the local-only SyncRecoveryQueue table. */
33
+ exports.syncRecoveryQueueMetaData = {
34
+ name: "SyncRecoveryQueue",
35
+ description: "Local queue of synchronized records that failed to materialize",
36
+ primaryKeyName: "syncRecoveryId",
37
+ fields: (0, orm_1.schemaToFields)(exports.syncRecoveryEntrySchema),
38
+ indexes: [{ fields: ["tableName", "recordId"], unique: true }],
39
+ localOnly: true,
40
+ };
41
+ function normalizeError(error) {
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ return message.slice(0, MAX_ERROR_LENGTH);
44
+ }
45
+ /**
46
+ * SQL-backed SyncRecoveryQueue table for a single database (personal or group).
47
+ *
48
+ * Constructed with a db handle like ChangeTrackingTable; not accessed via TableContainer.
49
+ * Prefer {@link syncRecoveryQueueTable} so callers share one instance per database.
50
+ */
51
+ class SyncRecoveryQueueTable extends orm_1.SQLDataSource {
52
+ constructor({ db }) {
53
+ super(db, exports.syncRecoveryQueueMetaData, exports.syncRecoveryEntrySchema);
54
+ }
55
+ /**
56
+ * Append a failure for a table/record, creating or updating the durable queue entry.
57
+ */
58
+ async recordFailure(input) {
59
+ await this.initTable();
60
+ if (this.db.runInTransaction && this.db.getSync && this.db.execSync) {
61
+ this.db.runInTransaction(() => this.recordFailureSync(input));
62
+ return;
63
+ }
64
+ const existing = await this.findOne({
65
+ tableName: input.tableName,
66
+ recordId: input.recordId,
67
+ });
68
+ const entry = this.mergeFailure(existing, input);
69
+ if (existing) {
70
+ await this.update(entry);
71
+ }
72
+ else {
73
+ await this.insert(entry);
74
+ }
75
+ }
76
+ /**
77
+ * Append a failure inside the caller's active transaction.
78
+ *
79
+ * The queue table must be initialized before this method is called.
80
+ */
81
+ recordFailureSync(input) {
82
+ if (!this.db.getSync || !this.db.execSync) {
83
+ throw new Error("Synchronous database operations are required for recovery queue updates");
84
+ }
85
+ const sqlRecord = this.db.getSync(`SELECT * FROM "${this.tableName}" WHERE tableName = ? AND recordId = ?`, [input.tableName, input.recordId]);
86
+ const existing = sqlRecord ? this.sqlRecordToJsRecord(sqlRecord) : undefined;
87
+ const entry = this.mergeFailure(existing, input);
88
+ if (existing) {
89
+ this.updateSync(entry);
90
+ }
91
+ else {
92
+ this.insertSync(entry);
93
+ }
94
+ }
95
+ /** Remove a successfully recovered record from the queue. */
96
+ async clearRecord(tableName, recordId) {
97
+ await this.initTable();
98
+ await this.db.exec(`DELETE FROM "${this.tableName}" WHERE tableName = ? AND recordId = ?`, [
99
+ tableName,
100
+ recordId,
101
+ ]);
102
+ }
103
+ /**
104
+ * Remove a successfully recovered record inside the caller's active transaction.
105
+ *
106
+ * The queue table must be initialized before this method is called.
107
+ */
108
+ clearRecordSync(tableName, recordId) {
109
+ if (!this.db.execSync) {
110
+ throw new Error("Synchronous database operations are required for recovery queue updates");
111
+ }
112
+ this.db.execSync(`DELETE FROM "${this.tableName}" WHERE tableName = ? AND recordId = ?`, [
113
+ tableName,
114
+ recordId,
115
+ ]);
116
+ }
117
+ mergeFailure(existing, input) {
118
+ const erroredAt = (0, utils_1.getTimestamp)();
119
+ const failure = {
120
+ error: normalizeError(input.error),
121
+ erroredAt,
122
+ sourceDeviceId: input.sourceDeviceId,
123
+ };
124
+ return {
125
+ syncRecoveryId: existing?.syncRecoveryId ?? (0, utils_1.newid)(),
126
+ tableName: input.tableName,
127
+ recordId: input.recordId,
128
+ firstFailedAt: existing?.firstFailedAt ?? erroredAt,
129
+ lastFailedAt: erroredAt,
130
+ attemptCount: (existing?.attemptCount ?? 0) + 1,
131
+ failures: [...(existing?.failures ?? []), failure].slice(-MAX_FAILURE_HISTORY),
132
+ };
133
+ }
134
+ }
135
+ exports.SyncRecoveryQueueTable = SyncRecoveryQueueTable;
136
+ const recoveryQueuesByDb = new WeakMap();
137
+ /** Return the single SyncRecoveryQueue table associated with a database instance. */
138
+ function syncRecoveryQueueTable(db) {
139
+ let table = recoveryQueuesByDb.get(db);
140
+ if (!table) {
141
+ table = new SyncRecoveryQueueTable({ db });
142
+ recoveryQueuesByDb.set(db, table);
143
+ }
144
+ return table;
145
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const sync_recovery_queue_1 = require("./sync-recovery-queue");
4
+ const SQLiteDB = require("better-sqlite3");
5
+ class DBHarness {
6
+ sqlite = new SQLiteDB(":memory:");
7
+ async get(sql, params = []) {
8
+ return this.sqlite.prepare(sql).get(params);
9
+ }
10
+ async all(sql, params = []) {
11
+ return this.sqlite.prepare(sql).all(params);
12
+ }
13
+ async exec(sql, params = []) {
14
+ const result = this.sqlite.prepare(sql).run(params);
15
+ return { changes: result.changes };
16
+ }
17
+ getSync(sql, params = []) {
18
+ return this.sqlite.prepare(sql).get(params);
19
+ }
20
+ allSync(sql, params = []) {
21
+ return this.sqlite.prepare(sql).all(params);
22
+ }
23
+ execSync(sql, params = []) {
24
+ this.sqlite.prepare(sql).run(params);
25
+ }
26
+ runInTransaction(fn) {
27
+ return this.sqlite.transaction(fn)();
28
+ }
29
+ async close() {
30
+ this.sqlite.close();
31
+ }
32
+ }
33
+ describe("SyncRecoveryQueueTable", () => {
34
+ let db;
35
+ let queue;
36
+ beforeEach(() => {
37
+ db = new DBHarness();
38
+ queue = (0, sync_recovery_queue_1.syncRecoveryQueueTable)(db);
39
+ });
40
+ afterEach(async () => {
41
+ await db.close();
42
+ });
43
+ it("returns one queue instance per database", async () => {
44
+ const otherDb = new DBHarness();
45
+ try {
46
+ expect((0, sync_recovery_queue_1.syncRecoveryQueueTable)(db)).toBe(queue);
47
+ expect((0, sync_recovery_queue_1.syncRecoveryQueueTable)(otherDb)).toBe((0, sync_recovery_queue_1.syncRecoveryQueueTable)(otherDb));
48
+ expect((0, sync_recovery_queue_1.syncRecoveryQueueTable)(otherDb)).not.toBe(queue);
49
+ }
50
+ finally {
51
+ await otherDb.close();
52
+ }
53
+ });
54
+ it("upserts failures for a record while preserving its queue identity", async () => {
55
+ await queue.recordFailure({
56
+ tableName: "Notes",
57
+ recordId: "note-1",
58
+ error: new Error("first failure"),
59
+ sourceDeviceId: "device-1",
60
+ });
61
+ const first = await queue.findOne({ tableName: "Notes", recordId: "note-1" });
62
+ await queue.recordFailure({
63
+ tableName: "Notes",
64
+ recordId: "note-1",
65
+ error: "second failure",
66
+ sourceDeviceId: "device-2",
67
+ });
68
+ const updated = await queue.findOne({ tableName: "Notes", recordId: "note-1" });
69
+ expect(first).toBeDefined();
70
+ expect(updated).toMatchObject({
71
+ syncRecoveryId: first?.syncRecoveryId,
72
+ tableName: "Notes",
73
+ recordId: "note-1",
74
+ firstFailedAt: first?.firstFailedAt,
75
+ attemptCount: 2,
76
+ failures: [
77
+ expect.objectContaining({ error: "first failure", sourceDeviceId: "device-1" }),
78
+ expect.objectContaining({ error: "second failure", sourceDeviceId: "device-2" }),
79
+ ],
80
+ });
81
+ if (!updated)
82
+ throw new Error("Expected an updated recovery entry");
83
+ expect(updated.lastFailedAt).toBeGreaterThanOrEqual(updated.firstFailedAt);
84
+ await expect(queue.count()).resolves.toBe(1);
85
+ });
86
+ it("bounds failure history and stored error length", async () => {
87
+ for (let attempt = 1; attempt <= 12; attempt++) {
88
+ await queue.recordFailure({
89
+ tableName: "Notes",
90
+ recordId: "note-1",
91
+ error: attempt === 12 ? "x".repeat(2_000) : `failure-${attempt}`,
92
+ });
93
+ }
94
+ const entry = await queue.findOne({ tableName: "Notes", recordId: "note-1" });
95
+ expect(entry?.attemptCount).toBe(12);
96
+ expect(entry?.failures).toHaveLength(10);
97
+ expect(entry?.failures[0].error).toBe("failure-3");
98
+ expect(entry?.failures.at(-1)?.error).toHaveLength(1_000);
99
+ });
100
+ it("supports asynchronous databases without transaction methods", async () => {
101
+ const asyncOnlyDb = {
102
+ get: db.get.bind(db),
103
+ all: db.all.bind(db),
104
+ exec: db.exec.bind(db),
105
+ close: async () => { },
106
+ };
107
+ const asyncQueue = new sync_recovery_queue_1.SyncRecoveryQueueTable({ db: asyncOnlyDb });
108
+ await asyncQueue.recordFailure({
109
+ tableName: "Notes",
110
+ recordId: "note-1",
111
+ error: new Error("first"),
112
+ });
113
+ await asyncQueue.recordFailure({
114
+ tableName: "Notes",
115
+ recordId: "note-1",
116
+ error: new Error("second"),
117
+ });
118
+ expect(await asyncQueue.findOne({ tableName: "Notes", recordId: "note-1" })).toMatchObject({
119
+ attemptCount: 2,
120
+ });
121
+ await asyncQueue.clearRecord("Notes", "note-1");
122
+ await expect(asyncQueue.count()).resolves.toBe(0);
123
+ });
124
+ it("rolls back synchronous queue clearing with its surrounding transaction", async () => {
125
+ await queue.recordFailure({
126
+ tableName: "Notes",
127
+ recordId: "note-1",
128
+ error: new Error("materialization failed"),
129
+ });
130
+ expect(() => db.runInTransaction(() => {
131
+ queue.clearRecordSync("Notes", "note-1");
132
+ throw new Error("rollback");
133
+ })).toThrow("rollback");
134
+ await expect(queue.count()).resolves.toBe(1);
135
+ db.runInTransaction(() => queue.clearRecordSync("Notes", "note-1"));
136
+ await expect(queue.count()).resolves.toBe(0);
137
+ });
138
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.21.3",
3
+ "version": "0.21.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"