@openparachute/vault 0.6.4 → 0.6.5-rc.10

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 (53) hide show
  1. package/core/src/__fixtures__/golden-vault.ts +125 -0
  2. package/core/src/__fixtures__/portable-export-golden.json +10 -0
  3. package/core/src/do-param-cap.test.ts +161 -0
  4. package/core/src/links.ts +8 -13
  5. package/core/src/mcp.ts +306 -314
  6. package/core/src/notes.ts +54 -62
  7. package/core/src/onboarding.ts +14 -296
  8. package/core/src/portable-md-batching.test.ts +67 -0
  9. package/core/src/portable-md-golden.test.ts +55 -0
  10. package/core/src/portable-md.ts +579 -435
  11. package/core/src/schema.ts +21 -43
  12. package/core/src/seed-packs.test.ts +191 -0
  13. package/core/src/seed-packs.ts +559 -0
  14. package/core/src/sql-in.test.ts +58 -0
  15. package/core/src/sql-in.ts +76 -0
  16. package/core/src/store.ts +14 -9
  17. package/core/src/transcription/provider.ts +141 -0
  18. package/core/src/txn.test.ts +229 -0
  19. package/core/src/txn.ts +105 -0
  20. package/core/src/types.ts +9 -0
  21. package/core/src/vault-projection.ts +1 -1
  22. package/core/src/wikilinks.ts +10 -4
  23. package/package.json +1 -1
  24. package/src/add-pack.test.ts +142 -0
  25. package/src/cli.ts +778 -7
  26. package/src/onboarding-seed.test.ts +126 -40
  27. package/src/onboarding-seed.ts +41 -46
  28. package/src/routes.ts +25 -7
  29. package/src/server.ts +108 -31
  30. package/src/transcription/build.test.ts +224 -0
  31. package/src/transcription/build.ts +252 -0
  32. package/src/transcription/capability.test.ts +118 -0
  33. package/src/transcription/capability.ts +96 -0
  34. package/src/transcription/install-python.test.ts +366 -0
  35. package/src/transcription/install-python.ts +471 -0
  36. package/src/transcription/install.test.ts +167 -0
  37. package/src/transcription/install.ts +296 -0
  38. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  39. package/src/transcription/providers/onnx-asr.ts +239 -0
  40. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  41. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  42. package/src/transcription/providers/scribe-http.test.ts +195 -0
  43. package/src/transcription/providers/scribe-http.ts +144 -0
  44. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  45. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  46. package/src/transcription/select.test.ts +259 -0
  47. package/src/transcription/select.ts +334 -0
  48. package/src/transcription/tiers.test.ts +197 -0
  49. package/src/transcription/tiers.ts +184 -0
  50. package/src/transcription-worker.test.ts +44 -0
  51. package/src/transcription-worker.ts +57 -122
  52. package/src/vault-create.test.ts +38 -10
  53. package/src/vault.test.ts +48 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Shared deterministic vault builder + export-tree serializer for the
3
+ * portable-md "old bytes == new bytes" golden fixture (Phase-1 streaming
4
+ * export refactor).
5
+ *
6
+ * Both the one-shot capture script (scripts/capture-portable-golden.ts,
7
+ * run against the PRE-refactor code) and the regression test
8
+ * (portable-md-golden.test.ts, run against the POST-refactor code) build
9
+ * the SAME vault here and serialize the export tree the SAME way — so a
10
+ * byte diff between the committed golden JSON and a fresh export is a real
11
+ * drift, not a fixture-vs-test mismatch.
12
+ *
13
+ * Determinism requirements met here:
14
+ * - explicit note ids + pinned created_at/updated_at (no wall-clock)
15
+ * - export called with `exportedAt` + `caseSensitiveOverride: true`
16
+ * (so the bytes don't depend on the runner's filesystem)
17
+ * - content that exercises every format field: pathed / unpathed /
18
+ * empty-content notes, a non-md (csv) note that forces a notes-meta
19
+ * sidecar, tags with schema + relationships, typed links, multi-line
20
+ * + control-character metadata, and created_at != updated_at.
21
+ */
22
+
23
+ import { readdirSync, readFileSync, statSync } from "fs";
24
+ import { join } from "path";
25
+ import type { Store } from "../types.js";
26
+
27
+ /** Fixed export timestamp — pins vault.yaml's `exported_at`. */
28
+ export const GOLDEN_EXPORTED_AT = "2026-07-02T00:00:00.000Z";
29
+
30
+ /**
31
+ * Populate `store` with a deterministic, format-exhaustive vault. Callers
32
+ * export it with `{ exportedAt: GOLDEN_EXPORTED_AT, caseSensitiveOverride: true }`.
33
+ */
34
+ export async function buildGoldenVault(store: Store): Promise<void> {
35
+ // Tag schema + opaque relationship vocabulary (vault#428 shape).
36
+ await store.upsertTagSchema("project", {
37
+ description: "A long-running effort",
38
+ fields: { status: { type: "string", enum: ["active", "done"], indexed: true } },
39
+ });
40
+ await store.upsertTagRecord("project", {
41
+ relationships: {
42
+ "works-on": { from: "person", to: "project" },
43
+ "based-at": { from: "project", to: "place", note: "freeform" },
44
+ },
45
+ });
46
+
47
+ const t0 = "2026-01-01T00:00:00.000Z";
48
+ const t1 = "2026-01-01T00:01:00.000Z";
49
+
50
+ await store.createNote("alpha body", {
51
+ id: "01HX001",
52
+ path: "Inbox/alpha",
53
+ tags: ["project", "z-other"],
54
+ metadata: {
55
+ priority: "high",
56
+ notes: "line1\nline2\nline3",
57
+ // control character exercises the \xNN escape path (vault#317 F1)
58
+ ctrl: "a\tb",
59
+ status: "active",
60
+ },
61
+ created_at: t0,
62
+ });
63
+ await store.createNote("beta body", {
64
+ id: "01HX002",
65
+ path: "Inbox/beta",
66
+ tags: ["project"],
67
+ metadata: { status: "done" },
68
+ created_at: t0,
69
+ });
70
+ await store.createNote("unpathed jot", { id: "01HX003", created_at: t0 });
71
+ // Empty-content skeleton note (vault#323).
72
+ await store.createNote("", {
73
+ id: "01HX004",
74
+ path: "Inbox/skeleton",
75
+ tags: ["project"],
76
+ created_at: t0,
77
+ });
78
+ // Non-md note → forces a .parachute/notes-meta/<id>.yaml sidecar.
79
+ await store.createNote("col1,col2\n1,2\n", {
80
+ id: "01HX005",
81
+ path: "Data/table",
82
+ extension: "csv",
83
+ tags: ["z-other"],
84
+ metadata: { rows: 2 },
85
+ created_at: t0,
86
+ });
87
+
88
+ await store.createLink("01HX001", "01HX002", "derived-from", { source: "git://example" });
89
+
90
+ // Pin every timestamp so the export bytes are wall-clock-independent.
91
+ // n1 gets a divergent updated_at to exercise restoreNoteTimestamps.
92
+ await store.restoreNoteTimestamps("01HX001", t0, t1);
93
+ for (const id of ["01HX002", "01HX003", "01HX004", "01HX005"]) {
94
+ await store.restoreNoteTimestamps(id, t0, t0);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Recursively serialize an export directory into a sorted map of
100
+ * `<relative-posix-path>` → file content. Directories are recorded as
101
+ * entries with a trailing `/` and an empty value so an intentionally
102
+ * empty dir (e.g. `.parachute/schemas/` on a schema-less vault) still
103
+ * shows up in the byte comparison.
104
+ */
105
+ export function serializeExportTree(dir: string): Record<string, string> {
106
+ const out: Record<string, string> = {};
107
+ const walk = (abs: string, rel: string): void => {
108
+ const entries = readdirSync(abs).sort();
109
+ if (entries.length === 0 && rel !== "") {
110
+ out[rel + "/"] = "";
111
+ return;
112
+ }
113
+ for (const entry of entries) {
114
+ const childAbs = join(abs, entry);
115
+ const childRel = rel === "" ? entry : `${rel}/${entry}`;
116
+ if (statSync(childAbs).isDirectory()) {
117
+ walk(childAbs, childRel);
118
+ } else {
119
+ out[childRel] = readFileSync(childAbs, "utf-8");
120
+ }
121
+ }
122
+ };
123
+ walk(dir, "");
124
+ return out;
125
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ ".parachute/notes-meta/01HX005.yaml": "id: 01HX005\npath: Data/table\nextension: csv\ntags:\n - z-other\nmetadata:\n rows: 2\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n",
3
+ ".parachute/schemas/project.yaml": "description: A long-running effort\nfields:\n status:\n enum:\n - active\n - done\n indexed: true\n type: string\nname: project\nrelationships:\n based-at:\n from: project\n note: freeform\n to: place\n works-on:\n from: person\n to: project\n",
4
+ ".parachute/vault.yaml": "description: portable-md byte-stability fixture\nexport_format_version: 1\nexported_at: 2026-07-02T00:00:00.000Z\nname: golden\n",
5
+ "Data/table.csv": "col1,col2\n1,2\n",
6
+ "Inbox/alpha.md": "---\nid: 01HX001\npath: Inbox/alpha\ntags:\n - project\n - z-other\nmetadata:\n ctrl: \"a\\tb\"\n notes: \"line1\\nline2\\nline3\"\n priority: high\n status: active\nlinks:\n - metadata:\n source: git://example\n relationship: derived-from\n target: 01HX002\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:01:00.000Z\n---\nalpha body\n",
7
+ "Inbox/beta.md": "---\nid: 01HX002\npath: Inbox/beta\ntags:\n - project\nmetadata:\n status: done\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\nbeta body\n",
8
+ "Inbox/skeleton.md": "---\nid: 01HX004\npath: Inbox/skeleton\ntags:\n - project\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\n",
9
+ "_unpathed/01HX003.md": "---\nid: 01HX003\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\nunpathed jot\n"
10
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Behavioral proof that the id-list query paths stay under the Cloudflare
3
+ * Durable Object SQLite 100-bound-param cap over a real >100-note vault.
4
+ *
5
+ * bun:sqlite tolerates 999+ bound params, so it CANNOT reproduce the DO
6
+ * `too many SQL variables` 500 directly — the ultimate DO-constraint proof is
7
+ * the cloud staging export re-verify (orchestrator's post-merge step). What we
8
+ * CAN prove locally, and what actually catches the regression that let the bug
9
+ * ship, is the *shape* of the SQL each path issues: no single prepared
10
+ * statement binds more than the chunk size (chunked lists) or a handful of
11
+ * params (json_each id-sets). A prepare-spy records the max bind count per
12
+ * statement; if a future edit raises the chunk size back over the cap, the
13
+ * placeholder-IN assertion here fails.
14
+ *
15
+ * Root cause recap: DO SQLite caps bound params at 100/statement. The export
16
+ * walk pages 500 notes at a time and hydrated each page's rows + tags with an
17
+ * `id IN (?×page)` list sized for standard SQLite's 999 floor → >100 params →
18
+ * DO 500. Fix: chunk id-lists at 90 (sql-in.ts) and bind embedded id-sets
19
+ * (e.g. `near` neighborhoods) as one json_each param.
20
+ */
21
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
22
+ import { Database } from "bun:sqlite";
23
+ import { existsSync, mkdtempSync, rmSync } from "fs";
24
+ import { tmpdir } from "os";
25
+ import { join } from "path";
26
+
27
+ import { SqliteStore } from "./store.js";
28
+ import { exportVaultToDir } from "./portable-md.js";
29
+ import { IN_PARAM_CHUNK } from "./sql-in.js";
30
+
31
+ /** Zero-padded so lexicographic id order == numeric order. */
32
+ const pad = (i: number): string => String(i).padStart(5, "0");
33
+
34
+ /** Wrap `db.prepare` to record the max bind-arg count seen per SQL string.
35
+ * Install AFTER seeding so bulk-insert params don't pollute the sample. */
36
+ function installBindSpy(db: Database) {
37
+ const perSql = new Map<string, number>();
38
+ const origPrepare = db.prepare.bind(db);
39
+ (db as unknown as { prepare: (sql: string) => unknown }).prepare = (sql: string) => {
40
+ const stmt = origPrepare(sql) as Record<string, unknown>;
41
+ const record = (n: number) => perSql.set(sql, Math.max(perSql.get(sql) ?? 0, n));
42
+ for (const m of ["all", "get", "run", "values", "iterate"] as const) {
43
+ const orig = stmt[m];
44
+ if (typeof orig === "function") {
45
+ stmt[m] = (...args: unknown[]) => {
46
+ record(args.length);
47
+ return (orig as (...a: unknown[]) => unknown).apply(stmt, args);
48
+ };
49
+ }
50
+ }
51
+ return stmt;
52
+ };
53
+ return {
54
+ restore() {
55
+ (db as unknown as { prepare: unknown }).prepare = origPrepare;
56
+ },
57
+ /** Max binds over statements carrying a literal `IN (?, …)` placeholder
58
+ * list — the chunked kind that must never exceed the chunk size. */
59
+ maxPlaceholderInBinds(): number {
60
+ let max = 0;
61
+ for (const [sql, n] of perSql) if (/\bIN \(\?/.test(sql)) max = Math.max(max, n);
62
+ return max;
63
+ },
64
+ /** Max binds over ALL statements — must stay under the DO cap. */
65
+ maxAnyBinds(): number {
66
+ let max = 0;
67
+ for (const n of perSql.values()) max = Math.max(max, n);
68
+ return max;
69
+ },
70
+ usedJsonEach(): boolean {
71
+ for (const sql of perSql.keys()) if (/json_each\(/.test(sql)) return true;
72
+ return false;
73
+ },
74
+ };
75
+ }
76
+
77
+ const DO_PARAM_CAP = 100;
78
+
79
+ describe("id-list query paths stay under the DO 100-bound-param cap", () => {
80
+ let store: SqliteStore;
81
+ let db: Database;
82
+
83
+ beforeEach(async () => {
84
+ db = new Database(":memory:");
85
+ store = new SqliteStore(db);
86
+ // 250 notes > the DO cap and > 2× the chunk size, so chunking spans
87
+ // multiple chunks (250 = 90 + 90 + 70).
88
+ const inputs = [];
89
+ for (let i = 0; i < 250; i++) {
90
+ inputs.push({ content: `note ${i}`, id: `n${pad(i)}`, path: `notes/n${pad(i)}` });
91
+ }
92
+ await store.createNotes(inputs);
93
+ });
94
+
95
+ it("getNotes chunks a >100-id fetch (correct rows, no statement over the cap)", async () => {
96
+ const ids = Array.from({ length: 250 }, (_, i) => `n${pad(i)}`);
97
+ const spy = installBindSpy(db);
98
+ try {
99
+ const notes = await store.getNotes(ids);
100
+ // Correctness: every requested note returned, ordered by created_at then id.
101
+ expect(notes.length).toBe(250);
102
+ expect(notes.map((n) => n.id)).toEqual(ids);
103
+ } finally {
104
+ spy.restore();
105
+ }
106
+ // Regression guard: no IN-list statement binds more than the chunk size...
107
+ expect(spy.maxPlaceholderInBinds()).toBeLessThanOrEqual(IN_PARAM_CHUNK);
108
+ // ...and nothing at all trips the DO cap.
109
+ expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
110
+ });
111
+
112
+ it("queryNotes over a >100-id set (the `near` path) binds the set as one json_each param", async () => {
113
+ const ids = Array.from({ length: 150 }, (_, i) => `n${pad(i)}`);
114
+ const spy = installBindSpy(db);
115
+ let notes;
116
+ try {
117
+ notes = await store.queryNotes({ ids, limit: 1000, sort: "asc" });
118
+ } finally {
119
+ spy.restore();
120
+ }
121
+ // Correctness: all 150 in-set notes returned.
122
+ expect(notes.length).toBe(150);
123
+ // The id-set filter rode a single json_each param, not 150 placeholders...
124
+ expect(spy.usedJsonEach()).toBe(true);
125
+ // ...so no statement — including the phase-1 id select carrying the set —
126
+ // exceeds the DO cap, and the phase-2 chunked hydration stays under it.
127
+ expect(spy.maxPlaceholderInBinds()).toBeLessThanOrEqual(IN_PARAM_CHUNK);
128
+ expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
129
+ });
130
+
131
+ it("empty id-set still short-circuits (no json_each, no rows)", async () => {
132
+ const notes = await store.queryNotes({ ids: [], limit: 10 });
133
+ expect(notes.length).toBe(0);
134
+ });
135
+
136
+ describe("export a >100-note vault", () => {
137
+ let outDir: string;
138
+ beforeEach(() => { outDir = mkdtempSync(join(tmpdir(), "do-cap-export-")); });
139
+ afterEach(() => { try { rmSync(outDir, { recursive: true, force: true }); } catch {} });
140
+
141
+ it("exports all 250 notes with no statement over the cap (the shipped bug)", async () => {
142
+ const spy = installBindSpy(db);
143
+ let stats;
144
+ try {
145
+ stats = await exportVaultToDir(store, { outDir, exportedAt: "2026-07-03T00:00:00.000Z" });
146
+ } finally {
147
+ spy.restore();
148
+ }
149
+ // Correctness: every note on disk.
150
+ expect(stats.notes).toBe(250);
151
+ expect(existsSync(join(outDir, "notes/n00000.md"))).toBe(true);
152
+ expect(existsSync(join(outDir, "notes/n00090.md"))).toBe(true); // first chunk boundary
153
+ expect(existsSync(join(outDir, "notes/n00249.md"))).toBe(true); // tail
154
+ // The bug: the export page's `id IN (?×page)` hydration exceeded 100 on
155
+ // DO. Now every IN-list statement stays under the chunk size...
156
+ expect(spy.maxPlaceholderInBinds()).toBeLessThanOrEqual(IN_PARAM_CHUNK);
157
+ // ...and no statement trips the DO cap.
158
+ expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
159
+ });
160
+ });
161
+ });
package/core/src/links.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { Link, NoteSummary, HydratedLink } from "./types.js";
3
3
  import { getNoteTagsForNotes } from "./notes.js";
4
+ import { chunkForInClause } from "./sql-in.js";
4
5
 
5
6
  export function createLink(
6
7
  db: Database,
@@ -103,16 +104,13 @@ function parseMetadata(raw: string | null): Record<string, unknown> | undefined
103
104
  try { return JSON.parse(raw); } catch { return undefined; }
104
105
  }
105
106
 
106
- /** IN-list chunk size — matches getLinkCounts' conservative bound-variable floor. */
107
- const IN_CHUNK = 900;
108
-
109
107
  function getNoteSummaries(db: Database, noteIds: string[]): Map<string, NoteSummary> {
110
108
  const map = new Map<string, NoteSummary>();
111
109
  if (noteIds.length === 0) return map;
112
110
  const ids = [...new Set(noteIds)];
113
111
  const rows: SummaryRow[] = [];
114
- for (let i = 0; i < ids.length; i += IN_CHUNK) {
115
- const chunk = ids.slice(i, i + IN_CHUNK);
112
+ // Chunk under the DO 100-bound-param cap (see sql-in.ts).
113
+ for (const chunk of chunkForInClause(ids)) {
116
114
  const placeholders = chunk.map(() => "?").join(", ");
117
115
  rows.push(...db.prepare(
118
116
  `SELECT id, path, metadata, created_at, updated_at FROM notes WHERE id IN (${placeholders})`,
@@ -193,8 +191,8 @@ export function getLinksHydratedForNotes(
193
191
  // (source, target, relationship) primary key so a link whose endpoints
194
192
  // are both on the page is fetched once.
195
193
  const rowsByKey = new Map<string, LinkRow>();
196
- for (let i = 0; i < ids.length; i += IN_CHUNK) {
197
- const chunk = ids.slice(i, i + IN_CHUNK);
194
+ // Chunk under the DO 100-bound-param cap (see sql-in.ts).
195
+ for (const chunk of chunkForInClause(ids)) {
198
196
  const placeholders = chunk.map(() => "?").join(", ");
199
197
  for (const column of ["source_id", "target_id"] as const) {
200
198
  const rows = db.prepare(
@@ -265,12 +263,9 @@ export function getLinkCounts(
265
263
  const wantOutbound = direction === "outbound" || direction === "both";
266
264
  const wantInbound = direction === "inbound" || direction === "both";
267
265
 
268
- // SQLite's default SQLITE_MAX_VARIABLE_NUMBER is 999 on older builds and
269
- // 32766 on newer ones; chunk well under the conservative floor so the
270
- // IN-list never trips the bind limit on a large page.
271
- const CHUNK = 900;
272
- for (let i = 0; i < ids.length; i += CHUNK) {
273
- const chunk = ids.slice(i, i + CHUNK);
266
+ // Chunk under the Cloudflare Durable Object SQLite 100-bound-param cap
267
+ // (bun:sqlite tolerates 999+, DO SQLite rejects >100). See sql-in.ts.
268
+ for (const chunk of chunkForInClause(ids)) {
274
269
  const placeholders = chunk.map(() => "?").join(", ");
275
270
 
276
271
  if (wantOutbound) {