engine-dj-mcp 0.9.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/dist/blobs/index.d.ts +337 -0
  4. package/dist/blobs/index.js +483 -0
  5. package/dist/blobs/qcompress.d.ts +44 -0
  6. package/dist/blobs/qcompress.js +146 -0
  7. package/dist/discovery.d.ts +36 -0
  8. package/dist/discovery.js +111 -0
  9. package/dist/errors.d.ts +30 -0
  10. package/dist/errors.js +49 -0
  11. package/dist/guard.d.ts +31 -0
  12. package/dist/guard.js +236 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.js +13 -0
  15. package/dist/library-select.d.ts +63 -0
  16. package/dist/library-select.js +97 -0
  17. package/dist/paths.d.ts +18 -0
  18. package/dist/paths.js +34 -0
  19. package/dist/probe.d.ts +7 -0
  20. package/dist/probe.js +20 -0
  21. package/dist/proc/query-client.d.ts +36 -0
  22. package/dist/proc/query-client.js +249 -0
  23. package/dist/proc/query-worker.d.ts +1 -0
  24. package/dist/proc/query-worker.js +72 -0
  25. package/dist/semantics.d.ts +43 -0
  26. package/dist/semantics.js +95 -0
  27. package/dist/server.d.ts +31 -0
  28. package/dist/server.js +439 -0
  29. package/dist/sidecar/build.d.ts +19 -0
  30. package/dist/sidecar/build.js +85 -0
  31. package/dist/sidecar/schema.d.ts +25 -0
  32. package/dist/sidecar/schema.js +36 -0
  33. package/dist/store/connections.d.ts +28 -0
  34. package/dist/store/connections.js +116 -0
  35. package/dist/store/index-manager.d.ts +29 -0
  36. package/dist/store/index-manager.js +187 -0
  37. package/dist/tools/audit.d.ts +15 -0
  38. package/dist/tools/audit.js +148 -0
  39. package/dist/tools/libraries.d.ts +40 -0
  40. package/dist/tools/libraries.js +30 -0
  41. package/dist/tools/performance.d.ts +15 -0
  42. package/dist/tools/performance.js +47 -0
  43. package/dist/tools/refresh.d.ts +8 -0
  44. package/dist/tools/refresh.js +3 -0
  45. package/dist/tools/search.d.ts +60 -0
  46. package/dist/tools/search.js +328 -0
  47. package/dist/tools/sql.d.ts +14 -0
  48. package/dist/tools/sql.js +21 -0
  49. package/dist/tools/tracks.d.ts +12 -0
  50. package/dist/tools/tracks.js +49 -0
  51. package/package.json +53 -0
package/dist/server.js ADDED
@@ -0,0 +1,439 @@
1
+ // src/server.ts
2
+ import { existsSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { discoverLibraries, defaultRoots, probeLibraries } from "./discovery.js";
7
+ import { libraryCandidates, sidecarDir } from "./paths.js";
8
+ import { LibraryArg, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
9
+ import { hasHotJournal } from "./store/connections.js";
10
+ import { QueryProcess } from "./proc/query-client.js";
11
+ import { IndexManager } from "./store/index-manager.js";
12
+ import { searchTracks, SearchInput } from "./tools/search.js";
13
+ import { getTracks, GetTracksInput } from "./tools/tracks.js";
14
+ import { getTrackPerformance, PerformanceInput } from "./tools/performance.js";
15
+ import { auditLibrary, AuditInput, AUDIT_CHECKS } from "./tools/audit.js";
16
+ import { runSql, RunSqlInput } from "./tools/sql.js";
17
+ import { listLibraries } from "./tools/libraries.js";
18
+ import { refreshIndex } from "./tools/refresh.js";
19
+ import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
20
+ const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
21
+ /**
22
+ * Appended to every tool description that takes a `library`. The argument's
23
+ * own schema description (see library-select.ts) is the authoritative text;
24
+ * this repeats the essentials in the description because some clients show a
25
+ * model the description and not the per-property schema documentation.
26
+ */
27
+ const LIBRARY_SELECTION_NOTE = "With more than one library connected, pass `library` (a uuid or path from list_libraries, " +
28
+ "either the ~/... form or the absolute one) to choose which one; the default is the " +
29
+ "supported library with the most tracks.";
30
+ function reply(value) {
31
+ return {
32
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
33
+ structuredContent: value,
34
+ isError: isEngineError(value),
35
+ };
36
+ }
37
+ /**
38
+ * discoverLibraries() reports only libraries it could actually read, by
39
+ * design (a permissions error on one candidate must not blank out every
40
+ * other one). That means a hot journal on the *only* library on this
41
+ * machine looks identical to no library existing at all -- both come back
42
+ * as an empty list, verified: opening it raises "attempt to write a
43
+ * readonly database", which readLibraryInfo currently folds into
44
+ * unsupported_schema and then drops entirely.
45
+ *
46
+ * This walks the same candidate paths independently, purely to tell those
47
+ * two cases apart, so `ready()` below can report library_needs_recovery
48
+ * instead of the misleading library_not_found -- never to open the file:
49
+ * recovering a hot journal requires a write, and this project never writes
50
+ * to the user's library, even to heal it.
51
+ */
52
+ export function findHotJournalCandidate(roots) {
53
+ for (const root of roots) {
54
+ for (const candidate of libraryCandidates(root)) {
55
+ if (existsSync(candidate) && hasHotJournal(candidate))
56
+ return candidate;
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+ export async function createServer(opts = {}) {
62
+ const server = new McpServer({ name: "engine-dj-mcp", version: "0.1.0" });
63
+ const libs = discoverLibraries(opts.roots);
64
+ // Shared by every tool for the "no primary library" case, so refresh_index
65
+ // cannot end up as the one call site that still flattens a hot journal
66
+ // into library_not_found while the rest correctly report
67
+ // library_needs_recovery.
68
+ const noLibraryError = () => {
69
+ const hotPath = findHotJournalCandidate(opts.roots ?? defaultRoots());
70
+ return hotPath
71
+ ? libraryNeedsRecovery()
72
+ : err("library_not_found", "No supported Engine DJ library was found");
73
+ };
74
+ /**
75
+ * Seeded from the start-time scan and grown by every list_libraries call
76
+ * after: once a candidate path has been read successfully, it stays here.
77
+ * That is what lets rescanLibraries() below keep reporting a library that
78
+ * a later scan catches locked, instead of discoverLibraries() silently
79
+ * dropping it -- the same library that "was discoverable before must not
80
+ * silently disappear because it is momentarily unreadable" (see
81
+ * tools/libraries.ts). Keyed by candidate path rather than uuid: a failed
82
+ * read has no fresh uuid to key on, only the path it was attempted at.
83
+ */
84
+ const knownLibraries = new Map(libs.map((l) => [l.path, l]));
85
+ /**
86
+ * The re-scan behind the list_libraries tool. Every candidate path that
87
+ * still exists but failed to read this time is reported using its last
88
+ * known-good LibraryInfo, marked `unreadable` with the fresh error --
89
+ * present, but visibly not fine, rather than absent. A candidate that no
90
+ * longer exists at all (the drive itself is gone) is forgotten instead:
91
+ * that is a real disappearance, not a degraded state.
92
+ */
93
+ const rescanLibraries = () => {
94
+ const roots = opts.roots ?? defaultRoots();
95
+ const seen = new Set();
96
+ const entries = [];
97
+ for (const probe of probeLibraries(roots)) {
98
+ seen.add(probe.path);
99
+ if (probe.info) {
100
+ knownLibraries.set(probe.path, probe.info);
101
+ entries.push(probe.info);
102
+ }
103
+ else {
104
+ const cached = knownLibraries.get(probe.path);
105
+ if (cached)
106
+ entries.push({ ...cached, unreadable: probe.error });
107
+ }
108
+ }
109
+ for (const path of knownLibraries.keys()) {
110
+ if (!seen.has(path))
111
+ knownLibraries.delete(path);
112
+ }
113
+ return entries;
114
+ };
115
+ /** Every library this server currently knows about, in root-scan order. */
116
+ const knownList = () => [...knownLibraries.values()];
117
+ /**
118
+ * Per-library state, keyed by the path of `m.db` rather than by uuid:
119
+ * copying a library to another drive copies its uuid too, so uuid is not
120
+ * unique across mounted volumes while the file's location always is.
121
+ */
122
+ const states = new Map();
123
+ /**
124
+ * Sidecars live at `<base>/<uuid>/index.db`, which isolates two libraries
125
+ * from each other -- verified -- for as long as their uuids differ. They
126
+ * do not always differ: a library cloned onto a second drive (a normal
127
+ * thing for a DJ to do) carries the original's uuid, and both would then
128
+ * rebuild over the same index file on every call, thrashing forever.
129
+ *
130
+ * Only the *second and later* claimants of a uuid are moved aside, so the
131
+ * ordinary single-library layout on disk is exactly what it was, and the
132
+ * library that owns the uuid by root-scan order keeps it across restarts.
133
+ */
134
+ const sidecarBaseFor = (lib) => {
135
+ const first = knownList().find((l) => l.uuid === lib.uuid);
136
+ if (!first || first.path === lib.path)
137
+ return opts.sidecarBaseDir;
138
+ const tag = createHash("sha256").update(lib.path).digest("hex").slice(0, 12);
139
+ return join(opts.sidecarBaseDir ?? sidecarDir(""), "duplicate-uuid", tag);
140
+ };
141
+ /** Lazily creates -- and thereafter reuses -- one query child per library. */
142
+ const stateFor = (lib) => {
143
+ const existing = states.get(lib.path);
144
+ if (existing)
145
+ return existing;
146
+ const qp = new QueryProcess(lib.path, null, 10_000);
147
+ const state = { lib, qp, mgr: new IndexManager(lib, qp, sidecarBaseFor(lib)) };
148
+ states.set(lib.path, state);
149
+ return state;
150
+ };
151
+ /**
152
+ * Turns the optional `library` argument into one specific library.
153
+ *
154
+ * A miss triggers a single re-scan before giving up: `list_libraries`
155
+ * re-discovers on every call precisely so a drive plugged in after this
156
+ * server started is visible, and a library a caller can see but cannot
157
+ * select is the defect this whole argument exists to close. The re-scan
158
+ * runs only on a miss, so the normal path stays a Map lookup.
159
+ */
160
+ const selectLibrary = (requested) => {
161
+ if (requested === undefined)
162
+ return pickDefaultLibrary(knownList()) ?? noLibraryError();
163
+ const direct = findLibrary(knownList(), requested);
164
+ if (direct)
165
+ return direct;
166
+ rescanLibraries();
167
+ return findLibrary(knownList(), requested) ?? libraryNotFound(requested, knownList());
168
+ };
169
+ /**
170
+ * `index_stale` is swallowed only when an index is genuinely attached:
171
+ * "the previous index is still in use" is a reason to answer anyway, but
172
+ * "the index could not be built yet" is not. Every tool's SQL joins
173
+ * `side.track_derived`, so letting the call proceed with nothing attached
174
+ * turned the project's headline scenario -- a first run while Engine DJ
175
+ * holds a write lock -- into `invalid_argument` carrying the raw SQLite
176
+ * string "no such table: side.track_derived", instead of `index_stale`
177
+ * with a `retry_after_ms` the model can act on.
178
+ */
179
+ const acquire = async (requested) => {
180
+ const lib = selectLibrary(requested);
181
+ if (isEngineError(lib))
182
+ return lib;
183
+ const state = stateFor(lib);
184
+ const fresh = await state.mgr.ensureFresh();
185
+ if (!isEngineError(fresh))
186
+ return state;
187
+ if (fresh.error === "index_stale" && state.qp.hasSidecar)
188
+ return state;
189
+ return fresh;
190
+ };
191
+ /**
192
+ * Shared by the engine://libraries resource and the list_libraries tool so
193
+ * the two cannot drift in shape, while differing in exactly one respect:
194
+ * which library list they are given.
195
+ *
196
+ * The resource is passed the start-time snapshot, which is what the spec
197
+ * claims a resource is. The tool re-discovers on every call, because a
198
+ * USB drive plugged in after the server started is the ordinary case for
199
+ * a DJ, and "restart your assistant to see the drive you just plugged in"
200
+ * is not an answer.
201
+ *
202
+ * index_generation only appears once a sidecar has actually been built at
203
+ * least once in this process -- a never-built IndexManager still reports
204
+ * generation 0, which is not a real generation number and must read as
205
+ * null, not as "generation zero". A library nobody has queried yet has no
206
+ * IndexManager at all and reports null for the same reason: listing the
207
+ * libraries must not fork a query child per drive to fill in a number.
208
+ */
209
+ const libraryReport = async (discovered) => {
210
+ const generations = new Map();
211
+ for (const state of states.values()) {
212
+ await state.mgr.ensureFresh(); // best effort: keeps a live library's generation accurate
213
+ if (state.mgr.generation > 0)
214
+ generations.set(state.lib.uuid, state.mgr.generation);
215
+ }
216
+ return listLibraries(generations, discovered);
217
+ };
218
+ server.registerResource("schema", "engine://schema", { title: "Engine DJ schema and semantics", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, text: SCHEMA_NOTE }] }));
219
+ server.registerResource("libraries", "engine://libraries", { title: "Discovered Engine DJ libraries", mimeType: "application/json" }, async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(await libraryReport(libs), null, 2) }] }));
220
+ server.registerTool("search_tracks", {
221
+ title: "Search tracks",
222
+ description: "Search the Engine DJ library by text, tempo, key, rating, play history and analysis " +
223
+ "flags. Set include_total for a count alongside the page: it is capped at 1000, and a " +
224
+ "capped result comes back as total: 1000 with total_capped: true -- treat that as " +
225
+ "'at least 1000', never as an exact count. " +
226
+ "flags.has_cues means a hot cue is actually set (the blob is decoded when the index " +
227
+ "is built), not merely that Engine analysed the track; flags.has_beatgrid means a " +
228
+ "beatData blob is present. " +
229
+ LIBRARY_SELECTION_NOTE,
230
+ inputSchema: { ...SearchInput.shape, library: LibraryArg },
231
+ annotations: RO,
232
+ }, async (args) => {
233
+ const state = await acquire(args.library);
234
+ if (isEngineError(state))
235
+ return reply(state);
236
+ return reply(await searchTracks(state.qp, args));
237
+ });
238
+ server.registerTool("get_tracks", {
239
+ title: "Get tracks by id",
240
+ description: "Fetch full metadata for specific track ids, in the order requested. " +
241
+ LIBRARY_SELECTION_NOTE,
242
+ inputSchema: { ...GetTracksInput.shape, library: LibraryArg },
243
+ annotations: RO,
244
+ }, async (args) => {
245
+ const state = await acquire(args.library);
246
+ if (isEngineError(state))
247
+ return reply(state);
248
+ return reply(await getTracks(state.qp, args));
249
+ });
250
+ server.registerTool("get_track_performance", {
251
+ title: "Get cues, loops and beatgrid",
252
+ description: "Decode PerformanceData for one track: hot cues, the main cue, saved loops, the " +
253
+ "beatgrid and a coarse waveform profile. Each field carries its own decode status " +
254
+ "and its own layout marker. " +
255
+ "layout: \"verified\" (cues, beatgrid, waveform_summary) means the binary layout was " +
256
+ "confirmed against a real Engine DJ library -- cue positions land inside the track, " +
257
+ "the beatgrid's implied tempo matches the analysed BPM, and the waveform's declared " +
258
+ "point spacing multiplies back out to the track's sample count -- so status: \"ok\" " +
259
+ "there is a claim about the values, not just about the parse. " +
260
+ "layout: \"unverified\" (loops) still means only that the bytes parsed: the loop slot " +
261
+ "structure is known, but no library was available with a loop actually saved, so " +
262
+ "loop bounds must not be reported to a user as fact. " +
263
+ "Positions are sample offsets; sample_rate at the top level converts them to " +
264
+ "seconds, and cue/loop items carry the seconds already. Only hot-cue and loop slots " +
265
+ "that hold something are listed -- slots is how many the track has in total, so " +
266
+ "items: [] with slots: 8 means an analysed track with no cues set. Items are capped " +
267
+ "at 64; total gives the full count and truncated says whether the cap was hit. " +
268
+ LIBRARY_SELECTION_NOTE,
269
+ inputSchema: { ...PerformanceInput.shape, library: LibraryArg },
270
+ annotations: RO,
271
+ }, async (args) => {
272
+ const state = await acquire(args.library);
273
+ if (isEngineError(state))
274
+ return reply(state);
275
+ return reply(await getTrackPerformance(state.qp, args));
276
+ });
277
+ server.registerTool("audit_library", {
278
+ title: "Audit the collection",
279
+ description: `Run collection health checks. Available: ${AUDIT_CHECKS.join(", ")}. ` +
280
+ `missing_files resolves each track against the selected library's own folder. ` +
281
+ `no_cues means "no hot cue is set" -- the quickCues blob is decoded for this, since ` +
282
+ `Engine writes one to every analysed track whether or not a pad is used -- while ` +
283
+ `no_beatgrid means the beatData blob is absent or empty. ` +
284
+ LIBRARY_SELECTION_NOTE,
285
+ inputSchema: { ...AuditInput.shape, library: LibraryArg },
286
+ annotations: RO,
287
+ }, async (args) => {
288
+ const state = await acquire(args.library);
289
+ if (isEngineError(state))
290
+ return reply(state);
291
+ // state.lib.path, never a captured "primary" path: missing_files
292
+ // resolves every relative Track.path against the grandparent of this
293
+ // argument, so the wrong library's path here would report a wrong
294
+ // answer rather than an error.
295
+ return reply(await auditLibrary(state.qp, state.lib.path, args));
296
+ });
297
+ server.registerTool("run_sql", {
298
+ title: "Run a read-only SQL query",
299
+ description: "Escape hatch for questions the other tools do not cover. Read-only is enforced by the " +
300
+ "kernel, not by this check alone. Use side.track_derived.camelot and side.track_derived.tempo " +
301
+ "in WHERE clauses rather than the camelot()/tempo() SQL functions, which run per row and defeat " +
302
+ "indexes. " +
303
+ LIBRARY_SELECTION_NOTE,
304
+ inputSchema: { ...RunSqlInput.shape, library: LibraryArg },
305
+ annotations: RO,
306
+ }, async (args) => {
307
+ const state = await acquire(args.library);
308
+ if (isEngineError(state))
309
+ return reply(state);
310
+ return reply(await runSql(state.qp, args));
311
+ });
312
+ server.registerTool("list_libraries", {
313
+ title: "List Engine DJ libraries",
314
+ description: "List every discovered library, including ones whose schema is unsupported. " +
315
+ "Re-scans on every call, so a drive plugged in after this server started is visible " +
316
+ "without a restart (the engine://libraries resource is a start-time snapshot). A " +
317
+ "library seen before but not readable right now (e.g. Engine DJ is writing to it) " +
318
+ "stays listed with status: \"unreadable\" and error set, instead of disappearing. " +
319
+ "Pass a listed uuid or path as the `library` argument of any other tool to act on that " +
320
+ "library; without it they use the supported library holding the most tracks.",
321
+ inputSchema: {},
322
+ annotations: RO,
323
+ }, async () => reply(await libraryReport(rescanLibraries())));
324
+ server.registerTool("refresh_index", {
325
+ title: "Refresh the search index",
326
+ description: "Rebuild the search index if the library has changed. " + LIBRARY_SELECTION_NOTE,
327
+ inputSchema: { library: LibraryArg },
328
+ annotations: RO,
329
+ }, async (args) => {
330
+ // Not gated through acquire(): this tool *is* the gate, so it reports
331
+ // ensureFresh's own result rather than swallowing index_stale.
332
+ const lib = selectLibrary(args.library);
333
+ if (isEngineError(lib))
334
+ return reply(lib);
335
+ return reply(await refreshIndex(stateFor(lib).mgr));
336
+ });
337
+ /**
338
+ * There was previously no way to shut this down at all: createServer
339
+ * forked a query child and handed back an McpServer whose close() knows
340
+ * only about the transport, so the child outlived every caller -- a leak
341
+ * in tests, and in a host that restarts its MCP servers a leak of one
342
+ * process per restart.
343
+ *
344
+ * close() is wrapped rather than replaced so a client disconnecting
345
+ * through the normal MCP path also releases the children; dispose() is
346
+ * exposed for a caller that owns the server directly. QueryProcess#kill
347
+ * tolerates being called with no live child, so both are idempotent --
348
+ * and it is *every* library's child now, not just the first one, or a
349
+ * session that touched two drives would leak one process per drive.
350
+ */
351
+ const disposeAll = () => {
352
+ for (const state of states.values())
353
+ state.qp.dispose();
354
+ };
355
+ const closeTransport = server.close.bind(server);
356
+ server.dispose = disposeAll;
357
+ server.close = async () => {
358
+ try {
359
+ await closeTransport();
360
+ }
361
+ finally {
362
+ disposeAll();
363
+ }
364
+ };
365
+ return server;
366
+ }
367
+ const SCHEMA_NOTE = `# Engine DJ library — schema and semantics
368
+
369
+ Tables live in \`m.db\` (attached as \`main\`); the search index lives in \`side\`.
370
+
371
+ ## Choosing a library
372
+ More than one library can be connected at once — the local one under
373
+ \`~/Music\` and one per USB drive. \`list_libraries\` reports each with a
374
+ \`uuid\` and a \`path\`, and every tool that reads library data
375
+ (\`search_tracks\`, \`get_tracks\`, \`get_track_performance\`,
376
+ \`audit_library\`, \`run_sql\`, \`refresh_index\`) takes an optional
377
+ \`library\` argument naming one of them: either the \`uuid\` or the
378
+ \`path\`, in the \`~/...\` form \`list_libraries\` prints or the absolute
379
+ one. A value matching neither comes back as \`library_not_found\` listing
380
+ the libraries that are selectable.
381
+
382
+ Omitting \`library\` selects the supported library holding the most tracks,
383
+ ties broken by scan order — so an empty local library does not shadow the
384
+ populated drive a DJ actually works from. Each library keeps its own search
385
+ index, and every query, audit and path resolution stays inside the library
386
+ selected for that call; nothing here compares two libraries against each
387
+ other.
388
+
389
+ ## Field semantics
390
+ - \`Track.key\` is 0..23, \`-1\` means undetermined. The mapping to Camelot
391
+ notation is confirmed against Engine DJ's own display (key=20 shows as 6B,
392
+ exactly what the formula produces). Use \`side.track_derived.camelot\` for
393
+ filtering -- it is indexed. The SQL function \`camelot(key)\` exists but
394
+ runs per row and defeats indexes.
395
+ - Real tempo is \`COALESCE(bpmAnalyzed, bpm)\`. \`bpm\` is stored at face
396
+ value -- 102 means 102 BPM, not 10200. It is NOT scaled by 100; that is a
397
+ rekordbox convention, not an Engine one (confirmed against a real Engine
398
+ library: stored values of 102, 105, 128, 145, 147 each matched
399
+ \`bpmAnalyzed\` to within 0.68, and Engine's own interface displays 102 for
400
+ the track stored as 102). \`side.track_derived.tempo\` holds the resolved
401
+ value and is indexed.
402
+ - \`Track.path\` is relative to the \`Engine Library\` folder and usually
403
+ contains \`..\`. The SQL function \`abs_path(path)\` resolves it against
404
+ this library's location; the home prefix comes back folded to \`~\`.
405
+ - Playlists are singly linked lists: order lives in \`Playlist.nextListId\`
406
+ and \`PlaylistEntity.nextEntityId\`, not in any position column.
407
+ - A track's natural key across drives is \`(originDatabaseUuid, originTrackId)\`.
408
+ - \`PerformanceData\`'s blob columns are binary and cannot be read with SQL.
409
+ Engine writes \`quickCues\`, \`loops\`, \`beatData\` and
410
+ \`overviewWaveFormData\` to **every analysed track** whether or not the DJ
411
+ set anything, so \`quickCues IS NOT NULL\` means "analysed", not "has
412
+ cues": a track with no hot cues still carries a full eight-slot blob.
413
+ \`get_track_performance\` decodes one track's blobs; for the whole library,
414
+ \`side.track_derived.has_cues\` below holds the decoded answer.
415
+
416
+ ## SQL functions
417
+ Registered on the query connection, all deterministic:
418
+ \`camelot(key)\`, \`key_name(key)\`, \`tempo(bpmAnalyzed, bpm)\`,
419
+ \`key_distance(a, b)\` and \`abs_path(path)\`. Each runs a callback per row,
420
+ so use them for projection and one-off questions, not in a \`WHERE\` clause
421
+ where \`side.track_derived\` is indexed and these are not.
422
+
423
+ ## Sidecar tables
424
+ - \`side.fts_track\` — FTS5 over title, artist, album, genre, comment, label,
425
+ with diacritics folded. Join via \`side.fts_map(rowid, track_id)\`.
426
+ - \`side.track_derived(track_id, camelot, tempo, has_cues, has_grid)\` — indexed.
427
+ \`has_cues\` is **not** \`quickCues IS NOT NULL\`: the blob is decoded when
428
+ this index is built, and the column means "at least one hot cue is actually
429
+ set". It is the right column for "which tracks have no cues?", and the
430
+ matching \`audit_library\` check is \`no_cues\`. \`has_grid\` does mean "a
431
+ \`beatData\` blob is present and non-empty", which on a real library is the
432
+ same thing as an analysed beatgrid.
433
+
434
+ ## Limits
435
+ The connection is read-only at the kernel level, not by convention: writes
436
+ are refused by SQLite itself. \`VACUUM\`, \`ATTACH\` and \`DETACH\` are
437
+ rejected. Only one statement per call. Queries are killed after 10 seconds.
438
+ \`search_tracks\`'s \`total\` (when requested) is capped at 1000; check
439
+ \`total_capped\` before treating it as exact.`;
@@ -0,0 +1,19 @@
1
+ export interface BuildArgs {
2
+ mdbPath: string;
3
+ outPath: string;
4
+ uuid: string;
5
+ schema: string;
6
+ generation?: number;
7
+ }
8
+ /**
9
+ * Full rebuild. There is no incremental path on purpose: lastEditTime is only
10
+ * bumped by UPDATE of 21 Track columns, so it misses inserts, deletes, path
11
+ * changes and play events, while a full rebuild is ~240 ms at 50k rows (3 ms
12
+ * on the real 257-track library it was measured against). About 100 ms of the
13
+ * 50k figure is decoding quickCues for has_cues, which is the price of that
14
+ * column answering "a cue is set" instead of "Engine analysed this".
15
+ */
16
+ export declare function buildSidecar(args: BuildArgs): {
17
+ indexed: number;
18
+ elapsed_ms: number;
19
+ };
@@ -0,0 +1,85 @@
1
+ import { rmSync } from "node:fs";
2
+ import { openSyncConnection } from "../store/connections.js";
3
+ import { readChangeCounter } from "../probe.js";
4
+ import { hasCueSet } from "../blobs/index.js";
5
+ import { SIDECAR_DDL, SIDECAR_INDEXES, SIDECAR_FORMAT } from "./schema.js";
6
+ /**
7
+ * Full rebuild. There is no incremental path on purpose: lastEditTime is only
8
+ * bumped by UPDATE of 21 Track columns, so it misses inserts, deletes, path
9
+ * changes and play events, while a full rebuild is ~240 ms at 50k rows (3 ms
10
+ * on the real 257-track library it was measured against). About 100 ms of the
11
+ * 50k figure is decoding quickCues for has_cues, which is the price of that
12
+ * column answering "a cue is set" instead of "Engine analysed this".
13
+ */
14
+ export function buildSidecar(args) {
15
+ const started = Date.now();
16
+ const counter = readChangeCounter(args.mdbPath);
17
+ rmSync(args.outPath, { force: true });
18
+ const db = openSyncConnection(args.outPath, args.mdbPath);
19
+ try {
20
+ // Durability is irrelevant: the file is disposable and swapped in atomically.
21
+ db.exec("PRAGMA journal_mode = OFF");
22
+ db.exec("PRAGMA synchronous = OFF");
23
+ for (const ddl of SIDECAR_DDL)
24
+ db.exec(ddl);
25
+ // Registered here rather than in semantics.ts, which is the set of
26
+ // functions the *model* can call through run_sql: this one runs a zlib
27
+ // inflate and a blob walk per row, so exposing it to arbitrary SQL would
28
+ // hand out a way to make any query cost a full decode of the library.
29
+ // The rebuild is the one place that needs it, and it runs once per
30
+ // library change.
31
+ //
32
+ // hasCueSet never throws (nor does anything under it), so a corrupt blob
33
+ // yields 0 rather than aborting a build over one bad row.
34
+ db.function("has_cue_set", { deterministic: true }, (v) => hasCueSet(v instanceof Uint8Array ? Buffer.from(v) : null) ? 1 : 0);
35
+ db.exec("BEGIN");
36
+ db.exec(`INSERT INTO fts_track(rowid, title, artist, album, genre, comment, label)
37
+ SELECT id, title, artist, album, genre, comment, label FROM engine.Track`);
38
+ db.exec(`INSERT INTO fts_map(rowid, track_id) SELECT id, id FROM engine.Track`);
39
+ // has_cues decodes the blob; has_grid tests that one is there. The
40
+ // asymmetry is measured on 281 real blobs, not a shortcut:
41
+ //
42
+ // quickCues is written to every analysed track with all eight slots at
43
+ // the -1.0 "unused" sentinel, so "the blob exists" is true for 281 of
44
+ // 281 while a cue is set on 3. A DJ asking which tracks still need cue
45
+ // points was told none did, on a library where 255 of 257 do. Only
46
+ // decoding can tell those apart, and this is our code, not SQL.
47
+ //
48
+ // beatData carries no equivalent "analysed but empty" state. All 281
49
+ // blobs decode, all 281 carry two markers in each of the two grids,
50
+ // the present-flag byte is 1 on all 281, and every implied tempo
51
+ // matches Track.bpmAnalyzed to within 0.5 BPM. Presence and a real
52
+ // grid have not once disagreed, because a beatgrid is produced by
53
+ // analysis where a cue is placed by a human. Decoding it would cost
54
+ // ~570 ms per rebuild at 50k tracks (measured: 2.95 ms for 257 blobs)
55
+ // to re-derive an answer already correct on every row available.
56
+ //
57
+ // "Empty OR NULL", not "NOT NULL", still holds for has_grid: a
58
+ // zero-length blob carries no beatgrid. Reading it as present made a
59
+ // track has_beatgrid: 1 in search while get_track_performance reported
60
+ // the same blob as `empty`.
61
+ db.exec(`INSERT INTO track_derived(track_id, camelot, tempo, has_cues, has_grid)
62
+ SELECT t.id,
63
+ camelot(t.key),
64
+ tempo(t.bpmAnalyzed, t.bpm),
65
+ has_cue_set(p.quickCues),
66
+ CASE WHEN COALESCE(length(p.beatData), 0) = 0 THEN 0 ELSE 1 END
67
+ FROM engine.Track t
68
+ LEFT JOIN engine.PerformanceData p ON p.trackId = t.id`);
69
+ db.exec("COMMIT");
70
+ for (const ix of SIDECAR_INDEXES)
71
+ db.exec(ix);
72
+ const indexed = Number(db.prepare("SELECT COUNT(*) c FROM track_derived").get().c);
73
+ db.prepare(`INSERT INTO index_meta (library_uuid, schema_version, change_counter, built_at, generation, index_format)
74
+ VALUES (?,?,?,?,?,?)`)
75
+ .run(args.uuid, args.schema, counter, Math.floor(started / 1000), args.generation ?? 1, SIDECAR_FORMAT);
76
+ return { indexed, elapsed_ms: Date.now() - started };
77
+ }
78
+ catch (e) {
79
+ rmSync(args.outPath, { force: true });
80
+ throw e;
81
+ }
82
+ finally {
83
+ db.close();
84
+ }
85
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Version of *this* file's meaning, not of the Engine library's schema.
3
+ *
4
+ * The staleness probe compares the library's change counter, so a sidecar
5
+ * only gets rebuilt when the library changes. That is right for content and
6
+ * wrong for semantics: when a column starts meaning something different, an
7
+ * untouched library would keep serving the old meaning from disk forever.
8
+ * `index_meta.index_format` is stored on build and checked on load, so a
9
+ * sidecar written by a different version of this code is treated as stale.
10
+ *
11
+ * 1 — the original columns.
12
+ * 2 — `has_cues` changed from "a quickCues blob exists" to "a hot cue is
13
+ * actually set", which is a different answer for 278 of 281 real tracks.
14
+ */
15
+ export declare const SIDECAR_FORMAT = 2;
16
+ export declare const SIDECAR_DDL: readonly [`CREATE VIRTUAL TABLE fts_track USING fts5(
17
+ title, artist, album, genre, comment, label,
18
+ tokenize='unicode61 remove_diacritics 2')`, `CREATE TABLE fts_map (rowid INTEGER PRIMARY KEY, track_id INTEGER UNIQUE)`, `CREATE TABLE track_derived (
19
+ track_id INTEGER PRIMARY KEY,
20
+ camelot TEXT, tempo REAL,
21
+ has_cues INTEGER, has_grid INTEGER)`, `CREATE TABLE index_meta (
22
+ library_uuid TEXT, schema_version TEXT,
23
+ change_counter INTEGER, built_at INTEGER, generation INTEGER,
24
+ index_format INTEGER)`];
25
+ export declare const SIDECAR_INDEXES: readonly [`CREATE INDEX ix_derived_camelot ON track_derived(camelot)`, `CREATE INDEX ix_derived_tempo ON track_derived(tempo)`];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Version of *this* file's meaning, not of the Engine library's schema.
3
+ *
4
+ * The staleness probe compares the library's change counter, so a sidecar
5
+ * only gets rebuilt when the library changes. That is right for content and
6
+ * wrong for semantics: when a column starts meaning something different, an
7
+ * untouched library would keep serving the old meaning from disk forever.
8
+ * `index_meta.index_format` is stored on build and checked on load, so a
9
+ * sidecar written by a different version of this code is treated as stale.
10
+ *
11
+ * 1 — the original columns.
12
+ * 2 — `has_cues` changed from "a quickCues blob exists" to "a hot cue is
13
+ * actually set", which is a different answer for 278 of 281 real tracks.
14
+ */
15
+ export const SIDECAR_FORMAT = 2;
16
+ export const SIDECAR_DDL = [
17
+ `CREATE VIRTUAL TABLE fts_track USING fts5(
18
+ title, artist, album, genre, comment, label,
19
+ tokenize='unicode61 remove_diacritics 2')`,
20
+ `CREATE TABLE fts_map (rowid INTEGER PRIMARY KEY, track_id INTEGER UNIQUE)`,
21
+ // has_cues: a hot cue is actually set (the quickCues blob is decoded during
22
+ // the build); has_grid: a beatData blob is present. The asymmetry is
23
+ // measured, not an oversight -- see sidecar/build.ts.
24
+ `CREATE TABLE track_derived (
25
+ track_id INTEGER PRIMARY KEY,
26
+ camelot TEXT, tempo REAL,
27
+ has_cues INTEGER, has_grid INTEGER)`,
28
+ `CREATE TABLE index_meta (
29
+ library_uuid TEXT, schema_version TEXT,
30
+ change_counter INTEGER, built_at INTEGER, generation INTEGER,
31
+ index_format INTEGER)`,
32
+ ];
33
+ export const SIDECAR_INDEXES = [
34
+ `CREATE INDEX ix_derived_camelot ON track_derived(camelot)`,
35
+ `CREATE INDEX ix_derived_tempo ON track_derived(tempo)`,
36
+ ];
@@ -0,0 +1,28 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ /**
3
+ * True when `<mdbPath>-journal` exists and carries SQLite's real hot-journal
4
+ * magic, meaning a previous writer died mid-transaction with unflushed pages
5
+ * on disk. SQLite itself will refuse to open such a database read-only (it
6
+ * needs to roll the journal forward, which requires a write) and raises a
7
+ * raw, unhelpful "attempt to write a readonly database" error. This lets
8
+ * openQueryConnection detect the condition first and explain it instead.
9
+ */
10
+ export declare function hasHotJournal(mdbPath: string): boolean;
11
+ /**
12
+ * Connection A — the one the model reaches through.
13
+ *
14
+ * m.db is the MAIN database and is opened readOnly, so the restriction is a
15
+ * property of the file descriptor rather than a PRAGMA. The inverse layout
16
+ * (sidecar as main, m.db attached read-only) was rejected: SQL-level
17
+ * `ATTACH '<m.db>' AS rw` escapes it and can write to the user's library.
18
+ * PRAGMA query_only was rejected too, because SQL can turn it back off.
19
+ *
20
+ * The attach path is bound as a parameter, not interpolated into the SQL
21
+ * text: a library directory containing an apostrophe (e.g. `Rock 'n' Roll`)
22
+ * breaks out of a string literal built by concatenation.
23
+ */
24
+ export declare function openQueryConnection(mdbPath: string, sidecar: string | null): DatabaseSync;
25
+ /** Connection B — used only by our own rebuild code, never exposed to the model. */
26
+ export declare function openSyncConnection(sidecar: string, mdbPath: string): DatabaseSync;
27
+ /** Re-attach the sidecar after an atomic swap; rename() alone is invisible. */
28
+ export declare function reattachSidecar(db: DatabaseSync, sidecar: string): void;