ushman-ledger 1.2.2 → 1.3.1
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/AGENTS.md +7 -5
- package/ARCHITECTURE.md +8 -2
- package/CHANGELOG.md +11 -0
- package/README.md +37 -5
- package/TROUBLESHOOTING.md +17 -3
- package/dist/blobs.d.ts.map +1 -1
- package/dist/blobs.js +1 -1
- package/dist/builders.d.ts +33 -0
- package/dist/builders.d.ts.map +1 -1
- package/dist/builders.js +10 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +153 -43
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +45 -11
- package/dist/handle.d.ts.map +1 -1
- package/dist/handle.js +67 -30
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +2 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/lab-min.d.ts +1 -1
- package/dist/lab-min.d.ts.map +1 -1
- package/dist/lab-min.js +2 -1
- package/dist/list.d.ts +32 -0
- package/dist/list.d.ts.map +1 -1
- package/dist/list.js +1 -1
- package/dist/patch-resolver.d.ts.map +1 -1
- package/dist/patch-resolver.js +1 -1
- package/dist/process.d.ts +2 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +16 -0
- package/dist/read-index.d.ts +7 -7
- package/dist/read-index.d.ts.map +1 -1
- package/dist/read-index.js +13 -9
- package/dist/record.d.ts.map +1 -1
- package/dist/record.js +1 -2
- package/dist/recovery.d.ts +8 -0
- package/dist/recovery.d.ts.map +1 -1
- package/dist/recovery.js +142 -30
- package/dist/render/retro.d.ts.map +1 -1
- package/dist/render/retro.js +4 -1
- package/dist/runtime-config.d.ts +2 -0
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +14 -0
- package/dist/schema/entry-core.d.ts +5 -2
- package/dist/schema/entry-core.d.ts.map +1 -1
- package/dist/schema/entry-core.js +3 -0
- package/dist/schema/entry-read.d.ts +57 -0
- package/dist/schema/entry-read.d.ts.map +1 -1
- package/dist/schema/entry-read.js +9 -1
- package/dist/schema/entry-write.d.ts +51 -0
- package/dist/schema/entry-write.d.ts.map +1 -1
- package/dist/schema/entry-write.js +9 -1
- package/dist/storage/filesystem.d.ts +14 -2
- package/dist/storage/filesystem.d.ts.map +1 -1
- package/dist/storage/filesystem.js +206 -39
- package/dist/storage/lock.d.ts.map +1 -1
- package/dist/storage/lock.js +38 -16
- package/package.json +2 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/storage/filesystem.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/storage/filesystem.ts"],"names":[],"mappings":"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"}
|
|
@@ -3,6 +3,7 @@ import { mkdir, open, readdir, readFile, rename, rm, stat } from 'node:fs/promis
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import * as v from 'valibot';
|
|
5
5
|
import { stableStringify } from "../json.js";
|
|
6
|
+
import { isProcessAlive } from "../process.js";
|
|
6
7
|
import { readLabManifestMin } from "../lab-min.js";
|
|
7
8
|
import { LEDGER_PHASES } from "../schema/entry.js";
|
|
8
9
|
import { LedgerManifestSchema } from "../schema/manifest.js";
|
|
@@ -46,9 +47,11 @@ export const resolveLedgerPaths = (workspaceRoot) => {
|
|
|
46
47
|
const root = path.join(workspaceRoot, ...LEDGER_ROOT_SEGMENTS);
|
|
47
48
|
return {
|
|
48
49
|
blobsDir: path.join(root, 'blobs'),
|
|
50
|
+
externalTempFilesDir: path.join(root, 'external-temp-files'),
|
|
49
51
|
lockFile: (phase) => path.join(root, phase, '.lock'),
|
|
50
52
|
manifestFile: path.join(root, 'manifest.json'),
|
|
51
53
|
manifestLockFile: path.join(root, '.manifest.lock'),
|
|
54
|
+
pendingCommitQuarantineDir: path.join(root, 'pending-quarantine'),
|
|
52
55
|
pendingArchivesDir: path.join(root, 'pending-archives'),
|
|
53
56
|
pendingCommitsDir: path.join(root, 'pending'),
|
|
54
57
|
phaseDir: (phase) => path.join(root, phase),
|
|
@@ -65,7 +68,9 @@ export const ensureLedgerDirectories = async (workspaceRoot) => {
|
|
|
65
68
|
const paths = resolveLedgerPaths(workspaceRoot);
|
|
66
69
|
await mkdir(paths.root, { recursive: true });
|
|
67
70
|
await mkdir(paths.blobsDir, { recursive: true });
|
|
71
|
+
await mkdir(paths.externalTempFilesDir, { recursive: true });
|
|
68
72
|
await mkdir(paths.pendingCommitsDir, { recursive: true });
|
|
73
|
+
await mkdir(paths.pendingCommitQuarantineDir, { recursive: true });
|
|
69
74
|
await mkdir(paths.pendingArchivesDir, { recursive: true });
|
|
70
75
|
await Promise.all(LEDGER_PHASES.map(async (phase) => {
|
|
71
76
|
await mkdir(paths.phaseDir(phase), { recursive: true });
|
|
@@ -80,6 +85,36 @@ export const setAtomicWriteTestHook = (filePath, hook) => {
|
|
|
80
85
|
}
|
|
81
86
|
atomicWriteTestHooks.set(key, hook);
|
|
82
87
|
};
|
|
88
|
+
const withPrimaryErrorPreserved = (error, cleanupError) => {
|
|
89
|
+
if (error instanceof Error) {
|
|
90
|
+
const errorWithCause = error;
|
|
91
|
+
if (errorWithCause.cause === undefined) {
|
|
92
|
+
Object.defineProperty(errorWithCause, 'cause', {
|
|
93
|
+
configurable: true,
|
|
94
|
+
enumerable: false,
|
|
95
|
+
value: cleanupError,
|
|
96
|
+
writable: true,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return error;
|
|
100
|
+
}
|
|
101
|
+
return new Error(String(error), { cause: cleanupError });
|
|
102
|
+
};
|
|
103
|
+
const removeTempRegistration = async (tempRegistration) => {
|
|
104
|
+
if (!tempRegistration) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await tempRegistration.remove();
|
|
108
|
+
};
|
|
109
|
+
const throwWithTempRegistrationCleanup = async ({ error, tempRegistration, }) => {
|
|
110
|
+
try {
|
|
111
|
+
await removeTempRegistration(tempRegistration);
|
|
112
|
+
}
|
|
113
|
+
catch (cleanupError) {
|
|
114
|
+
throw withPrimaryErrorPreserved(error, cleanupError);
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
};
|
|
83
118
|
const createManifest = async (workspaceRoot) => {
|
|
84
119
|
const labManifest = await readLabManifestMin(workspaceRoot);
|
|
85
120
|
const now = new Date().toISOString();
|
|
@@ -97,30 +132,73 @@ const createManifest = async (workspaceRoot) => {
|
|
|
97
132
|
workspaceId: labManifest.workspaceId,
|
|
98
133
|
});
|
|
99
134
|
};
|
|
100
|
-
export const writeAtomicTextFile = async (filePath, text) => {
|
|
135
|
+
export const writeAtomicTextFile = async (filePath, text, options = {}) => {
|
|
101
136
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
102
137
|
const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`;
|
|
103
|
-
const
|
|
138
|
+
const tempRegistration = await options.registerTempFile?.({ finalPath: filePath, tempPath });
|
|
139
|
+
let handle;
|
|
104
140
|
try {
|
|
105
|
-
await
|
|
106
|
-
|
|
141
|
+
handle = await open(tempPath, 'w');
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
await throwWithTempRegistrationCleanup({
|
|
145
|
+
error,
|
|
146
|
+
tempRegistration,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const fileHandle = handle;
|
|
150
|
+
if (!fileHandle) {
|
|
151
|
+
throw new Error(`Failed to open atomic temp file: ${tempPath}`);
|
|
152
|
+
}
|
|
153
|
+
let writeError;
|
|
154
|
+
try {
|
|
155
|
+
await fileHandle.writeFile(text, 'utf8');
|
|
156
|
+
await fileHandle.sync();
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
writeError = error;
|
|
107
160
|
}
|
|
108
161
|
finally {
|
|
109
|
-
await
|
|
162
|
+
await fileHandle.close();
|
|
163
|
+
}
|
|
164
|
+
if (writeError) {
|
|
165
|
+
await rm(tempPath, { force: true });
|
|
166
|
+
await throwWithTempRegistrationCleanup({
|
|
167
|
+
error: writeError,
|
|
168
|
+
tempRegistration,
|
|
169
|
+
});
|
|
110
170
|
}
|
|
111
171
|
await runAtomicWriteTestHook(filePath);
|
|
112
172
|
try {
|
|
113
173
|
await rename(tempPath, filePath);
|
|
174
|
+
await removeTempRegistration(tempRegistration);
|
|
114
175
|
}
|
|
115
176
|
catch (error) {
|
|
116
177
|
await rm(tempPath, { force: true });
|
|
117
|
-
|
|
178
|
+
await throwWithTempRegistrationCleanup({
|
|
179
|
+
error,
|
|
180
|
+
tempRegistration,
|
|
181
|
+
});
|
|
118
182
|
}
|
|
119
183
|
};
|
|
120
|
-
export const createAtomicTextFileWriter = async (filePath) => {
|
|
184
|
+
export const createAtomicTextFileWriter = async (filePath, options = {}) => {
|
|
121
185
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
122
186
|
const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`;
|
|
123
|
-
const
|
|
187
|
+
const tempRegistration = await options.registerTempFile?.({ finalPath: filePath, tempPath });
|
|
188
|
+
let handle;
|
|
189
|
+
try {
|
|
190
|
+
handle = await open(tempPath, 'w');
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
await throwWithTempRegistrationCleanup({
|
|
194
|
+
error,
|
|
195
|
+
tempRegistration,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const fileHandle = handle;
|
|
199
|
+
if (!fileHandle) {
|
|
200
|
+
throw new Error(`Failed to open atomic temp file: ${tempPath}`);
|
|
201
|
+
}
|
|
124
202
|
let completed = false;
|
|
125
203
|
let pendingWriteError;
|
|
126
204
|
let writeChain = Promise.resolve();
|
|
@@ -149,11 +227,12 @@ export const createAtomicTextFileWriter = async (filePath) => {
|
|
|
149
227
|
catch (error) {
|
|
150
228
|
writeError = error;
|
|
151
229
|
}
|
|
152
|
-
await
|
|
230
|
+
await fileHandle.close();
|
|
153
231
|
await rm(tempPath, { force: true });
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
232
|
+
await throwWithTempRegistrationCleanup({
|
|
233
|
+
error: writeError,
|
|
234
|
+
tempRegistration,
|
|
235
|
+
});
|
|
157
236
|
},
|
|
158
237
|
close: async () => {
|
|
159
238
|
if (!(await finalizeHandle())) {
|
|
@@ -163,23 +242,30 @@ export const createAtomicTextFileWriter = async (filePath) => {
|
|
|
163
242
|
await waitForWrites();
|
|
164
243
|
}
|
|
165
244
|
catch (error) {
|
|
166
|
-
await
|
|
245
|
+
await fileHandle.close();
|
|
167
246
|
await rm(tempPath, { force: true });
|
|
168
|
-
|
|
247
|
+
await throwWithTempRegistrationCleanup({
|
|
248
|
+
error,
|
|
249
|
+
tempRegistration,
|
|
250
|
+
});
|
|
169
251
|
}
|
|
170
252
|
try {
|
|
171
|
-
await
|
|
253
|
+
await fileHandle.sync();
|
|
172
254
|
}
|
|
173
255
|
finally {
|
|
174
|
-
await
|
|
256
|
+
await fileHandle.close();
|
|
175
257
|
}
|
|
176
258
|
await runAtomicWriteTestHook(filePath);
|
|
177
259
|
try {
|
|
178
260
|
await rename(tempPath, filePath);
|
|
261
|
+
await removeTempRegistration(tempRegistration);
|
|
179
262
|
}
|
|
180
263
|
catch (error) {
|
|
181
264
|
await rm(tempPath, { force: true });
|
|
182
|
-
|
|
265
|
+
await throwWithTempRegistrationCleanup({
|
|
266
|
+
error,
|
|
267
|
+
tempRegistration,
|
|
268
|
+
});
|
|
183
269
|
}
|
|
184
270
|
},
|
|
185
271
|
write: async (chunk) => {
|
|
@@ -188,7 +274,7 @@ export const createAtomicTextFileWriter = async (filePath) => {
|
|
|
188
274
|
return;
|
|
189
275
|
}
|
|
190
276
|
await runAtomicWriteWriteTestHook(filePath);
|
|
191
|
-
await
|
|
277
|
+
await fileHandle.write(chunk, undefined, 'utf8');
|
|
192
278
|
});
|
|
193
279
|
writeChain = nextWrite.catch((error) => {
|
|
194
280
|
pendingWriteError ??= error;
|
|
@@ -200,6 +286,26 @@ export const createAtomicTextFileWriter = async (filePath) => {
|
|
|
200
286
|
export const writeAtomicJsonFile = async (filePath, value) => {
|
|
201
287
|
await writeAtomicTextFile(filePath, `${stableStringify(value, true)}\n`);
|
|
202
288
|
};
|
|
289
|
+
const EXTERNAL_TEMP_FILE_SCHEMA_VERSION = 'ushman-ledger-external-temp-file/v1';
|
|
290
|
+
const ExternalTempFileRecordSchema = v.object({
|
|
291
|
+
createdAt: v.pipe(v.string(), v.isoTimestamp()),
|
|
292
|
+
finalPath: v.pipe(v.string(), v.minLength(1)),
|
|
293
|
+
schemaVersion: v.literal(EXTERNAL_TEMP_FILE_SCHEMA_VERSION),
|
|
294
|
+
tempPath: v.pipe(v.string(), v.minLength(1)),
|
|
295
|
+
});
|
|
296
|
+
export const createExternalTempFileRegistrar = (workspaceRoot) => async ({ finalPath, tempPath }) => {
|
|
297
|
+
const paths = await ensureLedgerDirectories(workspaceRoot);
|
|
298
|
+
const recordPath = path.join(paths.externalTempFilesDir, `${process.pid}.${Date.now()}.${randomUUID()}.json`);
|
|
299
|
+
await writeAtomicJsonFile(recordPath, v.parse(ExternalTempFileRecordSchema, {
|
|
300
|
+
createdAt: new Date().toISOString(),
|
|
301
|
+
finalPath: path.resolve(finalPath),
|
|
302
|
+
schemaVersion: EXTERNAL_TEMP_FILE_SCHEMA_VERSION,
|
|
303
|
+
tempPath: path.resolve(tempPath),
|
|
304
|
+
}));
|
|
305
|
+
return {
|
|
306
|
+
remove: async () => rm(recordPath, { force: true }),
|
|
307
|
+
};
|
|
308
|
+
};
|
|
203
309
|
const parseManifestText = (filePath, text) => {
|
|
204
310
|
try {
|
|
205
311
|
return v.parse(LedgerManifestSchema, JSON.parse(text));
|
|
@@ -268,22 +374,6 @@ const parseTempOwnerPid = (fileName) => {
|
|
|
268
374
|
}
|
|
269
375
|
return Number.parseInt(match[1], 10);
|
|
270
376
|
};
|
|
271
|
-
const isProcessAlive = (pid) => {
|
|
272
|
-
try {
|
|
273
|
-
process.kill(pid, 0);
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
catch (error) {
|
|
277
|
-
const code = error.code;
|
|
278
|
-
if (code === 'EPERM') {
|
|
279
|
-
return true;
|
|
280
|
-
}
|
|
281
|
-
if (code === 'ESRCH') {
|
|
282
|
-
return false;
|
|
283
|
-
}
|
|
284
|
-
throw error;
|
|
285
|
-
}
|
|
286
|
-
};
|
|
287
377
|
const resolveFinalPathFromTemp = (filePath) => {
|
|
288
378
|
const fileName = path.basename(filePath);
|
|
289
379
|
const tempMarkerIndex = fileName.lastIndexOf('.tmp.');
|
|
@@ -304,10 +394,12 @@ const cleanupStaleTempFile = async (fullPath) => {
|
|
|
304
394
|
throw error;
|
|
305
395
|
}
|
|
306
396
|
const ownerPid = parseTempOwnerPid(path.basename(fullPath));
|
|
307
|
-
if (
|
|
308
|
-
|
|
397
|
+
if (ownerPid !== null) {
|
|
398
|
+
if (isProcessAlive(ownerPid)) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
309
401
|
}
|
|
310
|
-
if (
|
|
402
|
+
else if (Date.now() - fileStat.mtimeMs < LEDGER_STALE_TEMP_MS) {
|
|
311
403
|
return;
|
|
312
404
|
}
|
|
313
405
|
const finalPath = resolveFinalPathFromTemp(fullPath);
|
|
@@ -325,6 +417,73 @@ const cleanupStaleTempFile = async (fullPath) => {
|
|
|
325
417
|
}
|
|
326
418
|
await rm(fullPath, { force: true });
|
|
327
419
|
};
|
|
420
|
+
const readExternalTempFileRecord = async (recordPath) => {
|
|
421
|
+
try {
|
|
422
|
+
const text = await readFile(recordPath, 'utf8');
|
|
423
|
+
return {
|
|
424
|
+
kind: 'valid',
|
|
425
|
+
record: v.parse(ExternalTempFileRecordSchema, JSON.parse(text)),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
if (error.code === 'ENOENT') {
|
|
430
|
+
return {
|
|
431
|
+
kind: 'missing',
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
kind: 'invalid',
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
const pathExists = async (filePath) => {
|
|
440
|
+
try {
|
|
441
|
+
await stat(filePath);
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
if (error.code === 'ENOENT') {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
const isTrackedAtomicTempRecord = (record) => path.dirname(record.tempPath) === path.dirname(record.finalPath) &&
|
|
452
|
+
path.basename(record.tempPath).startsWith(`${path.basename(record.finalPath)}.tmp.`);
|
|
453
|
+
const quarantineInvalidExternalTempRecord = async (recordPath) => {
|
|
454
|
+
const quarantinedPath = `${recordPath}.invalid.${Date.now()}.${randomUUID()}`;
|
|
455
|
+
try {
|
|
456
|
+
await rename(recordPath, quarantinedPath);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
if (error.code === 'ENOENT') {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
const cleanupTrackedExternalTempFile = async (recordPath) => {
|
|
466
|
+
const recordResult = await readExternalTempFileRecord(recordPath);
|
|
467
|
+
if (recordResult.kind === 'missing') {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (recordResult.kind === 'invalid') {
|
|
471
|
+
await quarantineInvalidExternalTempRecord(recordPath);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (!isTrackedAtomicTempRecord(recordResult.record)) {
|
|
475
|
+
await quarantineInvalidExternalTempRecord(recordPath);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!(await pathExists(recordResult.record.tempPath))) {
|
|
479
|
+
await rm(recordPath, { force: true });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
await cleanupStaleTempFile(recordResult.record.tempPath);
|
|
483
|
+
if (!(await pathExists(recordResult.record.tempPath))) {
|
|
484
|
+
await rm(recordPath, { force: true });
|
|
485
|
+
}
|
|
486
|
+
};
|
|
328
487
|
const listLedgerTempFiles = async (root) => {
|
|
329
488
|
const entries = await readdir(root, { withFileTypes: true });
|
|
330
489
|
const tempFiles = [];
|
|
@@ -342,8 +501,16 @@ const listLedgerTempFiles = async (root) => {
|
|
|
342
501
|
};
|
|
343
502
|
export const cleanupStaleTempFiles = async (workspaceRoot) => {
|
|
344
503
|
const paths = await ensureLedgerDirectories(workspaceRoot);
|
|
345
|
-
const tempFiles = await
|
|
346
|
-
|
|
504
|
+
const [tempFiles, externalTempRecords] = await Promise.all([
|
|
505
|
+
listLedgerTempFiles(paths.root),
|
|
506
|
+
readdir(paths.externalTempFilesDir, { withFileTypes: true }),
|
|
507
|
+
]);
|
|
508
|
+
await Promise.all([
|
|
509
|
+
...tempFiles.map(async (filePath) => cleanupStaleTempFile(filePath)),
|
|
510
|
+
...externalTempRecords
|
|
511
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
512
|
+
.map(async (entry) => cleanupTrackedExternalTempFile(path.join(paths.externalTempFilesDir, entry.name))),
|
|
513
|
+
]);
|
|
347
514
|
};
|
|
348
515
|
export const readPhaseEntryFileNames = async (workspaceRoot, phase) => {
|
|
349
516
|
const paths = await ensureLedgerDirectories(workspaceRoot);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../../src/storage/lock.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../../src/storage/lock.ts"],"names":[],"mappings":"AAIA,KAAK,WAAW,GAAG;IACf,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE;QACjB,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE;YAAE,QAAQ,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;KACtG,CAAC;IACF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC9B,CAAC;AAyMF,MAAM,MAAM,UAAU,GAAG;IACrB,QAAQ,CAAC,eAAe,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC,CAAC;AAkCF,eAAO,MAAM,WAAW,GAAU,UAAU,MAAM,EAAE,UAAS,OAAO,CAAC,WAAW,CAAM,KAAG,OAAO,CAAC,UAAU,CAgD1G,CAAC"}
|
package/dist/storage/lock.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { open, readFile, rename, rm
|
|
2
|
+
import { open, readFile, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { isProcessAlive } from "../process.js";
|
|
3
4
|
const DEFAULT_LOCK_OPTIONS = {
|
|
4
5
|
// Give writers enough time to detect and reclaim a stale lock before timing out.
|
|
5
6
|
retryDelayMs: 10,
|
|
@@ -7,6 +8,12 @@ const DEFAULT_LOCK_OPTIONS = {
|
|
|
7
8
|
timeoutMs: 35_000,
|
|
8
9
|
};
|
|
9
10
|
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
const parseOwnerPid = (value) => {
|
|
12
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
};
|
|
10
17
|
const readLockState = async (lockPath) => {
|
|
11
18
|
try {
|
|
12
19
|
const rawText = await readFile(lockPath, 'utf8');
|
|
@@ -14,6 +21,7 @@ const readLockState = async (lockPath) => {
|
|
|
14
21
|
const parsed = JSON.parse(rawText);
|
|
15
22
|
const startedAt = typeof parsed.startedAt === 'string' ? Date.parse(parsed.startedAt) : Number.NaN;
|
|
16
23
|
return {
|
|
24
|
+
ownerPid: parseOwnerPid(parsed.pid),
|
|
17
25
|
rawText,
|
|
18
26
|
startedAtMs: Number.isNaN(startedAt) ? null : startedAt,
|
|
19
27
|
token: typeof parsed.token === 'string' && parsed.token.length > 0 ? parsed.token : null,
|
|
@@ -21,6 +29,7 @@ const readLockState = async (lockPath) => {
|
|
|
21
29
|
}
|
|
22
30
|
catch {
|
|
23
31
|
return {
|
|
32
|
+
ownerPid: null,
|
|
24
33
|
rawText,
|
|
25
34
|
startedAtMs: null,
|
|
26
35
|
token: null,
|
|
@@ -35,6 +44,9 @@ const readLockState = async (lockPath) => {
|
|
|
35
44
|
}
|
|
36
45
|
};
|
|
37
46
|
const isFreshLockState = (lockState, staleMs) => {
|
|
47
|
+
if (lockState.ownerPid !== null && !isProcessAlive(lockState.ownerPid)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
38
50
|
if (lockState.startedAtMs === null) {
|
|
39
51
|
return false;
|
|
40
52
|
}
|
|
@@ -62,17 +74,17 @@ const writeExclusiveTextFile = async (filePath, text) => {
|
|
|
62
74
|
await handle.close();
|
|
63
75
|
}
|
|
64
76
|
};
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
const hasActiveReclaimMarker = async (lockPath, staleMs) => {
|
|
78
|
+
const reclaimMarkerPath = buildReclaimMarkerPath(lockPath);
|
|
79
|
+
const reclaimMarkerState = await readLockState(reclaimMarkerPath);
|
|
80
|
+
if (!reclaimMarkerState) {
|
|
81
|
+
return false;
|
|
69
82
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
throw error;
|
|
83
|
+
if (isFreshLockState(reclaimMarkerState, staleMs)) {
|
|
84
|
+
return true;
|
|
75
85
|
}
|
|
86
|
+
await rm(reclaimMarkerPath, { force: true });
|
|
87
|
+
return false;
|
|
76
88
|
};
|
|
77
89
|
const acquireReclaimMarker = async (lockPath) => {
|
|
78
90
|
const reclaimMarkerPath = buildReclaimMarkerPath(lockPath);
|
|
@@ -126,17 +138,24 @@ const reclaimIfObservedStateMatches = async ({ lockPath, observedState, testHook
|
|
|
126
138
|
}
|
|
127
139
|
throw error;
|
|
128
140
|
}
|
|
141
|
+
let shouldRemoveQuarantine = false;
|
|
129
142
|
try {
|
|
130
143
|
const quarantinedState = await readLockState(quarantinePath);
|
|
131
144
|
if (!quarantinedState || quarantinedState.rawText !== observedState.rawText) {
|
|
132
145
|
await restoreQuarantinedLock(lockPath, quarantinePath);
|
|
133
146
|
return false;
|
|
134
147
|
}
|
|
135
|
-
|
|
148
|
+
shouldRemoveQuarantine = true;
|
|
136
149
|
return true;
|
|
137
150
|
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
await restoreQuarantinedLock(lockPath, quarantinePath);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
138
155
|
finally {
|
|
139
|
-
|
|
156
|
+
if (shouldRemoveQuarantine) {
|
|
157
|
+
await rm(quarantinePath, { force: true });
|
|
158
|
+
}
|
|
140
159
|
}
|
|
141
160
|
}
|
|
142
161
|
finally {
|
|
@@ -155,7 +174,10 @@ const reclaimIfStale = async (lockPath, options) => {
|
|
|
155
174
|
});
|
|
156
175
|
};
|
|
157
176
|
const describeCurrentLockState = async (lockPath) => {
|
|
158
|
-
const [currentState, reclaiming] = await Promise.all([
|
|
177
|
+
const [currentState, reclaiming] = await Promise.all([
|
|
178
|
+
readLockState(lockPath),
|
|
179
|
+
hasActiveReclaimMarker(lockPath, DEFAULT_LOCK_OPTIONS.staleMs),
|
|
180
|
+
]);
|
|
159
181
|
if (!currentState) {
|
|
160
182
|
return reclaiming ? 'lock missing while stale reclamation marker is present' : 'lock file is missing';
|
|
161
183
|
}
|
|
@@ -163,7 +185,7 @@ const describeCurrentLockState = async (lockPath) => {
|
|
|
163
185
|
return `${ageMs}, token=${currentState.token ?? 'missing'}, reclaiming=${reclaiming ? 'yes' : 'no'}`;
|
|
164
186
|
};
|
|
165
187
|
const assertOwnership = async (lockPath, token) => {
|
|
166
|
-
if (await
|
|
188
|
+
if (await hasActiveReclaimMarker(lockPath, DEFAULT_LOCK_OPTIONS.staleMs)) {
|
|
167
189
|
throw new Error(`Lost ledger lock ownership for ${lockPath}: stale reclamation is in progress.`);
|
|
168
190
|
}
|
|
169
191
|
const currentState = await readLockState(lockPath);
|
|
@@ -182,7 +204,7 @@ export const acquireLock = async (lockPath, options = {}) => {
|
|
|
182
204
|
const mergedOptions = { ...DEFAULT_LOCK_OPTIONS, ...options };
|
|
183
205
|
const deadline = Date.now() + mergedOptions.timeoutMs;
|
|
184
206
|
while (Date.now() <= deadline) {
|
|
185
|
-
if (await
|
|
207
|
+
if (await hasActiveReclaimMarker(lockPath, mergedOptions.staleMs)) {
|
|
186
208
|
await sleep(mergedOptions.retryDelayMs);
|
|
187
209
|
continue;
|
|
188
210
|
}
|
|
@@ -201,7 +223,7 @@ export const acquireLock = async (lockPath, options = {}) => {
|
|
|
201
223
|
finally {
|
|
202
224
|
await handle.close();
|
|
203
225
|
}
|
|
204
|
-
if (await
|
|
226
|
+
if (await hasActiveReclaimMarker(lockPath, mergedOptions.staleMs)) {
|
|
205
227
|
await releaseIfOwned(lockPath, token);
|
|
206
228
|
await sleep(mergedOptions.retryDelayMs);
|
|
207
229
|
continue;
|
package/package.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"safe-stable-stringify": "^2.5.0",
|
|
14
14
|
"tar": "^7.5.15",
|
|
15
|
+
"ushman-lab-types": "^0.2.0",
|
|
15
16
|
"valibot": "^1.4.1"
|
|
16
17
|
},
|
|
17
18
|
"description": "Append-only workspace ledger library and CLI for Ushman v4.",
|
|
@@ -70,5 +71,5 @@
|
|
|
70
71
|
},
|
|
71
72
|
"type": "module",
|
|
72
73
|
"types": "dist/index.d.ts",
|
|
73
|
-
"version": "1.
|
|
74
|
+
"version": "1.3.1"
|
|
74
75
|
}
|