engine-dj-mcp 0.12.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { discoverLibraries, defaultRoots, probeLibraries } from "./discovery.js";
7
7
  import { libraryCandidates, libraryTag, sidecarDir } from "./paths.js";
8
- import { LibraryArg, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
8
+ import { LibraryArg, ambiguousLibrary, writeNeedsLibrary, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
9
9
  import { hasHotJournal } from "./store/connections.js";
10
10
  import { QueryProcess } from "./proc/query-client.js";
11
11
  import { IndexManager } from "./store/index-manager.js";
@@ -18,6 +18,7 @@ import { runSql, RunSqlInput } from "./tools/sql.js";
18
18
  import { listLibraries } from "./tools/libraries.js";
19
19
  import { refreshIndex } from "./tools/refresh.js";
20
20
  import { CreatePlaylistInput, runCreatePlaylist, AddTracksToPlaylistInput, runAddTracksToPlaylist, RemoveTracksFromPlaylistInput, runRemoveTracksFromPlaylist, ReorderPlaylistInput, runReorderPlaylist, } from "./tools/write-playlist.js";
21
+ import { UpdateTrackMetadataInput, runUpdateTrackMetadata } from "./tools/write-track-metadata.js";
21
22
  import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
22
23
  const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
23
24
  /**
@@ -35,6 +36,12 @@ const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false
35
36
  * additive told a client it need not ask before calling.
36
37
  */
37
38
  const RW_DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false };
39
+ /**
40
+ * A write that overwrites or clears what is there -- so destructive -- but
41
+ * that a repeat of the same call leaves alone: a track already holding the
42
+ * requested values is not written again. update_track_metadata.
43
+ */
44
+ const RW_OVERWRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true };
38
45
  /**
39
46
  * name/version reported to every client on initialize. Read from
40
47
  * package.json rather than typed here, so the two cannot re-diverge the way
@@ -56,9 +63,48 @@ const PACKAGE_INFO = JSON.parse(readFileSync(new URL("../package.json", import.m
56
63
  * this repeats the essentials in the description because some clients show a
57
64
  * model the description and not the per-property schema documentation.
58
65
  */
66
+ /**
67
+ * Every write result names the library it landed in, and every undo is scoped
68
+ * to that one library. Engine DJ propagates a playlist change to another
69
+ * connected library by itself -- measured 2026-09-01: an edit made to the
70
+ * library on the computer appeared on the USB drive after Engine was next
71
+ * launched, the copy carrying the very timestamp this server's INSERT had
72
+ * written. An undo call cannot reach that copy, and reports success anyway,
73
+ * because within its own library it did exactly what it promised.
74
+ *
75
+ * Stated in the description, not just the README, because the caller who has
76
+ * to act on it is the model holding the undo.
77
+ */
78
+ const UNDO_SCOPE_NOTE = "`undo` reverses this edit in ONE library: the one the result's `library` field names. " +
79
+ "Engine DJ copies playlist changes between connected libraries on its own, so launching it " +
80
+ "with a second library attached can leave a copy of this edit there -- and no undo call " +
81
+ "reaches that copy. With two libraries connected (a USB drive and its copy on the computer " +
82
+ "is the usual case), undo separately against each. ";
59
83
  const LIBRARY_SELECTION_NOTE = "With more than one library connected, pass `library` (a uuid or path from list_libraries, " +
60
84
  "either the ~/... form or the absolute one) to choose which one; the default is the " +
61
85
  "supported library with the most tracks.";
86
+ /**
87
+ * Appended to the write tools only. More than one library is a refusal there
88
+ * and a free choice on the read side, so the shared note above cannot carry it
89
+ * without being wrong for one of the two.
90
+ */
91
+ const WRITE_LIBRARY_NOTE = " With two or more supported libraries connected, this tool refuses with ambiguous_library " +
92
+ "rather than picking one, and lists them; nothing is written. That is so whatever their track " +
93
+ "counts are -- the count never said which disk should change. Ask the user which one, then " +
94
+ "retry with `library` set; do not pick for them, since one of them may be the drive they " +
95
+ "perform from.";
96
+ /**
97
+ * Not UNDO_SCOPE_NOTE: that one says Engine copies *playlist* changes between
98
+ * libraries, which was measured. For track tags, a fresh Engine launch was
99
+ * measured NOT copying a tag edit made on the USB library to the computer's
100
+ * library (spec §3.9); the other direction has not been measured for tags,
101
+ * so repeating the playlist claim here would state a guess as fact.
102
+ */
103
+ const TRACK_UNDO_NOTE = "`undo` reverses this edit in ONE library: the one the result's `library` field names, and each " +
104
+ "undo step carries it. It restores the values of the fields that changed; it does not restore " +
105
+ "lastEditTime, which Engine DJ's own trigger sets on every edit. Keep the undo from the first " +
106
+ "response: repeating a call that already succeeded finds nothing to change and returns an empty " +
107
+ "undo. For work spread over several calls, replay their undos in REVERSE order. ";
62
108
  function reply(value) {
63
109
  return {
64
110
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
@@ -222,6 +268,59 @@ export async function createServer(opts = {}) {
222
268
  return state;
223
269
  return fresh;
224
270
  };
271
+ /**
272
+ * `acquire` for the write tools: identical, except that an omitted
273
+ * `library` must resolve to exactly one candidate.
274
+ *
275
+ * `pickDefaultLibrary` breaks a tie on root-scan order, which is
276
+ * deterministic and, for a read, fine -- libraries tie because one is a
277
+ * copy of the other, so either answer is very nearly the same answer, and
278
+ * making a read demand a `library` it does not care about would be noise.
279
+ *
280
+ * A write is not that. The choice decides which physical disk changes, and
281
+ * one of the two is the drive the DJ performs from; root-scan order is not
282
+ * a reason to pick it. Measured 2026-09-01: the computer's library and the
283
+ * USB drive both held 257 tracks, tied precisely because one was a copy of
284
+ * the other.
285
+ *
286
+ * Only the omitted case refuses. A caller who named a library gets it, tie
287
+ * or no tie -- the ambiguity being refused here is the server's, not theirs.
288
+ *
289
+ * Rescans first, because `knownList()` is a cache that deliberately keeps a
290
+ * library a later scan cannot see -- so a momentarily locked drive does not
291
+ * vanish from list_libraries. For a tie check that is wrong in the
292
+ * direction that bites: pull the USB drive and one library is left, but the
293
+ * cache still holds two, and the write is refused naming a drive that is no
294
+ * longer there. rescanLibraries() forgets a candidate whose path is gone,
295
+ * which is exactly the distinction wanted here, and it also lets a drive
296
+ * plugged in mid-session be seen at all.
297
+ *
298
+ * The cost is one filesystem probe per write, against a write that is about
299
+ * to copy the entire database for its pre-write snapshot. Reads are left
300
+ * alone: they run far more often and a stale pick between two copies is not
301
+ * worth a probe apiece.
302
+ */
303
+ /**
304
+ * The library a write may land in, without touching its search index. A
305
+ * tool that addresses tracks by id needs no index, and building one right
306
+ * before a write only makes it stale the moment the write commits.
307
+ * acquireForWrite adds the index for the tools that resolve playlists.
308
+ */
309
+ const selectForWrite = (requested) => {
310
+ if (requested === undefined) {
311
+ rescanLibraries();
312
+ const choices = writeNeedsLibrary(knownList());
313
+ if (choices.length > 0)
314
+ return ambiguousLibrary(choices);
315
+ }
316
+ return selectLibrary(requested);
317
+ };
318
+ const acquireForWrite = async (requested) => {
319
+ const lib = selectForWrite(requested);
320
+ if (isEngineError(lib))
321
+ return lib;
322
+ return acquire(requested);
323
+ };
225
324
  /**
226
325
  * Shared by the engine://libraries resource and the list_libraries tool so
227
326
  * the two cannot drift in shape, while differing in exactly one respect:
@@ -371,6 +470,10 @@ export async function createServer(opts = {}) {
371
470
  title: "Audit the collection",
372
471
  description: `Run collection health checks. Available: ${AUDIT_CHECKS.join(", ")}. ` +
373
472
  `missing_files resolves each track against the selected library's own folder. ` +
473
+ `path_form_mismatch finds files that are there, but under a name in a different Unicode ` +
474
+ `normalization form from the stored path -- macOS opens them anyway, Linux does not ` +
475
+ `(measured on its exFAT driver), and Engine OS on a player is Linux, so these may fail ` +
476
+ `to load on hardware while missing_files on a Mac reports nothing. ` +
374
477
  `no_cues means "no hot cue is set" -- the quickCues blob is decoded for this, since ` +
375
478
  `Engine writes one to every analysed track whether or not a pad is used -- while ` +
376
479
  `no_beatgrid means the beatData blob is absent or empty. ` +
@@ -443,6 +546,10 @@ export async function createServer(opts = {}) {
443
546
  "write of this session; it is a recovery route for a damaged library, NOT an undo. " +
444
547
  "Restoring it reverts the entire library to that moment, discarding everything " +
445
548
  "Engine DJ has written since (play counts, imports, cue and beatgrid edits). " +
549
+ "The result's `library` field names which library this went into. Engine DJ copies " +
550
+ "playlist changes between connected libraries on its own (measured for an edit to an " +
551
+ "existing playlist), so with a second library attached the new playlist may appear " +
552
+ "there too. " +
446
553
  "No existing playlist is renamed, reordered, emptied or deleted, and no track, cue or " +
447
554
  "beatgrid is touched. The one existing row that moves is the previous last playlist's " +
448
555
  "link, and Engine's own insert trigger is what moves it. " +
@@ -453,11 +560,11 @@ export async function createServer(opts = {}) {
453
560
  "library is unchanged and \"committed_unverified\" when the write may have gone " +
454
561
  "through but could not be verified. track_ids may be empty (an empty playlist); a " +
455
562
  "track id may appear at most once. " +
456
- LIBRARY_SELECTION_NOTE,
563
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
457
564
  inputSchema: { ...CreatePlaylistInput.shape, library: LibraryArg },
458
565
  annotations: RW,
459
566
  }, async (args) => {
460
- const state = await acquire(args.library);
567
+ const state = await acquireForWrite(args.library);
461
568
  if (isEngineError(state))
462
569
  return reply(state);
463
570
  return reply(await runCreatePlaylist(state.lib.path, state.lib.uuid, args, opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups")));
@@ -486,11 +593,12 @@ export async function createServer(opts = {}) {
486
593
  "session's first write, discarding every play count, import, cue and beatgrid change " +
487
594
  "Engine DJ has recorded since -- not just this one edit. backup_path is only a " +
488
595
  "last-resort recovery route for a damaged library, never an undo. " +
489
- LIBRARY_SELECTION_NOTE,
596
+ UNDO_SCOPE_NOTE +
597
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
490
598
  inputSchema: { ...AddTracksToPlaylistInput.shape, library: LibraryArg },
491
599
  annotations: RW,
492
600
  }, async (args) => {
493
- const state = await acquire(args.library);
601
+ const state = await acquireForWrite(args.library);
494
602
  if (isEngineError(state))
495
603
  return reply(state);
496
604
  return reply(await runAddTracksToPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
@@ -524,11 +632,12 @@ export async function createServer(opts = {}) {
524
632
  "else. Preferred over restoring " +
525
633
  "backup_path, which reverts the WHOLE library to before this session's first write, " +
526
634
  "discarding everything Engine DJ has recorded since -- not just this edit. " +
527
- LIBRARY_SELECTION_NOTE,
635
+ UNDO_SCOPE_NOTE +
636
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
528
637
  inputSchema: { ...RemoveTracksFromPlaylistInput.shape, library: LibraryArg },
529
638
  annotations: RW_DESTRUCTIVE,
530
639
  }, async (args) => {
531
- const state = await acquire(args.library);
640
+ const state = await acquireForWrite(args.library);
532
641
  if (isEngineError(state))
533
642
  return reply(state);
534
643
  return reply(await runRemoveTracksFromPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
@@ -550,15 +659,51 @@ export async function createServer(opts = {}) {
550
659
  "backup_path, which reverts the " +
551
660
  "WHOLE library to before this session's first write, discarding everything Engine DJ " +
552
661
  "has recorded since -- not just this reorder. " +
553
- LIBRARY_SELECTION_NOTE,
662
+ UNDO_SCOPE_NOTE +
663
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
554
664
  inputSchema: { ...ReorderPlaylistInput.shape, library: LibraryArg },
555
665
  annotations: RW_DESTRUCTIVE,
556
666
  }, async (args) => {
557
- const state = await acquire(args.library);
667
+ const state = await acquireForWrite(args.library);
558
668
  if (isEngineError(state))
559
669
  return reply(state);
560
670
  return reply(await runReorderPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
561
671
  });
672
+ server.registerTool("update_track_metadata", {
673
+ title: "Edit track tags",
674
+ description: "Change genre, comment, label, year or rating on tracks in this Engine DJ library -- the " +
675
+ "values Engine shows in its columns. This WRITES to the library's database, not to the audio " +
676
+ "files' tags. Each entry names a track by id (from search_tracks or get_tracks) and only the " +
677
+ 'fields to change; "" clears a text field; rating_stars is 0-5 (Engine stores 0-100). Up to ' +
678
+ "200 tracks per call, all or nothing. A track already holding the requested values is left " +
679
+ "alone and counted in `unchanged`; `changed` lists which fields changed on which tracks. " +
680
+ "Each refusal names every offending track of its kind -- invalid_argument, unknown_track, " +
681
+ "track_not_editable (the track cannot be edited without harm -- say so to the user and leave it; " +
682
+ "do not search for it again), stale_value -- and kinds are reported one at a time, in that order: " +
683
+ "fix the one reported first and retry to see the next, if any -- except stale_value, which is not " +
684
+ "something to just retry: see below. stale_value means the track " +
685
+ "changed after the values in `expect` were read; tell the user which tracks and fields changed. " +
686
+ "Do NOT rebuild `expect` from a fresh read to force the write -- that would silently overwrite " +
687
+ "an edit the DJ made since, without their consent. " +
688
+ "Search results may keep showing the old values while Engine DJ holds the library open; " +
689
+ "refresh_index cannot help until Engine lets go. " +
690
+ TRACK_UNDO_NOTE +
691
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
692
+ inputSchema: { ...UpdateTrackMetadataInput.shape, library: LibraryArg },
693
+ annotations: RW_OVERWRITE,
694
+ }, async (args) => {
695
+ const lib = selectForWrite(args.library);
696
+ if (isEngineError(lib))
697
+ return reply(lib);
698
+ // ensureFresh is what refuses an unsupported schema for every other
699
+ // tool; this path skips it, so it must refuse here.
700
+ if (!lib.supported) {
701
+ return reply(err("unsupported_schema", `Schema ${lib.schema.join(".")} is not supported`, {
702
+ detail: "Supported versions are 3.0.0, 3.0.1 and 3.0.2",
703
+ }));
704
+ }
705
+ return reply(await runUpdateTrackMetadata(lib.path, lib.uuid, args, backupDirFor()));
706
+ });
562
707
  }
563
708
  /**
564
709
  * There was previously no way to shut this down at all: createServer
@@ -627,9 +772,33 @@ other.
627
772
  \`bpmAnalyzed\` to within 0.68, and Engine's own interface displays 102 for
628
773
  the track stored as 102). \`side.track_derived.tempo\` holds the resolved
629
774
  value and is indexed.
775
+ - \`Track.rating\` is 0, 20, 40, 60, 80 or 100 -- one step per star, measured
776
+ against Engine's own display. The \`rating\` field returns it as stored and
777
+ \`rating_stars\` returns 0..5; the \`rating\` **filter** takes stars. A value
778
+ from other software (ID3's POPM is 0..255) is kept exactly in \`rating\` and
779
+ rounded to the nearest star in \`rating_stars\`.
630
780
  - \`Track.path\` is relative to the \`Engine Library\` folder and usually
631
781
  contains \`..\`. The SQL function \`abs_path(path)\` resolves it against
632
782
  this library's location; the home prefix comes back folded to \`~\`.
783
+ - \`Track.streamingSource\`, \`uri\` and \`streamingFlags\` are reported to
784
+ decide whether Engine OS streams a track (from Dropbox) instead of reading
785
+ the file. Not measured here: on both reference libraries \`streamingSource\`
786
+ and \`uri\` are NULL on every track, and \`streamingFlags\` is 5 on about
787
+ half of them -- tracks that load and play -- so \`streamingFlags\` on its own
788
+ says nothing about whether a track will load. Selectable as the fields
789
+ \`streaming_source\`, \`streaming_flags\` and \`uri\`; \`uri\` is redacted like
790
+ \`path\`, including a home directory percent-encoded inside it.
791
+ - SQLite's \`LOWER()\` folds ASCII only: \`LOWER('ЭЙФОРИЯ')\` comes back
792
+ unchanged. To compare names regardless of case in any script, use
793
+ \`fold(text)\` -- one Unicode normalization form, lower-cased by Unicode
794
+ rules. It runs per row, like every function here.
795
+ - \`Track\` carries two Engine triggers. \`trigger_after_update_only_Track_timestamp\`
796
+ sets \`lastEditTime\` (epoch seconds) whenever genre, comment, label, year,
797
+ rating or a dozen other columns are updated -- even to the same value.
798
+ \`trigger_after_update_Track_fix_origin\` fires on ANY update and rewrites an
799
+ empty \`(originDatabaseUuid, originTrackId)\` to this library's uuid and the
800
+ track's own id; in SQLite \`'' = 0\` is false, so a TEXT '' originTrackId does
801
+ not count as empty.
633
802
  - A track's natural key across drives is \`(originDatabaseUuid, originTrackId)\`.
634
803
  - \`PerformanceData\`'s blob columns are binary and cannot be read with SQL.
635
804
  Engine writes \`quickCues\`, \`loops\`, \`beatData\` and
@@ -1,3 +1,4 @@
1
+ import { backup } from "node:sqlite";
1
2
  import { type EngineError } from "../errors.js";
2
3
  /**
3
4
  * The snapshots to delete, oldest first, from a directory listing.
@@ -23,4 +24,21 @@ import { type EngineError } from "../errors.js";
23
24
  * predate the tagged scheme, so they predate anything written since.
24
25
  */
25
26
  export declare function evictable(names: string[], uuid: string, tag: string): string[];
26
- export declare function snapshotLibrary(mdbPath: string, uuid: string, baseDir: string): Promise<string | EngineError>;
27
+ /**
28
+ * Partial copies in this library's namespace whose writer is gone.
29
+ *
30
+ * A snapshot is copied to `<final name>.partial-<pid>` and renamed into place
31
+ * only once backup() has resolved (see snapshotLibrary). A process killed
32
+ * mid-copy never gets to clean up after itself, so its partial stays behind:
33
+ * harmless to rotation, which counts only `.db` names, but a full-size file
34
+ * nobody will ever finish.
35
+ *
36
+ * The pid is what separates such an orphan from another server's copy that is
37
+ * being written right now -- two servers can snapshot one library at once --
38
+ * so only a pid with no live process behind it is reclaimed. `prefix` keeps it
39
+ * to this library, exactly as rotation is kept to it.
40
+ */
41
+ export declare function abandonedPartials(names: string[], prefix: string, isAlive?: (pid: number) => boolean): string[];
42
+ export declare function snapshotLibrary(mdbPath: string, uuid: string, baseDir: string, deps?: {
43
+ backup?: typeof backup;
44
+ }): Promise<string | EngineError>;
@@ -9,7 +9,7 @@
9
9
  // Engine Library folder -- the rule that no file is created there holds for
10
10
  // writes exactly as it did for reads.
11
11
  import { DatabaseSync, backup } from "node:sqlite";
12
- import { mkdirSync, readdirSync, rmSync } from "node:fs";
12
+ import { mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { err } from "../errors.js";
15
15
  import { libraryTag } from "../paths.js";
@@ -74,7 +74,49 @@ export function evictable(names, uuid, tag) {
74
74
  mine.sort((a, b) => (a.old !== b.old ? (a.old ? -1 : 1) : a.stamp < b.stamp ? -1 : a.stamp > b.stamp ? 1 : 0));
75
75
  return mine.slice(0, Math.max(0, mine.length - KEEP)).map((x) => x.name);
76
76
  }
77
- export async function snapshotLibrary(mdbPath, uuid, baseDir) {
77
+ /** Whether a process with this pid exists. EPERM means it does and is not ours to signal. */
78
+ function processAlive(pid) {
79
+ try {
80
+ process.kill(pid, 0);
81
+ return true;
82
+ }
83
+ catch (e) {
84
+ return e.code === "EPERM";
85
+ }
86
+ }
87
+ /**
88
+ * Partial copies in this library's namespace whose writer is gone.
89
+ *
90
+ * A snapshot is copied to `<final name>.partial-<pid>` and renamed into place
91
+ * only once backup() has resolved (see snapshotLibrary). A process killed
92
+ * mid-copy never gets to clean up after itself, so its partial stays behind:
93
+ * harmless to rotation, which counts only `.db` names, but a full-size file
94
+ * nobody will ever finish.
95
+ *
96
+ * The pid is what separates such an orphan from another server's copy that is
97
+ * being written right now -- two servers can snapshot one library at once --
98
+ * so only a pid with no live process behind it is reclaimed. `prefix` keeps it
99
+ * to this library, exactly as rotation is kept to it.
100
+ */
101
+ export function abandonedPartials(names, prefix, isAlive = processAlive) {
102
+ // With or without `-journal`: SQLite keeps a rollback journal beside the
103
+ // copy while backup() runs (measured -- it exists mid-copy and is gone once
104
+ // backup() resolves), so a process killed partway leaves both behind. They
105
+ // share the pid, so a live copy's journal is spared along with the copy.
106
+ const partial = /\.db\.partial-(\d+)(-journal)?$/;
107
+ return names.filter((name) => {
108
+ if (!name.startsWith(prefix))
109
+ return false;
110
+ const m = partial.exec(name);
111
+ return m !== null && !isAlive(Number(m[1]));
112
+ });
113
+ }
114
+ export async function snapshotLibrary(mdbPath, uuid, baseDir,
115
+ // Injectable so a test can make the copy fail after it has started writing
116
+ // -- the one failure that matters here, and one no real error reachable
117
+ // from a test produces (they all fail before the destination is touched).
118
+ deps = {}) {
119
+ const copy = deps.backup ?? backup;
78
120
  // node:sqlite stopped needing a flag in 22.13, which is where this
79
121
  // project's floor used to sit -- but backup() only arrived in 22.16. On
80
122
  // 22.13 through 22.15 the read path works perfectly and this one throws
@@ -82,11 +124,12 @@ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
82
124
  // run against the declared floor. `engines` now says 22.16, and npm only
83
125
  // enforces that under engine-strict, so the check is here too: a version
84
126
  // number a user can act on beats a TypeError from inside a dependency.
85
- if (typeof backup !== "function") {
127
+ if (typeof copy !== "function") {
86
128
  return err("library_unreadable", `This Node cannot snapshot a library before writing to it: node:sqlite gained backup() in ` +
87
129
  `22.16.0 and this is ${process.version}. Upgrade Node, or run without --allow-writes.`);
88
130
  }
89
131
  let src;
132
+ let partial;
90
133
  try {
91
134
  mkdirSync(baseDir, { recursive: true });
92
135
  src = new DatabaseSync(mdbPath, { readOnly: true });
@@ -98,16 +141,41 @@ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
98
141
  // the same tag server.ts's sidecarBaseFor uses to keep two such
99
142
  // libraries' indexes apart (see paths.ts).
100
143
  const prefix = `${uuid}-${libraryTag(mdbPath)}-`;
144
+ // Before copying, not after: a copy that died because the disk filled up
145
+ // left its partial behind, and this is exactly when that space is wanted.
146
+ for (const dead of abandonedPartials(readdirSync(baseDir), prefix)) {
147
+ rmSync(join(baseDir, dead), { force: true });
148
+ }
149
+ // Copied under a name rotation does not count and nobody would restore,
150
+ // then renamed into place only once backup() has resolved. Written
151
+ // straight to the final name, a copy that died partway was an incomplete
152
+ // database indistinguishable from a good one: rotation took it for the
153
+ // newest and evicted a real snapshot for it, and "restore the latest
154
+ // backup" would have restored it (#3). rename() within one directory is
155
+ // atomic, so the final name only ever holds a finished copy.
101
156
  const dest = join(baseDir, `${prefix}${stamp()}.db`);
102
- await backup(src, dest);
157
+ partial = `${dest}.partial-${process.pid}`;
158
+ await copy(src, partial);
103
159
  src.close();
104
160
  src = undefined;
161
+ renameSync(partial, dest);
162
+ partial = undefined;
105
163
  for (const stale of evictable(readdirSync(baseDir), uuid, libraryTag(mdbPath))) {
106
164
  rmSync(join(baseDir, stale), { force: true });
107
165
  }
108
166
  return dest;
109
167
  }
110
168
  catch (e) {
169
+ // Safe to delete, which the destination itself never was: this name
170
+ // carries this process's pid and a stamp whose counter never repeats
171
+ // within a process, so whatever is there was created by this call. The
172
+ // earlier objection to cleaning up on failure -- deleting a file at a path
173
+ // we may not have created -- does not apply to a path no one else can
174
+ // produce.
175
+ if (partial) {
176
+ rmSync(partial, { force: true });
177
+ rmSync(`${partial}-journal`, { force: true });
178
+ }
111
179
  return err("library_unreadable", `Could not snapshot ${mdbPath} before writing: ${String(e)}`);
112
180
  }
113
181
  finally {
@@ -0,0 +1,82 @@
1
+ import { type EngineError } from "../errors.js";
2
+ export type FieldName = "genre" | "comment" | "label" | "year" | "rating";
3
+ export declare const TEXT_FIELDS: readonly ["genre", "comment", "label"];
4
+ export type TextField = (typeof TEXT_FIELDS)[number];
5
+ export interface TrackExpect {
6
+ genre?: string;
7
+ comment?: string;
8
+ label?: string;
9
+ year?: number;
10
+ rating_raw?: number;
11
+ }
12
+ export interface TrackUpdate {
13
+ id: number;
14
+ genre?: string;
15
+ comment?: string;
16
+ label?: string;
17
+ year?: number;
18
+ rating_stars?: number;
19
+ rating_raw?: number;
20
+ expect?: TrackExpect;
21
+ }
22
+ export declare const MAX_UPDATES = 200;
23
+ export declare const MAX_TEXT = 1000;
24
+ /** The columns an update writes, in a fixed order. Both rating inputs write `rating`. */
25
+ export declare function writtenFields(u: TrackUpdate): FieldName[];
26
+ /**
27
+ * A refusal names every offender, not the first (spec §7.3): one vanished
28
+ * track must not cost two hundred round trips. Capped so the message stays
29
+ * readable.
30
+ */
31
+ export declare function listProblems(problems: string[]): string;
32
+ /**
33
+ * Rules that need no database. Ranges check NEW values only: a field written
34
+ * together with `expect` on the same field is a restore -- typically a replayed
35
+ * undo -- and must be able to put back whatever was there, however odd
36
+ * (spec §5.3). rating_raw is the exception that proves the rule: it exists only
37
+ * for restoring, so it always needs expect.rating_raw, and its 0-255 bound (the
38
+ * ID3 scale) always applies.
39
+ */
40
+ export declare function validateUpdates(updates: TrackUpdate[]): EngineError | undefined;
41
+ export type StoredValue = string | number | null;
42
+ /** One track as read from the library, already classified (see src/store/track-metadata.ts). */
43
+ export interface CurrentRow {
44
+ id: number;
45
+ genre: string | null;
46
+ comment: string | null;
47
+ label: string | null;
48
+ year: number | null;
49
+ rating: number | null;
50
+ /** Columns whose stored value this tool could not put back (spec §5.3). */
51
+ inexpressible: FieldName[];
52
+ /** The fix-origin trigger's own WHEN, evaluated by SQLite (spec §3.3). */
53
+ originEmpty: boolean;
54
+ }
55
+ export interface RowWrite {
56
+ id: number;
57
+ set: Partial<Record<FieldName, StoredValue>>;
58
+ fields: FieldName[];
59
+ }
60
+ export interface Plan {
61
+ writes: RowWrite[];
62
+ unchanged: number[];
63
+ undo: TrackUpdate[];
64
+ }
65
+ /** The value a field will hold once written. `""` is NULL; stars are Engine's 0-100. */
66
+ export declare function targetOf(u: TrackUpdate, f: FieldName): StoredValue;
67
+ /**
68
+ * Spec §5.1: NULL and '' are one empty for text, NULL and 0 for numbers.
69
+ * Otherwise exact -- no Unicode folding, because hiding a real difference is
70
+ * worse than reporting a confusing one (describeValue spells those out).
71
+ * Done in JS: in SQL, NULL = '' is neither true nor false.
72
+ */
73
+ export declare function sameStored(f: FieldName, a: StoredValue, b: StoredValue): boolean;
74
+ /**
75
+ * Decide, for rows already read, what each update writes. Order matters and
76
+ * follows spec §5.2 and §6: a row already at its target is unchanged before
77
+ * anything else is asked of it -- expect included, since expect guards writes
78
+ * and this row is not written. Refusals are collected across all rows and
79
+ * reported by class: unknown ids first, then tracks that cannot be edited,
80
+ * then stale expectations.
81
+ */
82
+ export declare function planUpdates(updates: TrackUpdate[], rows: ReadonlyMap<number, CurrentRow>): Plan | EngineError;