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/README.md +216 -44
- package/dist/errors.d.ts +15 -1
- package/dist/errors.js +14 -0
- package/dist/library-select.d.ts +33 -0
- package/dist/library-select.js +46 -1
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +20 -0
- package/dist/semantics.js +5 -0
- package/dist/server.js +178 -9
- package/dist/store/backup.d.ts +19 -1
- package/dist/store/backup.js +72 -4
- package/dist/store/track-metadata-plan.d.ts +82 -0
- package/dist/store/track-metadata-plan.js +236 -0
- package/dist/store/track-metadata.d.ts +22 -0
- package/dist/store/track-metadata.js +143 -0
- package/dist/store/write.d.ts +100 -0
- package/dist/store/write.js +44 -10
- package/dist/tools/audit.d.ts +29 -1
- package/dist/tools/audit.js +82 -3
- package/dist/tools/playlists.js +2 -11
- package/dist/tools/search.d.ts +7 -0
- package/dist/tools/search.js +35 -9
- package/dist/tools/tracks.js +2 -6
- package/dist/tools/write-track-metadata.d.ts +26 -0
- package/dist/tools/write-track-metadata.js +42 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/store/backup.d.ts
CHANGED
|
@@ -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
|
-
|
|
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>;
|
package/dist/store/backup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|