@druumen/sessions-db 0.1.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/CHANGELOG.md +249 -0
- package/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +250 -0
- package/cli/_write-helpers.mjs +99 -0
- package/cli/alias.mjs +115 -0
- package/cli/argparse.mjs +296 -0
- package/cli/close.mjs +116 -0
- package/cli/find.mjs +185 -0
- package/cli/format.mjs +277 -0
- package/cli/link-parent.mjs +133 -0
- package/cli/link.mjs +132 -0
- package/cli/rebuild.mjs +98 -0
- package/cli/sessions-db-session-start-main.mjs +454 -0
- package/cli/sessions-db-session-start.mjs +56 -0
- package/cli/sessions-db.mjs +119 -0
- package/cli/sweep.mjs +171 -0
- package/cli/tree.mjs +127 -0
- package/lib/git-context.mjs +479 -0
- package/lib/identity.mjs +616 -0
- package/lib/index.mjs +145 -0
- package/lib/init.mjs +185 -0
- package/lib/lock.mjs +86 -0
- package/lib/operations.mjs +490 -0
- package/lib/paths.mjs +199 -0
- package/lib/projection.mjs +496 -0
- package/lib/sanitize.mjs +131 -0
- package/lib/storage.mjs +759 -0
- package/lib/sweep.mjs +209 -0
- package/lib/transcript.mjs +230 -0
- package/lib/types.mjs +276 -0
- package/lib/uuid.mjs +116 -0
- package/lib/watch.mjs +217 -0
- package/package.json +53 -0
- package/types/git-context.d.mts +98 -0
- package/types/identity.d.mts +658 -0
- package/types/index.d.mts +10 -0
- package/types/index.d.ts +127 -0
- package/types/init.d.mts +53 -0
- package/types/lock.d.mts +18 -0
- package/types/operations.d.mts +204 -0
- package/types/paths.d.mts +54 -0
- package/types/projection.d.mts +79 -0
- package/types/sanitize.d.mts +39 -0
- package/types/storage.d.mts +276 -0
- package/types/sweep.d.mts +58 -0
- package/types/transcript.d.mts +59 -0
- package/types/types.d.mts +255 -0
- package/types/uuid.d.mts +17 -0
- package/types/watch.d.mts +33 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @druumen/sessions-db — public library entry.
|
|
3
|
+
*
|
|
4
|
+
* Curated re-export hub for the v0.1.0 public surface. Library consumers
|
|
5
|
+
* (cockpit's primary integration target, plus any future tooling that talks
|
|
6
|
+
* to sessions-db without spawning the CLI) should import EXCLUSIVELY from
|
|
7
|
+
* `@druumen/sessions-db` (this file) — never from the deeper
|
|
8
|
+
* `@druumen/sessions-db/lib/<module>.mjs` paths.
|
|
9
|
+
*
|
|
10
|
+
* The depth-paths still resolve (the package.json `exports` would let them),
|
|
11
|
+
* but they're treated as unstable internals — no semver guarantee. This
|
|
12
|
+
* file is the documented surface; anything not re-exported here is subject
|
|
13
|
+
* to refactor without notice.
|
|
14
|
+
*
|
|
15
|
+
* Type-side mirror: `types/index.d.ts` (hand-crafted) re-exports the
|
|
16
|
+
* matching TypeScript types so cockpit can write
|
|
17
|
+
*
|
|
18
|
+
* import { setAlias, watchProjection, type Projection } from '@druumen/sessions-db';
|
|
19
|
+
*
|
|
20
|
+
* and resolve everything through one entry.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Storage primitives — for consumers that already have a fully-built event
|
|
25
|
+
// and want direct lock-and-apply control. Most consumers should use
|
|
26
|
+
// `operations.*` (validated, structured-result wrappers) instead.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
loadProjection,
|
|
31
|
+
rebuildProjection,
|
|
32
|
+
recordSessionSeen,
|
|
33
|
+
tryUpdateProjection,
|
|
34
|
+
newEvent,
|
|
35
|
+
appendEvent,
|
|
36
|
+
readAllEvents,
|
|
37
|
+
saveProjection,
|
|
38
|
+
PATHS,
|
|
39
|
+
MAX_EVENT_BYTES,
|
|
40
|
+
} from './storage.mjs';
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Operations — the primary write surface for library consumers. Each
|
|
44
|
+
// function: validates input, ensures the target session exists, writes
|
|
45
|
+
// the event under the projection lock, returns
|
|
46
|
+
// `{ ok, event_id?, error? }`.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
setAlias,
|
|
51
|
+
linkTask,
|
|
52
|
+
unlinkTask,
|
|
53
|
+
setParent,
|
|
54
|
+
closeSession,
|
|
55
|
+
runSweep,
|
|
56
|
+
} from './operations.mjs';
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Lifecycle — initialize storage and watch projection for changes.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export { initProjection } from './init.mjs';
|
|
63
|
+
export { watchProjection } from './watch.mjs';
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Path resolution — exposed so library consumers (cockpit Setup Wizard,
|
|
67
|
+
// debug tooling) can introspect which storage location the resolver picks
|
|
68
|
+
// before they commit to it. The same chain is used internally by every
|
|
69
|
+
// storage primitive; surface it for explicit callers.
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export {
|
|
73
|
+
resolveStoragePaths,
|
|
74
|
+
pathsFromRoot,
|
|
75
|
+
STORAGE_FILENAMES,
|
|
76
|
+
MAX_ASCEND_DEPTH,
|
|
77
|
+
} from './paths.mjs';
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Identity — pure helpers for resolving stable_id from a Claude session
|
|
81
|
+
// signal set. Useful for consumers that want to introspect the resolution
|
|
82
|
+
// chain (e.g. visualize "matched by lineage" in a UI) without minting.
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export {
|
|
86
|
+
resolveIdentity,
|
|
87
|
+
findByClaudeSessionId,
|
|
88
|
+
findByTranscriptLineage,
|
|
89
|
+
scanFingerprintCandidates,
|
|
90
|
+
collectParentCandidates,
|
|
91
|
+
capParentCandidates,
|
|
92
|
+
classifyCorroborators,
|
|
93
|
+
meetsThreshold,
|
|
94
|
+
MAX_PARENT_CANDIDATES,
|
|
95
|
+
STRONG_CORROBORATORS,
|
|
96
|
+
WEAK_CORROBORATORS,
|
|
97
|
+
} from './identity.mjs';
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Sweep — pure planner. `runSweep` (above) wraps these for actual writes,
|
|
101
|
+
// but consumers may want the planner alone (e.g. preview UI in cockpit).
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export {
|
|
105
|
+
computeSweepTransitions,
|
|
106
|
+
computeEffectiveLastProgress,
|
|
107
|
+
} from './sweep.mjs';
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Sanitize — pure prompt-cleanup helpers used by the hook to redact PII /
|
|
111
|
+
// IDE wrappers / system reminders before persistence. Re-exported so any
|
|
112
|
+
// consumer constructing payloads outside the hook can apply the same
|
|
113
|
+
// guarantees.
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export {
|
|
117
|
+
sanitizeFirstPrompt,
|
|
118
|
+
stripIdeWrappers,
|
|
119
|
+
stripSystemReminders,
|
|
120
|
+
} from './sanitize.mjs';
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// UUIDv7 — session_id minter + helpers. Cockpit currently relies on
|
|
124
|
+
// `generateSessionId` to mint synthetic ids in tests; expose for parity
|
|
125
|
+
// with the internal hook.
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
export {
|
|
129
|
+
generateSessionId,
|
|
130
|
+
isSessionId,
|
|
131
|
+
extractTimestamp,
|
|
132
|
+
} from './uuid.mjs';
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Projection reducers — pure folders. Surface them so library consumers
|
|
136
|
+
// (and tests) can build projections from event arrays without importing
|
|
137
|
+
// the deep path.
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
export {
|
|
141
|
+
applyEvent,
|
|
142
|
+
emptyProjection,
|
|
143
|
+
emptySession,
|
|
144
|
+
rebuildFromEvents,
|
|
145
|
+
} from './projection.mjs';
|
package/lib/init.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent storage initializer for sessions-db.
|
|
3
|
+
*
|
|
4
|
+
* `initProjection({ rootPath })` is the entry point used by cockpit's Setup
|
|
5
|
+
* Wizard (and any other library consumer) to bootstrap the on-disk layout
|
|
6
|
+
* before the first `recordSessionSeen` / CLI write lands. Concretely it:
|
|
7
|
+
*
|
|
8
|
+
* - mkdir -p the parent directory for `tickets/_logs/` (or whatever
|
|
9
|
+
* `paths.eventsJsonl` resolves to)
|
|
10
|
+
* - create an empty (0-byte) `events.jsonl` if missing
|
|
11
|
+
* - create a valid empty `projection.json` (with `_meta.schema_version =
|
|
12
|
+
* 2`, fingerprint_versions, event_count = 0, last_event_id = null,
|
|
13
|
+
* sessions = {}) if missing
|
|
14
|
+
*
|
|
15
|
+
* The function is **idempotent**: calling it twice in a row leaves the
|
|
16
|
+
* second-call return value's `created.*` flags all `false` to indicate
|
|
17
|
+
* that the existing files were respected. Existing content is NEVER
|
|
18
|
+
* overwritten — the wizard MUST be safe to re-run.
|
|
19
|
+
*
|
|
20
|
+
* Failure mode: when permission / disk errors prevent creation we return
|
|
21
|
+
* `{ ok: false, error }` instead of throwing. That mirrors the rest of the
|
|
22
|
+
* library API (operations.mjs / tryUpdateProjection), so the wizard can
|
|
23
|
+
* surface errors uniformly.
|
|
24
|
+
*
|
|
25
|
+
* Why split this from storage.mjs? `loadProjection` already does a "create
|
|
26
|
+
* if missing → empty projection" path implicitly via rebuild-from-events,
|
|
27
|
+
* but it never persists that empty projection. The wizard needs visible
|
|
28
|
+
* on-disk artifacts so subsequent tools (file watchers, telemetry probes)
|
|
29
|
+
* have something to attach to.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
34
|
+
|
|
35
|
+
import { resolveStoragePaths } from './paths.mjs';
|
|
36
|
+
import { PATHS } from './storage.mjs';
|
|
37
|
+
|
|
38
|
+
const SCHEMA_VERSION = 2;
|
|
39
|
+
const FINGERPRINT_VERSIONS = ['first_human_prompt_v1', 'lineage_prefix_v1'];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Initialize sessions-db storage at the given root.
|
|
43
|
+
*
|
|
44
|
+
* Resolution semantics (Day 4):
|
|
45
|
+
*
|
|
46
|
+
* - `opts.paths` (legacy form) — fully-formed override: each `eventsJsonl`
|
|
47
|
+
* / `projectionJson` is anchored on `opts.rootPath` (or treated as
|
|
48
|
+
* absolute if it starts with `/`). Backward-compatible with Day 3
|
|
49
|
+
* callers that passed `paths: { eventsJsonl: 'custom/events.jsonl', ... }`.
|
|
50
|
+
*
|
|
51
|
+
* - `opts.rootPath` (Day 4 form, no `opts.paths`) — `rootPath` IS the
|
|
52
|
+
* storage directory; files live directly under it as
|
|
53
|
+
* `<rootPath>/sessions-db-events.jsonl` and `<rootPath>/sessions-db.json`.
|
|
54
|
+
* This is what cockpit's Setup Wizard passes (typically resolved to
|
|
55
|
+
* `<workspace>/.dru-code/`).
|
|
56
|
+
*
|
|
57
|
+
* - No opts (default) — delegates to `resolveStoragePaths()` which runs
|
|
58
|
+
* the env > existing-storage > cwd/.dru-code chain. Useful for ad-hoc
|
|
59
|
+
* "init wherever the resolver thinks it should go" scripts.
|
|
60
|
+
*
|
|
61
|
+
* @param {{
|
|
62
|
+
* rootPath?: string,
|
|
63
|
+
* paths?: { eventsJsonl?: string, projectionJson?: string, lockFile?: string },
|
|
64
|
+
* }} [opts]
|
|
65
|
+
* @returns {Promise<{
|
|
66
|
+
* ok: boolean,
|
|
67
|
+
* created?: { dir: boolean, eventsJsonl: boolean, projectionJson: boolean },
|
|
68
|
+
* paths?: { eventsJsonl: string, projectionJson: string },
|
|
69
|
+
* source?: string,
|
|
70
|
+
* error?: string,
|
|
71
|
+
* }>}
|
|
72
|
+
*/
|
|
73
|
+
export async function initProjection(opts) {
|
|
74
|
+
if (!opts || typeof opts !== 'object') {
|
|
75
|
+
return { ok: false, error: 'initProjection: opts required' };
|
|
76
|
+
}
|
|
77
|
+
const { rootPath } = opts;
|
|
78
|
+
|
|
79
|
+
let eventsPath;
|
|
80
|
+
let projectionPath;
|
|
81
|
+
let source = 'arg';
|
|
82
|
+
|
|
83
|
+
if (opts.paths) {
|
|
84
|
+
// Legacy `paths` override — anchor each rel-path against rootPath
|
|
85
|
+
// (required when paths is supplied) unless it's absolute. This shape
|
|
86
|
+
// pre-dates Day 4 and is preserved verbatim for backward compat.
|
|
87
|
+
if (typeof rootPath !== 'string' || rootPath.length === 0) {
|
|
88
|
+
return { ok: false, error: 'initProjection: rootPath required when paths override is supplied' };
|
|
89
|
+
}
|
|
90
|
+
const eventsRel = opts.paths.eventsJsonl ?? PATHS.eventsJsonl;
|
|
91
|
+
const projectionRel = opts.paths.projectionJson ?? PATHS.projectionJson;
|
|
92
|
+
const abs = (p) => (isAbsolute(p) ? p : resolve(rootPath, p));
|
|
93
|
+
eventsPath = abs(eventsRel);
|
|
94
|
+
projectionPath = abs(projectionRel);
|
|
95
|
+
} else if (typeof rootPath === 'string' && rootPath.length > 0) {
|
|
96
|
+
// Day 4 form — rootPath IS the storage dir. Delegates to resolver so
|
|
97
|
+
// canonical filenames stay in one place.
|
|
98
|
+
const r = resolveStoragePaths({ rootPath });
|
|
99
|
+
eventsPath = r.eventsJsonl;
|
|
100
|
+
projectionPath = r.projectionJson;
|
|
101
|
+
source = r.source;
|
|
102
|
+
} else {
|
|
103
|
+
// No opts — full default chain (env → ascend → cwd/.dru-code).
|
|
104
|
+
const r = resolveStoragePaths();
|
|
105
|
+
eventsPath = r.eventsJsonl;
|
|
106
|
+
projectionPath = r.projectionJson;
|
|
107
|
+
source = r.source;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Both files share a parent dir under tickets/_logs/. Compute the deeper
|
|
111
|
+
// of the two so we cover both even with custom path overrides.
|
|
112
|
+
const dirsToCreate = new Set([dirname(eventsPath), dirname(projectionPath)]);
|
|
113
|
+
|
|
114
|
+
const created = { dir: false, eventsJsonl: false, projectionJson: false };
|
|
115
|
+
try {
|
|
116
|
+
for (const dir of dirsToCreate) {
|
|
117
|
+
if (!existsSync(dir)) {
|
|
118
|
+
mkdirSync(dir, { recursive: true });
|
|
119
|
+
created.dir = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!existsSync(eventsPath)) {
|
|
123
|
+
// Touch — empty file (0 bytes). loadEvents reads this fine; the
|
|
124
|
+
// first `appendEvent` call will populate it.
|
|
125
|
+
writeFileSync(eventsPath, '', { flag: 'wx' });
|
|
126
|
+
created.eventsJsonl = true;
|
|
127
|
+
}
|
|
128
|
+
if (!existsSync(projectionPath)) {
|
|
129
|
+
const empty = emptyProjectionLiteral();
|
|
130
|
+
// `flag: 'wx'` so a concurrent initializer doesn't clobber a live
|
|
131
|
+
// projection. existsSync check + wx flag is belt-and-suspenders;
|
|
132
|
+
// the existsSync race would otherwise surface as EEXIST, which we
|
|
133
|
+
// re-translate as "not created" rather than an error.
|
|
134
|
+
try {
|
|
135
|
+
writeFileSync(
|
|
136
|
+
projectionPath,
|
|
137
|
+
JSON.stringify(empty, null, 2),
|
|
138
|
+
{ flag: 'wx' },
|
|
139
|
+
);
|
|
140
|
+
created.projectionJson = true;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err && err.code === 'EEXIST') {
|
|
143
|
+
// Lost the race; another initializer beat us. That's fine —
|
|
144
|
+
// the file exists, the contract holds.
|
|
145
|
+
created.projectionJson = false;
|
|
146
|
+
} else {
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
error: `initProjection: ${err && err.message ? err.message : String(err)}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
created,
|
|
161
|
+
paths: { eventsJsonl: eventsPath, projectionJson: projectionPath },
|
|
162
|
+
source,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build the empty projection literal we serialize for fresh initialization.
|
|
168
|
+
*
|
|
169
|
+
* Kept inline (rather than importing `emptyProjection()` from
|
|
170
|
+
* `projection.mjs`) so the on-disk shape is decoupled from the in-memory
|
|
171
|
+
* reducer — `_meta.updated` here is set to a real timestamp so consumers
|
|
172
|
+
* have a non-null marker, while `applyEvent`'s `updated` is event-driven.
|
|
173
|
+
*/
|
|
174
|
+
function emptyProjectionLiteral() {
|
|
175
|
+
return {
|
|
176
|
+
_meta: {
|
|
177
|
+
schema_version: SCHEMA_VERSION,
|
|
178
|
+
fingerprint_versions: [...FINGERPRINT_VERSIONS],
|
|
179
|
+
updated: new Date().toISOString(),
|
|
180
|
+
event_count: 0,
|
|
181
|
+
last_event_id: null,
|
|
182
|
+
},
|
|
183
|
+
sessions: {},
|
|
184
|
+
};
|
|
185
|
+
}
|
package/lib/lock.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based exclusive lock helper for sessions-db projection writes.
|
|
3
|
+
*
|
|
4
|
+
* Uses POSIX `O_CREAT | O_EXCL` semantics via `fs.openSync(path, 'wx')`:
|
|
5
|
+
* - Atomic create-or-fail across processes on a single filesystem
|
|
6
|
+
* - On EEXIST we retry until either the lock is released by the holder or
|
|
7
|
+
* the timeout window elapses
|
|
8
|
+
*
|
|
9
|
+
* The lock file content is `<pid>\t<iso-ts>\n` (one line). Future phases will
|
|
10
|
+
* use the embedded PID for stale-lock detection (kill -0 PID); this phase
|
|
11
|
+
* intentionally does not implement stale recovery — Phase 1 ticket §"Stale
|
|
12
|
+
* lock detection (PID-based)" is explicitly out of scope.
|
|
13
|
+
*
|
|
14
|
+
* Zero new npm deps: only `node:fs`, `node:timers/promises`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { closeSync, openSync, unlinkSync, writeSync } from 'node:fs';
|
|
18
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
21
|
+
const DEFAULT_RETRY_MS = 50;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Acquire an exclusive lock on `lockPath`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} lockPath - Absolute path to the lock file. Parent dir must
|
|
27
|
+
* exist; we do not mkdir-p (callers control layout).
|
|
28
|
+
* @param {{ timeoutMs?: number, retryMs?: number }} [opts]
|
|
29
|
+
* @returns {Promise<{ release: () => void }>} - Resolves with a release
|
|
30
|
+
* handle. `release()` is idempotent: calling it twice is a no-op.
|
|
31
|
+
*
|
|
32
|
+
* Throws on timeout: `Error("acquireLock: timeout after <ms>ms (path=...)").`
|
|
33
|
+
* Re-throws unexpected fs errors verbatim (anything other than EEXIST).
|
|
34
|
+
*/
|
|
35
|
+
export async function acquireLock(lockPath, opts = {}) {
|
|
36
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
37
|
+
const retryMs = opts.retryMs ?? DEFAULT_RETRY_MS;
|
|
38
|
+
const deadline = Date.now() + timeoutMs;
|
|
39
|
+
|
|
40
|
+
while (true) {
|
|
41
|
+
let fd;
|
|
42
|
+
try {
|
|
43
|
+
// 'wx' === O_WRONLY | O_CREAT | O_EXCL — atomic create-or-fail.
|
|
44
|
+
fd = openSync(lockPath, 'wx');
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err && err.code === 'EEXIST') {
|
|
47
|
+
if (Date.now() >= deadline) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`acquireLock: timeout after ${timeoutMs}ms (path=${lockPath})`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
await sleep(retryMs);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Stamp PID + iso ts so future stale-lock detection has the metadata
|
|
59
|
+
// it needs. Failure to write metadata still gives us the lock — release
|
|
60
|
+
// proceeds normally.
|
|
61
|
+
try {
|
|
62
|
+
const stamp = `${process.pid}\t${new Date().toISOString()}\n`;
|
|
63
|
+
writeSync(fd, stamp);
|
|
64
|
+
} catch {
|
|
65
|
+
// Non-fatal: keep the lock, swallow metadata write error.
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let released = false;
|
|
69
|
+
const release = () => {
|
|
70
|
+
if (released) return;
|
|
71
|
+
released = true;
|
|
72
|
+
try {
|
|
73
|
+
closeSync(fd);
|
|
74
|
+
} catch {
|
|
75
|
+
// fd may already be closed in edge cases — ignore.
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
unlinkSync(lockPath);
|
|
79
|
+
} catch {
|
|
80
|
+
// lock may already be gone — ignore.
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return { release };
|
|
85
|
+
}
|
|
86
|
+
}
|