@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.
- package/.parachute/module.json +15 -0
- package/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +727 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +1626 -183
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/core/src/schema.ts
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
-
--
|
|
51
|
-
CREATE TABLE
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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;
|