@openparachute/vault 0.6.0-rc.1 → 0.6.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.
Files changed (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/core/src/notes.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  type CursorPayload,
19
19
  type QueryHashInputs,
20
20
  } from "./cursor.js";
21
- import { releaseField } from "./indexed-fields.js";
21
+ import { getIndexedField, releaseField } from "./indexed-fields.js";
22
22
 
23
23
  let idCounter = 0;
24
24
 
@@ -83,7 +83,7 @@ export function createNote(
83
83
  }
84
84
 
85
85
  export function getNote(db: Database, id: string): Note | null {
86
- const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id) as NoteRow | undefined;
86
+ const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id) as NoteRow | null;
87
87
  if (!row) return null;
88
88
 
89
89
  const note = rowToNote(row);
@@ -111,7 +111,7 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
111
111
  if (extension !== undefined) {
112
112
  const row = db.prepare(
113
113
  "SELECT * FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
114
- ).get(path, extension.toLowerCase()) as NoteRow | undefined;
114
+ ).get(path, extension.toLowerCase()) as NoteRow | null;
115
115
  if (!row) return null;
116
116
  const note = rowToNote(row);
117
117
  note.tags = getNoteTags(db, note.id);
@@ -142,11 +142,7 @@ export function getNotes(db: Database, ids: string[]): Note[] {
142
142
  const rows = db.prepare(
143
143
  `SELECT * FROM notes WHERE id IN (${placeholders}) ORDER BY created_at`,
144
144
  ).all(...ids) as NoteRow[];
145
- return rows.map((row) => {
146
- const note = rowToNote(row);
147
- note.tags = getNoteTags(db, note.id);
148
- return note;
149
- });
145
+ return notesWithTags(db, rows);
150
146
  }
151
147
 
152
148
  /**
@@ -459,7 +455,7 @@ export function updateNote(
459
455
  if (isPathUniqueError(err)) {
460
456
  const conflictPath = updates.path !== undefined
461
457
  ? (normalizePath(updates.path) ?? updates.path)
462
- : ((db.prepare("SELECT path FROM notes WHERE id = ?").get(id) as { path: string | null } | undefined)?.path ?? "<unknown>");
458
+ : ((db.prepare("SELECT path FROM notes WHERE id = ?").get(id) as { path: string | null } | null)?.path ?? "<unknown>");
463
459
  throw new PathConflictError(conflictPath);
464
460
  }
465
461
  throw err;
@@ -475,7 +471,7 @@ export function updateNote(
475
471
  function throwConflictOrMissing(db: Database, id: string, expected: string): never {
476
472
  const row = db.prepare("SELECT updated_at, path FROM notes WHERE id = ?").get(id) as
477
473
  | { updated_at: string | null; path: string | null }
478
- | undefined;
474
+ | null;
479
475
  if (!row) {
480
476
  throw new Error(`Note not found: "${id}"`);
481
477
  }
@@ -489,7 +485,6 @@ export function deleteNote(db: Database, id: string): void {
489
485
  export function queryNotes(db: Database, opts: QueryOpts): Note[] {
490
486
  const conditions: string[] = [];
491
487
  const params: SQLQueryBindings[] = [];
492
- const joins: string[] = [];
493
488
 
494
489
  // Include tags — "all" (default): must have ALL tags; "any": must have ANY tag.
495
490
  // The `_tagsExpanded` internal field carries per-input-tag descendant sets
@@ -498,6 +493,15 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
498
493
  // `{manual, voice, text, ...}` per declared `_tags/*` config notes. Falls
499
494
  // back to `[opts.tags[i]]` (single-element set) when no expansion is set,
500
495
  // preserving the original semantics.
496
+ //
497
+ // Membership is expressed as a SEMIJOIN (`n.id IN (SELECT note_id ...)`),
498
+ // not a `JOIN note_tags`. A JOIN multiplies rows when a note carries
499
+ // several matching tags, which forced `SELECT DISTINCT n.*` — and that
500
+ // DISTINCT materialized every candidate's FULL row (content included)
501
+ // into a temp B-tree before LIMIT could apply, making large-tag queries
502
+ // cost O(candidates × row size) regardless of limit. The IN-subquery
503
+ // rides idx_note_tags_tag, produces each note id at most once, and lets
504
+ // the whole query drop DISTINCT. See the 2026-06-10 perf measurements.
501
505
  if (opts.tags && opts.tags.length > 0) {
502
506
  const tagSets: string[][] = (opts as QueryOpts & { _tagsExpanded?: string[][] })._tagsExpanded
503
507
  ?? opts.tags.map((t) => [t]);
@@ -508,17 +512,16 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
508
512
  const flat = Array.from(new Set(tagSets.flat()));
509
513
  if (flat.length > 0) {
510
514
  const placeholders = flat.map(() => "?").join(", ");
511
- joins.push(`JOIN note_tags nt_or ON nt_or.note_id = n.id AND nt_or.tag_name IN (${placeholders})`);
515
+ conditions.push(`n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${placeholders}))`);
512
516
  params.push(...flat);
513
517
  }
514
518
  } else {
515
- // "all": one JOIN per input tag, each accepting the input or any descendant.
516
- for (let i = 0; i < tagSets.length; i++) {
517
- const set = tagSets[i] ?? [];
518
- if (set.length === 0) continue;
519
- const alias = `nt${i}`;
519
+ // "all": one membership clause per input tag, each accepting the
520
+ // input or any descendant.
521
+ for (const set of tagSets) {
522
+ if (!set || set.length === 0) continue;
520
523
  const placeholders = set.map(() => "?").join(", ");
521
- joins.push(`JOIN note_tags ${alias} ON ${alias}.note_id = n.id AND ${alias}.tag_name IN (${placeholders})`);
524
+ conditions.push(`n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${placeholders}))`);
522
525
  params.push(...set);
523
526
  }
524
527
  }
@@ -601,6 +604,20 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
601
604
  // Metadata filters — operator objects route through the indexed generated
602
605
  // column (fast, loud errors on non-indexed fields); primitives keep the
603
606
  // existing JSON-scan exact-match behavior for backcompat.
607
+ //
608
+ // Plain-equality fast path (2026-06-10 perf measurements): when the field
609
+ // happens to be indexed, a plain `{field: value}` equality used to pay the
610
+ // same full-table json_extract scan as a non-indexed field — 280× slower
611
+ // than the operator form `{field: {eq: value}}` ON THE SAME column. We now
612
+ // prepend an indexed-prefilter conjunct (`"meta_<field>" = ?`) so the
613
+ // B-tree narrows the candidates, while KEEPING the original json_extract
614
+ // clause as a residual predicate. The conjunction is result-identical to
615
+ // the scan by construction: any row the scan matches also satisfies the
616
+ // prefilter (the generated column is the same json_extract under the
617
+ // column's type affinity), and rows where the affinity-converted column
618
+ // matches but the raw extraction doesn't (e.g. JSON number 5 vs query
619
+ // string "5") are excluded by the residual — exactly as the scan excluded
620
+ // them. Pinned by query-plain-eq-routing.test.ts.
604
621
  if (opts.metadata) {
605
622
  for (const [key, value] of Object.entries(opts.metadata)) {
606
623
  if (isOperatorObject(value)) {
@@ -612,8 +629,17 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
612
629
  conditions.push(sql);
613
630
  params.push(...opParams);
614
631
  } else {
615
- conditions.push(`json_extract(n.metadata, '$.' || ?) = ?`);
616
- params.push(key, typeof value === "string" ? value : JSON.stringify(value));
632
+ const bound = typeof value === "string" ? value : JSON.stringify(value);
633
+ // `getIndexedField` returning a row proves `key` was validated by
634
+ // FIELD_NAME_RE at declaration time, so interpolating the column
635
+ // name is safe — same justification as buildOperatorClause.
636
+ if (getIndexedField(db, key)) {
637
+ conditions.push(`("meta_${key}" = ? AND json_extract(n.metadata, '$.' || ?) = ?)`);
638
+ params.push(bound, key, bound);
639
+ } else {
640
+ conditions.push(`json_extract(n.metadata, '$.' || ?) = ?`);
641
+ params.push(key, bound);
642
+ }
617
643
  }
618
644
  }
619
645
  }
@@ -736,36 +762,121 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
736
762
  // be at the mercy of SQLite's row order and the next page could
737
763
  // miss or duplicate one.
738
764
  orderBy = "n.updated_at ASC, n.id ASC";
765
+ } else if (opts.orderBy === "link_count") {
766
+ // `link_count` is a pseudo-field — like `created_at`/`updated_at` in the
767
+ // dateFilter block above, it bypasses `requireIndexedField` (it's not a
768
+ // metadata column). Sort by link DEGREE using the SAME directional-sum
769
+ // definition as the `linkCount` response field (see `getLinkCounts` in
770
+ // links.ts): two correlated COUNT subqueries summed. This MUST stay a
771
+ // sum of two directional counts — a single
772
+ // `COUNT(*) ... WHERE source_id=n.id OR target_id=n.id` would count a
773
+ // self-loop ONCE (degree 1) and DIVERGE from the field's degree-2. Both
774
+ // subqueries ride the existing `idx_links_source` / `idx_links_target`
775
+ // B-trees. `created_at` stays the stable tiebreaker.
776
+ //
777
+ // Always the both-directions degree — inbound-only ordering is a future
778
+ // extension and is not built here.
779
+ //
780
+ // Perf caveat: these are correlated subqueries, evaluated once per
781
+ // candidate row. At small-to-moderate vault sizes (tens of thousands of
782
+ // notes) that's fine — each subquery is an O(log n) index probe. At very
783
+ // large vault sizes the per-row scan cost grows; the upgrade path is a
784
+ // maintained `link_count` counter column on `notes`, incremented in
785
+ // `createLink` and decremented in `deleteLink`, then ordered directly.
786
+ // NOT built now — flagged so a future contributor sees the lever.
787
+ orderBy =
788
+ `((SELECT COUNT(*) FROM links WHERE source_id = n.id) ` +
789
+ `+ (SELECT COUNT(*) FROM links WHERE target_id = n.id)) ${direction}, ` +
790
+ `n.created_at ${direction}`;
739
791
  } else if (opts.orderBy) {
740
792
  requireIndexedField(db, opts.orderBy);
741
793
  // `orderBy` came from indexed_fields (validated on declaration), so
742
794
  // the column name is safe to interpolate. Append created_at as a
743
795
  // stable tiebreaker so two rows with the same indexed value have a
744
796
  // deterministic order.
745
- orderBy = `"meta_${opts.orderBy}" ${direction}, n.created_at ${direction}`;
797
+ orderBy = `"meta_${opts.orderBy}" ${direction}, n.created_at ${direction}, n.id ${direction}`;
746
798
  } else {
747
- orderBy = `n.created_at ${direction}`;
799
+ // id tiebreaker: same-millisecond inserts get deterministic relative
800
+ // order — load-bearing now that the two-phase page fetch makes
801
+ // pagination ordering the contract (#485 review nit).
802
+ orderBy = `n.created_at ${direction}, n.id ${direction}`;
748
803
  }
749
804
  const limit = typeof opts.limit === "number" ? opts.limit : 100;
750
805
  const offset = typeof opts.offset === "number" ? opts.offset : 0;
751
806
 
752
807
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
753
808
 
754
- const sql = `
755
- SELECT DISTINCT n.* FROM notes n
756
- ${joins.join("\n")}
809
+ // Two-phase "deferred join" page fetch (2026-06-10 perf measurements).
810
+ //
811
+ // Phase 1 selects ONLY `n.id` — the ORDER BY temp B-tree (when one is
812
+ // needed) holds narrow id/sort-key entries instead of full note rows, so
813
+ // sort/materialization cost no longer scales with content size. With the
814
+ // tag semijoin above there is no row multiplication, so no DISTINCT.
815
+ //
816
+ // Phase 2 fetches full rows for just the page (≤ limit ids) and re-orders
817
+ // to the phase-1 order; tags are hydrated in ONE batched query instead of
818
+ // one query per returned note.
819
+ const idSql = `
820
+ SELECT n.id FROM notes n
757
821
  ${whereClause}
758
822
  ORDER BY ${orderBy}
759
823
  LIMIT ? OFFSET ?
760
824
  `;
761
825
  params.push(limit, offset);
762
826
 
763
- const rows = db.prepare(sql).all(...params) as NoteRow[];
764
- return rows.map((row) => {
765
- const note = rowToNote(row);
766
- note.tags = getNoteTags(db, note.id);
767
- return note;
768
- });
827
+ const idRows = db.prepare(idSql).all(...params) as { id: string }[];
828
+ return fetchNotesByIdsOrdered(db, idRows.map((r) => r.id));
829
+ }
830
+
831
+ /** Chunk size for IN-list queries — comfortably under SQLite's conservative
832
+ * 999 bound-variable floor (older builds), matching getLinkCounts. */
833
+ const IN_CHUNK = 900;
834
+
835
+ /**
836
+ * Fetch full note rows for `ids`, preserving the input order, with tags
837
+ * hydrated via ONE batched query per chunk (not one per note). Ids not
838
+ * found (deleted between phases) are silently dropped.
839
+ */
840
+ function fetchNotesByIdsOrdered(db: Database, ids: string[]): Note[] {
841
+ if (ids.length === 0) return [];
842
+ const rowsById = new Map<string, NoteRow>();
843
+ for (let i = 0; i < ids.length; i += IN_CHUNK) {
844
+ const chunk = ids.slice(i, i + IN_CHUNK);
845
+ const placeholders = chunk.map(() => "?").join(", ");
846
+ const rows = db.prepare(
847
+ `SELECT * FROM notes WHERE id IN (${placeholders})`,
848
+ ).all(...chunk) as NoteRow[];
849
+ for (const row of rows) rowsById.set(row.id, row);
850
+ }
851
+ const notes: Note[] = [];
852
+ for (const id of ids) {
853
+ const row = rowsById.get(id);
854
+ if (row) notes.push(rowToNote(row));
855
+ }
856
+ const tagsById = getNoteTagsForNotes(db, notes.map((n) => n.id));
857
+ for (const note of notes) note.tags = tagsById.get(note.id) ?? [];
858
+ return notes;
859
+ }
860
+
861
+ /**
862
+ * Batched tag lookup: tags for many notes in one IN-list query per chunk.
863
+ * Per-note arrays are sorted by tag_name — identical to `getNoteTags`.
864
+ * Every requested id is present in the map (empty array when untagged).
865
+ */
866
+ export function getNoteTagsForNotes(db: Database, noteIds: string[]): Map<string, string[]> {
867
+ const map = new Map<string, string[]>();
868
+ if (noteIds.length === 0) return map;
869
+ const ids = [...new Set(noteIds)];
870
+ for (const id of ids) map.set(id, []);
871
+ for (let i = 0; i < ids.length; i += IN_CHUNK) {
872
+ const chunk = ids.slice(i, i + IN_CHUNK);
873
+ const placeholders = chunk.map(() => "?").join(", ");
874
+ const rows = db.prepare(
875
+ `SELECT note_id, tag_name FROM note_tags WHERE note_id IN (${placeholders}) ORDER BY tag_name`,
876
+ ).all(...chunk) as { note_id: string; tag_name: string }[];
877
+ for (const row of rows) map.get(row.note_id)!.push(row.tag_name);
878
+ }
879
+ return map;
769
880
  }
770
881
 
771
882
  /**
@@ -869,20 +980,19 @@ export function searchNotes(
869
980
 
870
981
  if (opts?.tags && opts.tags.length > 0) {
871
982
  try {
983
+ // Tag membership as a semijoin — same rationale as queryNotes: a
984
+ // `JOIN note_tags` multiplies rows for multi-tagged notes and forced
985
+ // DISTINCT over full rows. The FTS join itself is 1:1 on rowid.
872
986
  const tagPlaceholders = opts.tags.map(() => "?").join(", ");
873
987
  const rows = db.prepare(`
874
- SELECT DISTINCT n.* FROM notes n
988
+ SELECT n.* FROM notes n
875
989
  JOIN notes_fts fts ON fts.rowid = n.rowid
876
- JOIN note_tags nt ON nt.note_id = n.id AND nt.tag_name IN (${tagPlaceholders})
877
990
  WHERE notes_fts MATCH ?
991
+ AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
878
992
  ORDER BY rank
879
993
  LIMIT ?
880
- `).all(...opts.tags, query, limit) as NoteRow[];
881
- return rows.map((row) => {
882
- const note = rowToNote(row);
883
- note.tags = getNoteTags(db, note.id);
884
- return note;
885
- });
994
+ `).all(query, ...opts.tags, limit) as NoteRow[];
995
+ return notesWithTags(db, rows);
886
996
  } catch {
887
997
  return [];
888
998
  }
@@ -896,16 +1006,20 @@ export function searchNotes(
896
1006
  ORDER BY rank
897
1007
  LIMIT ?
898
1008
  `).all(query, limit) as NoteRow[];
899
- return rows.map((row) => {
900
- const note = rowToNote(row);
901
- note.tags = getNoteTags(db, note.id);
902
- return note;
903
- });
1009
+ return notesWithTags(db, rows);
904
1010
  } catch {
905
1011
  return [];
906
1012
  }
907
1013
  }
908
1014
 
1015
+ /** Map rows → Notes with tags hydrated in one batched query. */
1016
+ function notesWithTags(db: Database, rows: NoteRow[]): Note[] {
1017
+ const notes = rows.map(rowToNote);
1018
+ const tagsById = getNoteTagsForNotes(db, notes.map((n) => n.id));
1019
+ for (const note of notes) note.tags = tagsById.get(note.id) ?? [];
1020
+ return notes;
1021
+ }
1022
+
909
1023
  // ---- Tag Operations ----
910
1024
 
911
1025
  export function tagNote(db: Database, noteId: string, tags: string[]): void {
@@ -946,7 +1060,7 @@ export function listTags(db: Database): { name: string; count: number }[] {
946
1060
  export function deleteTag(db: Database, name: string): { deleted: boolean; notes_untagged: number } {
947
1061
  const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
948
1062
  | { fields: string | null }
949
- | undefined;
1063
+ | null;
950
1064
  if (!row) return { deleted: false, notes_untagged: 0 };
951
1065
 
952
1066
  const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
@@ -1107,7 +1221,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1107
1221
  for (const { from, to } of renames) {
1108
1222
  const old = readStmt.get(from) as
1109
1223
  | { description: string | null; fields: string | null; relationships: string | null; parent_names: string | null; created_at: string | null }
1110
- | undefined;
1224
+ | null;
1111
1225
  insertStmt.run(
1112
1226
  to,
1113
1227
  old?.description ?? null,
@@ -1522,11 +1636,11 @@ export function getVaultStats(
1522
1636
 
1523
1637
  const earliestRow = db.prepare(
1524
1638
  "SELECT id, created_at FROM notes ORDER BY created_at ASC, id ASC LIMIT 1",
1525
- ).get() as { id: string; created_at: string } | undefined;
1639
+ ).get() as { id: string; created_at: string } | null;
1526
1640
 
1527
1641
  const latestRow = db.prepare(
1528
1642
  "SELECT id, created_at FROM notes ORDER BY created_at DESC, id DESC LIMIT 1",
1529
- ).get() as { id: string; created_at: string } | undefined;
1643
+ ).get() as { id: string; created_at: string } | null;
1530
1644
 
1531
1645
  const monthRows = db.prepare(`
1532
1646
  SELECT strftime('%Y-%m', created_at) AS month, COUNT(*) AS count
@@ -1553,6 +1667,15 @@ export function getVaultStats(
1553
1667
  const linkCountRow = db.prepare("SELECT COUNT(*) as c FROM links").get() as { c: number };
1554
1668
  const linkCount = linkCountRow.c;
1555
1669
 
1670
+ // Total content bytes. CAST(content AS BLOB) forces SQLite's LENGTH() to
1671
+ // count UTF-8 BYTES rather than characters (bare LENGTH on TEXT returns a
1672
+ // char count, which undercounts multibyte content). COALESCE because SUM
1673
+ // over zero rows is NULL. See VaultStats.contentBytes for the rationale.
1674
+ const contentBytesRow = db
1675
+ .prepare("SELECT COALESCE(SUM(LENGTH(CAST(content AS BLOB))), 0) as b FROM notes")
1676
+ .get() as { b: number };
1677
+ const contentBytes = contentBytesRow.b;
1678
+
1556
1679
  return {
1557
1680
  totalNotes,
1558
1681
  earliestNote: earliestRow
@@ -1566,6 +1689,7 @@ export function getVaultStats(
1566
1689
  tagCount,
1567
1690
  attachmentCount,
1568
1691
  linkCount,
1692
+ contentBytes,
1569
1693
  };
1570
1694
  }
1571
1695