@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +10 -0
  4. package/README.md +250 -0
  5. package/cli/_write-helpers.mjs +99 -0
  6. package/cli/alias.mjs +115 -0
  7. package/cli/argparse.mjs +296 -0
  8. package/cli/close.mjs +116 -0
  9. package/cli/find.mjs +185 -0
  10. package/cli/format.mjs +277 -0
  11. package/cli/link-parent.mjs +133 -0
  12. package/cli/link.mjs +132 -0
  13. package/cli/rebuild.mjs +98 -0
  14. package/cli/sessions-db-session-start-main.mjs +454 -0
  15. package/cli/sessions-db-session-start.mjs +56 -0
  16. package/cli/sessions-db.mjs +119 -0
  17. package/cli/sweep.mjs +171 -0
  18. package/cli/tree.mjs +127 -0
  19. package/lib/git-context.mjs +479 -0
  20. package/lib/identity.mjs +616 -0
  21. package/lib/index.mjs +145 -0
  22. package/lib/init.mjs +185 -0
  23. package/lib/lock.mjs +86 -0
  24. package/lib/operations.mjs +490 -0
  25. package/lib/paths.mjs +199 -0
  26. package/lib/projection.mjs +496 -0
  27. package/lib/sanitize.mjs +131 -0
  28. package/lib/storage.mjs +759 -0
  29. package/lib/sweep.mjs +209 -0
  30. package/lib/transcript.mjs +230 -0
  31. package/lib/types.mjs +276 -0
  32. package/lib/uuid.mjs +116 -0
  33. package/lib/watch.mjs +217 -0
  34. package/package.json +53 -0
  35. package/types/git-context.d.mts +98 -0
  36. package/types/identity.d.mts +658 -0
  37. package/types/index.d.mts +10 -0
  38. package/types/index.d.ts +127 -0
  39. package/types/init.d.mts +53 -0
  40. package/types/lock.d.mts +18 -0
  41. package/types/operations.d.mts +204 -0
  42. package/types/paths.d.mts +54 -0
  43. package/types/projection.d.mts +79 -0
  44. package/types/sanitize.d.mts +39 -0
  45. package/types/storage.d.mts +276 -0
  46. package/types/sweep.d.mts +58 -0
  47. package/types/transcript.d.mts +59 -0
  48. package/types/types.d.mts +255 -0
  49. package/types/uuid.d.mts +17 -0
  50. package/types/watch.d.mts +33 -0
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Library-API operations for sessions-db.
3
+ *
4
+ * These wrap the storage primitives (`tryUpdateProjection` + `loadProjection`)
5
+ * with input validation, business invariants, and a uniform `{ ok, event_id?,
6
+ * error? }` result shape so callers (the CLI handlers AND library consumers
7
+ * such as cockpit) do not have to re-implement the same checks.
8
+ *
9
+ * Three contracts every operation here MUST honor:
10
+ *
11
+ * 1. **Validate before write.** Each operation rejects invalid input and
12
+ * missing target sessions BEFORE appending to events.jsonl. We do not
13
+ * want the SSoT to grow `alias_set` events for non-existent sessions.
14
+ *
15
+ * 2. **Result shape.** Success → `{ ok: true, event_id: '<evt_...>' }`.
16
+ * Failure → `{ ok: false, error: '<message>' }`. Operations DO NOT
17
+ * throw for business-class failures (lock timeout, not-found, cycle).
18
+ * System-class failures (disk full, permission denied) are caught by
19
+ * `tryUpdateProjection` and returned as `{ ok: false }` too — operations
20
+ * preserve that shape rather than re-raising.
21
+ *
22
+ * 3. **Lock-safe.** Every operation that mutates the projection routes
23
+ * through `tryUpdateProjection`, which holds the projection lock across
24
+ * the load → apply → save cycle. Operations never themselves perform
25
+ * raw `appendEvent` / `saveProjection` outside that primitive.
26
+ *
27
+ * Adding a new operation: write a thin function that builds the canonical
28
+ * event payload, calls `tryUpdateProjection`, and returns `commitResult()`.
29
+ * Resist the urge to extend signatures with `--dry-run` or `--json` —
30
+ * those are CLI-display concerns; the library returns structured results
31
+ * and lets the caller render them.
32
+ */
33
+
34
+ import { computeSweepTransitions } from './sweep.mjs';
35
+ import {
36
+ loadProjection,
37
+ newEvent,
38
+ tryUpdateProjection,
39
+ } from './storage.mjs';
40
+
41
+ const VALID_OUTCOMES = new Set([
42
+ 'open',
43
+ 'done',
44
+ 'blocked',
45
+ 'abandoned',
46
+ 'merged',
47
+ 'superseded',
48
+ ]);
49
+
50
+ const MAX_PARENT_CHAIN_DEPTH = 50;
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Internal helpers
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Resolve `{ rootPath, root, paths }` opts into a single object suitable
58
+ * for storage primitives. All fields are optional; when omitted we let
59
+ * storage fall back to its full Day 4 resolution chain (env → ascend →
60
+ * cwd/.dru-code).
61
+ *
62
+ * Storage's `resolvePaths` honors all three shapes (`paths` > `rootPath`
63
+ * > `root` > default), so we just pass them through. Callers picking
64
+ * `rootPath` (Day 4 form) get the canonical-filename layout; callers on
65
+ * the legacy `root` form keep the `tickets/_logs/` anchored layout.
66
+ */
67
+ function storageOpts({ rootPath, root, paths } = {}) {
68
+ const out = {};
69
+ if (rootPath !== undefined) out.rootPath = rootPath;
70
+ if (root !== undefined) out.root = root;
71
+ if (paths !== undefined) out.paths = paths;
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * Verify a stable_id exists in the projection. Returns the matched session
77
+ * record on success or `{ ok: false, error }` on miss. Library consumers
78
+ * differentiate the miss via the `error` string; CLI wraps it in stderr +
79
+ * exit 1.
80
+ */
81
+ async function ensureSessionExists(stableId, opts) {
82
+ const projection = await loadProjection(storageOpts(opts));
83
+ const session = projection.sessions && projection.sessions[stableId];
84
+ if (!session) {
85
+ return { ok: false, error: `stable_id not found: ${stableId}`, projection: null };
86
+ }
87
+ return { ok: true, projection, session };
88
+ }
89
+
90
+ /**
91
+ * Build event + commit through tryUpdateProjection. Returns the canonical
92
+ * library-API result shape.
93
+ */
94
+ async function commitOp({ op, stableId, payload, opts }) {
95
+ const event = newEvent({ op, stable_id: stableId, payload });
96
+ const result = await tryUpdateProjection(event, storageOpts(opts));
97
+ if (!result.ok) {
98
+ return { ok: false, error: result.error };
99
+ }
100
+ return { ok: true, event_id: event.event_id };
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Public operations
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Set or clear the human-readable alias on a session.
109
+ *
110
+ * Either `alias` (non-empty string) or `clear: true` must be provided —
111
+ * mutually exclusive. Validation matches the CLI's argparse behavior so the
112
+ * library consumer surface is symmetric with the CLI surface.
113
+ *
114
+ * @param {{
115
+ * stableId: string,
116
+ * alias?: string,
117
+ * clear?: boolean,
118
+ * rootPath?: string,
119
+ * root?: string,
120
+ * paths?: object,
121
+ * }} opts
122
+ * @returns {Promise<{ ok: boolean, event_id?: string, error?: string }>}
123
+ */
124
+ export async function setAlias(opts) {
125
+ if (!opts || typeof opts !== 'object') {
126
+ return { ok: false, error: 'setAlias: opts required' };
127
+ }
128
+ const { stableId, alias, clear } = opts;
129
+ if (typeof stableId !== 'string' || stableId.length === 0) {
130
+ return { ok: false, error: 'setAlias: stableId required' };
131
+ }
132
+ const wantsClear = clear === true;
133
+ const hasAlias = alias !== undefined && alias !== null;
134
+ if (wantsClear && hasAlias) {
135
+ return { ok: false, error: 'setAlias: alias and clear are mutually exclusive' };
136
+ }
137
+ if (!wantsClear && !hasAlias) {
138
+ return { ok: false, error: 'setAlias: provide alias or clear=true' };
139
+ }
140
+ if (hasAlias && (typeof alias !== 'string' || alias.length === 0)) {
141
+ return { ok: false, error: 'setAlias: alias must be a non-empty string' };
142
+ }
143
+ const exists = await ensureSessionExists(stableId, opts);
144
+ if (!exists.ok) return { ok: false, error: exists.error };
145
+ const payload = wantsClear ? { alias: null } : { alias };
146
+ return commitOp({ op: 'alias_set', stableId, payload, opts });
147
+ }
148
+
149
+ /**
150
+ * Link a session to one or more tasks / projects (additive, idempotent).
151
+ *
152
+ * At least one of `tasks` / `projects` must be a non-empty array. The
153
+ * reducer already de-dupes against existing entries so re-running with the
154
+ * same payload is a no-op on projection state (but still writes an audit
155
+ * event).
156
+ *
157
+ * @param {{
158
+ * stableId: string,
159
+ * tasks?: string[],
160
+ * projects?: string[],
161
+ * rootPath?: string,
162
+ * root?: string,
163
+ * paths?: object,
164
+ * }} opts
165
+ * @returns {Promise<{ ok: boolean, event_id?: string, error?: string }>}
166
+ */
167
+ export async function linkTask(opts) {
168
+ if (!opts || typeof opts !== 'object') {
169
+ return { ok: false, error: 'linkTask: opts required' };
170
+ }
171
+ const { stableId } = opts;
172
+ if (typeof stableId !== 'string' || stableId.length === 0) {
173
+ return { ok: false, error: 'linkTask: stableId required' };
174
+ }
175
+ const tasks = normalizeIdList(opts.tasks);
176
+ const projects = normalizeIdList(opts.projects);
177
+ if (tasks.length === 0 && projects.length === 0) {
178
+ return { ok: false, error: 'linkTask: provide at least one task or project' };
179
+ }
180
+ const exists = await ensureSessionExists(stableId, opts);
181
+ if (!exists.ok) return { ok: false, error: exists.error };
182
+ const payload = {};
183
+ if (tasks.length > 0) payload.tasks = tasks;
184
+ if (projects.length > 0) payload.projects = projects;
185
+ return commitOp({ op: 'session_link', stableId, payload, opts });
186
+ }
187
+
188
+ /**
189
+ * Unlink one or more tasks / projects from a session (set-based filter,
190
+ * idempotent). Removing an id that isn't present is a no-op on projection
191
+ * state but still produces an audit event — operator intent is recorded
192
+ * regardless of resulting state change.
193
+ *
194
+ * @param {{
195
+ * stableId: string,
196
+ * tasks?: string[],
197
+ * projects?: string[],
198
+ * rootPath?: string,
199
+ * root?: string,
200
+ * paths?: object,
201
+ * }} opts
202
+ * @returns {Promise<{ ok: boolean, event_id?: string, error?: string }>}
203
+ */
204
+ export async function unlinkTask(opts) {
205
+ if (!opts || typeof opts !== 'object') {
206
+ return { ok: false, error: 'unlinkTask: opts required' };
207
+ }
208
+ const { stableId } = opts;
209
+ if (typeof stableId !== 'string' || stableId.length === 0) {
210
+ return { ok: false, error: 'unlinkTask: stableId required' };
211
+ }
212
+ const tasks = normalizeIdList(opts.tasks);
213
+ const projects = normalizeIdList(opts.projects);
214
+ if (tasks.length === 0 && projects.length === 0) {
215
+ return { ok: false, error: 'unlinkTask: provide at least one task or project' };
216
+ }
217
+ const exists = await ensureSessionExists(stableId, opts);
218
+ if (!exists.ok) return { ok: false, error: exists.error };
219
+ const payload = {};
220
+ if (tasks.length > 0) payload.tasks = tasks;
221
+ if (projects.length > 0) payload.projects = projects;
222
+ return commitOp({ op: 'session_unlink', stableId, payload, opts });
223
+ }
224
+
225
+ /**
226
+ * Set or clear the hub-spoke parent relationship for a session.
227
+ *
228
+ * Either `parentId` (non-empty string, distinct from `childId`) or `clear:
229
+ * true` must be provided. When setting a parent we:
230
+ * - reject self-cycle (parentId === childId, exit-1 in CLI)
231
+ * - verify parent exists
232
+ * - walk parent's ancestor chain up to MAX_PARENT_CHAIN_DEPTH and reject
233
+ * if `childId` appears anywhere — that would close a cycle of length
234
+ * ≥ 2 (e.g. existing A→B + proposed `setParent({childId: B, parentId: A})`
235
+ * would form A→B→A).
236
+ *
237
+ * The MAX_PARENT_CHAIN_DEPTH bound is a defense against a stale projection
238
+ * cycle (rare; would require an earlier guard bypass). 50 is generous —
239
+ * real hub-spoke chains are 1-3 hops.
240
+ *
241
+ * @param {{
242
+ * childId: string,
243
+ * parentId?: string,
244
+ * clear?: boolean,
245
+ * rootPath?: string,
246
+ * root?: string,
247
+ * paths?: object,
248
+ * }} opts
249
+ * @returns {Promise<{ ok: boolean, event_id?: string, error?: string }>}
250
+ */
251
+ export async function setParent(opts) {
252
+ if (!opts || typeof opts !== 'object') {
253
+ return { ok: false, error: 'setParent: opts required' };
254
+ }
255
+ const { childId, parentId, clear } = opts;
256
+ if (typeof childId !== 'string' || childId.length === 0) {
257
+ return { ok: false, error: 'setParent: childId required' };
258
+ }
259
+ const wantsClear = clear === true;
260
+ const hasParent = parentId !== undefined && parentId !== null;
261
+ if (wantsClear && hasParent) {
262
+ return { ok: false, error: 'setParent: parentId and clear are mutually exclusive' };
263
+ }
264
+ if (!wantsClear && !hasParent) {
265
+ return { ok: false, error: 'setParent: provide parentId or clear=true' };
266
+ }
267
+ if (hasParent && (typeof parentId !== 'string' || parentId.length === 0)) {
268
+ return { ok: false, error: 'setParent: parentId must be a non-empty string' };
269
+ }
270
+ if (hasParent && parentId === childId) {
271
+ return {
272
+ ok: false,
273
+ error: 'setParent: parent and child cannot be the same stable_id',
274
+ };
275
+ }
276
+
277
+ // Verify child exists. Cycle detection must use the same projection load
278
+ // so the walk reflects what storage will see when the event commits.
279
+ const childCheck = await ensureSessionExists(childId, opts);
280
+ if (!childCheck.ok) return { ok: false, error: childCheck.error };
281
+
282
+ if (hasParent) {
283
+ const projection = childCheck.projection;
284
+ const parentSession = projection.sessions && projection.sessions[parentId];
285
+ if (!parentSession) {
286
+ return { ok: false, error: `stable_id not found: ${parentId}` };
287
+ }
288
+
289
+ // Multi-hop cycle detection — walk parent's ancestor chain via
290
+ // parent_session_id pointers; refuse if we encounter `childId` along
291
+ // the way. The 1-cycle (parentId === childId) was already rejected.
292
+ let cursor = parentId;
293
+ for (let depth = 0; depth < MAX_PARENT_CHAIN_DEPTH && cursor; depth++) {
294
+ if (cursor === childId) {
295
+ return {
296
+ ok: false,
297
+ error:
298
+ `setParent: would create a cycle: proposed parent ${parentId} ` +
299
+ `reaches child ${childId} after ${depth} hop(s)`,
300
+ };
301
+ }
302
+ const ancestor = projection.sessions && projection.sessions[cursor];
303
+ cursor = ancestor && ancestor.parent_session_id
304
+ ? ancestor.parent_session_id
305
+ : null;
306
+ }
307
+ }
308
+
309
+ const payload = wantsClear
310
+ ? { parent_session_id: null }
311
+ : { parent_session_id: parentId };
312
+ return commitOp({ op: 'parent_set', stableId: childId, payload, opts });
313
+ }
314
+
315
+ /**
316
+ * Close (or reopen) a session with a terminal outcome.
317
+ *
318
+ * Outcome enum is enforced (matches projection schema): open | done |
319
+ * blocked | abandoned | merged | superseded. `open` is allowed — operators
320
+ * may reopen a previously-closed session by passing `outcome: 'open'`; the
321
+ * reducer's closed_at always tracks the latest close event so the reopen is
322
+ * visible in the audit trail.
323
+ *
324
+ * @param {{
325
+ * stableId: string,
326
+ * outcome: string,
327
+ * reason?: string,
328
+ * rootPath?: string,
329
+ * root?: string,
330
+ * paths?: object,
331
+ * }} opts
332
+ * @returns {Promise<{ ok: boolean, event_id?: string, error?: string }>}
333
+ */
334
+ export async function closeSession(opts) {
335
+ if (!opts || typeof opts !== 'object') {
336
+ return { ok: false, error: 'closeSession: opts required' };
337
+ }
338
+ const { stableId, outcome, reason } = opts;
339
+ if (typeof stableId !== 'string' || stableId.length === 0) {
340
+ return { ok: false, error: 'closeSession: stableId required' };
341
+ }
342
+ if (typeof outcome !== 'string' || outcome.length === 0) {
343
+ return { ok: false, error: 'closeSession: outcome required' };
344
+ }
345
+ if (!VALID_OUTCOMES.has(outcome)) {
346
+ return {
347
+ ok: false,
348
+ error:
349
+ `closeSession: outcome must be one of: ` +
350
+ `${[...VALID_OUTCOMES].join(', ')}`,
351
+ };
352
+ }
353
+ if (reason !== undefined && reason !== null && typeof reason !== 'string') {
354
+ return { ok: false, error: 'closeSession: reason must be a string' };
355
+ }
356
+ const exists = await ensureSessionExists(stableId, opts);
357
+ if (!exists.ok) return { ok: false, error: exists.error };
358
+
359
+ const payload = { outcome };
360
+ if (reason !== undefined) payload.closed_reason = reason;
361
+ return commitOp({ op: 'close', stableId, payload, opts });
362
+ }
363
+
364
+ /**
365
+ * Compute and (optionally) apply activity_state transitions across all
366
+ * sessions in the projection.
367
+ *
368
+ * Returns:
369
+ * - dryRun: true → `{ ok: true, dryRun: true, transitions }` with the
370
+ * planned transitions list (no events written).
371
+ * - dryRun: false → `{ ok: boolean, applied, failed, summary }` after
372
+ * attempting each transition through `tryUpdateProjection`. `ok` is
373
+ * true when zero failures.
374
+ *
375
+ * Lock model: each transition acquires the projection lock independently
376
+ * via `tryUpdateProjection`. For typical sweep volumes (single digits per
377
+ * run) this is fine; if the workspace grows huge a future `--batch` mode
378
+ * can fold all transitions into a single under-lock pass.
379
+ *
380
+ * @param {{
381
+ * rootPath?: string,
382
+ * root?: string,
383
+ * paths?: object,
384
+ * idleThresholdDays?: number,
385
+ * archiveThresholdDays?: number,
386
+ * dryRun?: boolean,
387
+ * now?: number,
388
+ * }} [opts]
389
+ * @returns {Promise<
390
+ * | { ok: true, dryRun: true, transitions: Array<object> }
391
+ * | { ok: boolean, applied: Array<object>, failed: Array<object>, summary: object }
392
+ * >}
393
+ */
394
+ export async function runSweep(opts = {}) {
395
+ const idleThresholdDays = opts.idleThresholdDays;
396
+ const archiveThresholdDays = opts.archiveThresholdDays;
397
+
398
+ if (idleThresholdDays !== undefined
399
+ && (!Number.isFinite(idleThresholdDays) || idleThresholdDays <= 0)) {
400
+ return {
401
+ ok: false,
402
+ error: `runSweep: idleThresholdDays must be a positive number (got: ${idleThresholdDays})`,
403
+ };
404
+ }
405
+ if (archiveThresholdDays !== undefined
406
+ && (!Number.isFinite(archiveThresholdDays) || archiveThresholdDays <= 0)) {
407
+ return {
408
+ ok: false,
409
+ error: `runSweep: archiveThresholdDays must be a positive number (got: ${archiveThresholdDays})`,
410
+ };
411
+ }
412
+ if (idleThresholdDays !== undefined
413
+ && archiveThresholdDays !== undefined
414
+ && archiveThresholdDays < idleThresholdDays) {
415
+ return {
416
+ ok: false,
417
+ error:
418
+ `runSweep: archiveThresholdDays (${archiveThresholdDays}) must be >= ` +
419
+ `idleThresholdDays (${idleThresholdDays})`,
420
+ };
421
+ }
422
+
423
+ const projection = await loadProjection(storageOpts(opts));
424
+ const transitions = computeSweepTransitions(projection, {
425
+ idleThresholdDays,
426
+ archiveThresholdDays,
427
+ now: opts.now,
428
+ });
429
+
430
+ if (opts.dryRun === true) {
431
+ return { ok: true, dryRun: true, transitions };
432
+ }
433
+
434
+ const applied = [];
435
+ const failed = [];
436
+ for (const t of transitions) {
437
+ const event = newEvent({
438
+ op: 'sweep',
439
+ stable_id: t.stable_id,
440
+ payload: {
441
+ activity_state: t.to_state,
442
+ effective_last_progress: t.effective_last_progress,
443
+ },
444
+ });
445
+ const result = await tryUpdateProjection(event, storageOpts(opts));
446
+ if (result.ok) {
447
+ applied.push({ ...t, event_id: event.event_id });
448
+ } else {
449
+ failed.push({ ...t, error: result.error });
450
+ }
451
+ }
452
+
453
+ const toIdle = applied.filter((a) => a.to_state === 'idle').length;
454
+ const toArchived = applied.filter((a) => a.to_state === 'archived').length;
455
+ return {
456
+ ok: failed.length === 0,
457
+ applied,
458
+ failed,
459
+ summary: {
460
+ total: transitions.length,
461
+ applied: applied.length,
462
+ failed: failed.length,
463
+ to_idle: toIdle,
464
+ to_archived: toArchived,
465
+ },
466
+ };
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // Local utilities
471
+ // ---------------------------------------------------------------------------
472
+
473
+ /**
474
+ * Coerce an id-list input into a deduped array of non-empty strings.
475
+ * Accepts undefined / null / single-string / array. Used by linkTask /
476
+ * unlinkTask so a caller passing `'foo'` instead of `['foo']` still works.
477
+ */
478
+ function normalizeIdList(input) {
479
+ if (input === undefined || input === null) return [];
480
+ const arr = Array.isArray(input) ? input : [input];
481
+ const seen = new Set();
482
+ const out = [];
483
+ for (const v of arr) {
484
+ if (typeof v !== 'string' || v.length === 0) continue;
485
+ if (seen.has(v)) continue;
486
+ seen.add(v);
487
+ out.push(v);
488
+ }
489
+ return out;
490
+ }
package/lib/paths.mjs ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Centralized storage-path resolution for sessions-db.
3
+ *
4
+ * Prior to Day 4, every storage primitive (`appendEvent`, `loadProjection`,
5
+ * `tryUpdateProjection`, `recordSessionSeen`, `initProjection`,
6
+ * `watchProjection`) hand-rolled its own "anchor opts.paths against opts.root
7
+ * or fall back to PATHS + cwd" path-joining logic. That worked while there
8
+ * was a single canonical layout (`tickets/_logs/`) and a single consumer
9
+ * (this monorepo), but it doesn't survive cockpit-marketplace users who:
10
+ * - don't have `tickets/_logs/` (no monorepo)
11
+ * - want a product-neutral default (`.dru-code/`)
12
+ * - need an env-var override for VS Code workspace overrides
13
+ * - might call from inside a child workspace dir and expect "find existing
14
+ * storage upward" instead of accidentally creating a parallel one
15
+ *
16
+ * `resolveStoragePaths(opts)` collapses all five priorities into one entry
17
+ * point. First hit wins:
18
+ *
19
+ * 1. opts.rootPath — explicit caller arg (highest priority; tests + library
20
+ * consumers that already know exactly where storage lives)
21
+ * 2. process.env.DRUUMEN_SESSIONS_DB_ROOT — env var override (cockpit
22
+ * Setup Wizard writes this, CI overrides it, ops can pin during incidents)
23
+ * 3. cwd-ascend (bounded) for an existing `tickets/_logs/sessions-db.json`
24
+ * — preserves the druumen monorepo experience: running any sessions-db
25
+ * command from anywhere inside the worktree finds the canonical
26
+ * tickets/_logs/ root just like the previous hand-rolled cwd-anchor did.
27
+ * 4. cwd-ascend (bounded) for an existing `.dru-code/sessions-db.json` —
28
+ * the new convention for fresh installs that have already been
29
+ * initialized once.
30
+ * 5. Default new: `<cwd>/.dru-code/` — what fresh `initProjection({})`
31
+ * lands when no existing storage is found. Cockpit marketplace's first
32
+ * install creates this dir.
33
+ *
34
+ * Layout invariant inside `<root>/`:
35
+ * - sessions-db-events.jsonl — append-only SSoT
36
+ * - sessions-db.json — projection cache
37
+ * - sessions-db.json.lock — exclusive-create lockfile
38
+ *
39
+ * The same three filenames are used for both druumen-monorepo
40
+ * (`tickets/_logs/`) and `.dru-code/` layouts so callers never need a
41
+ * layout-conditional path computation.
42
+ *
43
+ * Why an ascend bound (MAX_ASCEND_DEPTH=12)? Walking to filesystem `/` is
44
+ * slow on networked mounts and pointless — anyone keeping their workspace
45
+ * 12 directories deep is doing something unusual and should set
46
+ * `DRUUMEN_SESSIONS_DB_ROOT` explicitly. The bound caps the worst-case stat
47
+ * count at 12 × 2 (two candidate file checks per level) = 24 stats per call.
48
+ *
49
+ * Zero new runtime deps: `node:fs`, `node:path`. Same as the rest of lib/.
50
+ */
51
+
52
+ import { existsSync } from 'node:fs';
53
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
54
+
55
+ /**
56
+ * Hard cap on cwd-ascend depth. Twelve levels is generous — a typical
57
+ * worktree depth is 1-3, monorepos may go to 5-6. Pinning at 12 means the
58
+ * worst-case stat budget is 24 (two candidate paths × 12 levels) before
59
+ * we fall through to the default. Set deliberately conservative so the
60
+ * resolver never accidentally walks to `/` on a slow networked mount.
61
+ */
62
+ export const MAX_ASCEND_DEPTH = 12;
63
+
64
+ /**
65
+ * The three on-disk filenames (relative to whichever root the resolver
66
+ * picks). Frozen so callers can't accidentally mutate. Exported for tests
67
+ * + the rare library consumer that wants to know the canonical names.
68
+ */
69
+ export const STORAGE_FILENAMES = Object.freeze({
70
+ eventsJsonl: 'sessions-db-events.jsonl',
71
+ projectionJson: 'sessions-db.json',
72
+ lockFile: 'sessions-db.json.lock',
73
+ });
74
+
75
+ /**
76
+ * Resolve storage paths from caller opts + env + autodiscover.
77
+ *
78
+ * @param {{ rootPath?: string, cwd?: string }} [opts]
79
+ * @returns {{
80
+ * root: string,
81
+ * eventsJsonl: string,
82
+ * projectionJson: string,
83
+ * lockFile: string,
84
+ * source: 'arg' | 'env' | 'tickets-logs' | 'dru-code' | 'default',
85
+ * }}
86
+ */
87
+ export function resolveStoragePaths(opts = {}) {
88
+ // ----- Priority 1: explicit opts.rootPath -----
89
+ // Tests + library consumers that already pinned the location pass this.
90
+ // Resolved against process.cwd() so a relative override (`./tmp/db`) still
91
+ // produces an absolute path the rest of the library can use.
92
+ if (typeof opts.rootPath === 'string' && opts.rootPath.length > 0) {
93
+ const root = resolve(opts.rootPath);
94
+ return { root, ...buildFilePaths(root), source: 'arg' };
95
+ }
96
+
97
+ // ----- Priority 2: env var -----
98
+ // DRUUMEN_SESSIONS_DB_ROOT is the documented escape hatch for ops /
99
+ // cockpit Setup Wizard / CI matrix runs. Empty string is treated as
100
+ // "not set" so `DRUUMEN_SESSIONS_DB_ROOT=` in a half-configured env file
101
+ // doesn't silently send writes to `/sessions-db-events.jsonl`.
102
+ const envRoot = process.env.DRUUMEN_SESSIONS_DB_ROOT;
103
+ if (typeof envRoot === 'string' && envRoot.length > 0) {
104
+ const root = resolve(envRoot);
105
+ return { root, ...buildFilePaths(root), source: 'env' };
106
+ }
107
+
108
+ // ----- Priorities 3 + 4: cwd-ascend for existing storage -----
109
+ // We walk upward from opts.cwd (or process.cwd) checking for either a
110
+ // legacy druumen-monorepo `tickets/_logs/sessions-db.json` (priority 3)
111
+ // OR a new-convention `.dru-code/sessions-db.json` (priority 4). At each
112
+ // level the legacy check runs first — when both exist somehow, the
113
+ // existing-data location wins so we never silently bifurcate writes.
114
+ const startCwd = resolve(
115
+ typeof opts.cwd === 'string' && opts.cwd.length > 0 ? opts.cwd : process.cwd(),
116
+ );
117
+ const found = ascendForExistingDb(startCwd);
118
+ if (found) {
119
+ return { root: found.root, ...buildFilePaths(found.root), source: found.source };
120
+ }
121
+
122
+ // ----- Priority 5: new default `<cwd>/.dru-code/` -----
123
+ // Fresh-install case. `initProjection` will mkdir this; until it does,
124
+ // the path is virtual (just where future writes will land).
125
+ const defaultRoot = join(startCwd, '.dru-code');
126
+ return { root: defaultRoot, ...buildFilePaths(defaultRoot), source: 'default' };
127
+ }
128
+
129
+ /**
130
+ * Build absolute file paths from a root directory. Assumes `root` is already
131
+ * absolute (callers in this module always resolve before calling).
132
+ *
133
+ * @param {string} root
134
+ * @returns {{ eventsJsonl: string, projectionJson: string, lockFile: string }}
135
+ */
136
+ function buildFilePaths(root) {
137
+ return {
138
+ eventsJsonl: join(root, STORAGE_FILENAMES.eventsJsonl),
139
+ projectionJson: join(root, STORAGE_FILENAMES.projectionJson),
140
+ lockFile: join(root, STORAGE_FILENAMES.lockFile),
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Walk upward from `startCwd` looking for an existing sessions-db storage
146
+ * dir. Returns `{ root, source }` on first hit, null after MAX_ASCEND_DEPTH
147
+ * levels or when reaching the filesystem root.
148
+ *
149
+ * Order at each level:
150
+ * - tickets/_logs/sessions-db.json (druumen-monorepo legacy)
151
+ * - .dru-code/sessions-db.json (new convention)
152
+ *
153
+ * Why projection-file existence (not directory existence)? An empty
154
+ * `tickets/_logs/` or `.dru-code/` directory could legitimately predate
155
+ * sessions-db (e.g. another tool created it). Using the projection file as
156
+ * the existence signal guarantees we only adopt locations that already have
157
+ * sessions-db state — never sibling tools' storage dirs.
158
+ *
159
+ * @param {string} startCwd absolute path
160
+ * @returns {{ root: string, source: 'tickets-logs' | 'dru-code' } | null}
161
+ */
162
+ function ascendForExistingDb(startCwd) {
163
+ let cwd = startCwd;
164
+ for (let depth = 0; depth < MAX_ASCEND_DEPTH; depth++) {
165
+ // Priority 3: druumen monorepo convention
166
+ const ticketsLogsRoot = join(cwd, 'tickets', '_logs');
167
+ if (existsSync(join(ticketsLogsRoot, STORAGE_FILENAMES.projectionJson))) {
168
+ return { root: ticketsLogsRoot, source: 'tickets-logs' };
169
+ }
170
+ // Priority 4: .dru-code/ convention
171
+ const druCodeRoot = join(cwd, '.dru-code');
172
+ if (existsSync(join(druCodeRoot, STORAGE_FILENAMES.projectionJson))) {
173
+ return { root: druCodeRoot, source: 'dru-code' };
174
+ }
175
+ // Stop at filesystem root — `path.dirname('/') === '/'` on POSIX,
176
+ // and on Windows `path.dirname('C:\\') === 'C:\\'`. Either way the
177
+ // parent === self loop guard catches it.
178
+ const parent = dirname(cwd);
179
+ if (parent === cwd) break;
180
+ cwd = parent;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ /**
186
+ * Helper for callers that already have a fully-resolved root and want to
187
+ * compute file paths (tests, custom integrations). Public so consumers can
188
+ * mirror the layout invariant without importing internal helpers.
189
+ *
190
+ * @param {string} root absolute or relative; resolved against cwd if relative
191
+ * @returns {{ root: string, eventsJsonl: string, projectionJson: string, lockFile: string }}
192
+ */
193
+ export function pathsFromRoot(root) {
194
+ if (typeof root !== 'string' || root.length === 0) {
195
+ throw new TypeError('pathsFromRoot: root must be a non-empty string');
196
+ }
197
+ const abs = isAbsolute(root) ? root : resolve(root);
198
+ return { root: abs, ...buildFilePaths(abs) };
199
+ }