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.
package/dist/playlists.js CHANGED
@@ -353,6 +353,28 @@ function describe(items) {
353
353
  ? `${shown}; and ${items.length - NAMED_IN_ERROR} more (call get_playlists to see them all)`
354
354
  : shown;
355
355
  }
356
+ /**
357
+ * "No such playlist", for both branches resolvePlaylist can reach it from.
358
+ *
359
+ * The default `invalid_argument` code has no write-path contract on
360
+ * `detail` (see errors.ts), so the candidate listing lives there, exactly as
361
+ * before. `playlist_not_found` is different: a write tool's caller reads
362
+ * `detail` to learn whether the library changed (see errors.ts), and every
363
+ * other `playlist_not_found` -- raised inside store/write.ts once an id
364
+ * reaches it -- carries `detail: "not_committed"`. Nothing was attempted
365
+ * here either, so this must match, which leaves no room in `detail` for the
366
+ * candidate listing; it moves into `message` instead, so a human still sees
367
+ * it.
368
+ */
369
+ function notFoundError(names, reason, items) {
370
+ const candidates = items.length
371
+ ? `Playlists (id -- path): ${describe(items)}`
372
+ : "This library has no playlists.";
373
+ if (names.notFound) {
374
+ return err(names.notFound, `${reason}. ${candidates}`, { detail: "not_committed" });
375
+ }
376
+ return err("invalid_argument", reason, { detail: candidates });
377
+ }
356
378
  /**
357
379
  * Turns "id or name" into one specific playlist, or an actionable error.
358
380
  *
@@ -361,10 +383,14 @@ function describe(items) {
361
383
  * whole function exists to prevent. The error names every candidate with its
362
384
  * id and its full path, so the retry is a copy-paste rather than a guess.
363
385
  *
364
- * Deliberately reuses `invalid_argument` rather than adding an error code:
365
- * the taxonomy is closed (see errors.ts), and "the playlist you named is not
366
- * in this library" is a problem with the argument, reported the same way an
367
- * unknown field name is -- with the recognised values in `detail`.
386
+ * Reuses `invalid_argument` by default rather than adding an error code: the
387
+ * taxonomy is closed (see errors.ts), and for a reader "the playlist you
388
+ * named is not in this library" is a problem with the argument, reported the
389
+ * same way an unknown field name is -- with the recognised values in
390
+ * `detail`. A caller that already owns a more accurate code for that one
391
+ * case passes it as `names.notFound`; the write tools do, so their documented
392
+ * `playlist_not_found` is what a client actually sees. Ambiguity is never
393
+ * reported through it.
368
394
  */
369
395
  export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name: "playlist_name" }) {
370
396
  const hasId = sel.id !== undefined && sel.id !== null;
@@ -385,11 +411,7 @@ export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name
385
411
  if (hasId) {
386
412
  const found = tree.items.find((i) => i.id === sel.id);
387
413
  if (!found) {
388
- return err("invalid_argument", `No playlist with ${names.id} ${sel.id} in this library`, {
389
- detail: tree.items.length
390
- ? `Playlists (id -- path): ${describe(tree.items)}`
391
- : "This library has no playlists.",
392
- });
414
+ return notFoundError(names, `No playlist with ${names.id} ${sel.id} in this library`, tree.items);
393
415
  }
394
416
  return { playlist: found, warnings: tree.warnings };
395
417
  }
@@ -397,11 +419,7 @@ export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name
397
419
  if (matches.length === 1)
398
420
  return { playlist: matches[0], warnings: tree.warnings };
399
421
  if (matches.length === 0) {
400
- return err("invalid_argument", `No playlist named "${sel.name}" in this library`, {
401
- detail: tree.items.length
402
- ? `Playlists (id -- path): ${describe(tree.items)}`
403
- : "This library has no playlists.",
404
- });
422
+ return notFoundError(names, `No playlist named "${sel.name}" in this library`, tree.items);
405
423
  }
406
424
  return err("invalid_argument", `"${sel.name}" names ${matches.length} playlists in this library`, {
407
425
  detail: `Playlist names are unique only within a folder. Pass ${names.id}, or pass the full ` +
package/dist/semantics.js CHANGED
@@ -85,6 +85,11 @@ export function keyDistance(a, b) {
85
85
  export function registerFunctions(db, mdbPath) {
86
86
  const opts = { deterministic: true };
87
87
  db.function("camelot", opts, (key) => camelot(key === null ? null : Number(key)));
88
+ // A comparison key for text: one Unicode normalization form, lower-cased by
89
+ // Unicode rules. SQLite's own LOWER folds ASCII only -- LOWER('ЭЙФОРИЯ')
90
+ // comes back unchanged -- which made audit_library's duplicates find a
91
+ // capitalised Latin title and miss a capitalised Cyrillic one (#9).
92
+ db.function("fold", opts, (text) => text === null || text === undefined ? null : String(text).normalize("NFC").toLowerCase());
88
93
  db.function("key_name", opts, (key) => keyName(key === null ? null : Number(key)));
89
94
  db.function("tempo", opts, (a, b) => tempo(a === null ? null : Number(a), b === null ? null : Number(b)));
90
95
  db.function("key_distance", opts, (a, b) => a === null || b === null ? null : keyDistance(String(a), String(b)));
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, defaultLibraryTies, 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";
@@ -17,10 +17,24 @@ import { auditLibrary, AuditInput, AUDIT_CHECKS } from "./tools/audit.js";
17
17
  import { runSql, RunSqlInput } from "./tools/sql.js";
18
18
  import { listLibraries } from "./tools/libraries.js";
19
19
  import { refreshIndex } from "./tools/refresh.js";
20
- import { CreatePlaylistInput, runCreatePlaylist } from "./tools/write-playlist.js";
20
+ import { CreatePlaylistInput, runCreatePlaylist, AddTracksToPlaylistInput, runAddTracksToPlaylist, RemoveTracksFromPlaylistInput, runRemoveTracksFromPlaylist, ReorderPlaylistInput, runReorderPlaylist, } from "./tools/write-playlist.js";
21
21
  import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
22
22
  const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
23
+ /**
24
+ * A write that only ever *adds*. `destructiveHint: false` is a claim with a
25
+ * defined meaning in MCP -- "this tool performs only additive updates" -- and
26
+ * clients use it to decide whether to confirm with the user first. True of
27
+ * create_playlist (a new playlist, nothing else touched) and of
28
+ * add_tracks_to_playlist (new entries, existing ones left where they are).
29
+ */
23
30
  const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false };
31
+ /**
32
+ * A write that can destroy or reorganise what is already there:
33
+ * remove_tracks_from_playlist deletes entries, reorder_playlist rewrites the
34
+ * order of a list a DJ may be playing from live. Advertising either as
35
+ * additive told a client it need not ask before calling.
36
+ */
37
+ const RW_DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false };
24
38
  /**
25
39
  * name/version reported to every client on initialize. Read from
26
40
  * package.json rather than typed here, so the two cannot re-diverge the way
@@ -42,9 +56,34 @@ const PACKAGE_INFO = JSON.parse(readFileSync(new URL("../package.json", import.m
42
56
  * this repeats the essentials in the description because some clients show a
43
57
  * model the description and not the per-property schema documentation.
44
58
  */
59
+ /**
60
+ * Every write result names the library it landed in, and every undo is scoped
61
+ * to that one library. Engine DJ propagates a playlist change to another
62
+ * connected library by itself -- measured 2026-09-01: an edit made to the
63
+ * library on the computer appeared on the USB drive after Engine was next
64
+ * launched, the copy carrying the very timestamp this server's INSERT had
65
+ * written. An undo call cannot reach that copy, and reports success anyway,
66
+ * because within its own library it did exactly what it promised.
67
+ *
68
+ * Stated in the description, not just the README, because the caller who has
69
+ * to act on it is the model holding the undo.
70
+ */
71
+ const UNDO_SCOPE_NOTE = "`undo` reverses this edit in ONE library: the one the result's `library` field names. " +
72
+ "Engine DJ copies playlist changes between connected libraries on its own, so launching it " +
73
+ "with a second library attached can leave a copy of this edit there -- and no undo call " +
74
+ "reaches that copy. With two libraries connected (a USB drive and its copy on the computer " +
75
+ "is the usual case), undo separately against each. ";
45
76
  const LIBRARY_SELECTION_NOTE = "With more than one library connected, pass `library` (a uuid or path from list_libraries, " +
46
77
  "either the ~/... form or the absolute one) to choose which one; the default is the " +
47
78
  "supported library with the most tracks.";
79
+ /**
80
+ * Appended to the write tools only. A tie in the default rule is a refusal
81
+ * there and a free choice on the read side, so the shared note above cannot
82
+ * carry it without being wrong for one of the two.
83
+ */
84
+ const WRITE_LIBRARY_TIE_NOTE = " If two supported libraries hold the same most tracks -- what a USB drive and its copy on " +
85
+ "the computer produce -- this tool refuses with ambiguous_library rather than picking one, " +
86
+ "and lists both; nothing is written. Ask the user which one, then retry with `library` set -- do not pick for them, since one of the two may be the drive they perform from.";
48
87
  function reply(value) {
49
88
  return {
50
89
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
@@ -208,6 +247,47 @@ export async function createServer(opts = {}) {
208
247
  return state;
209
248
  return fresh;
210
249
  };
250
+ /**
251
+ * `acquire` for the write tools: identical, except that an omitted
252
+ * `library` must resolve to exactly one candidate.
253
+ *
254
+ * `pickDefaultLibrary` breaks a tie on root-scan order, which is
255
+ * deterministic and, for a read, fine -- libraries tie because one is a
256
+ * copy of the other, so either answer is very nearly the same answer, and
257
+ * making a read demand a `library` it does not care about would be noise.
258
+ *
259
+ * A write is not that. The choice decides which physical disk changes, and
260
+ * one of the two is the drive the DJ performs from; root-scan order is not
261
+ * a reason to pick it. Measured 2026-09-01: the computer's library and the
262
+ * USB drive both held 257 tracks, tied precisely because one was a copy of
263
+ * the other.
264
+ *
265
+ * Only the omitted case refuses. A caller who named a library gets it, tie
266
+ * or no tie -- the ambiguity being refused here is the server's, not theirs.
267
+ *
268
+ * Rescans first, because `knownList()` is a cache that deliberately keeps a
269
+ * library a later scan cannot see -- so a momentarily locked drive does not
270
+ * vanish from list_libraries. For a tie check that is wrong in the
271
+ * direction that bites: pull the USB drive and one library is left, but the
272
+ * cache still holds two, and the write is refused naming a drive that is no
273
+ * longer there. rescanLibraries() forgets a candidate whose path is gone,
274
+ * which is exactly the distinction wanted here, and it also lets a drive
275
+ * plugged in mid-session be seen at all.
276
+ *
277
+ * The cost is one filesystem probe per write, against a write that is about
278
+ * to copy the entire database for its pre-write snapshot. Reads are left
279
+ * alone: they run far more often and a stale pick between two copies is not
280
+ * worth a probe apiece.
281
+ */
282
+ const acquireForWrite = async (requested) => {
283
+ if (requested === undefined) {
284
+ rescanLibraries();
285
+ const tied = defaultLibraryTies(knownList());
286
+ if (tied.length > 1)
287
+ return ambiguousLibrary(tied);
288
+ }
289
+ return acquire(requested);
290
+ };
211
291
  /**
212
292
  * Shared by the engine://libraries resource and the list_libraries tool so
213
293
  * the two cannot drift in shape, while differing in exactly one respect:
@@ -357,6 +437,10 @@ export async function createServer(opts = {}) {
357
437
  title: "Audit the collection",
358
438
  description: `Run collection health checks. Available: ${AUDIT_CHECKS.join(", ")}. ` +
359
439
  `missing_files resolves each track against the selected library's own folder. ` +
440
+ `path_form_mismatch finds files that are there, but under a name in a different Unicode ` +
441
+ `normalization form from the stored path -- macOS opens them anyway, Linux does not ` +
442
+ `(measured on its exFAT driver), and Engine OS on a player is Linux, so these may fail ` +
443
+ `to load on hardware while missing_files on a Mac reports nothing. ` +
360
444
  `no_cues means "no hot cue is set" -- the quickCues blob is decoded for this, since ` +
361
445
  `Engine writes one to every analysed track whether or not a pad is used -- while ` +
362
446
  `no_beatgrid means the beatData blob is absent or empty. ` +
@@ -429,7 +513,13 @@ export async function createServer(opts = {}) {
429
513
  "write of this session; it is a recovery route for a damaged library, NOT an undo. " +
430
514
  "Restoring it reverts the entire library to that moment, discarding everything " +
431
515
  "Engine DJ has written since (play counts, imports, cue and beatgrid edits). " +
432
- "No existing playlist or entry is ever modified; this only adds a new one. " +
516
+ "The result's `library` field names which library this went into. Engine DJ copies " +
517
+ "playlist changes between connected libraries on its own (measured for an edit to an " +
518
+ "existing playlist), so with a second library attached the new playlist may appear " +
519
+ "there too. " +
520
+ "No existing playlist is renamed, reordered, emptied or deleted, and no track, cue or " +
521
+ "beatgrid is touched. The one existing row that moves is the previous last playlist's " +
522
+ "link, and Engine's own insert trigger is what moves it. " +
433
523
  "Fails with playlist_exists if a top-level playlist already has that title, and " +
434
524
  "with library_busy if Engine DJ or a player is holding a conflicting lock on the " +
435
525
  "library right then -- nothing is written in that case, so retry rather than " +
@@ -437,15 +527,115 @@ export async function createServer(opts = {}) {
437
527
  "library is unchanged and \"committed_unverified\" when the write may have gone " +
438
528
  "through but could not be verified. track_ids may be empty (an empty playlist); a " +
439
529
  "track id may appear at most once. " +
440
- LIBRARY_SELECTION_NOTE,
530
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
441
531
  inputSchema: { ...CreatePlaylistInput.shape, library: LibraryArg },
442
532
  annotations: RW,
443
533
  }, async (args) => {
444
- const state = await acquire(args.library);
534
+ const state = await acquireForWrite(args.library);
445
535
  if (isEngineError(state))
446
536
  return reply(state);
447
537
  return reply(await runCreatePlaylist(state.lib.path, state.lib.uuid, args, opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups")));
448
538
  });
539
+ const backupDirFor = () => opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups");
540
+ server.registerTool("add_tracks_to_playlist", {
541
+ title: "Add tracks to a playlist",
542
+ description: "Add one or more tracks to an EXISTING playlist -- this edits that playlist's " +
543
+ "contents, it does NOT create a new one (use create_playlist for that). Name the " +
544
+ "playlist with playlist_id or playlist_name, exactly one of the two, resolved the " +
545
+ "same way get_playlist_tracks does: a name matching more than one playlist in this " +
546
+ "library is refused, listing every candidate's id and full path, rather than guessed " +
547
+ "at. track_ids are ids from search_tracks or get_tracks; a track already in the " +
548
+ "playlist is refused as duplicate_track, since Engine allows a track in a playlist " +
549
+ "only once. at chooses where the new tracks land, using the playlist's current " +
550
+ "1-based positions (the same numbering get_playlist_tracks reports): \"start\", " +
551
+ "\"end\" (the default), or { after_position: n }. If this playlist's entry chain is " +
552
+ "already damaged, the write is refused outright rather than repaired -- nothing is " +
553
+ "added, and the error names what is broken. " +
554
+ "On success, the result's `undo` is the exact remove_tracks_from_playlist call that " +
555
+ "reverses this edit -- the positions the new tracks landed at, plus expect_track_ids " +
556
+ "naming the tracks that landed there, so a list someone changed in the meantime is " +
557
+ "refused rather than having the wrong rows removed -- and `undo_complete` is true. " +
558
+ "Call it to undo " +
559
+ "rather than restoring backup_path, which reverts the WHOLE library to before this " +
560
+ "session's first write, discarding every play count, import, cue and beatgrid change " +
561
+ "Engine DJ has recorded since -- not just this one edit. backup_path is only a " +
562
+ "last-resort recovery route for a damaged library, never an undo. " +
563
+ UNDO_SCOPE_NOTE +
564
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
565
+ inputSchema: { ...AddTracksToPlaylistInput.shape, library: LibraryArg },
566
+ annotations: RW,
567
+ }, async (args) => {
568
+ const state = await acquireForWrite(args.library);
569
+ if (isEngineError(state))
570
+ return reply(state);
571
+ return reply(await runAddTracksToPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
572
+ });
573
+ server.registerTool("remove_tracks_from_playlist", {
574
+ title: "Remove tracks from a playlist",
575
+ description: "Remove one or more tracks from an EXISTING playlist by position -- this edits that " +
576
+ "playlist's contents; it never touches any other playlist. Name the playlist with " +
577
+ "playlist_id or playlist_name, exactly one of the two, resolved the same way " +
578
+ "get_playlist_tracks does (an ambiguous name is refused with every candidate listed, " +
579
+ "not guessed at). positions are the 1-based positions get_playlist_tracks reports " +
580
+ "for THIS playlist right now -- they include entries whose track is missing from the " +
581
+ "library (get_playlist_tracks marks those missing: true), and removing one of those " +
582
+ "is a legitimate way to clean up a hole -- but it is the one removal that cannot be " +
583
+ "undone: such an entry names no track id, so no add_tracks_to_playlist call can put " +
584
+ "it back, and the result says so with undo_complete: false plus an undo_note naming " +
585
+ "those positions. expect_track_ids is optional and, when " +
586
+ "given, must have one entry per position: it verifies each named position still " +
587
+ "holds the track expected before anything is removed, refusing the whole call " +
588
+ "otherwise; null there means \"this position should hold an entry whose track is " +
589
+ "missing\", not \"no expectation\". If this playlist's entry chain is already " +
590
+ "damaged, the write is refused outright rather than repaired. " +
591
+ "On success, the result's `undo` is a SEQUENCE of add_tracks_to_playlist calls, one " +
592
+ "per removed track THAT CAN BE RESTORED -- run them IN THE ORDER GIVEN, never in " +
593
+ "parallel and never " +
594
+ "reversed: each step's target position is computed against the list as it stands " +
595
+ "after the previous step has already run, so firing them out of order or " +
596
+ "concurrently puts tracks back in the wrong places. Check `undo_complete`: false " +
597
+ "means one or more removed entries had no track to name and are gone for good -- " +
598
+ "`undo_note` says which positions, and the remaining steps still restore everything " +
599
+ "else. Preferred over restoring " +
600
+ "backup_path, which reverts the WHOLE library to before this session's first write, " +
601
+ "discarding everything Engine DJ has recorded since -- not just this edit. " +
602
+ UNDO_SCOPE_NOTE +
603
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
604
+ inputSchema: { ...RemoveTracksFromPlaylistInput.shape, library: LibraryArg },
605
+ annotations: RW_DESTRUCTIVE,
606
+ }, async (args) => {
607
+ const state = await acquireForWrite(args.library);
608
+ if (isEngineError(state))
609
+ return reply(state);
610
+ return reply(await runRemoveTracksFromPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
611
+ });
612
+ server.registerTool("reorder_playlist", {
613
+ title: "Reorder a playlist",
614
+ description: "Reorder an EXISTING playlist's tracks -- this changes the order of that playlist's " +
615
+ "existing entries; it adds nothing and removes nothing. Name the playlist with " +
616
+ "playlist_id or playlist_name, exactly one of the two, resolved the same way " +
617
+ "get_playlist_tracks does (an ambiguous name is refused with every candidate listed, " +
618
+ "not guessed at). order must be a full permutation of 1..n, n being the playlist's " +
619
+ "current entry count: order[i] names the CURRENT 1-based position (from " +
620
+ "get_playlist_tracks) of the track that should end up at position i + 1. A partial " +
621
+ "'move x to y' instruction is not accepted -- name every position, including ones " +
622
+ "that do not move. If this playlist's entry chain is already damaged, the write is " +
623
+ "refused outright rather than repaired. " +
624
+ "On success, the result's `undo` is the exact inverse permutation, as a single " +
625
+ "reorder_playlist call, and `undo_complete` is true; prefer it over restoring " +
626
+ "backup_path, which reverts the " +
627
+ "WHOLE library to before this session's first write, discarding everything Engine DJ " +
628
+ "has recorded since -- not just this reorder. " +
629
+ UNDO_SCOPE_NOTE +
630
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
631
+ inputSchema: { ...ReorderPlaylistInput.shape, library: LibraryArg },
632
+ annotations: RW_DESTRUCTIVE,
633
+ }, async (args) => {
634
+ const state = await acquireForWrite(args.library);
635
+ if (isEngineError(state))
636
+ return reply(state);
637
+ return reply(await runReorderPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
638
+ });
449
639
  }
450
640
  /**
451
641
  * There was previously no way to shut this down at all: createServer
@@ -517,6 +707,18 @@ other.
517
707
  - \`Track.path\` is relative to the \`Engine Library\` folder and usually
518
708
  contains \`..\`. The SQL function \`abs_path(path)\` resolves it against
519
709
  this library's location; the home prefix comes back folded to \`~\`.
710
+ - \`Track.streamingSource\`, \`uri\` and \`streamingFlags\` are reported to
711
+ decide whether Engine OS streams a track (from Dropbox) instead of reading
712
+ the file. Not measured here: on both reference libraries \`streamingSource\`
713
+ and \`uri\` are NULL on every track, and \`streamingFlags\` is 5 on about
714
+ half of them -- tracks that load and play -- so \`streamingFlags\` on its own
715
+ says nothing about whether a track will load. Selectable as the fields
716
+ \`streaming_source\`, \`streaming_flags\` and \`uri\`; \`uri\` is redacted like
717
+ \`path\`, including a home directory percent-encoded inside it.
718
+ - SQLite's \`LOWER()\` folds ASCII only: \`LOWER('ЭЙФОРИЯ')\` comes back
719
+ unchanged. To compare names regardless of case in any script, use
720
+ \`fold(text)\` -- one Unicode normalization form, lower-cased by Unicode
721
+ rules. It runs per row, like every function here.
520
722
  - A track's natural key across drives is \`(originDatabaseUuid, originTrackId)\`.
521
723
  - \`PerformanceData\`'s blob columns are binary and cannot be read with SQL.
522
724
  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 {