mdkg 0.1.6 → 0.1.7

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.
@@ -0,0 +1,510 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolveContainedProjectDbPath = resolveContainedProjectDbPath;
7
+ exports.sealProjectDbSnapshot = sealProjectDbSnapshot;
8
+ exports.verifyProjectDbSnapshot = verifyProjectDbSnapshot;
9
+ exports.projectDbSnapshotStatus = projectDbSnapshotStatus;
10
+ exports.canonicalDumpForSnapshot = canonicalDumpForSnapshot;
11
+ exports.dumpProjectDbSnapshot = dumpProjectDbSnapshot;
12
+ exports.diffProjectDbSnapshots = diffProjectDbSnapshots;
13
+ const crypto_1 = __importDefault(require("crypto"));
14
+ const fs_1 = __importDefault(require("fs"));
15
+ const path_1 = __importDefault(require("path"));
16
+ const project_db_1 = require("./project_db");
17
+ const version_1 = require("./version");
18
+ const atomic_1 = require("../util/atomic");
19
+ const errors_1 = require("../util/errors");
20
+ const project_db_migrations_1 = require("./project_db_migrations");
21
+ function loadDatabaseCtor() {
22
+ try {
23
+ const loaded = require("node:sqlite");
24
+ if (!loaded.DatabaseSync) {
25
+ throw new Error("node:sqlite DatabaseSync is unavailable");
26
+ }
27
+ return loaded.DatabaseSync;
28
+ }
29
+ catch (err) {
30
+ const message = err instanceof Error ? err.message : String(err);
31
+ throw new Error(`node:sqlite is required for mdkg project DB snapshots: ${message}`);
32
+ }
33
+ }
34
+ function toPosix(relativePath) {
35
+ return relativePath.split(path_1.default.sep).join("/");
36
+ }
37
+ function rel(root, filePath) {
38
+ return toPosix(path_1.default.relative(root, filePath));
39
+ }
40
+ function isInside(root, filePath) {
41
+ const relative = path_1.default.relative(root, filePath);
42
+ return relative === "" || (!relative.startsWith("..") && !path_1.default.isAbsolute(relative));
43
+ }
44
+ function resolveContainedProjectDbPath(root, rawPath, label) {
45
+ if (rawPath.trim().length === 0) {
46
+ throw new errors_1.UsageError(`${label} requires a non-empty path`);
47
+ }
48
+ const resolved = path_1.default.resolve(root, rawPath);
49
+ if (!isInside(root, resolved)) {
50
+ throw new errors_1.UsageError(`${label} must be inside the repo`);
51
+ }
52
+ return resolved;
53
+ }
54
+ function sha256Buffer(data) {
55
+ return `sha256:${crypto_1.default.createHash("sha256").update(data).digest("hex")}`;
56
+ }
57
+ function sha256File(filePath) {
58
+ return `sha256:${crypto_1.default.createHash("sha256").update(fs_1.default.readFileSync(filePath)).digest("hex")}`;
59
+ }
60
+ function quoteIdentifier(value) {
61
+ return `"${value.replace(/"/g, '""')}"`;
62
+ }
63
+ function quoteSqlString(value) {
64
+ return `'${value.replace(/'/g, "''")}'`;
65
+ }
66
+ function tableNames(db) {
67
+ return db
68
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name ASC")
69
+ .all()
70
+ .map((row) => String(row.name));
71
+ }
72
+ function tableCounts(db) {
73
+ return tableNames(db).map((name) => {
74
+ const row = db.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(name)}`).get();
75
+ return { name, row_count: Number(row?.count ?? 0) };
76
+ });
77
+ }
78
+ function readMigrations(db, tableName) {
79
+ const exists = db
80
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
81
+ .get(tableName);
82
+ if (!exists) {
83
+ return [];
84
+ }
85
+ return db
86
+ .prepare(`SELECT migration_key, ordinal, checksum, applied_at_ms FROM ${quoteIdentifier(tableName)} ORDER BY ordinal ASC`)
87
+ .all()
88
+ .map((row) => ({
89
+ migration_key: String(row.migration_key),
90
+ ordinal: Number(row.ordinal),
91
+ checksum: String(row.checksum),
92
+ applied_at_ms: Number(row.applied_at_ms),
93
+ }));
94
+ }
95
+ function assertSqliteIntegrity(db, label) {
96
+ const row = db.prepare("PRAGMA integrity_check").get();
97
+ const value = String(Object.values(row ?? {})[0] ?? "");
98
+ if (value !== "ok") {
99
+ throw new errors_1.ValidationError(`${label} integrity check failed: ${value}`);
100
+ }
101
+ }
102
+ function sqliteIntegrityCheck(root, filePath) {
103
+ const DatabaseSync = loadDatabaseCtor();
104
+ try {
105
+ const db = new DatabaseSync(filePath);
106
+ try {
107
+ assertSqliteIntegrity(db, "snapshot");
108
+ return {
109
+ name: "sqlite-integrity",
110
+ ok: true,
111
+ level: "ok",
112
+ path: rel(root, filePath),
113
+ detail: "snapshot SQLite integrity check ok",
114
+ errors: [],
115
+ warnings: [],
116
+ };
117
+ }
118
+ finally {
119
+ db.close();
120
+ }
121
+ }
122
+ catch (err) {
123
+ const message = err instanceof Error ? err.message : String(err);
124
+ return {
125
+ name: "sqlite-integrity",
126
+ ok: false,
127
+ level: "fail",
128
+ path: rel(root, filePath),
129
+ detail: "failed to verify snapshot SQLite integrity",
130
+ errors: [`snapshot SQLite integrity failed: ${message}`],
131
+ warnings: [],
132
+ };
133
+ }
134
+ }
135
+ function collectSnapshotMetadata(filePath, migrationTable) {
136
+ const DatabaseSync = loadDatabaseCtor();
137
+ const db = new DatabaseSync(filePath);
138
+ try {
139
+ assertSqliteIntegrity(db, "snapshot");
140
+ return {
141
+ table_counts: tableCounts(db),
142
+ migrations: readMigrations(db, migrationTable),
143
+ };
144
+ }
145
+ finally {
146
+ db.close();
147
+ }
148
+ }
149
+ function readManifest(filePath) {
150
+ let parsed;
151
+ try {
152
+ parsed = JSON.parse(fs_1.default.readFileSync(filePath, "utf8"));
153
+ }
154
+ catch (err) {
155
+ const message = err instanceof Error ? err.message : String(err);
156
+ throw new errors_1.ValidationError(`failed to read snapshot manifest: ${message}`);
157
+ }
158
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
159
+ throw new errors_1.ValidationError("snapshot manifest must be a JSON object");
160
+ }
161
+ const manifest = parsed;
162
+ if (manifest.manifest_version !== 1 ||
163
+ manifest.tool !== "mdkg" ||
164
+ manifest.kind !== "project_db_snapshot" ||
165
+ typeof manifest.snapshot_sha256 !== "string" ||
166
+ typeof manifest.byte_size !== "number" ||
167
+ !Array.isArray(manifest.table_counts) ||
168
+ !Array.isArray(manifest.migrations)) {
169
+ throw new errors_1.ValidationError("snapshot manifest has unsupported shape");
170
+ }
171
+ return manifest;
172
+ }
173
+ function buildManifest(root, config, snapshotFile, runtimeHash) {
174
+ const layout = (0, project_db_1.resolveConfiguredProjectDbLayout)(root, config.db);
175
+ const metadata = collectSnapshotMetadata(snapshotFile, config.db.migration_table);
176
+ return {
177
+ manifest_version: 1,
178
+ tool: "mdkg",
179
+ kind: "project_db_snapshot",
180
+ mdkg_version: (0, version_1.readPackageVersion)(),
181
+ generated_at: new Date().toISOString(),
182
+ schema_version: config.db.schema_version,
183
+ migration_table: config.db.migration_table,
184
+ runtime_path: rel(root, layout.runtimeFile),
185
+ snapshot_path: rel(root, layout.stateFile),
186
+ source_runtime_sha256: runtimeHash,
187
+ snapshot_sha256: sha256File(snapshotFile),
188
+ byte_size: fs_1.default.statSync(snapshotFile).size,
189
+ table_counts: metadata.table_counts,
190
+ migrations: metadata.migrations,
191
+ };
192
+ }
193
+ function warningListFromVerify(root, config) {
194
+ return (0, project_db_migrations_1.verifyProjectDb)(root, config).warnings;
195
+ }
196
+ function sealProjectDbSnapshot(root, config) {
197
+ const verification = (0, project_db_migrations_1.verifyProjectDb)(root, config);
198
+ if (!verification.ok) {
199
+ throw new errors_1.ValidationError(`db snapshot seal requires a valid project DB; run mdkg db verify`);
200
+ }
201
+ const layout = (0, project_db_1.resolveConfiguredProjectDbLayout)(root, config.db);
202
+ const oldHash = fs_1.default.existsSync(layout.stateFile) ? sha256File(layout.stateFile) : null;
203
+ fs_1.default.mkdirSync(layout.stateDir, { recursive: true });
204
+ const tempSnapshot = path_1.default.join(layout.stateDir, `.project.sqlite.${process.pid}-${Date.now()}.tmp`);
205
+ const tempManifest = path_1.default.join(layout.stateDir, `.project.manifest.${process.pid}-${Date.now()}.tmp`);
206
+ fs_1.default.rmSync(tempSnapshot, { force: true });
207
+ fs_1.default.rmSync(tempManifest, { force: true });
208
+ const DatabaseSync = loadDatabaseCtor();
209
+ const db = new DatabaseSync(layout.runtimeFile);
210
+ try {
211
+ db.exec("PRAGMA foreign_keys = ON;");
212
+ assertSqliteIntegrity(db, "runtime project DB");
213
+ try {
214
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE);");
215
+ }
216
+ catch {
217
+ // WAL checkpoint can be a no-op or unavailable depending on journal mode.
218
+ }
219
+ db.exec(`VACUUM INTO ${quoteSqlString(tempSnapshot)}`);
220
+ }
221
+ catch (err) {
222
+ fs_1.default.rmSync(tempSnapshot, { force: true });
223
+ const message = err instanceof Error ? err.message : String(err);
224
+ throw err instanceof errors_1.ValidationError ? err : new errors_1.ValidationError(`db snapshot seal failed: ${message}`);
225
+ }
226
+ finally {
227
+ db.close();
228
+ }
229
+ try {
230
+ const runtimeHash = sha256File(layout.runtimeFile);
231
+ const manifest = buildManifest(root, config, tempSnapshot, runtimeHash);
232
+ (0, atomic_1.atomicWriteFile)(tempManifest, `${JSON.stringify(manifest, null, 2)}\n`);
233
+ fs_1.default.renameSync(tempSnapshot, layout.stateFile);
234
+ fs_1.default.renameSync(tempManifest, layout.stateManifest);
235
+ return {
236
+ action: "db-snapshot-seal",
237
+ ok: true,
238
+ snapshot: rel(root, layout.stateFile),
239
+ manifest: rel(root, layout.stateManifest),
240
+ old_snapshot_sha256: oldHash,
241
+ new_snapshot_sha256: manifest.snapshot_sha256,
242
+ source_runtime_sha256: runtimeHash,
243
+ byte_size: manifest.byte_size,
244
+ table_counts: manifest.table_counts,
245
+ migrations: manifest.migrations,
246
+ warnings: warningListFromVerify(root, config),
247
+ };
248
+ }
249
+ catch (err) {
250
+ fs_1.default.rmSync(tempSnapshot, { force: true });
251
+ fs_1.default.rmSync(tempManifest, { force: true });
252
+ throw err;
253
+ }
254
+ }
255
+ function compareJson(a, b) {
256
+ return JSON.stringify(a) === JSON.stringify(b);
257
+ }
258
+ function verifyProjectDbSnapshot(root, config) {
259
+ const layout = (0, project_db_1.resolveConfiguredProjectDbLayout)(root, config.db);
260
+ const checks = [];
261
+ const snapshotRel = rel(root, layout.stateFile);
262
+ const manifestRel = rel(root, layout.stateManifest);
263
+ checks.push({
264
+ name: "snapshot-file",
265
+ ok: fs_1.default.existsSync(layout.stateFile) && !fs_1.default.statSync(layout.stateFile).isDirectory(),
266
+ level: fs_1.default.existsSync(layout.stateFile) && !fs_1.default.statSync(layout.stateFile).isDirectory() ? "ok" : "fail",
267
+ path: snapshotRel,
268
+ detail: fs_1.default.existsSync(layout.stateFile) ? "snapshot file exists" : "snapshot file missing",
269
+ errors: fs_1.default.existsSync(layout.stateFile) ? [] : [`${snapshotRel} missing; run mdkg db snapshot seal`],
270
+ warnings: [],
271
+ });
272
+ checks.push({
273
+ name: "manifest-file",
274
+ ok: fs_1.default.existsSync(layout.stateManifest) && !fs_1.default.statSync(layout.stateManifest).isDirectory(),
275
+ level: fs_1.default.existsSync(layout.stateManifest) && !fs_1.default.statSync(layout.stateManifest).isDirectory() ? "ok" : "fail",
276
+ path: manifestRel,
277
+ detail: fs_1.default.existsSync(layout.stateManifest) ? "snapshot manifest exists" : "snapshot manifest missing",
278
+ errors: fs_1.default.existsSync(layout.stateManifest) ? [] : [`${manifestRel} missing; run mdkg db snapshot seal`],
279
+ warnings: [],
280
+ });
281
+ let manifest;
282
+ if (checks.every((check) => check.ok)) {
283
+ try {
284
+ manifest = readManifest(layout.stateManifest);
285
+ checks.push({
286
+ name: "manifest-shape",
287
+ ok: true,
288
+ level: "ok",
289
+ path: manifestRel,
290
+ detail: "snapshot manifest shape is supported",
291
+ errors: [],
292
+ warnings: [],
293
+ });
294
+ }
295
+ catch (err) {
296
+ const message = err instanceof Error ? err.message : String(err);
297
+ checks.push({
298
+ name: "manifest-shape",
299
+ ok: false,
300
+ level: "fail",
301
+ path: manifestRel,
302
+ detail: "snapshot manifest shape is invalid",
303
+ errors: [message],
304
+ warnings: [],
305
+ });
306
+ }
307
+ }
308
+ if (manifest) {
309
+ checks.push(sqliteIntegrityCheck(root, layout.stateFile));
310
+ const actualHash = sha256File(layout.stateFile);
311
+ const actualSize = fs_1.default.statSync(layout.stateFile).size;
312
+ checks.push({
313
+ name: "snapshot-hash",
314
+ ok: actualHash === manifest.snapshot_sha256,
315
+ level: actualHash === manifest.snapshot_sha256 ? "ok" : "fail",
316
+ path: snapshotRel,
317
+ detail: actualHash === manifest.snapshot_sha256 ? "snapshot hash matches manifest" : "snapshot hash mismatch",
318
+ errors: actualHash === manifest.snapshot_sha256 ? [] : [`snapshot hash mismatch: manifest ${manifest.snapshot_sha256}, actual ${actualHash}`],
319
+ warnings: [],
320
+ });
321
+ checks.push({
322
+ name: "snapshot-size",
323
+ ok: actualSize === manifest.byte_size,
324
+ level: actualSize === manifest.byte_size ? "ok" : "fail",
325
+ path: snapshotRel,
326
+ detail: actualSize === manifest.byte_size ? "snapshot byte size matches manifest" : "snapshot byte size mismatch",
327
+ errors: actualSize === manifest.byte_size ? [] : [`snapshot byte size mismatch: manifest ${manifest.byte_size}, actual ${actualSize}`],
328
+ warnings: [],
329
+ });
330
+ try {
331
+ const metadata = collectSnapshotMetadata(layout.stateFile, config.db.migration_table);
332
+ const tablesMatch = compareJson(metadata.table_counts, manifest.table_counts);
333
+ checks.push({
334
+ name: "table-counts",
335
+ ok: tablesMatch,
336
+ level: tablesMatch ? "ok" : "fail",
337
+ detail: tablesMatch ? "table counts match manifest" : "table counts mismatch",
338
+ errors: tablesMatch ? [] : ["table counts do not match snapshot manifest"],
339
+ warnings: [],
340
+ });
341
+ const migrationsMatch = compareJson(metadata.migrations, manifest.migrations);
342
+ checks.push({
343
+ name: "migrations",
344
+ ok: migrationsMatch,
345
+ level: migrationsMatch ? "ok" : "fail",
346
+ detail: migrationsMatch ? "migration metadata matches manifest" : "migration metadata mismatch",
347
+ errors: migrationsMatch ? [] : ["migration metadata does not match snapshot manifest"],
348
+ warnings: [],
349
+ });
350
+ }
351
+ catch (err) {
352
+ const message = err instanceof Error ? err.message : String(err);
353
+ checks.push({
354
+ name: "snapshot-metadata",
355
+ ok: false,
356
+ level: "fail",
357
+ detail: "failed to read snapshot metadata",
358
+ errors: [`failed to read snapshot metadata: ${message}`],
359
+ warnings: [],
360
+ });
361
+ }
362
+ if (manifest.source_runtime_sha256 && fs_1.default.existsSync(layout.runtimeFile)) {
363
+ const runtimeHash = sha256File(layout.runtimeFile);
364
+ checks.push({
365
+ name: "runtime-freshness",
366
+ ok: true,
367
+ level: runtimeHash === manifest.source_runtime_sha256 ? "ok" : "warn",
368
+ path: rel(root, layout.runtimeFile),
369
+ detail: runtimeHash === manifest.source_runtime_sha256 ? "snapshot matches current runtime hash" : "runtime changed since snapshot seal",
370
+ errors: [],
371
+ warnings: runtimeHash === manifest.source_runtime_sha256 ? [] : ["runtime database hash differs from sealed snapshot source hash"],
372
+ });
373
+ }
374
+ }
375
+ const errors = checks.flatMap((check) => check.errors.map((error) => `${check.name}: ${error}`));
376
+ const warnings = checks.flatMap((check) => check.warnings.map((warning) => `${check.name}: ${warning}`));
377
+ const hasMissing = checks.some((check) => !check.ok && /missing/.test(check.detail));
378
+ const stale = warnings.some((warning) => /runtime database hash differs/.test(warning));
379
+ return {
380
+ action: "db-snapshot-verify",
381
+ ok: errors.length === 0,
382
+ status: errors.length > 0 ? (hasMissing ? "missing" : "invalid") : stale ? "stale" : "valid",
383
+ snapshot: snapshotRel,
384
+ manifest: manifestRel,
385
+ checks,
386
+ warning_count: warnings.length,
387
+ failure_count: errors.length,
388
+ warnings,
389
+ errors,
390
+ };
391
+ }
392
+ function projectDbSnapshotStatus(root, config) {
393
+ const payload = verifyProjectDbSnapshot(root, config);
394
+ return {
395
+ ...payload,
396
+ action: "db-snapshot-status",
397
+ };
398
+ }
399
+ function schemaLines(db) {
400
+ return db
401
+ .prepare("SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type ASC, name ASC")
402
+ .all()
403
+ .map((row) => `schema ${String(row.type)} ${String(row.name)}: ${String(row.sql).replace(/\s+/g, " ").trim()}`);
404
+ }
405
+ function columnNames(db, tableName) {
406
+ return db
407
+ .prepare(`PRAGMA table_info(${quoteIdentifier(tableName)})`)
408
+ .all()
409
+ .map((row) => String(row.name));
410
+ }
411
+ function canonicalValue(value) {
412
+ if (value instanceof Uint8Array) {
413
+ const buffer = Buffer.from(value);
414
+ return {
415
+ blob_sha256: sha256Buffer(buffer),
416
+ byte_size: buffer.length,
417
+ };
418
+ }
419
+ return value;
420
+ }
421
+ function canonicalDumpForSnapshot(root, snapshotPath) {
422
+ if (!fs_1.default.existsSync(snapshotPath) || fs_1.default.statSync(snapshotPath).isDirectory()) {
423
+ throw new errors_1.ValidationError(`${rel(root, snapshotPath)} missing or not a file`);
424
+ }
425
+ const DatabaseSync = loadDatabaseCtor();
426
+ const db = new DatabaseSync(snapshotPath);
427
+ try {
428
+ assertSqliteIntegrity(db, "snapshot");
429
+ const lines = [
430
+ "# mdkg project db canonical dump v1",
431
+ `snapshot: ${rel(root, snapshotPath)}`,
432
+ `snapshot_sha256: ${sha256File(snapshotPath)}`,
433
+ "",
434
+ "# Schema",
435
+ ...schemaLines(db),
436
+ "",
437
+ "# Tables",
438
+ ];
439
+ for (const table of tableNames(db)) {
440
+ const columns = columnNames(db, table);
441
+ lines.push(`table ${table}`);
442
+ lines.push(`columns ${JSON.stringify(columns)}`);
443
+ const selectColumns = columns.map((column) => quoteIdentifier(column)).join(", ");
444
+ const orderBy = columns.map((column) => `${quoteIdentifier(column)} ASC`).join(", ");
445
+ const rows = db
446
+ .prepare(`SELECT ${selectColumns} FROM ${quoteIdentifier(table)} ORDER BY ${orderBy}`)
447
+ .all();
448
+ for (const row of rows) {
449
+ const canonicalRow = {};
450
+ for (const column of columns) {
451
+ canonicalRow[column] = canonicalValue(row[column]);
452
+ }
453
+ lines.push(`row ${JSON.stringify(canonicalRow)}`);
454
+ }
455
+ lines.push("");
456
+ }
457
+ return `${lines.join("\n").trimEnd()}\n`;
458
+ }
459
+ finally {
460
+ db.close();
461
+ }
462
+ }
463
+ function dumpProjectDbSnapshot(root, config, snapshotPath, outputPath) {
464
+ const layout = (0, project_db_1.resolveConfiguredProjectDbLayout)(root, config.db);
465
+ const resolvedSnapshot = snapshotPath
466
+ ? resolveContainedProjectDbPath(root, snapshotPath, "--snapshot")
467
+ : layout.stateFile;
468
+ const dump = canonicalDumpForSnapshot(root, resolvedSnapshot);
469
+ const dumpHash = sha256Buffer(dump);
470
+ let output = null;
471
+ if (outputPath) {
472
+ const resolvedOutput = resolveContainedProjectDbPath(root, outputPath, "--output");
473
+ (0, atomic_1.atomicWriteFile)(resolvedOutput, dump);
474
+ output = rel(root, resolvedOutput);
475
+ }
476
+ return {
477
+ action: "db-snapshot-dump",
478
+ ok: true,
479
+ snapshot: rel(root, resolvedSnapshot),
480
+ output,
481
+ line_count: dump.trimEnd().length === 0 ? 0 : dump.trimEnd().split("\n").length,
482
+ sha256: dumpHash,
483
+ dump,
484
+ };
485
+ }
486
+ function diffProjectDbSnapshots(root, leftPath, rightPath) {
487
+ const left = resolveContainedProjectDbPath(root, leftPath, "left snapshot");
488
+ const right = resolveContainedProjectDbPath(root, rightPath, "right snapshot");
489
+ const leftDump = canonicalDumpForSnapshot(root, left);
490
+ const rightDump = canonicalDumpForSnapshot(root, right);
491
+ const leftLines = leftDump.trimEnd().split("\n");
492
+ const rightLines = rightDump.trimEnd().split("\n");
493
+ const leftSet = new Set(leftLines);
494
+ const rightSet = new Set(rightLines);
495
+ const added = rightLines.filter((line) => !leftSet.has(line));
496
+ const removed = leftLines.filter((line) => !rightSet.has(line));
497
+ return {
498
+ action: "db-snapshot-diff",
499
+ ok: true,
500
+ left: rel(root, left),
501
+ right: rel(root, right),
502
+ left_sha256: sha256Buffer(leftDump),
503
+ right_sha256: sha256Buffer(rightDump),
504
+ added_count: added.length,
505
+ removed_count: removed.length,
506
+ changed_count: added.length + removed.length,
507
+ added,
508
+ removed,
509
+ };
510
+ }
@@ -250,6 +250,11 @@ function requirePortableId(value, key, filePath) {
250
250
  throw formatError(filePath, `${key} must be a lowercase portable id`);
251
251
  }
252
252
  }
253
+ function requirePortableIdRef(value, key, filePath) {
254
+ if (value !== value.toLowerCase() || !(0, id_1.isPortableIdRef)(value)) {
255
+ throw formatError(filePath, `${key} must be a lowercase portable id or qid`);
256
+ }
257
+ }
253
258
  function requireSemver(value, key, filePath) {
254
259
  if (!SEMVER_RE.test(value)) {
255
260
  throw formatError(filePath, `${key} must be a semantic version like 1.0.0`);
@@ -280,8 +285,8 @@ function validatePortableRefs(values, key, filePath) {
280
285
  if (value !== value.toLowerCase()) {
281
286
  throw formatError(filePath, `${key}[${index}] must be lowercase`);
282
287
  }
283
- if (!(0, id_1.isPortableId)(value)) {
284
- throw formatError(filePath, `${key}[${index}] must be a lowercase portable id`);
288
+ if (!(0, id_1.isPortableIdRef)(value)) {
289
+ throw formatError(filePath, `${key}[${index}] must be a lowercase portable id or qid`);
285
290
  }
286
291
  }
287
292
  }
@@ -361,7 +366,7 @@ function validateAgentFrontmatter(type, frontmatter, filePath) {
361
366
  }
362
367
  case "work": {
363
368
  const agentId = expectString(frontmatter, "agent_id", filePath);
364
- requirePortableId(agentId, "agent_id", filePath);
369
+ requirePortableIdRef(agentId, "agent_id", filePath);
365
370
  const kind = expectString(frontmatter, "kind", filePath);
366
371
  requireLowerToken(kind, "kind", filePath);
367
372
  const pricingModel = expectString(frontmatter, "pricing_model", filePath);
@@ -380,7 +385,7 @@ function validateAgentFrontmatter(type, frontmatter, filePath) {
380
385
  }
381
386
  case "work_order": {
382
387
  const workId = expectString(frontmatter, "work_id", filePath);
383
- requirePortableId(workId, "work_id", filePath);
388
+ requirePortableIdRef(workId, "work_id", filePath);
384
389
  const workVersion = expectString(frontmatter, "work_version", filePath);
385
390
  requireSemver(workVersion, "work_version", filePath);
386
391
  const requester = expectRefString(frontmatter, "requester", filePath);
@@ -402,7 +407,7 @@ function validateAgentFrontmatter(type, frontmatter, filePath) {
402
407
  }
403
408
  case "receipt": {
404
409
  const workOrderId = expectString(frontmatter, "work_order_id", filePath);
405
- requirePortableId(workOrderId, "work_order_id", filePath);
410
+ requirePortableIdRef(workOrderId, "work_order_id", filePath);
406
411
  const receiptStatus = expectString(frontmatter, "receipt_status", filePath);
407
412
  requireEnum(receiptStatus, "receipt_status", RECEIPT_STATUS_VALUES, filePath);
408
413
  const outcome = expectString(frontmatter, "outcome", filePath);
@@ -419,7 +424,7 @@ function validateAgentFrontmatter(type, frontmatter, filePath) {
419
424
  }
420
425
  case "feedback": {
421
426
  const targetId = expectString(frontmatter, "target_id", filePath);
422
- requirePortableId(targetId, "target_id", filePath);
427
+ requirePortableIdRef(targetId, "target_id", filePath);
423
428
  const sentiment = expectString(frontmatter, "sentiment", filePath);
424
429
  requireEnum(sentiment, "sentiment", SENTIMENT_VALUES, filePath);
425
430
  const feedbackStatus = expectString(frontmatter, "feedback_status", filePath);
@@ -429,9 +434,9 @@ function validateAgentFrontmatter(type, frontmatter, filePath) {
429
434
  }
430
435
  case "dispute": {
431
436
  const workOrderId = expectString(frontmatter, "work_order_id", filePath);
432
- requirePortableId(workOrderId, "work_order_id", filePath);
437
+ requirePortableIdRef(workOrderId, "work_order_id", filePath);
433
438
  const receiptId = expectString(frontmatter, "receipt_id", filePath);
434
- requirePortableId(receiptId, "receipt_id", filePath);
439
+ requirePortableIdRef(receiptId, "receipt_id", filePath);
435
440
  const disputeStatus = expectString(frontmatter, "dispute_status", filePath);
436
441
  requireEnum(disputeStatus, "dispute_status", DISPUTE_STATUS_VALUES, filePath);
437
442
  const severity = expectString(frontmatter, "severity", filePath);
@@ -440,7 +445,7 @@ function validateAgentFrontmatter(type, frontmatter, filePath) {
440
445
  }
441
446
  case "proposal": {
442
447
  const targetId = expectString(frontmatter, "target_id", filePath);
443
- requirePortableId(targetId, "target_id", filePath);
448
+ requirePortableIdRef(targetId, "target_id", filePath);
444
449
  const proposalStatus = expectString(frontmatter, "proposal_status", filePath);
445
450
  requireEnum(proposalStatus, "proposal_status", PROPOSAL_STATUS_VALUES, filePath);
446
451
  const proposalKind = expectString(frontmatter, "proposal_kind", filePath);
@@ -308,7 +308,7 @@ function parseNode(content, filePath, options) {
308
308
  const owners = requireLowercaseList(optionalList(frontmatter, "owners", filePath), "owners", filePath);
309
309
  const links = optionalList(frontmatter, "links", filePath);
310
310
  const artifacts = optionalList(frontmatter, "artifacts", filePath);
311
- const refs = normalizeRefsList(optionalList(frontmatter, "refs", filePath), "refs", filePath, isPortableType);
311
+ const refs = normalizeRefsList(optionalList(frontmatter, "refs", filePath), "refs", filePath, true);
312
312
  const aliases = requireLowercaseList(optionalList(frontmatter, "aliases", filePath), "aliases", filePath);
313
313
  const skillsRaw = optionalList(frontmatter, "skills", filePath);
314
314
  const skills = normalizeSkillList(skillsRaw, filePath);
@@ -326,7 +326,7 @@ function parseNode(content, filePath, options) {
326
326
  throw formatError(filePath, "supersedes must be a dec-# id");
327
327
  }
328
328
  }
329
- const edges = (0, edges_1.extractEdges)(frontmatter, filePath, { allowPortableRefs: isPortableType });
329
+ const edges = (0, edges_1.extractEdges)(frontmatter, filePath, { allowPortableRefs: true });
330
330
  const attributes = {
331
331
  ...extractGoalAttributes(type, frontmatter),
332
332
  ...(0, agent_file_types_1.extractAgentAttributes)(type, frontmatter),
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isSkillsIndexStale = isSkillsIndexStale;
6
7
  exports.writeSkillsIndex = writeSkillsIndex;
7
8
  exports.loadSkillsIndex = loadSkillsIndex;
8
9
  const fs_1 = __importDefault(require("fs"));
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.SQLITE_SCHEMA_VERSION = void 0;
7
7
  exports.isSqliteBackend = isSqliteBackend;
8
8
  exports.resolveSqlitePath = resolveSqlitePath;
9
+ exports.sqliteSourceFingerprint = sqliteSourceFingerprint;
10
+ exports.readSqliteIndexMeta = readSqliteIndexMeta;
9
11
  exports.writeSqliteIndex = writeSqliteIndex;
10
12
  exports.reserveSqliteNumericId = reserveSqliteNumericId;
11
13
  exports.sqliteHealth = sqliteHealth;
@@ -153,6 +155,29 @@ function buildSourceFingerprint(options) {
153
155
  };
154
156
  return `sha256:${crypto_1.default.createHash("sha256").update(stableCacheJson(payload)).digest("hex")}`;
155
157
  }
158
+ function sqliteSourceFingerprint(options) {
159
+ const nodeHashes = new Map();
160
+ for (const node of Object.values(options.nodeIndex.nodes)) {
161
+ nodeHashes.set(node.qid, nodeSourceHash(options.root, node.path));
162
+ }
163
+ return buildSourceFingerprint({ ...options, nodeHashes });
164
+ }
165
+ function readSqliteIndexMeta(root, config) {
166
+ const sqlitePath = resolveSqlitePath(root, config);
167
+ const DatabaseSync = loadDatabaseCtor();
168
+ const db = new DatabaseSync(sqlitePath);
169
+ try {
170
+ const rows = db.prepare("SELECT key, value FROM meta").all();
171
+ const meta = {};
172
+ for (const row of rows) {
173
+ meta[String(row.key)] = String(row.value);
174
+ }
175
+ return meta;
176
+ }
177
+ finally {
178
+ db.close();
179
+ }
180
+ }
156
181
  function writeSqliteIndex(options) {
157
182
  const sqlitePath = resolveSqlitePath(options.root, options.config);
158
183
  fs_1.default.mkdirSync(path_1.default.dirname(sqlitePath), { recursive: true });