@openparachute/vault 0.6.0-rc.1 → 0.6.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 +14 -3
- package/README.md +7 -7
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +97 -2
- package/core/src/mcp.ts +201 -33
- package/core/src/notes.ts +44 -8
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/schema.ts +58 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +463 -1
- package/src/mirror-routes.ts +474 -4
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +696 -121
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +113 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-CGL256oe.js +60 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
|
@@ -794,6 +794,37 @@ describe("importPortableVault", async () => {
|
|
|
794
794
|
expect(typed!.metadata).toEqual({ source: "git://x" });
|
|
795
795
|
});
|
|
796
796
|
|
|
797
|
+
it("preserves an opaque relationship-vocabulary map across export → import → re-export (vault#428)", async () => {
|
|
798
|
+
const vocab = {
|
|
799
|
+
"works-on": { from: "person", to: "project" },
|
|
800
|
+
"member-of": { from: "person", to: "organization" },
|
|
801
|
+
"partner-of": { from: "person", to: "person" },
|
|
802
|
+
"based-at": { from: "project", to: "place", note: "freeform" },
|
|
803
|
+
};
|
|
804
|
+
await store.upsertTagRecord("person", {
|
|
805
|
+
description: "a human",
|
|
806
|
+
relationships: vocab,
|
|
807
|
+
});
|
|
808
|
+
const outDir = join(tmpBase, "out");
|
|
809
|
+
await exportVaultToDir(store, { outDir, exportedAt: "2026-05-13T00:00:00.000Z" });
|
|
810
|
+
|
|
811
|
+
const target = new SqliteStore(new Database(":memory:"));
|
|
812
|
+
const stats = await importPortableVault(target, { inDir: outDir });
|
|
813
|
+
expect(stats.schemas_restored).toBe(1);
|
|
814
|
+
|
|
815
|
+
// Imported value matches the original verbatim.
|
|
816
|
+
const restored = await target.getTagRecord("person");
|
|
817
|
+
expect(restored?.relationships).toEqual(vocab);
|
|
818
|
+
|
|
819
|
+
// Re-export from the restored store and confirm the schema file is
|
|
820
|
+
// byte-identical to the first export (deep round-trip stability).
|
|
821
|
+
const outDir2 = join(tmpBase, "out2");
|
|
822
|
+
await exportVaultToDir(target, { outDir: outDir2, exportedAt: "2026-05-13T00:00:00.000Z" });
|
|
823
|
+
const schemaA = readFileSync(join(outDir, SIDECAR_DIR, "schemas", "person.yaml"), "utf-8");
|
|
824
|
+
const schemaB = readFileSync(join(outDir2, SIDECAR_DIR, "schemas", "person.yaml"), "utf-8");
|
|
825
|
+
expect(schemaB).toBe(schemaA);
|
|
826
|
+
});
|
|
827
|
+
|
|
797
828
|
it("skips typed links whose target is missing from the import set", async () => {
|
|
798
829
|
// Source note has a typed link to a target we don't include in
|
|
799
830
|
// the export (synthetic — write the .md file by hand).
|
|
@@ -862,6 +893,15 @@ describe("portable-md round-trip — byte-equivalent re-export after blow-away i
|
|
|
862
893
|
description: "A long-running effort",
|
|
863
894
|
fields: { status: { type: "string", enum: ["active", "done"] } },
|
|
864
895
|
});
|
|
896
|
+
// Opaque relationship-vocabulary map (vault#428) — exercises that the
|
|
897
|
+
// round-trip preserves an arbitrary app-defined relationships shape,
|
|
898
|
+
// not just the historical { target_tag, cardinality } one.
|
|
899
|
+
await store.upsertTagRecord("project", {
|
|
900
|
+
relationships: {
|
|
901
|
+
"works-on": { from: "person", to: "project" },
|
|
902
|
+
"based-at": { from: "project", to: "place", note: "freeform" },
|
|
903
|
+
},
|
|
904
|
+
});
|
|
865
905
|
const n1 = await store.createNote("alpha body", {
|
|
866
906
|
id: "01HX001",
|
|
867
907
|
path: "Inbox/alpha",
|
package/core/src/portable-md.ts
CHANGED
|
@@ -2091,11 +2091,29 @@ export function parseFrontmatter(raw: string): {
|
|
|
2091
2091
|
frontmatter: Record<string, unknown>;
|
|
2092
2092
|
content: string;
|
|
2093
2093
|
} {
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
const
|
|
2094
|
+
// 1. Strip a leading UTF-8 BOM (U+FEFF) if present. Without this an
|
|
2095
|
+
// Obsidian export saved with a BOM (`---\n…`) fails the open
|
|
2096
|
+
// test and the whole file — frontmatter included — falls into the
|
|
2097
|
+
// body, silently losing id/tags/timestamps (contract FX-FENCE-BOM).
|
|
2098
|
+
const src = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
2099
|
+
|
|
2100
|
+
// 2. Line-scan close-fence (CRLF-aware). The opening line must be
|
|
2101
|
+
// EXACTLY `---`; the closing fence is the FIRST subsequent line that
|
|
2102
|
+
// is EXACTLY `---` — not `----`, not `---text`, not ` ---`. An
|
|
2103
|
+
// unclosed block means the whole file is body (never swallow). This
|
|
2104
|
+
// replaces the old `indexOf("\n---")` which wrongly matched `\n----`
|
|
2105
|
+
// and `\n---more` (contract §1.1, FX-FENCE-FOURDASH-OPEN).
|
|
2106
|
+
const lines = src.split(/\r?\n/);
|
|
2107
|
+
if (lines[0] !== "---") return { frontmatter: {}, content: src };
|
|
2108
|
+
|
|
2109
|
+
let closeIdx = -1;
|
|
2110
|
+
for (let i = 1; i < lines.length; i++) {
|
|
2111
|
+
if (lines[i] === "---") { closeIdx = i; break; }
|
|
2112
|
+
}
|
|
2113
|
+
if (closeIdx === -1) return { frontmatter: {}, content: src };
|
|
2114
|
+
|
|
2115
|
+
const yamlBlock = lines.slice(1, closeIdx).join("\n");
|
|
2116
|
+
const content = lines.slice(closeIdx + 1).join("\n");
|
|
2099
2117
|
return { frontmatter: parseBlock(yamlBlock, 0).value, content };
|
|
2100
2118
|
}
|
|
2101
2119
|
|
|
@@ -2119,11 +2137,16 @@ function parseBlock(text: string, baseIndent: number): ParseResult {
|
|
|
2119
2137
|
while (i < lines.length) {
|
|
2120
2138
|
const line = lines[i]!;
|
|
2121
2139
|
if (line.trim() === "") { i++; continue; }
|
|
2140
|
+
// Skip `#`-comment lines (contract C3 / §1.2). A comment at the
|
|
2141
|
+
// block's base level is not a key line; the parser must step over it.
|
|
2142
|
+
if (line.trimStart().startsWith("#")) { i++; continue; }
|
|
2122
2143
|
const indent = countLeadingSpaces(line);
|
|
2123
2144
|
if (indent < baseIndent) break;
|
|
2124
2145
|
if (indent > baseIndent) { i++; continue; } // shouldn't happen at this level
|
|
2125
2146
|
|
|
2126
|
-
|
|
2147
|
+
// Key regex (contract C2 / §1.2): dots allowed in keys, optional
|
|
2148
|
+
// whitespace before the colon (`created.at:` / `key :` both parse).
|
|
2149
|
+
const kv = line.slice(baseIndent).match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
|
|
2127
2150
|
if (!kv) { i++; continue; }
|
|
2128
2151
|
const key = kv[1]!;
|
|
2129
2152
|
const valueText = kv[2]!.trim();
|
|
@@ -2199,7 +2222,8 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
|
|
|
2199
2222
|
// First content after `- `.
|
|
2200
2223
|
const after = line.slice(indent + 2).trim();
|
|
2201
2224
|
// Is this a scalar item (`- foo`) or an object item (`- key: value`)?
|
|
2202
|
-
|
|
2225
|
+
// Key regex matches parseBlock's (contract C2): dots + optional space.
|
|
2226
|
+
const objMatch = after.match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
|
|
2203
2227
|
if (!objMatch) {
|
|
2204
2228
|
result.push(parseScalarOrInline(after));
|
|
2205
2229
|
i++;
|
|
@@ -2251,7 +2275,7 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
|
|
|
2251
2275
|
const sibIndent = countLeadingSpaces(sib);
|
|
2252
2276
|
if (sibIndent !== itemIndent) break;
|
|
2253
2277
|
if (sib.slice(sibIndent).startsWith("- ")) break;
|
|
2254
|
-
const sibKv = sib.slice(sibIndent).match(/^([\w][\w
|
|
2278
|
+
const sibKv = sib.slice(sibIndent).match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
|
|
2255
2279
|
if (!sibKv) break;
|
|
2256
2280
|
const sibKey = sibKv[1]!;
|
|
2257
2281
|
const sibValue = sibKv[2]!.trim();
|
|
@@ -2287,6 +2311,36 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
|
|
|
2287
2311
|
return { value: result, consumed: i - start };
|
|
2288
2312
|
}
|
|
2289
2313
|
|
|
2314
|
+
/**
|
|
2315
|
+
* Quote-aware split of an inline-array body on top-level commas
|
|
2316
|
+
* (contract C4 / §1.2). A comma inside a single- or double-quoted string
|
|
2317
|
+
* does not separate items, so `"a, b", c` → `['"a, b"', 'c']`. Quote
|
|
2318
|
+
* chars are preserved in the parts; `parseScalarOrInline` strips them via
|
|
2319
|
+
* `unquote`. Mirrors the web parser's `splitInlineArray`.
|
|
2320
|
+
*/
|
|
2321
|
+
function splitInlineArray(inner: string): string[] {
|
|
2322
|
+
const parts: string[] = [];
|
|
2323
|
+
let buf = "";
|
|
2324
|
+
let quote: '"' | "'" | null = null;
|
|
2325
|
+
for (let i = 0; i < inner.length; i++) {
|
|
2326
|
+
const ch = inner[i]!;
|
|
2327
|
+
if (quote) {
|
|
2328
|
+
buf += ch;
|
|
2329
|
+
if (ch === quote) quote = null;
|
|
2330
|
+
} else if (ch === '"' || ch === "'") {
|
|
2331
|
+
quote = ch;
|
|
2332
|
+
buf += ch;
|
|
2333
|
+
} else if (ch === ",") {
|
|
2334
|
+
parts.push(buf);
|
|
2335
|
+
buf = "";
|
|
2336
|
+
} else {
|
|
2337
|
+
buf += ch;
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
parts.push(buf);
|
|
2341
|
+
return parts;
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2290
2344
|
/**
|
|
2291
2345
|
* Parse a scalar or inline form (`[a, b]`, `{ k: v }`). Used for the
|
|
2292
2346
|
* value portion of `key: value` lines.
|
|
@@ -2295,7 +2349,10 @@ function parseScalarOrInline(s: string): unknown {
|
|
|
2295
2349
|
if (s.startsWith("[") && s.endsWith("]")) {
|
|
2296
2350
|
const inner = s.slice(1, -1).trim();
|
|
2297
2351
|
if (inner === "") return [];
|
|
2298
|
-
|
|
2352
|
+
// Quote-aware split (contract C4 / §1.2): a comma inside a quoted
|
|
2353
|
+
// string is NOT an item separator, so `["a, b", c]` → 2 items, not 3.
|
|
2354
|
+
// Matches the web parser's `splitInlineArray`.
|
|
2355
|
+
return splitInlineArray(inner).map((part) => parseScalarOrInline(part.trim()));
|
|
2299
2356
|
}
|
|
2300
2357
|
if (s.startsWith("{") && s.endsWith("}")) {
|
|
2301
2358
|
const inner = s.slice(1, -1).trim();
|
|
@@ -2376,18 +2433,59 @@ function unquote(s: string): unknown {
|
|
|
2376
2433
|
// Directory walking — shared with obsidian.ts
|
|
2377
2434
|
// ---------------------------------------------------------------------------
|
|
2378
2435
|
|
|
2379
|
-
/**
|
|
2380
|
-
*
|
|
2436
|
+
/**
|
|
2437
|
+
* Markdown-file classification (contract §1.7). Case-insensitive `.md`
|
|
2438
|
+
* OR `.markdown`. `.mdx`, `.txt`, etc. are NOT markdown for the importer.
|
|
2439
|
+
* Identical to the web parser's classifier.
|
|
2440
|
+
*/
|
|
2441
|
+
export function isMarkdownExtension(path: string): boolean {
|
|
2442
|
+
return /\.(md|markdown)$/i.test(path);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/**
|
|
2446
|
+
* Named intake-excluded directory/entry segments (contract §1.9). The
|
|
2447
|
+
* generic `startsWith(".")` rule below subsumes the dot-prefixed ones;
|
|
2448
|
+
* the named set is kept explicit for readability + to cover
|
|
2449
|
+
* `__MACOSX`/`node_modules` (which do not start with ".").
|
|
2450
|
+
*/
|
|
2451
|
+
const EXCLUDED_SEGMENTS = new Set([
|
|
2452
|
+
".obsidian",
|
|
2453
|
+
".trash",
|
|
2454
|
+
".git",
|
|
2455
|
+
".parachute",
|
|
2456
|
+
"__MACOSX",
|
|
2457
|
+
"node_modules",
|
|
2458
|
+
]);
|
|
2459
|
+
|
|
2460
|
+
/**
|
|
2461
|
+
* Intake (file-selection) exclusion (contract §1.9). Excludes a source
|
|
2462
|
+
* path if ANY `/`-segment is a named-excluded entry OR starts with "."
|
|
2463
|
+
* (generic dotfile/dotdir). Applied identically by both parsers before
|
|
2464
|
+
* parsing. The generic dot rule means legit dot-prefixed user files
|
|
2465
|
+
* (`.daily-note.md`) ARE excluded — the chosen, consistent behavior.
|
|
2466
|
+
*/
|
|
2467
|
+
export function isExcludedPath(sourcePath: string): boolean {
|
|
2468
|
+
for (const segment of sourcePath.split("/")) {
|
|
2469
|
+
if (segment === "") continue;
|
|
2470
|
+
if (EXCLUDED_SEGMENTS.has(segment)) return true;
|
|
2471
|
+
if (segment.startsWith(".")) return true;
|
|
2472
|
+
}
|
|
2473
|
+
return false;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
/** Recursively list all .md / .markdown files in a directory, excluding
|
|
2477
|
+
* hidden + named-internal dirs per the canonical `isExcludedPath` rule
|
|
2478
|
+
* (`.parachute/`, `.obsidian/`, `node_modules`, …). Legacy import path. */
|
|
2381
2479
|
export function walkMarkdownFiles(dir: string): string[] {
|
|
2382
2480
|
const results: string[] = [];
|
|
2383
2481
|
function walk(current: string) {
|
|
2384
2482
|
for (const entry of readdirSync(current)) {
|
|
2385
|
-
|
|
2386
|
-
if (entry
|
|
2483
|
+
// Per-segment exclusion: the named set + generic dotfile rule.
|
|
2484
|
+
if (EXCLUDED_SEGMENTS.has(entry) || entry.startsWith(".")) continue;
|
|
2387
2485
|
const full = join(current, entry);
|
|
2388
2486
|
const stat = statSync(full);
|
|
2389
2487
|
if (stat.isDirectory()) walk(full);
|
|
2390
|
-
else if (stat.isFile() &&
|
|
2488
|
+
else if (stat.isFile() && isMarkdownExtension(entry)) results.push(full);
|
|
2391
2489
|
}
|
|
2392
2490
|
}
|
|
2393
2491
|
walk(dir);
|
|
@@ -2417,15 +2515,43 @@ export function walkContentFiles(dir: string): string[] {
|
|
|
2417
2515
|
return results.sort();
|
|
2418
2516
|
}
|
|
2419
2517
|
|
|
2518
|
+
/**
|
|
2519
|
+
* Canonical inline-tag regex (contract §1.3). `#` at line-start or after
|
|
2520
|
+
* whitespace; body chars `[A-Za-z0-9_/-]` (slash for hierarchy); the tag
|
|
2521
|
+
* MUST contain ≥1 non-numeric char (the middle `[A-Za-z_/-]`), so `#2024`
|
|
2522
|
+
* is NOT a tag but `#v2`, `#2024-plan`, `#area/sub` are. Identical to the
|
|
2523
|
+
* web parser's `INLINE_HASHTAG`.
|
|
2524
|
+
*/
|
|
2525
|
+
const INLINE_TAG_REGEX = /(?:^|\s)#([A-Za-z0-9_/-]*[A-Za-z_/-][A-Za-z0-9_/-]*)/g;
|
|
2526
|
+
|
|
2527
|
+
/**
|
|
2528
|
+
* Normalize + slug-validate a tag value (contract §1.4). Shared by inline
|
|
2529
|
+
* tag extraction and frontmatter tag extraction (obsidian.ts re-exports
|
|
2530
|
+
* this) so both surfaces validate identically. Returns null when the
|
|
2531
|
+
* value is unusable (non-string non-scalar, empty, or fails slug rules).
|
|
2532
|
+
*/
|
|
2533
|
+
export function normalizeTagValue(v: unknown): string | null {
|
|
2534
|
+
if (typeof v === "number" || typeof v === "boolean") {
|
|
2535
|
+
return String(v).toLowerCase();
|
|
2536
|
+
}
|
|
2537
|
+
if (typeof v !== "string") return null;
|
|
2538
|
+
const stripped = v.trim().replace(/^#/, "").toLowerCase();
|
|
2539
|
+
if (stripped === "") return null;
|
|
2540
|
+
// Slug-validate: lowercase alnum, underscore, hyphen, slash (hierarchy).
|
|
2541
|
+
if (!/^[a-z0-9_/-]+$/.test(stripped)) return null;
|
|
2542
|
+
return stripped;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2420
2545
|
/** Extract inline #tags from markdown content. Excludes tags in code blocks. */
|
|
2421
2546
|
export function extractInlineTags(content: string): string[] {
|
|
2422
2547
|
let stripped = content.replace(/```[\s\S]*?```/g, "");
|
|
2423
2548
|
stripped = stripped.replace(/`[^`\n]+`/g, "");
|
|
2424
2549
|
const tags = new Set<string>();
|
|
2425
|
-
const regex =
|
|
2550
|
+
const regex = new RegExp(INLINE_TAG_REGEX.source, INLINE_TAG_REGEX.flags);
|
|
2426
2551
|
let match: RegExpExecArray | null;
|
|
2427
2552
|
while ((match = regex.exec(stripped)) !== null) {
|
|
2428
|
-
|
|
2553
|
+
const tag = normalizeTagValue(match[1]!);
|
|
2554
|
+
if (tag) tags.add(tag);
|
|
2429
2555
|
}
|
|
2430
2556
|
return [...tags];
|
|
2431
2557
|
}
|
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 = 21;
|
|
6
6
|
|
|
7
7
|
export const SCHEMA_SQL = `
|
|
8
8
|
-- Notes: the universal record.
|
|
@@ -30,10 +30,13 @@ CREATE TABLE IF NOT EXISTS notes (
|
|
|
30
30
|
-- description — human-readable blurb (markdown).
|
|
31
31
|
-- fields — JSON: indexed metadata field declarations per
|
|
32
32
|
-- query-operators.md. Replaces v6-era tag_schemas.fields.
|
|
33
|
-
-- relationships — JSON:
|
|
34
|
-
--
|
|
35
|
-
--
|
|
36
|
-
--
|
|
33
|
+
-- relationships — JSON: opaque relationship-vocabulary map. Keys are
|
|
34
|
+
-- relationship names; values are arbitrary JSON the declaring
|
|
35
|
+
-- app interprets (e.g. the Weaver's { "works-on": { from, to } }).
|
|
36
|
+
-- Stored + returned verbatim; only the top level is validated
|
|
37
|
+
-- (must be a JSON object/map). The historical typed shape
|
|
38
|
+
-- { "rel": { target_tag, cardinality, description? } } remains a
|
|
39
|
+
-- valid value. Not enforced at write time. See vault#428.
|
|
37
40
|
-- parent_names — JSON array of parent tag names. Replaces the v6-era
|
|
38
41
|
-- _tags/NAME config-note hierarchy.
|
|
39
42
|
CREATE TABLE IF NOT EXISTS tags (
|
|
@@ -96,7 +99,7 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
|
|
|
96
99
|
|
|
97
100
|
-- Tokens: API authentication with OAuth-standard scopes.
|
|
98
101
|
--
|
|
99
|
-
-- VESTIGIAL as of 0.
|
|
102
|
+
-- VESTIGIAL as of 0.5.0 (vault#282 Stage 2). Vault is a pure hub
|
|
100
103
|
-- resource-server: it no longer mints (pvt_*) or validates rows in this
|
|
101
104
|
-- table — auth runs through hub-issued JWTs + VAULT_AUTH_TOKEN + legacy YAML
|
|
102
105
|
-- api_keys only. The table is KEPT (not dropped) because migrateVaultKeys
|
|
@@ -105,7 +108,7 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
|
|
|
105
108
|
-- leftover rows. A future cosmetic migration may drop it alongside
|
|
106
109
|
-- oauth_clients/oauth_codes. 'tokens list' / 'tokens revoke' (CLI) still
|
|
107
110
|
-- read/delete here for cleanup of leftover rows. See the field docs below for
|
|
108
|
-
-- the historical (pre-0.
|
|
111
|
+
-- the historical (pre-0.5.0) semantics.
|
|
109
112
|
--
|
|
110
113
|
-- scopes is a whitespace-separated list of granted scopes (OAuth 2.0 §3.3)
|
|
111
114
|
-- — e.g. "vault:read vault:write". Introduced in v12 alongside enforcement;
|
|
@@ -140,7 +143,7 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
|
|
|
140
143
|
-- session. Session-pinned list+revoke in manage-token filters on this.
|
|
141
144
|
--
|
|
142
145
|
-- revoked_at (v19) marked soft-revocation of vault-DB tokens. Vestigial
|
|
143
|
-
-- post-0.
|
|
146
|
+
-- post-0.5.0 (vault#282 Stage 2) — the validation path that read it
|
|
144
147
|
-- (resolveToken) was removed alongside the pvt_* mint.
|
|
145
148
|
CREATE TABLE IF NOT EXISTS tokens (
|
|
146
149
|
token_hash TEXT PRIMARY KEY,
|
|
@@ -183,6 +186,23 @@ CREATE TABLE IF NOT EXISTS mcp_mint_ledger (
|
|
|
183
186
|
revoked_at TEXT
|
|
184
187
|
);
|
|
185
188
|
|
|
189
|
+
-- Triggers (v21, vault frictionless-channel-setup PR 1): runtime, persisted,
|
|
190
|
+
-- per-vault webhook triggers. Complements the static config.yaml trigger
|
|
191
|
+
-- system — config.yaml triggers stay global; rows here are scoped to THIS
|
|
192
|
+
-- vault's DB and fire only for events on this vault. The structured columns
|
|
193
|
+
-- (events/when/action) are JSON-encoded; the action column carries the webhook
|
|
194
|
+
-- URL, send mode, timeout, and an optional auth { bearer } for the JWT webhook
|
|
195
|
+
-- path. Managed at runtime via the admin-scoped /api/triggers REST surface
|
|
196
|
+
-- and re-registered on the live hook registry at boot. See src/triggers-api.ts.
|
|
197
|
+
CREATE TABLE IF NOT EXISTS triggers (
|
|
198
|
+
name TEXT PRIMARY KEY,
|
|
199
|
+
events TEXT NOT NULL DEFAULT '[]',
|
|
200
|
+
"when" TEXT NOT NULL DEFAULT '{}',
|
|
201
|
+
action TEXT NOT NULL DEFAULT '{}',
|
|
202
|
+
created_at TEXT NOT NULL,
|
|
203
|
+
updated_at TEXT NOT NULL
|
|
204
|
+
);
|
|
205
|
+
|
|
186
206
|
-- OAuth: registered clients (Dynamic Client Registration)
|
|
187
207
|
-- VESTIGIAL after vault 0.4.x workstream E (2026-05-25). The standalone
|
|
188
208
|
-- OAuth issuer that wrote these rows was retired (hub is the issuer now;
|
|
@@ -420,7 +440,7 @@ export function initSchema(db: Database): void {
|
|
|
420
440
|
// Migrate v15 → v16: add `vault_name` column to tokens. Existing rows
|
|
421
441
|
// backfilled to NULL ("server-wide / legacy" semantic) — at the time auth
|
|
422
442
|
// accepted NULL for any vault so pre-v16 pvt_* tokens kept working. (pvt_*
|
|
423
|
-
// validation was dropped at 0.
|
|
443
|
+
// validation was dropped at 0.5.0 / vault#282 Stage 2; the column is now
|
|
424
444
|
// vestigial.) See vault#257.
|
|
425
445
|
migrateToV16(db);
|
|
426
446
|
|
|
@@ -449,6 +469,11 @@ export function initSchema(db: Database): void {
|
|
|
449
469
|
// version bump. See vault#403 (MGT — manage-token mints hub JWTs).
|
|
450
470
|
migrateToV20(db);
|
|
451
471
|
|
|
472
|
+
// Migrate v20 → v21: ensure the `triggers` table exists (runtime per-vault
|
|
473
|
+
// webhook triggers). Created by SCHEMA_SQL's CREATE TABLE IF NOT EXISTS
|
|
474
|
+
// above, so this is a defensive confirmation hook for upgrading vaults.
|
|
475
|
+
migrateToV21(db);
|
|
476
|
+
|
|
452
477
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
453
478
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
454
479
|
rebuildIndexes(db);
|
|
@@ -794,7 +819,7 @@ function migrateToV15(db: Database): void {
|
|
|
794
819
|
// 2. Copy `_schema_defaults` note → schema_mappings.
|
|
795
820
|
const mappingNote = db.prepare(
|
|
796
821
|
"SELECT metadata FROM notes WHERE path = '_schema_defaults'",
|
|
797
|
-
).get() as { metadata: string | null } |
|
|
822
|
+
).get() as { metadata: string | null } | null;
|
|
798
823
|
if (mappingNote?.metadata) {
|
|
799
824
|
const insertMapping = db.prepare(
|
|
800
825
|
"INSERT OR IGNORE INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
|
|
@@ -852,7 +877,7 @@ function migrateToV15(db: Database): void {
|
|
|
852
877
|
* "server-wide / legacy" semantic. At the time, `authenticateVaultRequest`
|
|
853
878
|
* accepted NULL for any vault so pre-v16 pvt_* tokens kept working. (pvt_*
|
|
854
879
|
* validation + the `/vault/<name>/tokens` mint route were both removed at
|
|
855
|
-
* 0.
|
|
880
|
+
* 0.5.0 / vault#282 Stage 2 — the column + index are now vestigial; the
|
|
856
881
|
* index still speeds the per-vault `listTokens` cleanup listing.)
|
|
857
882
|
*
|
|
858
883
|
* Wrapped in BEGIN IMMEDIATE / COMMIT (with try/catch ROLLBACK) per the
|
|
@@ -1074,6 +1099,28 @@ function migrateToV20(db: Database): void {
|
|
|
1074
1099
|
);
|
|
1075
1100
|
}
|
|
1076
1101
|
|
|
1102
|
+
/**
|
|
1103
|
+
* Migrate v20 → v21: ensure the `triggers` table exists (runtime per-vault
|
|
1104
|
+
* webhook triggers — vault frictionless-channel-setup PR 1). SCHEMA_SQL's
|
|
1105
|
+
* `CREATE TABLE IF NOT EXISTS` already covers fresh AND upgrading vaults
|
|
1106
|
+
* (it runs unconditionally before the migration steps), so this is a
|
|
1107
|
+
* defensive no-op confirmation for vaults created before v21. The reserved
|
|
1108
|
+
* keyword `when` is quoted in the column definition. Idempotent.
|
|
1109
|
+
*/
|
|
1110
|
+
function migrateToV21(db: Database): void {
|
|
1111
|
+
if (hasTable(db, "triggers")) return;
|
|
1112
|
+
db.exec(`
|
|
1113
|
+
CREATE TABLE IF NOT EXISTS triggers (
|
|
1114
|
+
name TEXT PRIMARY KEY,
|
|
1115
|
+
events TEXT NOT NULL DEFAULT '[]',
|
|
1116
|
+
"when" TEXT NOT NULL DEFAULT '{}',
|
|
1117
|
+
action TEXT NOT NULL DEFAULT '{}',
|
|
1118
|
+
created_at TEXT NOT NULL,
|
|
1119
|
+
updated_at TEXT NOT NULL
|
|
1120
|
+
)
|
|
1121
|
+
`);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1077
1124
|
function hasTable(db: Database, name: string): boolean {
|
|
1078
1125
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
1079
1126
|
return !!row;
|
package/core/src/store.ts
CHANGED
|
@@ -15,10 +15,12 @@ import { pathTitle } from "./paths.js";
|
|
|
15
15
|
import { HookRegistry } from "./hooks.js";
|
|
16
16
|
import {
|
|
17
17
|
loadTagHierarchy,
|
|
18
|
-
|
|
18
|
+
getTagExpansion,
|
|
19
19
|
TAG_CONFIG_PREFIX,
|
|
20
20
|
DEFAULT_TAG_NAME,
|
|
21
|
+
DEFAULT_TAG_EXPAND_MODE,
|
|
21
22
|
type TagHierarchy,
|
|
23
|
+
type TagExpandMode,
|
|
22
24
|
} from "./tag-hierarchy.js";
|
|
23
25
|
import {
|
|
24
26
|
loadSchemaConfig,
|
|
@@ -278,13 +280,25 @@ export class BunSqliteStore implements Store {
|
|
|
278
280
|
* the other tags' notes — wrong).
|
|
279
281
|
*
|
|
280
282
|
* Other filters (path, metadata, dates) still apply in both cases.
|
|
283
|
+
*
|
|
284
|
+
* `expand` axis (vault tag `expand` axis): `opts.expand` selects WHICH axis
|
|
285
|
+
* each tag expands along — `"subtypes"` (default, the parent_names path
|
|
286
|
+
* documented above, with the `_default` magic), `"namespace"` (lexical
|
|
287
|
+
* `tag/*`), `"both"` (union), or `"exact"` (no expansion). The `_default`
|
|
288
|
+
* universal-parent magic is a SUBTYPES-axis concept, so it fires only when
|
|
289
|
+
* the resolved mode includes subtypes (`"subtypes"`/`"both"`); under
|
|
290
|
+
* `"namespace"`/`"exact"` a literal `_default` tag is treated like any other.
|
|
281
291
|
*/
|
|
282
292
|
private expandQueryTags(opts: QueryOpts): QueryOpts {
|
|
283
293
|
if (!opts.tags || opts.tags.length === 0) return opts;
|
|
284
294
|
const hierarchy = this.getTagHierarchy();
|
|
295
|
+
const mode: TagExpandMode = opts.expand ?? DEFAULT_TAG_EXPAND_MODE;
|
|
296
|
+
const subtypeAxis = mode === "subtypes" || mode === "both";
|
|
285
297
|
|
|
286
298
|
let tags = opts.tags;
|
|
287
|
-
|
|
299
|
+
// `_default` collapse only applies on the subtypes axis — it's the
|
|
300
|
+
// universal *parent* (an is-a relationship), not a namespace prefix.
|
|
301
|
+
if (subtypeAxis && hierarchy.allTags.has(DEFAULT_TAG_NAME) && tags.includes(DEFAULT_TAG_NAME)) {
|
|
288
302
|
const match = opts.tagMatch ?? "all";
|
|
289
303
|
if (match === "any") {
|
|
290
304
|
const { tags: _drop, ..._rest } = opts;
|
|
@@ -298,34 +312,61 @@ export class BunSqliteStore implements Store {
|
|
|
298
312
|
opts = { ...opts, tags };
|
|
299
313
|
}
|
|
300
314
|
|
|
301
|
-
|
|
302
|
-
|
|
315
|
+
// Subtypes fast-path: with no declared hierarchy there are no descendants,
|
|
316
|
+
// so the engine's `[tag]` fallback already produces the literal-tag join —
|
|
317
|
+
// skip attaching `_tagsExpanded` to stay byte-identical to pre-axis
|
|
318
|
+
// behavior. `exact` likewise needs no expansion. Namespace/both must still
|
|
319
|
+
// run (lexical expansion is independent of `parent_names`).
|
|
320
|
+
if (mode === "exact") return opts;
|
|
321
|
+
if (mode === "subtypes" && hierarchy.childrenOf.size === 0) return opts;
|
|
322
|
+
|
|
323
|
+
const expanded = tags.map((t) => Array.from(getTagExpansion(hierarchy, t, mode)));
|
|
303
324
|
return { ...opts, _tagsExpanded: expanded } as QueryOpts;
|
|
304
325
|
}
|
|
305
326
|
|
|
306
|
-
async searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]> {
|
|
307
|
-
// Same
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
327
|
+
async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]> {
|
|
328
|
+
// Same tag-expansion treatment as queryNotes, along the SAME `expand` axis
|
|
329
|
+
// (vault tag `expand` axis) — searching `#manual` should match notes
|
|
330
|
+
// tagged with any descendant under "subtypes", any `manual/*` under
|
|
331
|
+
// "namespace", etc. The underlying FTS path already uses `IN (...)` for
|
|
332
|
+
// tags, so we flatten the per-input expansions into a single union (search
|
|
333
|
+
// semantics are "any tag matches").
|
|
334
|
+
//
|
|
335
|
+
// `_default` collapse is a SUBTYPES-axis concept (the universal *parent*):
|
|
336
|
+
// when `_default` is among the requested tags and a `_default` row exists,
|
|
337
|
+
// the OR collapses to "every note" — drop the tag filter entirely so the
|
|
338
|
+
// search hits the full corpus and untagged notes are reachable. It fires
|
|
339
|
+
// only on the subtypes/both axes (mirrors `expandQueryTags`).
|
|
315
340
|
if (opts?.tags && opts.tags.length > 0) {
|
|
341
|
+
const mode: TagExpandMode = opts.expand ?? DEFAULT_TAG_EXPAND_MODE;
|
|
342
|
+
const subtypeAxis = mode === "subtypes" || mode === "both";
|
|
316
343
|
const hierarchy = this.getTagHierarchy();
|
|
317
|
-
if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
|
|
318
|
-
const { tags: _drop, ..._rest } = opts;
|
|
344
|
+
if (subtypeAxis && hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
|
|
345
|
+
const { tags: _drop, expand: _e, ..._rest } = opts;
|
|
319
346
|
return noteOps.searchNotes(this.db, query, _rest);
|
|
320
347
|
}
|
|
321
|
-
|
|
348
|
+
// Subtypes fast-path: with no declared hierarchy there are no
|
|
349
|
+
// descendants, so the tags pass through unchanged (byte-identical to
|
|
350
|
+
// pre-axis behavior). `exact` likewise needs no expansion.
|
|
351
|
+
// Namespace/both must still run (lexical expansion is independent of
|
|
352
|
+
// `parent_names`).
|
|
353
|
+
const skipExpansion =
|
|
354
|
+
mode === "exact" || (mode === "subtypes" && hierarchy.childrenOf.size === 0);
|
|
355
|
+
if (!skipExpansion) {
|
|
322
356
|
const expanded = new Set<string>();
|
|
323
357
|
for (const t of opts.tags) {
|
|
324
|
-
for (const x of
|
|
358
|
+
for (const x of getTagExpansion(hierarchy, t, mode)) expanded.add(x);
|
|
325
359
|
}
|
|
326
|
-
|
|
360
|
+
const { expand: _e, ..._rest } = opts;
|
|
361
|
+
return noteOps.searchNotes(this.db, query, { ..._rest, tags: Array.from(expanded) });
|
|
327
362
|
}
|
|
328
363
|
}
|
|
364
|
+
// Strip the internal `expand` before passing to noteOps (it has no field
|
|
365
|
+
// for it; harmless but keep the boundary clean).
|
|
366
|
+
if (opts && "expand" in opts) {
|
|
367
|
+
const { expand: _e, ..._rest } = opts;
|
|
368
|
+
return noteOps.searchNotes(this.db, query, _rest);
|
|
369
|
+
}
|
|
329
370
|
return noteOps.searchNotes(this.db, query, opts);
|
|
330
371
|
}
|
|
331
372
|
|
|
@@ -340,11 +381,17 @@ export class BunSqliteStore implements Store {
|
|
|
340
381
|
}
|
|
341
382
|
|
|
342
383
|
async expandTagsWithDescendants(tags: string[]): Promise<Set<string>> {
|
|
384
|
+
// Thin `mode:"subtypes"` shim over the mode-aware `expandTags`, so existing
|
|
385
|
+
// callers (tag-scope auth, search) keep the exact descendant semantics.
|
|
386
|
+
return this.expandTags(tags, "subtypes");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async expandTags(tags: string[], mode: TagExpandMode = DEFAULT_TAG_EXPAND_MODE): Promise<Set<string>> {
|
|
343
390
|
const expanded = new Set<string>();
|
|
344
391
|
if (tags.length === 0) return expanded;
|
|
345
392
|
const hierarchy = this.getTagHierarchy();
|
|
346
393
|
for (const t of tags) {
|
|
347
|
-
for (const x of
|
|
394
|
+
for (const x of getTagExpansion(hierarchy, t, mode)) expanded.add(x);
|
|
348
395
|
}
|
|
349
396
|
return expanded;
|
|
350
397
|
}
|
|
@@ -572,7 +619,7 @@ export class BunSqliteStore implements Store {
|
|
|
572
619
|
patch: {
|
|
573
620
|
description?: string | null;
|
|
574
621
|
fields?: Record<string, tagSchemaOps.TagFieldSchema> | null;
|
|
575
|
-
relationships?:
|
|
622
|
+
relationships?: tagSchemaOps.TagRelationshipMap | null;
|
|
576
623
|
parent_names?: string[] | null;
|
|
577
624
|
},
|
|
578
625
|
) {
|
|
@@ -730,7 +777,7 @@ export class BunSqliteStore implements Store {
|
|
|
730
777
|
// Scope by noteId so a token authorized for note A can't delete note B's attachments.
|
|
731
778
|
const row = this.db.prepare(
|
|
732
779
|
"SELECT path FROM attachments WHERE id = ? AND note_id = ?",
|
|
733
|
-
).get(attachmentId, noteId) as { path: string } |
|
|
780
|
+
).get(attachmentId, noteId) as { path: string } | null;
|
|
734
781
|
if (!row) return { deleted: false, path: null, orphaned: false };
|
|
735
782
|
|
|
736
783
|
this.db.prepare("DELETE FROM attachments WHERE id = ? AND note_id = ?").run(attachmentId, noteId);
|
|
@@ -756,7 +803,7 @@ export class BunSqliteStore implements Store {
|
|
|
756
803
|
async getAttachment(attachmentId: string): Promise<Attachment | null> {
|
|
757
804
|
const row = this.db.prepare(
|
|
758
805
|
"SELECT * FROM attachments WHERE id = ?",
|
|
759
|
-
).get(attachmentId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string } |
|
|
806
|
+
).get(attachmentId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string } | null;
|
|
760
807
|
if (!row) return null;
|
|
761
808
|
let metadata: Record<string, unknown> | undefined;
|
|
762
809
|
if (row.metadata && row.metadata !== "{}") {
|