@openparachute/vault 0.3.1 → 0.4.0
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 +9 -5
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- 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 +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- 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 +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +384 -78
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init-summary.test.ts +133 -0
- package/src/init-summary.ts +90 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- 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 +31 -14
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -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 +720 -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 +868 -3
- 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 = 16;
|
|
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,11 +65,40 @@ CREATE TABLE IF NOT EXISTS links (
|
|
|
47
65
|
UNIQUE(source_id, target_id, relationship)
|
|
48
66
|
);
|
|
49
67
|
|
|
50
|
-
--
|
|
51
|
-
CREATE TABLE
|
|
52
|
-
|
|
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 (v15): schema definitions used to validate notes by path
|
|
74
|
+
-- prefix or tag. Replaces the v6-era _schemas/NAME notes-as-config
|
|
75
|
+
-- convention. Validation is non-blocking — schemas surface warnings on
|
|
76
|
+
-- create/update responses, never reject the write. See
|
|
77
|
+
-- core/src/schema-defaults.ts and patterns/tag-data-model.md §Note schemas.
|
|
78
|
+
--
|
|
79
|
+
-- name — primary key; the schema identifier referenced by mappings.
|
|
80
|
+
-- description — human-readable blurb (markdown).
|
|
81
|
+
-- fields — JSON: { fieldName: { type?, enum?, description? } }.
|
|
82
|
+
-- required — JSON: string[] of required field names.
|
|
83
|
+
CREATE TABLE IF NOT EXISTS note_schemas (
|
|
84
|
+
name TEXT PRIMARY KEY,
|
|
53
85
|
description TEXT,
|
|
54
|
-
fields TEXT
|
|
86
|
+
fields TEXT,
|
|
87
|
+
required TEXT,
|
|
88
|
+
created_at TEXT,
|
|
89
|
+
updated_at TEXT
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
-- Schema mappings (v15): replaces the singleton _schema_defaults note. One
|
|
93
|
+
-- row per match rule; the resolver walks the table at note-write time.
|
|
94
|
+
-- match_kind is constrained to 'path_prefix' or 'tag'. Composite PK so
|
|
95
|
+
-- (schema, kind, value) is naturally unique without an extra surrogate id.
|
|
96
|
+
-- ON DELETE CASCADE: dropping a schema cleans up its mappings.
|
|
97
|
+
CREATE TABLE IF NOT EXISTS schema_mappings (
|
|
98
|
+
schema_name TEXT NOT NULL REFERENCES note_schemas(name) ON DELETE CASCADE,
|
|
99
|
+
match_kind TEXT NOT NULL CHECK (match_kind IN ('path_prefix', 'tag')),
|
|
100
|
+
match_value TEXT NOT NULL,
|
|
101
|
+
PRIMARY KEY (schema_name, match_kind, match_value)
|
|
55
102
|
);
|
|
56
103
|
|
|
57
104
|
-- Indexed fields: SSOT for generated columns and indexes on notes derived
|
|
@@ -73,6 +120,19 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
|
|
|
73
120
|
-- one-release-cycle back-compat window and will be dropped in a future
|
|
74
121
|
-- migration.
|
|
75
122
|
--
|
|
123
|
+
-- scoped_tags is a JSON-encoded array of root tag names that constrain the
|
|
124
|
+
-- token's effective access (intersection with the scopes column). NULL
|
|
125
|
+
-- means unscoped — full vault access per scopes. Introduced in v13 per
|
|
126
|
+
-- patterns/tag-scoped-tokens.md. Hierarchy expansion is applied at auth
|
|
127
|
+
-- time via getTagDescendants; the column stores root names only.
|
|
128
|
+
--
|
|
129
|
+
-- vault_name (v16) binds the token to a single vault. NULL means the
|
|
130
|
+
-- token is server-wide / legacy (pre-v16 rows backfill to NULL on the
|
|
131
|
+
-- migration; auth treats NULL as accept-any-vault for back-compat).
|
|
132
|
+
-- New tokens minted via per-vault routes write the column explicitly so
|
|
133
|
+
-- cross-vault presentation rejects in authenticateVaultRequest. See
|
|
134
|
+
-- vault#257.
|
|
135
|
+
--
|
|
76
136
|
-- scope_tag / scope_path_prefix are deprecated Phase-0 columns — never
|
|
77
137
|
-- enforced at runtime, kept only for schema stability.
|
|
78
138
|
CREATE TABLE IF NOT EXISTS tokens (
|
|
@@ -80,11 +140,13 @@ CREATE TABLE IF NOT EXISTS tokens (
|
|
|
80
140
|
label TEXT NOT NULL,
|
|
81
141
|
permission TEXT NOT NULL DEFAULT 'admin',
|
|
82
142
|
scopes TEXT,
|
|
143
|
+
scoped_tags TEXT,
|
|
83
144
|
scope_tag TEXT,
|
|
84
145
|
scope_path_prefix TEXT,
|
|
85
146
|
expires_at TEXT,
|
|
86
147
|
created_at TEXT NOT NULL,
|
|
87
|
-
last_used_at TEXT
|
|
148
|
+
last_used_at TEXT,
|
|
149
|
+
vault_name TEXT
|
|
88
150
|
);
|
|
89
151
|
|
|
90
152
|
-- OAuth: registered clients (Dynamic Client Registration)
|
|
@@ -147,6 +209,13 @@ CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag_name, note_id);
|
|
|
147
209
|
CREATE INDEX IF NOT EXISTS idx_attachments_note ON attachments(note_id);
|
|
148
210
|
CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_id);
|
|
149
211
|
CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_id);
|
|
212
|
+
CREATE INDEX IF NOT EXISTS idx_schema_mappings_match ON schema_mappings(match_kind, match_value);
|
|
213
|
+
-- idx_tokens_vault_name is created in migrateToV16, not here. SCHEMA_SQL
|
|
214
|
+
-- runs BEFORE migrations; an upgrading v15 vault doesn't yet have the
|
|
215
|
+
-- vault_name column when this section evaluates, so the index has to
|
|
216
|
+
-- live downstream of the ALTER TABLE that adds the column. Fresh vaults
|
|
217
|
+
-- (column already present from this CREATE TABLE) still get the index
|
|
218
|
+
-- because migrateToV16 also runs the unconditional CREATE INDEX path.
|
|
150
219
|
`;
|
|
151
220
|
|
|
152
221
|
/**
|
|
@@ -194,6 +263,28 @@ export function initSchema(db: Database): void {
|
|
|
194
263
|
// Migrate v11 → v12: add `scopes` column to tokens for Phase 2 enforcement.
|
|
195
264
|
migrateToV12(db);
|
|
196
265
|
|
|
266
|
+
// Migrate v12 → v13: add `scoped_tags` column to tokens for tag-scoped tokens.
|
|
267
|
+
migrateToV13(db);
|
|
268
|
+
|
|
269
|
+
// Migrate v13 → v14: tag-data-model reshape. Augment `tags` row with
|
|
270
|
+
// description/fields/relationships/parent_names/timestamps; copy data
|
|
271
|
+
// from the v6-era tag_schemas sidecar and from `_tags/<name>` config
|
|
272
|
+
// notes; drop tag_schemas after copy. See patterns/tag-data-model.md.
|
|
273
|
+
migrateToV14(db);
|
|
274
|
+
|
|
275
|
+
// Migrate v14 → v15: retire the `_schemas/<name>` and `_schema_defaults`
|
|
276
|
+
// notes-as-config sidecars. Copy each `_schemas/<name>` note into the
|
|
277
|
+
// new `note_schemas` table and the `_schema_defaults` mappings into
|
|
278
|
+
// `schema_mappings`. The legacy notes are LEFT IN PLACE — they are
|
|
279
|
+
// inert post-v15 (no resolver reads them) and serve as audit trail.
|
|
280
|
+
migrateToV15(db);
|
|
281
|
+
|
|
282
|
+
// Migrate v15 → v16: add `vault_name` column to tokens. Existing rows
|
|
283
|
+
// backfill to NULL ("server-wide / legacy" semantic) — auth accepts
|
|
284
|
+
// NULL for any vault, so today's pvt_* tokens keep working unchanged.
|
|
285
|
+
// New mints via per-vault routes write the column explicitly. See vault#257.
|
|
286
|
+
migrateToV16(db);
|
|
287
|
+
|
|
197
288
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
198
289
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
199
290
|
rebuildIndexes(db);
|
|
@@ -258,7 +349,7 @@ function migrateToV5(db: Database): void {
|
|
|
258
349
|
// Keep first, rename the rest
|
|
259
350
|
for (let i = 1; i < ids.length; i++) {
|
|
260
351
|
const newPath = `${dupe.path}-${i}`;
|
|
261
|
-
db.prepare("UPDATE notes SET path = ? WHERE id = ?").run(newPath, ids[i]);
|
|
352
|
+
db.prepare("UPDATE notes SET path = ? WHERE id = ?").run(newPath, ids[i]!);
|
|
262
353
|
}
|
|
263
354
|
}
|
|
264
355
|
|
|
@@ -337,6 +428,299 @@ function migrateToV12(db: Database): void {
|
|
|
337
428
|
}
|
|
338
429
|
}
|
|
339
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Migrate v12 → v13: add `scoped_tags` column to tokens. NULL means unscoped
|
|
433
|
+
* (current full-vault behavior); a JSON array of root tag names narrows the
|
|
434
|
+
* token's access to notes carrying one of those tags or a sub-tag thereof
|
|
435
|
+
* (hierarchy expansion via getTagDescendants at auth time). See
|
|
436
|
+
* parachute-patterns/patterns/tag-scoped-tokens.md.
|
|
437
|
+
*/
|
|
438
|
+
function migrateToV13(db: Database): void {
|
|
439
|
+
if (hasTable(db, "tokens") && !hasColumn(db, "tokens", "scoped_tags")) {
|
|
440
|
+
db.exec("ALTER TABLE tokens ADD COLUMN scoped_tags TEXT");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Migrate v13 → v14: tag-data-model reshape (patterns/tag-data-model.md).
|
|
446
|
+
*
|
|
447
|
+
* Augments the `tags` table with five new columns and one timestamp pair,
|
|
448
|
+
* then copies pre-existing data from two notes-as-config sidecars:
|
|
449
|
+
*
|
|
450
|
+
* - tag_schemas (v6 sidecar) → tags.{description,fields}
|
|
451
|
+
* - notes at path `_tags/<name>` → tags.parent_names (from metadata.parents)
|
|
452
|
+
*
|
|
453
|
+
* After the copy lands, `tag_schemas` is dropped. The `_tags/<name>` notes
|
|
454
|
+
* are LEFT IN PLACE — they're harmless historical record and a user might
|
|
455
|
+
* have other content there. Future writes go to the tags row directly.
|
|
456
|
+
*
|
|
457
|
+
* Wrapped in BEGIN IMMEDIATE / COMMIT (with a try/catch ROLLBACK) so a
|
|
458
|
+
* crash mid-migration leaves the DB in either pre-v14 or post-v14 state,
|
|
459
|
+
* never half-migrated. Each step remains individually idempotent — the
|
|
460
|
+
* transaction wrap is belt-and-suspenders, not load-bearing — so a future
|
|
461
|
+
* reader who removes the `hasColumn` / `hasTable` guards still gets correct
|
|
462
|
+
* behavior on retry.
|
|
463
|
+
*/
|
|
464
|
+
function migrateToV14(db: Database): void {
|
|
465
|
+
if (!hasTable(db, "tags")) return;
|
|
466
|
+
|
|
467
|
+
db.exec("BEGIN IMMEDIATE");
|
|
468
|
+
try {
|
|
469
|
+
// 1. ALTER TABLE — additive, idempotent.
|
|
470
|
+
const cols: [string, string][] = [
|
|
471
|
+
["description", "TEXT"],
|
|
472
|
+
["fields", "TEXT"],
|
|
473
|
+
["relationships", "TEXT"],
|
|
474
|
+
["parent_names", "TEXT"],
|
|
475
|
+
["created_at", "TEXT"],
|
|
476
|
+
["updated_at", "TEXT"],
|
|
477
|
+
];
|
|
478
|
+
for (const [col, type] of cols) {
|
|
479
|
+
if (!hasColumn(db, "tags", col)) {
|
|
480
|
+
db.exec(`ALTER TABLE tags ADD COLUMN ${col} ${type}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const now = new Date().toISOString();
|
|
485
|
+
let copiedSchemas = 0;
|
|
486
|
+
let copiedHierarchy = 0;
|
|
487
|
+
|
|
488
|
+
// 2. Copy tag_schemas → tags.{description,fields}.
|
|
489
|
+
if (hasTable(db, "tag_schemas")) {
|
|
490
|
+
const rows = db.prepare(
|
|
491
|
+
"SELECT tag_name, description, fields FROM tag_schemas",
|
|
492
|
+
).all() as { tag_name: string; description: string | null; fields: string | null }[];
|
|
493
|
+
const upsert = db.prepare(
|
|
494
|
+
"INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
|
|
495
|
+
);
|
|
496
|
+
const update = db.prepare(
|
|
497
|
+
"UPDATE tags SET description = ?, fields = ?, updated_at = ? WHERE name = ?",
|
|
498
|
+
);
|
|
499
|
+
for (const row of rows) {
|
|
500
|
+
upsert.run(row.tag_name, now, now);
|
|
501
|
+
update.run(row.description, row.fields, now, row.tag_name);
|
|
502
|
+
copiedSchemas++;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// 3. Copy `_tags/<name>` notes' metadata.parents → tags.parent_names.
|
|
507
|
+
// Only runs if the notes table exists (it always does post-SCHEMA_SQL,
|
|
508
|
+
// but stay defensive — initSchema runs SCHEMA_SQL first so this is true).
|
|
509
|
+
if (hasTable(db, "notes")) {
|
|
510
|
+
const tagNotes = db.prepare(
|
|
511
|
+
"SELECT path, metadata FROM notes WHERE path GLOB '_tags/*'",
|
|
512
|
+
).all() as { path: string; metadata: string | null }[];
|
|
513
|
+
const upsert = db.prepare(
|
|
514
|
+
"INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
|
|
515
|
+
);
|
|
516
|
+
const update = db.prepare(
|
|
517
|
+
"UPDATE tags SET parent_names = ?, updated_at = ? WHERE name = ?",
|
|
518
|
+
);
|
|
519
|
+
for (const note of tagNotes) {
|
|
520
|
+
const tagName = note.path.slice("_tags/".length);
|
|
521
|
+
if (!tagName) continue;
|
|
522
|
+
let parents: string[] | null = null;
|
|
523
|
+
try {
|
|
524
|
+
const meta = note.metadata ? JSON.parse(note.metadata) : {};
|
|
525
|
+
const raw = meta?.parents;
|
|
526
|
+
if (Array.isArray(raw) && raw.length > 0) {
|
|
527
|
+
const cleaned = raw.filter((p: unknown): p is string => typeof p === "string" && p.length > 0);
|
|
528
|
+
if (cleaned.length > 0) parents = cleaned;
|
|
529
|
+
}
|
|
530
|
+
} catch {
|
|
531
|
+
// Malformed metadata — skip; the note is left untouched.
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (!parents) continue;
|
|
535
|
+
upsert.run(tagName, now, now);
|
|
536
|
+
update.run(JSON.stringify(parents), now, tagName);
|
|
537
|
+
copiedHierarchy++;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// 4. Backfill timestamps for rows the copies didn't touch.
|
|
542
|
+
db.exec(`UPDATE tags SET created_at = '${now}' WHERE created_at IS NULL`);
|
|
543
|
+
|
|
544
|
+
// 5. Drop the sidecar after the copy is complete.
|
|
545
|
+
if (hasTable(db, "tag_schemas")) {
|
|
546
|
+
db.exec("DROP TABLE tag_schemas");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
db.exec("COMMIT");
|
|
550
|
+
|
|
551
|
+
if (copiedSchemas > 0 || copiedHierarchy > 0) {
|
|
552
|
+
console.log(
|
|
553
|
+
`[vault] migrated to schema v14: copied ${copiedSchemas} tag_schemas + ${copiedHierarchy} _tags/* hierarchies onto tags rows`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
} catch (err) {
|
|
557
|
+
db.exec("ROLLBACK");
|
|
558
|
+
throw err;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Migrate v14 → v15: retire `_schemas/<name>` + `_schema_defaults` notes
|
|
564
|
+
* as the canonical source for schema definitions and mapping rules. After
|
|
565
|
+
* this migration the resolver reads from `note_schemas` and
|
|
566
|
+
* `schema_mappings` tables. The legacy notes are LEFT IN PLACE — they're
|
|
567
|
+
* harmless historical record and a user might have other content there.
|
|
568
|
+
*
|
|
569
|
+
* Idempotent: SCHEMA_SQL creates the tables before this runs (CREATE TABLE
|
|
570
|
+
* IF NOT EXISTS); the data copy uses INSERT OR IGNORE so re-running on a
|
|
571
|
+
* post-v15 DB is a no-op. Wrapped in BEGIN/COMMIT so a crash mid-migration
|
|
572
|
+
* leaves the DB in either pre-v15 or post-v15 state, never partial.
|
|
573
|
+
*/
|
|
574
|
+
function migrateToV15(db: Database): void {
|
|
575
|
+
if (!hasTable(db, "note_schemas") || !hasTable(db, "notes")) return;
|
|
576
|
+
|
|
577
|
+
// Short-circuit: if either destination table already has data, the
|
|
578
|
+
// migration has run before. `||` not `&&` — a vault with schemas but zero
|
|
579
|
+
// mappings (or mappings but zero schemas) is a valid post-v15 state, and
|
|
580
|
+
// re-scanning notes on every boot would be wasted I/O.
|
|
581
|
+
const hasSchemas = (db.prepare(
|
|
582
|
+
"SELECT 1 FROM note_schemas LIMIT 1",
|
|
583
|
+
).get()) !== null;
|
|
584
|
+
const hasMappings = (db.prepare(
|
|
585
|
+
"SELECT 1 FROM schema_mappings LIMIT 1",
|
|
586
|
+
).get()) !== null;
|
|
587
|
+
if (hasSchemas || hasMappings) return;
|
|
588
|
+
|
|
589
|
+
db.exec("BEGIN IMMEDIATE");
|
|
590
|
+
try {
|
|
591
|
+
const now = new Date().toISOString();
|
|
592
|
+
let copiedSchemas = 0;
|
|
593
|
+
let copiedMappings = 0;
|
|
594
|
+
|
|
595
|
+
// 1. Copy `_schemas/<name>` notes → note_schemas.
|
|
596
|
+
const defRows = db.prepare(
|
|
597
|
+
"SELECT path, metadata FROM notes WHERE path GLOB '_schemas/*'",
|
|
598
|
+
).all() as { path: string; metadata: string | null }[];
|
|
599
|
+
const insertSchema = db.prepare(
|
|
600
|
+
"INSERT OR IGNORE INTO note_schemas (name, description, fields, required, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
601
|
+
);
|
|
602
|
+
for (const row of defRows) {
|
|
603
|
+
const name = row.path.slice("_schemas/".length);
|
|
604
|
+
if (!name) continue;
|
|
605
|
+
let description: string | null = null;
|
|
606
|
+
let fields: string | null = null;
|
|
607
|
+
let required: string | null = null;
|
|
608
|
+
try {
|
|
609
|
+
const meta = row.metadata ? JSON.parse(row.metadata) : {};
|
|
610
|
+
if (typeof meta?.description === "string") description = meta.description;
|
|
611
|
+
if (meta?.fields && typeof meta.fields === "object" && !Array.isArray(meta.fields)) {
|
|
612
|
+
fields = JSON.stringify(meta.fields);
|
|
613
|
+
}
|
|
614
|
+
if (Array.isArray(meta?.required)) {
|
|
615
|
+
const cleaned = meta.required.filter((x: unknown): x is string => typeof x === "string");
|
|
616
|
+
if (cleaned.length > 0) required = JSON.stringify(cleaned);
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
// Malformed metadata — skip; the note is left alone.
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
insertSchema.run(name, description, fields, required, now, now);
|
|
623
|
+
copiedSchemas++;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// 2. Copy `_schema_defaults` note → schema_mappings.
|
|
627
|
+
const mappingNote = db.prepare(
|
|
628
|
+
"SELECT metadata FROM notes WHERE path = '_schema_defaults'",
|
|
629
|
+
).get() as { metadata: string | null } | undefined;
|
|
630
|
+
if (mappingNote?.metadata) {
|
|
631
|
+
const insertMapping = db.prepare(
|
|
632
|
+
"INSERT OR IGNORE INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
|
|
633
|
+
);
|
|
634
|
+
const ensureSchemaRow = db.prepare(
|
|
635
|
+
"INSERT OR IGNORE INTO note_schemas (name, created_at, updated_at) VALUES (?, ?, ?)",
|
|
636
|
+
);
|
|
637
|
+
try {
|
|
638
|
+
const meta = JSON.parse(mappingNote.metadata);
|
|
639
|
+
const pathPrefixes = meta?.path_prefixes;
|
|
640
|
+
if (pathPrefixes && typeof pathPrefixes === "object" && !Array.isArray(pathPrefixes)) {
|
|
641
|
+
for (const [prefix, schema] of Object.entries(pathPrefixes as Record<string, unknown>)) {
|
|
642
|
+
if (typeof schema === "string" && schema.length > 0 && prefix.length > 0) {
|
|
643
|
+
// Foreign key requires the schema row to exist; create a stub
|
|
644
|
+
// if the user mapped to a name with no _schemas/<name> note.
|
|
645
|
+
ensureSchemaRow.run(schema, now, now);
|
|
646
|
+
insertMapping.run(schema, "path_prefix", prefix);
|
|
647
|
+
copiedMappings++;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const tags = meta?.tags;
|
|
652
|
+
if (tags && typeof tags === "object" && !Array.isArray(tags)) {
|
|
653
|
+
for (const [tag, schema] of Object.entries(tags as Record<string, unknown>)) {
|
|
654
|
+
if (typeof schema === "string" && schema.length > 0 && tag.length > 0) {
|
|
655
|
+
ensureSchemaRow.run(schema, now, now);
|
|
656
|
+
insertMapping.run(schema, "tag", tag);
|
|
657
|
+
copiedMappings++;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
} catch {
|
|
662
|
+
// Malformed _schema_defaults — leave both note and table empty;
|
|
663
|
+
// user can fix and re-run.
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
db.exec("COMMIT");
|
|
668
|
+
|
|
669
|
+
if (copiedSchemas > 0 || copiedMappings > 0) {
|
|
670
|
+
console.log(
|
|
671
|
+
`[vault] migrated to schema v15: copied ${copiedSchemas} _schemas/* + ${copiedMappings} _schema_defaults mappings into note_schemas/schema_mappings`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
} catch (err) {
|
|
675
|
+
db.exec("ROLLBACK");
|
|
676
|
+
throw err;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Migrate v15 → v16: per-vault token storage (vault#257).
|
|
682
|
+
*
|
|
683
|
+
* Adds `tokens.vault_name TEXT` (nullable). Existing rows stay NULL —
|
|
684
|
+
* "server-wide / legacy" semantic — and `authenticateVaultRequest`
|
|
685
|
+
* accepts NULL for any vault, so today's pvt_* tokens keep working
|
|
686
|
+
* unchanged. New mints via `/vault/<name>/tokens` write the column
|
|
687
|
+
* explicitly; cross-vault presentation rejects on the row's vault_name
|
|
688
|
+
* mismatch. The complementary index speeds the per-vault listTokens
|
|
689
|
+
* filter in the admin SPA.
|
|
690
|
+
*
|
|
691
|
+
* Wrapped in BEGIN IMMEDIATE / COMMIT (with try/catch ROLLBACK) per the
|
|
692
|
+
* v14/v15 wrap pattern from vault#251 — the column add and index create
|
|
693
|
+
* are individually idempotent (`hasColumn` / IF NOT EXISTS), but the
|
|
694
|
+
* transaction means a crash mid-migration leaves either pre-v16 or
|
|
695
|
+
* post-v16 state, never partial.
|
|
696
|
+
*/
|
|
697
|
+
function migrateToV16(db: Database): void {
|
|
698
|
+
if (!hasTable(db, "tokens")) return;
|
|
699
|
+
|
|
700
|
+
// Two responsibilities, separated so the index lands for both fresh
|
|
701
|
+
// vaults (SCHEMA_SQL already created the column) and upgrading v15
|
|
702
|
+
// vaults (column missing). Index creation lives outside the wrapped
|
|
703
|
+
// ALTER block so a fresh vault — where the column exists but the index
|
|
704
|
+
// doesn't — still gets it.
|
|
705
|
+
if (!hasColumn(db, "tokens", "vault_name")) {
|
|
706
|
+
db.exec("BEGIN IMMEDIATE");
|
|
707
|
+
try {
|
|
708
|
+
db.exec("ALTER TABLE tokens ADD COLUMN vault_name TEXT");
|
|
709
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_tokens_vault_name ON tokens(vault_name)");
|
|
710
|
+
db.exec("COMMIT");
|
|
711
|
+
} catch (err) {
|
|
712
|
+
db.exec("ROLLBACK");
|
|
713
|
+
throw err;
|
|
714
|
+
}
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Column exists (fresh vault from SCHEMA_SQL, or post-upgrade vault).
|
|
719
|
+
// Make sure the index exists too. IF NOT EXISTS makes this a no-op on
|
|
720
|
+
// the steady-state path.
|
|
721
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_tokens_vault_name ON tokens(vault_name)");
|
|
722
|
+
}
|
|
723
|
+
|
|
340
724
|
function hasTable(db: Database, name: string): boolean {
|
|
341
725
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
342
726
|
return !!row;
|