engine-dj-mcp 0.11.2 → 0.12.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.
@@ -11,7 +11,7 @@
11
11
  // verify, commit, check, close.
12
12
  import { existsSync } from "node:fs";
13
13
  import { DatabaseSync } from "node:sqlite";
14
- import { err, libraryNeedsRecovery } from "../errors.js";
14
+ import { err, isEngineError, libraryNeedsRecovery } from "../errors.js";
15
15
  import { snapshotLibrary } from "./backup.js";
16
16
  import { hasHotJournal } from "./connections.js";
17
17
  /**
@@ -51,7 +51,8 @@ export function resetSessionSnapshots() {
51
51
  /**
52
52
  * The snapshot for this library, taken once per process.
53
53
  *
54
- * Called before the write connection is even opened (see createPlaylist),
54
+ * Called before the write connection is even opened (see
55
+ * withWriteTransaction, which every write op in this module goes through),
55
56
  * so it never holds SQLite's RESERVED lock and never blocks a write Engine
56
57
  * DJ or a second concurrent call in this process is trying to make at the
57
58
  * same moment. The hot-journal check and the read-only pre-check have
@@ -101,6 +102,21 @@ function resolveOrigins(db, trackIds) {
101
102
  }
102
103
  return refs;
103
104
  }
105
+ /**
106
+ * One origin pair as a Map key, joined on NUL because a databaseUuid is free
107
+ * text: any printable separator is a character some uuid could itself
108
+ * contain, and a key collision here would refuse a track as duplicate_track
109
+ * when it is not in the playlist at all. `uuid` must never be null: template
110
+ * coercion turns `null` into the four characters "null", which would then
111
+ * collide with an entry whose databaseUuid genuinely is that literal
112
+ * string. A resolved track's uuid is never null -- resolveOrigins already
113
+ * refused one that is -- so the only null this module ever sees is a
114
+ * PlaylistEntity row's own `databaseUuid`, which the caller below skips
115
+ * rather than passing in here.
116
+ */
117
+ function pairKey(uuid, trackId) {
118
+ return `${uuid}\u0000${trackId}`;
119
+ }
104
120
  /**
105
121
  * Read the chain back starting from a row we know is the head, because we
106
122
  * inserted it first. Re-deriving the head as "the row nothing points at"
@@ -131,6 +147,114 @@ export function walkFrom(db, listId, headId) {
131
147
  }
132
148
  return out;
133
149
  }
150
+ /**
151
+ * One playlist's `PlaylistEntity` rows, in the shape `checkChain` wants: an
152
+ * entry id and the id it links to (0 for "links to nothing").
153
+ *
154
+ * Shared by every op that edits an *existing* playlist's entries --
155
+ * createPlaylist never calls this, because it builds a chain from nothing
156
+ * rather than reading one back.
157
+ */
158
+ export function readChain(db, listId) {
159
+ return db
160
+ .prepare("SELECT id, nextEntityId AS next FROM PlaylistEntity WHERE listId = ?")
161
+ .all(listId);
162
+ }
163
+ /**
164
+ * Whether one playlist's entry chain is sound enough to edit.
165
+ *
166
+ * Deliberately not `orderByChain` from src/playlists.ts. That function's
167
+ * contract is that it returns every node it was given, degrading a damaged
168
+ * chain to a warning, because a reader that silently returned 30 of 43
169
+ * entries would be worse than one that guesses an order and says so. An edit
170
+ * needs the opposite: a yes or no.
171
+ *
172
+ * Four conditions, and each is needed because different breakages trip
173
+ * different ones. Checking only that the walk covered every row is the trap:
174
+ * a list severed into two runs has two heads, and walking from both covers
175
+ * everything -- measured, on a chain broken on purpose, as "5 of 5" while the
176
+ * walk from the real head reached 2. Checking coverage without also checking
177
+ * that the walk *ended* is a second, subtler version of the same trap: a row
178
+ * whose next points back into an already-linked interior row (two
179
+ * predecessors, no row pointing at 0) can visit every row and still never
180
+ * terminate -- the walk stops only because it revisits a row it has already
181
+ * seen, not because it reached the end.
182
+ */
183
+ export function checkChain(rows) {
184
+ if (rows.length === 0)
185
+ return { ok: true, order: [] };
186
+ const byId = new Map(rows.map((r) => [r.id, r]));
187
+ for (const r of rows) {
188
+ if (r.next !== 0 && !byId.has(r.next)) {
189
+ return { ok: false, reason: `entry ${r.id} links to ${r.next}, which is not in this playlist`, order: [] };
190
+ }
191
+ }
192
+ // A self-link (next === id) still counts as something pointing at that row
193
+ // -- unlike orderByChain, which excludes it so the row remains visible as
194
+ // its own one-node run. Here that would instead make the row look like a
195
+ // *second* head next to the real one, turning a one-row cycle in the
196
+ // middle of an otherwise sound chain into a false "two heads" instead of
197
+ // the coverage miss it actually is.
198
+ const targets = new Set(rows.map((r) => r.next));
199
+ const heads = rows.filter((r) => !targets.has(r.id));
200
+ if (heads.length !== 1) {
201
+ return {
202
+ ok: false,
203
+ reason: `expected exactly one head (an entry nothing points at); found ${heads.length}`,
204
+ order: [],
205
+ };
206
+ }
207
+ const order = [];
208
+ const seen = new Set();
209
+ let cur = heads[0];
210
+ // Whether the walk stopped by reaching a row whose next is 0, as opposed
211
+ // to stopping because it looped back onto a row already visited. Coverage
212
+ // alone cannot tell these apart: a converging chain (row 3 pointing back
213
+ // into row 2, an already-visited interior row) walks every row before it
214
+ // repeats one, so `order.length === rows.length` is true for it too. This
215
+ // flag is what distinguishes ending from merely running out of new rows to
216
+ // visit.
217
+ let clean = false;
218
+ while (cur && !seen.has(cur.id)) {
219
+ seen.add(cur.id);
220
+ order.push(cur.id);
221
+ if (cur.next === 0) {
222
+ clean = true;
223
+ cur = undefined;
224
+ }
225
+ else {
226
+ cur = byId.get(cur.next);
227
+ }
228
+ }
229
+ // Checked before coverage, and independently of it: a converging chain can
230
+ // cover every row while still having no tail, so coverage passing must not
231
+ // stand in for this.
232
+ if (!clean) {
233
+ return {
234
+ ok: false,
235
+ reason: "the chain does not end -- it loops back into an entry already visited instead of reaching a terminating entry",
236
+ order: [],
237
+ };
238
+ }
239
+ if (order.length !== rows.length) {
240
+ return {
241
+ ok: false,
242
+ reason: `the chain reaches ${order.length} of ${rows.length} entries`,
243
+ order: [],
244
+ };
245
+ }
246
+ return { ok: true, order };
247
+ }
248
+ /**
249
+ * `checkChain(readChain(db, listId))` in one call. Every point in an edit
250
+ * that needs to know whether a playlist's entry chain is currently sound --
251
+ * the pre-check, the transaction right after BEGIN IMMEDIATE, and the
252
+ * post-write readback -- asks the same question of the same two functions,
253
+ * so it asks it through the same name.
254
+ */
255
+ function gateChain(db, listId) {
256
+ return checkChain(readChain(db, listId));
257
+ }
134
258
  /**
135
259
  * Roll back, swallowing a failure of the rollback itself.
136
260
  *
@@ -153,23 +277,27 @@ export function sameOrder(a, b) {
153
277
  return a.length === b.length && a.every((x, i) => x.uuid === b[i].uuid && x.trackId === b[i].trackId);
154
278
  }
155
279
  /**
156
- * Turns whatever node:sqlite throws into an EngineError. Shared between the
157
- * read-only pre-check and the write transaction below it: both open a
158
- * connection to the same file and can hit the same failure modes (the
159
- * library gone missing mid-session, Engine holding the lock, a foreign or
160
- * corrupt schema), and a caller whose promise is typed
161
- * `Promise<CreatePlaylistResult | EngineError>` must never see one of them
162
- * escape as a rejection instead.
280
+ * Turns whatever node:sqlite throws into an EngineError. Shared by every op
281
+ * in this module -- the read-only pre-check and the write transaction below
282
+ * it both open a connection to the same file and can hit the same failure
283
+ * modes (the library gone missing mid-session, Engine holding the lock, a
284
+ * foreign or corrupt schema), and a caller whose promise is typed
285
+ * `Promise<... | EngineError>` must never see one of them escape as a
286
+ * rejection instead.
287
+ *
288
+ * `subject` is whatever this write is named for in its own messages -- a new
289
+ * playlist's title for createPlaylist, `playlist ${listId}` for an op that
290
+ * edits one that already exists.
163
291
  *
164
292
  * Every path that reaches this function is one where the library is
165
293
  * unchanged: the transaction either never opened or is rolled back by the
166
294
  * caller, and a failure at or after COMMIT is answered before this is ever
167
- * called (see createPlaylist's catch). That is what lets the fallback below
168
- * say "nothing was changed" without qualification -- it used to say
295
+ * called (see classifyWriteFailure, below). That is what lets the fallback
296
+ * below say "nothing was changed" without qualification -- it used to say
169
297
  * `Writing "X" failed`, which reads as a half-write even when the failure was
170
298
  * "file is not a database" and not one byte was attempted.
171
299
  */
172
- function mapWriteError(e, title, mdbPath) {
300
+ function mapWriteError(e, subject, mdbPath) {
173
301
  const msg = e.message ?? String(e);
174
302
  const isUniqueViolation = /UNIQUE constraint failed/i.test(msg);
175
303
  // The constraint's *name* never appears in the message SQLite raises --
@@ -177,12 +305,12 @@ function mapWriteError(e, title, mdbPath) {
177
305
  // -- so the two conditions are checked independently rather than as one
178
306
  // pattern that happens to work only because title leads that index today.
179
307
  if (isUniqueViolation && /\bPlaylist\.title\b/.test(msg)) {
180
- return err("playlist_exists", `A playlist called "${title}" already exists in this library.`, {
308
+ return err("playlist_exists", `A playlist called "${subject}" already exists in this library.`, {
181
309
  detail: NOT_COMMITTED,
182
310
  });
183
311
  }
184
312
  if (isUniqueViolation && /\bPlaylistEntity\./.test(msg)) {
185
- return err("duplicate_track", `A track in "${title}" collided with an existing playlist entry; Engine allows a track in a playlist only once.`, { detail: NOT_COMMITTED });
313
+ return err("duplicate_track", `A track in "${subject}" collided with an existing playlist entry; Engine allows a track in a playlist only once.`, { detail: NOT_COMMITTED });
186
314
  }
187
315
  if (/SQLITE_BUSY|database is locked/i.test(msg)) {
188
316
  return err("library_busy", "The library is locked by Engine DJ or a player. Close it and try again.", {
@@ -200,10 +328,141 @@ function mapWriteError(e, title, mdbPath) {
200
328
  if (/unable to open database file/i.test(msg)) {
201
329
  return err("library_not_found", `No Engine library database at ${mdbPath}.`, { detail: NOT_COMMITTED });
202
330
  }
203
- return err("library_unreadable", `Could not write "${title}": ${msg}. Nothing was changed.`, {
331
+ return err("library_unreadable", `Could not write "${subject}": ${msg}. Nothing was changed.`, {
204
332
  detail: NOT_COMMITTED,
205
333
  });
206
334
  }
335
+ /**
336
+ * The check every write op in this module runs immediately after its own
337
+ * COMMIT, and the COMMITTED_UNVERIFIED error it produces when the database
338
+ * does not come back "ok". Shared because this step is identical for every
339
+ * op here -- only what ran before COMMIT differs.
340
+ *
341
+ * quick_check, not integrity_check: both walk every page -- the difference
342
+ * is that integrity_check additionally cross-checks every index against its
343
+ * table's actual content, and that cross-check is what dominates the cost on
344
+ * a library with hundreds of thousands of tracks. quick_check skips only
345
+ * that verification and still catches the on-disk structural damage (a
346
+ * malformed b-tree page, say) that a check running right after a write
347
+ * exists to catch.
348
+ *
349
+ * check?.quick_check, not check.quick_check: the pragma is documented to
350
+ * return at least one row, but a `.get()` that came back undefined here
351
+ * would raise a TypeError *after* a successful commit, and that lands in
352
+ * whatever catch block called this as an error about a library that has in
353
+ * fact already changed. Reading it as "not ok" says the same true thing
354
+ * without depending on the throw being classified correctly.
355
+ */
356
+ function verifyAfterCommit(db, subject, backupPath) {
357
+ const check = db.prepare("PRAGMA quick_check").get();
358
+ if (check?.quick_check === "ok")
359
+ return undefined;
360
+ return err("library_unreadable", `The database reports "${check?.quick_check ?? "no result"}" after writing "${subject}". A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
361
+ }
362
+ /**
363
+ * Classifies whatever the write transaction threw, using how far `commit`
364
+ * had gotten when it did. Shared across every op in this module: the
365
+ * three-way split below -- never reached COMMIT, SQLITE_BUSY on COMMIT
366
+ * itself, or past the point of no return -- does not depend on what the
367
+ * transaction was doing before it threw.
368
+ *
369
+ * A COMMIT that returned SQLITE_BUSY is the one in-flight failure SQLite
370
+ * defines precisely: the transaction stays open and nothing was written, so
371
+ * it is a plain retry, not an unverified write. Everything else that throws
372
+ * once COMMIT has started is past the point of no return: no ROLLBACK,
373
+ * because after a successful COMMIT there is no transaction left to roll
374
+ * back, and after a COMMIT that failed mid-flight there is no state this
375
+ * code can reason about well enough to undo by hand (the caller's `finally`
376
+ * block's db.close() ends anything still open). The honest answer there is
377
+ * that the write may have gone through, plus the path of the snapshot from
378
+ * before this session's first write, which is the only case where restoring
379
+ * one is ever the right next step.
380
+ */
381
+ function classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open) {
382
+ const busyOnCommit = commit === "maybe" && /SQLITE_BUSY|database is locked/i.test(e?.message ?? "");
383
+ if (commit === "not yet" || busyOnCommit) {
384
+ if (open && db)
385
+ rollback(db);
386
+ return mapWriteError(e, subject, mdbPath);
387
+ }
388
+ const msg = e?.message ?? String(e);
389
+ return err("library_unreadable", `Writing "${subject}" may have gone through: the library could not be verified afterwards (${msg}). ` +
390
+ `Check the library in Engine DJ. A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
391
+ }
392
+ /**
393
+ * Owns everything past the read-only pre-check that every write op in this
394
+ * module does identically: snapshot, open, `PRAGMA foreign_keys = ON`,
395
+ * `BEGIN IMMEDIATE`, commit, verify, and classify whatever went wrong.
396
+ * `body` does the one thing that differs between ops -- the INSERTs and
397
+ * UPDATEs specific to what this write is -- against the open `db` it is
398
+ * handed, and returns either the result to hand back (minus `backup_path`,
399
+ * which this function fills in once it knows COMMIT succeeded) or an
400
+ * `EngineError`. A body that has already written something rolls back before
401
+ * returning that error, but this function rolls back on that branch too
402
+ * rather than relying on it: `rollback` is a no-op when there is no
403
+ * transaction left to undo, and a body that forgot would otherwise leave the
404
+ * open transaction to `db.close()` -- correct today, and only by accident.
405
+ *
406
+ * `isEngineError`, not a second return channel, is what tells `body`'s
407
+ * success value apart from its failure one: this module already exports
408
+ * that check for callers, so reusing it here means a body never has to wrap
409
+ * its result to disambiguate the two.
410
+ */
411
+ async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
412
+ // Snapshot here, before the write connection is even opened, not after
413
+ // BEGIN IMMEDIATE. Taking it with RESERVED held meant a full-database copy
414
+ // -- tens of seconds on a multi-gigabyte USB library -- ran while every
415
+ // write Engine DJ attempted failed with SQLITE_BUSY, and a second
416
+ // concurrent call in this process (the MCP SDK dispatches concurrently)
417
+ // was told the library was locked by Engine DJ when it was this server
418
+ // holding the lock. Snapshotting before BEGIN IMMEDIATE used to mean a
419
+ // call that turned out to be busy spent a slot on every retry -- ten busy
420
+ // retries, ten full copies, evicting every genuine pre-write snapshot from
421
+ // backup.ts's KEEP window. The per-session memo (sessionSnapshot, above)
422
+ // is what makes moving it here safe: a session that only ever gets
423
+ // library_busy now leaves exactly one snapshot, not one per retry, so
424
+ // nothing is evicted. The hot-journal check and the read-only pre-check
425
+ // that ran before this was ever called still run first, so a call doomed
426
+ // by either of those still never copies anything.
427
+ const snapshot = await sessionSnapshot(mdbPath, uuid, opts.backupDir);
428
+ // snapshotLibrary sets no detail of its own (src/store/backup.ts); this is
429
+ // still a pre-commit failure, so the discriminator applies here too.
430
+ if (typeof snapshot !== "string")
431
+ return { ...snapshot, detail: NOT_COMMITTED };
432
+ const backupPath = snapshot;
433
+ let db;
434
+ let open = false;
435
+ let commit = "not yet";
436
+ try {
437
+ db = new DatabaseSync(mdbPath);
438
+ open = true;
439
+ db.exec("PRAGMA foreign_keys = ON");
440
+ db.exec("BEGIN IMMEDIATE");
441
+ const result = body(db);
442
+ if (isEngineError(result)) {
443
+ rollback(db);
444
+ return result;
445
+ }
446
+ commit = "maybe";
447
+ db.exec("COMMIT");
448
+ commit = "yes";
449
+ const verifyErr = verifyAfterCommit(db, subject, backupPath);
450
+ if (verifyErr)
451
+ return verifyErr;
452
+ return { ...result, backup_path: backupPath };
453
+ }
454
+ catch (e) {
455
+ return classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open);
456
+ }
457
+ finally {
458
+ try {
459
+ db?.close();
460
+ }
461
+ catch {
462
+ /* already closed */
463
+ }
464
+ }
465
+ }
207
466
  export async function createPlaylist(mdbPath, uuid, input, opts) {
208
467
  const title = input.title.trim();
209
468
  if (!title)
@@ -260,46 +519,8 @@ export async function createPlaylist(mdbPath, uuid, input, opts) {
260
519
  }
261
520
  if (!Array.isArray(refs))
262
521
  return refs;
263
- // Snapshot here, before the write connection is even opened, not after
264
- // BEGIN IMMEDIATE. Taking it with RESERVED held meant a full-database copy
265
- // -- tens of seconds on a multi-gigabyte USB library -- ran while every
266
- // write Engine DJ attempted failed with SQLITE_BUSY, and a second
267
- // concurrent create_playlist call in this process (the MCP SDK dispatches
268
- // concurrently) was told the library was locked by Engine DJ when it was
269
- // this server holding the lock. Snapshotting before BEGIN IMMEDIATE used
270
- // to mean a call that turned out to be busy spent a slot on every retry --
271
- // ten busy retries, ten full copies, evicting every genuine pre-write
272
- // snapshot from backup.ts's KEEP window. The per-session memo
273
- // (sessionSnapshot, above) is what makes moving it here safe: a session
274
- // that only ever gets library_busy now leaves exactly one snapshot, not
275
- // one per retry, so nothing is evicted. The hot-journal check and the
276
- // read-only pre-check above still run first, so a call doomed by either of
277
- // those still never copies anything.
278
- const snapshot = await sessionSnapshot(mdbPath, uuid, opts.backupDir);
279
- // snapshotLibrary sets no detail of its own (src/store/backup.ts); this is
280
- // still a pre-commit failure, so the discriminator applies here too.
281
- if (typeof snapshot !== "string")
282
- return { ...snapshot, detail: NOT_COMMITTED };
283
- const backupPath = snapshot;
284
- let db;
285
- let open = false;
286
- /**
287
- * "not yet" until COMMIT is reached; "maybe" for the moment COMMIT is in
288
- * flight; "yes" once it returned. Anything thrown while this is not "not
289
- * yet" may have left the playlist on disk -- COMMIT can fail at fsync with
290
- * SQLITE_IOERR or SQLITE_FULL after the pages are already there, and the
291
- * post-commit check below runs against a database that has definitely
292
- * changed. Reporting those as not_committed (which is what a single catch
293
- * calling mapWriteError did) inverts the one discriminator a client uses to
294
- * decide whether their library still is what it was, and drops the snapshot
295
- * path in exactly the case where it is the only way back.
296
- */
297
- let commit = "not yet";
298
- try {
299
- db = new DatabaseSync(mdbPath);
300
- open = true;
301
- db.exec("PRAGMA foreign_keys = ON");
302
- db.exec("BEGIN IMMEDIATE");
522
+ const trackRefs = refs;
523
+ return withWriteTransaction(mdbPath, uuid, title, opts, (db) => {
303
524
  // nextListId = 0 appends: Engine's own insert triggers move the tail
304
525
  // marker off the previous last row and point it at this one.
305
526
  const ins = db
@@ -316,7 +537,7 @@ export async function createPlaylist(mdbPath, uuid, input, opts) {
316
537
  VALUES (?, ?, ?, 0, 0)`);
317
538
  const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
318
539
  const ids = [];
319
- for (const ref of refs) {
540
+ for (const ref of trackRefs) {
320
541
  ids.push(Number(insEntity.run(listId, ref.trackId, ref.uuid).lastInsertRowid));
321
542
  }
322
543
  for (let i = 0; i + 1 < ids.length; i++)
@@ -333,7 +554,7 @@ export async function createPlaylist(mdbPath, uuid, input, opts) {
333
554
  // PlaylistEntity row left behind by some earlier deleted playlist would
334
555
  // fail every create_playlist call on that library forever, blaming this
335
556
  // write for damage that predates it.)
336
- if (ids.length > 0 && !sameOrder(walkFrom(db, listId, ids[0]), refs)) {
557
+ if (ids.length > 0 && !sameOrder(walkFrom(db, listId, ids[0]), trackRefs)) {
337
558
  rollback(db);
338
559
  return err("library_unreadable", `The entry chain for "${title}" did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
339
560
  }
@@ -342,56 +563,664 @@ export async function createPlaylist(mdbPath, uuid, input, opts) {
342
563
  // one per parent, and this insert always uses nextListId = 0, so more
343
564
  // than one tail is not a state this transaction can produce. Restating
344
565
  // that as a runtime check would be a tautology, not a safety net.
345
- commit = "maybe";
346
- db.exec("COMMIT");
347
- commit = "yes";
348
- // quick_check, not integrity_check: both walk every page -- the
349
- // difference is that integrity_check additionally cross-checks every
350
- // index against its table's actual content, and that cross-check is
351
- // what dominates the cost on a library with hundreds of thousands of
352
- // tracks. quick_check skips only that verification and still catches
353
- // the on-disk structural damage (a malformed b-tree page, say) that a
354
- // check running right after a write exists to catch.
566
+ return { playlist_id: listId, title, tracks_added: trackRefs.length };
567
+ });
568
+ }
569
+ /**
570
+ * Resolves `at` against a chain's *current* order into a 0-based insert
571
+ * index, or `invalid_position` if `after_position` names a position that
572
+ * order does not have.
573
+ *
574
+ * Called twice by addTracksToPlaylist, against two different reads of the
575
+ * same chain, deliberately: once in the pre-check, to fail fast, and again
576
+ * inside the transaction against the chain BEGIN IMMEDIATE just locked. The
577
+ * second call is the one that matters -- the pre-check's `order` can be
578
+ * stale by the time the lock is held, and reusing its answer instead of
579
+ * recomputing this one would mean a playlist that shrank in between turns a
580
+ * clean invalid_position refusal into `gate.order[insertAt - 1]` reading
581
+ * past the end of the array.
582
+ */
583
+ function resolveInsertAt(order, at, listId) {
584
+ if (at === "start")
585
+ return 0;
586
+ if (at === "end")
587
+ return order.length;
588
+ const p = at.after_position;
589
+ if (!Number.isInteger(p) || p < 1 || p > order.length) {
590
+ return err("invalid_position", `Playlist ${listId} has ${order.length} entries; after_position must be between 1 and ${order.length}.`, { detail: NOT_COMMITTED });
591
+ }
592
+ return p;
593
+ }
594
+ /**
595
+ * Adds one or more tracks to an existing playlist, at the start, the end, or
596
+ * after a named position in its current order.
597
+ *
598
+ * Shares its skeleton with createPlaylist: a read-only pre-check first (cheap
599
+ * enough to rule out the common failure modes without ever opening the
600
+ * library for writing), then withWriteTransaction. Where createPlaylist
601
+ * builds a chain from nothing, this extends one that already exists, so it
602
+ * also has to confirm that chain is sound before it touches it -- twice. The
603
+ * pre-check gates it once, both to fail fast (and skip the snapshot) for a
604
+ * playlist that cannot be edited at all, and because validating `at` needs
605
+ * to know how many entries the playlist currently has. The transaction gates
606
+ * it again after BEGIN IMMEDIATE, because that lock is the first moment
607
+ * nothing else can change the chain -- gating on the pre-check's read alone,
608
+ * or reusing the insert position it computed, would be trusting one that
609
+ * could already be stale.
610
+ */
611
+ export async function addTracksToPlaylist(mdbPath, uuid, input, opts) {
612
+ const { listId, trackIds, at } = input;
613
+ // Not a playlist title -- there isn't one here -- but the same role: what
614
+ // this write is named for in mapWriteError/verifyAfterCommit/
615
+ // classifyWriteFailure's shared messages.
616
+ const subject = `playlist ${listId}`;
617
+ if (trackIds.length === 0) {
618
+ return err("invalid_argument", "Name at least one track to add.", { detail: NOT_COMMITTED });
619
+ }
620
+ // See createPlaylist for why this has to be checked before anything else
621
+ // even tries to open the file.
622
+ if (hasHotJournal(mdbPath))
623
+ return { ...libraryNeedsRecovery(), detail: NOT_COMMITTED };
624
+ let refs;
625
+ {
626
+ let precheck;
627
+ try {
628
+ precheck = new DatabaseSync(mdbPath, { readOnly: true });
629
+ const exists = precheck.prepare("SELECT 1 FROM Playlist WHERE id = ?").get(listId);
630
+ if (!exists) {
631
+ return err("playlist_not_found", `No playlist with id ${listId} in this library.`, {
632
+ detail: NOT_COMMITTED,
633
+ });
634
+ }
635
+ const gate = gateChain(precheck, listId);
636
+ if (!gate.ok) {
637
+ return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
638
+ detail: NOT_COMMITTED,
639
+ });
640
+ }
641
+ refs = resolveOrigins(precheck, trackIds);
642
+ if (!Array.isArray(refs))
643
+ return refs;
644
+ // Compared as pairs, in the same direction the write goes: each
645
+ // existing entry's stored (databaseUuid, trackId) against the pairs
646
+ // the requested tracks resolve to -- which is exactly what
647
+ // UNIQUE (listId, databaseUuid, trackId) will compare when the INSERT
648
+ // runs. Its value is failing fast: before the snapshot is copied and
649
+ // before a write transaction opens, with a message naming the track,
650
+ // rather than a UNIQUE-constraint violation surfacing from inside a
651
+ // transaction and getting mapped back to the same code.
652
+ //
653
+ // The reverse direction -- resolving each entry back to a local track
654
+ // and asking whether the request names it -- looks equivalent and is
655
+ // strictly weaker, on two counts. First, it is not what gets compared:
656
+ // UNIQUE (listId, databaseUuid, trackId) compares the pair itself, so
657
+ // the pair form is exactly that comparison run early, while the
658
+ // reverse form goes through Track and answers a related but different
659
+ // question. Second, where two Track rows share one origin pair the
660
+ // reverse form has to pick one and can miss the other -- but Engine's
661
+ // own C_originDatabaseUuid_originTrackId UNIQUE constraint (see the
662
+ // ENTRY_TRACK_MATCH comment in playlists.ts) forbids a real library
663
+ // from ever holding that state; it is reachable at all only in this
664
+ // project's own fixture, whose generated schema omits that constraint
665
+ // (see tests/playlist-edit.test.ts). The pair form also drops one
666
+ // unindexed Track scan per existing entry from a path that runs before
667
+ // every add.
668
+ //
669
+ // Neither form can catch more than the constraint itself: an entry
670
+ // whose stored pair no longer names any local track -- what a library
671
+ // looks like right after a re-origination moved a track's origin on
672
+ // without updating the entries naming its old one -- is not a
673
+ // duplicate of anything, because nothing in the database still says
674
+ // that entry and the requested track are the same one. That is a
675
+ // property of the data, not a gap in the check.
676
+ const wanted = new Map();
677
+ for (let i = 0; i < refs.length; i++)
678
+ wanted.set(pairKey(refs[i].uuid, refs[i].trackId), trackIds[i]);
679
+ const existing = precheck
680
+ .prepare("SELECT trackId, databaseUuid FROM PlaylistEntity WHERE listId = ?")
681
+ .all(listId);
682
+ for (const e of existing) {
683
+ // A null databaseUuid names a malformed entry (see OrderedEntry in
684
+ // playlists.ts), never a real origin pair -- no resolved track can
685
+ // match it, since resolveOrigins already refuses a null uuid. Keying
686
+ // it would collide with pairKey's own null-coercion trap; skipping
687
+ // it is what the old `= NULL` comparison did for free, since that
688
+ // matches no row.
689
+ if (e.databaseUuid === null)
690
+ continue;
691
+ const clash = wanted.get(pairKey(e.databaseUuid, e.trackId));
692
+ if (clash !== undefined) {
693
+ return err("duplicate_track", `Track ${clash} is already in playlist ${listId}; Engine allows a track in a playlist only once.`, { detail: NOT_COMMITTED });
694
+ }
695
+ }
696
+ // Discarded once it has done its job: only the pass/fail matters here
697
+ // (see resolveInsertAt's own comment for why the number itself is not
698
+ // carried across the lock).
699
+ const insertAt = resolveInsertAt(gate.order, at, listId);
700
+ if (isEngineError(insertAt))
701
+ return insertAt;
702
+ }
703
+ catch (e) {
704
+ return mapWriteError(e, subject, mdbPath);
705
+ }
706
+ finally {
707
+ try {
708
+ precheck?.close();
709
+ }
710
+ catch {
711
+ /* never opened, or already closed */
712
+ }
713
+ }
714
+ }
715
+ const trackRefs = refs;
716
+ return withWriteTransaction(mdbPath, uuid, subject, opts, (db) => {
717
+ // The chain read in the pre-check is re-read here: BEGIN IMMEDIATE is
718
+ // the first moment nothing else can change it, and gating on a chain
719
+ // read before the lock would be gating on a stale one.
720
+ const gate = gateChain(db, listId);
721
+ if (!gate.ok) {
722
+ rollback(db);
723
+ return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
724
+ detail: NOT_COMMITTED,
725
+ });
726
+ }
727
+ // Re-resolved against this read of the chain, not the pre-check's: see
728
+ // resolveInsertAt's comment.
729
+ const insertAt = resolveInsertAt(gate.order, at, listId);
730
+ if (isEngineError(insertAt)) {
731
+ rollback(db);
732
+ return insertAt;
733
+ }
734
+ // What the final chain should read back as: the existing entries' track
735
+ // identities, with the requested tracks spliced in at the position this
736
+ // call resolved to. Built before the writes below, from a walk keyed by
737
+ // id rather than by row count, so the readback check afterwards does not
738
+ // itself depend on how the writes below number their new rows.
739
+ const existingRefs = gate.order.length > 0 ? walkFrom(db, listId, gate.order[0]) : [];
740
+ const expected = [...existingRefs.slice(0, insertAt), ...trackRefs, ...existingRefs.slice(insertAt)];
741
+ // Insert one row at a time, linking by the id each insert actually
742
+ // returned -- the same reason createPlaylist does: SQLite assigning
743
+ // AUTOINCREMENT in ORDER BY order is optimizer behaviour, not a promise.
744
+ const insEntity = db.prepare(`INSERT INTO PlaylistEntity (listId, trackId, databaseUuid, nextEntityId, membershipReference)
745
+ VALUES (?, ?, ?, 0, 0)`);
746
+ const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
747
+ const ids = trackRefs.map((ref) => Number(insEntity.run(listId, ref.trackId, ref.uuid).lastInsertRowid));
748
+ for (let i = 0; i + 1 < ids.length; i++)
749
+ link.run(ids[i + 1], ids[i]);
750
+ if (ids.length > 0) {
751
+ if (insertAt === 0) {
752
+ // Inserting at the start needs no other row touched, because nothing
753
+ // links to a head: the new run just becomes the head, ending in
754
+ // whatever was the old one (0 if the playlist was empty).
755
+ const oldHead = gate.order.length > 0 ? gate.order[0] : 0;
756
+ link.run(oldHead, ids[ids.length - 1]);
757
+ }
758
+ else {
759
+ // Otherwise the new run is spliced in after gate.order[insertAt - 1]:
760
+ // its link is repointed at the first new row, and the last new row
761
+ // takes the link the predecessor had (0 if it was the tail).
762
+ const predId = gate.order[insertAt - 1];
763
+ const predNext = insertAt < gate.order.length ? gate.order[insertAt] : 0;
764
+ link.run(ids[0], predId);
765
+ link.run(predNext, ids[ids.length - 1]);
766
+ }
767
+ }
768
+ // Re-gated, not just re-walked: sameOrder alone confirms the *values*
769
+ // survived the round trip in order, but a wrong implementation could in
770
+ // principle produce a chain that still walks to the right values from
771
+ // this head (e.g. a converging link elsewhere) while failing checkChain.
772
+ // Both are cheap; there is no reason to trust only one of them here.
773
+ const newHeadId = insertAt === 0 ? (ids[0] ?? gate.order[0] ?? 0) : gate.order[0];
774
+ const finalGate = gateChain(db, listId);
775
+ if (!finalGate.ok || !sameOrder(walkFrom(db, listId, newHeadId), expected)) {
776
+ rollback(db);
777
+ return err("library_unreadable", `The entry chain for playlist ${listId} did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
778
+ }
779
+ // No trigger maintains this: measured, changing PlaylistEntity leaves the
780
+ // parent Playlist row untouched. datetime('now'), not strftime('%s') --
781
+ // that is the Track convention, and Playlist.lastEditTime is TEXT.
782
+ db.prepare("UPDATE Playlist SET lastEditTime = datetime('now') WHERE id = ?").run(listId);
783
+ const positions = ids.map((_, i) => insertAt + 1 + i);
784
+ // expect_track_ids, not bare positions: this is the one moment the
785
+ // server knows exactly which tracks landed at those positions, and a
786
+ // playlist that changed between this edit and the undo would otherwise
787
+ // have the undo remove whatever now sits there -- silently, and with no
788
+ // way for the caller to notice. The ids are the caller's own local track
789
+ // ids, which is what remove_tracks_from_playlist compares against (it
790
+ // resolves each entry's stored origin pair back to a local track the
791
+ // same way). Stale list, refused undo; unchanged list, the undo runs.
792
+ return {
793
+ playlist_id: listId,
794
+ tracks_added: trackRefs.length,
795
+ positions,
796
+ undo: [
797
+ {
798
+ tool: "remove_tracks_from_playlist",
799
+ arguments: { playlist_id: listId, positions, expect_track_ids: trackIds },
800
+ },
801
+ ],
802
+ undo_complete: true,
803
+ };
804
+ });
805
+ }
806
+ /**
807
+ * The local `Track.id` that carries a given origin pair, or null if none
808
+ * does -- the reverse of resolveOrigins, going from a PlaylistEntity row's
809
+ * stored (databaseUuid, trackId) back to the local row a caller's
810
+ * expectTrackIds and the response's `removed[].track_id` are expressed in.
811
+ *
812
+ * Used only by resolveRemoval, which genuinely needs that direction: it has
813
+ * an entry and must say which local track it holds. addTracksToPlaylist's
814
+ * duplicate check used to go through here too and no longer does -- it
815
+ * compares origin pairs directly, which is both stronger and cheaper; see
816
+ * the comment on that check. Where two Track rows share one origin pair this
817
+ * function answers with whichever row the query returns first, which is
818
+ * exactly why the duplicate check must not be built on it.
819
+ */
820
+ function resolveLocalTrackId(db, uuid, trackId) {
821
+ const row = db
822
+ .prepare("SELECT id FROM Track WHERE originDatabaseUuid = ? AND originTrackId = ?")
823
+ .get(uuid, trackId);
824
+ return row ? row.id : null;
825
+ }
826
+ /**
827
+ * Validates `positions` against a chain's current order -- in range, no
828
+ * repeats -- and, when `expectTrackIds` is given, that each named position
829
+ * still holds the track a caller who read the list earlier believed it did
830
+ * (`null` there means "resolves to no local track", not "no expectation" --
831
+ * see resolveLocalTrackId, above -- so a non-null value at that slot is a
832
+ * mismatch, same as a wrong id would be at any other slot).
833
+ * Resolves each surviving position to the entry id to delete and the
834
+ * *local* track id it currently holds, translated from the stored origin
835
+ * pair the same way addTracksToPlaylist's duplicate-track check does (null
836
+ * if that pair no longer resolves to any local track).
837
+ *
838
+ * Called twice by removeTracksFromPlaylist, against two different reads of
839
+ * the same chain, for the same reason resolveInsertAt is: once in the
840
+ * pre-check, to fail fast, and again inside the transaction against the
841
+ * chain BEGIN IMMEDIATE just locked -- the pre-check's read can be stale by
842
+ * the time the lock is held.
843
+ */
844
+ function resolveRemoval(db, listId, order, positions, expectTrackIds) {
845
+ const seen = new Set();
846
+ for (const p of positions) {
847
+ if (!Number.isInteger(p) || p < 1 || p > order.length) {
848
+ return err("invalid_position", `Playlist ${listId} has ${order.length} entries; each position must be between 1 and ${order.length}.`, { detail: NOT_COMMITTED });
849
+ }
850
+ if (seen.has(p)) {
851
+ return err("invalid_position", `Position ${p} is named more than once.`, { detail: NOT_COMMITTED });
852
+ }
853
+ seen.add(p);
854
+ }
855
+ if (expectTrackIds && expectTrackIds.length !== positions.length) {
856
+ return err("invalid_position", `expectTrackIds has ${expectTrackIds.length} entries but positions has ${positions.length}.`, { detail: NOT_COMMITTED });
857
+ }
858
+ const entryAt = db.prepare("SELECT trackId, databaseUuid FROM PlaylistEntity WHERE id = ?");
859
+ const plan = [];
860
+ for (let i = 0; i < positions.length; i++) {
861
+ const position = positions[i];
862
+ const entryId = order[position - 1];
863
+ const row = entryAt.get(entryId);
864
+ const trackId = resolveLocalTrackId(db, row.databaseUuid, row.trackId);
865
+ if (expectTrackIds && expectTrackIds[i] !== trackId) {
866
+ return err("invalid_position", `Position ${position} in playlist ${listId} does not hold track ${expectTrackIds[i]}; refusing to remove the wrong track.`, { detail: NOT_COMMITTED });
867
+ }
868
+ plan.push({ position, entryId, trackId });
869
+ }
870
+ return plan;
871
+ }
872
+ /**
873
+ * Removes one or more tracks from an existing playlist by their current
874
+ * position.
875
+ *
876
+ * `trigger_before_delete_PlaylistEntity` (see gen-library.ts's copy of it,
877
+ * taken verbatim from a real 3.0.2 library) relinks each deleted row's
878
+ * predecessor onto its successor as SQLite processes the delete -- verified
879
+ * for a row removed at the head, the middle and the tail, and, by
880
+ * construction of the trigger itself, for a batch that removes several
881
+ * rows, adjacent or not, in one statement. So this function does no chain
882
+ * maintenance of its own; writing any would just be fighting Engine's own
883
+ * trigger. What it does own is checking that the trigger's job actually
884
+ * landed: its `WHEN OLD.trackId > 0` means a row with trackId <= 0 is
885
+ * deleted *without* relinking, leaving its predecessor pointing at a row
886
+ * that is now gone. No real library measured has such a row, but the
887
+ * post-delete check below is the only thing that would ever notice one --
888
+ * and it checks the surviving order against what was expected, not only
889
+ * that some sound chain is left, because "sound" and "right" are different
890
+ * questions and only the second one is what the caller asked for.
891
+ *
892
+ * Shares createPlaylist/addTracksToPlaylist's skeleton: a read-only
893
+ * pre-check first, then withWriteTransaction.
894
+ */
895
+ export async function removeTracksFromPlaylist(mdbPath, uuid, input, opts) {
896
+ const { listId, positions, expectTrackIds } = input;
897
+ const subject = `playlist ${listId}`;
898
+ if (positions.length === 0) {
899
+ return err("invalid_argument", "Name at least one position to remove.", { detail: NOT_COMMITTED });
900
+ }
901
+ // See createPlaylist for why this has to be checked before anything else
902
+ // even tries to open the file.
903
+ if (hasHotJournal(mdbPath))
904
+ return { ...libraryNeedsRecovery(), detail: NOT_COMMITTED };
905
+ {
906
+ let precheck;
907
+ try {
908
+ precheck = new DatabaseSync(mdbPath, { readOnly: true });
909
+ const exists = precheck.prepare("SELECT 1 FROM Playlist WHERE id = ?").get(listId);
910
+ if (!exists) {
911
+ return err("playlist_not_found", `No playlist with id ${listId} in this library.`, {
912
+ detail: NOT_COMMITTED,
913
+ });
914
+ }
915
+ const gate = gateChain(precheck, listId);
916
+ if (!gate.ok) {
917
+ return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
918
+ detail: NOT_COMMITTED,
919
+ });
920
+ }
921
+ const plan = resolveRemoval(precheck, listId, gate.order, positions, expectTrackIds);
922
+ if (isEngineError(plan))
923
+ return plan;
924
+ }
925
+ catch (e) {
926
+ return mapWriteError(e, subject, mdbPath);
927
+ }
928
+ finally {
929
+ try {
930
+ precheck?.close();
931
+ }
932
+ catch {
933
+ /* never opened, or already closed */
934
+ }
935
+ }
936
+ }
937
+ return withWriteTransaction(mdbPath, uuid, subject, opts, (db) => {
938
+ // Re-read: BEGIN IMMEDIATE is the first moment nothing else can change
939
+ // the chain, and gating on the pre-check's read alone would be trusting
940
+ // one that could already be stale.
941
+ const gate = gateChain(db, listId);
942
+ if (!gate.ok) {
943
+ rollback(db);
944
+ return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
945
+ detail: NOT_COMMITTED,
946
+ });
947
+ }
948
+ // Re-resolved against this read of the chain, not the pre-check's: see
949
+ // resolveRemoval's comment.
950
+ const plan = resolveRemoval(db, listId, gate.order, positions, expectTrackIds);
951
+ if (isEngineError(plan)) {
952
+ rollback(db);
953
+ return plan;
954
+ }
955
+ // What the chain must read back as once the deletes have landed: the
956
+ // entries this call did not name, in their original order. Built here,
957
+ // before the DELETE, from a walk of the chain BEGIN IMMEDIATE locked --
958
+ // the same reason addTracksToPlaylist and reorderPlaylist build theirs
959
+ // up front: a readback check derived from the writes it is meant to
960
+ // verify checks nothing.
961
+ const removedIndexes = new Set(plan.map((p) => p.position - 1));
962
+ const originalRefs = gate.order.length > 0 ? walkFrom(db, listId, gate.order[0]) : [];
963
+ const expected = originalRefs.filter((_, i) => !removedIndexes.has(i));
964
+ const survivors = gate.order.filter((_, i) => !removedIndexes.has(i));
965
+ // One statement, no chain maintenance -- see this function's own
966
+ // comment for why the trigger is trusted to relink around every row
967
+ // this deletes, including a batch of several at once.
968
+ const placeholders = plan.map(() => "?").join(", ");
969
+ db.prepare(`DELETE FROM PlaylistEntity WHERE id IN (${placeholders})`).run(...plan.map((p) => p.entryId));
970
+ // Gate *and* order, the same pairing add and reorder use, and spec §6
971
+ // asks for here specifically. The gate catches what the trigger's WHEN
972
+ // clause does not cover: a deleted row with trackId <= 0 leaves its
973
+ // predecessor pointing at a row that no longer exists. sameOrder catches
974
+ // the class the gate structurally cannot -- a chain that is still one
975
+ // sound run but holds the wrong entries, or the wrong number of them,
976
+ // which is what a delete that took the wrong row (or a trigger this code
977
+ // does not know about) leaves behind.
355
978
  //
356
- // check?.quick_check, not check.quick_check: the pragma is documented to
357
- // return at least one row, but a `.get()` that came back undefined here
358
- // would raise a TypeError *after* a successful commit, and that lands in
359
- // the catch below as an error about a library that has in fact already
360
- // changed. Reading it as "not ok" says the same true thing without
361
- // depending on the throw being classified correctly.
362
- const check = db.prepare("PRAGMA quick_check").get();
363
- if (check?.quick_check !== "ok") {
364
- return err("library_unreadable", `The database reports "${check?.quick_check ?? "no result"}" after writing "${title}". A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
365
- }
366
- return { playlist_id: listId, title, tracks_added: refs.length, backup_path: backupPath };
979
+ // library_unreadable, not playlist_chain_damaged, and deliberately the
980
+ // same code add and reorder use for their own post-check: the two mean
981
+ // different things to a client. playlist_chain_damaged means the chain
982
+ // was already broken before this edit and the edit refused to touch it;
983
+ // this one means the edit's own verification disagreed with what it
984
+ // wrote, so the transaction was rolled back. Both leave the library
985
+ // unchanged (detail: not_committed); only the second says the library
986
+ // did something this code cannot account for.
987
+ const newHeadId = survivors.length > 0 ? survivors[0] : 0;
988
+ const finalGate = gateChain(db, listId);
989
+ if (!finalGate.ok || !sameOrder(walkFrom(db, listId, newHeadId), expected)) {
990
+ rollback(db);
991
+ return err("library_unreadable", `The entry chain for playlist ${listId} did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
992
+ }
993
+ // No trigger maintains this: measured, changing PlaylistEntity leaves the
994
+ // parent Playlist row untouched. datetime('now'), not strftime('%s') --
995
+ // that is the Track convention, and Playlist.lastEditTime is TEXT.
996
+ db.prepare("UPDATE Playlist SET lastEditTime = datetime('now') WHERE id = ?").run(listId);
997
+ // Reported, and undone, in ascending position order rather than the
998
+ // order the caller named them in -- the undo below depends on it.
999
+ const removed = plan
1000
+ .slice()
1001
+ .sort((a, b) => a.position - b.position)
1002
+ .map((p) => ({ position: p.position, track_id: p.trackId }));
1003
+ // An entry whose stored origin pair matches no track in this library has
1004
+ // no track id to hand back, so no add_tracks_to_playlist call can
1005
+ // restore it: the DELETE above destroyed the only place that pair was
1006
+ // written down. Emitting a step for it anyway -- `track_ids: [null]` --
1007
+ // produced an undo its own tool's schema rejects, and the entry was gone
1008
+ // regardless. So no step is emitted for such a position, and the result
1009
+ // says so rather than implying a way back it does not have.
1010
+ const restorable = removed.filter((r) => r.track_id !== null);
1011
+ const lost = removed.filter((r) => r.track_id === null).map((r) => r.position);
1012
+ // Undo restores in that same ascending order, each step expressed
1013
+ // against the list as it will be *after* the previous step has run.
1014
+ // Restoring in ascending order means that immediately before the row
1015
+ // originally at position p is restored, every row originally before p
1016
+ // that *can* come back is present again -- either it was never removed,
1017
+ // or, being an earlier and already-restored entry, it is back in its
1018
+ // exact original spot -- and nothing originally at or after p has been
1019
+ // restored yet. So the number of rows preceding that slot at that moment
1020
+ // is p - 1 minus the rows originally before p that were removed and
1021
+ // cannot be restored. With nothing lost that is just `position - 1`
1022
+ // computed against the original position: removing positions 1 and 3
1023
+ // from a three-entry list restores 1 first (at "start") and then 3 (at
1024
+ // after_position: 2), reproducing the original order. Restoring 3 first
1025
+ // would compute that same after_position: 2 against a list from which 1
1026
+ // is *also* still missing -- a single surviving entry -- which is
1027
+ // already wrong (there is no position 2 to be after yet); ascending
1028
+ // order is what keeps every step's target position valid, not just
1029
+ // correct. Subtracting the lost rows is the same argument applied to a
1030
+ // list that will never get them back: a step that still counted them
1031
+ // would name a position the list does not reach, and be refused.
1032
+ const undo = restorable.map((r) => {
1033
+ const before = r.position - 1 - lost.filter((p) => p < r.position).length;
1034
+ return {
1035
+ tool: "add_tracks_to_playlist",
1036
+ arguments: {
1037
+ playlist_id: listId,
1038
+ track_ids: [r.track_id],
1039
+ at: before === 0 ? "start" : { after_position: before },
1040
+ },
1041
+ };
1042
+ });
1043
+ return {
1044
+ playlist_id: listId,
1045
+ tracks_removed: removed.length,
1046
+ removed,
1047
+ undo,
1048
+ undo_complete: lost.length === 0,
1049
+ ...(lost.length === 0
1050
+ ? {}
1051
+ : {
1052
+ undo_note: `Position${lost.length > 1 ? "s" : ""} ${lost.join(", ")} held an entry whose stored ` +
1053
+ `origin pair names no track in this library, so there is no track id to add back and ` +
1054
+ `no undo step can restore it. The other steps put everything else back; the only way ` +
1055
+ `back for ${lost.length > 1 ? "those entries" : "that entry"} is the snapshot at backup_path, ` +
1056
+ `which reverts the whole library.`,
1057
+ }),
1058
+ };
1059
+ });
1060
+ }
1061
+ /**
1062
+ * Validates `order` against a chain's current length: it must be a
1063
+ * permutation of `1..n` for `n = currentLength`, not a partial "move X to Y"
1064
+ * instruction. That is deliberate -- a full permutation is the only shape
1065
+ * that can catch a wrong length, a repeat, a zero, a negative or an
1066
+ * out-of-range value in one pass; a move instruction cannot name any of
1067
+ * those at all. Length is checked first and independently of range, so a
1068
+ * too-short or too-long `order` is reported as such rather than as an
1069
+ * in-range element failing to cover the tail.
1070
+ *
1071
+ * Called twice by reorderPlaylist, against two different reads of the same
1072
+ * chain, for the same reason resolveInsertAt and resolveRemoval are: once in
1073
+ * the pre-check, to fail fast, and again inside the transaction against the
1074
+ * chain BEGIN IMMEDIATE just locked -- the pre-check's read can be stale by
1075
+ * the time the lock is held.
1076
+ */
1077
+ function validatePermutation(order, currentLength, listId) {
1078
+ if (order.length !== currentLength) {
1079
+ return err("invalid_position", `Playlist ${listId} has ${currentLength} entries; order must name exactly that many positions.`, { detail: NOT_COMMITTED });
367
1080
  }
368
- catch (e) {
369
- // A COMMIT that returned SQLITE_BUSY is the one in-flight failure SQLite
370
- // defines precisely: the transaction stays open and nothing was written,
371
- // so it is a plain retry, not an unverified write.
372
- const busyOnCommit = commit === "maybe" && /SQLITE_BUSY|database is locked/i.test(e?.message ?? "");
373
- if (commit === "not yet" || busyOnCommit) {
374
- if (open && db)
375
- rollback(db);
376
- return mapWriteError(e, title, mdbPath);
1081
+ const seen = new Set();
1082
+ for (const p of order) {
1083
+ if (!Number.isInteger(p) || p < 1 || p > currentLength) {
1084
+ return err("invalid_position", `order must be a permutation of 1..${currentLength}; ${p} is out of range.`, { detail: NOT_COMMITTED });
377
1085
  }
378
- // Past the point of no return. No ROLLBACK: after a successful COMMIT
379
- // there is no transaction to roll back, and after a COMMIT that failed
380
- // mid-flight there is no state we can reason about well enough to undo
381
- // by hand -- db.close() in the finally block ends anything still open.
382
- // The honest answer is that the write may have gone through, plus the
383
- // path of the snapshot from before this session's first write, which is
384
- // the only case where restoring one is ever the right next step.
385
- const msg = e?.message ?? String(e);
386
- return err("library_unreadable", `Writing "${title}" may have gone through: the library could not be verified afterwards (${msg}). ` +
387
- `Check the library in Engine DJ. A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
1086
+ if (seen.has(p)) {
1087
+ return err("invalid_position", `order names position ${p} more than once.`, { detail: NOT_COMMITTED });
1088
+ }
1089
+ seen.add(p);
388
1090
  }
389
- finally {
1091
+ return undefined;
1092
+ }
1093
+ /**
1094
+ * Reorders an existing playlist's entries to a caller-given permutation of
1095
+ * its current order.
1096
+ *
1097
+ * Unlike add/remove, this rewrites links only -- no INSERT, no DELETE -- so
1098
+ * none of Engine's PlaylistEntity triggers fire and there is no trigger
1099
+ * behaviour to trust or verify here, only the links this function writes
1100
+ * itself. `order[i]` names the *current* 1-based position of the track that
1101
+ * should end up at position `i + 1` (see validatePermutation for why the
1102
+ * spec takes a full permutation rather than a move instruction). Only the
1103
+ * entries whose successor actually changes get an UPDATE -- measured on a
1104
+ * real library, moving an entry from the middle to the front took exactly
1105
+ * two updates, and the identity permutation writes no PlaylistEntity row at
1106
+ * all. It is still not a no-op: like every other op here it stamps
1107
+ * `Playlist.lastEditTime`, and the session's snapshot is copied before the
1108
+ * body ever runs, so the identity case costs a timestamp and (once per
1109
+ * session) a snapshot. Left that way deliberately -- "did this permutation
1110
+ * change anything" can only be answered honestly after BEGIN IMMEDIATE, by
1111
+ * which point the snapshot is already taken, and an edit that reports
1112
+ * success without touching lastEditTime would be the one op whose result
1113
+ * Engine cannot see.
1114
+ *
1115
+ * Shares createPlaylist/addTracksToPlaylist/removeTracksFromPlaylist's
1116
+ * skeleton: a read-only pre-check first, then withWriteTransaction.
1117
+ */
1118
+ export async function reorderPlaylist(mdbPath, uuid, input, opts) {
1119
+ const { listId, order: requestedOrder } = input;
1120
+ const subject = `playlist ${listId}`;
1121
+ // See createPlaylist for why this has to be checked before anything else
1122
+ // even tries to open the file.
1123
+ if (hasHotJournal(mdbPath))
1124
+ return { ...libraryNeedsRecovery(), detail: NOT_COMMITTED };
1125
+ {
1126
+ let precheck;
390
1127
  try {
391
- db?.close();
1128
+ precheck = new DatabaseSync(mdbPath, { readOnly: true });
1129
+ const exists = precheck.prepare("SELECT 1 FROM Playlist WHERE id = ?").get(listId);
1130
+ if (!exists) {
1131
+ return err("playlist_not_found", `No playlist with id ${listId} in this library.`, {
1132
+ detail: NOT_COMMITTED,
1133
+ });
1134
+ }
1135
+ const gate = gateChain(precheck, listId);
1136
+ if (!gate.ok) {
1137
+ return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
1138
+ detail: NOT_COMMITTED,
1139
+ });
1140
+ }
1141
+ const invalid = validatePermutation(requestedOrder, gate.order.length, listId);
1142
+ if (invalid)
1143
+ return invalid;
392
1144
  }
393
- catch {
394
- /* already closed */
1145
+ catch (e) {
1146
+ return mapWriteError(e, subject, mdbPath);
1147
+ }
1148
+ finally {
1149
+ try {
1150
+ precheck?.close();
1151
+ }
1152
+ catch {
1153
+ /* never opened, or already closed */
1154
+ }
395
1155
  }
396
1156
  }
1157
+ return withWriteTransaction(mdbPath, uuid, subject, opts, (db) => {
1158
+ // Re-read: BEGIN IMMEDIATE is the first moment nothing else can change
1159
+ // 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);
1162
+ if (!gate.ok) {
1163
+ rollback(db);
1164
+ return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
1165
+ detail: NOT_COMMITTED,
1166
+ });
1167
+ }
1168
+ // Re-validated against this read of the chain, not the pre-check's: see
1169
+ // validatePermutation's comment.
1170
+ const invalid = validatePermutation(requestedOrder, gate.order.length, listId);
1171
+ if (invalid) {
1172
+ rollback(db);
1173
+ return invalid;
1174
+ }
1175
+ // The entry-identity chain the final order must read back as, built from
1176
+ // this walk -- taken *before* any UPDATE below -- rather than re-queried
1177
+ // afterwards, the same reason addTracksToPlaylist builds `expected` up
1178
+ // front: the readback check must not depend on the writes it is meant to
1179
+ // verify.
1180
+ const originalRefs = gate.order.length > 0 ? walkFrom(db, listId, gate.order[0]) : [];
1181
+ const expected = requestedOrder.map((p) => originalRefs[p - 1]);
1182
+ // newSeq[i] is the entry id that must sit at position i + 1 once this
1183
+ // returns; each entry's new successor is the id that follows it there,
1184
+ // or 0 for the new tail.
1185
+ const newSeq = requestedOrder.map((p) => gate.order[p - 1]);
1186
+ const currentNext = new Map(readChain(db, listId).map((r) => [r.id, r.next]));
1187
+ const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
1188
+ for (let i = 0; i < newSeq.length; i++) {
1189
+ const entryId = newSeq[i];
1190
+ const newNext = i + 1 < newSeq.length ? newSeq[i + 1] : 0;
1191
+ // Only entries whose link actually changes are written -- this avoids
1192
+ // n redundant PlaylistEntity writes for an identity permutation. It
1193
+ // does not make the call itself a no-op: lastEditTime is still stamped
1194
+ // below, and the session's snapshot was already copied before this ran
1195
+ // (see the doc-comment above).
1196
+ if (currentNext.get(entryId) !== newNext)
1197
+ link.run(newNext, entryId);
1198
+ }
1199
+ // Re-gated, not just re-walked: see addTracksToPlaylist's comment on the
1200
+ // same pairing. gateChain confirms the structure is still one sound
1201
+ // chain; sameOrder confirms the values landed in the requested order.
1202
+ const newHeadId = newSeq.length > 0 ? newSeq[0] : 0;
1203
+ const finalGate = gateChain(db, listId);
1204
+ if (!finalGate.ok || (newSeq.length > 0 && !sameOrder(walkFrom(db, listId, newHeadId), expected))) {
1205
+ rollback(db);
1206
+ return err("library_unreadable", `The entry chain for playlist ${listId} did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
1207
+ }
1208
+ // No trigger maintains this: measured, changing PlaylistEntity leaves the
1209
+ // parent Playlist row untouched. datetime('now'), not strftime('%s') --
1210
+ // that is the Track convention, and Playlist.lastEditTime is TEXT.
1211
+ db.prepare("UPDATE Playlist SET lastEditTime = datetime('now') WHERE id = ?").run(listId);
1212
+ // The inverse permutation: if order[i] = p, then inverse[p - 1] = i + 1.
1213
+ // Applying it undoes this call exactly, because reorderPlaylist's own
1214
+ // effect is just "relabel positions by this permutation" -- composing a
1215
+ // permutation with its inverse is the identity.
1216
+ const inverse = new Array(requestedOrder.length);
1217
+ for (let i = 0; i < requestedOrder.length; i++) {
1218
+ inverse[requestedOrder[i] - 1] = i + 1;
1219
+ }
1220
+ return {
1221
+ playlist_id: listId,
1222
+ undo: [{ tool: "reorder_playlist", arguments: { playlist_id: listId, order: inverse } }],
1223
+ undo_complete: true,
1224
+ };
1225
+ });
397
1226
  }