storymapper 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 (61) hide show
  1. package/ARCHITECTURE.md +334 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +239 -0
  5. package/frontend/css/storymap.css +4637 -0
  6. package/frontend/js/adapters.js +423 -0
  7. package/frontend/js/card-animate.js +384 -0
  8. package/frontend/js/core/graph.js +825 -0
  9. package/frontend/js/core.js +3908 -0
  10. package/frontend/js/dialog-ticket-import.js +506 -0
  11. package/frontend/js/dnd.js +322 -0
  12. package/frontend/js/filter.js +215 -0
  13. package/frontend/js/main.js +2499 -0
  14. package/frontend/js/project-io.js +109 -0
  15. package/frontend/js/query-autocomplete.js +196 -0
  16. package/frontend/js/query-engine.js +339 -0
  17. package/frontend/js/query.js +280 -0
  18. package/frontend/js/renderer-card.js +639 -0
  19. package/frontend/js/renderer-dependencies.js +974 -0
  20. package/frontend/js/renderer-kanban.js +505 -0
  21. package/frontend/js/renderer-storymap.js +2530 -0
  22. package/frontend/js/renderer-ticket-editor.js +455 -0
  23. package/frontend/js/renderer-ticket-modal.js +758 -0
  24. package/frontend/js/save-pipeline.js +170 -0
  25. package/frontend/js/smartbar-autocomplete.js +162 -0
  26. package/frontend/js/store.js +197 -0
  27. package/frontend/js/ticket-editor-boot.js +24 -0
  28. package/frontend/js/ticket-form.js +2095 -0
  29. package/frontend/js/ticket-import.js +477 -0
  30. package/frontend/js/ui-shell.js +441 -0
  31. package/frontend/js/view-process-steps.js +233 -0
  32. package/frontend/js/view-requirements.js +361 -0
  33. package/frontend/js/view-settings.js +1864 -0
  34. package/frontend/js/view-table.js +659 -0
  35. package/frontend/js/wheel-pan.js +65 -0
  36. package/frontend/storymap.html +87 -0
  37. package/frontend/ticket-editor.html +29 -0
  38. package/package.json +76 -0
  39. package/server/bus.js +16 -0
  40. package/server/core/graph.js +10 -0
  41. package/server/core.js +10 -0
  42. package/server/identity.js +134 -0
  43. package/server/index.js +283 -0
  44. package/server/ingest.js +212 -0
  45. package/server/mcp.js +2510 -0
  46. package/server/server.js +1599 -0
  47. package/server/slice.js +103 -0
  48. package/server/storage.js +571 -0
  49. package/server/validation.js +225 -0
  50. package/shared/core/graph.js +825 -0
  51. package/shared/core.js +3908 -0
  52. package/shared/project-io.js +109 -0
  53. package/shared/query-autocomplete.js +196 -0
  54. package/shared/query-engine.js +339 -0
  55. package/shared/query.js +280 -0
  56. package/shared/ticket-import.js +477 -0
  57. package/skill/SKILL.md +458 -0
  58. package/skill/reference/anatomy.md +196 -0
  59. package/skill/reference/spec-evolution.md +52 -0
  60. package/skill/reference/tools.md +156 -0
  61. package/skill/reference/workflows.md +203 -0
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+
3
+ // SM-200 R-4: algorithmic pre-slicing. Split a Markdown document at ATX
4
+ // headings (`#`..`######`) into an ordered list of candidate sections, each
5
+ // tagged with a DOORS-style `sectionPath` derived from the heading hierarchy
6
+ // (the ReqIF Specification tree). Pure, zero-dependency, deterministic.
7
+ //
8
+ // This runs BEFORE the LLM: it guarantees that every section of the source is
9
+ // presented for requirement extraction (less omission), and the per-section
10
+ // char ranges (charStart/charEnd) anchor the reconciliation UI (R-7) back to
11
+ // the source text. The agent (R-5/R-9) turns each section's body into atomic
12
+ // requirement slices; this module does NO natural-language work — only
13
+ // structural splitting.
14
+
15
+ // ATX heading: 1–6 leading '#', a space, the text, optional trailing '#'s.
16
+ const HEADING_RE = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/;
17
+
18
+ // A fenced-code-block opener/closer: ``` or ~~~ (optionally indented ≤3).
19
+ const FENCE_RE = /^ {0,3}(```|~~~)/;
20
+
21
+ // Find heading lines with level, text, and character offset into `md`. Skips
22
+ // ATX-looking lines INSIDE fenced code blocks (a real concern for raw .md
23
+ // PRDs). Assumes `md` already has LF-only line endings.
24
+ function _findHeadings(md) {
25
+ const heads = [];
26
+ let offset = 0;
27
+ let inFence = false;
28
+ const lines = md.split("\n");
29
+ for (let i = 0; i < lines.length; i++) {
30
+ const line = lines[i];
31
+ if (FENCE_RE.test(line)) { inFence = !inFence; offset += line.length + 1; continue; }
32
+ if (!inFence) {
33
+ const m = line.match(HEADING_RE);
34
+ if (m) heads.push({ level: m[1].length, text: m[2].trim(), start: offset, lineLen: line.length });
35
+ }
36
+ offset += line.length + 1; // +1 for the consumed "\n"
37
+ }
38
+ return heads;
39
+ }
40
+
41
+ /**
42
+ * Slice Markdown into ordered candidate sections.
43
+ *
44
+ * @param {string} md
45
+ * @returns {Array<{sectionPath, level, heading, body, charStart, charEnd}>}
46
+ * - sectionPath: DOORS-style dotted id ("1", "1.1", "2"), numbered by NESTING
47
+ * DEPTH (level-stack) so a doc that starts at `##` still yields "1" and
48
+ * skipped levels (# → ###) still get a UNIQUE path; never collides.
49
+ * - level: the actual ATX heading level (1..6); 0 for a pre-heading preamble
50
+ * or a heading-less document.
51
+ * - heading: the heading text (without '#'); "" for preamble / no headings.
52
+ * - body: the section's text AFTER its heading line, up to the next heading,
53
+ * trimmed.
54
+ * - charStart/charEnd: offsets into `md` covering the whole section
55
+ * (heading line included), for source highlighting.
56
+ */
57
+ function sliceMarkdown(md) {
58
+ if (typeof md !== "string" || md.trim() === "") return [];
59
+ // Normalize line endings up front so heading detection + char offsets are
60
+ // consistent: a CRLF (Windows/Word-exported) PRD would otherwise leave a
61
+ // stray "\r" that defeats HEADING_RE and collapses the whole doc.
62
+ md = md.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
63
+ const heads = _findHeadings(md);
64
+
65
+ // No headings → the whole document is one addressable section.
66
+ if (heads.length === 0) {
67
+ return [{ sectionPath: "1", level: 0, heading: "", body: md.trim(), charStart: 0, charEnd: md.length }];
68
+ }
69
+
70
+ const sections = [];
71
+
72
+ // Preamble: non-whitespace content before the first heading.
73
+ const firstStart = heads[0].start;
74
+ if (md.slice(0, firstStart).trim() !== "") {
75
+ sections.push({ sectionPath: "0", level: 0, heading: "",
76
+ body: md.slice(0, firstStart).trim(), charStart: 0, charEnd: firstStart });
77
+ }
78
+
79
+ // Number sections by NESTING DEPTH using a level-stack, not by absolute
80
+ // heading level. This guarantees a UNIQUE monotonic sectionPath even when
81
+ // intermediate levels are skipped (# directly to ###, then back to ##):
82
+ // each heading is the next child of its nearest shallower ancestor. `level`
83
+ // still carries the true ATX depth for callers that need it.
84
+ const stack = []; // ancestry: entries { level }
85
+ const counters = []; // counters[depth] = current sibling number at that depth
86
+ for (let i = 0; i < heads.length; i++) {
87
+ const h = heads[i];
88
+ while (stack.length && stack[stack.length - 1].level >= h.level) stack.pop();
89
+ const depth = stack.length;
90
+ counters[depth] = (counters[depth] || 0) + 1;
91
+ counters.length = depth + 1; // reset any deeper counters
92
+ stack.push({ level: h.level });
93
+ const sectionPath = counters.slice(0, depth + 1).join(".");
94
+ const charStart = h.start;
95
+ const charEnd = (i + 1 < heads.length) ? heads[i + 1].start : md.length;
96
+ const bodyStart = h.start + h.lineLen + 1; // skip the heading line + its "\n"
97
+ const body = (bodyStart <= charEnd ? md.slice(bodyStart, charEnd) : "").trim();
98
+ sections.push({ sectionPath, level: h.level, heading: h.text, body, charStart, charEnd });
99
+ }
100
+ return sections;
101
+ }
102
+
103
+ module.exports = { sliceMarkdown, HEADING_RE };
@@ -0,0 +1,571 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * SQLite-backed storage for storymap projects.
5
+ *
6
+ * The snapshot is the canonical data form: a single JSON blob per project,
7
+ * stored in the `projects` table. Every save appends a row to `revisions`
8
+ * with the full snapshot + actor + op tag, monotonically-sortable revision
9
+ * IDs (UTC timestamp + collision counter).
10
+ *
11
+ * Per-project mutex ensures concurrent saves serialise → unique revision
12
+ * IDs and predictable revision history.
13
+ *
14
+ * Storage API (async):
15
+ * init()
16
+ * listProjects() → string[]
17
+ * loadProject(projectId) → snapshot | null
18
+ * saveProject(projectId, snapshot, { actor, op }) → { revision, savedAt, snapshot }
19
+ * deleteProject(projectId, { actor }) → void
20
+ * listRevisions(projectId, { limit?, beforeRevision? }) → [{ revision, savedAt, op, actor }]
21
+ * (SM-212: SQL LIMIT, default 100, newest first; beforeRevision pages older)
22
+ * getRevision(projectId, revision) → { revision, savedAt, snapshot }
23
+ * restoreRevision(projectId, revision, { actor }) → { revision, savedAt, snapshot }
24
+ * remove(projectId) → void (hard delete + cascade)
25
+ * close()
26
+ */
27
+
28
+ const fs = require("fs");
29
+ const path = require("path");
30
+ const Database = require("better-sqlite3");
31
+ const bus = require("./bus.js");
32
+ const core = require("./core.js");
33
+ const identity = require("./identity.js");
34
+
35
+ const PROJECT_ID_RE = /^[a-zA-Z0-9_-]{1,100}$/;
36
+
37
+ function validProjectId(id) {
38
+ return typeof id === "string" && PROJECT_ID_RE.test(id);
39
+ }
40
+
41
+ // --- Revision retention (SM-212) --------------------------------------------
42
+ // One full snapshot per (debounced) save grows the revisions table without
43
+ // bound. On every Nth save — inside the SAME write transaction — revisions
44
+ // beyond the newest KEEP_MIN_REVISIONS_PER_PROJECT window are deleted.
45
+ // Retention is purely COUNT-based (no age component): with many iterations per
46
+ // day an age guard never fires, so a hard per-project cap is what keeps the DB
47
+ // bounded (an unbounded history grew a single DB to ~1.8 GB before this cap).
48
+ // The History UI + restore keep working for any realistic look-back.
49
+ // Tests may override per-instance via `new Storage(dir, { retention: {...} })`.
50
+ const RETENTION = {
51
+ KEEP_MIN_REVISIONS_PER_PROJECT: 150, // newest N per project survive; older are pruned
52
+ PRUNE_EVERY_N_SAVES: 50, // prune cadence per project
53
+ LIST_DEFAULT_LIMIT: 100, // listRevisions default page
54
+ LIST_MAX_LIMIT: 1000 // listRevisions hard cap
55
+ };
56
+
57
+ // --- Attachments (SM-183) ---------------------------------------------------
58
+ // Binary reference docs live on disk NEXT TO the DB (never as SQLite blobs);
59
+ // the `attachments` row carries metadata + the relative path.
60
+ const ATTACHMENT_LIMITS = {
61
+ MAX_BYTES: 25 * 1024 * 1024 // 25 MB hard cap per file
62
+ };
63
+ function safeAttachmentName(name) {
64
+ const base = String(name || "file")
65
+ .replace(/[\\/]/g, "_") // no path separators
66
+ .replace(/[^\w.\- ]+/g, "_") // collapse anything exotic
67
+ .trim();
68
+ return base.length ? base.slice(0, 200) : "file";
69
+ }
70
+ function _safeParseJson(s) { try { return JSON.parse(s); } catch (_) { return null; } }
71
+ function attachmentRowToMeta(r) {
72
+ return {
73
+ id: r.id, projectId: r.project_id, ticketId: r.ticket_id || null,
74
+ filename: r.filename, mimeType: r.mime_type, size: r.size,
75
+ uploadedAt: r.uploaded_at,
76
+ uploadedBy: r.uploaded_by ? _safeParseJson(r.uploaded_by) : null
77
+ };
78
+ }
79
+
80
+ // --- Revision IDs -----------------------------------------------------------
81
+
82
+ function pad(n, w) {
83
+ const s = String(n);
84
+ return s.length >= w ? s : "0".repeat(w - s.length) + s;
85
+ }
86
+
87
+ function utcStamp(d) {
88
+ return (
89
+ pad(d.getUTCFullYear(), 4) +
90
+ pad(d.getUTCMonth() + 1, 2) +
91
+ pad(d.getUTCDate(), 2) + "-" +
92
+ pad(d.getUTCHours(), 2) +
93
+ pad(d.getUTCMinutes(), 2) +
94
+ pad(d.getUTCSeconds(), 2) + "-" +
95
+ pad(d.getUTCMilliseconds(), 3)
96
+ );
97
+ }
98
+
99
+ // Max re-mint attempts when a revision id collides on the (project_id,
100
+ // revision) PRIMARY KEY. Collisions only happen across PROCESSES (two
101
+ // RevisionMinters producing the same id in the same millisecond); the minter's
102
+ // collision counter makes each retry strictly different, so a handful suffices.
103
+ const WRITE_MAX_RETRIES = 5;
104
+
105
+ function isRevisionCollision(e) {
106
+ return !!e && (
107
+ e.code === "SQLITE_CONSTRAINT_PRIMARYKEY" ||
108
+ (typeof e.message === "string" && /UNIQUE constraint failed: revisions/.test(e.message))
109
+ );
110
+ }
111
+
112
+ class RevisionMinter {
113
+ constructor() {
114
+ this._last = "";
115
+ this._collisionCounter = 0;
116
+ }
117
+ next() {
118
+ const stamp = utcStamp(new Date());
119
+ // Monotonic high-water mark: if the clock did not advance (same ms) OR
120
+ // moved backward (NTP correction, VM resume), keep `_last` and disambiguate
121
+ // with a counter so revision ids stay strictly increasing and never collide
122
+ // on the (project_id, revision) PRIMARY KEY. (SM-149)
123
+ if (stamp <= this._last) {
124
+ this._collisionCounter++;
125
+ return this._last + "-" + pad(this._collisionCounter, 4);
126
+ }
127
+ this._last = stamp;
128
+ this._collisionCounter = 0;
129
+ return stamp;
130
+ }
131
+ }
132
+
133
+ // --- Per-key mutex (in-memory) ---------------------------------------------
134
+
135
+ class KeyedMutex {
136
+ constructor() { this._chains = new Map(); }
137
+ run(key, fn) {
138
+ const prev = this._chains.get(key) || Promise.resolve();
139
+ const next = prev.then(fn, fn); // run fn even after a prior rejection
140
+ // Replace chain; clean up when done so the map doesn't grow unbounded.
141
+ this._chains.set(key, next);
142
+ const cleanup = () => {
143
+ if (this._chains.get(key) === next) this._chains.delete(key);
144
+ };
145
+ next.then(cleanup, cleanup);
146
+ return next;
147
+ }
148
+ }
149
+
150
+ // --- Storage ---------------------------------------------------------------
151
+
152
+ class Storage {
153
+ constructor(dataDir, opts) {
154
+ if (!dataDir) throw new Error("Storage: dataDir is required");
155
+ this._dataDir = dataDir;
156
+ this._db = null;
157
+ this._minter = new RevisionMinter();
158
+ this._mutex = new KeyedMutex();
159
+ // SM-212: per-instance retention override (tests); defaults from RETENTION.
160
+ this._retention = Object.assign({}, RETENTION, (opts && opts.retention) || {});
161
+ this._saveCounts = new Map(); // projectId → saves since last prune check
162
+ }
163
+
164
+ async init() {
165
+ fs.mkdirSync(this._dataDir, { recursive: true });
166
+ const dbPath = path.join(this._dataDir, "storymap.sqlite");
167
+ this._db = new Database(dbPath);
168
+ this._db.pragma("journal_mode = WAL");
169
+ this._db.pragma("foreign_keys = ON");
170
+ // A second process may share this data-dir (the MCP server alongside the
171
+ // HTTP server, per the request_switch_project workflow). WAL permits only
172
+ // one writer at a time; without busy_timeout the second writer throws
173
+ // SQLITE_BUSY immediately. Wait up to 5s for the lock instead of failing.
174
+ this._db.pragma("busy_timeout = 5000");
175
+ this._initSchema();
176
+ }
177
+
178
+ _initSchema() {
179
+ const sql = `
180
+ CREATE TABLE IF NOT EXISTS projects (
181
+ id TEXT PRIMARY KEY,
182
+ snapshot TEXT NOT NULL,
183
+ saved_at INTEGER NOT NULL,
184
+ revision TEXT NOT NULL,
185
+ is_deleted INTEGER NOT NULL DEFAULT 0,
186
+ deleted_at INTEGER,
187
+ deleted_by TEXT
188
+ );
189
+ CREATE TABLE IF NOT EXISTS revisions (
190
+ project_id TEXT NOT NULL,
191
+ revision TEXT NOT NULL,
192
+ snapshot TEXT NOT NULL,
193
+ saved_at INTEGER NOT NULL,
194
+ op TEXT,
195
+ actor TEXT,
196
+ PRIMARY KEY (project_id, revision),
197
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
198
+ );
199
+ CREATE INDEX IF NOT EXISTS idx_revisions_project_saved_at
200
+ ON revisions (project_id, saved_at DESC);
201
+ CREATE TABLE IF NOT EXISTS attachments (
202
+ id TEXT PRIMARY KEY,
203
+ project_id TEXT NOT NULL,
204
+ ticket_id TEXT,
205
+ filename TEXT NOT NULL,
206
+ mime_type TEXT NOT NULL,
207
+ size INTEGER NOT NULL,
208
+ rel_path TEXT NOT NULL,
209
+ uploaded_at INTEGER NOT NULL,
210
+ uploaded_by TEXT,
211
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
212
+ );
213
+ CREATE INDEX IF NOT EXISTS idx_attachments_project ON attachments (project_id);
214
+ CREATE INDEX IF NOT EXISTS idx_attachments_ticket ON attachments (project_id, ticket_id);
215
+ `;
216
+ this._db.exec(sql);
217
+ }
218
+
219
+ close() {
220
+ if (this._db) {
221
+ try { this._db.close(); } catch (_) { /* ignore */ }
222
+ this._db = null;
223
+ }
224
+ }
225
+
226
+ // ---- Public API --------------------------------------------------------
227
+
228
+ async listProjects() {
229
+ const rows = this._db.prepare(
230
+ "SELECT id FROM projects WHERE is_deleted = 0 ORDER BY id"
231
+ ).all();
232
+ return rows.map(r => r.id);
233
+ }
234
+
235
+ async loadProject(projectId) {
236
+ if (!validProjectId(projectId)) return null;
237
+ const row = this._db.prepare(
238
+ "SELECT snapshot FROM projects WHERE id = ? AND is_deleted = 0"
239
+ ).get(projectId);
240
+ if (!row) return null;
241
+ return JSON.parse(row.snapshot);
242
+ }
243
+
244
+ async saveProject(projectId, snapshot, opts) {
245
+ if (!validProjectId(projectId)) {
246
+ throw new Error("invalid project id: " + JSON.stringify(projectId));
247
+ }
248
+ opts = opts || {};
249
+ const op = typeof opts.op === "string" ? opts.op : "save";
250
+ const actor = core.normalizeActor(opts.actor);
251
+ const originId = typeof opts.originId === "string" ? opts.originId : null;
252
+ return this._mutex.run(projectId,
253
+ () => this._writeLocked(projectId, snapshot, op, actor, originId));
254
+ }
255
+
256
+ /**
257
+ * SM-149 — atomic read-modify-write. Loads the current snapshot, runs
258
+ * `mutateFn(current) → next`, and writes it, ALL inside one mutex acquisition
259
+ * for the project. Eliminates the lost-update window that a separate
260
+ * loadProject()→saveProject() pair has when two writers interleave in the
261
+ * same process. `mutateFn` may be async and may throw (statusCode preserved).
262
+ */
263
+ async mutate(projectId, opts, mutateFn) {
264
+ if (!validProjectId(projectId)) throw new Error("invalid project id");
265
+ opts = opts || {};
266
+ const op = typeof opts.op === "string" ? opts.op : "save";
267
+ const actor = core.normalizeActor(opts.actor);
268
+ const originId = typeof opts.originId === "string" ? opts.originId : null;
269
+ return this._mutex.run(projectId, async () => {
270
+ const row = this._db.prepare(
271
+ "SELECT snapshot FROM projects WHERE id = ? AND is_deleted = 0"
272
+ ).get(projectId);
273
+ const current = row ? JSON.parse(row.snapshot) : null;
274
+ if (!current) {
275
+ throw Object.assign(new Error("project not found: " + projectId), { statusCode: 404 });
276
+ }
277
+ const next = await mutateFn(current);
278
+ return this._writeLocked(projectId, next, op, actor, originId);
279
+ });
280
+ }
281
+
282
+ // Unsynchronized write — callers MUST already hold the per-project mutex
283
+ // (saveProject + mutate wrap this). Never call directly.
284
+ _writeLocked(projectId, snapshot, op, actor, originId) {
285
+ const normalized = core.normalizeSnapshot(snapshot);
286
+ // Force project.id to match the storage key.
287
+ normalized.project.id = projectId;
288
+ // SM-129: single write-authorization choke-point. No-op by default;
289
+ // RBAC plugs in via identity.setAuthorizePolicy. A denial throws
290
+ // { statusCode: 403 } and aborts the save before any DB write.
291
+ identity.authorize(actor, op, { projectId, snapshot: normalized });
292
+ const savedAt = Date.now();
293
+ const json = JSON.stringify(normalized);
294
+ const actorJson = JSON.stringify(actor);
295
+
296
+ const writeOnce = (revision) => this._db.transaction(() => {
297
+ const upsert = this._db.prepare(`
298
+ INSERT INTO projects (id, snapshot, saved_at, revision, is_deleted, deleted_at, deleted_by)
299
+ VALUES (?, ?, ?, ?, 0, NULL, NULL)
300
+ ON CONFLICT(id) DO UPDATE SET
301
+ snapshot = excluded.snapshot,
302
+ saved_at = excluded.saved_at,
303
+ revision = excluded.revision,
304
+ is_deleted = 0,
305
+ deleted_at = NULL,
306
+ deleted_by = NULL
307
+ `);
308
+ upsert.run(projectId, json, savedAt, revision);
309
+ this._db.prepare(`
310
+ INSERT INTO revisions (project_id, revision, snapshot, saved_at, op, actor)
311
+ VALUES (?, ?, ?, ?, ?, ?)
312
+ `).run(projectId, revision, json, savedAt, op, actorJson);
313
+ this._maybePrune(projectId, savedAt);
314
+ });
315
+
316
+ // Re-mint + retry on a cross-process revision-id collision (see
317
+ // WRITE_MAX_RETRIES). The whole insert is one transaction, so a collided
318
+ // attempt rolls back cleanly before the next id is tried.
319
+ let revision;
320
+ for (let attempt = 0; ; attempt++) {
321
+ revision = this._minter.next();
322
+ try { writeOnce(revision)(); break; }
323
+ catch (e) {
324
+ if (isRevisionCollision(e) && attempt < WRITE_MAX_RETRIES) continue;
325
+ throw e;
326
+ }
327
+ }
328
+
329
+ const result = { revision, savedAt, snapshot: normalized };
330
+ bus.emit("change", { projectId, revision, savedAt, op, actor, originId });
331
+ return result;
332
+ }
333
+
334
+ async deleteProject(projectId, opts) {
335
+ if (!validProjectId(projectId)) throw new Error("invalid project id");
336
+ opts = opts || {};
337
+ const actor = core.normalizeActor(opts.actor);
338
+ return this._mutex.run(projectId, async () => {
339
+ // SM-129: deletes flow through the same authorization choke-point.
340
+ identity.authorize(actor, "project_delete", { projectId });
341
+ const deletedAt = Date.now();
342
+ const r = this._db.prepare(
343
+ "UPDATE projects SET is_deleted = 1, deleted_at = ?, deleted_by = ? WHERE id = ?"
344
+ ).run(deletedAt, JSON.stringify(actor), projectId);
345
+ if (r.changes > 0) {
346
+ bus.emit("change", {
347
+ projectId, revision: null, savedAt: deletedAt, op: "project_delete", actor
348
+ });
349
+ }
350
+ });
351
+ }
352
+
353
+ // SM-212: prune inside the write transaction every Nth save. Purely
354
+ // count-based — every revision outside the newest-N window (by revision id,
355
+ // which is a zero-padded UTC timestamp, so string order IS time order) is
356
+ // deleted. The newest N (incl. the latest) always survive. Callers hold the
357
+ // per-project mutex via _writeLocked.
358
+ _maybePrune(projectId, _savedAt) {
359
+ const r = this._retention;
360
+ const count = (this._saveCounts.get(projectId) || 0) + 1;
361
+ if (count < r.PRUNE_EVERY_N_SAVES) {
362
+ this._saveCounts.set(projectId, count);
363
+ return;
364
+ }
365
+ this._saveCounts.set(projectId, 0);
366
+ this._db.prepare(`
367
+ DELETE FROM revisions
368
+ WHERE project_id = ?
369
+ AND revision NOT IN (
370
+ SELECT revision FROM revisions
371
+ WHERE project_id = ?
372
+ ORDER BY revision DESC
373
+ LIMIT ?
374
+ )
375
+ `).run(projectId, projectId, r.KEEP_MIN_REVISIONS_PER_PROJECT);
376
+ }
377
+
378
+ // SM-212: real SQL LIMIT + beforeRevision cursor — no select-all + JS slice.
379
+ // Revision ids are zero-padded UTC timestamps, so string order IS time order.
380
+ async listRevisions(projectId, opts) {
381
+ if (!validProjectId(projectId)) return [];
382
+ opts = opts || {};
383
+ const ret = this._retention;
384
+ const rawLimit = Number.isFinite(opts.limit) ? Math.floor(opts.limit) : ret.LIST_DEFAULT_LIMIT;
385
+ const limit = Math.max(1, Math.min(ret.LIST_MAX_LIMIT, rawLimit));
386
+ const before = typeof opts.beforeRevision === "string" && opts.beforeRevision ? opts.beforeRevision : null;
387
+ const rows = before
388
+ ? this._db.prepare(`
389
+ SELECT revision, saved_at AS savedAt, op, actor
390
+ FROM revisions
391
+ WHERE project_id = ? AND revision < ?
392
+ ORDER BY revision DESC
393
+ LIMIT ?
394
+ `).all(projectId, before, limit)
395
+ : this._db.prepare(`
396
+ SELECT revision, saved_at AS savedAt, op, actor
397
+ FROM revisions
398
+ WHERE project_id = ?
399
+ ORDER BY revision DESC
400
+ LIMIT ?
401
+ `).all(projectId, limit);
402
+ return rows.map(r => ({
403
+ revision: r.revision,
404
+ savedAt: r.savedAt,
405
+ op: r.op,
406
+ actor: r.actor ? JSON.parse(r.actor) : null
407
+ }));
408
+ }
409
+
410
+ async getRevision(projectId, revision) {
411
+ if (!validProjectId(projectId)) return null;
412
+ const row = this._db.prepare(`
413
+ SELECT revision, snapshot, saved_at AS savedAt
414
+ FROM revisions
415
+ WHERE project_id = ? AND revision = ?
416
+ `).get(projectId, revision);
417
+ if (!row) return null;
418
+ return {
419
+ revision: row.revision,
420
+ savedAt: row.savedAt,
421
+ snapshot: JSON.parse(row.snapshot)
422
+ };
423
+ }
424
+
425
+ async restoreRevision(projectId, revision, opts) {
426
+ const r = await this.getRevision(projectId, revision);
427
+ if (!r) throw new Error("revision not found: " + revision);
428
+ return this.saveProject(projectId, r.snapshot, {
429
+ // Never freshly mint an "unknown" actor on a write path (identity seam
430
+ // invariant). Fall back to the known default transport. (SM-149)
431
+ actor: (opts && opts.actor) || identity.DEFAULT_HTTP_ACTOR,
432
+ op: "project_restore"
433
+ });
434
+ }
435
+
436
+ async remove(projectId) {
437
+ if (!validProjectId(projectId)) throw new Error("invalid project id");
438
+ return this._mutex.run(projectId, async () => {
439
+ let changed = 0;
440
+ const txn = this._db.transaction(() => {
441
+ // CASCADE removes revisions automatically thanks to FK.
442
+ const r = this._db.prepare("DELETE FROM projects WHERE id = ?").run(projectId);
443
+ changed = r.changes;
444
+ });
445
+ txn();
446
+ // Only broadcast if a row was actually deleted (mirror deleteProject) —
447
+ // a no-op remove must not spam connected browsers. (SM-149)
448
+ if (changed > 0) {
449
+ // SM-183: the FK cascade drops attachment ROWS; the FILES on disk are
450
+ // separate — remove the project's whole attachments dir too.
451
+ try { fs.rmSync(path.join(this._attachmentsRoot(), projectId), { recursive: true, force: true }); }
452
+ catch (_) { /* best-effort */ }
453
+ bus.emit("change", {
454
+ projectId, revision: null, savedAt: Date.now(), op: "project_remove", actor: null
455
+ });
456
+ }
457
+ });
458
+ }
459
+
460
+ // --- Attachments (SM-183) -------------------------------------------------
461
+
462
+ _attachmentsRoot() { return path.join(this._dataDir, "attachments"); }
463
+
464
+ // Resolve a stored rel_path to an absolute path, asserting it stays under the
465
+ // attachments root (defends against a tampered/garbage rel_path).
466
+ _attachmentAbsPath(relPath) {
467
+ const abs = path.resolve(this._dataDir, relPath);
468
+ const root = path.resolve(this._attachmentsRoot());
469
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
470
+ throw Object.assign(new Error("attachment path escapes root"), { statusCode: 400 });
471
+ }
472
+ return abs;
473
+ }
474
+
475
+ async addAttachment(projectId, file, actor) {
476
+ if (!validProjectId(projectId)) throw Object.assign(new Error("invalid project id"), { statusCode: 400 });
477
+ const buf = file && file.buffer;
478
+ if (!Buffer.isBuffer(buf) || buf.length === 0) {
479
+ throw Object.assign(new Error("attachment body required"), { statusCode: 400 });
480
+ }
481
+ if (buf.length > ATTACHMENT_LIMITS.MAX_BYTES) {
482
+ throw Object.assign(new Error("attachment too large (max " + ATTACHMENT_LIMITS.MAX_BYTES + " bytes)"), { statusCode: 413 });
483
+ }
484
+ return this._mutex.run(projectId, async () => {
485
+ // FK requires the project row to exist — give a clean 404 instead of a
486
+ // raw SQLite constraint error.
487
+ const proj = this._db.prepare("SELECT id FROM projects WHERE id = ?").get(projectId);
488
+ if (!proj) throw Object.assign(new Error("project not found: " + projectId), { statusCode: 404 });
489
+ const id = "att-" + require("crypto").randomUUID();
490
+ const safe = safeAttachmentName(file.filename);
491
+ const relPath = path.join("attachments", projectId, id + "-" + safe);
492
+ const absPath = this._attachmentAbsPath(relPath);
493
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
494
+ fs.writeFileSync(absPath, buf);
495
+ const by = (actor && typeof actor === "object") ? actor : null;
496
+ const meta = {
497
+ id, projectId,
498
+ // SM-194: "none" is the project-level LIST sentinel — it must never be
499
+ // persisted as a literal ticket_id (else the row is orphaned: it shows
500
+ // up in neither the project-level IS NULL list nor any real ticket's
501
+ // list). Normalize "" and "none" to null here, the single write choke-point.
502
+ ticketId: (file.ticketId != null && file.ticketId !== "" && file.ticketId !== "none") ? String(file.ticketId) : null,
503
+ filename: safe,
504
+ mimeType: (typeof file.mimeType === "string" && file.mimeType) ? file.mimeType : "application/octet-stream",
505
+ size: buf.length, uploadedAt: Date.now(), uploadedBy: by
506
+ };
507
+ try {
508
+ this._db.prepare(
509
+ `INSERT INTO attachments
510
+ (id, project_id, ticket_id, filename, mime_type, size, rel_path, uploaded_at, uploaded_by)
511
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
512
+ ).run(id, projectId, meta.ticketId, meta.filename, meta.mimeType, meta.size, relPath, meta.uploadedAt,
513
+ by ? JSON.stringify(by) : null);
514
+ } catch (e) {
515
+ // No orphan files: if the row insert fails, drop the bytes we just wrote.
516
+ try { fs.rmSync(absPath, { force: true }); } catch (_) { /* best-effort */ }
517
+ throw e;
518
+ }
519
+ bus.emit("change", {
520
+ projectId, revision: null, savedAt: meta.uploadedAt, op: "attachment_add", actor: by, originId: null
521
+ });
522
+ return meta;
523
+ });
524
+ }
525
+
526
+ async listAttachments(projectId, opts) {
527
+ opts = opts || {};
528
+ // Three scopes: a specific ticketId; the "none" sentinel = project-level
529
+ // attachments only (ticket_id IS NULL, e.g. source PRDs, SM-194); or
530
+ // unscoped = everything for the project.
531
+ let rows;
532
+ if (opts.ticketId === "none") {
533
+ rows = this._db.prepare("SELECT * FROM attachments WHERE project_id = ? AND ticket_id IS NULL ORDER BY uploaded_at ASC").all(projectId);
534
+ } else if (opts.ticketId != null && opts.ticketId !== "") {
535
+ rows = this._db.prepare("SELECT * FROM attachments WHERE project_id = ? AND ticket_id = ? ORDER BY uploaded_at ASC").all(projectId, String(opts.ticketId));
536
+ } else {
537
+ rows = this._db.prepare("SELECT * FROM attachments WHERE project_id = ? ORDER BY uploaded_at ASC").all(projectId);
538
+ }
539
+ return rows.map(attachmentRowToMeta);
540
+ }
541
+
542
+ // Returns { ...meta, absPath } for streaming, or null if unknown.
543
+ async getAttachment(attachmentId) {
544
+ const row = this._db.prepare("SELECT * FROM attachments WHERE id = ?").get(attachmentId);
545
+ if (!row) return null;
546
+ return Object.assign(attachmentRowToMeta(row), { absPath: this._attachmentAbsPath(row.rel_path) });
547
+ }
548
+
549
+ async removeAttachment(attachmentId, actor) {
550
+ const row = this._db.prepare("SELECT * FROM attachments WHERE id = ?").get(attachmentId);
551
+ if (!row) return false;
552
+ return this._mutex.run(row.project_id, async () => {
553
+ try { fs.rmSync(this._attachmentAbsPath(row.rel_path), { force: true }); }
554
+ catch (_) { /* file already gone — drop the row anyway */ }
555
+ this._db.prepare("DELETE FROM attachments WHERE id = ?").run(attachmentId);
556
+ const by = (actor && typeof actor === "object") ? actor : null;
557
+ bus.emit("change", {
558
+ projectId: row.project_id, revision: null, savedAt: Date.now(),
559
+ op: "attachment_remove", actor: by, originId: null
560
+ });
561
+ return true;
562
+ });
563
+ }
564
+ }
565
+
566
+ module.exports = Storage;
567
+ // SM-183: single source for the size cap so the server route + the storage
568
+ // guard can't drift (Konstanten-Disziplin).
569
+ module.exports.ATTACHMENT_LIMITS = ATTACHMENT_LIMITS;
570
+ // SM-212: retention + listing-limit constants (tests reference these).
571
+ module.exports.RETENTION = RETENTION;