@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
package/lib/types.mjs ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Shared `@typedef` declarations for `@druumen/sessions-db`.
3
+ *
4
+ * This file is the single source of truth for the public type vocabulary —
5
+ * other `.mjs` files reference these typedefs by name in their JSDoc rather
6
+ * than re-declaring the same shape locally. `tsc --emitDeclarationOnly`
7
+ * lifts the typedefs from this module into `types/types.d.ts`, and the
8
+ * curated `types/index.d.ts` re-exports the public subset.
9
+ *
10
+ * Conventions:
11
+ * - Branded scalar types (SessionStableId, ClaudeSessionId, EventId, Iso8601)
12
+ * are kept as plain `string` aliases so JS callers do not need cast helpers.
13
+ * Cockpit / TS consumers get nominal-ish naming via the alias name in
14
+ * function signatures (`(stableId: SessionStableId)` reads better than
15
+ * `(stableId: string)`); the runtime check stays a string check.
16
+ * - Enums (ActivityState, Outcome, IdentitySource, IdentityConfidence,
17
+ * EventOp) are union-of-string-literals so misspellings are caught at
18
+ * type-check time.
19
+ * - The projection schema mirrors `lib/projection.mjs` `emptySession()` 1:1.
20
+ * When that function gains a field, this typedef MUST be updated in the
21
+ * same commit.
22
+ *
23
+ * This module exports nothing at runtime — it is types-only. The `export {}`
24
+ * keeps it a valid ES module so tsc/Node both treat it as a module rather
25
+ * than a script.
26
+ */
27
+
28
+ /**
29
+ * @typedef {string} SessionStableId
30
+ * Sessions-db stable identifier — `sess_<uuidv7-with-dashes>`.
31
+ * See `uuid.mjs` `generateSessionId()` / `isSessionId()`.
32
+ */
33
+
34
+ /**
35
+ * @typedef {string} ClaudeSessionId
36
+ * Claude Code's per-process session UUID (canonical 8-4-4-4-12, v4).
37
+ * Read from the SessionStart hook payload's `session_id` field.
38
+ */
39
+
40
+ /**
41
+ * @typedef {string} EventId
42
+ * events.jsonl per-row identifier — `evt_<uuidv7-with-dashes>`.
43
+ * Same generator as SessionStableId, different prefix (so visual scans of
44
+ * the jsonl tail can tell event ids from session ids).
45
+ */
46
+
47
+ /**
48
+ * @typedef {string} Iso8601
49
+ * ISO 8601 timestamp string (UTC `Z` suffix preferred, but offset forms
50
+ * are accepted on input — sweep + projection consumers parse via
51
+ * `Date.parse` not lex compare). All sessions-db writers emit `Z`.
52
+ */
53
+
54
+ /**
55
+ * Activity state machine (sweep-driven).
56
+ *
57
+ * active — fresh session OR within idle threshold (default 14d)
58
+ * idle — past idle threshold but within archive threshold (default 30d)
59
+ * archived — past archive threshold (terminal — sweep never re-promotes)
60
+ *
61
+ * @typedef {'active' | 'idle' | 'archived'} ActivityState
62
+ */
63
+
64
+ /**
65
+ * Operator-driven outcome (set by `close` op).
66
+ *
67
+ * open — default; session has not been explicitly closed
68
+ * done — work completed successfully
69
+ * blocked — paused on external dependency
70
+ * abandoned — won't continue
71
+ * merged — folded into another session (manual_link target)
72
+ * superseded — replaced by a newer session
73
+ *
74
+ * @typedef {'open' | 'done' | 'blocked' | 'abandoned' | 'merged' | 'superseded'} Outcome
75
+ */
76
+
77
+ /**
78
+ * Identity resolution source. Reflects which priority chain step assigned
79
+ * the session's stable_id during the most recent `session_seen`.
80
+ *
81
+ * claude_session_id_index — P1: exact csid hit in projection
82
+ * transcript_lineage — P2: incoming firstParentUuid matches an existing
83
+ * transcript_files[*].last_uuid (resume / fork)
84
+ * fingerprint_corroborator — P3: fingerprint match + sufficient corroborators
85
+ * minted — none of the above; fresh stable_id
86
+ *
87
+ * @typedef {'claude_session_id_index' | 'transcript_lineage' | 'fingerprint_corroborator' | 'minted'} IdentitySource
88
+ */
89
+
90
+ /**
91
+ * Confidence label co-emitted with `IdentitySource`.
92
+ *
93
+ * exact — P1 hit (csid is unique per session)
94
+ * high — P2 hit (lineage chain is structurally derived)
95
+ * low — P3 hit (fingerprint + corroborators is heuristic)
96
+ * minted — no resolution path matched (fresh id)
97
+ *
98
+ * @typedef {'exact' | 'high' | 'low' | 'minted'} IdentityConfidence
99
+ */
100
+
101
+ /**
102
+ * events.jsonl op label. Each op has its own reducer in
103
+ * `lib/projection.mjs` (`reduceSessionSeen`, `reduceSessionLink`, …).
104
+ *
105
+ * session_seen — primary observation (created + every SessionStart)
106
+ * session_link — additive: attach tasks/projects to a session
107
+ * session_unlink — set-based filter: detach tasks/projects (P5)
108
+ * alias_set — set or clear human-readable alias
109
+ * parent_set — set or clear parent_session_id
110
+ * close — set outcome + closed_at + closed_reason
111
+ * sweep — synthetic: activity_state transition (active → idle / archived)
112
+ * manual_link — operator-supplied parent_candidate_ids merge
113
+ *
114
+ * @typedef {'session_seen' | 'session_link' | 'session_unlink' | 'alias_set' | 'parent_set' | 'close' | 'sweep' | 'manual_link'} EventOp
115
+ */
116
+
117
+ /**
118
+ * One transcript file (`~/.claude/projects/<workspace-hash>/<uuid>.jsonl`)
119
+ * as captured in a session's `transcript_files[]`.
120
+ *
121
+ * `first_uuid` and `last_uuid` are the lineage anchors used by the P2
122
+ * `transcript_lineage` resolution; `status` reflects the parser outcome
123
+ * (`'ok' | 'corrupted' | 'too_large'`, see `lib/transcript.mjs`).
124
+ *
125
+ * @typedef {Object} TranscriptFile
126
+ * @property {string} path Absolute path on disk
127
+ * @property {(string|null)} first_uuid First record uuid (lineage start)
128
+ * @property {(string|null)} last_uuid Last record uuid (lineage tail)
129
+ * @property {number} size File size in bytes
130
+ * @property {Iso8601} mtime fs mtime (ISO string)
131
+ * @property {('ok' | 'corrupted' | 'too_large')} status
132
+ * Parser outcome — `corrupted` => unrecoverable, `too_large` =>
133
+ * skipped (`> maxSizeMb`)
134
+ */
135
+
136
+ /**
137
+ * Audit trail attached to each `session_seen` event payload (and mirrored
138
+ * to `KnownSession.identity_resolution` — last-write-wins).
139
+ *
140
+ * `matched` is op-specific and kept loose (`Record<string, unknown>`)
141
+ * because each `IdentitySource` populates a different shape:
142
+ * - claude_session_id_index → `{ claude_session_id }`
143
+ * - transcript_lineage → `{ first_parent_uuid, matched_transcript_path, matched_last_uuid }`
144
+ * - fingerprint_corroborator → `{ fingerprints_matched, corroborators, corroborator_count, strong_corroborator_count }`
145
+ * - minted → `{}` or `{ ambiguous: true, ambiguous_count }`
146
+ *
147
+ * @typedef {Object} IdentityResolution
148
+ * @property {IdentitySource} source
149
+ * @property {IdentityConfidence} confidence
150
+ * @property {Record<string, unknown>} matched
151
+ */
152
+
153
+ /**
154
+ * Hub-spoke parent hint surfaced when fingerprint evidence exists but does
155
+ * not meet the corroborator threshold (or when multiple candidates tie).
156
+ *
157
+ * The `reason.confidence` carried inside the candidate object is a
158
+ * categorical label (currently always `'low'` from `collectParentCandidates`).
159
+ * The numeric `confidence` field at the top of the typedef is reserved for
160
+ * future scoring (0..1) — current writers leave it as a category-derived
161
+ * string in tests, so we type it loosely.
162
+ *
163
+ * @typedef {Object} ParentCandidate
164
+ * @property {SessionStableId} candidate
165
+ * Stable id of the candidate parent session
166
+ * @property {(number|string)} confidence
167
+ * 0..1 numeric score OR category label (`'low'`); current writers
168
+ * emit the categorical form
169
+ * @property {Object} reason
170
+ * @property {string[]} reason.fingerprints_matched
171
+ * Fingerprint version names that matched (e.g.
172
+ * `['first_human_prompt_v1']`)
173
+ * @property {number} reason.corroborator_count
174
+ * Total corroborators (strong + weak)
175
+ * @property {number} reason.strong_corroborator_count
176
+ * Location-anchored corroborators (cwd / worktree_realpath)
177
+ * @property {number} reason.weak_corroborator_count
178
+ * Signal-anchored corroborators (branch / time-window)
179
+ */
180
+
181
+ /**
182
+ * Per-session record in `Projection.sessions[stable_id]`.
183
+ *
184
+ * Every field is populated lazily by the per-op reducers in
185
+ * `lib/projection.mjs`. Rules of thumb:
186
+ * - `claude_session_ids[]` and `transcript_files[]` are append+dedup
187
+ * (later observations augment, never overwrite, the lineage history).
188
+ * - `worktree_*`, `branch_current`, `head_last_seen`, `identity_resolution`,
189
+ * `parent_candidates_omitted_count` are last-write-wins (recency
190
+ * matters more than first observation).
191
+ * - `branch_at_start`, `head_at_start`, `first_prompt_preview` are
192
+ * first-write-wins (initial observation captures these and we refuse
193
+ * to overwrite to preserve history).
194
+ * - `tasks[]` and `projects[]` are set-mutated by `session_link` (add) /
195
+ * `session_unlink` (remove).
196
+ * - `activity_state` is sweep-driven; `outcome` / `closed_at` /
197
+ * `closed_reason` are operator-driven via `close`.
198
+ *
199
+ * @typedef {Object} KnownSession
200
+ * @property {SessionStableId} stable_id
201
+ * @property {(string|null)} alias
202
+ * @property {ClaudeSessionId[]} claude_session_ids
203
+ * @property {TranscriptFile[]} transcript_files
204
+ * @property {Object} fingerprints
205
+ * @property {(string|null)} fingerprints.first_human_prompt_v1
206
+ * @property {(string|null)} fingerprints.lineage_prefix_v1
207
+ * @property {(SessionStableId|null)} parent_session_id
208
+ * @property {ParentCandidate[]} parent_candidate_ids
209
+ * @property {number} parent_candidates_omitted_count
210
+ * Number of parent candidates dropped by the
211
+ * `MAX_PARENT_CANDIDATES` cap on the most recent session_seen
212
+ * @property {(IdentityResolution|null)} identity_resolution
213
+ * @property {(string|null)} worktree_path_observed
214
+ * @property {(string|null)} worktree_realpath
215
+ * @property {(string|null)} worktree_registry_name
216
+ * @property {(string|null)} git_common_dir
217
+ * @property {(string|null)} branch_at_start
218
+ * @property {(string|null)} branch_current
219
+ * @property {(string|null)} head_at_start
220
+ * @property {(string|null)} head_last_seen
221
+ * @property {string[]} tasks
222
+ * @property {string[]} projects
223
+ * @property {ActivityState} activity_state
224
+ * @property {Outcome} outcome
225
+ * @property {(Iso8601|null)} closed_at
226
+ * @property {(string|null)} closed_reason
227
+ * @property {Iso8601} created_at
228
+ * @property {Iso8601} last_progress_at
229
+ * @property {(string|null)} first_prompt_preview
230
+ */
231
+
232
+ /**
233
+ * Cache file `_meta` block.
234
+ *
235
+ * @typedef {Object} ProjectionMeta
236
+ * @property {2} schema_version
237
+ * Pinned to `2` — bump when reducer semantics change
238
+ * @property {string[]} fingerprint_versions
239
+ * Names of the fingerprint algorithms the writer emits
240
+ * (e.g. `['first_human_prompt_v1', 'lineage_prefix_v1']`)
241
+ * @property {(Iso8601|null)} updated
242
+ * Last write timestamp (saveProjection bumps to now)
243
+ * @property {number} event_count
244
+ * Total events folded into this projection
245
+ * @property {(EventId|null)} last_event_id
246
+ */
247
+
248
+ /**
249
+ * On-disk projection cache shape (`tickets/_logs/sessions-db.json`).
250
+ * Result of folding `events.jsonl` from the empty projection.
251
+ *
252
+ * @typedef {Object} Projection
253
+ * @property {ProjectionMeta} _meta
254
+ * @property {Record<SessionStableId, KnownSession>} sessions
255
+ */
256
+
257
+ /**
258
+ * One row in `events.jsonl` (the SSoT). `payload` is op-specific — each
259
+ * op's reducer reads only the fields it understands; unknown fields are
260
+ * preserved by the storage layer but ignored by the reducer.
261
+ *
262
+ * Tightening `payload` to a per-op union of payload shapes is intentionally
263
+ * deferred — current writers (CLI + hook) treat payloads as
264
+ * `Record<string, unknown>` and rely on runtime defensive reads. A future
265
+ * type-tightening pass can add `SessionSeenPayload`, `SessionLinkPayload`,
266
+ * etc. without breaking consumers.
267
+ *
268
+ * @typedef {Object} SessionEvent
269
+ * @property {Iso8601} ts
270
+ * @property {EventId} event_id
271
+ * @property {EventOp} op
272
+ * @property {SessionStableId} stable_id
273
+ * @property {Record<string, unknown>} payload
274
+ */
275
+
276
+ export {};
package/lib/uuid.mjs ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * UUIDv7 generator for sessions-db stable IDs.
3
+ *
4
+ * Spec: RFC 9562 §5.7 — UUIDv7 (Unix Epoch time-ordered).
5
+ * Layout (128 bits, big-endian):
6
+ * - bits 0..47 : 48-bit unix timestamp in milliseconds
7
+ * - bits 48..51 : 4-bit version (= 0b0111 = 7)
8
+ * - bits 52..63 : 12 bits of random "rand_a"
9
+ * - bits 64..65 : 2-bit IETF variant (= 0b10)
10
+ * - bits 66..127 : 62 bits of random "rand_b"
11
+ *
12
+ * Output format: `sess_<uuidv7-with-dashes>`
13
+ *
14
+ * Notes:
15
+ * - Node 22's `crypto.randomUUID` always emits v4; the `{version:7}` option
16
+ * is silently ignored. We implement the bit layout ourselves with
17
+ * `crypto.randomFillSync` (16 bytes, then patch the version + variant
18
+ * nibbles, then write the timestamp big-endian into the first 6 bytes).
19
+ * - To preserve monotonic ordering when multiple IDs are generated within
20
+ * the same millisecond we keep the previous timestamp around and bump
21
+ * the 12-bit `rand_a` counter when a collision is detected. Across
22
+ * millisecond boundaries `rand_a` is fully random.
23
+ */
24
+
25
+ import { randomFillSync } from 'node:crypto';
26
+
27
+ const PREFIX = 'sess_';
28
+ // Matches the canonical 8-4-4-4-12 hex shape with version nibble forced to 7
29
+ // and variant nibble in {8,9,a,b}.
30
+ const UUIDV7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
31
+ const SESSION_ID_RE = new RegExp(`^${PREFIX}${UUIDV7_RE.source.slice(1, -1)}$`);
32
+
33
+ let lastTimestampMs = -1;
34
+ let lastRandA = 0;
35
+
36
+ /**
37
+ * Generate a fresh `sess_<uuidv7>` string.
38
+ * @returns {string}
39
+ */
40
+ export function generateSessionId() {
41
+ const bytes = Buffer.alloc(16);
42
+ randomFillSync(bytes);
43
+
44
+ const nowMs = Date.now();
45
+ let timestampMs = nowMs;
46
+ let randA;
47
+
48
+ if (nowMs <= lastTimestampMs) {
49
+ // Same-or-earlier millisecond: reuse the previous timestamp and bump
50
+ // rand_a so successive IDs remain strictly monotonic.
51
+ timestampMs = lastTimestampMs;
52
+ randA = (lastRandA + 1) & 0xfff;
53
+ if (randA === 0) {
54
+ // Overflowed the 12-bit counter — advance the timestamp by 1ms so we
55
+ // do not collide with the prior ID.
56
+ timestampMs += 1;
57
+ // randA stays 0 after overflow; rand_b is still random so we keep
58
+ // collision probability negligible.
59
+ }
60
+ } else {
61
+ // Use 12 random bits from the buffer for rand_a.
62
+ randA = ((bytes[6] & 0x0f) << 8) | bytes[7];
63
+ }
64
+
65
+ // Write the 48-bit timestamp big-endian into bytes[0..5].
66
+ // (Buffer.writeUIntBE supports up to 6 bytes, exactly 48 bits.)
67
+ bytes.writeUIntBE(timestampMs, 0, 6);
68
+
69
+ // bytes[6]: top nibble = version (0x70), bottom nibble = high 4 bits of randA.
70
+ bytes[6] = 0x70 | ((randA >>> 8) & 0x0f);
71
+ // bytes[7]: low 8 bits of randA.
72
+ bytes[7] = randA & 0xff;
73
+ // bytes[8]: top 2 bits = variant 0b10, remaining 6 bits stay random.
74
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
75
+
76
+ lastTimestampMs = timestampMs;
77
+ lastRandA = randA;
78
+
79
+ const hex = bytes.toString('hex');
80
+ const uuid =
81
+ hex.slice(0, 8) +
82
+ '-' +
83
+ hex.slice(8, 12) +
84
+ '-' +
85
+ hex.slice(12, 16) +
86
+ '-' +
87
+ hex.slice(16, 20) +
88
+ '-' +
89
+ hex.slice(20, 32);
90
+
91
+ return PREFIX + uuid;
92
+ }
93
+
94
+ /**
95
+ * Validate a sessions-db session id.
96
+ * @param {unknown} s
97
+ * @returns {boolean}
98
+ */
99
+ export function isSessionId(s) {
100
+ return typeof s === 'string' && SESSION_ID_RE.test(s);
101
+ }
102
+
103
+ /**
104
+ * Extract the embedded unix-ms timestamp from a UUIDv7 session id.
105
+ * @param {string} sessionId
106
+ * @returns {number} unix ms
107
+ */
108
+ export function extractTimestamp(sessionId) {
109
+ if (!isSessionId(sessionId)) {
110
+ throw new TypeError(`extractTimestamp: not a sessions-db id: ${sessionId}`);
111
+ }
112
+ // Strip prefix, then strip dashes from the first three groups (12 hex chars
113
+ // = 48 bits) and parseInt as base-16. Number is safe since 48 bits < 53.
114
+ const hex = sessionId.slice(PREFIX.length).replace(/-/g, '').slice(0, 12);
115
+ return Number.parseInt(hex, 16);
116
+ }
package/lib/watch.mjs ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Projection-file watcher for sessions-db.
3
+ *
4
+ * `watchProjection(rootPath, listener)` invokes `listener` whenever the
5
+ * projection cache (`tickets/_logs/sessions-db.json` by default) changes.
6
+ * Returns a Disposable (`{ dispose }`) so callers can unsubscribe — used
7
+ * by cockpit to feed the live UI without re-polling the on-disk JSON.
8
+ *
9
+ * Two redundant signals are wired up:
10
+ *
11
+ * 1. **fs.watch** — the OS-level inotify / FSEvents subscription. Fast
12
+ * (sub-100ms latency on typical hardware) but unreliable under some
13
+ * conditions:
14
+ * - editors that "atomic save" (write to tmp + rename) deliver a
15
+ * `rename` event but the watcher dies on the inode swap on Linux
16
+ * - networked filesystems may drop events
17
+ * - macOS FSEvents coalesces aggressively under load
18
+ *
19
+ * 2. **1s polling fallback** — `setInterval` reads `mtimeMs` and fires
20
+ * the listener if it changed since the last poll. Bounded latency
21
+ * regardless of watcher health. Cheap (a single stat per second).
22
+ *
23
+ * Both paths funnel through a single **80ms debounce** window so a flurry
24
+ * of events (sweep applying multiple transitions, or fs.watch + poll both
25
+ * detecting the same write) collapses into one listener call. The window
26
+ * is internal — callers do not need to debounce themselves.
27
+ *
28
+ * Why 80ms? Long enough to coalesce a write-then-rename (atomic save) and
29
+ * a fs.watch+poll race (typically < 50ms apart), short enough that the UI
30
+ * still feels live. Tunable via `opts.debounceMs` for tests.
31
+ *
32
+ * Listener contract:
33
+ * - Called as `listener({ type, path })` where `type` is one of
34
+ * `'change' | 'rename' | 'poll'` (whichever signal fired) and `path`
35
+ * is the absolute path to projection.json.
36
+ * - Synchronous; if your handler is async, swallow promise rejections
37
+ * yourself — the watcher does NOT await.
38
+ * - Errors thrown by the listener are caught and silently ignored so a
39
+ * buggy consumer doesn't crash the watcher loop.
40
+ */
41
+
42
+ import { existsSync, statSync, watch } from 'node:fs';
43
+ import { isAbsolute, resolve } from 'node:path';
44
+
45
+ import { resolveStoragePaths } from './paths.mjs';
46
+ import { PATHS } from './storage.mjs';
47
+
48
+ const DEFAULT_POLL_INTERVAL_MS = 1000;
49
+ const DEFAULT_DEBOUNCE_MS = 80;
50
+
51
+ /**
52
+ * Watch the projection file at `rootPath` and invoke `listener` on change.
53
+ *
54
+ * Path resolution (Day 4 — symmetric with storage.mjs):
55
+ *
56
+ * - If `opts.paths.projectionJson` is provided, that's the file watched
57
+ * (anchored on `rootPath` if relative). Backward-compat with Day 1-3.
58
+ *
59
+ * - Otherwise `rootPath` is treated as a Day 4 storage root and we
60
+ * delegate to `resolveStoragePaths({ rootPath })` so the canonical
61
+ * filename + cross-platform path normalization apply uniformly.
62
+ *
63
+ * @param {string} rootPath
64
+ * @param {(event: { type: 'change' | 'rename' | 'poll', path: string }) => void} listener
65
+ * @param {{
66
+ * paths?: { projectionJson?: string },
67
+ * pollIntervalMs?: number,
68
+ * debounceMs?: number,
69
+ * }} [opts]
70
+ * @returns {{ dispose: () => void }}
71
+ */
72
+ export function watchProjection(rootPath, listener, opts = {}) {
73
+ if (typeof rootPath !== 'string' || rootPath.length === 0) {
74
+ throw new TypeError('watchProjection: rootPath required');
75
+ }
76
+ if (typeof listener !== 'function') {
77
+ throw new TypeError('watchProjection: listener function required');
78
+ }
79
+
80
+ let projectionPath;
81
+ if (opts.paths && opts.paths.projectionJson) {
82
+ // Legacy form: explicit override anchored at rootPath. Preserves the
83
+ // pre-Day-4 layout (rootPath = monorepo root, paths embed
84
+ // `tickets/_logs/`).
85
+ const projectionRel = opts.paths.projectionJson;
86
+ projectionPath = isAbsolute(projectionRel)
87
+ ? projectionRel
88
+ : resolve(rootPath, projectionRel);
89
+ } else {
90
+ // Day 4 form: rootPath IS the storage dir. Delegate so canonical
91
+ // filenames + path-normalization stay in lib/paths.mjs.
92
+ const r = resolveStoragePaths({ rootPath });
93
+ projectionPath = r.projectionJson;
94
+ }
95
+
96
+ const pollIntervalMs = typeof opts.pollIntervalMs === 'number' && opts.pollIntervalMs > 0
97
+ ? opts.pollIntervalMs
98
+ : DEFAULT_POLL_INTERVAL_MS;
99
+ const debounceMs = typeof opts.debounceMs === 'number' && opts.debounceMs >= 0
100
+ ? opts.debounceMs
101
+ : DEFAULT_DEBOUNCE_MS;
102
+
103
+ // Debounce coalesces multiple events within `debounceMs` into one listener
104
+ // call. We capture the LAST fired event's `type` so the consumer sees the
105
+ // most recent signal source ("rename" wins over earlier "change" within the
106
+ // same window — useful for atomic-save detection).
107
+ let pendingTimer = null;
108
+ let pendingType = null;
109
+ const fireSoon = (type) => {
110
+ pendingType = type;
111
+ if (pendingTimer !== null) return;
112
+ pendingTimer = setTimeout(() => {
113
+ const t = pendingType;
114
+ pendingTimer = null;
115
+ pendingType = null;
116
+ try {
117
+ listener({ type: t, path: projectionPath });
118
+ } catch {
119
+ // Swallow listener errors — a buggy consumer must not kill the
120
+ // watcher. If they need observability, they should add their own
121
+ // try/catch.
122
+ }
123
+ }, debounceMs);
124
+ };
125
+
126
+ // -------------------------------------------------------------------------
127
+ // fs.watch — primary signal. May fail to attach (file doesn't exist yet)
128
+ // or die mid-way (atomic-save inode swap). Both are tolerated; the poll
129
+ // fallback covers gaps.
130
+ // -------------------------------------------------------------------------
131
+ let fsWatcher = null;
132
+ const tryAttachWatcher = () => {
133
+ if (!existsSync(projectionPath)) return;
134
+ if (fsWatcher) return;
135
+ try {
136
+ fsWatcher = watch(projectionPath, { persistent: false }, (eventType) => {
137
+ // eventType is 'change' | 'rename'. Atomic save fires 'rename'
138
+ // and may then invalidate this watcher; the next poll will
139
+ // detect it and re-attach.
140
+ if (eventType === 'rename') {
141
+ // Tear down the watcher — the underlying inode is gone.
142
+ try { fsWatcher && fsWatcher.close(); } catch { /* noop */ }
143
+ fsWatcher = null;
144
+ }
145
+ fireSoon(eventType === 'rename' ? 'rename' : 'change');
146
+ });
147
+ fsWatcher.on('error', () => {
148
+ // Swallow watcher errors and rely on poll fallback. The watcher
149
+ // will be re-attached on the next poll once the file is back.
150
+ try { fsWatcher && fsWatcher.close(); } catch { /* noop */ }
151
+ fsWatcher = null;
152
+ });
153
+ } catch {
154
+ fsWatcher = null;
155
+ }
156
+ };
157
+ tryAttachWatcher();
158
+
159
+ // -------------------------------------------------------------------------
160
+ // Polling — runs every `pollIntervalMs` regardless of watcher health.
161
+ // Tracks the last seen `mtimeMs` and fires the listener on change. Also
162
+ // re-attaches fs.watch if it died (atomic save dropped the inode).
163
+ // -------------------------------------------------------------------------
164
+ let lastMtimeMs = readMtimeSafe(projectionPath);
165
+ let pollTimer = setInterval(() => {
166
+ // Re-attach watcher first so any subsequent fs.watch deliveries can
167
+ // pre-empt this poll's debounce window cleanly.
168
+ if (!fsWatcher) tryAttachWatcher();
169
+
170
+ const current = readMtimeSafe(projectionPath);
171
+ if (current === null) {
172
+ // File doesn't exist (yet). Reset baseline so the first appearance
173
+ // counts as a change.
174
+ if (lastMtimeMs !== null) lastMtimeMs = null;
175
+ return;
176
+ }
177
+ if (lastMtimeMs === null || current !== lastMtimeMs) {
178
+ lastMtimeMs = current;
179
+ fireSoon('poll');
180
+ }
181
+ }, pollIntervalMs);
182
+ // Don't keep the event loop alive on the watcher's behalf — the consumer
183
+ // owns liveness via the Disposable.
184
+ if (typeof pollTimer.unref === 'function') pollTimer.unref();
185
+
186
+ return {
187
+ dispose() {
188
+ if (pendingTimer !== null) {
189
+ clearTimeout(pendingTimer);
190
+ pendingTimer = null;
191
+ pendingType = null;
192
+ }
193
+ if (pollTimer !== null) {
194
+ clearInterval(pollTimer);
195
+ pollTimer = null;
196
+ }
197
+ if (fsWatcher) {
198
+ try { fsWatcher.close(); } catch { /* noop */ }
199
+ fsWatcher = null;
200
+ }
201
+ },
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Read the file's `mtimeMs` and return it, or null if the file is missing
207
+ * or unreadable. Never throws — this is the polling-loop hot path and any
208
+ * error must degrade to "no change" rather than crashing the timer.
209
+ */
210
+ function readMtimeSafe(path) {
211
+ try {
212
+ if (!existsSync(path)) return null;
213
+ return statSync(path).mtimeMs;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@druumen/sessions-db",
3
+ "version": "0.1.0",
4
+ "description": "Cross-session traceability for Claude Code — events.jsonl SSoT + JSON projection cache + 3-priority identity reconciliation",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./lib/index.mjs",
8
+ "types": "./types/index.d.ts",
9
+ "bin": {
10
+ "sessions-db": "./cli/sessions-db.mjs",
11
+ "sessions-db-session-start": "./cli/sessions-db-session-start.mjs"
12
+ },
13
+ "exports": {
14
+ ".": "./lib/index.mjs",
15
+ "./cli": "./cli/sessions-db.mjs",
16
+ "./hook": "./cli/sessions-db-session-start.mjs"
17
+ },
18
+ "files": [
19
+ "lib/",
20
+ "cli/",
21
+ "types/",
22
+ "LICENSE",
23
+ "NOTICE",
24
+ "README.md",
25
+ "CHANGELOG.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+ssh://git@gitlab.tinfant.org:8922/druumen/sessions-db.git"
33
+ },
34
+ "bugs": {
35
+ "email": "security@druumen.com"
36
+ },
37
+ "homepage": "https://github.com/druumen/sessions-db#readme",
38
+ "keywords": [
39
+ "claude-code",
40
+ "session-tracking",
41
+ "cli",
42
+ "developer-tools"
43
+ ],
44
+ "scripts": {
45
+ "test": "find __tests__ -name '*.test.mjs' -type f -print0 | xargs -0 node --test",
46
+ "build:types": "tsc -p tsconfig.json",
47
+ "check:types-smoke": "tsc --noEmit -p __tests__/types-smoke/tsconfig.json",
48
+ "prepublishOnly": "npm run build:types"
49
+ },
50
+ "devDependencies": {
51
+ "typescript": "^5.9.3"
52
+ }
53
+ }