engine-dj-mcp 0.9.2 → 0.11.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 +366 -134
- package/dist/blobs/index.d.ts +18 -12
- package/dist/blobs/index.js +24 -16
- package/dist/discovery.js +3 -2
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +8 -0
- package/dist/index.js +8 -2
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +14 -0
- package/dist/playlists.d.ts +240 -0
- package/dist/playlists.js +447 -0
- package/dist/server.d.ts +5 -2
- package/dist/server.js +138 -18
- package/dist/store/backup.d.ts +2 -0
- package/dist/store/backup.js +72 -0
- package/dist/store/write.d.ts +37 -0
- package/dist/store/write.js +397 -0
- package/dist/tools/audit.js +12 -1
- package/dist/tools/playlists.d.ts +59 -0
- package/dist/tools/playlists.js +206 -0
- package/dist/tools/search.d.ts +10 -0
- package/dist/tools/search.js +58 -0
- package/dist/tools/write-playlist.d.ts +11 -0
- package/dist/tools/write-playlist.js +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// src/store/write.ts
|
|
2
|
+
//
|
|
3
|
+
// The only code in this project that writes to a user's Engine library, and
|
|
4
|
+
// it runs only when the server was started with --allow-writes.
|
|
5
|
+
//
|
|
6
|
+
// The read path is deliberately not reused. Queries run in a forked child
|
|
7
|
+
// whose connection is opened readOnly: true, and that guarantee is the
|
|
8
|
+
// product's core promise -- teaching it to write would dissolve it for reads
|
|
9
|
+
// as well. Writes therefore get their own short-lived connection here:
|
|
10
|
+
// validate read-only, snapshot, open, take the write lock, one transaction,
|
|
11
|
+
// verify, commit, check, close.
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { DatabaseSync } from "node:sqlite";
|
|
14
|
+
import { err, libraryNeedsRecovery } from "../errors.js";
|
|
15
|
+
import { snapshotLibrary } from "./backup.js";
|
|
16
|
+
import { hasHotJournal } from "./connections.js";
|
|
17
|
+
/**
|
|
18
|
+
* `detail` discriminator values for the EngineError this module returns.
|
|
19
|
+
* Stable across releases so a caller can decide "is the library still what
|
|
20
|
+
* it was" without parsing message prose. Everything before COMMIT --
|
|
21
|
+
* including validation that never reaches the database at all -- collapses
|
|
22
|
+
* to the same NOT_COMMITTED answer. COMMITTED_UNVERIFIED is produced by
|
|
23
|
+
* exactly two things, and both mean "the playlist may be on disk": the
|
|
24
|
+
* post-commit check reporting anything but "ok", and anything thrown from
|
|
25
|
+
* the COMMIT itself onwards. It is also the only case that carries
|
|
26
|
+
* backup_path, because it is the only case where restoring from a snapshot
|
|
27
|
+
* is ever the right next step.
|
|
28
|
+
*
|
|
29
|
+
* These two strings are part of the tool's contract; see src/errors.ts.
|
|
30
|
+
*/
|
|
31
|
+
const NOT_COMMITTED = "not_committed";
|
|
32
|
+
const COMMITTED_UNVERIFIED = "committed_unverified";
|
|
33
|
+
/**
|
|
34
|
+
* One snapshot per library per process, which is what "before the first write
|
|
35
|
+
* of a session" means in the spec (§6.1) and in the README.
|
|
36
|
+
*
|
|
37
|
+
* Keyed by backup directory, library path *and* uuid so a test (or a second
|
|
38
|
+
* configured backup root) cannot silently reuse a snapshot that lives
|
|
39
|
+
* somewhere else -- and so a different library that lands at the same path
|
|
40
|
+
* (a second USB stick sharing a volume label, an m.db replaced in place)
|
|
41
|
+
* cannot hit another library's cached entry and hand back its snapshot as
|
|
42
|
+
* this session's way back. The value is only ever a snapshot that actually
|
|
43
|
+
* landed on disk; a failed snapshot is not cached, so the next write tries
|
|
44
|
+
* again.
|
|
45
|
+
*/
|
|
46
|
+
const sessionSnapshots = new Map();
|
|
47
|
+
/** Test seam only: forget this process's snapshots so a test can start clean. */
|
|
48
|
+
export function resetSessionSnapshots() {
|
|
49
|
+
sessionSnapshots.clear();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The snapshot for this library, taken once per process.
|
|
53
|
+
*
|
|
54
|
+
* Called before the write connection is even opened (see createPlaylist),
|
|
55
|
+
* so it never holds SQLite's RESERVED lock and never blocks a write Engine
|
|
56
|
+
* DJ or a second concurrent call in this process is trying to make at the
|
|
57
|
+
* same moment. The hot-journal check and the read-only pre-check have
|
|
58
|
+
* already run by the time this is called, so a library needing recovery or
|
|
59
|
+
* failing the title/track validation never spends a snapshot slot; a
|
|
60
|
+
* library that turns out to be busy still does, once, and the memo above is
|
|
61
|
+
* what keeps a session that only ever hits library_busy at exactly one
|
|
62
|
+
* snapshot rather than one per retry.
|
|
63
|
+
*/
|
|
64
|
+
async function sessionSnapshot(mdbPath, uuid, backupDir) {
|
|
65
|
+
const key = `${backupDir}\u0000${mdbPath}\u0000${uuid}`;
|
|
66
|
+
// existsSync, not a bare Map hit: a user who cleared ~/.engine-dj-mcp/backups
|
|
67
|
+
// mid-session must get a real snapshot back, not a path to a deleted file.
|
|
68
|
+
const cached = sessionSnapshots.get(key);
|
|
69
|
+
if (cached && existsSync(cached))
|
|
70
|
+
return cached;
|
|
71
|
+
const fresh = await snapshotLibrary(mdbPath, uuid, backupDir);
|
|
72
|
+
if (typeof fresh === "string")
|
|
73
|
+
sessionSnapshots.set(key, fresh);
|
|
74
|
+
return fresh;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Engine stores a playlist entry's track as the pair the track was *born*
|
|
78
|
+
* with, not as a local row id. On both libraries measured, originTrackId
|
|
79
|
+
* happens to equal id -- which is exactly why this translation has to be
|
|
80
|
+
* explicit and tested against re-originated rows: the naive version is
|
|
81
|
+
* invisible in normal use and wrong on any library that has travelled.
|
|
82
|
+
*/
|
|
83
|
+
function resolveOrigins(db, trackIds) {
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
for (const id of trackIds) {
|
|
86
|
+
if (seen.has(id)) {
|
|
87
|
+
return err("duplicate_track", `Track ${id} appears more than once; Engine allows a track in a playlist only once.`, { detail: NOT_COMMITTED });
|
|
88
|
+
}
|
|
89
|
+
seen.add(id);
|
|
90
|
+
}
|
|
91
|
+
const stmt = db.prepare("SELECT originDatabaseUuid AS uuid, originTrackId AS trackId FROM Track WHERE id = ?");
|
|
92
|
+
const refs = [];
|
|
93
|
+
for (const id of trackIds) {
|
|
94
|
+
const row = stmt.get(id);
|
|
95
|
+
// == null, not a falsy check: originTrackId = 0 or originDatabaseUuid =
|
|
96
|
+
// "" are real values a track can legitimately carry, not "not found".
|
|
97
|
+
if (!row || row.uuid == null || row.trackId == null) {
|
|
98
|
+
return err("unknown_track", `No track with id ${id} in this library.`, { detail: NOT_COMMITTED });
|
|
99
|
+
}
|
|
100
|
+
refs.push({ uuid: row.uuid, trackId: row.trackId });
|
|
101
|
+
}
|
|
102
|
+
return refs;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Read the chain back starting from a row we know is the head, because we
|
|
106
|
+
* inserted it first. Re-deriving the head as "the row nothing points at"
|
|
107
|
+
* would be the same assumption the write just made, so it could not catch a
|
|
108
|
+
* write that made it wrongly.
|
|
109
|
+
*
|
|
110
|
+
* This, together with `sameOrder`, confirms that the *links* survived the
|
|
111
|
+
* round trip in the order given -- it does not independently confirm the
|
|
112
|
+
* *values* are correct. The comparison target is `refs`, the same array
|
|
113
|
+
* `resolveOrigins` produced and the write consumed, so a `resolveOrigins`
|
|
114
|
+
* that resolved every id wrongly (e.g. to the local row id instead of the
|
|
115
|
+
* origin pair) would write wrong values, read the same wrong values back,
|
|
116
|
+
* and pass this check. Catching that class of bug is what the
|
|
117
|
+
* re-originated-track test is for, not this readback.
|
|
118
|
+
*/
|
|
119
|
+
export function walkFrom(db, listId, headId) {
|
|
120
|
+
const rows = db
|
|
121
|
+
.prepare("SELECT id, trackId, databaseUuid, nextEntityId FROM PlaylistEntity WHERE listId = ?")
|
|
122
|
+
.all(listId);
|
|
123
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
124
|
+
const out = [];
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
let cur = byId.get(headId);
|
|
127
|
+
while (cur && !seen.has(cur.id)) {
|
|
128
|
+
seen.add(cur.id);
|
|
129
|
+
out.push({ uuid: cur.databaseUuid, trackId: cur.trackId });
|
|
130
|
+
cur = byId.get(cur.nextEntityId);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
export function sameOrder(a, b) {
|
|
135
|
+
return a.length === b.length && a.every((x, i) => x.uuid === b[i].uuid && x.trackId === b[i].trackId);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Turns whatever node:sqlite throws into an EngineError. Shared between the
|
|
139
|
+
* read-only pre-check and the write transaction below it: both open a
|
|
140
|
+
* connection to the same file and can hit the same failure modes (the
|
|
141
|
+
* library gone missing mid-session, Engine holding the lock, a foreign or
|
|
142
|
+
* corrupt schema), and a caller whose promise is typed
|
|
143
|
+
* `Promise<CreatePlaylistResult | EngineError>` must never see one of them
|
|
144
|
+
* escape as a rejection instead.
|
|
145
|
+
*
|
|
146
|
+
* Every path that reaches this function is one where the library is
|
|
147
|
+
* unchanged: the transaction either never opened or is rolled back by the
|
|
148
|
+
* caller, and a failure at or after COMMIT is answered before this is ever
|
|
149
|
+
* called (see createPlaylist's catch). That is what lets the fallback below
|
|
150
|
+
* say "nothing was changed" without qualification -- it used to say
|
|
151
|
+
* `Writing "X" failed`, which reads as a half-write even when the failure was
|
|
152
|
+
* "file is not a database" and not one byte was attempted.
|
|
153
|
+
*/
|
|
154
|
+
function mapWriteError(e, title, mdbPath) {
|
|
155
|
+
const msg = e.message ?? String(e);
|
|
156
|
+
const isUniqueViolation = /UNIQUE constraint failed/i.test(msg);
|
|
157
|
+
// The constraint's *name* never appears in the message SQLite raises --
|
|
158
|
+
// only the column list does, e.g. "Playlist.title, Playlist.parentListId"
|
|
159
|
+
// -- so the two conditions are checked independently rather than as one
|
|
160
|
+
// pattern that happens to work only because title leads that index today.
|
|
161
|
+
if (isUniqueViolation && /\bPlaylist\.title\b/.test(msg)) {
|
|
162
|
+
return err("playlist_exists", `A playlist called "${title}" already exists in this library.`, {
|
|
163
|
+
detail: NOT_COMMITTED,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (isUniqueViolation && /\bPlaylistEntity\./.test(msg)) {
|
|
167
|
+
return err("duplicate_track", `A track in "${title}" collided with an existing playlist entry; Engine allows a track in a playlist only once.`, { detail: NOT_COMMITTED });
|
|
168
|
+
}
|
|
169
|
+
if (/SQLITE_BUSY|database is locked/i.test(msg)) {
|
|
170
|
+
return err("library_busy", "The library is locked by Engine DJ or a player. Close it and try again.", {
|
|
171
|
+
detail: NOT_COMMITTED,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (/readonly|attempt to write a readonly database/i.test(msg)) {
|
|
175
|
+
return err("library_unreadable", `The library at ${mdbPath} cannot be written to.`, { detail: NOT_COMMITTED });
|
|
176
|
+
}
|
|
177
|
+
// The volume can go away between discovery and this call -- a USB drive
|
|
178
|
+
// pulled mid-set is the live-performance version of this. node:sqlite's
|
|
179
|
+
// message for that is generic ("unable to open database file"), so this
|
|
180
|
+
// is matched by wording rather than an errno, the same tradeoff every
|
|
181
|
+
// other branch here makes.
|
|
182
|
+
if (/unable to open database file/i.test(msg)) {
|
|
183
|
+
return err("library_not_found", `No Engine library database at ${mdbPath}.`, { detail: NOT_COMMITTED });
|
|
184
|
+
}
|
|
185
|
+
return err("library_unreadable", `Could not write "${title}": ${msg}. Nothing was changed.`, {
|
|
186
|
+
detail: NOT_COMMITTED,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
export async function createPlaylist(mdbPath, uuid, input, opts) {
|
|
190
|
+
const title = input.title.trim();
|
|
191
|
+
if (!title)
|
|
192
|
+
return err("invalid_argument", "A playlist needs a non-empty title.", { detail: NOT_COMMITTED });
|
|
193
|
+
// A hot journal is a mandatory refusal reason (spec §6.2), and it has to be
|
|
194
|
+
// checked here rather than left to whatever opens the file first: SQLite
|
|
195
|
+
// refuses to open such a database *read-only* (rolling the journal forward
|
|
196
|
+
// is a write) with the raw "attempt to write a readonly database", which
|
|
197
|
+
// this module would otherwise map to library_unreadable -- the wrong code,
|
|
198
|
+
// and actively false, since the library can be written to perfectly well
|
|
199
|
+
// once Engine DJ has recovered it. Nor does the caller's acquire() cover
|
|
200
|
+
// it: IndexManager.ensureFresh reads the header change counter as raw
|
|
201
|
+
// bytes and returns "fresh" without opening the database at all, so a
|
|
202
|
+
// journal left behind after an earlier successful read reaches this
|
|
203
|
+
// function untouched.
|
|
204
|
+
if (hasHotJournal(mdbPath))
|
|
205
|
+
return { ...libraryNeedsRecovery(), detail: NOT_COMMITTED };
|
|
206
|
+
// Validate against a short-lived read-only connection before opening the
|
|
207
|
+
// library for writing at all. This pass only rules out the common case
|
|
208
|
+
// cheaply -- another writer can still create the same title (or, in
|
|
209
|
+
// principle, the same entry) between this check and the INSERT below, so
|
|
210
|
+
// the UNIQUE-constraint catch further down stays in place as the backstop
|
|
211
|
+
// for that race and must still report it correctly, not as a generic
|
|
212
|
+
// failure.
|
|
213
|
+
let refs;
|
|
214
|
+
{
|
|
215
|
+
// The constructor is inside the try, not just the statements after it:
|
|
216
|
+
// a missing file, an unmounted volume, or Engine holding the lock all
|
|
217
|
+
// fail right here, and this connection must report those exactly like
|
|
218
|
+
// the write connection below does rather than let them throw past
|
|
219
|
+
// createPlaylist's Promise<CreatePlaylistResult | EngineError> contract.
|
|
220
|
+
let precheck;
|
|
221
|
+
try {
|
|
222
|
+
precheck = new DatabaseSync(mdbPath, { readOnly: true });
|
|
223
|
+
const exists = precheck.prepare("SELECT 1 FROM Playlist WHERE title = ? AND parentListId = 0").get(title);
|
|
224
|
+
if (exists) {
|
|
225
|
+
return err("playlist_exists", `A playlist called "${title}" already exists in this library.`, {
|
|
226
|
+
detail: NOT_COMMITTED,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
refs = resolveOrigins(precheck, input.trackIds);
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
return mapWriteError(e, title, mdbPath);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
try {
|
|
236
|
+
precheck?.close();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
/* never opened, or already closed */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (!Array.isArray(refs))
|
|
244
|
+
return refs;
|
|
245
|
+
// Snapshot here, before the write connection is even opened, not after
|
|
246
|
+
// BEGIN IMMEDIATE. Taking it with RESERVED held meant a full-database copy
|
|
247
|
+
// -- tens of seconds on a multi-gigabyte USB library -- ran while every
|
|
248
|
+
// write Engine DJ attempted failed with SQLITE_BUSY, and a second
|
|
249
|
+
// concurrent create_playlist call in this process (the MCP SDK dispatches
|
|
250
|
+
// concurrently) was told the library was locked by Engine DJ when it was
|
|
251
|
+
// this server holding the lock. Snapshotting before BEGIN IMMEDIATE used
|
|
252
|
+
// to mean a call that turned out to be busy spent a slot on every retry --
|
|
253
|
+
// ten busy retries, ten full copies, evicting every genuine pre-write
|
|
254
|
+
// snapshot from backup.ts's KEEP window. The per-session memo
|
|
255
|
+
// (sessionSnapshot, above) is what makes moving it here safe: a session
|
|
256
|
+
// that only ever gets library_busy now leaves exactly one snapshot, not
|
|
257
|
+
// one per retry, so nothing is evicted. The hot-journal check and the
|
|
258
|
+
// read-only pre-check above still run first, so a call doomed by either of
|
|
259
|
+
// those still never copies anything.
|
|
260
|
+
const snapshot = await sessionSnapshot(mdbPath, uuid, opts.backupDir);
|
|
261
|
+
// snapshotLibrary sets no detail of its own (src/store/backup.ts); this is
|
|
262
|
+
// still a pre-commit failure, so the discriminator applies here too.
|
|
263
|
+
if (typeof snapshot !== "string")
|
|
264
|
+
return { ...snapshot, detail: NOT_COMMITTED };
|
|
265
|
+
const backupPath = snapshot;
|
|
266
|
+
let db;
|
|
267
|
+
let open = false;
|
|
268
|
+
/**
|
|
269
|
+
* "not yet" until COMMIT is reached; "maybe" for the moment COMMIT is in
|
|
270
|
+
* flight; "yes" once it returned. Anything thrown while this is not "not
|
|
271
|
+
* yet" may have left the playlist on disk -- COMMIT can fail at fsync with
|
|
272
|
+
* SQLITE_IOERR or SQLITE_FULL after the pages are already there, and the
|
|
273
|
+
* post-commit check below runs against a database that has definitely
|
|
274
|
+
* changed. Reporting those as not_committed (which is what a single catch
|
|
275
|
+
* calling mapWriteError did) inverts the one discriminator a client uses to
|
|
276
|
+
* decide whether their library still is what it was, and drops the snapshot
|
|
277
|
+
* path in exactly the case where it is the only way back.
|
|
278
|
+
*/
|
|
279
|
+
let commit = "not yet";
|
|
280
|
+
try {
|
|
281
|
+
db = new DatabaseSync(mdbPath);
|
|
282
|
+
open = true;
|
|
283
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
284
|
+
db.exec("BEGIN IMMEDIATE");
|
|
285
|
+
// nextListId = 0 appends: Engine's own insert triggers move the tail
|
|
286
|
+
// marker off the previous last row and point it at this one.
|
|
287
|
+
const ins = db
|
|
288
|
+
.prepare(`INSERT INTO Playlist (title, parentListId, isPersisted, nextListId, lastEditTime, isExplicitlyExported)
|
|
289
|
+
VALUES (?, 0, 1, 0, datetime('now'), 0)`)
|
|
290
|
+
.run(title);
|
|
291
|
+
const listId = Number(ins.lastInsertRowid);
|
|
292
|
+
// One row at a time, linked by the id the insert actually returned.
|
|
293
|
+
// A single INSERT ... SELECT ... ORDER BY would depend on SQLite
|
|
294
|
+
// assigning AUTOINCREMENT in sort order, which it does today and does not
|
|
295
|
+
// promise; the failure mode is a playlist with the right tracks in the
|
|
296
|
+
// wrong order, which looks like success.
|
|
297
|
+
const insEntity = db.prepare(`INSERT INTO PlaylistEntity (listId, trackId, databaseUuid, nextEntityId, membershipReference)
|
|
298
|
+
VALUES (?, ?, ?, 0, 0)`);
|
|
299
|
+
const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
|
|
300
|
+
const ids = [];
|
|
301
|
+
for (const ref of refs) {
|
|
302
|
+
ids.push(Number(insEntity.run(listId, ref.trackId, ref.uuid).lastInsertRowid));
|
|
303
|
+
}
|
|
304
|
+
for (let i = 0; i + 1 < ids.length; i++)
|
|
305
|
+
link.run(ids[i + 1], ids[i]);
|
|
306
|
+
// Spec §6.3's foreign-key gate, asserted about the rows *this transaction
|
|
307
|
+
// wrote* rather than about the table they went into.
|
|
308
|
+
//
|
|
309
|
+
// PlaylistEntity carries exactly one foreign key -- listId -> Playlist(id)
|
|
310
|
+
// -- so this query is that key, checked on our own rows. `PRAGMA
|
|
311
|
+
// foreign_key_check(PlaylistEntity)` looked equivalent and is not: it
|
|
312
|
+
// reports every orphan in the table, and one PlaylistEntity row left
|
|
313
|
+
// behind by a deleted playlist (measured: a pre-existing orphan comes back
|
|
314
|
+
// from the scoped pragma inside an unrelated transaction) would fail every
|
|
315
|
+
// create_playlist call on that library forever, accusing this write of
|
|
316
|
+
// damage that predates it in words the user cannot tell apart from a real
|
|
317
|
+
// violation. The cross-library debris these libraries do accumulate --
|
|
318
|
+
// entries naming a third library's (databaseUuid, trackId), see
|
|
319
|
+
// src/playlists.ts:52-58 -- is not a foreign key at all and no pragma
|
|
320
|
+
// scoping ever protected against it.
|
|
321
|
+
const orphan = db
|
|
322
|
+
.prepare("SELECT 1 FROM PlaylistEntity WHERE listId = ? AND listId NOT IN (SELECT id FROM Playlist)")
|
|
323
|
+
.get(listId);
|
|
324
|
+
if (orphan) {
|
|
325
|
+
db.exec("ROLLBACK");
|
|
326
|
+
return err("library_unreadable", `Writing "${title}" would have broken a foreign key; nothing was changed.`, {
|
|
327
|
+
detail: NOT_COMMITTED,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (ids.length > 0 && !sameOrder(walkFrom(db, listId, ids[0]), refs)) {
|
|
331
|
+
db.exec("ROLLBACK");
|
|
332
|
+
return err("library_unreadable", `The entry chain for "${title}" did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
|
|
333
|
+
}
|
|
334
|
+
// No check that exactly one Playlist row has nextListId = 0: the schema's
|
|
335
|
+
// own C_NEXT_LIST_ID_UNIQUE_FOR_PARENT constraint already permits at most
|
|
336
|
+
// one per parent, and this insert always uses nextListId = 0, so more
|
|
337
|
+
// than one tail is not a state this transaction can produce. Restating
|
|
338
|
+
// that as a runtime check would be a tautology, not a safety net.
|
|
339
|
+
commit = "maybe";
|
|
340
|
+
db.exec("COMMIT");
|
|
341
|
+
commit = "yes";
|
|
342
|
+
// quick_check, not integrity_check: both walk every page -- the
|
|
343
|
+
// difference is that integrity_check additionally cross-checks every
|
|
344
|
+
// index against its table's actual content, and that cross-check is
|
|
345
|
+
// what dominates the cost on a library with hundreds of thousands of
|
|
346
|
+
// tracks. quick_check skips only that verification and still catches
|
|
347
|
+
// the on-disk structural damage (a malformed b-tree page, say) that a
|
|
348
|
+
// check running right after a write exists to catch.
|
|
349
|
+
//
|
|
350
|
+
// check?.quick_check, not check.quick_check: the pragma is documented to
|
|
351
|
+
// return at least one row, but a `.get()` that came back undefined here
|
|
352
|
+
// would raise a TypeError *after* a successful commit, and that lands in
|
|
353
|
+
// the catch below as an error about a library that has in fact already
|
|
354
|
+
// changed. Reading it as "not ok" says the same true thing without
|
|
355
|
+
// depending on the throw being classified correctly.
|
|
356
|
+
const check = db.prepare("PRAGMA quick_check").get();
|
|
357
|
+
if (check?.quick_check !== "ok") {
|
|
358
|
+
return err("library_unreadable", `The database reports "${check?.quick_check ?? "no result"}" after writing "${title}". A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
|
|
359
|
+
}
|
|
360
|
+
return { playlist_id: listId, title, tracks_added: refs.length, backup_path: backupPath };
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
// A COMMIT that returned SQLITE_BUSY is the one in-flight failure SQLite
|
|
364
|
+
// defines precisely: the transaction stays open and nothing was written,
|
|
365
|
+
// so it is a plain retry, not an unverified write.
|
|
366
|
+
const busyOnCommit = commit === "maybe" && /SQLITE_BUSY|database is locked/i.test(e?.message ?? "");
|
|
367
|
+
if (commit === "not yet" || busyOnCommit) {
|
|
368
|
+
if (open && db) {
|
|
369
|
+
try {
|
|
370
|
+
db.exec("ROLLBACK");
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
/* no transaction in progress */
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return mapWriteError(e, title, mdbPath);
|
|
377
|
+
}
|
|
378
|
+
// Past the point of no return. No ROLLBACK: after a successful COMMIT
|
|
379
|
+
// there is no transaction to roll back, and after a COMMIT that failed
|
|
380
|
+
// mid-flight there is no state we can reason about well enough to undo
|
|
381
|
+
// by hand -- db.close() in the finally block ends anything still open.
|
|
382
|
+
// The honest answer is that the write may have gone through, plus the
|
|
383
|
+
// path of the snapshot from before this session's first write, which is
|
|
384
|
+
// the only case where restoring one is ever the right next step.
|
|
385
|
+
const msg = e?.message ?? String(e);
|
|
386
|
+
return err("library_unreadable", `Writing "${title}" may have gone through: the library could not be verified afterwards (${msg}). ` +
|
|
387
|
+
`Check the library in Engine DJ. A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
try {
|
|
391
|
+
db?.close();
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
/* already closed */
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
package/dist/tools/audit.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { err, isEngineError } from "../errors.js";
|
|
5
5
|
import { absTrackPath } from "../paths.js";
|
|
6
|
+
import { ENTRY_TRACK_MATCH } from "../playlists.js";
|
|
6
7
|
export const AUDIT_CHECKS = [
|
|
7
8
|
"missing_files",
|
|
8
9
|
"unavailable",
|
|
@@ -83,9 +84,19 @@ const SQL_CHECKS = {
|
|
|
83
84
|
WHERE artist IS NOT NULL AND title IS NOT NULL
|
|
84
85
|
GROUP BY 1 HAVING COUNT(*) > 1)`,
|
|
85
86
|
},
|
|
87
|
+
// "No track answers to this entry" — asked on the natural key
|
|
88
|
+
// `(databaseUuid, trackId)` -> `(originDatabaseUuid, originTrackId)`, never
|
|
89
|
+
// on `Track.id`. ENTRY_TRACK_MATCH carries the measurement: on the
|
|
90
|
+
// reference library the id join reported 105 orphans of 202 entries on a
|
|
91
|
+
// library that has none, which is this check's whole user-visible failure
|
|
92
|
+
// mode — a DJ told their playlists are full of holes.
|
|
93
|
+
//
|
|
94
|
+
// NOT EXISTS rather than a LEFT JOIN so the count is entries, not matched
|
|
95
|
+
// pairs, whatever the file on disk happens to contain.
|
|
86
96
|
orphan_entries: {
|
|
87
97
|
id: "e.id",
|
|
88
|
-
body: `FROM PlaylistEntity e
|
|
98
|
+
body: `FROM PlaylistEntity e
|
|
99
|
+
WHERE NOT EXISTS (SELECT 1 FROM Track t WHERE ${ENTRY_TRACK_MATCH})`,
|
|
89
100
|
},
|
|
90
101
|
};
|
|
91
102
|
export async function auditLibrary(qp, mdbPath, raw) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type EngineError } from "../errors.js";
|
|
3
|
+
import { type PlaylistItem } from "../playlists.js";
|
|
4
|
+
import type { QueryProcess } from "../proc/query-client.js";
|
|
5
|
+
export declare const GetPlaylistsInput: z.ZodObject<{
|
|
6
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export type GetPlaylistsInput = z.input<typeof GetPlaylistsInput>;
|
|
9
|
+
export interface GetPlaylistsResult {
|
|
10
|
+
playlists: PlaylistItem[];
|
|
11
|
+
total: number;
|
|
12
|
+
truncated: boolean;
|
|
13
|
+
warnings?: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The library's playlist tree, in the order Engine DJ displays it.
|
|
17
|
+
*
|
|
18
|
+
* Flat, in pre-order, with `depth` and `path` carrying the nesting -- see
|
|
19
|
+
* buildPlaylistTree for why that beats nested `children` arrays here.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getPlaylists(qp: QueryProcess, raw: GetPlaylistsInput): Promise<GetPlaylistsResult | EngineError>;
|
|
22
|
+
export declare const GetPlaylistTracksInput: z.ZodObject<{
|
|
23
|
+
playlist_id: z.ZodOptional<z.ZodNumber>;
|
|
24
|
+
playlist_name: z.ZodOptional<z.ZodString>;
|
|
25
|
+
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
26
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
27
|
+
cursor: z.ZodOptional<z.ZodString>;
|
|
28
|
+
redact_paths: z.ZodDefault<z.ZodBoolean>;
|
|
29
|
+
}, z.core.$strip>;
|
|
30
|
+
export type GetPlaylistTracksInput = z.input<typeof GetPlaylistTracksInput>;
|
|
31
|
+
/**
|
|
32
|
+
* A row of a playlist. Either a track (the requested `fields`, plus its
|
|
33
|
+
* position) or a hole where a track used to be.
|
|
34
|
+
*
|
|
35
|
+
* A hole is a real, ordinary state, not corruption: `PlaylistEntity` rows
|
|
36
|
+
* survive their track, which is why `audit_library` has an `orphan_entries`
|
|
37
|
+
* check at all. What a hole is *not* is an entry that merely came from
|
|
38
|
+
* another drive — those resolve perfectly well through the natural key (see
|
|
39
|
+
* ENTRY_TRACK_MATCH), and on the reference USB library all 202 entries do,
|
|
40
|
+
* including the 178 stamped with a third library's uuid.
|
|
41
|
+
*
|
|
42
|
+
* Genuine holes are kept in the list rather than filtered out, at their real
|
|
43
|
+
* positions, precisely so `tracks.length` still equals the playlist's own
|
|
44
|
+
* length and position 12 is still the twelfth thing the DJ sees in Engine.
|
|
45
|
+
* Dropping them would make a 43-entry playlist silently return fewer rows
|
|
46
|
+
* and look like a paging bug.
|
|
47
|
+
*/
|
|
48
|
+
export type PlaylistTrackRow = Record<string, unknown>;
|
|
49
|
+
export interface GetPlaylistTracksResult {
|
|
50
|
+
playlist: PlaylistItem;
|
|
51
|
+
tracks: PlaylistTrackRow[];
|
|
52
|
+
/** Entries in the whole playlist, not in this page. */
|
|
53
|
+
entry_count: number;
|
|
54
|
+
/** Entries in the whole playlist whose track is not in this library. */
|
|
55
|
+
missing_count: number;
|
|
56
|
+
next_cursor?: string;
|
|
57
|
+
warnings?: string[];
|
|
58
|
+
}
|
|
59
|
+
export declare function getPlaylistTracks(qp: QueryProcess, raw: GetPlaylistTracksInput): Promise<GetPlaylistTracksResult | EngineError>;
|