engine-dj-mcp 0.12.0 → 0.17.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.
@@ -0,0 +1,236 @@
1
+ // src/store/track-metadata-plan.ts
2
+ //
3
+ // The pure half of update_track_metadata (spec docs/superpowers/specs/
4
+ // 2026-09-14-track-metadata-design.md). No database access, so every rule can
5
+ // be tested against an exact row.
6
+ import { err } from "../errors.js";
7
+ import { NOT_COMMITTED } from "./write.js";
8
+ export const TEXT_FIELDS = ["genre", "comment", "label"];
9
+ export const MAX_UPDATES = 200;
10
+ export const MAX_TEXT = 1000;
11
+ const LIST_LIMIT = 20;
12
+ /** The columns an update writes, in a fixed order. Both rating inputs write `rating`. */
13
+ export function writtenFields(u) {
14
+ const out = [];
15
+ for (const f of TEXT_FIELDS)
16
+ if (u[f] !== undefined)
17
+ out.push(f);
18
+ if (u.year !== undefined)
19
+ out.push("year");
20
+ if (u.rating_stars !== undefined || u.rating_raw !== undefined)
21
+ out.push("rating");
22
+ return out;
23
+ }
24
+ /**
25
+ * A refusal names every offender, not the first (spec §7.3): one vanished
26
+ * track must not cost two hundred round trips. Capped so the message stays
27
+ * readable.
28
+ */
29
+ export function listProblems(problems) {
30
+ const shown = problems.slice(0, LIST_LIMIT).join("; ");
31
+ const rest = problems.length - LIST_LIMIT;
32
+ return rest > 0 ? `${shown}; and ${rest} more` : shown;
33
+ }
34
+ const isWhole = (n, lo, hi) => Number.isInteger(n) && n >= lo && n <= hi;
35
+ /**
36
+ * Rules that need no database. Ranges check NEW values only: a field written
37
+ * together with `expect` on the same field is a restore -- typically a replayed
38
+ * undo -- and must be able to put back whatever was there, however odd
39
+ * (spec §5.3). rating_raw is the exception that proves the rule: it exists only
40
+ * for restoring, so it always needs expect.rating_raw, and its 0-255 bound (the
41
+ * ID3 scale) always applies.
42
+ */
43
+ export function validateUpdates(updates) {
44
+ if (updates.length === 0) {
45
+ return err("invalid_argument", "updates is empty; name at least one track to change. Nothing was written.", {
46
+ detail: NOT_COMMITTED,
47
+ });
48
+ }
49
+ const problems = [];
50
+ if (updates.length > MAX_UPDATES)
51
+ problems.push(`${updates.length} updates, more than the ${MAX_UPDATES} allowed per call`);
52
+ const seen = new Set();
53
+ const repeated = new Set();
54
+ for (const u of updates) {
55
+ if (seen.has(u.id))
56
+ repeated.add(u.id);
57
+ seen.add(u.id);
58
+ }
59
+ if (repeated.size > 0) {
60
+ problems.push(`track id${repeated.size > 1 ? "s" : ""} ${[...repeated].join(", ")} named more than once`);
61
+ }
62
+ for (const u of updates) {
63
+ const at = `track ${u.id}`;
64
+ const fields = writtenFields(u);
65
+ const expect = u.expect ?? {};
66
+ if (fields.length === 0)
67
+ problems.push(`${at}: no field to change`);
68
+ if (u.rating_stars !== undefined && u.rating_raw !== undefined) {
69
+ problems.push(`${at}: rating_stars and rating_raw together`);
70
+ }
71
+ if (u.rating_raw !== undefined && expect.rating_raw === undefined) {
72
+ problems.push(`${at}: rating_raw restores an exact value and needs expect.rating_raw; to set a rating use rating_stars`);
73
+ }
74
+ for (const key of Object.keys(expect)) {
75
+ if (expect[key] === undefined)
76
+ continue;
77
+ const writes = key === "rating_raw" ? fields.includes("rating") : fields.includes(key);
78
+ if (!writes)
79
+ problems.push(`${at}: expect.${key} names a field this update does not change`);
80
+ }
81
+ if (u.rating_stars !== undefined && !isWhole(u.rating_stars, 0, 5)) {
82
+ problems.push(`${at}: rating_stars must be a whole number 0-5`);
83
+ }
84
+ if (u.rating_raw !== undefined && !isWhole(u.rating_raw, 0, 255)) {
85
+ problems.push(`${at}: rating_raw must be a whole number 0-255`);
86
+ }
87
+ if (u.year !== undefined) {
88
+ if (!Number.isInteger(u.year))
89
+ problems.push(`${at}: year must be a whole number`);
90
+ else if (expect.year === undefined && u.year !== 0 && !isWhole(u.year, 1000, 2200)) {
91
+ problems.push(`${at}: year must be 0 (unknown) or 1000-2200`);
92
+ }
93
+ }
94
+ for (const f of TEXT_FIELDS) {
95
+ const v = u[f];
96
+ // A lone surrogate passes every other check here, reaches SQLite, and
97
+ // comes back mangled -- caught downstream as library_unreadable
98
+ // "did not read back as written" instead of the invalid_argument this
99
+ // is. Checked on both the new value and its expect counterpart, and
100
+ // regardless of whether this is a restore: MAX_TEXT is waived for a
101
+ // restore (spec §5.3), but a value that is not valid Unicode text at
102
+ // all is never something this tool could have written or read back.
103
+ if (v !== undefined && !v.isWellFormed())
104
+ problems.push(`${at}: ${f} is not valid Unicode text`);
105
+ const ev = expect[f];
106
+ if (ev !== undefined && !ev.isWellFormed())
107
+ problems.push(`${at}: expect.${f} is not valid Unicode text`);
108
+ if (v !== undefined && expect[f] === undefined && v.length > MAX_TEXT) {
109
+ problems.push(`${at}: ${f} is longer than ${MAX_TEXT} characters`);
110
+ }
111
+ }
112
+ }
113
+ if (problems.length === 0)
114
+ return undefined;
115
+ return err("invalid_argument", `${problems.length} problem${problems.length > 1 ? "s" : ""} with updates, nothing was written: ${listProblems(problems)}`, { detail: NOT_COMMITTED });
116
+ }
117
+ /** The value a field will hold once written. `""` is NULL; stars are Engine's 0-100. */
118
+ export function targetOf(u, f) {
119
+ if (f === "rating")
120
+ return u.rating_raw !== undefined ? u.rating_raw : u.rating_stars * 20;
121
+ if (f === "year")
122
+ return u.year;
123
+ const v = u[f];
124
+ return v === "" ? null : v;
125
+ }
126
+ /**
127
+ * Spec §5.1: NULL and '' are one empty for text, NULL and 0 for numbers.
128
+ * Otherwise exact -- no Unicode folding, because hiding a real difference is
129
+ * worse than reporting a confusing one (describeValue spells those out).
130
+ * Done in JS: in SQL, NULL = '' is neither true nor false.
131
+ */
132
+ export function sameStored(f, a, b) {
133
+ if (f === "year" || f === "rating")
134
+ return (a ?? 0) === (b ?? 0);
135
+ return (a ?? "") === (b ?? "");
136
+ }
137
+ const codePoints = (s) => [...s].map((c) => "U+" + c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")).join(" ");
138
+ function describeMismatch(m) {
139
+ const show = (v) => (v === null ? "empty" : JSON.stringify(v));
140
+ let line = `track ${m.id} ${m.field}: expected ${show(m.expected)}, found ${show(m.actual)}`;
141
+ if (typeof m.expected === "string" && typeof m.actual === "string" &&
142
+ m.expected !== m.actual && m.expected.normalize("NFC") === m.actual.normalize("NFC")) {
143
+ line += ` (same text in a different Unicode form: expected ${codePoints(m.expected)}, found ${codePoints(m.actual)})`;
144
+ }
145
+ return line;
146
+ }
147
+ /**
148
+ * Decide, for rows already read, what each update writes. Order matters and
149
+ * follows spec §5.2 and §6: a row already at its target is unchanged before
150
+ * anything else is asked of it -- expect included, since expect guards writes
151
+ * and this row is not written. Refusals are collected across all rows and
152
+ * reported by class: unknown ids first, then tracks that cannot be edited,
153
+ * then stale expectations.
154
+ */
155
+ export function planUpdates(updates, rows) {
156
+ const plan = { writes: [], unchanged: [], undo: [] };
157
+ const unknown = [];
158
+ const notEditable = [];
159
+ const mismatches = [];
160
+ for (const u of updates) {
161
+ const current = rows.get(u.id);
162
+ if (!current) {
163
+ unknown.push(u.id);
164
+ continue;
165
+ }
166
+ // An inexpressible field is read as null, so comparing it would call a stored
167
+ // 999 "already 0 stars". It always counts as different, which routes it to
168
+ // the track_not_editable refusal below instead of a false "unchanged".
169
+ const differs = writtenFields(u).filter((f) => current.inexpressible.includes(f) || !sameStored(f, current[f], targetOf(u, f)));
170
+ if (differs.length === 0) {
171
+ plan.unchanged.push(u.id);
172
+ continue;
173
+ }
174
+ if (current.originEmpty) {
175
+ notEditable.push(`track ${u.id}: its origin is empty, and Engine's own trigger rewrites an empty origin on any ` +
176
+ `update, which would detach it from playlist entries on other drives; edit it in Engine DJ instead`);
177
+ }
178
+ for (const f of differs) {
179
+ if (current.inexpressible.includes(f)) {
180
+ notEditable.push(`track ${u.id}: ${f} holds a stored value this tool could not put back`);
181
+ }
182
+ }
183
+ const expect = u.expect ?? {};
184
+ for (const key of Object.keys(expect)) {
185
+ const expected = expect[key];
186
+ if (expected === undefined)
187
+ continue;
188
+ const field = key === "rating_raw" ? "rating" : key;
189
+ // Spec §5.2 (field-level): expect guards writes, so an expect on a
190
+ // field this row will not write is skipped. Otherwise undoing a
191
+ // "genre + comment" edit would refuse the whole row because genre was
192
+ // already put back by hand, though comment still needs restoring.
193
+ if (!differs.includes(field))
194
+ continue;
195
+ const want = field === "year" || field === "rating" ? expected : expected || null;
196
+ if (!sameStored(field, current[field], want)) {
197
+ mismatches.push({ id: u.id, field: key, expected, actual: current[field] });
198
+ }
199
+ }
200
+ const write = { id: u.id, set: {}, fields: differs };
201
+ const back = { id: u.id, expect: {} };
202
+ for (const f of differs) {
203
+ const now = targetOf(u, f);
204
+ write.set[f] = now;
205
+ const was = current[f];
206
+ if (f === "rating") {
207
+ back.rating_raw = was ?? 0;
208
+ back.expect.rating_raw = now ?? 0;
209
+ }
210
+ else if (f === "year") {
211
+ back.year = was ?? 0;
212
+ back.expect.year = now ?? 0;
213
+ }
214
+ else {
215
+ back[f] = was ?? "";
216
+ back.expect[f] = now ?? "";
217
+ }
218
+ }
219
+ plan.writes.push(write);
220
+ plan.undo.push(back);
221
+ }
222
+ if (unknown.length > 0) {
223
+ return err("unknown_track", `${unknown.length} track id${unknown.length > 1 ? "s are" : " is"} not in this library, nothing was written: ` +
224
+ listProblems(unknown.map(String)), { detail: NOT_COMMITTED });
225
+ }
226
+ if (notEditable.length > 0) {
227
+ return err("track_not_editable", `${notEditable.length} edit${notEditable.length > 1 ? "s" : ""} cannot be made, nothing was written: ${listProblems(notEditable)}`, { detail: NOT_COMMITTED });
228
+ }
229
+ if (mismatches.length > 0) {
230
+ return err("stale_value", `${mismatches.length} expected value${mismatches.length > 1 ? "s" : ""} no longer match, nothing was written. ` +
231
+ `The track changed after the values in expect were read -- tell the user which tracks and fields changed. ` +
232
+ `Do NOT rebuild expect from a fresh read to force the write without the user's consent: ` +
233
+ `${listProblems(mismatches.map(describeMismatch))}`, { detail: NOT_COMMITTED, mismatches: mismatches.slice(0, LIST_LIMIT) });
234
+ }
235
+ return plan;
236
+ }
@@ -0,0 +1,22 @@
1
+ import { type EngineError } from "../errors.js";
2
+ import { type LibraryRef, type UndoStep } from "./write.js";
3
+ import { type FieldName, type TrackUpdate } from "./track-metadata-plan.js";
4
+ export interface TrackMetadataResult {
5
+ updated: number;
6
+ unchanged: number;
7
+ changed: {
8
+ id: number;
9
+ fields: FieldName[];
10
+ }[];
11
+ undo: UndoStep[];
12
+ undo_complete: true;
13
+ }
14
+ export declare function updateTrackMetadata(mdbPath: string, uuid: string, input: {
15
+ updates: TrackUpdate[];
16
+ }, opts: {
17
+ backupDir: string;
18
+ beforeLock?: () => void;
19
+ }): Promise<(TrackMetadataResult & {
20
+ library: LibraryRef;
21
+ backup_path?: string;
22
+ }) | EngineError>;
@@ -0,0 +1,143 @@
1
+ // src/store/track-metadata.ts
2
+ //
3
+ // update_track_metadata against a real library. The decisions live in
4
+ // track-metadata-plan.ts; this module reads rows, applies a plan and verifies
5
+ // it. Order (spec §6): hot journal, input rules, read-only pre-check, then --
6
+ // only if something would change -- the shared write transaction, where every
7
+ // row is read and planned AGAIN under the lock and that second reading decides.
8
+ import { DatabaseSync } from "node:sqlite";
9
+ import { err, isEngineError, libraryNeedsRecovery } from "../errors.js";
10
+ import { redactPath } from "../paths.js";
11
+ import { hasHotJournal } from "./connections.js";
12
+ import { NOT_COMMITTED, mapWriteError, rollback, withWriteTransaction, } from "./write.js";
13
+ import { planUpdates, sameStored, validateUpdates, TEXT_FIELDS, } from "./track-metadata-plan.js";
14
+ const MAX_SAFE = Number.MAX_SAFE_INTEGER;
15
+ /**
16
+ * Types and ranges are settled in SQL, before a value reaches JS: node:sqlite
17
+ * throws on an INTEGER beyond 2^53, and a REAL rating or a TEXT year is not a
18
+ * value this tool could put back. Such a column comes back null and is listed
19
+ * in `inexpressible`. The empty-origin test is the fix-origin trigger's own
20
+ * WHEN, evaluated by SQLite, so it agrees with the trigger on TEXT '' (spec §3.3).
21
+ */
22
+ const READ_ROW = `SELECT id,
23
+ CASE WHEN typeof(genre) = 'text' THEN genre END AS genre, typeof(genre) AS genre_t,
24
+ CASE WHEN typeof(comment) = 'text' THEN comment END AS comment, typeof(comment) AS comment_t,
25
+ CASE WHEN typeof(label) = 'text' THEN label END AS label, typeof(label) AS label_t,
26
+ CASE WHEN typeof(year) = 'integer' AND year BETWEEN -${MAX_SAFE} AND ${MAX_SAFE} THEN year END AS year,
27
+ (typeof(year) = 'null' OR (typeof(year) = 'integer' AND year BETWEEN -${MAX_SAFE} AND ${MAX_SAFE})) AS year_ok,
28
+ CASE WHEN typeof(rating) = 'integer' AND rating BETWEEN 0 AND 255 THEN rating END AS rating,
29
+ (typeof(rating) = 'null' OR (typeof(rating) = 'integer' AND rating BETWEEN 0 AND 255)) AS rating_ok,
30
+ (IFNULL(originTrackId, 0) = 0 OR IFNULL(originDatabaseUuid, '') = '') AS origin_empty,
31
+ CAST(originDatabaseUuid AS TEXT) || '|' || CAST(originTrackId AS TEXT) AS origin
32
+ FROM Track WHERE id = ?`;
33
+ function readRows(db, ids) {
34
+ const stmt = db.prepare(READ_ROW);
35
+ const out = new Map();
36
+ for (const id of ids) {
37
+ const r = stmt.get(id);
38
+ if (!r)
39
+ continue;
40
+ const inexpressible = [];
41
+ for (const f of TEXT_FIELDS) {
42
+ if (r[`${f}_t`] !== "text" && r[`${f}_t`] !== "null")
43
+ inexpressible.push(f);
44
+ }
45
+ if (!r.year_ok)
46
+ inexpressible.push("year");
47
+ if (!r.rating_ok)
48
+ inexpressible.push("rating");
49
+ out.set(id, {
50
+ id,
51
+ genre: r.genre,
52
+ comment: r.comment,
53
+ label: r.label,
54
+ year: r.year,
55
+ rating: r.rating,
56
+ inexpressible,
57
+ originEmpty: r.origin_empty === 1,
58
+ origin: r.origin,
59
+ });
60
+ }
61
+ return out;
62
+ }
63
+ export async function updateTrackMetadata(mdbPath, uuid, input, opts) {
64
+ const { updates } = input;
65
+ const ids = updates.map((u) => u.id);
66
+ const subject = `${updates.length} track${updates.length === 1 ? "" : "s"}`;
67
+ // First, before anything opens the file: see createPlaylist in write.ts.
68
+ if (hasHotJournal(mdbPath))
69
+ return { ...libraryNeedsRecovery(), detail: NOT_COMMITTED };
70
+ const invalid = validateUpdates(updates);
71
+ if (invalid)
72
+ return invalid;
73
+ // Not the authority -- the transaction re-plans. This only spares a
74
+ // snapshot for a call that is going to refuse anyway, or has nothing to do.
75
+ let pre;
76
+ let precheck;
77
+ try {
78
+ precheck = new DatabaseSync(mdbPath, { readOnly: true });
79
+ pre = planUpdates(updates, readRows(precheck, ids));
80
+ }
81
+ catch (e) {
82
+ return mapWriteError(e, subject, mdbPath);
83
+ }
84
+ finally {
85
+ try {
86
+ precheck?.close();
87
+ }
88
+ catch {
89
+ /* never opened */
90
+ }
91
+ }
92
+ if (isEngineError(pre))
93
+ return pre;
94
+ if (pre.writes.length === 0) {
95
+ // Spec §5.5: no transaction, no snapshot, so no backup_path -- and
96
+ // withWriteTransaction is not here to fill in `library`.
97
+ return {
98
+ updated: 0,
99
+ unchanged: pre.unchanged.length,
100
+ changed: [],
101
+ undo: [],
102
+ undo_complete: true,
103
+ library: { uuid, path: redactPath(mdbPath) },
104
+ };
105
+ }
106
+ return withWriteTransaction(mdbPath, uuid, subject, opts, (db) => {
107
+ // Under BEGIN IMMEDIATE. Engine may have changed any of these rows while
108
+ // the snapshot was being copied; what is read here decides (spec §6.5).
109
+ const rows = readRows(db, ids);
110
+ const plan = planUpdates(updates, rows);
111
+ if (isEngineError(plan)) {
112
+ rollback(db);
113
+ return plan;
114
+ }
115
+ for (const w of plan.writes) {
116
+ // Column names come from the FieldName union, never from caller text.
117
+ const assignments = w.fields.map((f) => `${f} = ?`).join(", ");
118
+ // Set as Engine sets it on its own tag edits (spec §3.9, measured).
119
+ db.prepare(`UPDATE Track SET ${assignments}, isMetadataOfPackedTrackChanged = 1 WHERE id = ?`).run(...w.fields.map((f) => w.set[f] ?? null), w.id);
120
+ }
121
+ const after = readRows(db, plan.writes.map((w) => w.id));
122
+ for (const w of plan.writes) {
123
+ const got = after.get(w.id);
124
+ const landed = got !== undefined &&
125
+ got.origin === rows.get(w.id).origin &&
126
+ w.fields.every((f) => sameStored(f, got[f], w.set[f] ?? null));
127
+ if (!landed) {
128
+ rollback(db);
129
+ return err("library_unreadable", `Track ${w.id} did not read back as written; nothing was changed.`, {
130
+ detail: NOT_COMMITTED,
131
+ });
132
+ }
133
+ }
134
+ const result = {
135
+ updated: plan.writes.length,
136
+ unchanged: plan.unchanged.length,
137
+ changed: plan.writes.map((w) => ({ id: w.id, fields: w.fields })),
138
+ undo: plan.undo.length > 0 ? [{ tool: "update_track_metadata", arguments: { updates: plan.undo } }] : [],
139
+ undo_complete: true,
140
+ };
141
+ return result;
142
+ });
143
+ }
@@ -4,8 +4,24 @@ export interface CreatePlaylistResult {
4
4
  playlist_id: number;
5
5
  title: string;
6
6
  tracks_added: number;
7
+ library: LibraryRef;
7
8
  backup_path: string;
8
9
  }
10
+ /**
11
+ * Which library a write actually landed in. Two connected libraries -- a USB
12
+ * drive and its copy on the computer -- is the ordinary setup, so "which one
13
+ * did that go to" is a question every write result has to answer on its own,
14
+ * without the caller re-deriving it from an argument it may not have passed.
15
+ *
16
+ * It is also the context `undo` needs: an undo reverses the edit in this
17
+ * library and cannot reach a copy Engine DJ has since propagated to another
18
+ * one (measured 2026-09-01, see README).
19
+ */
20
+ export interface LibraryRef {
21
+ uuid: string;
22
+ /** The m.db path, in the `~/...` form list_libraries prints. */
23
+ path: string;
24
+ }
9
25
  /**
10
26
  * What editing an existing playlist's entries returns. One shape for
11
27
  * add/remove/reorder alike -- each op leaves the fields it did not touch
@@ -37,6 +53,7 @@ export interface EditResult {
37
53
  undo_complete: boolean;
38
54
  /** Set only when `undo_complete` is false: which positions have no way back, and why. */
39
55
  undo_note?: string;
56
+ library: LibraryRef;
40
57
  backup_path: string;
41
58
  }
42
59
  /** One step of the tool call that would undo an edit, in the shape a client replays it. */
@@ -48,6 +65,22 @@ export interface OriginRef {
48
65
  uuid: string;
49
66
  trackId: number;
50
67
  }
68
+ /**
69
+ * `detail` discriminator values for the EngineError this module returns.
70
+ * Stable across releases so a caller can decide "is the library still what
71
+ * it was" without parsing message prose. Everything before COMMIT --
72
+ * including validation that never reaches the database at all -- collapses
73
+ * to the same NOT_COMMITTED answer. COMMITTED_UNVERIFIED is produced by
74
+ * exactly two things, and both mean "the playlist may be on disk": the
75
+ * post-commit check reporting anything but "ok", and anything thrown from
76
+ * the COMMIT itself onwards. It is also the only case that carries
77
+ * backup_path, because it is the only case where restoring from a snapshot
78
+ * is ever the right next step.
79
+ *
80
+ * These two strings are part of the tool's contract; see src/errors.ts.
81
+ */
82
+ /** Exported for src/store/track-metadata*.ts; the string is part of the tool contract. */
83
+ export declare const NOT_COMMITTED = "not_committed";
51
84
  /** Test seam only: forget this process's snapshots so a test can start clean. */
52
85
  export declare function resetSessionSnapshots(): void;
53
86
  /**
@@ -108,7 +141,74 @@ export declare function checkChain(rows: {
108
141
  id: number;
109
142
  next: number;
110
143
  }[]): ChainCheck;
144
+ /**
145
+ * Roll back, swallowing a failure of the rollback itself.
146
+ *
147
+ * Every caller is already returning a specific error -- the chain did not read
148
+ * back, the library is busy -- and a ROLLBACK that throws on the way out would
149
+ * replace that reason with its own, telling the user about a failed rollback
150
+ * instead of what actually went wrong. Nothing is lost by ignoring it:
151
+ * db.close() in the finally block ends any transaction still open, and SQLite
152
+ * discards an uncommitted one on close.
153
+ */
154
+ /** Exported for src/store/track-metadata.ts. */
155
+ export declare function rollback(db: DatabaseSync): void;
111
156
  export declare function sameOrder(a: OriginRef[], b: OriginRef[]): boolean;
157
+ /**
158
+ * Turns whatever node:sqlite throws into an EngineError. Shared by every op
159
+ * in this module -- the read-only pre-check and the write transaction below
160
+ * it both open a connection to the same file and can hit the same failure
161
+ * modes (the library gone missing mid-session, Engine holding the lock, a
162
+ * foreign or corrupt schema), and a caller whose promise is typed
163
+ * `Promise<... | EngineError>` must never see one of them escape as a
164
+ * rejection instead.
165
+ *
166
+ * `subject` is whatever this write is named for in its own messages -- a new
167
+ * playlist's title for createPlaylist, `playlist ${listId}` for an op that
168
+ * edits one that already exists.
169
+ *
170
+ * Every path that reaches this function is one where the library is
171
+ * unchanged: the transaction either never opened or is rolled back by the
172
+ * caller, and a failure at or after COMMIT is answered before this is ever
173
+ * called (see classifyWriteFailure, below). That is what lets the fallback
174
+ * below say "nothing was changed" without qualification -- it used to say
175
+ * `Writing "X" failed`, which reads as a half-write even when the failure was
176
+ * "file is not a database" and not one byte was attempted.
177
+ */
178
+ /** Exported for src/store/track-metadata.ts. */
179
+ export declare function mapWriteError(e: unknown, subject: string, mdbPath: string): EngineError;
180
+ /**
181
+ * Owns everything past the read-only pre-check that every write op in this
182
+ * module does identically: snapshot, open, `PRAGMA foreign_keys = ON`,
183
+ * `BEGIN IMMEDIATE`, commit, verify, and classify whatever went wrong.
184
+ * `body` does the one thing that differs between ops -- the INSERTs and
185
+ * UPDATEs specific to what this write is -- against the open `db` it is
186
+ * handed, and returns either the result to hand back (minus `backup_path`,
187
+ * which this function fills in once it knows COMMIT succeeded) or an
188
+ * `EngineError`. A body that has already written something rolls back before
189
+ * returning that error, but this function rolls back on that branch too
190
+ * rather than relying on it: `rollback` is a no-op when there is no
191
+ * transaction left to undo, and a body that forgot would otherwise leave the
192
+ * open transaction to `db.close()` -- correct today, and only by accident.
193
+ *
194
+ * `isEngineError`, not a second return channel, is what tells `body`'s
195
+ * success value apart from its failure one: this module already exports
196
+ * that check for callers, so reusing it here means a body never has to wrap
197
+ * its result to disambiguate the two.
198
+ */
199
+ /** Exported for src/store/track-metadata.ts. */
200
+ export declare function withWriteTransaction<T extends object>(mdbPath: string, uuid: string, subject: string, opts: {
201
+ backupDir: string;
202
+ /**
203
+ * Test seam only. Runs after the snapshot and before the write connection
204
+ * opens -- the window in which Engine DJ can still change a row this call
205
+ * already pre-checked. No production caller passes it.
206
+ */
207
+ beforeLock?: () => void;
208
+ }, body: (db: DatabaseSync) => T | EngineError): Promise<(T & {
209
+ library: LibraryRef;
210
+ backup_path: string;
211
+ }) | EngineError>;
112
212
  export declare function createPlaylist(mdbPath: string, uuid: string, input: {
113
213
  title: string;
114
214
  trackIds: number[];
@@ -1,7 +1,8 @@
1
1
  // src/store/write.ts
2
2
  //
3
- // The only code in this project that writes to a user's Engine library, and
4
- // it runs only when the server was started with --allow-writes.
3
+ // Writes to a user's Engine library, together with src/store/track-metadata.ts
4
+ // (which reuses withWriteTransaction, mapWriteError and rollback from here).
5
+ // Both run only when the server was started with --allow-writes.
5
6
  //
6
7
  // The read path is deliberately not reused. Queries run in a forked child
7
8
  // whose connection is opened readOnly: true, and that guarantee is the
@@ -14,6 +15,7 @@ import { DatabaseSync } from "node:sqlite";
14
15
  import { err, isEngineError, libraryNeedsRecovery } from "../errors.js";
15
16
  import { snapshotLibrary } from "./backup.js";
16
17
  import { hasHotJournal } from "./connections.js";
18
+ import { redactPath } from "../paths.js";
17
19
  /**
18
20
  * `detail` discriminator values for the EngineError this module returns.
19
21
  * Stable across releases so a caller can decide "is the library still what
@@ -28,7 +30,8 @@ import { hasHotJournal } from "./connections.js";
28
30
  *
29
31
  * These two strings are part of the tool's contract; see src/errors.ts.
30
32
  */
31
- const NOT_COMMITTED = "not_committed";
33
+ /** Exported for src/store/track-metadata*.ts; the string is part of the tool contract. */
34
+ export const NOT_COMMITTED = "not_committed";
32
35
  const COMMITTED_UNVERIFIED = "committed_unverified";
33
36
  /**
34
37
  * One snapshot per library per process, which is what "before the first write
@@ -265,7 +268,8 @@ function gateChain(db, listId) {
265
268
  * db.close() in the finally block ends any transaction still open, and SQLite
266
269
  * discards an uncommitted one on close.
267
270
  */
268
- function rollback(db) {
271
+ /** Exported for src/store/track-metadata.ts. */
272
+ export function rollback(db) {
269
273
  try {
270
274
  db.exec("ROLLBACK");
271
275
  }
@@ -297,7 +301,8 @@ export function sameOrder(a, b) {
297
301
  * `Writing "X" failed`, which reads as a half-write even when the failure was
298
302
  * "file is not a database" and not one byte was attempted.
299
303
  */
300
- function mapWriteError(e, subject, mdbPath) {
304
+ /** Exported for src/store/track-metadata.ts. */
305
+ export function mapWriteError(e, subject, mdbPath) {
301
306
  const msg = e.message ?? String(e);
302
307
  const isUniqueViolation = /UNIQUE constraint failed/i.test(msg);
303
308
  // The constraint's *name* never appears in the message SQLite raises --
@@ -408,7 +413,8 @@ function classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open)
408
413
  * that check for callers, so reusing it here means a body never has to wrap
409
414
  * its result to disambiguate the two.
410
415
  */
411
- async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
416
+ /** Exported for src/store/track-metadata.ts. */
417
+ export async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
412
418
  // Snapshot here, before the write connection is even opened, not after
413
419
  // BEGIN IMMEDIATE. Taking it with RESERVED held meant a full-database copy
414
420
  // -- tens of seconds on a multi-gigabyte USB library -- ran while every
@@ -430,6 +436,7 @@ async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
430
436
  if (typeof snapshot !== "string")
431
437
  return { ...snapshot, detail: NOT_COMMITTED };
432
438
  const backupPath = snapshot;
439
+ opts.beforeLock?.();
433
440
  let db;
434
441
  let open = false;
435
442
  let commit = "not yet";
@@ -449,7 +456,32 @@ async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
449
456
  const verifyErr = verifyAfterCommit(db, subject, backupPath);
450
457
  if (verifyErr)
451
458
  return verifyErr;
452
- return { ...result, backup_path: backupPath };
459
+ // Filled in here, alongside backup_path, for the same reason: it is the
460
+ // one place that knows the write succeeded, and doing it per-op would let
461
+ // a new op ship without it.
462
+ const library = { uuid, path: redactPath(mdbPath) };
463
+ // Every undo step carries the library too, and for a sharper reason than
464
+ // symmetry: a caller replaying an undo sends `undo.arguments` and nothing
465
+ // else, so without it the replay resolves whatever library is the default
466
+ // at replay time. A USB drive and its copy hold the same playlist ids and
467
+ // the same track ids, so such a replay lands on the wrong disk and
468
+ // expect_track_ids agrees -- both sides having been edited the same way.
469
+ //
470
+ // By path, not by uuid: a library and its clone on a second drive share a
471
+ // uuid (see store/backup.ts, which tags snapshots by path for exactly
472
+ // that), and a uuid could not say which of the two to undo in. A path
473
+ // names one. If that drive has since moved, the replay fails loudly with
474
+ // library_not_found rather than quietly editing the other copy.
475
+ //
476
+ // Injected here rather than in each op so a new op cannot ship without it.
477
+ const withUndo = result;
478
+ if (Array.isArray(withUndo.undo)) {
479
+ withUndo.undo = withUndo.undo.map((step) => ({
480
+ ...step,
481
+ arguments: { library: library.path, ...step.arguments },
482
+ }));
483
+ }
484
+ return { ...result, library, backup_path: backupPath };
453
485
  }
454
486
  catch (e) {
455
487
  return classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open);
@@ -1157,8 +1189,10 @@ export async function reorderPlaylist(mdbPath, uuid, input, opts) {
1157
1189
  return withWriteTransaction(mdbPath, uuid, subject, opts, (db) => {
1158
1190
  // Re-read: BEGIN IMMEDIATE is the first moment nothing else can change
1159
1191
  // the chain, and gating on the pre-check's read alone would be trusting
1160
- // one that could already be stale.
1161
- const gate = gateChain(db, listId);
1192
+ // one that could already be stale. Read once and kept: the links rewritten
1193
+ // below are exactly the ones this check passed, not a second read of them.
1194
+ const rows = readChain(db, listId);
1195
+ const gate = checkChain(rows);
1162
1196
  if (!gate.ok) {
1163
1197
  rollback(db);
1164
1198
  return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
@@ -1183,7 +1217,7 @@ export async function reorderPlaylist(mdbPath, uuid, input, opts) {
1183
1217
  // returns; each entry's new successor is the id that follows it there,
1184
1218
  // or 0 for the new tail.
1185
1219
  const newSeq = requestedOrder.map((p) => gate.order[p - 1]);
1186
- const currentNext = new Map(readChain(db, listId).map((r) => [r.id, r.next]));
1220
+ const currentNext = new Map(rows.map((r) => [r.id, r.next]));
1187
1221
  const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
1188
1222
  for (let i = 0; i < newSeq.length; i++) {
1189
1223
  const entryId = newSeq[i];