serato-dj-mcp 0.1.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/LICENSE +21 -0
- package/README.md +274 -0
- package/dist/apply/backup.d.ts +17 -0
- package/dist/apply/backup.js +98 -0
- package/dist/apply/manifest.d.ts +35 -0
- package/dist/apply/manifest.js +73 -0
- package/dist/apply/mutex.d.ts +21 -0
- package/dist/apply/mutex.js +64 -0
- package/dist/apply/root.d.ts +39 -0
- package/dist/apply/root.js +99 -0
- package/dist/apply/serato.d.ts +28 -0
- package/dist/apply/serato.js +102 -0
- package/dist/apply/sqlite.d.ts +2 -0
- package/dist/apply/sqlite.js +4 -0
- package/dist/apply/transaction.d.ts +34 -0
- package/dist/apply/transaction.js +330 -0
- package/dist/args.d.ts +25 -0
- package/dist/args.js +49 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +80 -0
- package/dist/discovery/index.d.ts +51 -0
- package/dist/discovery/index.js +152 -0
- package/dist/envelope.d.ts +56 -0
- package/dist/envelope.js +70 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +39 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +43 -0
- package/dist/paths.d.ts +20 -0
- package/dist/paths.js +42 -0
- package/dist/read/audit.d.ts +75 -0
- package/dist/read/audit.js +305 -0
- package/dist/read/crates.d.ts +36 -0
- package/dist/read/crates.js +88 -0
- package/dist/read/cursor.d.ts +52 -0
- package/dist/read/cursor.js +105 -0
- package/dist/read/fields.d.ts +36 -0
- package/dist/read/fields.js +133 -0
- package/dist/read/filters.d.ts +69 -0
- package/dist/read/filters.js +242 -0
- package/dist/read/key.d.ts +58 -0
- package/dist/read/key.js +135 -0
- package/dist/read/session.d.ts +34 -0
- package/dist/read/session.js +86 -0
- package/dist/read/sort.d.ts +31 -0
- package/dist/read/sort.js +131 -0
- package/dist/schema/index.d.ts +11 -0
- package/dist/schema/index.js +14 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +170 -0
- package/dist/snapshot/derive.d.ts +29 -0
- package/dist/snapshot/derive.js +80 -0
- package/dist/snapshot/index.d.ts +47 -0
- package/dist/snapshot/index.js +268 -0
- package/dist/stage/name.d.ts +26 -0
- package/dist/stage/name.js +50 -0
- package/dist/stage/store.d.ts +40 -0
- package/dist/stage/store.js +128 -0
- package/dist/tools/apply-changes.d.ts +39 -0
- package/dist/tools/apply-changes.js +173 -0
- package/dist/tools/audit-library.d.ts +30 -0
- package/dist/tools/audit-library.js +67 -0
- package/dist/tools/discard-changes.d.ts +21 -0
- package/dist/tools/discard-changes.js +57 -0
- package/dist/tools/get-crate-tracks.d.ts +40 -0
- package/dist/tools/get-crate-tracks.js +97 -0
- package/dist/tools/get-tracks.d.ts +27 -0
- package/dist/tools/get-tracks.js +53 -0
- package/dist/tools/list-crates.d.ts +35 -0
- package/dist/tools/list-crates.js +67 -0
- package/dist/tools/list-libraries.d.ts +58 -0
- package/dist/tools/list-libraries.js +124 -0
- package/dist/tools/preview-changes.d.ts +50 -0
- package/dist/tools/preview-changes.js +55 -0
- package/dist/tools/run-sql.d.ts +40 -0
- package/dist/tools/run-sql.js +212 -0
- package/dist/tools/search-tracks.d.ts +57 -0
- package/dist/tools/search-tracks.js +175 -0
- package/dist/tools/stage-crate.d.ts +52 -0
- package/dist/tools/stage-crate.js +255 -0
- package/dist/version.d.ts +17 -0
- package/dist/version.js +18 -0
- package/package.json +60 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { err } from "../errors.js";
|
|
4
|
+
import { ANCHOR_SPACE_NAME } from "../read/crates.js";
|
|
5
|
+
/** The tables root.sqlite must have for this protocol to write to it. */
|
|
6
|
+
export const REQUIRED_ROOT_TABLES = [
|
|
7
|
+
"serato",
|
|
8
|
+
"master",
|
|
9
|
+
"space",
|
|
10
|
+
"container",
|
|
11
|
+
"container_asset",
|
|
12
|
+
"space_asset",
|
|
13
|
+
"asset",
|
|
14
|
+
];
|
|
15
|
+
const refuse = (reason, message, extra = {}) => err("write_refused", message, { reason, rejected_track_ids: [], ...extra });
|
|
16
|
+
export function checkRootSchema(root) {
|
|
17
|
+
const tables = new Set(root.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
|
|
18
|
+
const missing = REQUIRED_ROOT_TABLES.filter((t) => !tables.has(t));
|
|
19
|
+
const seratoColumns = tables.has("serato")
|
|
20
|
+
? root.prepare("PRAGMA table_info('serato')").all().map((r) => r.name)
|
|
21
|
+
: [];
|
|
22
|
+
if (missing.length > 0 || !seratoColumns.includes("revision")) {
|
|
23
|
+
return refuse("root_schema_unsupported", "root.sqlite does not have the schema this protocol writes", {
|
|
24
|
+
missing_tables: missing,
|
|
25
|
+
serato_has_revision: seratoColumns.includes("revision"),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Both queries mirror how Serato itself resolves them. The space by name
|
|
32
|
+
* (space is UNIQUE on name COLLATE NOCASE, so 0 or 1 rows); the root
|
|
33
|
+
* container by parent_id = 0, never by name -- its name is generated as
|
|
34
|
+
* "<space> root", and UNIQUE (parent_id, name, type) does not stop a user
|
|
35
|
+
* crate of that name at another type.
|
|
36
|
+
*/
|
|
37
|
+
export function findAnchors(root) {
|
|
38
|
+
const spaces = root
|
|
39
|
+
.prepare("SELECT id FROM space WHERE name = ? COLLATE NOCASE")
|
|
40
|
+
.all(ANCHOR_SPACE_NAME);
|
|
41
|
+
if (spaces.length !== 1) {
|
|
42
|
+
return refuse("anchor_space_missing", `root.sqlite has no "${ANCHOR_SPACE_NAME}" space`, {
|
|
43
|
+
found: spaces.length,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const containers = root
|
|
47
|
+
.prepare("SELECT c.id FROM space s JOIN container c ON s.id = c.space_id WHERE s.id = ? AND c.parent_id = 0")
|
|
48
|
+
.all(spaces[0].id);
|
|
49
|
+
if (containers.length !== 1) {
|
|
50
|
+
return refuse("anchor_container_ambiguous", "the space's root container is not exactly one row", {
|
|
51
|
+
found: containers.length,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return { spaceId: spaces[0].id, rootContainerId: containers[0].id };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* portable_id -> root.asset.id (unique index on portable_id COLLATE NOCASE,
|
|
58
|
+
* so this is one index probe per track) -> space_asset.id by (asset_id,
|
|
59
|
+
* space_id). A track without that space_asset row is unresolvable: this
|
|
60
|
+
* server's write path only ever inserts container and container_asset rows,
|
|
61
|
+
* never asset or space_asset ones.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveSpaceAssets(root, spaceId, portableIds) {
|
|
64
|
+
const stmt = root.prepare(`SELECT sa.id FROM asset a JOIN space_asset sa ON sa.asset_id = a.id
|
|
65
|
+
WHERE a.portable_id = ? COLLATE NOCASE AND sa.space_id = ?`);
|
|
66
|
+
const resolved = new Map();
|
|
67
|
+
const missing = [];
|
|
68
|
+
for (const portableId of portableIds) {
|
|
69
|
+
const row = stmt.get(portableId, spaceId);
|
|
70
|
+
if (row === undefined)
|
|
71
|
+
missing.push(portableId);
|
|
72
|
+
else
|
|
73
|
+
resolved.set(portableId, row.id);
|
|
74
|
+
}
|
|
75
|
+
return { resolved, missing };
|
|
76
|
+
}
|
|
77
|
+
/** Folds case the way container's UNIQUE(parent_id, name COLLATE NOCASE,
|
|
78
|
+
* type) does -- measured on a copy of a real library: 'sErAtO dEmO tRaCkS'
|
|
79
|
+
* collides with 'Serato Demo Tracks'. */
|
|
80
|
+
export function existingCrateId(root, rootContainerId, name) {
|
|
81
|
+
const row = root
|
|
82
|
+
.prepare("SELECT id FROM container WHERE parent_id = ? AND name = ? COLLATE NOCASE AND type = 1")
|
|
83
|
+
.get(rootContainerId, name);
|
|
84
|
+
return row === undefined ? null : row.id;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Informational only, not a gate: apply re-validates what the crate depends
|
|
88
|
+
* on instead of refusing on this value, and reports a change as a warning.
|
|
89
|
+
* Reads serato.revision only -- never the master table, whose
|
|
90
|
+
* last_sync_secret does not fit a JavaScript number (measured 2026-09-14).
|
|
91
|
+
*/
|
|
92
|
+
export function rootGeneration(root, rootPath, libraryId) {
|
|
93
|
+
const { revision } = root.prepare("SELECT revision FROM serato").get();
|
|
94
|
+
const st = statSync(rootPath);
|
|
95
|
+
return createHash("sha256")
|
|
96
|
+
.update(`${libraryId}|${revision}|${st.mtimeMs}|${st.size}`)
|
|
97
|
+
.digest("hex")
|
|
98
|
+
.slice(0, 12);
|
|
99
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type SeratoError } from "../errors.js";
|
|
2
|
+
export type LockRow = {
|
|
3
|
+
owner_process_id: number | null;
|
|
4
|
+
owner_process_name: string | null;
|
|
5
|
+
};
|
|
6
|
+
export type ProcessProbe = {
|
|
7
|
+
isAlive(pid: number): boolean;
|
|
8
|
+
nameOf(pid: number): string | null;
|
|
9
|
+
};
|
|
10
|
+
export declare const systemProbe: ProcessProbe;
|
|
11
|
+
/**
|
|
12
|
+
* Reads master.sqlite's lock table from the LIVE file, read-only. Every other
|
|
13
|
+
* read in this server goes through a snapshot copy; this one cannot, because
|
|
14
|
+
* a snapshot describes the past and "is Serato running now" is a question
|
|
15
|
+
* about the present.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readLockRows(masterPath: string): LockRow[] | SeratoError;
|
|
18
|
+
/**
|
|
19
|
+
* A lock row counts only if its process is alive AND is still called what
|
|
20
|
+
* the row says. The row survives an unclean shutdown -- measured 2026-09-03
|
|
21
|
+
* after SIGTERM, and live on 2026-09-14 for pid 73438 -- and the OS recycles
|
|
22
|
+
* pids, so either check alone gives a permanent false "running".
|
|
23
|
+
*/
|
|
24
|
+
export declare function findLiveSerato(rows: LockRow[], probe: ProcessProbe): {
|
|
25
|
+
pid: number;
|
|
26
|
+
name: string;
|
|
27
|
+
} | null;
|
|
28
|
+
export declare function checkSeratoClosed(masterPath: string, probe?: ProcessProbe): null | SeratoError;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { err } from "../errors.js";
|
|
5
|
+
export const systemProbe = {
|
|
6
|
+
isAlive(pid) {
|
|
7
|
+
try {
|
|
8
|
+
process.kill(pid, 0);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
// EPERM: the process exists and belongs to another user -- that is a
|
|
13
|
+
// live process, not a dead one.
|
|
14
|
+
return e.code === "EPERM";
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
nameOf(pid) {
|
|
18
|
+
try {
|
|
19
|
+
const out = execFileSync("ps", ["-p", String(pid), "-o", "comm="], {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
22
|
+
});
|
|
23
|
+
return out.trim() === "" ? null : out.trim();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Reads master.sqlite's lock table from the LIVE file, read-only. Every other
|
|
32
|
+
* read in this server goes through a snapshot copy; this one cannot, because
|
|
33
|
+
* a snapshot describes the past and "is Serato running now" is a question
|
|
34
|
+
* about the present.
|
|
35
|
+
*/
|
|
36
|
+
export function readLockRows(masterPath) {
|
|
37
|
+
let db;
|
|
38
|
+
try {
|
|
39
|
+
db = new DatabaseSync(masterPath, { readOnly: true });
|
|
40
|
+
db.exec("PRAGMA busy_timeout = 3000");
|
|
41
|
+
const hasTable = db
|
|
42
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lock'")
|
|
43
|
+
.get();
|
|
44
|
+
if (hasTable === undefined)
|
|
45
|
+
return [];
|
|
46
|
+
return db.prepare("SELECT owner_process_id, owner_process_name FROM lock").all();
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
// Unable to tell whether Serato is running is a refusal to write, never a
|
|
50
|
+
// guess that it is not.
|
|
51
|
+
return err("write_refused", `cannot read master.sqlite's lock table: ${String(e)}`, {
|
|
52
|
+
reason: "lock_unreadable",
|
|
53
|
+
rejected_track_ids: [],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
db?.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A lock row counts only if its process is alive AND is still called what
|
|
62
|
+
* the row says. The row survives an unclean shutdown -- measured 2026-09-03
|
|
63
|
+
* after SIGTERM, and live on 2026-09-14 for pid 73438 -- and the OS recycles
|
|
64
|
+
* pids, so either check alone gives a permanent false "running".
|
|
65
|
+
*/
|
|
66
|
+
export function findLiveSerato(rows, probe) {
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
const pid = row.owner_process_id;
|
|
69
|
+
const expected = row.owner_process_name;
|
|
70
|
+
if (pid === null || expected === null)
|
|
71
|
+
continue;
|
|
72
|
+
if (!probe.isAlive(pid))
|
|
73
|
+
continue;
|
|
74
|
+
const actual = probe.nameOf(pid);
|
|
75
|
+
if (actual === null) {
|
|
76
|
+
// The name could not be read. If the process died in between, the row
|
|
77
|
+
// is stale; if it is still alive, not knowing what it is must refuse,
|
|
78
|
+
// never guess -- the same rule readLockRows follows.
|
|
79
|
+
if (probe.isAlive(pid))
|
|
80
|
+
return { pid, name: expected };
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// `ps -o comm=` prints the full executable path for an app bundle on
|
|
84
|
+
// macOS; the row holds the bare application name.
|
|
85
|
+
if (actual === expected || basename(actual) === expected || actual.endsWith(`/${expected}`)) {
|
|
86
|
+
return { pid, name: expected };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
export function checkSeratoClosed(masterPath, probe = systemProbe) {
|
|
92
|
+
const rows = readLockRows(masterPath);
|
|
93
|
+
if (!Array.isArray(rows))
|
|
94
|
+
return rows;
|
|
95
|
+
const live = findLiveSerato(rows, probe);
|
|
96
|
+
if (live === null)
|
|
97
|
+
return null;
|
|
98
|
+
return err("serato_running", `${live.name} is running; quit it before applying changes`, {
|
|
99
|
+
owner_process_id: live.pid,
|
|
100
|
+
owner_process_name: live.name,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Warning } from "../envelope.js";
|
|
2
|
+
import { type SeratoError } from "../errors.js";
|
|
3
|
+
import type { StagedCrate } from "../stage/store.js";
|
|
4
|
+
import type { BackupPaths } from "./backup.js";
|
|
5
|
+
import { type ProcessProbe } from "./serato.js";
|
|
6
|
+
/** SQLITE_BUSY becomes busy with retry_after_ms equal to this. */
|
|
7
|
+
export declare const BUSY_TIMEOUT_MS = 3000;
|
|
8
|
+
export type AppliedCrate = {
|
|
9
|
+
staged_id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
container_id: number;
|
|
12
|
+
track_count: number;
|
|
13
|
+
};
|
|
14
|
+
export type ApplyOutcome = {
|
|
15
|
+
applied: AppliedCrate[];
|
|
16
|
+
revision: number;
|
|
17
|
+
warnings: Warning[];
|
|
18
|
+
};
|
|
19
|
+
export type ApplyInput = {
|
|
20
|
+
rootPath: string;
|
|
21
|
+
masterPath: string;
|
|
22
|
+
libraryId: string;
|
|
23
|
+
crates: StagedCrate[];
|
|
24
|
+
stagedRootGeneration: string;
|
|
25
|
+
backupPaths: BackupPaths;
|
|
26
|
+
probe?: ProcessProbe;
|
|
27
|
+
};
|
|
28
|
+
export declare function applyCrates(input: ApplyInput): ApplyOutcome | SeratoError;
|
|
29
|
+
/**
|
|
30
|
+
* After COMMIT, on a NEW connection -- the only thing that makes
|
|
31
|
+
* committed_unverified a reachable state rather than a hope. A failure here
|
|
32
|
+
* means the data may well be in the file; the caller gets the backup paths.
|
|
33
|
+
*/
|
|
34
|
+
export declare function verifyCommitted(input: ApplyInput, outcome: ApplyOutcome): SeratoError | null;
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { err, isSeratoError } from "../errors.js";
|
|
4
|
+
import { sameCrateName } from "../stage/name.js";
|
|
5
|
+
import { checkRootSchema, existingCrateId, findAnchors, resolveSpaceAssets, rootGeneration, } from "./root.js";
|
|
6
|
+
import { checkSeratoClosed, systemProbe } from "./serato.js";
|
|
7
|
+
import { isSqliteBusy } from "./sqlite.js";
|
|
8
|
+
/** SQLITE_BUSY becomes busy with retry_after_ms equal to this. */
|
|
9
|
+
export const BUSY_TIMEOUT_MS = 3000;
|
|
10
|
+
/** node:sqlite's extended code for a UNIQUE violation, measured 2026-09-14.
|
|
11
|
+
* The primary code 19 is shared with NOT NULL (1299), CHECK and FK, so only
|
|
12
|
+
* the extended code identifies a name collision. */
|
|
13
|
+
const SQLITE_CONSTRAINT_UNIQUE = 2067;
|
|
14
|
+
export function applyCrates(input) {
|
|
15
|
+
const written = writeInTransaction(input);
|
|
16
|
+
if (isSeratoError(written))
|
|
17
|
+
return written;
|
|
18
|
+
return verifyCommitted(input, written) ?? written;
|
|
19
|
+
}
|
|
20
|
+
function writeInTransaction(input) {
|
|
21
|
+
const probe = input.probe ?? systemProbe;
|
|
22
|
+
const notCommitted = (stage, message, extra = {}) => err("write_failed_not_committed", message, { stage, ...extra });
|
|
23
|
+
// Checked before opening: `new DatabaseSync(path)` creates a 0-byte
|
|
24
|
+
// root.sqlite for a path that does not exist yet, which must never happen.
|
|
25
|
+
if (!existsSync(input.rootPath)) {
|
|
26
|
+
return err("write_refused", "this library has no root.sqlite to write crates into", {
|
|
27
|
+
reason: "root_missing",
|
|
28
|
+
rejected_track_ids: [],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
let db;
|
|
32
|
+
try {
|
|
33
|
+
db = new DatabaseSync(input.rootPath);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
return notCommitted("open", `cannot open root.sqlite: ${String(e)}`);
|
|
37
|
+
}
|
|
38
|
+
const rollback = () => {
|
|
39
|
+
try {
|
|
40
|
+
if (db.isTransaction)
|
|
41
|
+
db.exec("ROLLBACK");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// A failed ROLLBACK leaves nothing to add: SQLite rolls back an open
|
|
45
|
+
// transaction when the connection closes, which the finally does.
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
// Explicit even though node:sqlite enables foreign keys by default: the
|
|
50
|
+
// protocol must not depend on a binding's default.
|
|
51
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
52
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
53
|
+
try {
|
|
54
|
+
db.exec("BEGIN IMMEDIATE");
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
if (isSqliteBusy(e)) {
|
|
58
|
+
return err("busy", `root.sqlite is locked by another writer: ${String(e)}`, {
|
|
59
|
+
retry_after_ms: BUSY_TIMEOUT_MS,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// A file that is not a database fails the same way every time, so it
|
|
63
|
+
// must never invite a retry.
|
|
64
|
+
return notCommitted("begin", `cannot start the write transaction: ${String(e)}`);
|
|
65
|
+
}
|
|
66
|
+
// Checked again, now that we hold the write lock: Serato may have
|
|
67
|
+
// started between the caller's first check and this BEGIN.
|
|
68
|
+
const running = checkSeratoClosed(input.masterPath, probe);
|
|
69
|
+
if (running !== null) {
|
|
70
|
+
rollback();
|
|
71
|
+
return running;
|
|
72
|
+
}
|
|
73
|
+
const schema = checkRootSchema(db);
|
|
74
|
+
if (schema !== null) {
|
|
75
|
+
rollback();
|
|
76
|
+
return schema;
|
|
77
|
+
}
|
|
78
|
+
const anchors = findAnchors(db);
|
|
79
|
+
if (isSeratoError(anchors)) {
|
|
80
|
+
rollback();
|
|
81
|
+
return anchors;
|
|
82
|
+
}
|
|
83
|
+
// The triggers only ever raise space.revision TO serato.revision, so a
|
|
84
|
+
// space already ahead of it stays ahead after this write's bump: the
|
|
85
|
+
// pre-COMMIT check below would then refuse every apply behind a
|
|
86
|
+
// misleading "revision unmoved" failure, forever. Never observed: the
|
|
87
|
+
// space revision has been seen equal to the library's (72/72 on the live
|
|
88
|
+
// library) and one behind it (13/12 in an earlier run, both moved to 14
|
|
89
|
+
// by that write), never ahead of it -- but cheap to rule out up front.
|
|
90
|
+
const revisionsBefore = db
|
|
91
|
+
.prepare("SELECT (SELECT revision FROM serato) AS serato, (SELECT revision FROM space WHERE id = ?) AS space")
|
|
92
|
+
.get(anchors.spaceId);
|
|
93
|
+
if (revisionsBefore.space > revisionsBefore.serato) {
|
|
94
|
+
rollback();
|
|
95
|
+
return err("write_refused", "the crate space's revision is already ahead of serato's", {
|
|
96
|
+
reason: "space_revision_ahead",
|
|
97
|
+
rejected_track_ids: [],
|
|
98
|
+
serato_revision: revisionsBefore.serato,
|
|
99
|
+
space_revision: revisionsBefore.space,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const warnings = [];
|
|
103
|
+
// Informational only: what the crate actually depends on is re-validated
|
|
104
|
+
// below instead.
|
|
105
|
+
if (rootGeneration(db, input.rootPath, input.libraryId) !== input.stagedRootGeneration) {
|
|
106
|
+
warnings.push({
|
|
107
|
+
code: "root_generation_changed",
|
|
108
|
+
message: "root.sqlite changed since these crates were staged; everything they depend on was re-checked and still holds",
|
|
109
|
+
details: { staged_root_generation: input.stagedRootGeneration },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
for (let i = 0; i < input.crates.length; i += 1) {
|
|
113
|
+
for (let j = 0; j < i; j += 1) {
|
|
114
|
+
if (sameCrateName(input.crates[i].name, input.crates[j].name)) {
|
|
115
|
+
rollback();
|
|
116
|
+
return err("invalid_crate_name", `two staged crates are both named "${input.crates[i].name}"`, {
|
|
117
|
+
reason: "duplicate_in_batch",
|
|
118
|
+
staged_ids: [input.crates[j].staged_id, input.crates[i].staged_id],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Re-validation: name conflicts first, because a conflict makes the whole
|
|
124
|
+
// batch meaningless regardless of tracks.
|
|
125
|
+
const resolvedByCrate = new Map();
|
|
126
|
+
const unresolvable = [];
|
|
127
|
+
for (const crate of input.crates) {
|
|
128
|
+
const conflict = existingCrateId(db, anchors.rootContainerId, crate.name);
|
|
129
|
+
if (conflict !== null) {
|
|
130
|
+
rollback();
|
|
131
|
+
return err("crate_name_conflict", `a crate named "${crate.name}" already exists`, {
|
|
132
|
+
existing_container_id: conflict,
|
|
133
|
+
crate_name: crate.name,
|
|
134
|
+
staged_id: crate.staged_id,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const { resolved, missing } = resolveSpaceAssets(db, anchors.spaceId, crate.tracks.map((t) => t.portable_id));
|
|
138
|
+
resolvedByCrate.set(crate.staged_id, resolved);
|
|
139
|
+
const missingSet = new Set(missing);
|
|
140
|
+
for (const t of crate.tracks)
|
|
141
|
+
if (missingSet.has(t.portable_id))
|
|
142
|
+
unresolvable.push(t.track_id);
|
|
143
|
+
}
|
|
144
|
+
if (unresolvable.length > 0) {
|
|
145
|
+
rollback();
|
|
146
|
+
return err("write_refused", "some staged tracks are no longer in the Serato Library space", {
|
|
147
|
+
reason: "tracks_no_longer_resolve",
|
|
148
|
+
rejected_track_ids: [...new Set(unresolvable)],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// The foreign-key check is about THIS write, so it is a count before and
|
|
152
|
+
// after rather than a demand for zero: a library that already carries an
|
|
153
|
+
// orphan row would otherwise refuse every apply forever, over damage the
|
|
154
|
+
// write did not cause. (The live root had none on 2026-09-15.)
|
|
155
|
+
const foreignKeyViolations = () => db.prepare("PRAGMA foreign_key_check(container)").all().length +
|
|
156
|
+
db.prepare("PRAGMA foreign_key_check(container_asset)").all().length;
|
|
157
|
+
const fkBefore = foreignKeyViolations();
|
|
158
|
+
// (A) revision first. The space-revision triggers ASSIGN
|
|
159
|
+
// space.revision := serato.revision under space.revision < serato.revision,
|
|
160
|
+
// so bumping after the inserts would leave the space revision unmoved and
|
|
161
|
+
// the crate invisible to Serato.
|
|
162
|
+
db.exec("UPDATE serato SET revision = COALESCE(revision, 0) + 1");
|
|
163
|
+
const { revision } = db.prepare("SELECT revision FROM serato").get();
|
|
164
|
+
const insertContainer = db.prepare(`INSERT INTO container (revision, parent_id, name, type, list_order, space_id, expanded, portable_id, color)
|
|
165
|
+
VALUES (?, ?, ?, 1,
|
|
166
|
+
(SELECT COALESCE(MAX(list_order), 0) + 1 FROM container WHERE parent_id = ?),
|
|
167
|
+
?, 0, '', NULL)`);
|
|
168
|
+
const insertTrack = db.prepare("INSERT INTO container_asset (revision, container_id, space_asset_id, list_order) VALUES (?, ?, ?, ?)");
|
|
169
|
+
const applied = [];
|
|
170
|
+
for (const crate of input.crates) {
|
|
171
|
+
let containerId;
|
|
172
|
+
try {
|
|
173
|
+
// (B) no explicit id: AUTOINCREMENT keeps sqlite_sequence right, and an
|
|
174
|
+
// explicit id could collide with ids Serato hands out later. The rowid
|
|
175
|
+
// is read from THIS statement's result, before any other insert can
|
|
176
|
+
// overwrite last_insert_rowid.
|
|
177
|
+
containerId = Number(insertContainer.run(revision, anchors.rootContainerId, crate.name, anchors.rootContainerId, anchors.spaceId).lastInsertRowid);
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
if (e.errcode === SQLITE_CONSTRAINT_UNIQUE) {
|
|
181
|
+
// Read before rollback: a collision with a crate this same batch
|
|
182
|
+
// inserted earlier only still has an id while that insert stands.
|
|
183
|
+
const existingContainerId = existingCrateId(db, anchors.rootContainerId, crate.name);
|
|
184
|
+
rollback();
|
|
185
|
+
return err("crate_name_conflict", `a crate named "${crate.name}" already exists`, {
|
|
186
|
+
existing_container_id: existingContainerId,
|
|
187
|
+
crate_name: crate.name,
|
|
188
|
+
staged_id: crate.staged_id,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
rollback();
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
194
|
+
const resolved = resolvedByCrate.get(crate.staged_id) ?? new Map();
|
|
195
|
+
// (C) list_order 1..N in the staged order, which is the running order.
|
|
196
|
+
crate.tracks.forEach((track, index) => {
|
|
197
|
+
const spaceAssetId = resolved.get(track.portable_id);
|
|
198
|
+
if (spaceAssetId === undefined) {
|
|
199
|
+
throw new Error(`staged track ${track.portable_id} lost its resolution mid-transaction`);
|
|
200
|
+
}
|
|
201
|
+
insertTrack.run(revision, containerId, spaceAssetId, index + 1);
|
|
202
|
+
});
|
|
203
|
+
applied.push({
|
|
204
|
+
staged_id: crate.staged_id,
|
|
205
|
+
name: crate.name,
|
|
206
|
+
container_id: containerId,
|
|
207
|
+
track_count: crate.tracks.length,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// Before COMMIT: targeted checks instead of quick_check over the whole
|
|
211
|
+
// database.
|
|
212
|
+
const problems = [];
|
|
213
|
+
const fkAfter = foreignKeyViolations();
|
|
214
|
+
if (fkAfter > fkBefore) {
|
|
215
|
+
problems.push(`foreign_key_check found ${fkAfter - fkBefore} new violation(s)`);
|
|
216
|
+
}
|
|
217
|
+
const space = db.prepare("SELECT revision FROM space WHERE id = ?").get(anchors.spaceId);
|
|
218
|
+
if (space.revision !== revision) {
|
|
219
|
+
problems.push(`space.revision is ${space.revision}, expected ${revision}`);
|
|
220
|
+
}
|
|
221
|
+
for (const a of applied) {
|
|
222
|
+
const found = db
|
|
223
|
+
.prepare("SELECT 1 FROM container WHERE id = ? AND type = 1 AND parent_id = ?")
|
|
224
|
+
.get(a.container_id, anchors.rootContainerId);
|
|
225
|
+
const { n } = db
|
|
226
|
+
.prepare("SELECT count(*) AS n FROM container_asset WHERE container_id = ?")
|
|
227
|
+
.get(a.container_id);
|
|
228
|
+
if (found === undefined)
|
|
229
|
+
problems.push(`container ${a.container_id} is not readable`);
|
|
230
|
+
if (n !== a.track_count)
|
|
231
|
+
problems.push(`container ${a.container_id} has ${n} tracks, expected ${a.track_count}`);
|
|
232
|
+
}
|
|
233
|
+
if (problems.length > 0) {
|
|
234
|
+
rollback();
|
|
235
|
+
return notCommitted("verify_in_transaction", "the write did not check out and was rolled back", {
|
|
236
|
+
problems,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
db.exec("COMMIT");
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
if (db.isTransaction) {
|
|
244
|
+
// SQLite kept the transaction open: nothing was committed. Busy here
|
|
245
|
+
// is the expected cause -- a reader on another connection holds
|
|
246
|
+
// SHARED, e.g. stage_crate on a second server instance -- so it is
|
|
247
|
+
// contention, not failure, and gets a retry hint like any other.
|
|
248
|
+
rollback();
|
|
249
|
+
if (isSqliteBusy(e)) {
|
|
250
|
+
return err("busy", `COMMIT is blocked by another reader: ${String(e)}`, {
|
|
251
|
+
retry_after_ms: BUSY_TIMEOUT_MS,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return notCommitted("commit", `COMMIT failed and the write was rolled back: ${String(e)}`);
|
|
255
|
+
}
|
|
256
|
+
// SQLite has already rolled back on its own here -- measured for
|
|
257
|
+
// IOERR_DELETE, which leaves a hot journal for the next opener.
|
|
258
|
+
return notCommitted("commit", `COMMIT failed and the write was rolled back: ${String(e)}`);
|
|
259
|
+
}
|
|
260
|
+
return { applied, revision, warnings };
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
rollback();
|
|
264
|
+
if (isSqliteBusy(e)) {
|
|
265
|
+
// Defensive, not observed: measured with 200k container_asset rows
|
|
266
|
+
// inserted while a reader held SHARED for 60s, a page-cache spill does
|
|
267
|
+
// not surface as busy -- the transaction waited 60.4s under
|
|
268
|
+
// busy_timeout and committed, rather than throwing here. Kept in case
|
|
269
|
+
// some other mid-transaction contention does throw busy at this point.
|
|
270
|
+
return err("busy", `root.sqlite became busy mid-transaction: ${String(e)}`, {
|
|
271
|
+
retry_after_ms: BUSY_TIMEOUT_MS,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return notCommitted("transaction", `the write failed and was rolled back: ${String(e)}`);
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
db.close();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* After COMMIT, on a NEW connection -- the only thing that makes
|
|
282
|
+
* committed_unverified a reachable state rather than a hope. A failure here
|
|
283
|
+
* means the data may well be in the file; the caller gets the backup paths.
|
|
284
|
+
*/
|
|
285
|
+
export function verifyCommitted(input, outcome) {
|
|
286
|
+
const unverified = (message, problems = []) => err("write_failed_committed_unverified", message, {
|
|
287
|
+
stage: "verify_after_commit",
|
|
288
|
+
backup_paths: input.backupPaths,
|
|
289
|
+
problems,
|
|
290
|
+
});
|
|
291
|
+
let db;
|
|
292
|
+
try {
|
|
293
|
+
db = new DatabaseSync(input.rootPath, { readOnly: true });
|
|
294
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
295
|
+
const problems = [];
|
|
296
|
+
const { revision } = db.prepare("SELECT revision FROM serato").get();
|
|
297
|
+
if (revision !== outcome.revision)
|
|
298
|
+
problems.push(`serato.revision is ${revision}, expected ${outcome.revision}`);
|
|
299
|
+
if (outcome.applied.length > 0) {
|
|
300
|
+
// The applied crates all share one space (findAnchors resolves exactly
|
|
301
|
+
// one), so the first container's own space_id identifies it, without
|
|
302
|
+
// this read-only connection re-deriving anchors a second way.
|
|
303
|
+
const spaceRevision = db
|
|
304
|
+
.prepare("SELECT revision FROM space WHERE id = (SELECT space_id FROM container WHERE id = ?)")
|
|
305
|
+
.get(outcome.applied[0].container_id);
|
|
306
|
+
if (spaceRevision?.revision !== outcome.revision) {
|
|
307
|
+
problems.push(`space.revision is ${spaceRevision?.revision}, expected ${outcome.revision}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (const a of outcome.applied) {
|
|
311
|
+
const row = db.prepare("SELECT name FROM container WHERE id = ?").get(a.container_id);
|
|
312
|
+
const { n } = db
|
|
313
|
+
.prepare("SELECT count(*) AS n FROM container_asset WHERE container_id = ?")
|
|
314
|
+
.get(a.container_id);
|
|
315
|
+
if (row?.name !== a.name)
|
|
316
|
+
problems.push(`container ${a.container_id} does not read back as "${a.name}"`);
|
|
317
|
+
if (n !== a.track_count)
|
|
318
|
+
problems.push(`container ${a.container_id} reads back ${n} tracks`);
|
|
319
|
+
}
|
|
320
|
+
return problems.length === 0
|
|
321
|
+
? null
|
|
322
|
+
: unverified("the committed write did not read back", problems);
|
|
323
|
+
}
|
|
324
|
+
catch (e) {
|
|
325
|
+
return unverified(`the committed write could not be read back: ${String(e)}`);
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
db?.close();
|
|
329
|
+
}
|
|
330
|
+
}
|
package/dist/args.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { type SeratoError } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* The single place raw tool arguments become typed ones, deliberately the
|
|
5
|
+
* only one: engine-dj-mcp's defect was that some tools called .parse() and
|
|
6
|
+
* others safeParse(), so one class of problem -- a malformed argument --
|
|
7
|
+
* reached the model in two different shapes.
|
|
8
|
+
*
|
|
9
|
+
* Every failure comes back as an `invalid_argument` value carrying the
|
|
10
|
+
* mandatory `reason`, never as a throw: a thrown ZodError is converted by
|
|
11
|
+
* the MCP layer into prose with no code at all (measured 2026-09-06:
|
|
12
|
+
* `{"sql":"","limit":1000}` came back as isError text reading "MCP error
|
|
13
|
+
* -32602: Input validation error: Invalid arguments for tool run_sql: Too
|
|
14
|
+
* small ..."), which the model cannot dispatch on and which contradicts the
|
|
15
|
+
* taxonomy in errors.ts.
|
|
16
|
+
*
|
|
17
|
+
* `reason` defaults to "schema_violation" but a check can name its own by
|
|
18
|
+
* passing `params: { reason: "..." }` to .refine()/.superRefine(). That is
|
|
19
|
+
* how this server's cross-field refusals -- `around` together with
|
|
20
|
+
* `min`/`max`, `crate.id` together with `crate.name`, a cursor that does not
|
|
21
|
+
* match its query -- get their specific reason without a second error path:
|
|
22
|
+
* they are refinements on the tool's own schema, and they land here like any
|
|
23
|
+
* other issue.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseToolArgs<T extends z.ZodType>(schema: T, raw: unknown): z.output<T> | SeratoError;
|