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