@danypops/papyrus 0.11.3 → 0.11.4
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/package.json +1 -1
- package/src/db.ts +81 -5
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Dual-runtime: bun:sqlite (Bun) / node:sqlite (Node/pi host).
|
|
4
4
|
* Four kinds (doc/task/rule/skill) are FK-enforced; relations are universal (any→any).
|
|
5
5
|
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
import { createRequire } from "node:module";
|
|
7
8
|
import { mkdirSync } from "node:fs";
|
|
8
9
|
import { join, dirname } from "node:path";
|
|
@@ -65,6 +66,14 @@ export function inTransaction<T>(db: Db, fn: () => T): T {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
const SCHEMA = `
|
|
69
|
+
CREATE TABLE IF NOT EXISTS module_migrations (
|
|
70
|
+
module_id TEXT NOT NULL,
|
|
71
|
+
version INTEGER NOT NULL,
|
|
72
|
+
name TEXT NOT NULL,
|
|
73
|
+
checksum TEXT NOT NULL,
|
|
74
|
+
applied_at TEXT NOT NULL,
|
|
75
|
+
PRIMARY KEY (module_id, version)
|
|
76
|
+
);
|
|
68
77
|
CREATE TABLE IF NOT EXISTS kinds (
|
|
69
78
|
name TEXT PRIMARY KEY,
|
|
70
79
|
description TEXT
|
|
@@ -183,16 +192,81 @@ export function schemaVersion(db: Db): number {
|
|
|
183
192
|
return (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
|
|
184
193
|
}
|
|
185
194
|
|
|
195
|
+
/**
|
|
196
|
+
* One row per (module_id, version) applied migration, checksummed so a since-edited
|
|
197
|
+
* definition is detected rather than silently trusted. "core" consolidates this
|
|
198
|
+
* repository's entire pre-ledger migration history (every schemaVersion 1..CURRENT
|
|
199
|
+
* branch below) into one baseline, checked against the exact SCHEMA+SEED_SQL text those
|
|
200
|
+
* branches converge on -- new modules going forward register their own migrations
|
|
201
|
+
* independently, without touching "core" or duplicating a separate bootstrap path.
|
|
202
|
+
*/
|
|
203
|
+
export interface ModuleMigrationRow {
|
|
204
|
+
readonly moduleId: string;
|
|
205
|
+
readonly version: number;
|
|
206
|
+
readonly name: string;
|
|
207
|
+
readonly checksum: string;
|
|
208
|
+
readonly appliedAt: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const CORE_BASELINE_CHECKSUM = createHash("sha256").update(SCHEMA + SEED_SQL).digest("hex");
|
|
212
|
+
|
|
213
|
+
export function migrationLedger(db: Db): ModuleMigrationRow[] {
|
|
214
|
+
// A database that has never reached current schema (still awaiting explicit migrateDb())
|
|
215
|
+
// has no ledger table yet -- "nothing recorded" is the correct answer, not an error.
|
|
216
|
+
const tableExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'module_migrations'").get() != null;
|
|
217
|
+
if (!tableExists) return [];
|
|
218
|
+
const rows = db.prepare("SELECT module_id, version, name, checksum, applied_at FROM module_migrations ORDER BY module_id, version").all() as Array<{
|
|
219
|
+
module_id: string; version: number; name: string; checksum: string; applied_at: string;
|
|
220
|
+
}>;
|
|
221
|
+
return rows.map((row) => ({ moduleId: row.module_id, version: row.version, name: row.name, checksum: row.checksum, appliedAt: row.applied_at }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Ensures the ledger correctly reflects "core" once a database is confirmed at the full
|
|
226
|
+
* current schema, however it got there: a truly empty database runs the baseline DDL and
|
|
227
|
+
* records it; a database that already reached current shape (a fresh bootstrap from a
|
|
228
|
+
* past release, or a full upgrade through the pre-ledger sequential migrateDb() chain
|
|
229
|
+
* below) is backfilled without re-running any DDL against data that already exists.
|
|
230
|
+
* Verifies the stored checksum on every open so a since-edited baseline is caught, not
|
|
231
|
+
* silently trusted.
|
|
232
|
+
*/
|
|
233
|
+
function ensureCoreBaseline(db: Db, alreadyAtCurrentSchema: boolean): void {
|
|
234
|
+
// Idempotent and standalone: must succeed even on a truly empty database, before the rest
|
|
235
|
+
// of SCHEMA (which also declares this table) has run.
|
|
236
|
+
db.exec(`
|
|
237
|
+
CREATE TABLE IF NOT EXISTS module_migrations (
|
|
238
|
+
module_id TEXT NOT NULL,
|
|
239
|
+
version INTEGER NOT NULL,
|
|
240
|
+
name TEXT NOT NULL,
|
|
241
|
+
checksum TEXT NOT NULL,
|
|
242
|
+
applied_at TEXT NOT NULL,
|
|
243
|
+
PRIMARY KEY (module_id, version)
|
|
244
|
+
);
|
|
245
|
+
`);
|
|
246
|
+
const existingRow = db.prepare("SELECT checksum FROM module_migrations WHERE module_id = 'core' AND version = 1").get() as { checksum: string } | null;
|
|
247
|
+
if (existingRow != null) {
|
|
248
|
+
if (existingRow.checksum !== CORE_BASELINE_CHECKSUM) {
|
|
249
|
+
throw new Error('module migration "core" version 1 checksum mismatch: the baseline definition changed since it was applied');
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
inTransaction(db, () => {
|
|
254
|
+
if (!alreadyAtCurrentSchema) {
|
|
255
|
+
db.exec(SCHEMA);
|
|
256
|
+
db.exec(SEED_SQL);
|
|
257
|
+
db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
|
|
258
|
+
}
|
|
259
|
+
db.prepare("INSERT INTO module_migrations (module_id, version, name, checksum, applied_at) VALUES ('core', 1, 'baseline', ?, ?)")
|
|
260
|
+
.run(CORE_BASELINE_CHECKSUM, new Date().toISOString());
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
186
264
|
function bootstrapEmptyDatabase(db: Db): void {
|
|
187
265
|
const existing = db
|
|
188
266
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1")
|
|
189
267
|
.get();
|
|
190
268
|
if (existing) throw new Error("database schema is unversioned; refusing to migrate existing data during boot");
|
|
191
|
-
|
|
192
|
-
db.exec(SCHEMA);
|
|
193
|
-
db.exec(SEED_SQL);
|
|
194
|
-
db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
|
|
195
|
-
});
|
|
269
|
+
ensureCoreBaseline(db, false);
|
|
196
270
|
}
|
|
197
271
|
|
|
198
272
|
export function migrateDb(db: Db): MigrationResult {
|
|
@@ -291,6 +365,7 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
291
365
|
applied.push("task-focus-continuation");
|
|
292
366
|
}
|
|
293
367
|
});
|
|
368
|
+
if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreBaseline(db, true);
|
|
294
369
|
return { from, to: schemaVersion(db), applied };
|
|
295
370
|
}
|
|
296
371
|
|
|
@@ -306,6 +381,7 @@ export function openDb(path: string): Db {
|
|
|
306
381
|
throw new Error(`database schema ${current} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
307
382
|
}
|
|
308
383
|
if (current === 0) bootstrapEmptyDatabase(db);
|
|
384
|
+
else if (current === SQLITE_SCHEMA_VERSION) ensureCoreBaseline(db, true);
|
|
309
385
|
db.exec("PRAGMA optimize=0x10002");
|
|
310
386
|
return db;
|
|
311
387
|
}
|