engine-dj-mcp 0.15.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,82 @@
1
+ import { type EngineError } from "../errors.js";
2
+ export type FieldName = "genre" | "comment" | "label" | "year" | "rating";
3
+ export declare const TEXT_FIELDS: readonly ["genre", "comment", "label"];
4
+ export type TextField = (typeof TEXT_FIELDS)[number];
5
+ export interface TrackExpect {
6
+ genre?: string;
7
+ comment?: string;
8
+ label?: string;
9
+ year?: number;
10
+ rating_raw?: number;
11
+ }
12
+ export interface TrackUpdate {
13
+ id: number;
14
+ genre?: string;
15
+ comment?: string;
16
+ label?: string;
17
+ year?: number;
18
+ rating_stars?: number;
19
+ rating_raw?: number;
20
+ expect?: TrackExpect;
21
+ }
22
+ export declare const MAX_UPDATES = 200;
23
+ export declare const MAX_TEXT = 1000;
24
+ /** The columns an update writes, in a fixed order. Both rating inputs write `rating`. */
25
+ export declare function writtenFields(u: TrackUpdate): FieldName[];
26
+ /**
27
+ * A refusal names every offender, not the first (spec §7.3): one vanished
28
+ * track must not cost two hundred round trips. Capped so the message stays
29
+ * readable.
30
+ */
31
+ export declare function listProblems(problems: string[]): string;
32
+ /**
33
+ * Rules that need no database. Ranges check NEW values only: a field written
34
+ * together with `expect` on the same field is a restore -- typically a replayed
35
+ * undo -- and must be able to put back whatever was there, however odd
36
+ * (spec §5.3). rating_raw is the exception that proves the rule: it exists only
37
+ * for restoring, so it always needs expect.rating_raw, and its 0-255 bound (the
38
+ * ID3 scale) always applies.
39
+ */
40
+ export declare function validateUpdates(updates: TrackUpdate[]): EngineError | undefined;
41
+ export type StoredValue = string | number | null;
42
+ /** One track as read from the library, already classified (see src/store/track-metadata.ts). */
43
+ export interface CurrentRow {
44
+ id: number;
45
+ genre: string | null;
46
+ comment: string | null;
47
+ label: string | null;
48
+ year: number | null;
49
+ rating: number | null;
50
+ /** Columns whose stored value this tool could not put back (spec §5.3). */
51
+ inexpressible: FieldName[];
52
+ /** The fix-origin trigger's own WHEN, evaluated by SQLite (spec §3.3). */
53
+ originEmpty: boolean;
54
+ }
55
+ export interface RowWrite {
56
+ id: number;
57
+ set: Partial<Record<FieldName, StoredValue>>;
58
+ fields: FieldName[];
59
+ }
60
+ export interface Plan {
61
+ writes: RowWrite[];
62
+ unchanged: number[];
63
+ undo: TrackUpdate[];
64
+ }
65
+ /** The value a field will hold once written. `""` is NULL; stars are Engine's 0-100. */
66
+ export declare function targetOf(u: TrackUpdate, f: FieldName): StoredValue;
67
+ /**
68
+ * Spec §5.1: NULL and '' are one empty for text, NULL and 0 for numbers.
69
+ * Otherwise exact -- no Unicode folding, because hiding a real difference is
70
+ * worse than reporting a confusing one (describeValue spells those out).
71
+ * Done in JS: in SQL, NULL = '' is neither true nor false.
72
+ */
73
+ export declare function sameStored(f: FieldName, a: StoredValue, b: StoredValue): boolean;
74
+ /**
75
+ * Decide, for rows already read, what each update writes. Order matters and
76
+ * follows spec §5.2 and §6: a row already at its target is unchanged before
77
+ * anything else is asked of it -- expect included, since expect guards writes
78
+ * and this row is not written. Refusals are collected across all rows and
79
+ * reported by class: unknown ids first, then tracks that cannot be edited,
80
+ * then stale expectations.
81
+ */
82
+ export declare function planUpdates(updates: TrackUpdate[], rows: ReadonlyMap<number, CurrentRow>): Plan | EngineError;
@@ -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
+ }
@@ -65,6 +65,22 @@ export interface OriginRef {
65
65
  uuid: string;
66
66
  trackId: number;
67
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";
68
84
  /** Test seam only: forget this process's snapshots so a test can start clean. */
69
85
  export declare function resetSessionSnapshots(): void;
70
86
  /**
@@ -125,7 +141,74 @@ export declare function checkChain(rows: {
125
141
  id: number;
126
142
  next: number;
127
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;
128
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>;
129
212
  export declare function createPlaylist(mdbPath: string, uuid: string, input: {
130
213
  title: string;
131
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
@@ -29,7 +30,8 @@ import { redactPath } from "../paths.js";
29
30
  *
30
31
  * These two strings are part of the tool's contract; see src/errors.ts.
31
32
  */
32
- 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";
33
35
  const COMMITTED_UNVERIFIED = "committed_unverified";
34
36
  /**
35
37
  * One snapshot per library per process, which is what "before the first write
@@ -266,7 +268,8 @@ function gateChain(db, listId) {
266
268
  * db.close() in the finally block ends any transaction still open, and SQLite
267
269
  * discards an uncommitted one on close.
268
270
  */
269
- function rollback(db) {
271
+ /** Exported for src/store/track-metadata.ts. */
272
+ export function rollback(db) {
270
273
  try {
271
274
  db.exec("ROLLBACK");
272
275
  }
@@ -298,7 +301,8 @@ export function sameOrder(a, b) {
298
301
  * `Writing "X" failed`, which reads as a half-write even when the failure was
299
302
  * "file is not a database" and not one byte was attempted.
300
303
  */
301
- function mapWriteError(e, subject, mdbPath) {
304
+ /** Exported for src/store/track-metadata.ts. */
305
+ export function mapWriteError(e, subject, mdbPath) {
302
306
  const msg = e.message ?? String(e);
303
307
  const isUniqueViolation = /UNIQUE constraint failed/i.test(msg);
304
308
  // The constraint's *name* never appears in the message SQLite raises --
@@ -409,7 +413,8 @@ function classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open)
409
413
  * that check for callers, so reusing it here means a body never has to wrap
410
414
  * its result to disambiguate the two.
411
415
  */
412
- 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) {
413
418
  // Snapshot here, before the write connection is even opened, not after
414
419
  // BEGIN IMMEDIATE. Taking it with RESERVED held meant a full-database copy
415
420
  // -- tens of seconds on a multi-gigabyte USB library -- ran while every
@@ -431,6 +436,7 @@ async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
431
436
  if (typeof snapshot !== "string")
432
437
  return { ...snapshot, detail: NOT_COMMITTED };
433
438
  const backupPath = snapshot;
439
+ opts.beforeLock?.();
434
440
  let db;
435
441
  let open = false;
436
442
  let commit = "not yet";
@@ -453,7 +459,29 @@ async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
453
459
  // Filled in here, alongside backup_path, for the same reason: it is the
454
460
  // one place that knows the write succeeded, and doing it per-op would let
455
461
  // a new op ship without it.
456
- return { ...result, library: { uuid, path: redactPath(mdbPath) }, backup_path: backupPath };
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 };
457
485
  }
458
486
  catch (e) {
459
487
  return classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open);