@openparachute/vault 0.5.1-rc.2 → 0.5.2-rc.1

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.
@@ -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",
@@ -2091,11 +2091,29 @@ export function parseFrontmatter(raw: string): {
2091
2091
  frontmatter: Record<string, unknown>;
2092
2092
  content: string;
2093
2093
  } {
2094
- if (!raw.startsWith("---")) return { frontmatter: {}, content: raw };
2095
- const endIdx = raw.indexOf("\n---", 3);
2096
- if (endIdx === -1) return { frontmatter: {}, content: raw };
2097
- const yamlBlock = raw.slice(4, endIdx); // skip opening "---\n"
2098
- const content = raw.slice(endIdx + 4).replace(/^\n/, "");
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
- const kv = line.slice(baseIndent).match(/^([\w][\w-]*):\s*(.*)$/);
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
- const objMatch = after.match(/^([\w][\w-]*):\s*(.*)$/);
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-]*):\s*(.*)$/);
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
- return inner.split(",").map((part) => parseScalarOrInline(part.trim()));
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
- /** Recursively list all .md files in a directory, excluding hidden dirs
2380
- * (including `.parachute/` and `.obsidian/`). */
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
- if (entry.startsWith(".")) continue;
2386
- if (entry === "node_modules") continue;
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() && extname(entry).toLowerCase() === ".md") results.push(full);
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 = /(?:^|\s)#([\w][\w/-]*[\w]|[\w])/gm;
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
- tags.add(match[1]!.toLowerCase());
2553
+ const tag = normalizeTagValue(match[1]!);
2554
+ if (tag) tags.add(tag);
2429
2555
  }
2430
2556
  return [...tags];
2431
2557
  }
@@ -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: typed-link declarations
34
- -- ({ "rel": { target_tag, cardinality, description? } }).
35
- -- Cardinality vocabulary: one | optional | many | many-required.
36
- -- Phase 1 informational declared but not enforced at write.
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 (
package/core/src/store.ts CHANGED
@@ -572,7 +572,7 @@ export class BunSqliteStore implements Store {
572
572
  patch: {
573
573
  description?: string | null;
574
574
  fields?: Record<string, tagSchemaOps.TagFieldSchema> | null;
575
- relationships?: Record<string, tagSchemaOps.TagRelationship> | null;
575
+ relationships?: tagSchemaOps.TagRelationshipMap | null;
576
576
  parent_names?: string[] | null;
577
577
  },
578
578
  ) {
@@ -33,10 +33,13 @@ export interface TagFieldSchema {
33
33
  }
34
34
 
35
35
  /**
36
- * Cardinality vocabulary for typed relationships. Names rather than
37
- * algebra so AI clients reading `list-tags` can reason about intent
38
- * directly. Phase 1 is informational declarations are not enforced
39
- * at write time. See patterns/tag-data-model.md §Typed relationships.
36
+ * Cardinality vocabulary for the historical typed-relationship shape.
37
+ * Names rather than algebra so AI clients reading `list-tags` can reason
38
+ * about intent directly. Retained for callers that still want the typed
39
+ * `{ target_tag, cardinality }` declaration but `relationships` is now an
40
+ * opaque vocabulary map (see `TagRelationshipMap` / `validateRelationships`),
41
+ * so this is one valid value shape among many, not a required one.
42
+ * See patterns/tag-data-model.md §Relationships.
40
43
  */
41
44
  export type TagRelCardinality = "one" | "optional" | "many" | "many-required";
42
45
 
@@ -47,12 +50,24 @@ export const TAG_REL_CARDINALITIES: readonly TagRelCardinality[] = [
47
50
  "many-required",
48
51
  ] as const;
49
52
 
53
+ /**
54
+ * The historical typed-relationship declaration. Still a valid opaque-map
55
+ * value — vault no longer enforces it. New apps (the Weaver / structural-link
56
+ * picker) declare their own freeform vocabulary instead.
57
+ */
50
58
  export interface TagRelationship {
51
59
  target_tag: string;
52
60
  cardinality: TagRelCardinality;
53
61
  description?: string;
54
62
  }
55
63
 
64
+ /**
65
+ * `relationships` is an opaque vocabulary map: relationship-name → arbitrary
66
+ * JSON value the declaring app interprets. Vault stores and returns the values
67
+ * verbatim and enforces only that the top-level value is a JSON object (a map).
68
+ */
69
+ export type TagRelationshipMap = Record<string, unknown>;
70
+
56
71
  /**
57
72
  * Schema-only view of a tag — the historical shape. Backwards-compatible
58
73
  * with v13-and-earlier callers.
@@ -67,7 +82,7 @@ export interface TagSchema {
67
82
  * Full tag record — schema + typed relationships + hierarchy parents.
68
83
  */
69
84
  export interface TagRecord extends TagSchema {
70
- relationships?: Record<string, TagRelationship>;
85
+ relationships?: TagRelationshipMap;
71
86
  parent_names?: string[];
72
87
  created_at?: string;
73
88
  updated_at?: string;
@@ -115,7 +130,7 @@ export function upsertTagRecord(
115
130
  patch: {
116
131
  description?: string | null;
117
132
  fields?: Record<string, TagFieldSchema> | null;
118
- relationships?: Record<string, TagRelationship> | null;
133
+ relationships?: TagRelationshipMap | null;
119
134
  parent_names?: string[] | null;
120
135
  },
121
136
  ): TagRecord {
@@ -226,56 +241,56 @@ export function deleteTagSchema(db: Database, tag: string): boolean {
226
241
  }
227
242
 
228
243
  // ---------------------------------------------------------------------------
229
- // Validation — typed relationships
244
+ // Validation — relationships (opaque vocabulary map)
230
245
  // ---------------------------------------------------------------------------
231
246
 
232
247
  /**
233
- * Validate a `relationships` payload before persisting. Returns the
234
- * canonicalized object on success; throws Error with a user-readable
235
- * message on the first violation. Rules:
248
+ * Validate a `relationships` payload before persisting. `relationships` is
249
+ * an **opaque vocabulary map**: a JSON object whose keys are relationship
250
+ * names and whose values are arbitrary JSON the declaring app interprets
251
+ * (e.g. the Weaver / structural-link picker's `{ "works-on": { from, to } }`
252
+ * shape). Vault does not enforce any inner structure — it stores and returns
253
+ * the values verbatim.
254
+ *
255
+ * Rules (the only ones):
256
+ * - The top-level value must be a plain JSON object (a map). A top-level
257
+ * array or primitive is rejected — relationships is a map, not a list.
258
+ * - The payload must be JSON-serializable (no circular refs / functions /
259
+ * bigints), since it's persisted as a JSON column.
236
260
  *
237
- * - Each value must declare `target_tag` (non-empty string) and
238
- * `cardinality` from the named vocabulary.
239
- * - `description` is optional, must be a string when present.
240
- * - Relationship keys must be non-empty strings.
261
+ * Returns the value verbatim (round-trips through JSON.parse(JSON.stringify)
262
+ * to both prove serializability and strip anything non-serializable). The
263
+ * historical typed shape `{ target_tag, cardinality }` is a valid opaque map,
264
+ * so this is a backwards-compatible superset — existing typed declarations
265
+ * and callers keep working unchanged.
266
+ *
267
+ * Phase 1 was already informational ("declarations are not enforced at write
268
+ * time"); dropping the inner-shape gate is consistent with that intent.
241
269
  */
242
- export function validateRelationships(
243
- raw: unknown,
244
- ): Record<string, TagRelationship> {
270
+ export function validateRelationships(raw: unknown): Record<string, unknown> {
245
271
  if (raw === null || raw === undefined) {
246
272
  throw new Error("relationships: expected an object, got null/undefined");
247
273
  }
248
274
  if (typeof raw !== "object" || Array.isArray(raw)) {
249
- throw new Error("relationships: expected an object mapping rel name → declaration");
275
+ throw new Error(
276
+ "relationships: expected an object mapping relationship name → value (got an array or primitive)",
277
+ );
250
278
  }
251
- const out: Record<string, TagRelationship> = {};
252
- for (const [rel, decl] of Object.entries(raw as Record<string, unknown>)) {
253
- if (!rel || typeof rel !== "string") {
279
+ for (const rel of Object.keys(raw as Record<string, unknown>)) {
280
+ if (!rel) {
254
281
  throw new Error("relationships: keys must be non-empty strings");
255
282
  }
256
- if (!decl || typeof decl !== "object" || Array.isArray(decl)) {
257
- throw new Error(`relationships["${rel}"]: declaration must be an object`);
258
- }
259
- const d = decl as Record<string, unknown>;
260
- if (typeof d.target_tag !== "string" || d.target_tag.length === 0) {
261
- throw new Error(`relationships["${rel}"]: target_tag must be a non-empty string`);
262
- }
263
- const card = d.cardinality;
264
- if (typeof card !== "string" || !TAG_REL_CARDINALITIES.includes(card as TagRelCardinality)) {
265
- throw new Error(
266
- `relationships["${rel}"]: cardinality must be one of ${TAG_REL_CARDINALITIES.join(" | ")}; got ${JSON.stringify(card)}`,
267
- );
268
- }
269
- if (d.description !== undefined && typeof d.description !== "string") {
270
- throw new Error(`relationships["${rel}"]: description must be a string when set`);
271
- }
272
- out[rel] = {
273
- target_tag: d.target_tag,
274
- cardinality: card as TagRelCardinality,
275
- ...(d.description !== undefined ? { description: d.description as string } : {}),
276
- };
277
283
  }
278
- return out;
284
+ // Round-trip through JSON to (a) confirm the payload is serializable —
285
+ // the column is stored as JSON — and (b) return a clean, owned copy with
286
+ // no non-JSON values lingering. Throws on circular refs / bigint / etc.
287
+ let serialized: string;
288
+ try {
289
+ serialized = JSON.stringify(raw);
290
+ } catch (err) {
291
+ throw new Error(`relationships: value must be JSON-serializable (${(err as Error).message})`);
292
+ }
293
+ return JSON.parse(serialized) as Record<string, unknown>;
279
294
  }
280
295
 
281
296
  // ---------------------------------------------------------------------------
@@ -287,7 +302,7 @@ function rowToRecord(row: TagRow): TagRecord {
287
302
  tag: row.name,
288
303
  description: row.description ?? undefined,
289
304
  fields: parseJson<Record<string, TagFieldSchema>>(row.fields),
290
- relationships: parseJson<Record<string, TagRelationship>>(row.relationships),
305
+ relationships: parseJson<TagRelationshipMap>(row.relationships),
291
306
  parent_names: parseJson<string[]>(row.parent_names),
292
307
  created_at: row.created_at ?? undefined,
293
308
  updated_at: row.updated_at ?? undefined,
package/core/src/types.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
1
+ import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
2
2
  import type { PrunedField } from "./indexed-fields.js";
3
3
 
4
4
  // ---- Re-exports ----
5
5
 
6
- export type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
6
+ export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
7
7
  export type { PrunedField } from "./indexed-fields.js";
8
8
 
9
9
  // ---- Note ----
@@ -25,6 +25,14 @@ export interface Note {
25
25
  updatedAt?: string;
26
26
  tags?: string[];
27
27
  links?: Link[];
28
+ /**
29
+ * Opt-in link degree (raw row count, both directions by default). Present
30
+ * only when the caller requests it via `include_link_count` (REST/MCP).
31
+ * Surfaced the same way `links`/`attachments` are — an extra key injected
32
+ * onto the response after the base shape. See `getLinkCounts` in links.ts
33
+ * for the exact degree semantics (self-loop = 2 under `both`).
34
+ */
35
+ linkCount?: number;
28
36
  }
29
37
 
30
38
  // ---- Link ----
@@ -59,6 +67,16 @@ export interface VaultStats {
59
67
  tagCount: number;
60
68
  attachmentCount: number;
61
69
  linkCount: number;
70
+ /**
71
+ * Total bytes of all note content, computed as the sum of the UTF-8 byte
72
+ * length of every note's `content`. The SQL uses `LENGTH(CAST(content AS
73
+ * BLOB))` deliberately: SQLite's bare `LENGTH(text)` returns the number of
74
+ * *characters*, not bytes, so a note full of multibyte UTF-8 (emoji, CJK,
75
+ * accents) would undercount its true on-disk/on-wire footprint. Casting to
76
+ * BLOB forces `LENGTH` to count raw bytes. This is the logical content size,
77
+ * not the physical DB-file size (see `usage.ts:dbBytes` for the latter).
78
+ */
79
+ contentBytes: number;
62
80
  }
63
81
 
64
82
  // ---- Query Options ----
@@ -115,6 +133,12 @@ export interface QueryOpts {
115
133
  // declared `indexed: true`; errors loudly otherwise. Direction is taken
116
134
  // from `sort` (default "asc") and `created_at` is appended as a stable
117
135
  // tiebreaker.
136
+ //
137
+ // The pseudo-field `link_count` is special-cased (no indexed-field
138
+ // declaration needed): it sorts by link DEGREE — the both-directions
139
+ // raw row count — using the same directional-sum definition as the
140
+ // `linkCount` response field, so the sort key equals the field value for
141
+ // every note (self-loops included). See `queryNotes`/`getLinkCounts`.
118
142
  orderBy?: string;
119
143
  limit?: number;
120
144
  offset?: number;
@@ -153,6 +177,8 @@ export interface NoteSummary {
153
177
  createdAt: string;
154
178
  updatedAt?: string;
155
179
  tags?: string[];
180
+ /** Opt-in link degree (see `Note.linkCount`). */
181
+ linkCount?: number;
156
182
  }
157
183
 
158
184
  /**
@@ -169,6 +195,8 @@ export interface NoteIndex {
169
195
  metadata?: Record<string, unknown>;
170
196
  byteSize: number;
171
197
  preview: string;
198
+ /** Opt-in link degree (see `Note.linkCount`). */
199
+ linkCount?: number;
172
200
  }
173
201
 
174
202
  /** Link with hydrated note summaries. */
@@ -313,7 +341,7 @@ export interface Store {
313
341
  patch: {
314
342
  description?: string | null;
315
343
  fields?: Record<string, TagFieldSchema> | null;
316
- relationships?: Record<string, TagRelationship> | null;
344
+ relationships?: TagRelationshipMap | null;
317
345
  parent_names?: string[] | null;
318
346
  },
319
347
  ): Promise<TagRecord>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.5.1-rc.2",
3
+ "version": "0.5.2-rc.1",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/auth.test.ts CHANGED
@@ -26,7 +26,8 @@ import {
26
26
  hashKey,
27
27
  } from "./config.ts";
28
28
  import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
29
- import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
29
+ import { authenticateVaultRequest, authenticateGlobalRequest, warnLegacyGlobalApiKeys } from "./auth.ts";
30
+ import type { StoredKey } from "./config.ts";
30
31
 
31
32
  let tmpHome: string;
32
33
  let prevHome: string | undefined;
@@ -442,3 +443,38 @@ describe("auth — VAULT_AUTH_TOKEN server-wide operator bearer", () => {
442
443
  expect("error" in result).toBe(true);
443
444
  });
444
445
  });
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Legacy GLOBAL api_keys boot warning (security review — multi-user
449
+ // hardening). Cross-vault credentials in config.yaml must be surfaced loudly
450
+ // at boot, but never altered. Pure-function unit tests (no server boot).
451
+ // ---------------------------------------------------------------------------
452
+ describe("warnLegacyGlobalApiKeys (legacy cross-vault key boot warning)", () => {
453
+ function key(id: string): StoredKey {
454
+ return {
455
+ id,
456
+ label: id,
457
+ key_hash: `sha256:${id}`,
458
+ scope: "full",
459
+ created_at: new Date().toISOString(),
460
+ };
461
+ }
462
+
463
+ test("warns when global api_keys are present", () => {
464
+ const msgs: string[] = [];
465
+ const count = warnLegacyGlobalApiKeys([key("a"), key("b")], (m) => msgs.push(m));
466
+ expect(count).toBe(2);
467
+ expect(msgs).toHaveLength(1);
468
+ expect(msgs[0]).toContain("legacy GLOBAL api_key");
469
+ expect(msgs[0]).toContain("CROSS-VAULT");
470
+ // Heads-up only — must signal it does NOT alter the keys.
471
+ expect(msgs[0]).toContain("remain active");
472
+ });
473
+
474
+ test("silent when there are no global api_keys", () => {
475
+ const msgs: string[] = [];
476
+ expect(warnLegacyGlobalApiKeys([], (m) => msgs.push(m))).toBe(0);
477
+ expect(warnLegacyGlobalApiKeys(undefined, (m) => msgs.push(m))).toBe(0);
478
+ expect(msgs).toHaveLength(0);
479
+ });
480
+ });
package/src/auth.ts CHANGED
@@ -171,6 +171,35 @@ export function warnLegacyOnce(cacheKey: string, context: string): void {
171
171
  );
172
172
  }
173
173
 
174
+ /**
175
+ * Boot-time warning for legacy GLOBAL `api_keys` in `config.yaml` (security
176
+ * review — multi-user hardening). Those keys are CROSS-VAULT credentials: a
177
+ * single key authenticates against EVERY vault on this server (see the global
178
+ * `api_keys` branch in `authenticate` ~L283). That predates per-vault keys +
179
+ * tag-scoped hub JWTs and is a confidentiality hazard once a server hosts
180
+ * multiple users' vaults — one user's global key reads another's vault.
181
+ *
182
+ * WARNING ONLY — never touches the keys (the operator owns them). The
183
+ * verification flagged 6 such keys on the live box; this surfaces them at
184
+ * boot so they're rotated/removed before multi-user sharing. Returns the
185
+ * count it warned about (0 = silent) so callers / tests can assert.
186
+ */
187
+ export function warnLegacyGlobalApiKeys(
188
+ globalApiKeys: StoredKey[] | undefined,
189
+ warn: (msg: string) => void = console.warn,
190
+ ): number {
191
+ const count = globalApiKeys?.length ?? 0;
192
+ if (count === 0) return 0;
193
+ warn(
194
+ `[auth] WARNING: ${count} legacy GLOBAL api_key(s) found in config.yaml. ` +
195
+ "These are CROSS-VAULT credentials (each grants access to every vault on this server) " +
196
+ "and predate per-vault keys + tag-scoped hub JWTs. Before multi-user sharing, ROTATE or " +
197
+ "REMOVE them — a global key leaks one user's vault to another. They remain active (the " +
198
+ "operator owns them); this is a heads-up, not an automatic change.",
199
+ );
200
+ return count;
201
+ }
202
+
174
203
  /** Read-only tools (the only tools allowed for "read" permission). */
175
204
  const READ_TOOLS = new Set([
176
205
  "query-notes",