@openparachute/vault 0.3.3 → 0.4.3

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 (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
3
  import { rebuildIndexes } from "./indexed-fields.js";
4
4
 
5
- export const SCHEMA_VERSION = 12;
5
+ export const SCHEMA_VERSION = 17;
6
6
 
7
7
  export const SCHEMA_SQL = `
8
8
  -- Notes: the universal record
@@ -15,9 +15,27 @@ CREATE TABLE IF NOT EXISTS notes (
15
15
  updated_at TEXT
16
16
  );
17
17
 
18
- -- Tags: flat labels
18
+ -- Tags: first-class identity carrying schema, hierarchy, and typed-link
19
+ -- declarations. One row per tag; no notes-as-config sidecars for these
20
+ -- concerns. See parachute-patterns/patterns/tag-data-model.md.
21
+ --
22
+ -- description — human-readable blurb (markdown).
23
+ -- fields — JSON: indexed metadata field declarations per
24
+ -- query-operators.md. Replaces v6-era tag_schemas.fields.
25
+ -- relationships — JSON: typed-link declarations
26
+ -- ({ "rel": { target_tag, cardinality, description? } }).
27
+ -- Cardinality vocabulary: one | optional | many | many-required.
28
+ -- Phase 1 informational — declared but not enforced at write.
29
+ -- parent_names — JSON array of parent tag names. Replaces the v6-era
30
+ -- _tags/NAME config-note hierarchy.
19
31
  CREATE TABLE IF NOT EXISTS tags (
20
- name TEXT PRIMARY KEY
32
+ name TEXT PRIMARY KEY,
33
+ description TEXT,
34
+ fields TEXT,
35
+ relationships TEXT,
36
+ parent_names TEXT,
37
+ created_at TEXT,
38
+ updated_at TEXT
21
39
  );
22
40
 
23
41
  -- Note-Tag join
@@ -47,12 +65,16 @@ CREATE TABLE IF NOT EXISTS links (
47
65
  UNIQUE(source_id, target_id, relationship)
48
66
  );
49
67
 
50
- -- Tag schemas: optional metadata schema per tag
51
- CREATE TABLE IF NOT EXISTS tag_schemas (
52
- tag_name TEXT PRIMARY KEY REFERENCES tags(name) ON DELETE CASCADE,
53
- description TEXT,
54
- fields TEXT -- JSON: { "field_name": { "type": "string", "description": "..." }, ... }
55
- );
68
+ -- tag_schemas (v6) was retired in v14; description + fields lifted onto the
69
+ -- tags row directly. The CREATE TABLE was removed from SCHEMA_SQL after the
70
+ -- v14 data migration drops the table; existing v6+ vaults pick up the
71
+ -- migration on next boot. See migrateToV14.
72
+ --
73
+ -- note_schemas + schema_mappings (v15) were retired in v17 (vault#267).
74
+ -- The two-table validation subsystem turned out to be a parallel path to
75
+ -- the per-tag fields column with zero operator usage; v17 drops both
76
+ -- tables and the six MCP tools that managed them. Validation now reads
77
+ -- tags.fields exclusively — see core/src/schema-defaults.ts.
56
78
 
57
79
  -- Indexed fields: SSOT for generated columns and indexes on notes derived
58
80
  -- from tag-declared fields with indexed=true. One row per indexed metadata
@@ -73,6 +95,19 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
73
95
  -- one-release-cycle back-compat window and will be dropped in a future
74
96
  -- migration.
75
97
  --
98
+ -- scoped_tags is a JSON-encoded array of root tag names that constrain the
99
+ -- token's effective access (intersection with the scopes column). NULL
100
+ -- means unscoped — full vault access per scopes. Introduced in v13 per
101
+ -- patterns/tag-scoped-tokens.md. Hierarchy expansion is applied at auth
102
+ -- time via getTagDescendants; the column stores root names only.
103
+ --
104
+ -- vault_name (v16) binds the token to a single vault. NULL means the
105
+ -- token is server-wide / legacy (pre-v16 rows backfill to NULL on the
106
+ -- migration; auth treats NULL as accept-any-vault for back-compat).
107
+ -- New tokens minted via per-vault routes write the column explicitly so
108
+ -- cross-vault presentation rejects in authenticateVaultRequest. See
109
+ -- vault#257.
110
+ --
76
111
  -- scope_tag / scope_path_prefix are deprecated Phase-0 columns — never
77
112
  -- enforced at runtime, kept only for schema stability.
78
113
  CREATE TABLE IF NOT EXISTS tokens (
@@ -80,11 +115,13 @@ CREATE TABLE IF NOT EXISTS tokens (
80
115
  label TEXT NOT NULL,
81
116
  permission TEXT NOT NULL DEFAULT 'admin',
82
117
  scopes TEXT,
118
+ scoped_tags TEXT,
83
119
  scope_tag TEXT,
84
120
  scope_path_prefix TEXT,
85
121
  expires_at TEXT,
86
122
  created_at TEXT NOT NULL,
87
- last_used_at TEXT
123
+ last_used_at TEXT,
124
+ vault_name TEXT
88
125
  );
89
126
 
90
127
  -- OAuth: registered clients (Dynamic Client Registration)
@@ -147,6 +184,12 @@ CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag_name, note_id);
147
184
  CREATE INDEX IF NOT EXISTS idx_attachments_note ON attachments(note_id);
148
185
  CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_id);
149
186
  CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_id);
187
+ -- idx_tokens_vault_name is created in migrateToV16, not here. SCHEMA_SQL
188
+ -- runs BEFORE migrations; an upgrading v15 vault doesn't yet have the
189
+ -- vault_name column when this section evaluates, so the index has to
190
+ -- live downstream of the ALTER TABLE that adds the column. Fresh vaults
191
+ -- (column already present from this CREATE TABLE) still get the index
192
+ -- because migrateToV16 also runs the unconditional CREATE INDEX path.
150
193
  `;
151
194
 
152
195
  /**
@@ -194,6 +237,35 @@ export function initSchema(db: Database): void {
194
237
  // Migrate v11 → v12: add `scopes` column to tokens for Phase 2 enforcement.
195
238
  migrateToV12(db);
196
239
 
240
+ // Migrate v12 → v13: add `scoped_tags` column to tokens for tag-scoped tokens.
241
+ migrateToV13(db);
242
+
243
+ // Migrate v13 → v14: tag-data-model reshape. Augment `tags` row with
244
+ // description/fields/relationships/parent_names/timestamps; copy data
245
+ // from the v6-era tag_schemas sidecar and from `_tags/<name>` config
246
+ // notes; drop tag_schemas after copy. See patterns/tag-data-model.md.
247
+ migrateToV14(db);
248
+
249
+ // Migrate v14 → v15: retire the `_schemas/<name>` and `_schema_defaults`
250
+ // notes-as-config sidecars. Copy each `_schemas/<name>` note into the
251
+ // new `note_schemas` table and the `_schema_defaults` mappings into
252
+ // `schema_mappings`. The legacy notes are LEFT IN PLACE — they are
253
+ // inert post-v15 (no resolver reads them) and serve as audit trail.
254
+ migrateToV15(db);
255
+
256
+ // Migrate v15 → v16: add `vault_name` column to tokens. Existing rows
257
+ // backfill to NULL ("server-wide / legacy" semantic) — auth accepts
258
+ // NULL for any vault, so today's pvt_* tokens keep working unchanged.
259
+ // New mints via per-vault routes write the column explicitly. See vault#257.
260
+ migrateToV16(db);
261
+
262
+ // Migrate v16 → v17: rip the standalone `note_schemas` + `schema_mappings`
263
+ // subsystem. Validation now reads `tags.fields` exclusively. The two
264
+ // tables are dropped wholesale; if a vault carried rows we log a warning
265
+ // naming the dropped schemas/mappings so the operator can recreate them
266
+ // as `tags.fields` if needed. See vault#267.
267
+ migrateToV17(db);
268
+
197
269
  // Rebuild any generated columns + indexes declared in indexed_fields.
198
270
  // No-op for a fresh vault; idempotent on existing vaults.
199
271
  rebuildIndexes(db);
@@ -258,7 +330,7 @@ function migrateToV5(db: Database): void {
258
330
  // Keep first, rename the rest
259
331
  for (let i = 1; i < ids.length; i++) {
260
332
  const newPath = `${dupe.path}-${i}`;
261
- db.prepare("UPDATE notes SET path = ? WHERE id = ?").run(newPath, ids[i]);
333
+ db.prepare("UPDATE notes SET path = ? WHERE id = ?").run(newPath, ids[i]!);
262
334
  }
263
335
  }
264
336
 
@@ -337,6 +409,390 @@ function migrateToV12(db: Database): void {
337
409
  }
338
410
  }
339
411
 
412
+ /**
413
+ * Migrate v12 → v13: add `scoped_tags` column to tokens. NULL means unscoped
414
+ * (current full-vault behavior); a JSON array of root tag names narrows the
415
+ * token's access to notes carrying one of those tags or a sub-tag thereof
416
+ * (hierarchy expansion via getTagDescendants at auth time). See
417
+ * parachute-patterns/patterns/tag-scoped-tokens.md.
418
+ */
419
+ function migrateToV13(db: Database): void {
420
+ if (hasTable(db, "tokens") && !hasColumn(db, "tokens", "scoped_tags")) {
421
+ db.exec("ALTER TABLE tokens ADD COLUMN scoped_tags TEXT");
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Migrate v13 → v14: tag-data-model reshape (patterns/tag-data-model.md).
427
+ *
428
+ * Augments the `tags` table with five new columns and one timestamp pair,
429
+ * then copies pre-existing data from two notes-as-config sidecars:
430
+ *
431
+ * - tag_schemas (v6 sidecar) → tags.{description,fields}
432
+ * - notes at path `_tags/<name>` → tags.parent_names (from metadata.parents)
433
+ *
434
+ * After the copy lands, `tag_schemas` is dropped. The `_tags/<name>` notes
435
+ * are LEFT IN PLACE — they're harmless historical record and a user might
436
+ * have other content there. Future writes go to the tags row directly.
437
+ *
438
+ * Wrapped in BEGIN IMMEDIATE / COMMIT (with a try/catch ROLLBACK) so a
439
+ * crash mid-migration leaves the DB in either pre-v14 or post-v14 state,
440
+ * never half-migrated. Each step remains individually idempotent — the
441
+ * transaction wrap is belt-and-suspenders, not load-bearing — so a future
442
+ * reader who removes the `hasColumn` / `hasTable` guards still gets correct
443
+ * behavior on retry.
444
+ */
445
+ function migrateToV14(db: Database): void {
446
+ if (!hasTable(db, "tags")) return;
447
+
448
+ db.exec("BEGIN IMMEDIATE");
449
+ try {
450
+ // 1. ALTER TABLE — additive, idempotent.
451
+ const cols: [string, string][] = [
452
+ ["description", "TEXT"],
453
+ ["fields", "TEXT"],
454
+ ["relationships", "TEXT"],
455
+ ["parent_names", "TEXT"],
456
+ ["created_at", "TEXT"],
457
+ ["updated_at", "TEXT"],
458
+ ];
459
+ for (const [col, type] of cols) {
460
+ if (!hasColumn(db, "tags", col)) {
461
+ db.exec(`ALTER TABLE tags ADD COLUMN ${col} ${type}`);
462
+ }
463
+ }
464
+
465
+ const now = new Date().toISOString();
466
+ let copiedSchemas = 0;
467
+ let copiedHierarchy = 0;
468
+
469
+ // 2. Copy tag_schemas → tags.{description,fields}.
470
+ if (hasTable(db, "tag_schemas")) {
471
+ const rows = db.prepare(
472
+ "SELECT tag_name, description, fields FROM tag_schemas",
473
+ ).all() as { tag_name: string; description: string | null; fields: string | null }[];
474
+ const upsert = db.prepare(
475
+ "INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
476
+ );
477
+ const update = db.prepare(
478
+ "UPDATE tags SET description = ?, fields = ?, updated_at = ? WHERE name = ?",
479
+ );
480
+ for (const row of rows) {
481
+ upsert.run(row.tag_name, now, now);
482
+ update.run(row.description, row.fields, now, row.tag_name);
483
+ copiedSchemas++;
484
+ }
485
+ }
486
+
487
+ // 3. Copy `_tags/<name>` notes' metadata.parents → tags.parent_names.
488
+ // Only runs if the notes table exists (it always does post-SCHEMA_SQL,
489
+ // but stay defensive — initSchema runs SCHEMA_SQL first so this is true).
490
+ if (hasTable(db, "notes")) {
491
+ const tagNotes = db.prepare(
492
+ "SELECT path, metadata FROM notes WHERE path GLOB '_tags/*'",
493
+ ).all() as { path: string; metadata: string | null }[];
494
+ const upsert = db.prepare(
495
+ "INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
496
+ );
497
+ const update = db.prepare(
498
+ "UPDATE tags SET parent_names = ?, updated_at = ? WHERE name = ?",
499
+ );
500
+ for (const note of tagNotes) {
501
+ const tagName = note.path.slice("_tags/".length);
502
+ if (!tagName) continue;
503
+ let parents: string[] | null = null;
504
+ try {
505
+ const meta = note.metadata ? JSON.parse(note.metadata) : {};
506
+ const raw = meta?.parents;
507
+ if (Array.isArray(raw) && raw.length > 0) {
508
+ const cleaned = raw.filter((p: unknown): p is string => typeof p === "string" && p.length > 0);
509
+ if (cleaned.length > 0) parents = cleaned;
510
+ }
511
+ } catch {
512
+ // Malformed metadata — skip; the note is left untouched.
513
+ continue;
514
+ }
515
+ if (!parents) continue;
516
+ upsert.run(tagName, now, now);
517
+ update.run(JSON.stringify(parents), now, tagName);
518
+ copiedHierarchy++;
519
+ }
520
+ }
521
+
522
+ // 4. Backfill timestamps for rows the copies didn't touch.
523
+ db.exec(`UPDATE tags SET created_at = '${now}' WHERE created_at IS NULL`);
524
+
525
+ // 5. Drop the sidecar after the copy is complete.
526
+ if (hasTable(db, "tag_schemas")) {
527
+ db.exec("DROP TABLE tag_schemas");
528
+ }
529
+
530
+ db.exec("COMMIT");
531
+
532
+ if (copiedSchemas > 0 || copiedHierarchy > 0) {
533
+ console.log(
534
+ `[vault] migrated to schema v14: copied ${copiedSchemas} tag_schemas + ${copiedHierarchy} _tags/* hierarchies onto tags rows`,
535
+ );
536
+ }
537
+ } catch (err) {
538
+ db.exec("ROLLBACK");
539
+ throw err;
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Migrate v14 → v15: retire `_schemas/<name>` + `_schema_defaults` notes
545
+ * as the canonical source for schema definitions and mapping rules. After
546
+ * this migration the resolver reads from `note_schemas` and
547
+ * `schema_mappings` tables. The legacy notes are LEFT IN PLACE — they're
548
+ * harmless historical record and a user might have other content there.
549
+ *
550
+ * Idempotent: SCHEMA_SQL creates the tables before this runs (CREATE TABLE
551
+ * IF NOT EXISTS); the data copy uses INSERT OR IGNORE so re-running on a
552
+ * post-v15 DB is a no-op. Wrapped in BEGIN/COMMIT so a crash mid-migration
553
+ * leaves the DB in either pre-v15 or post-v15 state, never partial.
554
+ */
555
+ function migrateToV15(db: Database): void {
556
+ // note_schemas was dropped in v17; on any v17+ vault (or a v14→v17 skip),
557
+ // this guard returns immediately and the function is effectively dead code.
558
+ // Left in place rather than deleted because removing it would change initSchema's
559
+ // migration call ordering. Safe to delete in a future cleanup.
560
+ if (!hasTable(db, "note_schemas") || !hasTable(db, "notes")) return;
561
+
562
+ // Short-circuit: if either destination table already has data, the
563
+ // migration has run before. `||` not `&&` — a vault with schemas but zero
564
+ // mappings (or mappings but zero schemas) is a valid post-v15 state, and
565
+ // re-scanning notes on every boot would be wasted I/O.
566
+ const hasSchemas = (db.prepare(
567
+ "SELECT 1 FROM note_schemas LIMIT 1",
568
+ ).get()) !== null;
569
+ const hasMappings = (db.prepare(
570
+ "SELECT 1 FROM schema_mappings LIMIT 1",
571
+ ).get()) !== null;
572
+ if (hasSchemas || hasMappings) return;
573
+
574
+ db.exec("BEGIN IMMEDIATE");
575
+ try {
576
+ const now = new Date().toISOString();
577
+ let copiedSchemas = 0;
578
+ let copiedMappings = 0;
579
+
580
+ // 1. Copy `_schemas/<name>` notes → note_schemas.
581
+ const defRows = db.prepare(
582
+ "SELECT path, metadata FROM notes WHERE path GLOB '_schemas/*'",
583
+ ).all() as { path: string; metadata: string | null }[];
584
+ const insertSchema = db.prepare(
585
+ "INSERT OR IGNORE INTO note_schemas (name, description, fields, required, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
586
+ );
587
+ for (const row of defRows) {
588
+ const name = row.path.slice("_schemas/".length);
589
+ if (!name) continue;
590
+ let description: string | null = null;
591
+ let fields: string | null = null;
592
+ let required: string | null = null;
593
+ try {
594
+ const meta = row.metadata ? JSON.parse(row.metadata) : {};
595
+ if (typeof meta?.description === "string") description = meta.description;
596
+ if (meta?.fields && typeof meta.fields === "object" && !Array.isArray(meta.fields)) {
597
+ fields = JSON.stringify(meta.fields);
598
+ }
599
+ if (Array.isArray(meta?.required)) {
600
+ const cleaned = meta.required.filter((x: unknown): x is string => typeof x === "string");
601
+ if (cleaned.length > 0) required = JSON.stringify(cleaned);
602
+ }
603
+ } catch {
604
+ // Malformed metadata — skip; the note is left alone.
605
+ continue;
606
+ }
607
+ insertSchema.run(name, description, fields, required, now, now);
608
+ copiedSchemas++;
609
+ }
610
+
611
+ // 2. Copy `_schema_defaults` note → schema_mappings.
612
+ const mappingNote = db.prepare(
613
+ "SELECT metadata FROM notes WHERE path = '_schema_defaults'",
614
+ ).get() as { metadata: string | null } | undefined;
615
+ if (mappingNote?.metadata) {
616
+ const insertMapping = db.prepare(
617
+ "INSERT OR IGNORE INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
618
+ );
619
+ const ensureSchemaRow = db.prepare(
620
+ "INSERT OR IGNORE INTO note_schemas (name, created_at, updated_at) VALUES (?, ?, ?)",
621
+ );
622
+ try {
623
+ const meta = JSON.parse(mappingNote.metadata);
624
+ const pathPrefixes = meta?.path_prefixes;
625
+ if (pathPrefixes && typeof pathPrefixes === "object" && !Array.isArray(pathPrefixes)) {
626
+ for (const [prefix, schema] of Object.entries(pathPrefixes as Record<string, unknown>)) {
627
+ if (typeof schema === "string" && schema.length > 0 && prefix.length > 0) {
628
+ // Foreign key requires the schema row to exist; create a stub
629
+ // if the user mapped to a name with no _schemas/<name> note.
630
+ ensureSchemaRow.run(schema, now, now);
631
+ insertMapping.run(schema, "path_prefix", prefix);
632
+ copiedMappings++;
633
+ }
634
+ }
635
+ }
636
+ const tags = meta?.tags;
637
+ if (tags && typeof tags === "object" && !Array.isArray(tags)) {
638
+ for (const [tag, schema] of Object.entries(tags as Record<string, unknown>)) {
639
+ if (typeof schema === "string" && schema.length > 0 && tag.length > 0) {
640
+ ensureSchemaRow.run(schema, now, now);
641
+ insertMapping.run(schema, "tag", tag);
642
+ copiedMappings++;
643
+ }
644
+ }
645
+ }
646
+ } catch {
647
+ // Malformed _schema_defaults — leave both note and table empty;
648
+ // user can fix and re-run.
649
+ }
650
+ }
651
+
652
+ db.exec("COMMIT");
653
+
654
+ if (copiedSchemas > 0 || copiedMappings > 0) {
655
+ console.log(
656
+ `[vault] migrated to schema v15: copied ${copiedSchemas} _schemas/* + ${copiedMappings} _schema_defaults mappings into note_schemas/schema_mappings`,
657
+ );
658
+ }
659
+ } catch (err) {
660
+ db.exec("ROLLBACK");
661
+ throw err;
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Migrate v15 → v16: per-vault token storage (vault#257).
667
+ *
668
+ * Adds `tokens.vault_name TEXT` (nullable). Existing rows stay NULL —
669
+ * "server-wide / legacy" semantic — and `authenticateVaultRequest`
670
+ * accepts NULL for any vault, so today's pvt_* tokens keep working
671
+ * unchanged. New mints via `/vault/<name>/tokens` write the column
672
+ * explicitly; cross-vault presentation rejects on the row's vault_name
673
+ * mismatch. The complementary index speeds the per-vault listTokens
674
+ * filter in the admin SPA.
675
+ *
676
+ * Wrapped in BEGIN IMMEDIATE / COMMIT (with try/catch ROLLBACK) per the
677
+ * v14/v15 wrap pattern from vault#251 — the column add and index create
678
+ * are individually idempotent (`hasColumn` / IF NOT EXISTS), but the
679
+ * transaction means a crash mid-migration leaves either pre-v16 or
680
+ * post-v16 state, never partial.
681
+ */
682
+ function migrateToV16(db: Database): void {
683
+ if (!hasTable(db, "tokens")) return;
684
+
685
+ // Two responsibilities, separated so the index lands for both fresh
686
+ // vaults (SCHEMA_SQL already created the column) and upgrading v15
687
+ // vaults (column missing). Index creation lives outside the wrapped
688
+ // ALTER block so a fresh vault — where the column exists but the index
689
+ // doesn't — still gets it.
690
+ if (!hasColumn(db, "tokens", "vault_name")) {
691
+ db.exec("BEGIN IMMEDIATE");
692
+ try {
693
+ db.exec("ALTER TABLE tokens ADD COLUMN vault_name TEXT");
694
+ db.exec("CREATE INDEX IF NOT EXISTS idx_tokens_vault_name ON tokens(vault_name)");
695
+ db.exec("COMMIT");
696
+ } catch (err) {
697
+ db.exec("ROLLBACK");
698
+ throw err;
699
+ }
700
+ return;
701
+ }
702
+
703
+ // Column exists (fresh vault from SCHEMA_SQL, or post-upgrade vault).
704
+ // Make sure the index exists too. IF NOT EXISTS makes this a no-op on
705
+ // the steady-state path.
706
+ db.exec("CREATE INDEX IF NOT EXISTS idx_tokens_vault_name ON tokens(vault_name)");
707
+ }
708
+
709
+ /**
710
+ * Migrate v16 → v17: rip `note_schemas` + `schema_mappings` (vault#267).
711
+ *
712
+ * The two-table validation subsystem from v15 turned out to be a parallel
713
+ * path to `tags.fields` with zero operator usage. v17 drops both tables
714
+ * outright. Fresh vaults never see them; upgrading vaults lose them.
715
+ *
716
+ * If an upgrading vault DID carry rows (which Aaron's didn't, but a future
717
+ * operator's might), the migration logs the dropped names + mapping rules
718
+ * so the operator can re-create them as `tags.fields` declarations on the
719
+ * relevant tag rows. We don't try to auto-migrate path_prefix mappings —
720
+ * the new validation surface is tag-axis only, and a path_prefix → tag
721
+ * translation has no faithful one-to-one shape.
722
+ *
723
+ * Wrapped in BEGIN IMMEDIATE / COMMIT / ROLLBACK per the v14/v15/v16
724
+ * pattern from vault#251 — DROP TABLE statements are individually atomic,
725
+ * but the wrap means a crash mid-migration leaves either pre-v17 or
726
+ * post-v17 state, never partial.
727
+ */
728
+ function migrateToV17(db: Database): void {
729
+ const hasNoteSchemas = hasTable(db, "note_schemas");
730
+ const hasSchemaMappings = hasTable(db, "schema_mappings");
731
+ if (!hasNoteSchemas && !hasSchemaMappings) return;
732
+
733
+ // Snapshot any data so the operator can recreate as `tags.fields` if
734
+ // needed. Read BEFORE the transaction so we don't lose the warning if
735
+ // the DROP fails (the COMMIT below atomically swaps state).
736
+ let droppedSchemas: { name: string; description: string | null }[] = [];
737
+ let droppedMappings: { schema_name: string; match_kind: string; match_value: string }[] = [];
738
+ if (hasNoteSchemas) {
739
+ droppedSchemas = db.prepare(
740
+ "SELECT name, description FROM note_schemas",
741
+ ).all() as { name: string; description: string | null }[];
742
+ }
743
+ if (hasSchemaMappings) {
744
+ droppedMappings = db.prepare(
745
+ "SELECT schema_name, match_kind, match_value FROM schema_mappings",
746
+ ).all() as { schema_name: string; match_kind: string; match_value: string }[];
747
+ }
748
+
749
+ db.exec("BEGIN IMMEDIATE");
750
+ try {
751
+ // Drop the index first — the index references the table; SQLite would
752
+ // tear it down on DROP TABLE but the explicit DROP keeps the order
753
+ // obvious if a future migration reads from sqlite_master mid-flight.
754
+ db.exec("DROP INDEX IF EXISTS idx_schema_mappings_match");
755
+ // schema_mappings has an FK to note_schemas — drop it first.
756
+ if (hasSchemaMappings) {
757
+ db.exec("DROP TABLE schema_mappings");
758
+ }
759
+ if (hasNoteSchemas) {
760
+ db.exec("DROP TABLE note_schemas");
761
+ }
762
+ db.exec("COMMIT");
763
+ } catch (err) {
764
+ db.exec("ROLLBACK");
765
+ throw err;
766
+ }
767
+
768
+ if (droppedSchemas.length > 0 || droppedMappings.length > 0) {
769
+ const schemaNames = droppedSchemas.map((s) => s.name).join(", ");
770
+ const tagMappings = droppedMappings.filter((m) => m.match_kind === "tag");
771
+ const pathMappings = droppedMappings.filter((m) => m.match_kind === "path_prefix");
772
+ const lines: string[] = [
773
+ `[vault] migrated to schema v17 (vault#267): note_schemas + schema_mappings retired.`,
774
+ ];
775
+ if (droppedSchemas.length > 0) {
776
+ lines.push(` dropped schemas (${droppedSchemas.length}): ${schemaNames}`);
777
+ }
778
+ if (tagMappings.length > 0) {
779
+ const list = tagMappings.map((m) => `${m.match_value}→${m.schema_name}`).join(", ");
780
+ lines.push(
781
+ ` dropped tag mappings (${tagMappings.length}): ${list}`,
782
+ ` recreate as \`tags.fields\` declarations on the relevant tag rows.`,
783
+ );
784
+ }
785
+ if (pathMappings.length > 0) {
786
+ const list = pathMappings.map((m) => `${m.match_value}→${m.schema_name}`).join(", ");
787
+ lines.push(
788
+ ` dropped path_prefix mappings (${pathMappings.length}): ${list}`,
789
+ ` no path-prefix-driven validation in v17 — file vault#267 if you need this.`,
790
+ );
791
+ }
792
+ console.log(lines.join("\n"));
793
+ }
794
+ }
795
+
340
796
  function hasTable(db: Database, name: string): boolean {
341
797
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
342
798
  return !!row;