@openparachute/vault 0.6.5-rc.2 → 0.6.5
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/.parachute/module.json +1 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/do-param-cap.test.ts +161 -0
- package/core/src/links.ts +8 -13
- package/core/src/mcp.ts +1 -1
- package/core/src/notes.ts +33 -15
- package/core/src/onboarding.ts +14 -296
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +357 -0
- package/core/src/seed-packs.ts +823 -0
- package/core/src/sql-in.test.ts +58 -0
- package/core/src/sql-in.ts +76 -0
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/transcription/provider.ts +141 -0
- package/core/src/types.ts +1 -1
- package/core/src/vault-projection.ts +1 -1
- package/core/src/wikilinks.ts +10 -4
- package/package.json +1 -1
- package/src/add-pack.test.ts +142 -0
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +806 -7
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/onboarding-seed.test.ts +188 -40
- package/src/onboarding-seed.ts +41 -46
- package/src/routes.ts +20 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +18 -6
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +133 -31
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +305 -0
- package/src/transcription/build.ts +332 -0
- package/src/transcription/capability.test.ts +118 -0
- package/src/transcription/capability.ts +96 -0
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/install.test.ts +167 -0
- package/src/transcription/install.ts +296 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/providers/scribe-http.test.ts +195 -0
- package/src/transcription/providers/scribe-http.ts +144 -0
- package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
- package/src/transcription/providers/transcribe-cpp.ts +293 -0
- package/src/transcription/select.test.ts +299 -0
- package/src/transcription/select.ts +397 -0
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/transcription-worker.test.ts +44 -0
- package/src/transcription-worker.ts +57 -122
- package/src/vault-create.test.ts +43 -10
- package/src/vault.test.ts +49 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL `IN`-list helpers sized for the smallest backing SQLite we run on.
|
|
3
|
+
*
|
|
4
|
+
* ## The DO 100-bound-parameter cap
|
|
5
|
+
*
|
|
6
|
+
* Cloudflare Durable Object SQLite (the cloud vault's backing store) rejects
|
|
7
|
+
* any statement carrying **more than 100 bound parameters** with
|
|
8
|
+
* `too many SQL variables`. Standard SQLite — including bun:sqlite (self-host)
|
|
9
|
+
* — tolerates 999 on old builds and 32766 on new ones, which is why an
|
|
10
|
+
* `IN (?, ?, …)` list sized for the 999 floor shipped fine on self-host and
|
|
11
|
+
* only 500'd on cloud vaults: a >100-note export page passed >100 ids in one
|
|
12
|
+
* statement and DO SQLite rejected it. See vault export-500 diagnosis.
|
|
13
|
+
*
|
|
14
|
+
* Two DO-safe shapes, depending on whether the id-list is the *only* thing
|
|
15
|
+
* in the statement:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Standalone id-list** (its own `SELECT … WHERE id IN (…)`, no other
|
|
18
|
+
* params, no pagination): chunk at {@link IN_PARAM_CHUNK} and union the
|
|
19
|
+
* results in JS. Use {@link chunkForInClause}.
|
|
20
|
+
*
|
|
21
|
+
* 2. **Embedded id-set** (an `IN` filter inside a larger statement that also
|
|
22
|
+
* carries `ORDER BY` / `LIMIT` / `OFFSET` / other params — chunking would
|
|
23
|
+
* break the shared pagination window): bind the whole set as a SINGLE JSON
|
|
24
|
+
* array param via {@link IN_VIA_JSON_EACH} + {@link jsonEachParam}. One
|
|
25
|
+
* param regardless of set size, so it can never trip the cap. Requires the
|
|
26
|
+
* JSON1 extension, which both bun:sqlite and DO SQLite ship (the vault
|
|
27
|
+
* already relies on `json_extract` on DO for metadata queries).
|
|
28
|
+
*
|
|
29
|
+
* ## Future refinement — `maxBoundParams`
|
|
30
|
+
*
|
|
31
|
+
* The clean long-term shape is for the Store / DB shim to advertise a
|
|
32
|
+
* `maxBoundParams` (DO = 100, bun = 999) and chunk to
|
|
33
|
+
* `min(IN_PARAM_CHUNK, maxBoundParams - reservedParams)`, so self-host keeps
|
|
34
|
+
* large-batch efficiency instead of paying the DO tax. Until then a global
|
|
35
|
+
* 90 is SAFE on bun — it just issues more cheap, fully-indexed IN queries —
|
|
36
|
+
* and is the conservative floor everywhere.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Chunk size for standalone `IN (?, ?, …)` id-lists. 90 leaves headroom
|
|
41
|
+
* under the DO 100-param cap for statements whose only bound params are the
|
|
42
|
+
* IN-list (the extra 10 covers the rare embedded extra param). Never raise
|
|
43
|
+
* this above 100 without teaching every embedded callsite the DO cap — see
|
|
44
|
+
* the module header.
|
|
45
|
+
*/
|
|
46
|
+
export const IN_PARAM_CHUNK = 90;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Split `items` into consecutive chunks of at most `size` (default
|
|
50
|
+
* {@link IN_PARAM_CHUNK}) for feeding to a chunked `IN (?, …)` query. Empty
|
|
51
|
+
* input yields no chunks. This is the single place that decides IN-list chunk
|
|
52
|
+
* boundaries, so a regression that raises the size is caught by one test.
|
|
53
|
+
*/
|
|
54
|
+
export function chunkForInClause<T>(items: readonly T[], size = IN_PARAM_CHUNK): T[][] {
|
|
55
|
+
const chunks: T[][] = [];
|
|
56
|
+
for (let i = 0; i < items.length; i += size) {
|
|
57
|
+
chunks.push(items.slice(i, i + size));
|
|
58
|
+
}
|
|
59
|
+
return chunks;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* SQL fragment for a single-param `IN` filter over an id-set embedded in a
|
|
64
|
+
* larger statement: `WHERE col IN ${IN_VIA_JSON_EACH}`. Bind the matching
|
|
65
|
+
* `?` with {@link jsonEachParam}. json_each expands the bound JSON array into
|
|
66
|
+
* a one-column table, so the whole set costs exactly ONE bound parameter and
|
|
67
|
+
* can never exceed the DO cap. Callers must guarantee the set is non-empty
|
|
68
|
+
* (`json_each('[]')` yields no rows — correct for `IN`, but an empty set is
|
|
69
|
+
* usually short-circuited to a `0 = 1` no-match upstream to skip the query).
|
|
70
|
+
*/
|
|
71
|
+
export const IN_VIA_JSON_EACH = "(SELECT value FROM json_each(?))";
|
|
72
|
+
|
|
73
|
+
/** Serialize an id-set for the single {@link IN_VIA_JSON_EACH} bound param. */
|
|
74
|
+
export function jsonEachParam(values: readonly (string | number)[]): string {
|
|
75
|
+
return JSON.stringify(values);
|
|
76
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* History: pre-v14 vaults stored hierarchy in notes-as-config at
|
|
10
10
|
* `_tags/<name>`. The v14 migration (see core/src/schema.ts:migrateToV14)
|
|
11
11
|
* lifts those parent declarations onto the tags row and the resolver here
|
|
12
|
-
* was swapped accordingly. See
|
|
12
|
+
* was swapped accordingly. See docs/contracts/tag-data-model.md.
|
|
13
13
|
*
|
|
14
14
|
* Resolution model:
|
|
15
15
|
* - Lazy: built on first access, cached on the store.
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Each tag row carries: human-readable description, indexed metadata field
|
|
5
5
|
* declarations (`fields`), typed-link relationship declarations
|
|
6
6
|
* (`relationships`), and the hierarchy parent list (`parent_names`).
|
|
7
|
-
* See
|
|
7
|
+
* See docs/contracts/tag-data-model.md.
|
|
8
8
|
*
|
|
9
9
|
* This module retains the historical `tag-schemas` filename and exports
|
|
10
10
|
* (`TagSchema`, `listTagSchemas`, `getTagSchema`, `upsertTagSchema`,
|
|
@@ -51,7 +51,7 @@ export interface TagFieldSchema {
|
|
|
51
51
|
* `{ target_tag, cardinality }` declaration — but `relationships` is now an
|
|
52
52
|
* opaque vocabulary map (see `TagRelationshipMap` / `validateRelationships`),
|
|
53
53
|
* so this is one valid value shape among many, not a required one.
|
|
54
|
-
* See
|
|
54
|
+
* See docs/contracts/tag-data-model.md §Relationships.
|
|
55
55
|
*/
|
|
56
56
|
export type TagRelCardinality = "one" | "optional" | "many" | "many-required";
|
|
57
57
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcription provider seam (scribe-fold Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* A `TranscriptionProvider` is the single, runtime-agnostic contract for
|
|
5
|
+
* turning an audio blob into text. It exists so vault's transcription worker
|
|
6
|
+
* doesn't hard-code "POST to scribe" — the worker resolves a provider and
|
|
7
|
+
* calls `transcribe()`, and the provider owns HOW the bytes become text.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this lives in core (not src)
|
|
10
|
+
*
|
|
11
|
+
* The interface must be importable from BOTH runtimes without dragging in
|
|
12
|
+
* Bun- or Node-only APIs:
|
|
13
|
+
*
|
|
14
|
+
* - the Bun vault (`src/transcription/providers/scribe-http.ts` — the
|
|
15
|
+
* whisper-compatible remote provider that reproduces today's scribe call);
|
|
16
|
+
* - the cloud vault (a future Cloudflare Workers-AI provider implemented
|
|
17
|
+
* against THIS interface for the metered voice build).
|
|
18
|
+
*
|
|
19
|
+
* So it is pure types + one dependency-free error class — no `fs`, no
|
|
20
|
+
* `FormData` serialization, no `fetch`. `audio` arrives as a `Uint8Array`
|
|
21
|
+
* (both runtimes have it); a provider decides how to ship it. Core has no
|
|
22
|
+
* package `exports` map, so consumers import the src subpath directly
|
|
23
|
+
* (`../core/src/transcription/provider.ts`), the same convention as
|
|
24
|
+
* `core/src/seed-packs.ts`.
|
|
25
|
+
*
|
|
26
|
+
* ## The fold (context)
|
|
27
|
+
*
|
|
28
|
+
* Ratified 2026-07-03 (Aaron): scribe folds into vault as a built-in
|
|
29
|
+
* transcription feature. Phase 1 (this seam) is behavior-preserving — the
|
|
30
|
+
* default `scribe-http` provider does EXACTLY what today's `callScribe` did,
|
|
31
|
+
* so existing scribe installs keep working unchanged. Phase 2 moves local-ASR
|
|
32
|
+
* backends behind the same interface. See
|
|
33
|
+
* `design/2026-07-03-transcription-provider-seam.md`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A single context entry attached alongside the audio (person/project notes,
|
|
38
|
+
* etc.). Structurally identical to `src/context.ts`'s `ContextEntry` — the
|
|
39
|
+
* same shape scribe already consumes over the `context` multipart part — so a
|
|
40
|
+
* `ContextPayload` produced there is assignable here without conversion. The
|
|
41
|
+
* interface stays runtime-agnostic: a provider decides whether/how to
|
|
42
|
+
* serialize it.
|
|
43
|
+
*/
|
|
44
|
+
export interface TranscriptionContextEntry {
|
|
45
|
+
/** Note path basename, or note id if no path. */
|
|
46
|
+
name: string;
|
|
47
|
+
/** Whitelisted metadata fields carried through from the vault query. */
|
|
48
|
+
[key: string]: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The `{ entries: [...] }` context blob a provider may forward with the audio. */
|
|
52
|
+
export interface TranscriptionContext {
|
|
53
|
+
entries: TranscriptionContextEntry[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The audio + descriptors handed to a provider for one transcription. */
|
|
57
|
+
export interface TranscribeInput {
|
|
58
|
+
/** Raw audio bytes. Both runtimes expose `Uint8Array`; a `Buffer` is one. */
|
|
59
|
+
audio: Uint8Array;
|
|
60
|
+
/** Original filename (used for the multipart `file` part / provider hints). */
|
|
61
|
+
filename: string;
|
|
62
|
+
/** MIME type of the audio (e.g. `audio/webm`). */
|
|
63
|
+
mimeType: string;
|
|
64
|
+
/**
|
|
65
|
+
* Optional vault-provided context (person/project notes) to forward with the
|
|
66
|
+
* audio so the provider/model can use it. `null`/`undefined` means none.
|
|
67
|
+
*/
|
|
68
|
+
context?: TranscriptionContext | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What a provider returns on a successful transcription. */
|
|
72
|
+
export interface TranscribeResult {
|
|
73
|
+
/** The transcript text. */
|
|
74
|
+
text: string;
|
|
75
|
+
/**
|
|
76
|
+
* Duration of the transcribed AUDIO in seconds, when the provider reports it.
|
|
77
|
+
* This is a metered/plan concern (the cloud voice build will decrement a
|
|
78
|
+
* minutes balance by it); self-host providers that don't measure it leave it
|
|
79
|
+
* unset. NOT the wall-clock request time — the worker measures that itself.
|
|
80
|
+
*/
|
|
81
|
+
audioSeconds?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Provider readiness probe result. */
|
|
85
|
+
export interface ProviderAvailability {
|
|
86
|
+
/** True when the provider can service `transcribe()` calls right now. */
|
|
87
|
+
ok: boolean;
|
|
88
|
+
/** Human-readable cause when `ok` is false (e.g. "no scribe URL configured"). */
|
|
89
|
+
reason?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The transcription contract. Implementations own the audio→text step; the
|
|
94
|
+
* worker owns queueing, backoff/retry, transcript-note materialization, and
|
|
95
|
+
* retention — none of which belong here.
|
|
96
|
+
*/
|
|
97
|
+
export interface TranscriptionProvider {
|
|
98
|
+
/** Stable provider identifier, surfaced on the capability flag (e.g. "scribe-http"). */
|
|
99
|
+
readonly name: string;
|
|
100
|
+
/** Turn audio into text (or throw — see `TranscriptionError` for the retry contract). */
|
|
101
|
+
transcribe(input: TranscribeInput): Promise<TranscribeResult>;
|
|
102
|
+
/**
|
|
103
|
+
* Cheap, side-effect-free readiness check. MUST NOT perform the actual
|
|
104
|
+
* transcription or a network round-trip unless that is genuinely cheap —
|
|
105
|
+
* it gates the capability flag on the vault landing, called on config reads.
|
|
106
|
+
*/
|
|
107
|
+
available(): Promise<ProviderAvailability>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Structured, provider-agnostic transcription failure.
|
|
112
|
+
*
|
|
113
|
+
* The worker's terminal-vs-retry decision keys off `retriable`: a
|
|
114
|
+
* non-retriable error (a 4xx the operator must fix — no provider configured,
|
|
115
|
+
* bad auth) fails immediately with no backoff; a retriable one (5xx, transient
|
|
116
|
+
* upstream) is retried with exponential backoff. `code` is a stable machine
|
|
117
|
+
* string (e.g. `missing_provider`) surfaced on the transcript note's
|
|
118
|
+
* frontmatter and the attachment's `transcribe_error_code` so callers branch
|
|
119
|
+
* on it instead of regex-matching message text.
|
|
120
|
+
*
|
|
121
|
+
* Providers throw this for structured failures; a plain `Error` (network drop,
|
|
122
|
+
* timeout, malformed response) is treated as retriable by the worker.
|
|
123
|
+
*/
|
|
124
|
+
export class TranscriptionError extends Error {
|
|
125
|
+
/** Stable machine code (e.g. "missing_provider"), when the provider supplied one. */
|
|
126
|
+
readonly code?: string;
|
|
127
|
+
/** Upstream HTTP status, when the failure came from an HTTP provider. */
|
|
128
|
+
readonly httpStatus?: number;
|
|
129
|
+
/** Whether retrying the same audio could plausibly succeed. */
|
|
130
|
+
readonly retriable: boolean;
|
|
131
|
+
constructor(
|
|
132
|
+
message: string,
|
|
133
|
+
opts: { code?: string; httpStatus?: number; retriable: boolean },
|
|
134
|
+
) {
|
|
135
|
+
super(message);
|
|
136
|
+
this.name = "TranscriptionError";
|
|
137
|
+
this.code = opts.code;
|
|
138
|
+
this.httpStatus = opts.httpStatus;
|
|
139
|
+
this.retriable = opts.retriable;
|
|
140
|
+
}
|
|
141
|
+
}
|
package/core/src/types.ts
CHANGED
|
@@ -406,7 +406,7 @@ export interface Store {
|
|
|
406
406
|
|
|
407
407
|
// Tag records — full v14 identity row (description + fields + typed
|
|
408
408
|
// relationships + parent_names + timestamps). See
|
|
409
|
-
//
|
|
409
|
+
// docs/contracts/tag-data-model.md.
|
|
410
410
|
listTagRecords(): Promise<TagRecord[]>;
|
|
411
411
|
getTagRecord(tag: string): Promise<TagRecord | null>;
|
|
412
412
|
/**
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
import { DEFAULT_TAG_NAME } from "./tag-hierarchy.ts";
|
|
34
34
|
import * as noteOps from "./notes.ts";
|
|
35
35
|
import type { VaultStats } from "./types.ts";
|
|
36
|
-
import { GETTING_STARTED_PATH } from "./
|
|
36
|
+
import { GETTING_STARTED_PATH } from "./seed-packs.ts";
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Does a note live at the seeded onboarding path? Used to gate the "Start here"
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import * as linkOps from "./links.js";
|
|
3
|
+
import { chunkForInClause } from "./sql-in.js";
|
|
3
4
|
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// Parser — extract [[wikilinks]] from markdown content
|
|
@@ -272,10 +273,15 @@ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved:
|
|
|
272
273
|
if (rows.length === 0) return { unresolved: [], count: total };
|
|
273
274
|
|
|
274
275
|
const sourceIds = [...new Set(rows.map((r) => r.source_id))];
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
276
|
+
// Chunk under the DO 100-bound-param cap (see sql-in.ts) — with a large
|
|
277
|
+
// `limit` this hydrates >100 source ids in one IN-list otherwise.
|
|
278
|
+
const pathRows: { id: string; path: string | null }[] = [];
|
|
279
|
+
for (const chunk of chunkForInClause(sourceIds)) {
|
|
280
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
281
|
+
pathRows.push(...db.prepare(
|
|
282
|
+
`SELECT id, path FROM notes WHERE id IN (${placeholders})`,
|
|
283
|
+
).all(...chunk) as { id: string; path: string | null }[]);
|
|
284
|
+
}
|
|
279
285
|
const pathMap = new Map(pathRows.map((r) => [r.id, r.path]));
|
|
280
286
|
|
|
281
287
|
const unresolved: UnresolvedWikilink[] = rows.map((r) => ({
|
package/package.json
CHANGED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for `parachute-vault add-pack <pack> [--vault <name>]`.
|
|
3
|
+
*
|
|
4
|
+
* Spawns the CLI in a temp `PARACHUTE_HOME` (same harness shape as
|
|
5
|
+
* vault-create.test.ts). Covers: listing on no-arg / unknown pack, applying
|
|
6
|
+
* `surface-starter` onto a default-seeded vault, idempotent re-runs, the
|
|
7
|
+
* missing-vault error, and `--vault` targeting.
|
|
8
|
+
*
|
|
9
|
+
* No in-test `Bun.serve` is involved, so `Bun.spawnSync` is fine here (see
|
|
10
|
+
* CLAUDE.md "Subprocess tests + Bun.serve").
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
import { resolve } from "path";
|
|
16
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
17
|
+
import { tmpdir } from "os";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
|
|
20
|
+
const CLI = resolve(import.meta.dir, "cli.ts");
|
|
21
|
+
|
|
22
|
+
function runCli(
|
|
23
|
+
args: string[],
|
|
24
|
+
env: Record<string, string>,
|
|
25
|
+
): { exitCode: number; stdout: string; stderr: string } {
|
|
26
|
+
// Hermetic: don't inherit the dev/CI box's PARACHUTE_HUB_ORIGIN (see
|
|
27
|
+
// vault-create.test.ts for the rationale).
|
|
28
|
+
const baseEnv: Record<string, string | undefined> = { ...process.env };
|
|
29
|
+
delete baseEnv.PARACHUTE_HUB_ORIGIN;
|
|
30
|
+
const proc = Bun.spawnSync({
|
|
31
|
+
cmd: ["bun", CLI, ...args],
|
|
32
|
+
stdout: "pipe",
|
|
33
|
+
stderr: "pipe",
|
|
34
|
+
env: { ...baseEnv, ...env },
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
exitCode: proc.exitCode ?? -1,
|
|
38
|
+
stdout: new TextDecoder().decode(proc.stdout),
|
|
39
|
+
stderr: new TextDecoder().decode(proc.stderr),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read all note paths from a vault's SQLite DB inside the temp home. */
|
|
44
|
+
function readNotePaths(home: string, name: string): (string | null)[] {
|
|
45
|
+
const db = new Database(join(home, "vault", "data", name, "vault.db"), {
|
|
46
|
+
readonly: true,
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
return (db.query("SELECT path FROM notes").all() as { path: string | null }[]).map(
|
|
50
|
+
(r) => r.path,
|
|
51
|
+
);
|
|
52
|
+
} finally {
|
|
53
|
+
db.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let home: string;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
home = mkdtempSync(join(tmpdir(), "add-pack-test-"));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
rmSync(home, { recursive: true, force: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("add-pack — listing + errors", () => {
|
|
68
|
+
test("no arguments → usage + the three packs, exit 1", () => {
|
|
69
|
+
const { exitCode, stderr } = runCli(["add-pack"], { PARACHUTE_HOME: home });
|
|
70
|
+
expect(exitCode).toBe(1);
|
|
71
|
+
expect(stderr).toContain("Usage: parachute-vault add-pack");
|
|
72
|
+
expect(stderr).toContain("Available packs:");
|
|
73
|
+
expect(stderr).toContain("welcome");
|
|
74
|
+
expect(stderr).toContain("getting-started");
|
|
75
|
+
expect(stderr).toContain("surface-starter");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("unknown pack → error + listing, exit 1", () => {
|
|
79
|
+
const { exitCode, stderr } = runCli(["add-pack", "does-not-exist"], {
|
|
80
|
+
PARACHUTE_HOME: home,
|
|
81
|
+
});
|
|
82
|
+
expect(exitCode).toBe(1);
|
|
83
|
+
expect(stderr).toContain('Unknown pack: "does-not-exist"');
|
|
84
|
+
expect(stderr).toContain("Available packs:");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("missing vault → actionable error, exit 1", () => {
|
|
88
|
+
const { exitCode, stderr } = runCli(
|
|
89
|
+
["add-pack", "surface-starter", "--vault", "ghost"],
|
|
90
|
+
{ PARACHUTE_HOME: home },
|
|
91
|
+
);
|
|
92
|
+
expect(exitCode).toBe(1);
|
|
93
|
+
expect(stderr).toContain('Vault "ghost" not found');
|
|
94
|
+
expect(stderr).toContain("parachute-vault create ghost");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("add-pack surface-starter", () => {
|
|
99
|
+
test("applies onto a default-seeded vault and reports what was added", () => {
|
|
100
|
+
expect(runCli(["create", "packed", "--json"], { PARACHUTE_HOME: home }).exitCode).toBe(0);
|
|
101
|
+
// Default seed does NOT include Surface Starter.
|
|
102
|
+
expect(readNotePaths(home, "packed")).not.toContain("Surface Starter");
|
|
103
|
+
|
|
104
|
+
const { exitCode, stdout } = runCli(
|
|
105
|
+
["add-pack", "surface-starter", "--vault", "packed"],
|
|
106
|
+
{ PARACHUTE_HOME: home },
|
|
107
|
+
);
|
|
108
|
+
expect(exitCode).toBe(0);
|
|
109
|
+
expect(stdout).toContain('Pack "surface-starter" applied to vault "packed"');
|
|
110
|
+
expect(stdout).toContain("+ note Surface Starter");
|
|
111
|
+
expect(readNotePaths(home, "packed")).toContain("Surface Starter");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("idempotent: a re-run skips (exit 0) and doesn't duplicate", () => {
|
|
115
|
+
runCli(["create", "packed", "--json"], { PARACHUTE_HOME: home });
|
|
116
|
+
runCli(["add-pack", "surface-starter", "--vault", "packed"], { PARACHUTE_HOME: home });
|
|
117
|
+
|
|
118
|
+
const { exitCode, stdout } = runCli(
|
|
119
|
+
["add-pack", "surface-starter", "--vault", "packed"],
|
|
120
|
+
{ PARACHUTE_HOME: home },
|
|
121
|
+
);
|
|
122
|
+
expect(exitCode).toBe(0);
|
|
123
|
+
expect(stdout).toContain("= note Surface Starter (already exists — left untouched)");
|
|
124
|
+
expect(stdout).not.toContain("+ note");
|
|
125
|
+
|
|
126
|
+
const paths = readNotePaths(home, "packed");
|
|
127
|
+
expect(paths.filter((p) => p === "Surface Starter")).toHaveLength(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("re-applying a default pack is a clean no-op-style run (welcome on a fresh vault)", () => {
|
|
131
|
+
runCli(["create", "packed", "--json"], { PARACHUTE_HOME: home });
|
|
132
|
+
const { exitCode, stdout } = runCli(
|
|
133
|
+
["add-pack", "welcome", "--vault", "packed"],
|
|
134
|
+
{ PARACHUTE_HOME: home },
|
|
135
|
+
);
|
|
136
|
+
expect(exitCode).toBe(0);
|
|
137
|
+
// Notes were already default-seeded → all skipped; tags re-upsert.
|
|
138
|
+
expect(stdout).not.toContain("+ note");
|
|
139
|
+
expect(stdout).toContain("= note Welcome to your vault 🪂 (already exists — left untouched)");
|
|
140
|
+
expect(stdout).toContain("~ tag capture (upserted)");
|
|
141
|
+
});
|
|
142
|
+
});
|
package/src/admin-spa.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Per-vault mount (vault#252): the SPA lives under `/vault/<name>/admin/*`
|
|
10
10
|
* rather than the origin-rooted `/admin/*` it shipped with. The hub only
|
|
11
|
-
* proxies `/vault/<name>/*` paths (per
|
|
11
|
+
* proxies `/vault/<name>/*` paths (per the module protocol —
|
|
12
|
+
* https://github.com/ParachuteComputer/parachute-hub/blob/main/docs/contracts/module-protocol.md),
|
|
12
13
|
* so an origin-rooted SPA is unreachable through the hub. Asset URLs are
|
|
13
14
|
* relative (Vite `base: "./"`), so the same bundle works at any mount
|
|
14
15
|
* point — no rebuild per vault.
|
package/src/auth.ts
CHANGED
|
@@ -123,7 +123,7 @@ export interface AuthResult {
|
|
|
123
123
|
* Tag-allowlist (root tag names) for tag-scoped tokens. NULL = unscoped
|
|
124
124
|
* (current full-vault behavior). Hub-issued JWTs and legacy YAML keys
|
|
125
125
|
* always have null — tag scoping is a per-token vault-DB attribute, not
|
|
126
|
-
* an OAuth-claim concern. See
|
|
126
|
+
* an OAuth-claim concern. See docs/contracts/tag-scoped-tokens.md.
|
|
127
127
|
*/
|
|
128
128
|
scoped_tags: string[] | null;
|
|
129
129
|
/**
|