@openparachute/vault 0.6.4-rc.9 → 0.6.5-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.
package/core/src/notes.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Database, type SQLQueryBindings } from "bun:sqlite";
2
2
  import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats } from "./types.js";
3
3
  import { normalizePath } from "./paths.js";
4
+ import { transaction } from "./txn.js";
4
5
  import {
5
6
  buildOperatorClause,
6
7
  isOperatorObject,
@@ -19,6 +20,7 @@ import {
19
20
  type QueryHashInputs,
20
21
  } from "./cursor.js";
21
22
  import { getIndexedField, releaseField } from "./indexed-fields.js";
23
+ import { stripTagHash } from "./tag-hierarchy.js";
22
24
 
23
25
  let idCounter = 0;
24
26
 
@@ -686,8 +688,14 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
686
688
  // rides idx_note_tags_tag, produces each note id at most once, and lets
687
689
  // the whole query drop DISTINCT. See the 2026-06-10 perf measurements.
688
690
  if (opts.tags && opts.tags.length > 0) {
691
+ // Canonical-bare-tag guard (vault#XXX) backstop for direct-core callers
692
+ // that bypass BunSqliteStore.normalizeQueryTags (the store normalizes +
693
+ // hierarchy-expands before reaching here; this protects the raw noteOps
694
+ // entry point and tests). `_tagsExpanded`, when present, was already built
695
+ // from bare names by the store, so prefer it; otherwise strip the literal
696
+ // tags. No-op on already-bare input.
689
697
  const tagSets: string[][] = (opts as QueryOpts & { _tagsExpanded?: string[][] })._tagsExpanded
690
- ?? opts.tags.map((t) => [t]);
698
+ ?? opts.tags.map((t) => [stripTagHash(t)]);
691
699
  const match = opts.tagMatch ?? "all";
692
700
  if (match === "any") {
693
701
  // Flatten all expanded sets and dedupe — a note tagged with any one
@@ -710,9 +718,11 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
710
718
  }
711
719
  }
712
720
 
713
- // Exclude tags
721
+ // Exclude tags — bare-tag guard backstop (see tags block above).
714
722
  if (opts.excludeTags && opts.excludeTags.length > 0) {
715
- for (const tag of opts.excludeTags) {
723
+ for (const rawTag of opts.excludeTags) {
724
+ const tag = stripTagHash(rawTag);
725
+ if (tag === "") continue;
716
726
  conditions.push(`NOT EXISTS (SELECT 1 FROM note_tags ex WHERE ex.note_id = n.id AND ex.tag_name = ?)`);
717
727
  params.push(tag);
718
728
  }
@@ -1189,11 +1199,18 @@ export function searchNotes(
1189
1199
  const limit = typeof opts?.limit === "number" ? opts.limit : 50;
1190
1200
 
1191
1201
  if (opts?.tags && opts.tags.length > 0) {
1202
+ // Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
1203
+ const searchTags = opts.tags.map(stripTagHash).filter((t) => t !== "");
1204
+ if (searchTags.length === 0) {
1205
+ // All tag filters collapsed to empty — fall through to the untagged
1206
+ // search path below (no tag constraint).
1207
+ opts = { ...opts, tags: undefined };
1208
+ } else {
1192
1209
  try {
1193
1210
  // Tag membership as a semijoin — same rationale as queryNotes: a
1194
1211
  // `JOIN note_tags` multiplies rows for multi-tagged notes and forced
1195
1212
  // DISTINCT over full rows. The FTS join itself is 1:1 on rowid.
1196
- const tagPlaceholders = opts.tags.map(() => "?").join(", ");
1213
+ const tagPlaceholders = searchTags.map(() => "?").join(", ");
1197
1214
  const rows = db.prepare(`
1198
1215
  SELECT n.* FROM notes n
1199
1216
  JOIN notes_fts fts ON fts.rowid = n.rowid
@@ -1201,11 +1218,12 @@ export function searchNotes(
1201
1218
  AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
1202
1219
  ORDER BY rank
1203
1220
  LIMIT ?
1204
- `).all(query, ...opts.tags, limit) as NoteRow[];
1221
+ `).all(query, ...searchTags, limit) as NoteRow[];
1205
1222
  return notesWithTags(db, rows);
1206
1223
  } catch {
1207
1224
  return [];
1208
1225
  }
1226
+ }
1209
1227
  }
1210
1228
 
1211
1229
  try {
@@ -1236,7 +1254,15 @@ export function tagNote(db: Database, noteId: string, tags: string[]): void {
1236
1254
  const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
1237
1255
  const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
1238
1256
 
1239
- for (const tag of tags) {
1257
+ // Canonical-bare-tag guard (vault#XXX): strip any leading `#` so the
1258
+ // `#`-decorated form a client may pass (the agent module stored
1259
+ // `#agent/message/inbound` verbatim) lands on the same bare row everyone
1260
+ // else queries. This is the single write chokepoint — createNote /
1261
+ // updateNote / batch / MCP add-tags / REST / import / transcript all funnel
1262
+ // through store.tagNote → here.
1263
+ for (const raw of tags) {
1264
+ const tag = stripTagHash(raw);
1265
+ if (tag === "") continue;
1240
1266
  insertTag.run(tag);
1241
1267
  insertNoteTag.run(noteId, tag);
1242
1268
  }
@@ -1244,7 +1270,10 @@ export function tagNote(db: Database, noteId: string, tags: string[]): void {
1244
1270
 
1245
1271
  export function untagNote(db: Database, noteId: string, tags: string[]): void {
1246
1272
  const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
1247
- for (const tag of tags) {
1273
+ // Mirror tagNote's normalization so removing `#tag` deletes the bare row.
1274
+ for (const raw of tags) {
1275
+ const tag = stripTagHash(raw);
1276
+ if (tag === "") continue;
1248
1277
  stmt.run(noteId, tag);
1249
1278
  }
1250
1279
  }
@@ -1359,6 +1388,11 @@ export type RenameTagResult =
1359
1388
  * after the cascade returns.
1360
1389
  */
1361
1390
  export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
1391
+ // Normalize the TARGET so a rename can never create a `#`-prefixed tag. The
1392
+ // SOURCE (`oldName`) is left LITERAL on purpose — it's the transitional escape
1393
+ // hatch that lets the `#legacy/*` → `legacy/*` data migration find the
1394
+ // `#`-prefixed rows. (Renaming TO a `#`-name is the thing we're preventing.)
1395
+ newName = stripTagHash(newName);
1362
1396
  if (oldName === newName) {
1363
1397
  const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
1364
1398
  return exists
@@ -1405,8 +1439,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1405
1439
  return { error: "target_exists", conflicting };
1406
1440
  }
1407
1441
 
1408
- db.exec("BEGIN IMMEDIATE");
1409
- try {
1442
+ const result = transaction(db, (): RenameTagSuccess => {
1410
1443
  let renamedNoteTags = 0;
1411
1444
  let pathsRenamed = 0;
1412
1445
 
@@ -1567,9 +1600,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1567
1600
  }
1568
1601
  }
1569
1602
 
1570
- db.exec("COMMIT");
1571
-
1572
- const result: RenameTagSuccess = {
1603
+ return {
1573
1604
  renamed: renamedNoteTags,
1574
1605
  sub_tags_renamed: renames.length - 1,
1575
1606
  parent_refs_updated: parentRefsUpdated,
@@ -1578,21 +1609,18 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1578
1609
  notes_rewritten: notesRewritten,
1579
1610
  paths_renamed: pathsRenamed,
1580
1611
  };
1612
+ });
1581
1613
 
1582
- // Audit log: single line so operators searching `[vault] tag rename`
1583
- // can correlate cascades after the fact. Includes the stats and the
1584
- // mapping for non-trivial sub-tag cases.
1585
- console.error(
1586
- `[vault] tag rename cascade: ${oldName} → ${newName}` +
1587
- (renames.length > 1 ? ` (+${renames.length - 1} sub-tags)` : "") +
1588
- ` — note_tags:${result.renamed} parent_refs:${result.parent_refs_updated} tokens:${result.tokens_updated} indexed:${result.indexed_field_declarers_updated} notes:${result.notes_rewritten} paths:${result.paths_renamed}`,
1589
- );
1614
+ // Audit log: single line so operators searching `[vault] tag rename`
1615
+ // can correlate cascades after the fact. Includes the stats and the
1616
+ // mapping for non-trivial sub-tag cases.
1617
+ console.error(
1618
+ `[vault] tag rename cascade: ${oldName} → ${newName}` +
1619
+ (renames.length > 1 ? ` (+${renames.length - 1} sub-tags)` : "") +
1620
+ ` — note_tags:${result.renamed} parent_refs:${result.parent_refs_updated} tokens:${result.tokens_updated} indexed:${result.indexed_field_declarers_updated} notes:${result.notes_rewritten} paths:${result.paths_renamed}`,
1621
+ );
1590
1622
 
1591
- return result;
1592
- } catch (err) {
1593
- db.exec("ROLLBACK");
1594
- throw err;
1595
- }
1623
+ return result;
1596
1624
  }
1597
1625
 
1598
1626
  function emptyCascadeResult(): RenameTagSuccess {
@@ -1727,13 +1755,15 @@ export function mergeTags(
1727
1755
  sources: string[],
1728
1756
  target: string,
1729
1757
  ): { merged: Record<string, number>; target: string } {
1758
+ // Normalize the TARGET so a merge can never create a `#`-prefixed tag. SOURCES
1759
+ // stay LITERAL so `#legacy/*` rows can be merged away (same carve-out as rename).
1760
+ target = stripTagHash(target);
1730
1761
  // Dedup + drop target-in-sources (self-merge is a no-op).
1731
1762
  const uniqueSources = Array.from(new Set(sources)).filter((s) => s !== target);
1732
1763
 
1733
1764
  const merged: Record<string, number> = {};
1734
1765
 
1735
- db.exec("BEGIN");
1736
- try {
1766
+ transaction(db, () => {
1737
1767
  // Target might not exist yet. Seed it so INSERT OR IGNORE into note_tags
1738
1768
  // can reference it; leave any existing schema on target untouched.
1739
1769
  db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(target);
@@ -1760,12 +1790,7 @@ export function mergeTags(
1760
1790
  deleteTagStmt.run(source);
1761
1791
  merged[source] = before;
1762
1792
  }
1763
-
1764
- db.exec("COMMIT");
1765
- } catch (err) {
1766
- db.exec("ROLLBACK");
1767
- throw err;
1768
- }
1793
+ });
1769
1794
 
1770
1795
  return { merged, target };
1771
1796
  }
@@ -1963,8 +1988,7 @@ export interface BulkNoteInput {
1963
1988
  export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
1964
1989
  const results: Note[] = [];
1965
1990
 
1966
- db.exec("BEGIN");
1967
- try {
1991
+ transaction(db, () => {
1968
1992
  for (const input of inputs) {
1969
1993
  results.push(
1970
1994
  createNote(db, input.content, {
@@ -1979,11 +2003,7 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
1979
2003
  }),
1980
2004
  );
1981
2005
  }
1982
- db.exec("COMMIT");
1983
- } catch (err) {
1984
- db.exec("ROLLBACK");
1985
- throw err;
1986
- }
2006
+ });
1987
2007
 
1988
2008
  return results;
1989
2009
  }
@@ -1991,45 +2011,41 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
1991
2011
  export function batchTag(db: Database, noteIds: string[], tags: string[]): number {
1992
2012
  const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
1993
2013
  const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
2014
+ // Canonical-bare-tag guard (vault#XXX) — batchTag has its own SQL (does NOT
2015
+ // funnel through tagNote), so it strips leading `#` independently.
2016
+ const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
1994
2017
  let count = 0;
1995
2018
 
1996
- db.exec("BEGIN");
1997
- try {
1998
- for (const tag of tags) {
2019
+ transaction(db, () => {
2020
+ for (const tag of bareTags) {
1999
2021
  insertTag.run(tag);
2000
2022
  }
2001
2023
  for (const noteId of noteIds) {
2002
- for (const tag of tags) {
2024
+ for (const tag of bareTags) {
2003
2025
  insertNoteTag.run(noteId, tag);
2004
2026
  count++;
2005
2027
  }
2006
2028
  }
2007
- db.exec("COMMIT");
2008
- } catch (err) {
2009
- db.exec("ROLLBACK");
2010
- throw err;
2011
- }
2029
+ });
2012
2030
 
2013
2031
  return count;
2014
2032
  }
2015
2033
 
2016
2034
  export function batchUntag(db: Database, noteIds: string[], tags: string[]): number {
2017
2035
  const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
2036
+ // Mirror batchTag's bare-tag normalization so removing `#tag` deletes the
2037
+ // bare row.
2038
+ const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
2018
2039
  let count = 0;
2019
2040
 
2020
- db.exec("BEGIN");
2021
- try {
2041
+ transaction(db, () => {
2022
2042
  for (const noteId of noteIds) {
2023
- for (const tag of tags) {
2043
+ for (const tag of bareTags) {
2024
2044
  stmt.run(noteId, tag);
2025
2045
  count++;
2026
2046
  }
2027
2047
  }
2028
- db.exec("COMMIT");
2029
- } catch (err) {
2030
- db.exec("ROLLBACK");
2031
- throw err;
2032
- }
2048
+ });
2033
2049
 
2034
2050
  return count;
2035
2051
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Batched-export coverage (Phase-1 streaming refactor). The export walk
3
+ * streams notes in windows of `EXPORT_BATCH_SIZE` (500) instead of one
4
+ * full-corpus query. These tests prove the windowing walks EVERY note across
5
+ * the batch boundaries — both a non-multiple remainder (1,203 = 500 + 500 +
6
+ * 203, so the final partial batch is exercised) and an exact multiple (1,000
7
+ * = 500 + 500 + a terminating empty query), with a sentinel note pinned in
8
+ * the final batch so an off-by-one that dropped the tail would fail loudly.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
12
+ import { Database } from "bun:sqlite";
13
+ import { existsSync, mkdtempSync, rmSync } from "fs";
14
+ import { tmpdir } from "os";
15
+ import { join } from "path";
16
+
17
+ import { SqliteStore } from "./store.js";
18
+ import { exportVaultToDir } from "./portable-md.js";
19
+
20
+ /** Zero-padded so lexicographic id order == numeric order (the export walks
21
+ * `created_at ASC, id ASC`; fresh notes share a created_at so id breaks the
22
+ * tie — this makes the batch membership of each note deterministic). */
23
+ const pad = (i: number): string => String(i).padStart(5, "0");
24
+
25
+ async function seed(store: SqliteStore, n: number): Promise<void> {
26
+ const inputs = [];
27
+ for (let i = 0; i < n; i++) {
28
+ inputs.push({ content: `note ${i}`, id: `n${pad(i)}`, path: `notes/n${pad(i)}` });
29
+ }
30
+ await store.createNotes(inputs);
31
+ }
32
+
33
+ describe("exportVaultToDir — batched walk across boundaries", () => {
34
+ let store: SqliteStore;
35
+ let outDir: string;
36
+
37
+ beforeEach(() => {
38
+ store = new SqliteStore(new Database(":memory:"));
39
+ outDir = mkdtempSync(join(tmpdir(), "portable-batch-"));
40
+ });
41
+ afterEach(() => {
42
+ try { rmSync(outDir, { recursive: true, force: true }); } catch {}
43
+ });
44
+
45
+ it("walks all 1,203 notes (2 full batches + a 203-note remainder)", async () => {
46
+ await seed(store, 1203);
47
+ const stats = await exportVaultToDir(store, { outDir, exportedAt: "2026-07-02T00:00:00.000Z" });
48
+
49
+ expect(stats.notes).toBe(1203);
50
+ // First note, both interior batch boundaries, and the sentinel in the
51
+ // final partial batch must all be on disk.
52
+ expect(existsSync(join(outDir, "notes/n00000.md"))).toBe(true); // batch 1 head
53
+ expect(existsSync(join(outDir, "notes/n00500.md"))).toBe(true); // batch 2 head
54
+ expect(existsSync(join(outDir, "notes/n01000.md"))).toBe(true); // batch 3 head
55
+ expect(existsSync(join(outDir, "notes/n01202.md"))).toBe(true); // last partial-batch sentinel
56
+ });
57
+
58
+ it("walks all notes on an exact multiple of the batch size (1,000 = 2×500 + terminating empty query)", async () => {
59
+ await seed(store, 1000);
60
+ const stats = await exportVaultToDir(store, { outDir, exportedAt: "2026-07-02T00:00:00.000Z" });
61
+
62
+ expect(stats.notes).toBe(1000);
63
+ expect(existsSync(join(outDir, "notes/n00000.md"))).toBe(true);
64
+ expect(existsSync(join(outDir, "notes/n00500.md"))).toBe(true);
65
+ expect(existsSync(join(outDir, "notes/n00999.md"))).toBe(true); // last note of the final full batch
66
+ });
67
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Golden-bytes regression for the portable-md streaming export refactor
3
+ * (Phase-1 shared-core, vault cloud design §4).
4
+ *
5
+ * `core/src/__fixtures__/portable-export-golden.json` was captured from the
6
+ * PRE-refactor (full-corpus, sync-fs) exporter by
7
+ * `scripts/capture-portable-golden.ts`. This test builds the SAME
8
+ * deterministic vault and asserts the CURRENT exporter reproduces those
9
+ * bytes exactly — the streaming/source-sink rewrite must be byte-for-byte
10
+ * indistinguishable on the fs backend. A diff here is a real format drift,
11
+ * not test flake (both sides share `buildGoldenVault` + `serializeExportTree`
12
+ * from the fixture module, so a fixture-vs-test mismatch is impossible).
13
+ *
14
+ * If a format change is ever intentional, re-run the capture script against
15
+ * the new code, review the JSON diff, and commit both together.
16
+ */
17
+
18
+ import { describe, it, expect } from "bun:test";
19
+ import { Database } from "bun:sqlite";
20
+ import { mkdtempSync, rmSync } from "fs";
21
+ import { tmpdir } from "os";
22
+ import { join } from "path";
23
+
24
+ import { SqliteStore } from "./store.js";
25
+ import { exportVaultToDir } from "./portable-md.js";
26
+ import {
27
+ buildGoldenVault,
28
+ serializeExportTree,
29
+ GOLDEN_EXPORTED_AT,
30
+ } from "./__fixtures__/golden-vault.js";
31
+ import goldenTree from "./__fixtures__/portable-export-golden.json";
32
+
33
+ describe("portable-md golden bytes — new exporter == old-exporter fixture", () => {
34
+ it("reproduces the captured export tree byte-for-byte", async () => {
35
+ const store = new SqliteStore(new Database(":memory:"));
36
+ await buildGoldenVault(store);
37
+
38
+ const outDir = mkdtempSync(join(tmpdir(), "portable-golden-test-"));
39
+ try {
40
+ await exportVaultToDir(store, {
41
+ outDir,
42
+ vaultName: "golden",
43
+ vaultDescription: "portable-md byte-stability fixture",
44
+ exportedAt: GOLDEN_EXPORTED_AT,
45
+ caseSensitiveOverride: true,
46
+ });
47
+ const tree = serializeExportTree(outDir);
48
+ // Compare as objects (per-file byte equality) — a keyed diff points
49
+ // straight at the drifting file if this ever fails.
50
+ expect(tree).toEqual(goldenTree as Record<string, string>);
51
+ } finally {
52
+ rmSync(outDir, { recursive: true, force: true });
53
+ }
54
+ });
55
+ });