@openparachute/vault 0.6.5-rc.9 → 0.7.0-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/.parachute/module.json +1 -0
- package/core/src/contract-concurrency.test.ts +117 -0
- package/core/src/contract-taxonomy.test.ts +119 -0
- package/core/src/contract-typed-index.test.ts +106 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/mcp.ts +1 -1
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +222 -56
- package/core/src/seed-packs.ts +334 -70
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +317 -53
- package/src/contract-errors.test.ts +98 -0
- package/src/contract-honest-queries.test.ts +100 -0
- package/src/contract-search.test.ts +127 -0
- 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 +82 -20
- package/src/onboarding-seed.ts +2 -2
- package/src/routes.ts +3 -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 +56 -0
- 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 +86 -5
- package/src/transcription/build.ts +87 -7
- package/src/transcription/capability.test.ts +25 -0
- package/src/transcription/capability.ts +27 -2
- 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/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/select.test.ts +166 -1
- package/src/transcription/select.ts +234 -1
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/vault-create.test.ts +19 -14
- package/src/vault.test.ts +1 -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,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract suite — honest queries at the REST boundary (Wave 1 of the
|
|
3
|
+
* Reliability & Usability Program, umbrella #556). Encodes the 2026-07-09
|
|
4
|
+
* nine-persona deep test's WS1 findings (#550) as executable tests: PASSING
|
|
5
|
+
* tests lock in behavior that is correct today; `test.todo` entries describe
|
|
6
|
+
* the target behavior for confirmed-broken cases, to be flipped to real
|
|
7
|
+
* assertions in a later wave. See #550 for the full write-up — this file
|
|
8
|
+
* covers the `src/routes.ts` REST surface specifically (the cursor-bootstrap
|
|
9
|
+
* gap lives at routes.ts:1102/1106, the tags/{name} 404 gap at the GET /tags
|
|
10
|
+
* handler in `handleTags`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
import { BunStore } from "./vault-store.ts";
|
|
16
|
+
import { handleNotes, handleTags } from "./routes.ts";
|
|
17
|
+
|
|
18
|
+
let db: Database;
|
|
19
|
+
let store: BunStore;
|
|
20
|
+
|
|
21
|
+
const BASE = "http://localhost/api";
|
|
22
|
+
|
|
23
|
+
function getNotes(qs: string): Promise<Response> {
|
|
24
|
+
return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getTags(qs: string): Promise<Response> {
|
|
28
|
+
return handleTags(new Request(`${BASE}/tags?${qs}`, { method: "GET" }), store, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
db = new Database(":memory:");
|
|
33
|
+
store = new BunStore(db);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
db.close();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("contract: honest queries — passing (lock in current behavior)", () => {
|
|
41
|
+
it("querying a nonexistent tag returns 200 with an empty array, not an error", async () => {
|
|
42
|
+
const res = await getNotes("tag=zzznonexistenttag");
|
|
43
|
+
expect(res.status).toBe(200);
|
|
44
|
+
const body = await res.json();
|
|
45
|
+
// Shape may gain a `warnings` field later (#550) — assert only status +
|
|
46
|
+
// array shape now so that addition doesn't break this contract test.
|
|
47
|
+
expect(Array.isArray(body)).toBe(true);
|
|
48
|
+
expect(body).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("a metadata operator query on a non-indexed field errors loudly with FIELD_NOT_INDEXED (existing behavior)", async () => {
|
|
52
|
+
const res = await getNotes("meta[not_a_real_field][gt]=5");
|
|
53
|
+
expect(res.status).toBe(400);
|
|
54
|
+
const body: any = await res.json();
|
|
55
|
+
expect(body.code).toBe("FIELD_NOT_INDEXED");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("a `near` query against a nonexistent anchor note errors cleanly with 404, not a silent []", async () => {
|
|
59
|
+
const res = await getNotes("near[note_id]=does-not-exist");
|
|
60
|
+
expect(res.status).toBe(404);
|
|
61
|
+
const body: any = await res.json();
|
|
62
|
+
expect(body.error).toBeDefined();
|
|
63
|
+
expect(body.note_id).toBe("does-not-exist");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// removed in W2 — once #550's cursor-bootstrap fix lands, an omitted
|
|
67
|
+
// `cursor` param will (per the documented intent) still return a flat
|
|
68
|
+
// array for callers who never opt into cursor pagination, so this
|
|
69
|
+
// specific assertion should survive; the paired todo below is the one
|
|
70
|
+
// that flips when the bootstrap gap closes. Kept both here so the fix
|
|
71
|
+
// is a conscious, reviewed change rather than an incidental snapshot.
|
|
72
|
+
it("a cursor-less limit query returns a flat array (today's shape) — removed in W2", async () => {
|
|
73
|
+
await store.createNote("only note");
|
|
74
|
+
const res = await getNotes("limit=5");
|
|
75
|
+
expect(res.status).toBe(200);
|
|
76
|
+
const body = await res.json();
|
|
77
|
+
expect(Array.isArray(body)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("contract: honest queries — todo (#550)", () => {
|
|
82
|
+
test.todo(
|
|
83
|
+
"#550: limit=-1 returns a structured error instead of silently meaning \"unlimited\" (today: SQLite's negative-LIMIT-means-no-limit semantics leak straight through to the caller)",
|
|
84
|
+
);
|
|
85
|
+
test.todo(
|
|
86
|
+
"#550: query responses carry a warnings: [] channel, populated with a did_you_mean suggestion for an unknown tag name",
|
|
87
|
+
);
|
|
88
|
+
test.todo(
|
|
89
|
+
"#550: an invalid date_filter value (unparseable date string) returns a structured error instead of silently matching nothing or everything",
|
|
90
|
+
);
|
|
91
|
+
test.todo(
|
|
92
|
+
"#550: GET /api/tags/{nonexistent} returns 404 instead of 200 with an all-null synthesized record",
|
|
93
|
+
);
|
|
94
|
+
test.todo(
|
|
95
|
+
"#550: list-tags reports expanded_count (rollup through parent_names descendants) alongside the literal per-tag count — today a parent tag with only child-tagged notes reports count: 0",
|
|
96
|
+
);
|
|
97
|
+
test.todo(
|
|
98
|
+
"#550: cursor bootstrap — a first call that expresses cursor intent (no `cursor` param yet, but pagination is desired) returns a {notes, next_cursor} envelope, and a second call passing that cursor back sees only notes written since the first call (today: routes.ts only wraps the response in {notes, next_cursor} when a cursor param is ALREADY present, so the very first call can never obtain one — core/src/mcp.ts:331 documents a bootstrap flow that is not actually reachable)",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract suite — search (Wave 1 of the Reliability & Usability Program,
|
|
3
|
+
* umbrella #556). Encodes the 2026-07-09 nine-persona deep test's search
|
|
4
|
+
* findings (WS2, #551) as executable tests: PASSING tests lock in behavior
|
|
5
|
+
* that is correct today; `test.todo` entries describe the target behavior
|
|
6
|
+
* for confirmed-broken cases, to be flipped to real assertions in a later
|
|
7
|
+
* wave. See #551 for the full write-up.
|
|
8
|
+
*
|
|
9
|
+
* Ground truth for every assertion here was re-verified live against this
|
|
10
|
+
* repo's FTS5 search path (`core/src/notes.ts` searchNotes → REST `GET
|
|
11
|
+
* /notes?search=`) before writing — see the PR description for the probe
|
|
12
|
+
* transcript.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
|
|
16
|
+
import { Database } from "bun:sqlite";
|
|
17
|
+
import { BunStore } from "./vault-store.ts";
|
|
18
|
+
import { handleNotes } from "./routes.ts";
|
|
19
|
+
|
|
20
|
+
let db: Database;
|
|
21
|
+
let store: BunStore;
|
|
22
|
+
|
|
23
|
+
const BASE = "http://localhost/api";
|
|
24
|
+
|
|
25
|
+
function search(qs: string): Promise<Response> {
|
|
26
|
+
return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Planted corpus — each note exists to exercise exactly one FTS5 quirk. */
|
|
30
|
+
const NOTES = {
|
|
31
|
+
hyphenPhrase: "The rollout had an eleven-day capping delay before it shipped.",
|
|
32
|
+
contraction: "She said she didn't know about the capping delay.",
|
|
33
|
+
decimal: "The measurement came out to 18.6 percent this quarter.",
|
|
34
|
+
bothWords: "A plain keyword note about widgets and gadgets.",
|
|
35
|
+
widgetsOnly: "Only widgets here, no other word.",
|
|
36
|
+
gadgetsOnly: "Gadgets alone, nothing else notable.",
|
|
37
|
+
filler1: "Quarterly report drafted for the finance team.",
|
|
38
|
+
filler2: "Meeting notes from the Tuesday standup.",
|
|
39
|
+
filler3: "Grocery list: eggs, bread, oat milk.",
|
|
40
|
+
filler4: "Project plan for the Q3 roadmap.",
|
|
41
|
+
filler5: "Weekly retro: wins and blockers for the sprint.",
|
|
42
|
+
filler6: "Random thoughts on the trip itinerary.",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
db = new Database(":memory:");
|
|
47
|
+
store = new BunStore(db);
|
|
48
|
+
for (const content of Object.values(NOTES)) {
|
|
49
|
+
await store.createNote(content);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
db.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function bodyOf(res: Response): Promise<any> {
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("contract: search — passing (lock in current behavior)", () => {
|
|
62
|
+
it("plain keyword search finds the matching notes", async () => {
|
|
63
|
+
const res = await search("search=widgets&include_content=true");
|
|
64
|
+
expect(res.status).toBe(200);
|
|
65
|
+
const body = await bodyOf(res);
|
|
66
|
+
const contents = new Set(body.map((n: any) => n.content));
|
|
67
|
+
expect(contents.has(NOTES.bothWords)).toBe(true);
|
|
68
|
+
expect(contents.has(NOTES.widgetsOnly)).toBe(true);
|
|
69
|
+
expect(contents.has(NOTES.gadgetsOnly)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("two-word unquoted search is implicit AND — only the note containing BOTH terms matches", async () => {
|
|
73
|
+
const res = await search("search=widgets+gadgets&include_content=true");
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
const body = await bodyOf(res);
|
|
76
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('quoted phrase finds hyphenated phrase text: "eleven-day capping delay"', async () => {
|
|
80
|
+
const res = await search(`search=${encodeURIComponent('"eleven-day capping delay"')}&include_content=true`);
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
const body = await bodyOf(res);
|
|
83
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('quoted decimal literal finds it: "18.6"', async () => {
|
|
87
|
+
const res = await search(`search=${encodeURIComponent('"18.6"')}&include_content=true`);
|
|
88
|
+
expect(res.status).toBe(200);
|
|
89
|
+
const body = await bodyOf(res);
|
|
90
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('quoted contraction finds it: "didn\'t"', async () => {
|
|
94
|
+
const res = await search(`search=${encodeURIComponent(`"didn't"`)}&include_content=true`);
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
const body = await bodyOf(res);
|
|
97
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("unknown-word search returns []", async () => {
|
|
101
|
+
const res = await search("search=zzzznonexistentword&include_content=true");
|
|
102
|
+
expect(res.status).toBe(200);
|
|
103
|
+
const body = await bodyOf(res);
|
|
104
|
+
expect(body).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("contract: search — todo (#551, literal-by-default + recall + ranking)", () => {
|
|
109
|
+
test.todo(
|
|
110
|
+
`#551: unquoted search: "didn't" finds the contraction content (literal-by-default — today the bare apostrophe splits into two AND'd tokens and returns [])`,
|
|
111
|
+
);
|
|
112
|
+
test.todo(
|
|
113
|
+
`#551: unquoted search: "eleven-day capping delay" finds the hyphenated-phrase note (literal-by-default — today the bare hyphenated word tokenizes into separate AND'd terms and returns [])`,
|
|
114
|
+
);
|
|
115
|
+
test.todo(
|
|
116
|
+
`#551: unquoted search: "18.6" finds the decimal note (literal-by-default — today the bare decimal tokenizes into separate AND'd terms and returns [])`,
|
|
117
|
+
);
|
|
118
|
+
test.todo(
|
|
119
|
+
`#551: search_mode:"advanced" preserves raw FTS5 query syntax (AND/OR/NOT, phrase, prefix) once literal-by-default escaping ships as the new default`,
|
|
120
|
+
);
|
|
121
|
+
test.todo(
|
|
122
|
+
`#551: sort:"asc"/"desc" changes result order under search (today silently ignored — FTS5 rank order wins regardless of the sort param)`,
|
|
123
|
+
);
|
|
124
|
+
test.todo(
|
|
125
|
+
`#551: malformed FTS syntax (e.g. an unbalanced quote) yields a structured warning or error, not a silently swallowed [] (today searchNotes catches every FTS5 syntax error and returns [])`,
|
|
126
|
+
);
|
|
127
|
+
});
|
package/src/export-watch.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* cursor captured at the *start* of the previous cycle. Vault writes are
|
|
11
11
|
* HTTP-mediated and don't surface to filesystem watchers (the bun:sqlite DB
|
|
12
12
|
* is opaque), so polling on `updated_at >= cursor` is the simplest robust
|
|
13
|
-
* detection. See `
|
|
13
|
+
* detection. See `docs/contracts/vault-portable-export.md`.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { ensureGitAvailable } from "./git-preflight.ts";
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-query frame PARITY + the pure WS helpers (WS-hibernation migration,
|
|
3
|
+
* self-host door).
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing CROSS-DOOR conformance check: for the shared frame corpus
|
|
6
|
+
* (mirrored byte-for-byte from the cloud door), the self-host WS message folds
|
|
7
|
+
* the event name into a `type` discriminator (plus the snapshot `done` chunk
|
|
8
|
+
* flag) over an inner payload (`notes` / `note` / `id`) that serializes
|
|
9
|
+
* byte-IDENTICALLY to the corpus. Because the corpus AND the `wsFrame` formatter
|
|
10
|
+
* are byte-shaped identical to cloud's, this transitively proves: self-host WS
|
|
11
|
+
* bytes === corpus === cloud WS bytes. (SSE was the original transport; it was
|
|
12
|
+
* removed in Phase 5 — WebSocket is now the sole live transport, polling the
|
|
13
|
+
* client floor.)
|
|
14
|
+
*
|
|
15
|
+
* Plus unit coverage of the pure helpers that back the Bun.serve integration:
|
|
16
|
+
* snapshot chunking, first-message parsing, verb-rank, and the query rejects.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from "bun:test";
|
|
19
|
+
import { wsFrame, WsSink } from "./subscriptions.ts";
|
|
20
|
+
import {
|
|
21
|
+
buildSnapshotFrames,
|
|
22
|
+
parseClientMessage,
|
|
23
|
+
vaultVerbRank,
|
|
24
|
+
sameTagScope,
|
|
25
|
+
validateWsSubscribeQuery,
|
|
26
|
+
subscriptionCapResponse,
|
|
27
|
+
urlFromQuery,
|
|
28
|
+
SNAPSHOT_CHUNK_MAX_NOTES,
|
|
29
|
+
WS_CLOSE,
|
|
30
|
+
} from "./ws-subscribe.ts";
|
|
31
|
+
import { unsupportedSubscriptionReason } from "./live-match.ts";
|
|
32
|
+
import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
|
|
33
|
+
|
|
34
|
+
describe("live-frame parity — self-host WS bytes === corpus === cloud WS bytes", () => {
|
|
35
|
+
for (const c of FRAME_CORPUS) {
|
|
36
|
+
it(`${c.name}: WS message folds the event into \`type\` over the corpus payload`, () => {
|
|
37
|
+
const ws = wsFrame(c.event, c.data);
|
|
38
|
+
|
|
39
|
+
// WS message is `{ type, ...data }` — the event name folds into `type`.
|
|
40
|
+
const parsedWs = JSON.parse(ws) as Record<string, unknown>;
|
|
41
|
+
expect(parsedWs.type).toBe(c.event);
|
|
42
|
+
const { type, ...rest } = parsedWs;
|
|
43
|
+
void type;
|
|
44
|
+
expect(rest).toEqual(c.data);
|
|
45
|
+
|
|
46
|
+
// BYTE parity of the inner payload: whatever value the event carries
|
|
47
|
+
// (`notes` / `note` / `id`) serializes IDENTICALLY to the corpus
|
|
48
|
+
// serialization (the cross-door invariant).
|
|
49
|
+
const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
|
|
50
|
+
const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
|
|
51
|
+
expect(ws.includes(innerBytes)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("sink classes render through the ONE formatter", () => {
|
|
57
|
+
it("WsSink.send emits exactly wsFrame(event, data)", () => {
|
|
58
|
+
const sent: string[] = [];
|
|
59
|
+
const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
|
|
60
|
+
for (const c of FRAME_CORPUS) {
|
|
61
|
+
sink.send(c.event, c.data);
|
|
62
|
+
expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("buildSnapshotFrames — chunking + done flag", () => {
|
|
68
|
+
it("empty set → exactly one done:true frame", () => {
|
|
69
|
+
const frames = buildSnapshotFrames([]);
|
|
70
|
+
expect(frames.length).toBe(1);
|
|
71
|
+
expect(JSON.parse(frames[0]!)).toEqual({ type: "snapshot", notes: [], done: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("small set → one done:true frame carrying every note", () => {
|
|
75
|
+
const frames = buildSnapshotFrames([NOTE_A as any, NOTE_A as any]);
|
|
76
|
+
expect(frames.length).toBe(1);
|
|
77
|
+
const f = JSON.parse(frames[0]!);
|
|
78
|
+
expect(f.done).toBe(true);
|
|
79
|
+
expect(f.notes.length).toBe(2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("> SNAPSHOT_CHUNK_MAX_NOTES → multiple frames, only the last done:true, concat = full set", () => {
|
|
83
|
+
const many = Array.from({ length: SNAPSHOT_CHUNK_MAX_NOTES + 5 }, (_, i) => ({ ...NOTE_A, id: `n-${i}` }));
|
|
84
|
+
const frames = buildSnapshotFrames(many as any);
|
|
85
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
86
|
+
const parsed = frames.map((f) => JSON.parse(f));
|
|
87
|
+
expect(parsed.filter((p) => p.done).length).toBe(1);
|
|
88
|
+
expect(parsed[parsed.length - 1]!.done).toBe(true);
|
|
89
|
+
parsed.slice(0, -1).forEach((p) => expect(p.done).toBe(false));
|
|
90
|
+
const concat = parsed.flatMap((p) => p.notes);
|
|
91
|
+
expect(concat.map((n: any) => n.id)).toEqual(many.map((n) => n.id));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("each frame stays under the WS message ceiling with large notes", () => {
|
|
95
|
+
const big = Array.from({ length: 8 }, (_, i) => ({ ...NOTE_A, id: `big-${i}`, content: "x".repeat(200_000) }));
|
|
96
|
+
const frames = buildSnapshotFrames(big as any);
|
|
97
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
98
|
+
for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
|
|
99
|
+
expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("parseClientMessage", () => {
|
|
104
|
+
it("valid auth", () => {
|
|
105
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "t" }))).toEqual({ kind: "auth", token: "t" });
|
|
106
|
+
});
|
|
107
|
+
it("auth without a token → malformed", () => {
|
|
108
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth" })).kind).toBe("malformed");
|
|
109
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "" })).kind).toBe("malformed");
|
|
110
|
+
});
|
|
111
|
+
it("non-json / non-object → malformed", () => {
|
|
112
|
+
expect(parseClientMessage("not json").kind).toBe("malformed");
|
|
113
|
+
expect(parseClientMessage(JSON.stringify([1, 2])).kind).toBe("malformed");
|
|
114
|
+
});
|
|
115
|
+
it("other typed message → other", () => {
|
|
116
|
+
expect(parseClientMessage(JSON.stringify({ type: "hello" }))).toEqual({ kind: "other", type: "hello" });
|
|
117
|
+
});
|
|
118
|
+
it("decodes a Uint8Array frame", () => {
|
|
119
|
+
const bytes = new TextEncoder().encode(JSON.stringify({ type: "auth", token: "b" }));
|
|
120
|
+
expect(parseClientMessage(bytes)).toEqual({ kind: "auth", token: "b" });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("vaultVerbRank — narrow-or-equal ordering", () => {
|
|
125
|
+
it("ranks read < write < admin, -1 for none/other-vault", () => {
|
|
126
|
+
expect(vaultVerbRank(["vault:v:read"], "v")).toBe(0);
|
|
127
|
+
expect(vaultVerbRank(["vault:v:write"], "v")).toBe(1);
|
|
128
|
+
expect(vaultVerbRank(["vault:v:admin"], "v")).toBe(2);
|
|
129
|
+
expect(vaultVerbRank(["vault:other:read"], "v")).toBe(-1);
|
|
130
|
+
expect(vaultVerbRank([], "v")).toBe(-1);
|
|
131
|
+
});
|
|
132
|
+
it("a widen is strictly greater (the 4403 trigger)", () => {
|
|
133
|
+
expect(vaultVerbRank(["vault:v:admin"], "v") > vaultVerbRank(["vault:v:read"], "v")).toBe(true);
|
|
134
|
+
expect(vaultVerbRank(["vault:v:read"], "v") > vaultVerbRank(["vault:v:admin"], "v")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("sameTagScope — the WS re-auth tag-scope-delta gate", () => {
|
|
139
|
+
it("both unscoped (null) → same", () => {
|
|
140
|
+
expect(sameTagScope(null, null)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
it("null vs scoped → differ (a drop/gain of scoping)", () => {
|
|
143
|
+
expect(sameTagScope(null, ["work/eng"])).toBe(false);
|
|
144
|
+
expect(sameTagScope(["work/eng"], null)).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
it("same set (order/dupe-insensitive) → same", () => {
|
|
147
|
+
expect(sameTagScope(["a", "b"], ["b", "a"])).toBe(true);
|
|
148
|
+
expect(sameTagScope(["a", "a", "b"], ["b", "a"])).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
it("different membership → differ", () => {
|
|
151
|
+
expect(sameTagScope(["work/eng"], ["work/sales"])).toBe(false);
|
|
152
|
+
expect(sameTagScope(["a"], ["a", "b"])).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-identical bodies)", () => {
|
|
157
|
+
const reject = async (q: string) => {
|
|
158
|
+
const v = validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?${q}`));
|
|
159
|
+
expect("error" in v).toBe(true);
|
|
160
|
+
const body = await (v as { error: Response }).error.json();
|
|
161
|
+
expect((v as { error: Response }).error.status).toBe(400);
|
|
162
|
+
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
163
|
+
};
|
|
164
|
+
it("rejects the URL-reachable unsupported shapes: search / near / cursor / has_links", async () => {
|
|
165
|
+
await reject("search=hi");
|
|
166
|
+
await reject("near[note_id]=abc");
|
|
167
|
+
await reject("cursor=xyz");
|
|
168
|
+
await reject("has_links=true");
|
|
169
|
+
});
|
|
170
|
+
it("accepts a supported query", () => {
|
|
171
|
+
const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
|
|
172
|
+
expect("queryOpts" in v).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
|
|
175
|
+
// The belt-and-suspenders layer both doors + the SSE route route through:
|
|
176
|
+
// a date filter isn't expressible as a flat URL param (removed 0.6.4), but
|
|
177
|
+
// if one ever reaches queryOpts it is rejected identically.
|
|
178
|
+
expect(unsupportedSubscriptionReason({ cursor: "x" } as any)).toBeTruthy();
|
|
179
|
+
expect(unsupportedSubscriptionReason({ hasLinks: true } as any)).toBeTruthy();
|
|
180
|
+
expect(unsupportedSubscriptionReason({ dateFrom: "2026-01-01" } as any)).toBeTruthy();
|
|
181
|
+
expect(unsupportedSubscriptionReason({ dateFilter: { field: "created_at" } } as any)).toBeTruthy();
|
|
182
|
+
expect(unsupportedSubscriptionReason({ tags: ["chat"] } as any)).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("misc pure helpers", () => {
|
|
187
|
+
it("subscriptionCapResponse → 503 SUBSCRIPTION_CAP_REACHED", async () => {
|
|
188
|
+
const res = subscriptionCapResponse();
|
|
189
|
+
expect(res.status).toBe(503);
|
|
190
|
+
expect((await res.json()).code).toBe("SUBSCRIPTION_CAP_REACHED");
|
|
191
|
+
});
|
|
192
|
+
it("urlFromQuery reconstructs searchParams from a raw query string", () => {
|
|
193
|
+
expect(urlFromQuery("?tag=chat&a=b").searchParams.get("tag")).toBe("chat");
|
|
194
|
+
expect(urlFromQuery("").search).toBe("");
|
|
195
|
+
});
|
|
196
|
+
it("WS_CLOSE carries the four application close codes", () => {
|
|
197
|
+
expect([WS_CLOSE.PROTOCOL, WS_CLOSE.UNAUTHORIZED, WS_CLOSE.FORBIDDEN, WS_CLOSE.AUTH_TIMEOUT]).toEqual([
|
|
198
|
+
4400, 4401, 4403, 4408,
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
});
|
package/src/module-manifest.ts
CHANGED
|
@@ -48,6 +48,14 @@ export interface VaultModuleManifest {
|
|
|
48
48
|
readonly paths: readonly string[];
|
|
49
49
|
readonly health: string;
|
|
50
50
|
readonly stripPrefix?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* When `true`, vault's daemon accepts WebSocket upgrades (the live-query WS
|
|
53
|
+
* binding) and the hub's ws-bridge forwards `Upgrade: websocket` requests on
|
|
54
|
+
* vault's mounts. DENY BY DEFAULT on the hub side. Carried onto the
|
|
55
|
+
* self-registered services.json row too (`ServiceEntry.websocket`); the hub
|
|
56
|
+
* honors either source. See `parachute-hub/src/ws-bridge.ts`.
|
|
57
|
+
*/
|
|
58
|
+
readonly websocket?: boolean;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/**
|
|
@@ -23,24 +23,29 @@ import { BunStore } from "./vault-store.ts";
|
|
|
23
23
|
import { seedOnboardingNotes } from "./onboarding-seed.ts";
|
|
24
24
|
import {
|
|
25
25
|
applySeedPack,
|
|
26
|
+
CAPTURE_ANYTHING_PATH,
|
|
26
27
|
CONNECT_AI_PATH,
|
|
27
28
|
GETTING_STARTED_PATH,
|
|
28
29
|
NOTES_REQUIRED_TAGS,
|
|
29
30
|
SURFACE_STARTER_PACK,
|
|
30
31
|
SURFACE_STARTER_PATH,
|
|
31
|
-
|
|
32
|
+
TAGS_GRAPH_PATH,
|
|
32
33
|
WELCOME_PATH,
|
|
34
|
+
YOURS_TO_KEEP_PATH,
|
|
33
35
|
} from "../core/src/seed-packs.ts";
|
|
34
36
|
import {
|
|
35
37
|
buildVaultProjection,
|
|
36
38
|
projectionToMarkdown,
|
|
37
39
|
} from "../core/src/vault-projection.ts";
|
|
38
40
|
|
|
39
|
-
/** Every note path the default seed writes on a blank vault
|
|
41
|
+
/** Every note path the default seed writes on a blank vault (the five-guide
|
|
42
|
+
* welcome ring + the AI-facing Getting Started). */
|
|
40
43
|
const DEFAULT_SEED_PATHS = [
|
|
41
44
|
WELCOME_PATH,
|
|
42
|
-
|
|
45
|
+
CAPTURE_ANYTHING_PATH,
|
|
46
|
+
TAGS_GRAPH_PATH,
|
|
43
47
|
CONNECT_AI_PATH,
|
|
48
|
+
YOURS_TO_KEEP_PATH,
|
|
44
49
|
GETTING_STARTED_PATH,
|
|
45
50
|
];
|
|
46
51
|
|
|
@@ -77,18 +82,30 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
77
82
|
expect(gs!.content).toContain("Tags = types");
|
|
78
83
|
expect(gs!.content).toContain("Paths = organization");
|
|
79
84
|
expect(gs!.content).toContain("Schemas = typed metadata fields");
|
|
80
|
-
|
|
85
|
+
// Data-in leads with the import UI + MCP bridge; the old `parachute-vault
|
|
86
|
+
// import` CLI line (a live bug on cloud) is gone (2026-07-06 rewrite).
|
|
87
|
+
expect(gs!.content).not.toContain("parachute-vault import");
|
|
88
|
+
expect(gs!.content).toContain("Bringing existing notes in");
|
|
81
89
|
expect(gs!.content).toContain("Adapt this note");
|
|
82
90
|
|
|
83
|
-
// The welcome
|
|
84
|
-
for (const path of [
|
|
91
|
+
// The welcome ring: five person-voiced guide notes.
|
|
92
|
+
for (const path of [
|
|
93
|
+
WELCOME_PATH,
|
|
94
|
+
CAPTURE_ANYTHING_PATH,
|
|
95
|
+
TAGS_GRAPH_PATH,
|
|
96
|
+
CONNECT_AI_PATH,
|
|
97
|
+
YOURS_TO_KEEP_PATH,
|
|
98
|
+
]) {
|
|
85
99
|
expect(await store.getNoteByPath(path)).not.toBeNull();
|
|
86
100
|
}
|
|
87
101
|
});
|
|
88
102
|
|
|
89
|
-
test("seeds the
|
|
103
|
+
test("seeds the capture tag Notes requires (byte-equal semantics) + the guide tag", async () => {
|
|
90
104
|
const result = await seedOnboardingNotes(store);
|
|
91
|
-
|
|
105
|
+
// welcome upserts [capture, guide]; getting-started re-upserts [guide]
|
|
106
|
+
// (converges). applySeedPack reports every declared tag per pack, so the
|
|
107
|
+
// concatenation carries guide twice — deterministic.
|
|
108
|
+
expect(result.tags).toEqual(["capture", "guide", "guide"]);
|
|
92
109
|
|
|
93
110
|
for (const decl of NOTES_REQUIRED_TAGS) {
|
|
94
111
|
const record = await store.getTagRecord(decl.name);
|
|
@@ -97,6 +114,15 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
97
114
|
expect(record!.parent_names ?? []).toEqual(decl.parent_names ?? []);
|
|
98
115
|
}
|
|
99
116
|
|
|
117
|
+
// The guide tag lands with no metadata schema — guides are for you and your
|
|
118
|
+
// AI alike, plain markdown, so no per-guide audience field (written_for dropped).
|
|
119
|
+
const guide = await store.getTagRecord("guide");
|
|
120
|
+
expect(guide).not.toBeNull();
|
|
121
|
+
expect(guide!.fields?.written_for).toBeUndefined();
|
|
122
|
+
// #pinned is NOT seeded (dropped 2026-07-07 — did nothing visible on a fresh
|
|
123
|
+
// vault). Pinning still works when the user pins a note in the app.
|
|
124
|
+
expect(await store.getTagRecord("pinned")).toBeNull();
|
|
125
|
+
|
|
100
126
|
// The retired subtype tags are NOT seeded — entry method is note
|
|
101
127
|
// metadata.source (text|voice), not taxonomy (2026-07-03). Existing
|
|
102
128
|
// vaults carrying these tags stay valid; new vaults don't get them.
|
|
@@ -104,6 +130,29 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
104
130
|
expect(await store.getTagRecord("capture/voice")).toBeNull();
|
|
105
131
|
});
|
|
106
132
|
|
|
133
|
+
test("seeded guides carry #guide (create-time tag write-through)", async () => {
|
|
134
|
+
await seedOnboardingNotes(store);
|
|
135
|
+
|
|
136
|
+
// Welcome guide: tagged guide (no longer pinned).
|
|
137
|
+
const welcome = await store.getNoteByPath(WELCOME_PATH);
|
|
138
|
+
expect([...(welcome!.tags ?? [])].sort()).toEqual(["guide"]);
|
|
139
|
+
|
|
140
|
+
// The four sibling guides: guide only.
|
|
141
|
+
for (const path of [
|
|
142
|
+
CAPTURE_ANYTHING_PATH,
|
|
143
|
+
TAGS_GRAPH_PATH,
|
|
144
|
+
CONNECT_AI_PATH,
|
|
145
|
+
YOURS_TO_KEEP_PATH,
|
|
146
|
+
]) {
|
|
147
|
+
const note = await store.getNoteByPath(path);
|
|
148
|
+
expect(note!.tags).toEqual(["guide"]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The AI-facing Getting Started guide: guide.
|
|
152
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
153
|
+
expect(gs!.tags).toEqual(["guide"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
107
156
|
test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
|
|
108
157
|
await seedOnboardingNotes(store);
|
|
109
158
|
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
@@ -121,26 +170,38 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
121
170
|
expect(gs!.content).toContain("first listed value");
|
|
122
171
|
});
|
|
123
172
|
|
|
124
|
-
test("A3 (reshaped): the welcome
|
|
173
|
+
test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
|
|
125
174
|
await seedOnboardingNotes(store);
|
|
126
175
|
|
|
127
|
-
// welcome → try-linking, try-linking → welcome, connect-AI → welcome.
|
|
128
176
|
const welcome = await store.getNoteByPath(WELCOME_PATH);
|
|
129
|
-
const
|
|
177
|
+
const capture = await store.getNoteByPath(CAPTURE_ANYTHING_PATH);
|
|
178
|
+
const tags = await store.getNoteByPath(TAGS_GRAPH_PATH);
|
|
130
179
|
const connectAi = await store.getNoteByPath(CONNECT_AI_PATH);
|
|
180
|
+
const yours = await store.getNoteByPath(YOURS_TO_KEEP_PATH);
|
|
181
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
131
182
|
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
183
|
+
const outbound = async (id: string) =>
|
|
184
|
+
(await store.getLinks(id, { direction: "outbound" })).map((l) => l.targetId);
|
|
185
|
+
|
|
186
|
+
// Welcome → all four siblings.
|
|
187
|
+
const welcomeOut = await outbound(welcome!.id);
|
|
188
|
+
for (const t of [capture!.id, tags!.id, connectAi!.id, yours!.id]) {
|
|
189
|
+
expect(welcomeOut).toContain(t);
|
|
190
|
+
}
|
|
191
|
+
// The chain forward: capture → tags → connect → yours → welcome.
|
|
192
|
+
expect(await outbound(capture!.id)).toContain(tags!.id);
|
|
193
|
+
expect(await outbound(tags!.id)).toContain(connectAi!.id);
|
|
194
|
+
const connectOut = await outbound(connectAi!.id);
|
|
195
|
+
expect(connectOut).toContain(yours!.id);
|
|
196
|
+
// Connect your AI also links the AI-facing Getting Started note (seeded by
|
|
197
|
+
// the getting-started pack) — the ring's cross-link resolves, no dangle.
|
|
198
|
+
expect(connectOut).toContain(gs!.id);
|
|
199
|
+
expect(await outbound(yours!.id)).toContain(welcome!.id);
|
|
138
200
|
|
|
139
201
|
// Getting Started mentions the surface-starter PACK — not a wikilink to a
|
|
140
202
|
// note that the default seed no longer creates.
|
|
141
|
-
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
142
203
|
expect(gs!.content).not.toContain("[[Surface Starter]]");
|
|
143
|
-
expect(gs!.content).toContain("add-pack
|
|
204
|
+
expect(gs!.content).toContain("add-pack");
|
|
144
205
|
});
|
|
145
206
|
|
|
146
207
|
test("idempotent: a second seed run skips all notes (does not duplicate)", async () => {
|
|
@@ -179,7 +240,8 @@ describe("applySeedPack — surface-starter via add-pack", () => {
|
|
|
179
240
|
expect(result.pack).toBe("surface-starter");
|
|
180
241
|
expect(result.seededNotes).toEqual([SURFACE_STARTER_PATH]);
|
|
181
242
|
expect(result.skippedNotes).toEqual([]);
|
|
182
|
-
|
|
243
|
+
// surface-starter now declares GUIDE_TAG (it's a guide).
|
|
244
|
+
expect(result.tags).toEqual(["guide"]);
|
|
183
245
|
|
|
184
246
|
const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
|
|
185
247
|
expect(ss).not.toBeNull();
|
package/src/onboarding-seed.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Seed the default packs on vault creation.
|
|
3
3
|
*
|
|
4
|
-
* A freshly-created vault gets the `welcome` pack (the
|
|
5
|
-
*
|
|
4
|
+
* A freshly-created vault gets the `welcome` pack (the five-guide welcome ring
|
|
5
|
+
* + the `capture` / `guide` / `pinned` tags the Notes surface expects) and
|
|
6
6
|
* the `getting-started` pack (the AI-legible start-here doctrine) — so the
|
|
7
7
|
* first minute shows a small living graph, and a connected AI can read the
|
|
8
8
|
* guide and help the operator set the vault up.
|
package/src/routes.ts
CHANGED
|
@@ -2089,7 +2089,7 @@ export async function handleTags(
|
|
|
2089
2089
|
|
|
2090
2090
|
// PUT /tags/:name — upsert tag identity row. Body accepts any combination
|
|
2091
2091
|
// of { description, fields, relationships, parent_names }; omitted keys
|
|
2092
|
-
// are preserved, explicit null clears. See
|
|
2092
|
+
// are preserved, explicit null clears. See docs/contracts/tag-data-model.md.
|
|
2093
2093
|
if (req.method === "PUT") {
|
|
2094
2094
|
// Canonical-bare-tag guard (vault#XXX): normalize the upserted tag NAME so
|
|
2095
2095
|
// the existing-field merge read (store.getTagSchema below) and the upsert
|
|
@@ -2114,7 +2114,7 @@ export async function handleTags(
|
|
|
2114
2114
|
* the MCP `update-tag` tool relies on (omitted keys preserved). The
|
|
2115
2115
|
* Schema editor (vault#283) sends the full map + `replace_fields: true`
|
|
2116
2116
|
* so removing a field row actually deletes the field. See
|
|
2117
|
-
*
|
|
2117
|
+
* docs/contracts/tag-data-model.md.
|
|
2118
2118
|
*/
|
|
2119
2119
|
replace_fields?: unknown;
|
|
2120
2120
|
};
|
|
@@ -2211,7 +2211,7 @@ export async function handleTags(
|
|
|
2211
2211
|
// Tag-scoped tokens reference root tags by name; deleting a referenced
|
|
2212
2212
|
// tag would silently orphan the token's allowlist. Fail closed (409)
|
|
2213
2213
|
// and name the offending tokens so the operator can revoke or re-mint
|
|
2214
|
-
// before retrying.
|
|
2214
|
+
// before retrying. docs/contracts/tag-scoped-tokens.md §Dependencies.
|
|
2215
2215
|
const referenced_by = findTokensReferencingTag(store.db, tagName);
|
|
2216
2216
|
if (referenced_by.length > 0) {
|
|
2217
2217
|
return json(
|