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.
- package/ARCHITECTURE.md +334 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +239 -0
- package/frontend/css/storymap.css +4637 -0
- package/frontend/js/adapters.js +423 -0
- package/frontend/js/card-animate.js +384 -0
- package/frontend/js/core/graph.js +825 -0
- package/frontend/js/core.js +3908 -0
- package/frontend/js/dialog-ticket-import.js +506 -0
- package/frontend/js/dnd.js +322 -0
- package/frontend/js/filter.js +215 -0
- package/frontend/js/main.js +2499 -0
- package/frontend/js/project-io.js +109 -0
- package/frontend/js/query-autocomplete.js +196 -0
- package/frontend/js/query-engine.js +339 -0
- package/frontend/js/query.js +280 -0
- package/frontend/js/renderer-card.js +639 -0
- package/frontend/js/renderer-dependencies.js +974 -0
- package/frontend/js/renderer-kanban.js +505 -0
- package/frontend/js/renderer-storymap.js +2530 -0
- package/frontend/js/renderer-ticket-editor.js +455 -0
- package/frontend/js/renderer-ticket-modal.js +758 -0
- package/frontend/js/save-pipeline.js +170 -0
- package/frontend/js/smartbar-autocomplete.js +162 -0
- package/frontend/js/store.js +197 -0
- package/frontend/js/ticket-editor-boot.js +24 -0
- package/frontend/js/ticket-form.js +2095 -0
- package/frontend/js/ticket-import.js +477 -0
- package/frontend/js/ui-shell.js +441 -0
- package/frontend/js/view-process-steps.js +233 -0
- package/frontend/js/view-requirements.js +361 -0
- package/frontend/js/view-settings.js +1864 -0
- package/frontend/js/view-table.js +659 -0
- package/frontend/js/wheel-pan.js +65 -0
- package/frontend/storymap.html +87 -0
- package/frontend/ticket-editor.html +29 -0
- package/package.json +76 -0
- package/server/bus.js +16 -0
- package/server/core/graph.js +10 -0
- package/server/core.js +10 -0
- package/server/identity.js +134 -0
- package/server/index.js +283 -0
- package/server/ingest.js +212 -0
- package/server/mcp.js +2510 -0
- package/server/server.js +1599 -0
- package/server/slice.js +103 -0
- package/server/storage.js +571 -0
- package/server/validation.js +225 -0
- package/shared/core/graph.js +825 -0
- package/shared/core.js +3908 -0
- package/shared/project-io.js +109 -0
- package/shared/query-autocomplete.js +196 -0
- package/shared/query-engine.js +339 -0
- package/shared/query.js +280 -0
- package/shared/ticket-import.js +477 -0
- package/skill/SKILL.md +458 -0
- package/skill/reference/anatomy.md +196 -0
- package/skill/reference/spec-evolution.md +52 -0
- package/skill/reference/tools.md +156 -0
- package/skill/reference/workflows.md +203 -0
package/server/slice.js
ADDED
|
@@ -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;
|