@peers-app/peers-sdk 0.15.4 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/data/index.d.ts +0 -1
- package/dist/data/index.js +0 -1
- package/dist/data/orm/table-container.d.ts +22 -0
- package/dist/data/orm/table-container.js +68 -2
- package/dist/data/orm/table-container.test.js +57 -2
- package/dist/data/table-definitions-table.d.ts +14 -0
- package/dist/data/table-definitions-table.js +13 -0
- package/dist/data/workflow-logs.js +1 -0
- package/dist/data/workflow-runs.d.ts +2 -13
- package/dist/data/workflow-runs.js +3 -98
- package/dist/data/workflows.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -5
- package/dist/keys.d.ts +52 -1
- package/dist/keys.js +47 -1
- package/dist/serial-json.d.ts +11 -0
- package/dist/serial-json.js +11 -0
- package/dist/types/workflow-run-context.d.ts +2 -0
- package/dist/utils.d.ts +50 -2
- package/dist/utils.js +49 -2
- package/package.json +1 -1
- package/dist/data/data-locks.d.ts +0 -37
- package/dist/data/data-locks.js +0 -189
- package/dist/data/data-locks.test.d.ts +0 -1
- package/dist/data/data-locks.test.js +0 -464
package/dist/data/data-locks.js
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DataLocksTable = exports.dataLockSchema = void 0;
|
|
4
|
-
exports.DataLocks = DataLocks;
|
|
5
|
-
const lodash_1 = require("lodash");
|
|
6
|
-
const zod_1 = require("zod");
|
|
7
|
-
const context_1 = require("../context");
|
|
8
|
-
const zod_types_1 = require("../types/zod-types");
|
|
9
|
-
const utils_1 = require("../utils");
|
|
10
|
-
const orm_1 = require("./orm");
|
|
11
|
-
const table_definitions_system_1 = require("./orm/table-definitions.system");
|
|
12
|
-
/*
|
|
13
|
-
This works by waiting for a majority of peers to acknowledge the lock.
|
|
14
|
-
Once that happens it is considered "well propagated" and it also assumes older
|
|
15
|
-
locks from other devices have all been synced but that's not actually guaranteed.
|
|
16
|
-
|
|
17
|
-
This system works okay for a few peers but it won't scale well when _all_ the peers are trying to
|
|
18
|
-
do something like emit a scheduled event at the same time. They'll all try to acquire the lock at the same time
|
|
19
|
-
and even if this works, it'll create a ton of traffic and contention on the locks table.
|
|
20
|
-
|
|
21
|
-
The issue with emitting scheduled events might be an edge case but it still feels like the locking problem
|
|
22
|
-
should be solved in a more robust and scalable way.
|
|
23
|
-
|
|
24
|
-
We already have an election system for preferred connections. This feels like it could be solved in a similar way,
|
|
25
|
-
maybe by just using the existing elections. I also suspect there are going to be other problems that would
|
|
26
|
-
benefit greatly from utilizing an elected coordinator for these kinds of problems that require coordination
|
|
27
|
-
|
|
28
|
-
*/
|
|
29
|
-
exports.dataLockSchema = zod_1.z.object({
|
|
30
|
-
dataLockId: zod_types_1.zodPeerId,
|
|
31
|
-
recordId: zod_1.z.string(),
|
|
32
|
-
lockedUntil: zod_1.z.number(),
|
|
33
|
-
acknowledged: zod_1.z.number().optional(), // timestamp of acknowledgment
|
|
34
|
-
});
|
|
35
|
-
const dataLocksTableMetaData = {
|
|
36
|
-
name: "DataLocks",
|
|
37
|
-
primaryKeyName: "dataLockId",
|
|
38
|
-
description: "Data locks table to track exclusive write access to records in other tables.",
|
|
39
|
-
fields: (0, orm_1.schemaToFields)(exports.dataLockSchema),
|
|
40
|
-
indexes: [{ fields: ["recordId"] }],
|
|
41
|
-
};
|
|
42
|
-
class DataLocksTable extends orm_1.Table {
|
|
43
|
-
DEFAULT_LOCK_TIME_MS = 300_000; // 5 minutes
|
|
44
|
-
DEFAULT_TIMEOUT_MS = 20_000; // 20 seconds
|
|
45
|
-
DEAD_PERIOD_MS = 10_000; // The cannot be renewed in it's last 10 seconds to avoid races
|
|
46
|
-
peerDevice;
|
|
47
|
-
constructor(metaData, deps) {
|
|
48
|
-
super(metaData, deps);
|
|
49
|
-
// signal to other peers when we become aware of a new data lock
|
|
50
|
-
this.dataChanged.subscribe(async (evt) => {
|
|
51
|
-
const dataLock = evt.dataObject;
|
|
52
|
-
if (evt.op === "insert") {
|
|
53
|
-
dataLock.acknowledged = (0, utils_1.getTimestamp)();
|
|
54
|
-
this.dataSource.update(dataLock);
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
async getCurrentLock(recordId) {
|
|
59
|
-
const locks = await this.list({
|
|
60
|
-
recordId,
|
|
61
|
-
lockedUntil: { $gt: Date.now() }, // Only consider locks that are still valid
|
|
62
|
-
});
|
|
63
|
-
return (0, lodash_1.sortBy)(locks, "dataLockId")[0];
|
|
64
|
-
}
|
|
65
|
-
async releaseLock(dataLock) {
|
|
66
|
-
// Remove the lock from the database
|
|
67
|
-
await this.delete(dataLock);
|
|
68
|
-
}
|
|
69
|
-
async createLock(recordId, lockTimeMs = this.DEFAULT_LOCK_TIME_MS) {
|
|
70
|
-
const newLock = {
|
|
71
|
-
dataLockId: (0, utils_1.newid)(),
|
|
72
|
-
recordId,
|
|
73
|
-
lockedUntil: Date.now() + lockTimeMs,
|
|
74
|
-
};
|
|
75
|
-
return await this.insert(newLock);
|
|
76
|
-
}
|
|
77
|
-
async acquireLock(recordIdOrLock, timeoutMs = this.DEFAULT_TIMEOUT_MS, lockTimeMs = this.DEFAULT_LOCK_TIME_MS) {
|
|
78
|
-
const lock = typeof recordIdOrLock === "string"
|
|
79
|
-
? await this.createLock(recordIdOrLock, lockTimeMs)
|
|
80
|
-
: recordIdOrLock;
|
|
81
|
-
const recordId = lock.recordId;
|
|
82
|
-
const confirmedLock = await this.waitForLockConfirmedAndCurrent(lock, timeoutMs);
|
|
83
|
-
if (confirmedLock) {
|
|
84
|
-
await this.renewLock(confirmedLock, lockTimeMs);
|
|
85
|
-
const currentLock = await this.getCurrentLock(recordId);
|
|
86
|
-
if (!currentLock || currentLock.dataLockId !== confirmedLock.dataLockId) {
|
|
87
|
-
console.warn(`Lock acquisition failed for ${recordId}`);
|
|
88
|
-
await this.releaseLock(confirmedLock); // clean up unconfirmed lock
|
|
89
|
-
return undefined; // Return undefined if the lock could not be renewed
|
|
90
|
-
}
|
|
91
|
-
return currentLock;
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
94
|
-
await this.releaseLock(lock); // clean up unconfirmed lock
|
|
95
|
-
return undefined; // Return undefined if the lock could not be acquired
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
async renewLock(dataLock, lockTimeMs = this.DEFAULT_LOCK_TIME_MS) {
|
|
99
|
-
if (dataLock.lockedUntil < Date.now() - this.DEAD_PERIOD_MS) {
|
|
100
|
-
console.warn(`Lock renewal rejected for expired lock ${dataLock.dataLockId} for ${dataLock.recordId}`);
|
|
101
|
-
return undefined;
|
|
102
|
-
}
|
|
103
|
-
dataLock.lockedUntil = Date.now() + lockTimeMs;
|
|
104
|
-
const renewedLock = await this.update(dataLock);
|
|
105
|
-
const currentLock = await this.getCurrentLock(dataLock.recordId);
|
|
106
|
-
if (!currentLock || currentLock.dataLockId !== dataLock.dataLockId) {
|
|
107
|
-
// currently we don't allow renewing a lock if it is not the current lock - this could change in the future if needed
|
|
108
|
-
console.warn(`Lock renewal failed for ${dataLock.dataLockId} on ${dataLock.recordId}`);
|
|
109
|
-
return undefined;
|
|
110
|
-
}
|
|
111
|
-
return renewedLock;
|
|
112
|
-
}
|
|
113
|
-
async countLockAcknowledgements(lock) {
|
|
114
|
-
if (!this.peerDevice)
|
|
115
|
-
throw new Error(`Peer device not set for DataLocksTable`);
|
|
116
|
-
const changes = (await this.peerDevice.listChanges({
|
|
117
|
-
tableName: dataLocksTableMetaData.name,
|
|
118
|
-
recordId: lock.dataLockId,
|
|
119
|
-
op: "set", // V2 uses 'set' for updates
|
|
120
|
-
}));
|
|
121
|
-
// In V2, look for changes at path "/acknowledged" or full record changes at path "/"
|
|
122
|
-
const acknowledgedUpdates = changes
|
|
123
|
-
.map((change) => {
|
|
124
|
-
if (change.path === "/acknowledged") {
|
|
125
|
-
return change.value;
|
|
126
|
-
}
|
|
127
|
-
else if (change.path === "/" && change.value && typeof change.value === "object") {
|
|
128
|
-
return change.value.acknowledged;
|
|
129
|
-
}
|
|
130
|
-
return undefined;
|
|
131
|
-
})
|
|
132
|
-
.filter((a) => a !== undefined);
|
|
133
|
-
// NOTE this could potentially miscount if two peers acknowledge the lock with the same timestamp (very unlikely)
|
|
134
|
-
return (0, lodash_1.uniq)(acknowledgedUpdates).length;
|
|
135
|
-
}
|
|
136
|
-
async lockStatus(lock) {
|
|
137
|
-
if (!this.peerDevice)
|
|
138
|
-
throw new Error(`Peer device not set for DataLocksTable`);
|
|
139
|
-
const connectionCount = (await this.peerDevice.getNetworkInfo()).connections.length;
|
|
140
|
-
const ackCnt = await this.countLockAcknowledgements(lock);
|
|
141
|
-
const acksNeeded = Math.ceil(connectionCount * 0.7 + 1); // 70% of the connections and +1 for the local device
|
|
142
|
-
if (ackCnt >= acksNeeded) {
|
|
143
|
-
return "confirmed";
|
|
144
|
-
}
|
|
145
|
-
return "pending";
|
|
146
|
-
}
|
|
147
|
-
async waitForLockConfirmedAndCurrent(lock, timeoutMs = this.DEFAULT_TIMEOUT_MS) {
|
|
148
|
-
const startTime = Date.now();
|
|
149
|
-
let resolveLock;
|
|
150
|
-
const confirmPromise = new Promise((resolve) => {
|
|
151
|
-
resolveLock = resolve;
|
|
152
|
-
});
|
|
153
|
-
const subscription = this.dataChanged.subscribe(async (evt) => {
|
|
154
|
-
if (evt.dataObject.recordId === lock.recordId) {
|
|
155
|
-
checkLockStatus();
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
const checkLockStatus = async () => {
|
|
159
|
-
const status = await this.lockStatus(lock);
|
|
160
|
-
if (status === "confirmed") {
|
|
161
|
-
const currentLock = await this.getCurrentLock(lock.recordId);
|
|
162
|
-
if (currentLock?.dataLockId === lock.dataLockId) {
|
|
163
|
-
subscription.unsubscribe();
|
|
164
|
-
clearTimeout(timeoutId);
|
|
165
|
-
resolveLock(lock);
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
console.debug(`Lock confirmed but not current for ${lock.recordId}`);
|
|
169
|
-
}
|
|
170
|
-
if (Date.now() - startTime > timeoutMs) {
|
|
171
|
-
console.warn(`Lock confirmation timed out for ${lock.recordId}`);
|
|
172
|
-
subscription.unsubscribe();
|
|
173
|
-
clearTimeout(timeoutId);
|
|
174
|
-
resolveLock(undefined);
|
|
175
|
-
}
|
|
176
|
-
};
|
|
177
|
-
const timeoutId = setTimeout(() => {
|
|
178
|
-
checkLockStatus(); // Check lock status after timeout
|
|
179
|
-
}, timeoutMs + 1);
|
|
180
|
-
checkLockStatus(); // Initial check for lock status
|
|
181
|
-
return confirmPromise;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
exports.DataLocksTable = DataLocksTable;
|
|
185
|
-
(0, table_definitions_system_1.registerSystemTableDefinition)(dataLocksTableMetaData, exports.dataLockSchema, DataLocksTable);
|
|
186
|
-
function DataLocks(dataContext) {
|
|
187
|
-
const tableFactory = (0, context_1.getTableContainer)(dataContext);
|
|
188
|
-
return tableFactory.getTable(dataLocksTableMetaData);
|
|
189
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,464 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
const SQLiteDB = require("better-sqlite3");
|
|
4
|
-
const utils_1 = require("../utils");
|
|
5
|
-
const data_locks_1 = require("./data-locks");
|
|
6
|
-
const group_member_roles_1 = require("./group-member-roles");
|
|
7
|
-
const sql_data_source_1 = require("./orm/sql.data-source");
|
|
8
|
-
const types_1 = require("./orm/types");
|
|
9
|
-
class DBHarness {
|
|
10
|
-
_db = null;
|
|
11
|
-
get database() {
|
|
12
|
-
if (!this._db) {
|
|
13
|
-
this._db = new SQLiteDB(":memory:");
|
|
14
|
-
this._db.pragma("journal_mode = WAL");
|
|
15
|
-
}
|
|
16
|
-
return this._db;
|
|
17
|
-
}
|
|
18
|
-
async get(sql, params = []) {
|
|
19
|
-
return this.database.prepare(sql).get(params);
|
|
20
|
-
}
|
|
21
|
-
async all(sql, params = []) {
|
|
22
|
-
return this.database.prepare(sql).all(params);
|
|
23
|
-
}
|
|
24
|
-
async exec(sql, params = []) {
|
|
25
|
-
const result = this.database.prepare(sql).run(params);
|
|
26
|
-
return { changes: result.changes };
|
|
27
|
-
}
|
|
28
|
-
async close() {
|
|
29
|
-
this._db?.close();
|
|
30
|
-
this._db = null;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
// Mock peer device for testing
|
|
34
|
-
class MockPeerDevice {
|
|
35
|
-
deviceId = "mock-device";
|
|
36
|
-
userId = "mock-user";
|
|
37
|
-
role = group_member_roles_1.GroupMemberRole.Owner;
|
|
38
|
-
connections = [];
|
|
39
|
-
changes = [];
|
|
40
|
-
constructor(connectionCount = 3) {
|
|
41
|
-
this.connections = Array.from({ length: connectionCount }, (_, i) => ({
|
|
42
|
-
deviceId: `peer-${i}`,
|
|
43
|
-
latencyMs: 50,
|
|
44
|
-
errorRate: 0,
|
|
45
|
-
timestampLastApplied: Date.now(),
|
|
46
|
-
}));
|
|
47
|
-
}
|
|
48
|
-
async getNetworkInfo() {
|
|
49
|
-
return {
|
|
50
|
-
deviceId: this.deviceId,
|
|
51
|
-
timestampLastApplied: Date.now(),
|
|
52
|
-
connections: this.connections,
|
|
53
|
-
preferredDeviceIds: [],
|
|
54
|
-
cpuPercent: 10,
|
|
55
|
-
memPercent: 10,
|
|
56
|
-
connectionSlotsAvailable: 10,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
async listChanges(query) {
|
|
60
|
-
if (!query)
|
|
61
|
-
return this.changes;
|
|
62
|
-
return this.changes.filter((change) => change.tableName === query.tableName &&
|
|
63
|
-
change.recordId === query.recordId &&
|
|
64
|
-
change.op === query.op);
|
|
65
|
-
}
|
|
66
|
-
async notifyOfChanges(_deviceId, _timestampLastApplied) {
|
|
67
|
-
// Mock implementation
|
|
68
|
-
}
|
|
69
|
-
async downloadFileChunk(_chunkHash) {
|
|
70
|
-
// Mock implementation - return null to indicate chunk not found
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
async getFileChunkInfo(_chunkHash) {
|
|
74
|
-
// Mock implementation - return that we don't have the chunk
|
|
75
|
-
return { hasChunk: false };
|
|
76
|
-
}
|
|
77
|
-
addAcknowledgment(recordId, acknowledged) {
|
|
78
|
-
this.changes.push({
|
|
79
|
-
changeId: (0, utils_1.newid)(),
|
|
80
|
-
tableName: "DataLocks",
|
|
81
|
-
recordId,
|
|
82
|
-
op: "set",
|
|
83
|
-
path: "/acknowledged",
|
|
84
|
-
value: acknowledged,
|
|
85
|
-
createdAt: Date.now(),
|
|
86
|
-
appliedAt: Date.now(),
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
setConnectionCount(count) {
|
|
90
|
-
this.connections = Array.from({ length: count }, (_, i) => ({
|
|
91
|
-
deviceId: `peer-${i}`,
|
|
92
|
-
latencyMs: 50,
|
|
93
|
-
errorRate: 0,
|
|
94
|
-
timestampLastApplied: Date.now(),
|
|
95
|
-
}));
|
|
96
|
-
}
|
|
97
|
-
sendDeviceMessage(_message) {
|
|
98
|
-
throw new Error("Method not implemented.");
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
describe("data-locks.ts", () => {
|
|
102
|
-
const fiveMinutesMs = 5 * 60 * 1000;
|
|
103
|
-
jest.setTimeout(fiveMinutesMs);
|
|
104
|
-
let db;
|
|
105
|
-
let dataSource;
|
|
106
|
-
let dataLocksTable;
|
|
107
|
-
let mockPeerDevice;
|
|
108
|
-
const dataLocksTableMetaData = {
|
|
109
|
-
name: "DataLocks",
|
|
110
|
-
primaryKeyName: "dataLockId",
|
|
111
|
-
description: "Data locks table to track exclusive write access to records in other tables.",
|
|
112
|
-
fields: (0, types_1.schemaToFields)(data_locks_1.dataLockSchema),
|
|
113
|
-
indexes: [{ fields: ["recordId"] }],
|
|
114
|
-
};
|
|
115
|
-
beforeAll(async () => {
|
|
116
|
-
db = new DBHarness();
|
|
117
|
-
dataSource = new sql_data_source_1.SQLDataSource(db, dataLocksTableMetaData);
|
|
118
|
-
const mockDataContextForRegistry = {
|
|
119
|
-
dataContextId: "data-locks-test",
|
|
120
|
-
};
|
|
121
|
-
const { EventRegistry } = require("./orm/event-registry");
|
|
122
|
-
const eventRegistry = new EventRegistry(mockDataContextForRegistry);
|
|
123
|
-
const _mockDataContext = {
|
|
124
|
-
dataSourceFactory: () => dataSource,
|
|
125
|
-
eventRegistry: eventRegistry,
|
|
126
|
-
};
|
|
127
|
-
const deps = {
|
|
128
|
-
dataSource,
|
|
129
|
-
eventRegistry,
|
|
130
|
-
schema: data_locks_1.dataLockSchema,
|
|
131
|
-
};
|
|
132
|
-
dataLocksTable = new data_locks_1.DataLocksTable(dataLocksTableMetaData, deps);
|
|
133
|
-
mockPeerDevice = new MockPeerDevice();
|
|
134
|
-
dataLocksTable.peerDevice = mockPeerDevice;
|
|
135
|
-
await dataSource.dropTableIfExists();
|
|
136
|
-
});
|
|
137
|
-
afterAll(async () => {
|
|
138
|
-
await db.close();
|
|
139
|
-
});
|
|
140
|
-
beforeEach(async () => {
|
|
141
|
-
// Clean up any existing locks before each test
|
|
142
|
-
const allLocks = await dataLocksTable.list();
|
|
143
|
-
for (const lock of allLocks) {
|
|
144
|
-
await dataLocksTable.delete(lock);
|
|
145
|
-
}
|
|
146
|
-
mockPeerDevice.setConnectionCount(3);
|
|
147
|
-
});
|
|
148
|
-
describe("DataLocksTable", () => {
|
|
149
|
-
describe("constructor", () => {
|
|
150
|
-
it("should create a DataLocksTable instance with default constants", () => {
|
|
151
|
-
expect(dataLocksTable.DEFAULT_LOCK_TIME_MS).toBe(300_000); // 5 minutes
|
|
152
|
-
expect(dataLocksTable.DEFAULT_TIMEOUT_MS).toBe(20_000); // 20 seconds
|
|
153
|
-
expect(dataLocksTable.DEAD_PERIOD_MS).toBe(10_000); // 10 seconds
|
|
154
|
-
});
|
|
155
|
-
it("should have peerDevice property", () => {
|
|
156
|
-
expect(dataLocksTable.peerDevice).toBeDefined();
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
describe("getCurrentLock", () => {
|
|
160
|
-
it("should return undefined when no locks exist for recordId", async () => {
|
|
161
|
-
const result = await dataLocksTable.getCurrentLock("nonexistent-record");
|
|
162
|
-
expect(result).toBeUndefined();
|
|
163
|
-
});
|
|
164
|
-
it("should return undefined when only expired locks exist", async () => {
|
|
165
|
-
const recordId = (0, utils_1.newid)();
|
|
166
|
-
const expiredLock = {
|
|
167
|
-
dataLockId: (0, utils_1.newid)(),
|
|
168
|
-
recordId,
|
|
169
|
-
lockedUntil: Date.now() - 1000, // Expired 1 second ago
|
|
170
|
-
};
|
|
171
|
-
await dataLocksTable.insert(expiredLock);
|
|
172
|
-
const result = await dataLocksTable.getCurrentLock(recordId);
|
|
173
|
-
expect(result).toBeUndefined();
|
|
174
|
-
});
|
|
175
|
-
it("should return the current valid lock", async () => {
|
|
176
|
-
const recordId = (0, utils_1.newid)();
|
|
177
|
-
const validLock = {
|
|
178
|
-
dataLockId: (0, utils_1.newid)(),
|
|
179
|
-
recordId,
|
|
180
|
-
lockedUntil: Date.now() + 60000, // Expires in 1 minute
|
|
181
|
-
};
|
|
182
|
-
await dataLocksTable.insert(validLock);
|
|
183
|
-
const result = await dataLocksTable.getCurrentLock(recordId);
|
|
184
|
-
expect(result).toEqual(validLock);
|
|
185
|
-
});
|
|
186
|
-
it("should return the earliest lock when multiple valid locks exist", async () => {
|
|
187
|
-
const recordId = (0, utils_1.newid)();
|
|
188
|
-
const lock1 = {
|
|
189
|
-
dataLockId: (0, utils_1.newid)(),
|
|
190
|
-
recordId,
|
|
191
|
-
lockedUntil: Date.now() + 60000,
|
|
192
|
-
};
|
|
193
|
-
const lock2 = {
|
|
194
|
-
dataLockId: (0, utils_1.newid)(),
|
|
195
|
-
recordId,
|
|
196
|
-
lockedUntil: Date.now() + 60000,
|
|
197
|
-
};
|
|
198
|
-
// Insert them in order so we can predict which should be first
|
|
199
|
-
const firstLock = lock1.dataLockId < lock2.dataLockId ? lock1 : lock2;
|
|
200
|
-
const secondLock = lock1.dataLockId < lock2.dataLockId ? lock2 : lock1;
|
|
201
|
-
await dataLocksTable.insert(secondLock);
|
|
202
|
-
await dataLocksTable.insert(firstLock);
|
|
203
|
-
const result = await dataLocksTable.getCurrentLock(recordId);
|
|
204
|
-
expect(result?.dataLockId).toBe(firstLock.dataLockId);
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
describe("releaseLock", () => {
|
|
208
|
-
it("should delete the lock from the database", async () => {
|
|
209
|
-
const lock = {
|
|
210
|
-
dataLockId: (0, utils_1.newid)(),
|
|
211
|
-
recordId: (0, utils_1.newid)(),
|
|
212
|
-
lockedUntil: Date.now() + 60000,
|
|
213
|
-
};
|
|
214
|
-
await dataLocksTable.insert(lock);
|
|
215
|
-
expect(await dataLocksTable.get(lock.dataLockId)).toEqual(lock);
|
|
216
|
-
await dataLocksTable.releaseLock(lock);
|
|
217
|
-
expect(await dataLocksTable.get(lock.dataLockId)).toBeUndefined();
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
describe("acquireLock", () => {
|
|
221
|
-
it("should acquire a lock when no existing locks", async () => {
|
|
222
|
-
const recordId = (0, utils_1.newid)();
|
|
223
|
-
// Mock sufficient acknowledgments for lock confirmation
|
|
224
|
-
const mockAcquireLock = jest.spyOn(dataLocksTable, "acquireLock");
|
|
225
|
-
mockAcquireLock.mockImplementation(async () => {
|
|
226
|
-
const lock = {
|
|
227
|
-
dataLockId: (0, utils_1.newid)(),
|
|
228
|
-
recordId,
|
|
229
|
-
lockedUntil: Date.now() + dataLocksTable.DEFAULT_LOCK_TIME_MS,
|
|
230
|
-
};
|
|
231
|
-
await dataLocksTable.insert(lock);
|
|
232
|
-
return lock;
|
|
233
|
-
});
|
|
234
|
-
const result = await dataLocksTable.acquireLock(recordId);
|
|
235
|
-
expect(result).toBeDefined();
|
|
236
|
-
expect(result?.recordId).toBe(recordId);
|
|
237
|
-
expect(result?.lockedUntil).toBeGreaterThan(Date.now());
|
|
238
|
-
mockAcquireLock.mockRestore();
|
|
239
|
-
});
|
|
240
|
-
it("should use custom timeout and lock time", async () => {
|
|
241
|
-
const recordId = (0, utils_1.newid)();
|
|
242
|
-
const customTimeout = 5000;
|
|
243
|
-
const customLockTime = 120000;
|
|
244
|
-
const mockAcquireLock = jest.spyOn(dataLocksTable, "acquireLock");
|
|
245
|
-
mockAcquireLock.mockImplementation(async (recordId, timeoutMs, lockTimeMs) => {
|
|
246
|
-
expect(timeoutMs).toBe(customTimeout);
|
|
247
|
-
expect(lockTimeMs).toBe(customLockTime);
|
|
248
|
-
return {
|
|
249
|
-
dataLockId: (0, utils_1.newid)(),
|
|
250
|
-
recordId,
|
|
251
|
-
lockedUntil: Date.now() + (lockTimeMs || dataLocksTable.DEFAULT_LOCK_TIME_MS),
|
|
252
|
-
};
|
|
253
|
-
});
|
|
254
|
-
await dataLocksTable.acquireLock(recordId, customTimeout, customLockTime);
|
|
255
|
-
mockAcquireLock.mockRestore();
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
describe("renewLock", () => {
|
|
259
|
-
it("should renew a valid lock", async () => {
|
|
260
|
-
const lock = {
|
|
261
|
-
dataLockId: (0, utils_1.newid)(),
|
|
262
|
-
recordId: (0, utils_1.newid)(),
|
|
263
|
-
lockedUntil: Date.now() + 60000, // 1 minute from now
|
|
264
|
-
};
|
|
265
|
-
await dataLocksTable.insert(lock);
|
|
266
|
-
const originalLockedUntil = lock.lockedUntil;
|
|
267
|
-
// Small delay to ensure time difference
|
|
268
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
269
|
-
await sleep(10);
|
|
270
|
-
const renewedLock = await dataLocksTable.renewLock(lock, 120000);
|
|
271
|
-
expect(renewedLock).toBeDefined();
|
|
272
|
-
expect(renewedLock?.lockedUntil).toBeGreaterThan(originalLockedUntil);
|
|
273
|
-
});
|
|
274
|
-
it("should reject renewal of expired lock", async () => {
|
|
275
|
-
const lock = {
|
|
276
|
-
dataLockId: (0, utils_1.newid)(),
|
|
277
|
-
recordId: (0, utils_1.newid)(),
|
|
278
|
-
lockedUntil: Date.now() - 20000, // Expired 20 seconds ago (beyond DEAD_PERIOD_MS)
|
|
279
|
-
};
|
|
280
|
-
await dataLocksTable.insert(lock);
|
|
281
|
-
const result = await dataLocksTable.renewLock(lock);
|
|
282
|
-
expect(result).toBeUndefined();
|
|
283
|
-
});
|
|
284
|
-
it("should reject renewal when lock is no longer current", async () => {
|
|
285
|
-
const recordId = (0, utils_1.newid)();
|
|
286
|
-
// getCurrentLock returns the lock with the lexicographically first dataLockId,
|
|
287
|
-
// so ensure newLockId sorts before oldLockId
|
|
288
|
-
const [newLockId, oldLockId] = [(0, utils_1.newid)(), (0, utils_1.newid)()].sort();
|
|
289
|
-
const oldLock = {
|
|
290
|
-
dataLockId: oldLockId,
|
|
291
|
-
recordId,
|
|
292
|
-
lockedUntil: Date.now() + 60000,
|
|
293
|
-
};
|
|
294
|
-
const newLock = {
|
|
295
|
-
dataLockId: newLockId,
|
|
296
|
-
recordId,
|
|
297
|
-
lockedUntil: Date.now() + 60000,
|
|
298
|
-
};
|
|
299
|
-
await dataLocksTable.insert(oldLock);
|
|
300
|
-
await dataLocksTable.insert(newLock);
|
|
301
|
-
// Try to renew the old lock - should fail because new lock is current (sorts first)
|
|
302
|
-
const result = await dataLocksTable.renewLock(oldLock);
|
|
303
|
-
expect(result).toBeUndefined();
|
|
304
|
-
});
|
|
305
|
-
});
|
|
306
|
-
describe("lockStatus", () => {
|
|
307
|
-
it("should return confirmed when sufficient acknowledgments", async () => {
|
|
308
|
-
const lock = {
|
|
309
|
-
dataLockId: (0, utils_1.newid)(),
|
|
310
|
-
recordId: (0, utils_1.newid)(),
|
|
311
|
-
lockedUntil: Date.now() + 60000,
|
|
312
|
-
};
|
|
313
|
-
// Add sufficient acknowledgments (need 4 for 3 connections: Math.ceil(3 * 0.7) + 1 = 4)
|
|
314
|
-
mockPeerDevice.addAcknowledgment(lock.dataLockId, Date.now());
|
|
315
|
-
mockPeerDevice.addAcknowledgment(lock.dataLockId, Date.now() + 1);
|
|
316
|
-
mockPeerDevice.addAcknowledgment(lock.dataLockId, Date.now() + 2);
|
|
317
|
-
mockPeerDevice.addAcknowledgment(lock.dataLockId, Date.now() + 3);
|
|
318
|
-
const status = await dataLocksTable.lockStatus(lock);
|
|
319
|
-
expect(status).toBe("confirmed");
|
|
320
|
-
});
|
|
321
|
-
it("should return pending when insufficient acknowledgments", async () => {
|
|
322
|
-
const lock = {
|
|
323
|
-
dataLockId: (0, utils_1.newid)(),
|
|
324
|
-
recordId: (0, utils_1.newid)(),
|
|
325
|
-
lockedUntil: Date.now() + 60000,
|
|
326
|
-
};
|
|
327
|
-
// Add insufficient acknowledgments (only 1, need 4)
|
|
328
|
-
mockPeerDevice.addAcknowledgment(lock.dataLockId, Date.now());
|
|
329
|
-
const status = await dataLocksTable.lockStatus(lock);
|
|
330
|
-
expect(status).toBe("pending");
|
|
331
|
-
});
|
|
332
|
-
it("should calculate acknowledgments needed correctly for different network sizes", async () => {
|
|
333
|
-
const lock = {
|
|
334
|
-
dataLockId: (0, utils_1.newid)(),
|
|
335
|
-
recordId: (0, utils_1.newid)(),
|
|
336
|
-
lockedUntil: Date.now() + 60000,
|
|
337
|
-
};
|
|
338
|
-
// Test with 1 connection (need Math.ceil(1 * 0.7) + 1 = 2 acknowledgments)
|
|
339
|
-
mockPeerDevice.setConnectionCount(1);
|
|
340
|
-
mockPeerDevice.addAcknowledgment(lock.dataLockId, Date.now());
|
|
341
|
-
mockPeerDevice.addAcknowledgment(lock.dataLockId, Date.now() + 1);
|
|
342
|
-
let status = await dataLocksTable.lockStatus(lock);
|
|
343
|
-
expect(status).toBe("confirmed");
|
|
344
|
-
// Test with 5 connections (need Math.ceil(5 * 0.7) + 1 = 5 acknowledgments)
|
|
345
|
-
mockPeerDevice.setConnectionCount(5);
|
|
346
|
-
status = await dataLocksTable.lockStatus(lock);
|
|
347
|
-
expect(status).toBe("pending"); // Still only 2 acknowledgments, need 5
|
|
348
|
-
});
|
|
349
|
-
it("should throw error when peerDevice is not set", async () => {
|
|
350
|
-
const lock = {
|
|
351
|
-
dataLockId: (0, utils_1.newid)(),
|
|
352
|
-
recordId: (0, utils_1.newid)(),
|
|
353
|
-
lockedUntil: Date.now() + 60000,
|
|
354
|
-
};
|
|
355
|
-
const originalPeerDevice = dataLocksTable.peerDevice;
|
|
356
|
-
dataLocksTable.peerDevice = undefined;
|
|
357
|
-
await expect(dataLocksTable.lockStatus(lock)).rejects.toThrow("Peer device not set for DataLocksTable");
|
|
358
|
-
dataLocksTable.peerDevice = originalPeerDevice;
|
|
359
|
-
});
|
|
360
|
-
});
|
|
361
|
-
});
|
|
362
|
-
describe("dataLockSchema", () => {
|
|
363
|
-
it("should validate valid lock data", () => {
|
|
364
|
-
const validLock = {
|
|
365
|
-
dataLockId: (0, utils_1.newid)(),
|
|
366
|
-
recordId: "test-record",
|
|
367
|
-
lockedUntil: Date.now() + 60000,
|
|
368
|
-
};
|
|
369
|
-
const result = data_locks_1.dataLockSchema.safeParse(validLock);
|
|
370
|
-
expect(result.success).toBe(true);
|
|
371
|
-
});
|
|
372
|
-
it("should validate lock with acknowledgment", () => {
|
|
373
|
-
const validLock = {
|
|
374
|
-
dataLockId: (0, utils_1.newid)(),
|
|
375
|
-
recordId: "test-record",
|
|
376
|
-
lockedUntil: Date.now() + 60000,
|
|
377
|
-
acknowledged: Date.now(),
|
|
378
|
-
};
|
|
379
|
-
const result = data_locks_1.dataLockSchema.safeParse(validLock);
|
|
380
|
-
expect(result.success).toBe(true);
|
|
381
|
-
});
|
|
382
|
-
it("should reject invalid lock data", () => {
|
|
383
|
-
const invalidLock = {
|
|
384
|
-
dataLockId: 123, // Should be string
|
|
385
|
-
recordId: "test-record",
|
|
386
|
-
lockedUntil: "invalid", // Should be number
|
|
387
|
-
};
|
|
388
|
-
const result = data_locks_1.dataLockSchema.safeParse(invalidLock);
|
|
389
|
-
expect(result.success).toBe(false);
|
|
390
|
-
});
|
|
391
|
-
it("should reject missing required fields", () => {
|
|
392
|
-
const incompleteLock = {
|
|
393
|
-
dataLockId: (0, utils_1.newid)(),
|
|
394
|
-
// Missing recordId and lockedUntil
|
|
395
|
-
};
|
|
396
|
-
const result = data_locks_1.dataLockSchema.safeParse(incompleteLock);
|
|
397
|
-
expect(result.success).toBe(false);
|
|
398
|
-
});
|
|
399
|
-
});
|
|
400
|
-
describe("DataLocks factory function", () => {
|
|
401
|
-
it("should return a DataLocksTable instance", () => {
|
|
402
|
-
// This is a simplified test - in practice you'd need to set up the factory properly
|
|
403
|
-
expect(typeof data_locks_1.DataLocks).toBe("function");
|
|
404
|
-
});
|
|
405
|
-
});
|
|
406
|
-
describe("integration scenarios", () => {
|
|
407
|
-
it("should handle concurrent lock attempts", async () => {
|
|
408
|
-
const recordId = (0, utils_1.newid)();
|
|
409
|
-
// This is a simplified version - real concurrent testing would require more complex setup
|
|
410
|
-
const mockInsert = jest.spyOn(dataLocksTable, "insert");
|
|
411
|
-
let insertCount = 0;
|
|
412
|
-
mockInsert.mockImplementation(async (lock) => {
|
|
413
|
-
insertCount++;
|
|
414
|
-
return { ...lock, dataLockId: `lock-${insertCount}` };
|
|
415
|
-
});
|
|
416
|
-
const promises = [
|
|
417
|
-
dataLocksTable.insert({ dataLockId: (0, utils_1.newid)(), recordId, lockedUntil: Date.now() + 60000 }),
|
|
418
|
-
dataLocksTable.insert({ dataLockId: (0, utils_1.newid)(), recordId, lockedUntil: Date.now() + 60000 }),
|
|
419
|
-
dataLocksTable.insert({ dataLockId: (0, utils_1.newid)(), recordId, lockedUntil: Date.now() + 60000 }),
|
|
420
|
-
];
|
|
421
|
-
const results = await Promise.all(promises);
|
|
422
|
-
expect(results).toHaveLength(3);
|
|
423
|
-
expect(insertCount).toBe(3);
|
|
424
|
-
mockInsert.mockRestore();
|
|
425
|
-
});
|
|
426
|
-
it("should handle lock expiration cleanup", async () => {
|
|
427
|
-
const recordId = (0, utils_1.newid)();
|
|
428
|
-
const expiredLock = {
|
|
429
|
-
dataLockId: (0, utils_1.newid)(),
|
|
430
|
-
recordId,
|
|
431
|
-
lockedUntil: Date.now() - 1000, // Expired
|
|
432
|
-
};
|
|
433
|
-
const validLock = {
|
|
434
|
-
dataLockId: (0, utils_1.newid)(),
|
|
435
|
-
recordId,
|
|
436
|
-
lockedUntil: Date.now() + 60000, // Valid
|
|
437
|
-
};
|
|
438
|
-
await dataLocksTable.insert(expiredLock);
|
|
439
|
-
await dataLocksTable.insert(validLock);
|
|
440
|
-
const currentLock = await dataLocksTable.getCurrentLock(recordId);
|
|
441
|
-
expect(currentLock?.dataLockId).toBe(validLock.dataLockId);
|
|
442
|
-
});
|
|
443
|
-
it("should handle multiple records with independent locks", async () => {
|
|
444
|
-
const record1 = (0, utils_1.newid)();
|
|
445
|
-
const record2 = (0, utils_1.newid)();
|
|
446
|
-
const lock1 = {
|
|
447
|
-
dataLockId: (0, utils_1.newid)(),
|
|
448
|
-
recordId: record1,
|
|
449
|
-
lockedUntil: Date.now() + 60000,
|
|
450
|
-
};
|
|
451
|
-
const lock2 = {
|
|
452
|
-
dataLockId: (0, utils_1.newid)(),
|
|
453
|
-
recordId: record2,
|
|
454
|
-
lockedUntil: Date.now() + 60000,
|
|
455
|
-
};
|
|
456
|
-
await dataLocksTable.insert(lock1);
|
|
457
|
-
await dataLocksTable.insert(lock2);
|
|
458
|
-
const currentLock1 = await dataLocksTable.getCurrentLock(record1);
|
|
459
|
-
const currentLock2 = await dataLocksTable.getCurrentLock(record2);
|
|
460
|
-
expect(currentLock1?.dataLockId).toBe(lock1.dataLockId);
|
|
461
|
-
expect(currentLock2?.dataLockId).toBe(lock2.dataLockId);
|
|
462
|
-
});
|
|
463
|
-
});
|
|
464
|
-
});
|