@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,559 @@
1
+ /**
2
+ * Named seed packs — curated starter content a vault can be seeded with, plus
3
+ * the generic idempotent applier.
4
+ *
5
+ * A pack is a named bundle of `tags` (upserted) + `notes` (created only when
6
+ * no note already lives at the path). Three packs ship today:
7
+ *
8
+ * - `welcome` — the person-voiced three-note welcome web + the `capture`
9
+ * tag the Notes surface expects. Default-seeded on vault creation.
10
+ * - `getting-started` — the AI-facing start-here guide (SKILL.md-style
11
+ * doctrine addressed to a connected assistant). Default-seeded.
12
+ * - `surface-starter` — the living starter guide for building a custom
13
+ * surface (UI) over the vault. NOT default-seeded (ratified 2026-07-02:
14
+ * Surface Starter is out of the default seed) — added on demand via
15
+ * `parachute-vault add-pack surface-starter` or a console affordance.
16
+ *
17
+ * This module is the single source of truth for pack content across BOTH
18
+ * runtimes: the bun vault (`src/onboarding-seed.ts` default seed + the
19
+ * `add-pack` CLI verb) and the cloud vault DO (parachute-cloud
20
+ * `workers/vault` — a sibling PR consumes these packs in place of its own
21
+ * `welcome.ts` copy). Keep `applySeedPack` to single-item Store calls: the
22
+ * cloud DO routes them through its real `transactionSync` seam, and its
23
+ * conformance suite pins a zero-raw-BEGIN tripwire.
24
+ *
25
+ * `core/src/onboarding.ts` remains as a deprecated re-export shim for the
26
+ * Getting Started / Surface Starter path + content constants.
27
+ */
28
+
29
+ import type { Store } from "./types.ts";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Pack shape
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** A tag declaration a pack upserts (identity row, not a schema migration). */
36
+ export interface SeedPackTag {
37
+ name: string;
38
+ description: string;
39
+ parent_names?: string[];
40
+ }
41
+
42
+ /** A note a pack seeds — created only when no note exists at `path`. */
43
+ export interface SeedPackNote {
44
+ path: string;
45
+ content: string;
46
+ }
47
+
48
+ /** A named bundle of starter tags + notes. */
49
+ export interface SeedPack {
50
+ /** Stable pack name — the `add-pack <name>` handle. */
51
+ name: string;
52
+ /** One-line human description (shown by the pack listing). */
53
+ description: string;
54
+ tags: ReadonlyArray<SeedPackTag>;
55
+ notes: ReadonlyArray<SeedPackNote>;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // `welcome` pack — person-voiced first-minute content
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * The tag Notes requires — name/description must stay BYTE-EQUAL to notes-ui's
64
+ * `NOTES_REQUIRED_SCHEMA`
65
+ * (parachute-surface/packages/notes-ui/src/lib/vault/schema.ts): the PWA's
66
+ * connect-time audit (schema-audit.ts) compares `description` verbatim, so its
67
+ * schema banner clears because the tag genuinely exists with the semantics
68
+ * Notes declares — not because we gamed the check. Do not edit these strings
69
+ * without changing notes-ui in lockstep.
70
+ *
71
+ * ONE tag since 2026-07-03 (Aaron-ratified): `#capture` carries the sacred
72
+ * raw-input semantics; entry method (text vs voice) is provenance, not
73
+ * user-facing taxonomy, and moves to note `metadata.source` (`text` | `voice`)
74
+ * — a sibling notes-ui PR lands the client side. The old `capture/text` /
75
+ * `capture/voice` subtype tags are no longer seeded; existing vaults that
76
+ * carry them remain valid (tags are user data — this changes only what NEW
77
+ * vaults get).
78
+ */
79
+ export const NOTES_REQUIRED_TAGS: ReadonlyArray<SeedPackTag> = [
80
+ {
81
+ name: "capture",
82
+ description: "Notes captured directly by the user (text or voice).",
83
+ },
84
+ ];
85
+
86
+ export const WELCOME_PATH = "Welcome to your vault 🪂";
87
+ export const TRY_LINKING_PATH = "Try linking notes";
88
+ export const CONNECT_AI_PATH = "Connect your AI";
89
+
90
+ /**
91
+ * Build the `welcome` pack: a three-note welcome web (welcome → try-linking →
92
+ * back, connect-AI → welcome) so the graph view shows a connected structure
93
+ * from minute one, plus the `capture` tag above. The notes are ordinary
94
+ * notes — no special flags, deletable like anything else.
95
+ *
96
+ * Content is ported EXACTLY from parachute-cloud workers/vault/src/welcome.ts
97
+ * (person-voiced, addressed to the everyday user). `consoleOrigin` is the
98
+ * origin of the operator's console (the cloud console, or a hub portal) —
99
+ * when known, the Connect-your-AI note names it; when omitted (the bun vault
100
+ * seeds at create time, often pre-expose, and must never bake in a loopback
101
+ * origin) the line stays generic.
102
+ */
103
+ export function welcomePack(opts: { consoleOrigin?: string } = {}): SeedPack {
104
+ const consoleLine = opts.consoleOrigin
105
+ ? `Grab the connection URL from your console at ${opts.consoleOrigin}.`
106
+ : `Grab the connection URL from your console.`;
107
+ return {
108
+ name: "welcome",
109
+ description:
110
+ "A small linked welcome web (three notes) + the capture tag the Notes surface uses. Seeded by default on new vaults.",
111
+ tags: NOTES_REQUIRED_TAGS,
112
+ // `[[wikilinks]]` resolve by note path — pending links auto-resolve when
113
+ // the target is created, so order only affects how briefly a link sits
114
+ // unresolved during the seed.
115
+ notes: [
116
+ {
117
+ path: WELCOME_PATH,
118
+ content: `# ${WELCOME_PATH}
119
+
120
+ This vault is yours.
121
+ Write anything.
122
+ Notes can link to each other, like this: [[${TRY_LINKING_PATH}]].
123
+ `,
124
+ },
125
+ {
126
+ path: TRY_LINKING_PATH,
127
+ content: `# ${TRY_LINKING_PATH}
128
+
129
+ Wrap a note's name in double square brackets to make a wikilink, like this one back to [[${WELCOME_PATH}]].
130
+ `,
131
+ },
132
+ {
133
+ path: CONNECT_AI_PATH,
134
+ content: `# ${CONNECT_AI_PATH}
135
+
136
+ Your vault speaks MCP. ${consoleLine}
137
+ Start from [[${WELCOME_PATH}]].
138
+ `,
139
+ },
140
+ ],
141
+ };
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // `getting-started` pack — the AI-facing start-here guide
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** Canonical path of the seeded onboarding guide. Top-level, title-cased so it
149
+ * reads as a doc and sorts to the top of a casual file listing. */
150
+ export const GETTING_STARTED_PATH = "Getting Started";
151
+
152
+ /** Canonical path of the seeded surface-build starter (the `surface-starter`
153
+ * pack — NOT seeded by default; see the module doc). */
154
+ export const SURFACE_STARTER_PATH = "Surface Starter";
155
+
156
+ /**
157
+ * Body of the seeded `Getting Started` note.
158
+ *
159
+ * Voice: addressed to the connected AI ("you"), practical, SKILL.md-style.
160
+ * Covers (a) what a Parachute vault is, (b) tags-vs-paths-vs-schemas design,
161
+ * (c) importing existing notes, (d) that it's an adaptable starting point.
162
+ * Mentions the Surface Starter PACK by name (no wikilink — the note isn't
163
+ * seeded by default, and a dangling `[[Surface Starter]]` would sit unresolved
164
+ * on every fresh vault).
165
+ */
166
+ export const GETTING_STARTED_CONTENT = `# Getting Started
167
+
168
+ This is the **start-here guide** for this Parachute vault — think of it like a
169
+ \`SKILL.md\`: practical instructions for setting up and growing the vault. Read it
170
+ when you're **getting the vault started** or orienting yourself to it — you don't
171
+ need to re-read it every session. It's a **starting point, not a script**, and
172
+ it's adaptable: edit it (see "Adapt this note") as the vault takes shape.
173
+
174
+ When the operator says something like *"help me set up my parachute,"* this is
175
+ your brief: design their structure with them, import what they already have, and
176
+ shape the vault around how they actually think and work.
177
+
178
+ ## What a Parachute vault is
179
+
180
+ A vault is **notes + tags + links** in one graph, reachable over MCP (you, now),
181
+ a REST API (scripts), and any surface (a UI). It ships *nearly blank* — just a
182
+ small welcome web and the \`capture\` tag the Notes surface uses; no other
183
+ predefined tags or schema. You and the operator design the structure that fits
184
+ *their* life and work. The vault is the engine; the meaning is yours to bring.
185
+
186
+ Core moves you already have as MCP tools:
187
+ - \`create-note\` / \`update-note\` / \`delete-note\` — write notes (single or batch).
188
+ - \`query-notes\` — by id/path, by tag, full-text \`search\`, or graph \`near\` a note.
189
+ - \`list-tags\` / \`update-tag\` / \`delete-tag\` — manage the tag vocabulary + schemas.
190
+ - \`find-path\` — shortest link path between two notes.
191
+ - \`vault-info\` — refresh the live schema/stats projection any time.
192
+
193
+ \`[[wikilinks]]\` in note content auto-link to the note at that path — use them
194
+ freely; they resolve even if the target is created later.
195
+
196
+ ## Tags vs paths vs schemas — the design vocabulary
197
+
198
+ These three axes are the heart of vault design. Use the right one for the job:
199
+
200
+ - **Tags = types / membership.** A tag answers *"what kind of thing is this?"*
201
+ (\`#person\`, \`#meeting\`, \`#project\`). Queries **expand over tags**: a tag can
202
+ declare \`parent_names\` so \`tag:X\` also returns its subtypes (e.g. tagging a
203
+ note \`#meeting/standup\` with \`parent_names: [meeting]\` means \`query-notes
204
+ { tag: "meeting" }\` finds it). Tags are how you ask *"show me all my people."*
205
+ This is the primary structure — reach for a tag first.
206
+
207
+ - **Paths = organization / filing.** A path (\`Projects/Acme/Kickoff\`) is *where*
208
+ a note lives — a human-browsable address, unique per note. Paths are for
209
+ folders and named docs (like this one). They do **not** drive type queries;
210
+ don't encode meaning in a path that a tag should carry. A note can have a
211
+ path, tags, or both.
212
+
213
+ - **Schemas = typed metadata fields.** Attach a schema to a tag (via
214
+ \`update-tag\`) to declare typed metadata fields — e.g. \`#meeting\` with a
215
+ \`held_on\` date, \`#person\` with an \`email\`. Each field can **optionally** be
216
+ marked \`indexed: true\` to make it **queryable with operators** (\`query-notes
217
+ { tag: "meeting", metadata: { held_on: { gte: "2026-01-01" } } }\`); indexing
218
+ is opt-in per field, not automatic. Add a schema (and index a field) when you
219
+ find yourself wanting to filter or sort on a value, not before.
220
+
221
+ Rule of thumb: **type with tags, file with paths, make-it-queryable with
222
+ schemas.** Start minimal — invent tags as real notes need them, declare a
223
+ schema only when a query demands it. Over-designing an empty vault is the
224
+ common mistake.
225
+
226
+ Declaring a schema is one \`update-tag\` call — the \`fields\` object maps each
227
+ field name to \`{ type, enum?, indexed? }\` (\`type\` is \`"string"\`, \`"boolean"\`,
228
+ or \`"integer"\`):
229
+
230
+ \`\`\`
231
+ update-tag {
232
+ tag: "meeting",
233
+ description: "A meeting with notes",
234
+ fields: {
235
+ held_on: { type: "string", indexed: true }, // queryable with operators
236
+ status: { type: "string", enum: ["scheduled", "done"] }, // first enum value is the default
237
+ rating: { type: "integer" }
238
+ }
239
+ }
240
+ \`\`\`
241
+
242
+ \`fields\` is **merged** (new keys added, existing replaced); \`parent_names\` and
243
+ \`relationships\` are replaced wholesale when passed. Only \`indexed: true\` fields
244
+ support operator queries (\`metadata: { held_on: { gte: "..." } }\`) and
245
+ \`order_by\`; all tags declaring the same field must agree on its \`type\` and
246
+ \`indexed\` flag.
247
+
248
+ ## Write gotchas
249
+
250
+ A few behaviors worth knowing before you write at scale:
251
+
252
+ - **\`update-note\` requires optimistic concurrency by default.** Pass
253
+ \`if_updated_at\` with the \`updated_at\` you last read; a mismatch returns a
254
+ conflict error (re-read, reconcile, retry). For bulk/scripted writes where
255
+ concurrency is known-safe, pass \`force: true\` to waive the *requirement to
256
+ supply* it. \`append\`/\`prepend\`-only updates are exempt (no-conflict-by-design).
257
+ - **A schema field's default is filled in on write, so it shows up even when you
258
+ didn't set it.** When a note gets a tag whose schema declares a field, the
259
+ missing field is back-filled: an \`enum\` field → its **first listed value**, an
260
+ \`integer\` → \`0\`, a \`boolean\` → \`false\`, a plain string → \`""\`. So a
261
+ \`rating: { type: "integer" }\` reads as \`0\` on notes nobody rated — that \`0\`
262
+ is "unset," not "rated zero." Order an \`enum\`'s values so the first is a sane
263
+ default, and don't read a back-filled \`0\`/\`""\`/\`false\` as a real value.
264
+ - **Validation is advisory, never blocking.** A type/enum mismatch comes back as
265
+ a \`validation_status\` warning on the write response — the write still lands.
266
+ Read those warnings and self-correct on the next turn.
267
+
268
+ (Full design guide, with copy-paste examples: https://parachute.computer/scripting/)
269
+
270
+ ## Importing existing notes
271
+
272
+ If the operator already keeps notes (Obsidian, Markdown, etc.), bring them in
273
+ rather than starting cold:
274
+
275
+ - **Obsidian / a Markdown folder:** \`parachute-vault import <path>\` — preserves
276
+ frontmatter, tags, \`[[wikilinks]]\`, and file paths.
277
+ - **A portable Parachute export** (a dir with \`.parachute/vault.yaml\`): the same
278
+ \`import\` command auto-detects it and does a lossless round-trip (ids, typed
279
+ links, tag schemas, attachments).
280
+ - **Ad hoc / pasted content:** just \`create-note\` it. Then help the operator tag
281
+ and schematize: read a sample of imported notes, propose a small tag
282
+ vocabulary, and apply it.
283
+
284
+ After an import, orient yourself: \`vault-info\` for the new schema picture,
285
+ \`list-tags\` to see what vocabulary arrived, \`query-notes { search: "..." }\` to
286
+ spot-check. Then propose structure — don't impose it silently.
287
+
288
+ ## Later: a custom surface
289
+
290
+ Building a custom UI over the vault (a dashboard, a notes app) is usually **not**
291
+ the starting point — get the notes and structure right first. If and when the
292
+ operator wants one, add the **Surface Starter** guide to this vault — it's a
293
+ seed pack that isn't installed by default. Run
294
+ \`parachute-vault add-pack surface-starter\` (or use the console's add-pack
295
+ affordance) to seed it; it covers building a surface with
296
+ \`@openparachute/surface-client\` + \`@openparachute/surface-render\`.
297
+
298
+ ## Adapt this note
299
+
300
+ This guide is a **default starting point, not gospel** — edit it to fit this
301
+ vault. As you and the operator settle on a tag vocabulary, conventions, or a
302
+ surface, you can record that here so a future session inherits the current shape
303
+ of the vault instead of this blank-slate default. Useful things to capture:
304
+ - the tag vocabulary you've settled on and what each tag means;
305
+ - naming/path conventions for this vault;
306
+ - which schemas exist and why;
307
+ - anything a fresh AI would need to be immediately useful.
308
+
309
+ Treat setup as a relationship, not a one-time install.
310
+ `;
311
+
312
+ export const GETTING_STARTED_PACK: SeedPack = {
313
+ name: "getting-started",
314
+ description:
315
+ "The AI-facing start-here guide: vault design vocabulary (tags/paths/schemas), imports, write gotchas. Seeded by default on new vaults.",
316
+ tags: [],
317
+ notes: [{ path: GETTING_STARTED_PATH, content: GETTING_STARTED_CONTENT }],
318
+ };
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // `surface-starter` pack — the surface-build guide (opt-in)
322
+ // ---------------------------------------------------------------------------
323
+
324
+ /**
325
+ * Body of the seeded `Surface Starter` note.
326
+ *
327
+ * A concise, *living* starter prompt for building a custom surface (UI) over
328
+ * the vault using the published surface packages. Tells the AI to import the
329
+ * packages rather than hand-roll OAuth/API/rendering.
330
+ */
331
+ export const SURFACE_STARTER_CONTENT = `# Surface Starter
332
+
333
+ A **surface** is a custom UI over this vault — a dashboard, a notes app, a
334
+ single-purpose tool. This note is a living starter for building one *with the
335
+ operator*. Update it as you settle on a stack, conventions, or a deployed
336
+ surface for this vault.
337
+
338
+ ## ⚠️ Build a surface in your editor, not from this session
339
+
340
+ A surface runs **in a browser**: it needs a real OAuth round-trip (a redirect to
341
+ the hub's consent screen and back), a dev server to serve the app, and a CORS
342
+ origin the hub trusts. **None of that exists in this MCP/chat session** — there's
343
+ no browser, no redirect, no dev server. So **don't try to "run" a surface from
344
+ the vault session.** Build it in **Claude Code (or your editor)** against a local
345
+ dev server (\`vite\`/\`bun dev\`), sign in through the browser there, and iterate.
346
+ From *this* session you design the vault structure the surface will consume and
347
+ write the code — you can't exercise the OAuth/render loop here.
348
+
349
+ ## Don't hand-roll the plumbing
350
+
351
+ Two published packages do the heavy lifting — import them instead of writing
352
+ OAuth, the vault API client, or note rendering by hand:
353
+
354
+ - **\`@openparachute/surface-client\`** — \`createVaultSurface(...)\` wires up
355
+ Parachute OAuth (sign-in on first connect) and a typed vault API client
356
+ (query/create/update notes, tags, links) so your app code just calls methods.
357
+ - **\`@openparachute/surface-render\`** — \`<NoteRenderer>\` and friends render note
358
+ content (Markdown, wikilinks, embeds) the way the rest of the ecosystem does,
359
+ so your surface looks native without re-implementing the renderer.
360
+
361
+ ## Minimal end-to-end (config → sign-in → query → render)
362
+
363
+ A React sketch wiring all four steps. \`createVaultSurface\` is the only required
364
+ config (its \`clientName\` is the sole required option; \`hubUrl\` defaults to the
365
+ page origin, \`vaultName\` to \`"default"\`, \`scope\` to \`"vault:read vault:write"\`).
366
+ \`getClient()\` returns a \`VaultClient\` (or \`null\` until signed in) whose
367
+ \`queryNotes()\` takes the same query grammar you use over MCP. See
368
+ [[Getting Started]] / \`vault-info\` for this vault's NAME and hub origin.
369
+
370
+ \`\`\`tsx
371
+ import { useEffect, useState } from "react";
372
+ import { createVaultSurface, type Note } from "@openparachute/surface-client";
373
+ import { NoteRenderer } from "@openparachute/surface-render";
374
+
375
+ // One surface per (hub, vault) config. clientName shows on the consent screen.
376
+ const surface = createVaultSurface({
377
+ clientName: "My Vault Surface",
378
+ hubUrl: "https://your-hub.example", // omit to default to window.location.origin
379
+ vaultName: "default", // this vault's name (see vault-info)
380
+ });
381
+
382
+ export function App() {
383
+ const [notes, setNotes] = useState<Note[] | null>(null);
384
+
385
+ useEffect(() => {
386
+ (async () => {
387
+ // OAuth: finish a redirect callback if we're on it, else send the browser
388
+ // off to sign in. handleCallback() needs BOTH code + state, so guard on
389
+ // both. (Real apps route /oauth/callback to its own component.)
390
+ const q = new URLSearchParams(location.search);
391
+ if (q.get("code") && q.get("state")) await surface.handleCallback();
392
+ // getClient() builds a FRESH VaultClient on each call — fine here (one-shot
393
+ // effect); in a real component keep it in state/ref, don't call it per render.
394
+ const client = surface.getClient(); // VaultClient | null (null = not signed in)
395
+ if (!client) return void surface.login();
396
+ setNotes(await client.queryNotes({ tag: "note", limit: 20 }));
397
+ })();
398
+ }, []);
399
+
400
+ if (!notes) return <p>Connecting…</p>;
401
+ return (
402
+ <>
403
+ {notes.map((n) => (
404
+ // resolve maps a [[wikilink]] target → { href, exists } (or null = inert).
405
+ // You own this href's trust boundary — keep it a fragment (or validate the
406
+ // target). Don't build a raw passthrough href: a vault note could carry a
407
+ // javascript: target.
408
+ <NoteRenderer
409
+ key={n.id}
410
+ note={n}
411
+ resolve={(target) => ({ href: \`#/n/\${encodeURIComponent(target)}\`, exists: true })}
412
+ />
413
+ ))}
414
+ </>
415
+ );
416
+ }
417
+ \`\`\`
418
+
419
+ That's the whole spine. \`<NoteRenderer>\` also takes \`linkComponent\` (your
420
+ router's \`<Link>\`) and \`fetchBlob\` (\`(url) => Promise<Blob>\`, for auth'd
421
+ image/audio embeds) when you need them — both optional.
422
+
423
+ ## Build order
424
+
425
+ 1. **Auth + data first.** Stand up \`createVaultSurface\` pointed at this vault;
426
+ confirm you can sign in and \`queryNotes\` round-trips before any UI polish.
427
+ 2. **Render next.** Drop in \`<NoteRenderer>\` to display note content; wire
428
+ wikilink/embed resolution through the package, not by hand.
429
+ 3. **UX last.** Layout, navigation, and the surface's actual purpose — now that
430
+ auth, data, and rendering are solid.
431
+
432
+ ## Design it around this vault's structure
433
+
434
+ A good surface is shaped by the vault's tags + schemas (see [[Getting Started]]
435
+ for the tags-vs-paths-vs-schemas design vocabulary). Query by the tags that
436
+ matter to the operator; surface the indexed fields they filter on. If the vault
437
+ doesn't yet have the structure a surface wants, that's a signal to design tags +
438
+ schemas first.
439
+
440
+ ## Adapt this note
441
+
442
+ When you build a surface for this vault, record it here: what it's for, the
443
+ stack, how to run it, the queries it depends on. The next session should be able
444
+ to pick it up from this note.
445
+
446
+ (More on the surface stack + examples: https://parachute.computer/scripting/)
447
+ `;
448
+
449
+ export const SURFACE_STARTER_PACK: SeedPack = {
450
+ name: "surface-starter",
451
+ description:
452
+ "A living starter guide for building a custom surface (UI) over this vault with the published surface packages. Opt-in — not seeded by default.",
453
+ tags: [],
454
+ notes: [{ path: SURFACE_STARTER_PATH, content: SURFACE_STARTER_CONTENT }],
455
+ };
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Registry
459
+ // ---------------------------------------------------------------------------
460
+
461
+ /** The stable pack-name handles, in listing order. */
462
+ export const SEED_PACK_NAMES = [
463
+ "welcome",
464
+ "getting-started",
465
+ "surface-starter",
466
+ ] as const;
467
+
468
+ export type SeedPackName = (typeof SEED_PACK_NAMES)[number];
469
+
470
+ /** List the available packs (name + description) for CLI/console listings. */
471
+ export function listSeedPacks(): ReadonlyArray<{ name: string; description: string }> {
472
+ return SEED_PACK_NAMES.map((name) => {
473
+ const pack = getSeedPack(name)!;
474
+ return { name: pack.name, description: pack.description };
475
+ });
476
+ }
477
+
478
+ /**
479
+ * Resolve a pack by name. `opts` only affects the `welcome` pack (its
480
+ * Connect-your-AI note can name the operator's console origin). Returns
481
+ * `null` for an unknown name — callers list the available packs.
482
+ */
483
+ export function getSeedPack(
484
+ name: string,
485
+ opts: { consoleOrigin?: string } = {},
486
+ ): SeedPack | null {
487
+ switch (name) {
488
+ case "welcome":
489
+ return welcomePack(opts);
490
+ case "getting-started":
491
+ return GETTING_STARTED_PACK;
492
+ case "surface-starter":
493
+ return SURFACE_STARTER_PACK;
494
+ default:
495
+ return null;
496
+ }
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // Applier
501
+ // ---------------------------------------------------------------------------
502
+
503
+ export interface ApplySeedPackResult {
504
+ /** The pack that was applied. */
505
+ pack: string;
506
+ /** Tag names upserted (upserts are idempotent; always every declared tag). */
507
+ tags: string[];
508
+ /** Note paths written this run (absent ones). */
509
+ seededNotes: string[];
510
+ /** Note paths skipped because a note already lives there (idempotency). */
511
+ skippedNotes: string[];
512
+ }
513
+
514
+ /**
515
+ * Apply a pack to `store`, idempotently per item: each note is created only
516
+ * when no note exists at its path, and the tag upserts converge on the same
517
+ * rows — so a re-run can never duplicate, and never clobbers a note the
518
+ * operator/AI has since edited or recreated.
519
+ *
520
+ * Errors propagate — best-effort semantics (seed-must-never-fail-a-create)
521
+ * belong to the caller, which knows its failure policy.
522
+ *
523
+ * Uses only single-item Store calls — on the cloud DO they route through the
524
+ * real `transactionSync` seam, keeping the conformance suite's zero-raw-BEGIN
525
+ * tripwire honest.
526
+ */
527
+ export async function applySeedPack(
528
+ store: Store,
529
+ pack: SeedPack,
530
+ ): Promise<ApplySeedPackResult> {
531
+ const result: ApplySeedPackResult = {
532
+ pack: pack.name,
533
+ tags: [],
534
+ seededNotes: [],
535
+ skippedNotes: [],
536
+ };
537
+
538
+ // Parents first so `parent_names` reads naturally in logs (the store accepts
539
+ // forward references, but the order matches the conceptual model).
540
+ for (const decl of pack.tags) {
541
+ await store.upsertTagRecord(decl.name, {
542
+ description: decl.description,
543
+ ...(decl.parent_names ? { parent_names: decl.parent_names } : {}),
544
+ });
545
+ result.tags.push(decl.name);
546
+ }
547
+
548
+ for (const { path, content } of pack.notes) {
549
+ const existing = await store.getNoteByPath(path);
550
+ if (existing) {
551
+ result.skippedNotes.push(path);
552
+ continue;
553
+ }
554
+ await store.createNote(content, { path });
555
+ result.seededNotes.push(path);
556
+ }
557
+
558
+ return result;
559
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Unit coverage for the DO-safe IN-list helpers (sql-in.ts).
3
+ *
4
+ * These are the cheap, backing-store-independent guards: a regression that
5
+ * raises the chunk size above the Cloudflare Durable Object 100-bound-param
6
+ * cap — the exact class that 500'd cloud-vault exports — fails HERE, without
7
+ * needing a live DO. The behavioral proof over a real >100-note vault lives
8
+ * in do-param-cap.test.ts; the ultimate DO-constraint proof is the cloud
9
+ * staging export re-verify (bun:sqlite tolerates 999 params, so it can't
10
+ * reproduce the DO failure locally).
11
+ */
12
+ import { describe, it, expect } from "bun:test";
13
+ import { IN_PARAM_CHUNK, chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
14
+
15
+ describe("IN_PARAM_CHUNK", () => {
16
+ it("stays at or under the DO 100-bound-param cap (regression guard)", () => {
17
+ // The whole point: never let the chunk size drift back over the cap.
18
+ expect(IN_PARAM_CHUNK).toBeLessThanOrEqual(100);
19
+ expect(IN_PARAM_CHUNK).toBeGreaterThan(0);
20
+ });
21
+ });
22
+
23
+ describe("chunkForInClause", () => {
24
+ it("splits 250 ids into chunks that are each <= the chunk size", () => {
25
+ const ids = Array.from({ length: 250 }, (_, i) => `id${i}`);
26
+ const chunks = chunkForInClause(ids);
27
+ // 250 / 90 -> 3 chunks (90, 90, 70)
28
+ expect(chunks.length).toBe(3);
29
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(IN_PARAM_CHUNK);
30
+ expect(chunks.map((c) => c.length)).toEqual([90, 90, 70]);
31
+ // Every id is present exactly once, in order.
32
+ expect(chunks.flat()).toEqual(ids);
33
+ });
34
+
35
+ it("returns no chunks for empty input", () => {
36
+ expect(chunkForInClause([])).toEqual([]);
37
+ });
38
+
39
+ it("returns a single chunk when input fits", () => {
40
+ const ids = ["a", "b", "c"];
41
+ expect(chunkForInClause(ids)).toEqual([["a", "b", "c"]]);
42
+ });
43
+
44
+ it("honors an explicit smaller size", () => {
45
+ const chunks = chunkForInClause([1, 2, 3, 4, 5], 2);
46
+ expect(chunks).toEqual([[1, 2], [3, 4], [5]]);
47
+ });
48
+ });
49
+
50
+ describe("json_each single-param IN", () => {
51
+ it("serializes an id-set to one bound JSON param", () => {
52
+ const ids = ["a", "b", "c"];
53
+ expect(jsonEachParam(ids)).toBe('["a","b","c"]');
54
+ // The fragment carries exactly one placeholder regardless of set size.
55
+ expect(IN_VIA_JSON_EACH).toContain("json_each(?)");
56
+ expect((IN_VIA_JSON_EACH.match(/\?/g) ?? []).length).toBe(1);
57
+ });
58
+ });