ushman-ledger 0.3.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/AGENTS.md +41 -0
- package/CHANGELOG.md +23 -0
- package/LICENSE.md +21 -0
- package/README.md +233 -0
- package/dist/archive-journal.d.ts +63 -0
- package/dist/archive-journal.d.ts.map +1 -0
- package/dist/archive-journal.js +220 -0
- package/dist/archive.d.ts +30 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +117 -0
- package/dist/async.d.ts +2 -0
- package/dist/async.d.ts.map +1 -0
- package/dist/async.js +20 -0
- package/dist/blobs.d.ts +10 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +58 -0
- package/dist/builders.d.ts +465 -0
- package/dist/builders.d.ts.map +1 -0
- package/dist/builders.js +73 -0
- package/dist/candidate-paths.d.ts +3 -0
- package/dist/candidate-paths.d.ts.map +1 -0
- package/dist/candidate-paths.js +11 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +562 -0
- package/dist/coverage.d.ts +8 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +128 -0
- package/dist/doctor.d.ts +9 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +172 -0
- package/dist/handle.d.ts +28 -0
- package/dist/handle.d.ts.map +1 -0
- package/dist/handle.js +90 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/json.d.ts +4 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +25 -0
- package/dist/lab-min.d.ts +9 -0
- package/dist/lab-min.d.ts.map +1 -0
- package/dist/lab-min.js +23 -0
- package/dist/list.d.ts +582 -0
- package/dist/list.d.ts.map +1 -0
- package/dist/list.js +139 -0
- package/dist/manifest-update.d.ts +13 -0
- package/dist/manifest-update.d.ts.map +1 -0
- package/dist/manifest-update.js +43 -0
- package/dist/note.d.ts +13 -0
- package/dist/note.d.ts.map +1 -0
- package/dist/note.js +15 -0
- package/dist/patch-metadata.d.ts +37 -0
- package/dist/patch-metadata.d.ts.map +1 -0
- package/dist/patch-metadata.js +300 -0
- package/dist/read-index.d.ts +114 -0
- package/dist/read-index.d.ts.map +1 -0
- package/dist/read-index.js +210 -0
- package/dist/record.d.ts +25 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +268 -0
- package/dist/recovery.d.ts +39 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +189 -0
- package/dist/render/analytics-summary.d.ts +58 -0
- package/dist/render/analytics-summary.d.ts.map +1 -0
- package/dist/render/analytics-summary.js +151 -0
- package/dist/render/dependency-graph.d.ts +3 -0
- package/dist/render/dependency-graph.d.ts.map +1 -0
- package/dist/render/dependency-graph.js +18 -0
- package/dist/render/jsonl.d.ts +3 -0
- package/dist/render/jsonl.d.ts.map +1 -0
- package/dist/render/jsonl.js +8 -0
- package/dist/render/retro.d.ts +6 -0
- package/dist/render/retro.d.ts.map +1 -0
- package/dist/render/retro.js +124 -0
- package/dist/render/timeline-html.d.ts +3 -0
- package/dist/render/timeline-html.d.ts.map +1 -0
- package/dist/render/timeline-html.js +37 -0
- package/dist/schema/entry.d.ts +3298 -0
- package/dist/schema/entry.d.ts.map +1 -0
- package/dist/schema/entry.js +619 -0
- package/dist/schema/manifest.d.ts +42 -0
- package/dist/schema/manifest.d.ts.map +1 -0
- package/dist/schema/manifest.js +27 -0
- package/dist/schema/note.d.ts +10 -0
- package/dist/schema/note.d.ts.map +1 -0
- package/dist/schema/note.js +2 -0
- package/dist/storage/filesystem.d.ts +35 -0
- package/dist/storage/filesystem.d.ts.map +1 -0
- package/dist/storage/filesystem.js +258 -0
- package/dist/storage/lock.d.ts +18 -0
- package/dist/storage/lock.d.ts.map +1 -0
- package/dist/storage/lock.js +224 -0
- package/dist/uuid.d.ts +7 -0
- package/dist/uuid.d.ts.map +1 -0
- package/dist/uuid.js +25 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { LedgerPhaseSchema } from "./entry.js";
|
|
3
|
+
export const LedgerArchiveMetadataSchema = z.object({
|
|
4
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
5
|
+
integrityHash: z.string().length(64),
|
|
6
|
+
outPath: z.string().min(1),
|
|
7
|
+
});
|
|
8
|
+
export const LedgerManifestSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
archives: z.array(LedgerArchiveMetadataSchema).default([]),
|
|
11
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
12
|
+
entryCount: z.number().int().nonnegative().default(0),
|
|
13
|
+
entryLocations: z
|
|
14
|
+
.record(z.string(), z.object({
|
|
15
|
+
phase: LedgerPhaseSchema,
|
|
16
|
+
sequence: z.number().int().positive(),
|
|
17
|
+
}))
|
|
18
|
+
.default({}),
|
|
19
|
+
idempotencyIndex: z.record(z.string(), z.record(z.string(), z.string())).default({}),
|
|
20
|
+
lastSequence: z.number().int().nonnegative().default(0),
|
|
21
|
+
perPhaseCounts: z.record(z.string(), z.number().int().nonnegative()).default({}),
|
|
22
|
+
perPhaseLatest: z.record(z.string(), z.string()).default({}),
|
|
23
|
+
schemaVersion: z.literal('ushman-ledger-manifest/v1'),
|
|
24
|
+
updatedAt: z.string().datetime({ offset: true }),
|
|
25
|
+
workspaceId: z.string().uuid(),
|
|
26
|
+
})
|
|
27
|
+
.passthrough();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const NoteSubkindSchema: z.ZodEnum<{
|
|
3
|
+
regression: "regression";
|
|
4
|
+
automation: "automation";
|
|
5
|
+
retro: "retro";
|
|
6
|
+
operator: "operator";
|
|
7
|
+
"tooling-gap": "tooling-gap";
|
|
8
|
+
}>;
|
|
9
|
+
export type NoteSubkind = z.infer<typeof NoteSubkindSchema>;
|
|
10
|
+
//# sourceMappingURL=note.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"note.d.ts","sourceRoot":"","sources":["../../src/schema/note.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,iBAAiB;;;;;;EAA2E,CAAC;AAE1G,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { LEDGER_PHASES, type LedgerPhase } from '../schema/entry.ts';
|
|
2
|
+
import { type LedgerManifest } from '../schema/manifest.ts';
|
|
3
|
+
export { LEDGER_PHASES };
|
|
4
|
+
export declare const LEDGER_ROOT_SEGMENTS: readonly [".lab", "ledger"];
|
|
5
|
+
export declare const LEDGER_STALE_TEMP_MS = 60000;
|
|
6
|
+
type AtomicWriteTestHook = {
|
|
7
|
+
readonly beforeRename?: () => Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
export type LedgerPaths = {
|
|
10
|
+
readonly analyticsSummaryFile: string;
|
|
11
|
+
readonly blobsDir: string;
|
|
12
|
+
readonly lockFile: (phase: LedgerPhase) => string;
|
|
13
|
+
readonly manifestLockFile: string;
|
|
14
|
+
readonly manifestFile: string;
|
|
15
|
+
readonly pendingCommitsDir: string;
|
|
16
|
+
readonly pendingArchivesDir: string;
|
|
17
|
+
readonly phaseDir: (phase: LedgerPhase) => string;
|
|
18
|
+
readonly readIndexFile: string;
|
|
19
|
+
readonly renderFile: string;
|
|
20
|
+
readonly renderTimelineFile: string;
|
|
21
|
+
readonly root: string;
|
|
22
|
+
readonly workspaceRoot: string;
|
|
23
|
+
};
|
|
24
|
+
export declare const resolveLedgerPaths: (workspaceRoot: string) => LedgerPaths;
|
|
25
|
+
export declare const ensureLedgerDirectories: (workspaceRoot: string) => Promise<LedgerPaths>;
|
|
26
|
+
export declare const setAtomicWriteTestHook: (filePath: string, hook: AtomicWriteTestHook | null) => void;
|
|
27
|
+
export declare const writeAtomicTextFile: (filePath: string, text: string) => Promise<void>;
|
|
28
|
+
export declare const writeAtomicJsonFile: (filePath: string, value: unknown) => Promise<void>;
|
|
29
|
+
export declare const readManifest: (workspaceRoot: string) => Promise<LedgerManifest>;
|
|
30
|
+
export declare const saveManifest: (workspaceRoot: string, manifest: LedgerManifest) => Promise<void>;
|
|
31
|
+
export declare const cleanupStaleTempFiles: (workspaceRoot: string) => Promise<void>;
|
|
32
|
+
export declare const readPhaseEntryFileNames: (workspaceRoot: string, phase: LedgerPhase) => Promise<string[]>;
|
|
33
|
+
export declare const readPhaseEntryText: (workspaceRoot: string, phase: LedgerPhase, fileName: string) => Promise<string>;
|
|
34
|
+
export declare const writeEntryFile: (workspaceRoot: string, phase: LedgerPhase, fileName: string, value: unknown) => Promise<string>;
|
|
35
|
+
//# sourceMappingURL=filesystem.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/storage/filesystem.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,KAAK,cAAc,EAAwB,MAAM,uBAAuB,CAAC;AAElF,OAAO,EAAE,aAAa,EAAE,CAAC;AACzB,eAAO,MAAM,oBAAoB,6BAA8B,CAAC;AAChE,eAAO,MAAM,oBAAoB,QAAS,CAAC;AAC3C,KAAK,mBAAmB,GAAG;IACvB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/C,CAAC;AAaF,MAAM,MAAM,WAAW,GAAG;IACtB,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,MAAM,CAAC;IAClD,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,MAAM,CAAC;IAClD,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAClC,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,eAAe,MAAM,KAAG,WAiB1D,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAAU,eAAe,MAAM,KAAG,OAAO,CAAC,WAAW,CAYxF,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAAI,UAAU,MAAM,EAAE,MAAM,mBAAmB,GAAG,IAAI,SAOxF,CAAC;AAoBF,eAAO,MAAM,mBAAmB,GAAU,UAAU,MAAM,EAAE,MAAM,MAAM,kBAiBvE,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAAU,UAAU,MAAM,EAAE,OAAO,OAAO,kBAEzE,CAAC;AAoCF,eAAO,MAAM,YAAY,GAAU,eAAe,MAAM,KAAG,OAAO,CAAC,cAAc,CAmBhF,CAAC;AAEF,eAAO,MAAM,YAAY,GAAU,eAAe,MAAM,EAAE,UAAU,cAAc,kBASjF,CAAC;AAoFF,eAAO,MAAM,qBAAqB,GAAU,eAAe,MAAM,kBAIhE,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAAU,eAAe,MAAM,EAAE,OAAO,WAAW,KAAG,OAAO,CAAC,MAAM,EAAE,CAOzG,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAU,eAAe,MAAM,EAAE,OAAO,WAAW,EAAE,UAAU,MAAM,oBAGnG,CAAC;AAEF,eAAO,MAAM,cAAc,GAAU,eAAe,MAAM,EAAE,OAAO,WAAW,EAAE,UAAU,MAAM,EAAE,OAAO,OAAO,oBAK/G,CAAC"}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readdir, readFile, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { stableStringify } from "../json.js";
|
|
5
|
+
import { readLabManifestMin } from "../lab-min.js";
|
|
6
|
+
import { LEDGER_PHASES } from "../schema/entry.js";
|
|
7
|
+
import { LedgerManifestSchema } from "../schema/manifest.js";
|
|
8
|
+
export { LEDGER_PHASES };
|
|
9
|
+
export const LEDGER_ROOT_SEGMENTS = ['.lab', 'ledger'];
|
|
10
|
+
export const LEDGER_STALE_TEMP_MS = 60_000;
|
|
11
|
+
const atomicWriteTestHooks = new Map();
|
|
12
|
+
const resolveAtomicWriteKey = (filePath) => path.resolve(filePath);
|
|
13
|
+
const consumeAtomicWriteTestHook = (filePath) => {
|
|
14
|
+
const key = resolveAtomicWriteKey(filePath);
|
|
15
|
+
const hook = atomicWriteTestHooks.get(key);
|
|
16
|
+
atomicWriteTestHooks.delete(key);
|
|
17
|
+
return hook;
|
|
18
|
+
};
|
|
19
|
+
export const resolveLedgerPaths = (workspaceRoot) => {
|
|
20
|
+
const root = path.join(workspaceRoot, ...LEDGER_ROOT_SEGMENTS);
|
|
21
|
+
return {
|
|
22
|
+
analyticsSummaryFile: path.join(root, 'analytics-summary.json'),
|
|
23
|
+
blobsDir: path.join(root, 'blobs'),
|
|
24
|
+
lockFile: (phase) => path.join(root, phase, '.lock'),
|
|
25
|
+
manifestFile: path.join(root, 'manifest.json'),
|
|
26
|
+
manifestLockFile: path.join(root, '.manifest.lock'),
|
|
27
|
+
pendingArchivesDir: path.join(root, 'pending-archives'),
|
|
28
|
+
pendingCommitsDir: path.join(root, 'pending'),
|
|
29
|
+
phaseDir: (phase) => path.join(root, phase),
|
|
30
|
+
readIndexFile: path.join(root, 'read-index.json'),
|
|
31
|
+
renderFile: path.join(root, 'render.md'),
|
|
32
|
+
renderTimelineFile: path.join(root, 'render.timeline.html'),
|
|
33
|
+
root,
|
|
34
|
+
workspaceRoot,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export const ensureLedgerDirectories = async (workspaceRoot) => {
|
|
38
|
+
const paths = resolveLedgerPaths(workspaceRoot);
|
|
39
|
+
await mkdir(paths.root, { recursive: true });
|
|
40
|
+
await mkdir(paths.blobsDir, { recursive: true });
|
|
41
|
+
await mkdir(paths.pendingCommitsDir, { recursive: true });
|
|
42
|
+
await mkdir(paths.pendingArchivesDir, { recursive: true });
|
|
43
|
+
await Promise.all(LEDGER_PHASES.map(async (phase) => {
|
|
44
|
+
await mkdir(paths.phaseDir(phase), { recursive: true });
|
|
45
|
+
}));
|
|
46
|
+
return paths;
|
|
47
|
+
};
|
|
48
|
+
export const setAtomicWriteTestHook = (filePath, hook) => {
|
|
49
|
+
const key = resolveAtomicWriteKey(filePath);
|
|
50
|
+
if (!hook) {
|
|
51
|
+
atomicWriteTestHooks.delete(key);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
atomicWriteTestHooks.set(key, hook);
|
|
55
|
+
};
|
|
56
|
+
const createManifest = async (workspaceRoot) => {
|
|
57
|
+
const labManifest = await readLabManifestMin(workspaceRoot);
|
|
58
|
+
const now = new Date().toISOString();
|
|
59
|
+
return LedgerManifestSchema.parse({
|
|
60
|
+
archives: [],
|
|
61
|
+
createdAt: now,
|
|
62
|
+
entryCount: 0,
|
|
63
|
+
entryLocations: {},
|
|
64
|
+
idempotencyIndex: {},
|
|
65
|
+
lastSequence: 0,
|
|
66
|
+
perPhaseCounts: {},
|
|
67
|
+
perPhaseLatest: {},
|
|
68
|
+
schemaVersion: 'ushman-ledger-manifest/v1',
|
|
69
|
+
updatedAt: now,
|
|
70
|
+
workspaceId: labManifest.workspaceId,
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
export const writeAtomicTextFile = async (filePath, text) => {
|
|
74
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
75
|
+
const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`;
|
|
76
|
+
const handle = await open(tempPath, 'w');
|
|
77
|
+
try {
|
|
78
|
+
await handle.writeFile(text, 'utf8');
|
|
79
|
+
await handle.sync();
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await handle.close();
|
|
83
|
+
}
|
|
84
|
+
await consumeAtomicWriteTestHook(filePath)?.beforeRename?.();
|
|
85
|
+
try {
|
|
86
|
+
await rename(tempPath, filePath);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
await rm(tempPath, { force: true });
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
export const writeAtomicJsonFile = async (filePath, value) => {
|
|
94
|
+
await writeAtomicTextFile(filePath, `${stableStringify(value, true)}\n`);
|
|
95
|
+
};
|
|
96
|
+
const parseManifestText = (filePath, text) => {
|
|
97
|
+
try {
|
|
98
|
+
return LedgerManifestSchema.parse(JSON.parse(text));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw new Error(`Invalid ledger manifest at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const writeNewTextFileExclusive = async (filePath, text) => {
|
|
105
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
106
|
+
let handle;
|
|
107
|
+
try {
|
|
108
|
+
handle = await open(filePath, 'wx');
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (error.code === 'EEXIST') {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
await handle.writeFile(text, 'utf8');
|
|
118
|
+
await handle.sync();
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
await rm(filePath, { force: true });
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await handle.close();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
export const readManifest = async (workspaceRoot) => {
|
|
130
|
+
const paths = await ensureLedgerDirectories(workspaceRoot);
|
|
131
|
+
try {
|
|
132
|
+
const text = await readFile(paths.manifestFile, 'utf8');
|
|
133
|
+
return parseManifestText(paths.manifestFile, text);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const code = error.code;
|
|
137
|
+
if (code !== 'ENOENT') {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const manifest = await createManifest(workspaceRoot);
|
|
142
|
+
const created = await writeNewTextFileExclusive(paths.manifestFile, `${stableStringify(manifest, true)}\n`);
|
|
143
|
+
if (created) {
|
|
144
|
+
return manifest;
|
|
145
|
+
}
|
|
146
|
+
const text = await readFile(paths.manifestFile, 'utf8');
|
|
147
|
+
return parseManifestText(paths.manifestFile, text);
|
|
148
|
+
};
|
|
149
|
+
export const saveManifest = async (workspaceRoot, manifest) => {
|
|
150
|
+
const paths = await ensureLedgerDirectories(workspaceRoot);
|
|
151
|
+
await writeAtomicJsonFile(paths.manifestFile, LedgerManifestSchema.parse({
|
|
152
|
+
...manifest,
|
|
153
|
+
updatedAt: new Date().toISOString(),
|
|
154
|
+
}));
|
|
155
|
+
};
|
|
156
|
+
const isTempFile = (name) => name.includes('.tmp.');
|
|
157
|
+
const parseTempOwnerPid = (fileName) => {
|
|
158
|
+
const match = /\.tmp\.(\d+)\./u.exec(fileName);
|
|
159
|
+
if (!match) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
return Number.parseInt(match[1], 10);
|
|
163
|
+
};
|
|
164
|
+
const isProcessAlive = (pid) => {
|
|
165
|
+
try {
|
|
166
|
+
process.kill(pid, 0);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
const code = error.code;
|
|
171
|
+
if (code === 'EPERM') {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
if (code === 'ESRCH') {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const resolveFinalPathFromTemp = (filePath) => {
|
|
181
|
+
const fileName = path.basename(filePath);
|
|
182
|
+
const tempMarkerIndex = fileName.lastIndexOf('.tmp.');
|
|
183
|
+
if (tempMarkerIndex === -1) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return path.join(path.dirname(filePath), fileName.slice(0, tempMarkerIndex));
|
|
187
|
+
};
|
|
188
|
+
const cleanupStaleTempFile = async (fullPath) => {
|
|
189
|
+
let fileStat;
|
|
190
|
+
try {
|
|
191
|
+
fileStat = await stat(fullPath);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error.code === 'ENOENT') {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
const ownerPid = parseTempOwnerPid(path.basename(fullPath));
|
|
200
|
+
if (Date.now() - fileStat.mtimeMs < LEDGER_STALE_TEMP_MS) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (ownerPid !== null && isProcessAlive(ownerPid)) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const finalPath = resolveFinalPathFromTemp(fullPath);
|
|
207
|
+
if (finalPath) {
|
|
208
|
+
try {
|
|
209
|
+
await stat(finalPath);
|
|
210
|
+
await rm(fullPath, { force: true });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (error.code !== 'ENOENT') {
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
await rm(fullPath, { force: true });
|
|
220
|
+
};
|
|
221
|
+
const listLedgerTempFiles = async (root) => {
|
|
222
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
223
|
+
const tempFiles = [];
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
const fullPath = path.join(root, entry.name);
|
|
226
|
+
if (entry.isDirectory()) {
|
|
227
|
+
tempFiles.push(...(await listLedgerTempFiles(fullPath)));
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (entry.isFile() && isTempFile(entry.name)) {
|
|
231
|
+
tempFiles.push(fullPath);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return tempFiles;
|
|
235
|
+
};
|
|
236
|
+
export const cleanupStaleTempFiles = async (workspaceRoot) => {
|
|
237
|
+
const paths = await ensureLedgerDirectories(workspaceRoot);
|
|
238
|
+
const tempFiles = await listLedgerTempFiles(paths.root);
|
|
239
|
+
await Promise.all(tempFiles.map(async (filePath) => cleanupStaleTempFile(filePath)));
|
|
240
|
+
};
|
|
241
|
+
export const readPhaseEntryFileNames = async (workspaceRoot, phase) => {
|
|
242
|
+
const paths = await ensureLedgerDirectories(workspaceRoot);
|
|
243
|
+
const entries = await readdir(paths.phaseDir(phase), { withFileTypes: true });
|
|
244
|
+
return entries
|
|
245
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
246
|
+
.map((entry) => entry.name)
|
|
247
|
+
.sort((left, right) => left.localeCompare(right));
|
|
248
|
+
};
|
|
249
|
+
export const readPhaseEntryText = async (workspaceRoot, phase, fileName) => {
|
|
250
|
+
const paths = await ensureLedgerDirectories(workspaceRoot);
|
|
251
|
+
return readFile(path.join(paths.phaseDir(phase), fileName), 'utf8');
|
|
252
|
+
};
|
|
253
|
+
export const writeEntryFile = async (workspaceRoot, phase, fileName, value) => {
|
|
254
|
+
const paths = await ensureLedgerDirectories(workspaceRoot);
|
|
255
|
+
const filePath = path.join(paths.phaseDir(phase), fileName);
|
|
256
|
+
await writeAtomicJsonFile(filePath, value);
|
|
257
|
+
return filePath;
|
|
258
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type LockOptions = {
|
|
2
|
+
readonly retryDelayMs: number;
|
|
3
|
+
readonly staleMs: number;
|
|
4
|
+
readonly testHooks?: {
|
|
5
|
+
readonly afterStaleObservation?: (context: {
|
|
6
|
+
lockPath: string;
|
|
7
|
+
rawText: string;
|
|
8
|
+
}) => Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
readonly timeoutMs: number;
|
|
11
|
+
};
|
|
12
|
+
export type LockHandle = {
|
|
13
|
+
readonly assertOwnership: () => Promise<void>;
|
|
14
|
+
readonly release: () => Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
export declare const acquireLock: (lockPath: string, options?: Partial<LockOptions>) => Promise<LockHandle>;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../../src/storage/lock.ts"],"names":[],"mappings":"AAGA,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;AAqLF,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;AA+BF,eAAO,MAAM,WAAW,GAAU,UAAU,MAAM,EAAE,UAAS,OAAO,CAAC,WAAW,CAAM,KAAG,OAAO,CAAC,UAAU,CAgD1G,CAAC"}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { open, readFile, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
const DEFAULT_LOCK_OPTIONS = {
|
|
4
|
+
// Give writers enough time to detect and reclaim a stale lock before timing out.
|
|
5
|
+
retryDelayMs: 10,
|
|
6
|
+
staleMs: 30_000,
|
|
7
|
+
timeoutMs: 35_000,
|
|
8
|
+
};
|
|
9
|
+
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
const readLockState = async (lockPath) => {
|
|
11
|
+
try {
|
|
12
|
+
const rawText = await readFile(lockPath, 'utf8');
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(rawText);
|
|
15
|
+
const startedAt = typeof parsed.startedAt === 'string' ? Date.parse(parsed.startedAt) : Number.NaN;
|
|
16
|
+
return {
|
|
17
|
+
rawText,
|
|
18
|
+
startedAtMs: Number.isNaN(startedAt) ? null : startedAt,
|
|
19
|
+
token: typeof parsed.token === 'string' && parsed.token.length > 0 ? parsed.token : null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {
|
|
24
|
+
rawText,
|
|
25
|
+
startedAtMs: null,
|
|
26
|
+
token: null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === 'ENOENT') {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const isFreshLockState = (lockState, staleMs) => {
|
|
38
|
+
if (lockState.startedAtMs === null) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return Date.now() - lockState.startedAtMs < staleMs;
|
|
42
|
+
};
|
|
43
|
+
const buildReclaimMarkerPath = (lockPath) => `${lockPath}.reclaim`;
|
|
44
|
+
const buildQuarantinePath = (lockPath, token) => `${lockPath}.stale.${token ?? 'corrupt'}.${process.pid}.${Date.now()}.${randomUUID()}`;
|
|
45
|
+
const writeExclusiveTextFile = async (filePath, text) => {
|
|
46
|
+
let handle;
|
|
47
|
+
try {
|
|
48
|
+
handle = await open(filePath, 'wx');
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code === 'EEXIST') {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await handle.writeFile(text, 'utf8');
|
|
58
|
+
await handle.sync();
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await handle.close();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const reclaimMarkerExists = async (lockPath) => {
|
|
66
|
+
try {
|
|
67
|
+
await stat(buildReclaimMarkerPath(lockPath));
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code === 'ENOENT') {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const acquireReclaimMarker = async (lockPath) => {
|
|
78
|
+
const reclaimMarkerPath = buildReclaimMarkerPath(lockPath);
|
|
79
|
+
const created = await writeExclusiveTextFile(reclaimMarkerPath, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
80
|
+
if (!created) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
release: async () => rm(reclaimMarkerPath, { force: true }),
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
const restoreQuarantinedLock = async (lockPath, quarantinePath) => {
|
|
88
|
+
try {
|
|
89
|
+
await rename(quarantinePath, lockPath);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const code = error.code;
|
|
93
|
+
if (code === 'ENOENT') {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (code === 'EEXIST') {
|
|
97
|
+
await rm(quarantinePath, { force: true });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const reclaimIfObservedStateMatches = async ({ lockPath, observedState, testHooks, }) => {
|
|
104
|
+
// The reclaim marker prevents a fresh owner from keeping a lock created while
|
|
105
|
+
// stale reclamation temporarily moves the observed lock path out of the way.
|
|
106
|
+
const reclaimMarker = await acquireReclaimMarker(lockPath);
|
|
107
|
+
if (!reclaimMarker) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const currentState = await readLockState(lockPath);
|
|
112
|
+
if (!currentState || currentState.rawText !== observedState.rawText) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
await testHooks?.afterStaleObservation?.({
|
|
116
|
+
lockPath,
|
|
117
|
+
rawText: observedState.rawText,
|
|
118
|
+
});
|
|
119
|
+
const quarantinePath = buildQuarantinePath(lockPath, observedState.token);
|
|
120
|
+
try {
|
|
121
|
+
await rename(lockPath, quarantinePath);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error.code === 'ENOENT') {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const quarantinedState = await readLockState(quarantinePath);
|
|
131
|
+
if (!quarantinedState || quarantinedState.rawText !== observedState.rawText) {
|
|
132
|
+
await restoreQuarantinedLock(lockPath, quarantinePath);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
await rm(quarantinePath, { force: true });
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await rm(quarantinePath, { force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await reclaimMarker.release();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const reclaimIfStale = async (lockPath, options) => {
|
|
147
|
+
const observedState = await readLockState(lockPath);
|
|
148
|
+
if (!observedState || isFreshLockState(observedState, options.staleMs)) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return reclaimIfObservedStateMatches({
|
|
152
|
+
lockPath,
|
|
153
|
+
observedState,
|
|
154
|
+
testHooks: options.testHooks,
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
const describeCurrentLockState = async (lockPath) => {
|
|
158
|
+
const [currentState, reclaiming] = await Promise.all([readLockState(lockPath), reclaimMarkerExists(lockPath)]);
|
|
159
|
+
if (!currentState) {
|
|
160
|
+
return reclaiming ? 'lock missing while stale reclamation marker is present' : 'lock file is missing';
|
|
161
|
+
}
|
|
162
|
+
const ageMs = currentState.startedAtMs === null ? 'unknown age' : `${Date.now() - currentState.startedAtMs}ms old`;
|
|
163
|
+
return `${ageMs}, token=${currentState.token ?? 'missing'}, reclaiming=${reclaiming ? 'yes' : 'no'}`;
|
|
164
|
+
};
|
|
165
|
+
const assertOwnership = async (lockPath, token) => {
|
|
166
|
+
if (await reclaimMarkerExists(lockPath)) {
|
|
167
|
+
throw new Error(`Lost ledger lock ownership for ${lockPath}: stale reclamation is in progress.`);
|
|
168
|
+
}
|
|
169
|
+
const currentState = await readLockState(lockPath);
|
|
170
|
+
if (currentState?.token !== token) {
|
|
171
|
+
throw new Error(`Lost ledger lock ownership for ${lockPath}: expected token ${token}, found ${currentState?.token ?? 'missing'}.`);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const releaseIfOwned = async (lockPath, token) => {
|
|
175
|
+
const currentState = await readLockState(lockPath);
|
|
176
|
+
if (currentState?.token !== token) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
await rm(lockPath, { force: true });
|
|
180
|
+
};
|
|
181
|
+
export const acquireLock = async (lockPath, options = {}) => {
|
|
182
|
+
const mergedOptions = { ...DEFAULT_LOCK_OPTIONS, ...options };
|
|
183
|
+
const deadline = Date.now() + mergedOptions.timeoutMs;
|
|
184
|
+
while (Date.now() <= deadline) {
|
|
185
|
+
if (await reclaimMarkerExists(lockPath)) {
|
|
186
|
+
await sleep(mergedOptions.retryDelayMs);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const token = randomUUID();
|
|
190
|
+
let handle;
|
|
191
|
+
try {
|
|
192
|
+
handle = await open(lockPath, 'wx');
|
|
193
|
+
try {
|
|
194
|
+
await handle.writeFile(`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), token }, null, 2)}\n`, 'utf8');
|
|
195
|
+
await handle.sync();
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
await rm(lockPath, { force: true });
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await handle.close();
|
|
203
|
+
}
|
|
204
|
+
if (await reclaimMarkerExists(lockPath)) {
|
|
205
|
+
await releaseIfOwned(lockPath, token);
|
|
206
|
+
await sleep(mergedOptions.retryDelayMs);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
assertOwnership: async () => assertOwnership(lockPath, token),
|
|
211
|
+
release: async () => releaseIfOwned(lockPath, token),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
const code = error.code;
|
|
216
|
+
if (code !== 'EEXIST') {
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
await reclaimIfStale(lockPath, mergedOptions);
|
|
220
|
+
await sleep(mergedOptions.retryDelayMs);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
throw new Error(`Timed out waiting for ledger lock after ${mergedOptions.timeoutMs}ms: ${lockPath} (${await describeCurrentLockState(lockPath)}).`);
|
|
224
|
+
};
|
package/dist/uuid.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const isUuidV7: (value: string) => boolean;
|
|
2
|
+
export declare const generateUuidV7: () => string;
|
|
3
|
+
export declare const createDeterministicUuidV7: ({ seed, timestamp, }: {
|
|
4
|
+
readonly seed: string;
|
|
5
|
+
readonly timestamp: number | string;
|
|
6
|
+
}) => string;
|
|
7
|
+
//# sourceMappingURL=uuid.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"uuid.d.ts","sourceRoot":"","sources":["../src/uuid.ts"],"names":[],"mappings":"AAmBA,eAAO,MAAM,QAAQ,GAAI,OAAO,MAAM,YAAgC,CAAC;AAEvE,eAAO,MAAM,cAAc,cAAiB,CAAC;AAE7C,eAAO,MAAM,yBAAyB,GAAI,sBAGvC;IACC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;CACvC,WAQA,CAAC"}
|
package/dist/uuid.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { uuidv7 } from 'uuidv7';
|
|
2
|
+
import { sha256Hex } from "./json.js";
|
|
3
|
+
const UUID_V7_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
4
|
+
const toTimestampHex = (timestamp) => timestamp.toString(16).padStart(12, '0').slice(-12);
|
|
5
|
+
const normalizeTimestamp = (timestamp) => {
|
|
6
|
+
if (typeof timestamp === 'number') {
|
|
7
|
+
if (!Number.isFinite(timestamp) || timestamp < 0) {
|
|
8
|
+
return 0;
|
|
9
|
+
}
|
|
10
|
+
return Math.trunc(timestamp);
|
|
11
|
+
}
|
|
12
|
+
const parsed = Date.parse(timestamp);
|
|
13
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
14
|
+
};
|
|
15
|
+
export const isUuidV7 = (value) => UUID_V7_PATTERN.test(value);
|
|
16
|
+
export const generateUuidV7 = () => uuidv7();
|
|
17
|
+
export const createDeterministicUuidV7 = ({ seed, timestamp, }) => {
|
|
18
|
+
const hash = sha256Hex(seed);
|
|
19
|
+
const timestampHex = toTimestampHex(normalizeTimestamp(timestamp));
|
|
20
|
+
const versionedSegment = `7${hash.slice(0, 3)}`;
|
|
21
|
+
const variantNibble = ((Number.parseInt(hash[3] ?? '0', 16) & 0x3) | 0x8).toString(16);
|
|
22
|
+
const variantSegment = `${variantNibble}${hash.slice(4, 7)}`;
|
|
23
|
+
const tailSegment = hash.slice(7, 19);
|
|
24
|
+
return `${timestampHex.slice(0, 8)}-${timestampHex.slice(8, 12)}-${versionedSegment}-${variantSegment}-${tailSegment}`;
|
|
25
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB,UAAU,CAAC"}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const LEDGER_LIBRARY_VERSION = '1.0.0';
|