@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/db.ts +81 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
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
- inTransaction(db, () => {
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
  }