@openparachute/vault 0.7.6 → 0.7.7
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/README.md +16 -16
- package/core/src/attachment/policy.test.ts +7 -0
- package/core/src/attachment/policy.ts +9 -0
- package/core/src/attachment-tickets-tool.test.ts +15 -0
- package/core/src/conformance.test.ts +78 -0
- package/core/src/conformance.ts +34 -5
- package/core/src/connection-pragmas.test.ts +27 -1
- package/core/src/core.test.ts +88 -3
- package/core/src/cursor.ts +2 -0
- package/core/src/do-param-cap.test.ts +167 -0
- package/core/src/lede.test.ts +60 -0
- package/core/src/mcp-manifest.ts +15 -1
- package/core/src/mcp.ts +37 -5
- package/core/src/notes.ts +120 -48
- package/core/src/query-operators.ts +87 -5
- package/core/src/query-warnings.ts +11 -3
- package/core/src/schema.ts +32 -0
- package/core/src/seed-packs.ts +74 -5
- package/core/src/sql-in.ts +32 -2
- package/core/src/store.ts +16 -49
- package/core/src/test-preload.ts +48 -3
- package/core/src/types.ts +13 -1
- package/core/src/wikilinks.test.ts +57 -0
- package/core/src/wikilinks.ts +63 -29
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +62 -0
- package/src/attachment-tickets.ts +2 -2
- package/src/cli.ts +36 -8
- package/src/config.ts +34 -1
- package/src/contract-honest-queries.test.ts +33 -1
- package/src/contract-search.test.ts +47 -0
- package/src/embedding/select.ts +16 -3
- package/src/live-match.test.ts +8 -0
- package/src/live-match.ts +15 -0
- package/src/mcp-http.test.ts +12 -0
- package/src/mcp-http.ts +1 -0
- package/src/mcp-tools.ts +51 -17
- package/src/mirror-routes.test.ts +22 -31
- package/src/onboarding-seed.test.ts +68 -0
- package/src/routes.ts +88 -25
- package/src/routing.test.ts +24 -0
- package/src/routing.ts +2 -0
- package/src/subscriptions.ts +18 -2
- package/src/tag-scope-note-tags.test.ts +476 -0
- package/src/tag-scope.ts +73 -5
- package/src/test-home-isolation.test.ts +137 -0
- package/src/test-support/spawn.ts +12 -0
- package/src/transcription/download.test.ts +187 -1
- package/src/transcription/download.ts +149 -2
- package/src/transcription/install-python.test.ts +23 -2
- package/src/transcription/install-python.ts +13 -4
- package/src/vault.test.ts +39 -4
- package/src/version.test.ts +8 -0
- package/src/ws-server.ts +12 -2
package/README.md
CHANGED
|
@@ -949,26 +949,26 @@ Set `PARACHUTE_VAULT_NAME` to auto-create a first vault on startup (name: lowerc
|
|
|
949
949
|
|
|
950
950
|
### Cloud platforms
|
|
951
951
|
|
|
952
|
-
####
|
|
952
|
+
#### A VPS — the tested path
|
|
953
953
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
supervises vault's process, the shared persistent disk holds vault
|
|
960
|
-
state, and module upgrades flow through the admin UI rather than
|
|
961
|
-
separate Render redeploys.
|
|
954
|
+
The current self-host story is a **bun-native install on a plain
|
|
955
|
+
Ubuntu/Debian box**: create the box, open a root shell, paste one line.
|
|
956
|
+
See <https://parachute.computer/start/>, which walks it click by click
|
|
957
|
+
for DigitalOcean and works the same on Hetzner, EC2, or a machine in
|
|
958
|
+
your closet. That's the path the project tests and supports.
|
|
962
959
|
|
|
963
|
-
|
|
960
|
+
The PaaS Blueprints below still work and stay in tree for operators who
|
|
961
|
+
want them, but they're a **legacy** shape — reach for them only if you
|
|
962
|
+
specifically want that platform's ergonomics.
|
|
963
|
+
|
|
964
|
+
#### Render — standalone vault (legacy)
|
|
964
965
|
|
|
965
966
|
The `render.yaml` Blueprint at the repo root deploys vault as its own
|
|
966
|
-
Render web service, separate from hub
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
operators specifically want this shape (vault#341).
|
|
967
|
+
Render web service, separate from hub — useful when you want vault on
|
|
968
|
+
its own container (separate scaling, isolated logs, vault-only deploy
|
|
969
|
+
without a hub). If you're not sure, use the VPS path above. The
|
|
970
|
+
Blueprint stays in tree because some operators specifically want this
|
|
971
|
+
shape (vault#341).
|
|
972
972
|
|
|
973
973
|
Set `PARACHUTE_VAULT_NAME` to auto-create a first vault on startup (name:
|
|
974
974
|
lowercase alphanumeric + hyphens or underscores, 2–32 chars). Without it
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ATTACHMENT_MIME_TYPES,
|
|
5
5
|
sanitizeAttachmentExtension,
|
|
6
6
|
mimeForAttachmentExtension,
|
|
7
|
+
contentTypeForAttachmentPath,
|
|
7
8
|
} from "./policy.ts";
|
|
8
9
|
|
|
9
10
|
describe("sanitizeAttachmentExtension", () => {
|
|
@@ -49,6 +50,12 @@ describe("mimeForAttachmentExtension", () => {
|
|
|
49
50
|
expect(mimeForAttachmentExtension(".azw3")).toBe("application/octet-stream");
|
|
50
51
|
});
|
|
51
52
|
|
|
53
|
+
test("contentTypeForAttachmentPath ignores a caller-asserted active mime (vault#617)", () => {
|
|
54
|
+
expect(contentTypeForAttachmentPath("2026-07-17/photo.png")).toBe("image/png");
|
|
55
|
+
expect(contentTypeForAttachmentPath("notes/evil.png")).toBe("image/png");
|
|
56
|
+
expect(contentTypeForAttachmentPath("notes/unknown.azw3")).toBe("application/octet-stream");
|
|
57
|
+
});
|
|
58
|
+
|
|
52
59
|
test("no MIME entry maps to a browser-active type (INVARIANT)", () => {
|
|
53
60
|
const activeTypes = new Set([
|
|
54
61
|
"text/html",
|
|
@@ -129,3 +129,12 @@ export function sanitizeAttachmentExtension(filename: string): string {
|
|
|
129
129
|
export function mimeForAttachmentExtension(ext: string): string {
|
|
130
130
|
return ATTACHMENT_MIME_TYPES[ext] ?? "application/octet-stream";
|
|
131
131
|
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Download Content-Type for an attachment path (vault#617).
|
|
135
|
+
* Extension wins; the row's caller-asserted `mime_type` is never consulted.
|
|
136
|
+
* Matches REST `GET /storage/<path>` (`src/routes.ts`).
|
|
137
|
+
*/
|
|
138
|
+
export function contentTypeForAttachmentPath(path: string): string {
|
|
139
|
+
return mimeForAttachmentExtension(sanitizeAttachmentExtension(path));
|
|
140
|
+
}
|
|
@@ -245,6 +245,21 @@ describe("request-attachment-download — mint", () => {
|
|
|
245
245
|
expect(stored.attachmentId).toBe(attachment.id);
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
+
test("mime_type is derived from the path, not the caller-asserted row mime (vault#617)", async () => {
|
|
249
|
+
const note = await store.createNote("# T\n", { path: "t7-mime" });
|
|
250
|
+
const attachment = await store.addAttachment(note.id, "2026-07-17/photo.png", "text/html", { size: 42 });
|
|
251
|
+
const provider = new FakeTicketProvider();
|
|
252
|
+
const tools = generateMcpTools(store, {
|
|
253
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
254
|
+
});
|
|
255
|
+
const result = await callTool(findTool(tools, "request-attachment-download"), {
|
|
256
|
+
attachment_id: attachment.id,
|
|
257
|
+
});
|
|
258
|
+
expect(result.mime_type).toBe("image/png");
|
|
259
|
+
const ticketId = result.url.split("/tickets/")[1] as string;
|
|
260
|
+
expect(provider.tickets.get(ticketId)!.mimeType).toBe("image/png");
|
|
261
|
+
});
|
|
262
|
+
|
|
248
263
|
test("missing attachment_id → missing_required_field", async () => {
|
|
249
264
|
const provider = new FakeTicketProvider();
|
|
250
265
|
const tools = generateMcpTools(store, {
|
|
@@ -99,6 +99,84 @@ describe("countConformanceViolations", () => {
|
|
|
99
99
|
expect(r.violating_notes).toBe(5);
|
|
100
100
|
expect(r.sample.length).toBe(2);
|
|
101
101
|
});
|
|
102
|
+
describe("past the queryNotes default LIMIT 100 (vault#592)", () => {
|
|
103
|
+
it("counts every note on a >100-note tag, not just the first page", async () => {
|
|
104
|
+
for (let i = 0; i < 137; i++) {
|
|
105
|
+
await store.createNote(`n${i}`, { tags: ["task"], metadata: {} });
|
|
106
|
+
}
|
|
107
|
+
const r = countConformanceViolations(db, "task", {
|
|
108
|
+
req: { type: "string", required: true },
|
|
109
|
+
});
|
|
110
|
+
expect(r.total_notes).toBe(137);
|
|
111
|
+
expect(r.violating_notes).toBe(137);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("reports total_notes honestly past the cap with no fields proposed", async () => {
|
|
115
|
+
for (let i = 0; i < 137; i++) {
|
|
116
|
+
await store.createNote(`n${i}`, { tags: ["task"], metadata: {} });
|
|
117
|
+
}
|
|
118
|
+
const r = countConformanceViolations(db, "task", {});
|
|
119
|
+
expect(r.total_notes).toBe(137);
|
|
120
|
+
expect(r.violating_notes).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("finds violations that live only past the first 100 notes", async () => {
|
|
124
|
+
// 120 conforming notes first, then 5 violators — under the old LIMIT 100
|
|
125
|
+
// walk the violators were never even looked at.
|
|
126
|
+
for (let i = 0; i < 120; i++) {
|
|
127
|
+
await store.createNote(`ok${i}`, { tags: ["task"], metadata: { req: "here" } });
|
|
128
|
+
}
|
|
129
|
+
for (let i = 0; i < 5; i++) {
|
|
130
|
+
await store.createNote(`bad${i}`, { tags: ["task"], metadata: {} });
|
|
131
|
+
}
|
|
132
|
+
const r = countConformanceViolations(db, "task", {
|
|
133
|
+
req: { type: "string", required: true },
|
|
134
|
+
});
|
|
135
|
+
expect(r.total_notes).toBe(125);
|
|
136
|
+
expect(r.violating_notes).toBe(5);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("counts across a tag hierarchy past the cap (descendants included)", async () => {
|
|
140
|
+
await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
|
|
141
|
+
for (let i = 0; i < 60; i++) {
|
|
142
|
+
await store.createNote(`p${i}`, { tags: ["dev"], metadata: {} });
|
|
143
|
+
}
|
|
144
|
+
for (let i = 0; i < 60; i++) {
|
|
145
|
+
await store.createNote(`c${i}`, { tags: ["dev/log"], metadata: {} });
|
|
146
|
+
}
|
|
147
|
+
const r = countConformanceViolations(db, "dev", {
|
|
148
|
+
req: { type: "string", required: true },
|
|
149
|
+
});
|
|
150
|
+
expect(r.total_notes).toBe(120);
|
|
151
|
+
expect(r.violating_notes).toBe(120);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("counts a note carrying BOTH the tag and a descendant exactly once", async () => {
|
|
155
|
+
await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
|
|
156
|
+
for (let i = 0; i < 105; i++) {
|
|
157
|
+
await store.createNote(`both${i}`, { tags: ["dev", "dev/log"], metadata: {} });
|
|
158
|
+
}
|
|
159
|
+
const r = countConformanceViolations(db, "dev", {
|
|
160
|
+
req: { type: "string", required: true },
|
|
161
|
+
});
|
|
162
|
+
expect(r.total_notes).toBe(105);
|
|
163
|
+
expect(r.violating_notes).toBe(105);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("still caps the sample at sampleLimit while counting the full set", async () => {
|
|
167
|
+
for (let i = 0; i < 137; i++) {
|
|
168
|
+
await store.createNote(`n${i}`, { tags: ["task"], metadata: {} });
|
|
169
|
+
}
|
|
170
|
+
const r = countConformanceViolations(
|
|
171
|
+
db,
|
|
172
|
+
"task",
|
|
173
|
+
{ req: { type: "string", required: true } },
|
|
174
|
+
{ sampleLimit: 3 },
|
|
175
|
+
);
|
|
176
|
+
expect(r.violating_notes).toBe(137);
|
|
177
|
+
expect(r.sample.length).toBe(3);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
102
180
|
});
|
|
103
181
|
|
|
104
182
|
describe("store.countTagConformance", () => {
|
package/core/src/conformance.ts
CHANGED
|
@@ -36,7 +36,8 @@ import {
|
|
|
36
36
|
} from "./schema-defaults.ts";
|
|
37
37
|
import type { TagFieldSchema } from "./tag-schemas.ts";
|
|
38
38
|
import { getTagDescendants, loadTagHierarchy } from "./tag-hierarchy.ts";
|
|
39
|
-
import {
|
|
39
|
+
import { getNotes, getNoteTagsForNotes } from "./notes.ts";
|
|
40
|
+
import { chunkForInClause } from "./sql-in.ts";
|
|
40
41
|
|
|
41
42
|
export interface ConformanceViolation {
|
|
42
43
|
/** Note id of a non-conforming note. */
|
|
@@ -131,6 +132,9 @@ function overlayProposedSchema(
|
|
|
131
132
|
*
|
|
132
133
|
* Pure read; no mutation. Returns 0 violating notes when `proposedFields` is
|
|
133
134
|
* empty (nothing to enforce).
|
|
135
|
+
*
|
|
136
|
+
* The walk is UNBOUNDED — every note carrying the tag or a descendant, not a
|
|
137
|
+
* `queryNotes` page (vault#592). `total_notes` is a real total.
|
|
134
138
|
*/
|
|
135
139
|
export function countConformanceViolations(
|
|
136
140
|
db: Database,
|
|
@@ -155,10 +159,35 @@ export function countConformanceViolations(
|
|
|
155
159
|
const tagSet = Array.from(getTagDescendants(hierarchy, tag));
|
|
156
160
|
if (tagSet.length === 0) return empty;
|
|
157
161
|
|
|
158
|
-
// All notes carrying the tag (or a descendant)
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
+
// All notes carrying the tag (or a descendant) — a note on ANY of these
|
|
163
|
+
// tags is in scope.
|
|
164
|
+
//
|
|
165
|
+
// UNBOUNDED id sweep (vault#592). This used to call
|
|
166
|
+
// `queryNotes(db, { tags: tagSet, tagMatch: "any" })` with no `limit`,
|
|
167
|
+
// which silently inherits that function's default `LIMIT 100`
|
|
168
|
+
// (`notes.ts`) — so on a tag with more than 100 notes both `total_notes`
|
|
169
|
+
// and the violation scan reported the first page only. The operator was
|
|
170
|
+
// told "3 of 100 notes violate this" for a 1,200-note tag, and violations
|
|
171
|
+
// living past note 100 were never even looked at. A conformance count that
|
|
172
|
+
// undercounts is worse than no count: it is the number the operator
|
|
173
|
+
// decides on.
|
|
174
|
+
//
|
|
175
|
+
// Same shape as `SqliteStore.backfillReferenceFieldLinks` (store.ts),
|
|
176
|
+
// which fixed the identical inherited-cap bug on the reference backfill: a
|
|
177
|
+
// direct `note_tags` scan chunked under the IN-param cap, deduped across
|
|
178
|
+
// tags (a note carrying both `dev` and `dev/log` is ONE note), then
|
|
179
|
+
// hydrated in a batch. Still a pure read.
|
|
180
|
+
const idSet = new Set<string>();
|
|
181
|
+
for (const chunk of chunkForInClause(tagSet)) {
|
|
182
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
183
|
+
const rows = db
|
|
184
|
+
.prepare(`SELECT DISTINCT note_id FROM note_tags WHERE tag_name IN (${placeholders})`)
|
|
185
|
+
.all(...chunk) as { note_id: string }[];
|
|
186
|
+
for (const r of rows) idSet.add(r.note_id);
|
|
187
|
+
}
|
|
188
|
+
if (idSet.size === 0) return empty;
|
|
189
|
+
|
|
190
|
+
const notes = getNotes(db, Array.from(idSet));
|
|
162
191
|
if (notes.length === 0) return empty;
|
|
163
192
|
|
|
164
193
|
if (checkedFields.length === 0) {
|
|
@@ -13,7 +13,7 @@ import { Database } from "bun:sqlite";
|
|
|
13
13
|
import { mkdtempSync, rmSync } from "fs";
|
|
14
14
|
import { join } from "path";
|
|
15
15
|
import { tmpdir } from "os";
|
|
16
|
-
import { applyConnectionPragmas, initSchema } from "./schema.ts";
|
|
16
|
+
import { applyConnectionPragmas, BUSY_TIMEOUT_MS, initSchema } from "./schema.ts";
|
|
17
17
|
|
|
18
18
|
let dir: string;
|
|
19
19
|
|
|
@@ -133,6 +133,32 @@ describe("applyConnectionPragmas — :memory: DB", () => {
|
|
|
133
133
|
expect(pragma(db, "foreign_keys")).toBe(1);
|
|
134
134
|
db.close();
|
|
135
135
|
});
|
|
136
|
+
|
|
137
|
+
it("sets busy_timeout on the non-WAL branch too (vault#527)", () => {
|
|
138
|
+
// SQLITE_BUSY is not WAL-specific — a rollback-journal DB serializes even
|
|
139
|
+
// harder. The timeout must not be gated on the WAL success path.
|
|
140
|
+
const db = new Database(":memory:");
|
|
141
|
+
applyConnectionPragmas(db);
|
|
142
|
+
expect(pragma(db, "busy_timeout")).toBe(BUSY_TIMEOUT_MS);
|
|
143
|
+
db.close();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("applyConnectionPragmas — busy_timeout (vault#527)", () => {
|
|
148
|
+
it("sets a non-zero busy_timeout so SQLITE_BUSY retries instead of failing instantly", () => {
|
|
149
|
+
const db = new Database(join(dir, "busy.db"));
|
|
150
|
+
applyConnectionPragmas(db);
|
|
151
|
+
expect(pragma(db, "busy_timeout")).toBe(BUSY_TIMEOUT_MS);
|
|
152
|
+
expect(BUSY_TIMEOUT_MS).toBeGreaterThan(0);
|
|
153
|
+
db.close();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("initSchema's open path carries the busy_timeout", () => {
|
|
157
|
+
const db = new Database(join(dir, "busy-init.db"));
|
|
158
|
+
initSchema(db);
|
|
159
|
+
expect(pragma(db, "busy_timeout")).toBe(BUSY_TIMEOUT_MS);
|
|
160
|
+
db.close();
|
|
161
|
+
});
|
|
136
162
|
});
|
|
137
163
|
|
|
138
164
|
describe("applyConnectionPragmas — WAL-unsupported on-disk filesystem (simulated)", () => {
|
package/core/src/core.test.ts
CHANGED
|
@@ -2334,6 +2334,25 @@ describe("searchNotes", async () => {
|
|
|
2334
2334
|
expect(results[0].tags).toContain("daily");
|
|
2335
2335
|
});
|
|
2336
2336
|
|
|
2337
|
+
it("filters search by excludeTags (vault#647)", async () => {
|
|
2338
|
+
await store.createNote("Flagstaff keep", { tags: ["daily"] });
|
|
2339
|
+
await store.createNote("Flagstaff drop", { tags: ["daily", "archived"] });
|
|
2340
|
+
|
|
2341
|
+
const results = await store.searchNotes("Flagstaff", { tags: ["daily"], excludeTags: ["archived"] });
|
|
2342
|
+
expect(results).toHaveLength(1);
|
|
2343
|
+
expect(results[0].content).toContain("keep");
|
|
2344
|
+
expect(results[0].tags).not.toContain("archived");
|
|
2345
|
+
});
|
|
2346
|
+
|
|
2347
|
+
it("filters search by dateFrom (vault#647)", async () => {
|
|
2348
|
+
await store.createNote("Flagstaff recent", { tags: ["daily"], created_at: "2026-07-01T00:00:00.000Z" });
|
|
2349
|
+
await store.createNote("Flagstaff old", { tags: ["daily"], created_at: "2023-06-01T00:00:00.000Z" });
|
|
2350
|
+
|
|
2351
|
+
const results = await store.searchNotes("Flagstaff", { tags: ["daily"], dateFrom: "2026-06-01" });
|
|
2352
|
+
expect(results).toHaveLength(1);
|
|
2353
|
+
expect(results[0].content).toContain("recent");
|
|
2354
|
+
});
|
|
2355
|
+
|
|
2337
2356
|
it("returns empty for no match", async () => {
|
|
2338
2357
|
await store.createNote("Hello world");
|
|
2339
2358
|
const results = await store.searchNotes("nonexistent");
|
|
@@ -4339,7 +4358,38 @@ describe("MCP tools", async () => {
|
|
|
4339
4358
|
}) as any;
|
|
4340
4359
|
const page = Array.isArray(pageResult) ? pageResult : pageResult.notes;
|
|
4341
4360
|
expect(page).toHaveLength(2);
|
|
4342
|
-
|
|
4361
|
+
// vault#601: explicit offset is deliberate paging — no truncated warning.
|
|
4362
|
+
expect(pageResult.warnings?.some((w: any) => w.code === "truncated") ?? false).toBe(false);
|
|
4363
|
+
});
|
|
4364
|
+
|
|
4365
|
+
it("query-notes exclude_path_prefix drops matching paths and keeps pathless notes (vault#628)", async () => {
|
|
4366
|
+
await store.createNote("user", { path: "Projects/a" });
|
|
4367
|
+
await store.createNote("sys", { path: ".parachute/notes/settings" });
|
|
4368
|
+
await store.createNote("bare"); // no path
|
|
4369
|
+
const tools = generateMcpTools(store);
|
|
4370
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4371
|
+
const r = await query.execute({ exclude_path_prefix: ".parachute/", include_content: true }) as any[];
|
|
4372
|
+
const contents = r.map((n: any) => n.content).sort();
|
|
4373
|
+
expect(contents).toEqual(["bare", "user"]);
|
|
4374
|
+
});
|
|
4375
|
+
|
|
4376
|
+
it("query-notes search composes with exclude_path_prefix (vault#628)", async () => {
|
|
4377
|
+
// The MCP search branch enumerates QueryOpts fields rather than
|
|
4378
|
+
// spreading them. After #656, REST search picks up exclude_path_prefix
|
|
4379
|
+
// via ...parsed.queryOpts; omitting the field here is the silent-drop
|
|
4380
|
+
// class this lane kills.
|
|
4381
|
+
await store.createNote("unique-628-search-term keep", { path: "Projects/a" });
|
|
4382
|
+
await store.createNote("unique-628-search-term drop", { path: ".parachute/notes/settings" });
|
|
4383
|
+
const tools = generateMcpTools(store);
|
|
4384
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4385
|
+
const r = await query.execute({
|
|
4386
|
+
search: "unique-628-search-term",
|
|
4387
|
+
exclude_path_prefix: ".parachute/",
|
|
4388
|
+
include_content: true,
|
|
4389
|
+
}) as any[];
|
|
4390
|
+
const contents = r.map((n: any) => n.content);
|
|
4391
|
+
expect(contents).toContain("unique-628-search-term keep");
|
|
4392
|
+
expect(contents).not.toContain("unique-628-search-term drop");
|
|
4343
4393
|
});
|
|
4344
4394
|
|
|
4345
4395
|
it("query-notes full-text search works", async () => {
|
|
@@ -4411,13 +4461,17 @@ describe("MCP tools", async () => {
|
|
|
4411
4461
|
expect(b.metadata).toEqual({ summary: "b" }); // status absent → omitted
|
|
4412
4462
|
});
|
|
4413
4463
|
|
|
4414
|
-
it("query-notes include_metadata: string[] with no matching fields
|
|
4464
|
+
it("query-notes include_metadata: string[] with no matching fields keeps metadata: {} (vault#600)", async () => {
|
|
4465
|
+
// V1.1 invariant: rowToNote always emits `metadata: {}`. The projection
|
|
4466
|
+
// branch used to set `metadata: undefined` when no requested keys
|
|
4467
|
+
// matched, so the key vanished from the wire again.
|
|
4415
4468
|
await store.createNote("A", { tags: ["no-match-meta"], metadata: { summary: "a" } });
|
|
4416
4469
|
const tools = generateMcpTools(store);
|
|
4417
4470
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4418
4471
|
const result = await query.execute({ tag: "no-match-meta", include_metadata: ["nonexistent"] }) as any[];
|
|
4419
4472
|
expect(result).toHaveLength(1);
|
|
4420
|
-
expect(result[0]
|
|
4473
|
+
expect(result[0]).toHaveProperty("metadata", {});
|
|
4474
|
+
expect(JSON.parse(JSON.stringify(result[0]))).toHaveProperty("metadata", {});
|
|
4421
4475
|
});
|
|
4422
4476
|
|
|
4423
4477
|
it("query-notes near param scopes results to graph neighborhood", async () => {
|
|
@@ -5386,6 +5440,37 @@ describe("MCP tools", async () => {
|
|
|
5386
5440
|
expect(r[0].content).toBe("a");
|
|
5387
5441
|
});
|
|
5388
5442
|
|
|
5443
|
+
it("query-notes search composes with exclude_tags (vault#647)", async () => {
|
|
5444
|
+
// Pre-fix the FTS branch forwarded only `{tags, limit, expand, mode, sort}`
|
|
5445
|
+
// so exclude_tags was silently dropped: the response looked like a real
|
|
5446
|
+
// result set that simply answered a different question.
|
|
5447
|
+
await store.createNote("widget keep", { tags: ["email"] });
|
|
5448
|
+
await store.createNote("widget drop", { tags: ["email", "urgent"] });
|
|
5449
|
+
const tools = generateMcpTools(store);
|
|
5450
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
5451
|
+
const r = await queryNotes.execute({
|
|
5452
|
+
search: "widget",
|
|
5453
|
+
tag: "email",
|
|
5454
|
+
exclude_tags: "urgent",
|
|
5455
|
+
include_content: true,
|
|
5456
|
+
}) as any[];
|
|
5457
|
+
expect(r.map((n: any) => n.content)).toEqual(["widget keep"]);
|
|
5458
|
+
});
|
|
5459
|
+
|
|
5460
|
+
it("query-notes search composes with date_from (vault#647)", async () => {
|
|
5461
|
+
await store.createNote("widget recent", { tags: ["email"], created_at: "2026-07-01T00:00:00.000Z" });
|
|
5462
|
+
await store.createNote("widget old", { tags: ["email"], created_at: "2023-06-01T00:00:00.000Z" });
|
|
5463
|
+
const tools = generateMcpTools(store);
|
|
5464
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
5465
|
+
const r = await queryNotes.execute({
|
|
5466
|
+
search: "widget",
|
|
5467
|
+
tag: "email",
|
|
5468
|
+
date_from: "2026-06-01",
|
|
5469
|
+
include_content: true,
|
|
5470
|
+
}) as any[];
|
|
5471
|
+
expect(r.map((n: any) => n.content)).toEqual(["widget recent"]);
|
|
5472
|
+
});
|
|
5473
|
+
|
|
5389
5474
|
it("query-notes routes through store.queryNotes so tag-hierarchy expansion fires", async () => {
|
|
5390
5475
|
// `voice` and `text` declare "manual" as their parent via the v14
|
|
5391
5476
|
// tags.parent_names column. A query for `tag: "manual"` should match
|
package/core/src/cursor.ts
CHANGED
|
@@ -176,6 +176,7 @@ export interface QueryHashInputs {
|
|
|
176
176
|
hasBrokenLinks?: boolean;
|
|
177
177
|
path?: string;
|
|
178
178
|
pathPrefix?: string;
|
|
179
|
+
excludePathPrefix?: string[];
|
|
179
180
|
extension?: string | string[];
|
|
180
181
|
ids?: string[];
|
|
181
182
|
metadata?: Record<string, unknown>;
|
|
@@ -249,6 +250,7 @@ function canonicalize(value: unknown): unknown {
|
|
|
249
250
|
const ORDER_IRRELEVANT_STRING_ARRAYS = new Set([
|
|
250
251
|
"tags",
|
|
251
252
|
"excludeTags",
|
|
253
|
+
"excludePathPrefix",
|
|
252
254
|
"ids",
|
|
253
255
|
"extension",
|
|
254
256
|
]);
|
|
@@ -133,6 +133,173 @@ describe("id-list query paths stay under the DO 100-bound-param cap", () => {
|
|
|
133
133
|
expect(notes.length).toBe(0);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
describe("metadata in / not_in operators (vault#536)", () => {
|
|
137
|
+
/**
|
|
138
|
+
* `in` / `not_in` used to build `meta_<field> IN (?, ?, …)` — one bound
|
|
139
|
+
* param per user-supplied value, with no chunking and no json_each. It's
|
|
140
|
+
* an embedded filter inside a paginated statement, so chunking isn't
|
|
141
|
+
* available (it would break the shared LIMIT/OFFSET window); the fix is
|
|
142
|
+
* the single-json_each-param shape #535 introduced for `near`.
|
|
143
|
+
*/
|
|
144
|
+
beforeEach(async () => {
|
|
145
|
+
await store.upsertTagRecord("doc", {
|
|
146
|
+
fields: { rank: { type: "integer", indexed: true } },
|
|
147
|
+
});
|
|
148
|
+
for (let i = 0; i < 250; i++) {
|
|
149
|
+
await store.updateNote(`n${pad(i)}`, { tags: ["doc"], metadata: { rank: i } });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("a >100-value `in` binds one json_each param, not one per value", async () => {
|
|
154
|
+
const wanted = Array.from({ length: 150 }, (_, i) => i);
|
|
155
|
+
const spy = installBindSpy(db);
|
|
156
|
+
let notes;
|
|
157
|
+
try {
|
|
158
|
+
notes = await store.queryNotes({
|
|
159
|
+
metadata: { rank: { in: wanted } },
|
|
160
|
+
limit: 1000,
|
|
161
|
+
sort: "asc",
|
|
162
|
+
});
|
|
163
|
+
} finally {
|
|
164
|
+
spy.restore();
|
|
165
|
+
}
|
|
166
|
+
expect(notes.length).toBe(150);
|
|
167
|
+
expect(spy.usedJsonEach()).toBe(true);
|
|
168
|
+
expect(spy.maxPlaceholderInBinds()).toBeLessThanOrEqual(IN_PARAM_CHUNK);
|
|
169
|
+
expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("a >100-value `not_in` binds one json_each param too", async () => {
|
|
173
|
+
const excluded = Array.from({ length: 150 }, (_, i) => i);
|
|
174
|
+
const spy = installBindSpy(db);
|
|
175
|
+
let notes;
|
|
176
|
+
try {
|
|
177
|
+
notes = await store.queryNotes({
|
|
178
|
+
metadata: { rank: { not_in: excluded } },
|
|
179
|
+
limit: 1000,
|
|
180
|
+
sort: "asc",
|
|
181
|
+
});
|
|
182
|
+
} finally {
|
|
183
|
+
spy.restore();
|
|
184
|
+
}
|
|
185
|
+
// 250 notes, 150 excluded → the 100 with rank >= 150.
|
|
186
|
+
expect(notes.length).toBe(100);
|
|
187
|
+
expect(spy.usedJsonEach()).toBe(true);
|
|
188
|
+
expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("stays under the cap alongside LIMIT/OFFSET pagination params", async () => {
|
|
192
|
+
const wanted = Array.from({ length: 200 }, (_, i) => i);
|
|
193
|
+
const spy = installBindSpy(db);
|
|
194
|
+
let notes;
|
|
195
|
+
try {
|
|
196
|
+
notes = await store.queryNotes({
|
|
197
|
+
metadata: { rank: { in: wanted } },
|
|
198
|
+
limit: 25,
|
|
199
|
+
offset: 10,
|
|
200
|
+
sort: "asc",
|
|
201
|
+
});
|
|
202
|
+
} finally {
|
|
203
|
+
spy.restore();
|
|
204
|
+
}
|
|
205
|
+
expect(notes.length).toBe(25);
|
|
206
|
+
expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// -------------------------------------------------------------------------
|
|
211
|
+
// Cross-type affinity: `in`/`not_in` route the value-set through a
|
|
212
|
+
// `json_each` subquery, which — unlike a placeholder `IN (?, …)` list — does
|
|
213
|
+
// NOT apply the left column's type affinity to each candidate. The fix CASTs
|
|
214
|
+
// the set to the column's declared storage type so numeric-vs-TEXT (and the
|
|
215
|
+
// reverse) matches stay identical to the pre-#536 placeholder-list path
|
|
216
|
+
// (vault#676). These probes send the exact shapes the reviewer flagged and
|
|
217
|
+
// pin the row-sets that a naive json_each would silently narrow.
|
|
218
|
+
// -------------------------------------------------------------------------
|
|
219
|
+
describe("in / not_in preserve cross-type affinity (vault#676)", () => {
|
|
220
|
+
it("string-typed indexed column matches BARE NUMERIC in/not_in values", async () => {
|
|
221
|
+
// `version` is a TEXT-affinity column; the stored values are strings.
|
|
222
|
+
await store.upsertTagRecord("release", {
|
|
223
|
+
fields: { version: { type: "string", indexed: true } },
|
|
224
|
+
});
|
|
225
|
+
await store.updateNote("n00000", { tags: ["release"], metadata: { version: "5" } });
|
|
226
|
+
await store.updateNote("n00001", { tags: ["release"], metadata: { version: "6" } });
|
|
227
|
+
await store.updateNote("n00002", { tags: ["release"], metadata: { version: "7" } });
|
|
228
|
+
|
|
229
|
+
// The reviewer's probe: bare JSON numbers against a string column.
|
|
230
|
+
const inHit = await store.queryNotes({
|
|
231
|
+
metadata: { version: { in: [5, 6] } },
|
|
232
|
+
limit: 100,
|
|
233
|
+
sort: "asc",
|
|
234
|
+
});
|
|
235
|
+
expect(inHit.map((n) => n.id)).toEqual(["n00000", "n00001"]);
|
|
236
|
+
|
|
237
|
+
// Same set as JSON strings must return the SAME rows (the control the
|
|
238
|
+
// placeholder-list path always satisfied).
|
|
239
|
+
const inHitStr = await store.queryNotes({
|
|
240
|
+
metadata: { version: { in: ["5", "6"] } },
|
|
241
|
+
limit: 100,
|
|
242
|
+
sort: "asc",
|
|
243
|
+
});
|
|
244
|
+
expect(inHitStr.map((n) => n.id)).toEqual(inHit.map((n) => n.id));
|
|
245
|
+
|
|
246
|
+
// not_in with bare numbers must exclude the string rows `"5"`/`"6"` and
|
|
247
|
+
// keep `"7"` — the affinity-reconciled mirror of the `in` probe. (Rows
|
|
248
|
+
// with no `version` at all have a NULL column and legitimately match
|
|
249
|
+
// not_in, so assert membership rather than an exact set.)
|
|
250
|
+
const notInHit = await store.queryNotes({
|
|
251
|
+
metadata: { version: { not_in: [5, 6] } },
|
|
252
|
+
limit: 1000,
|
|
253
|
+
sort: "asc",
|
|
254
|
+
});
|
|
255
|
+
const notInIds = new Set(notInHit.map((n) => n.id));
|
|
256
|
+
expect(notInIds.has("n00000")).toBe(false); // version "5" — excluded
|
|
257
|
+
expect(notInIds.has("n00001")).toBe(false); // version "6" — excluded
|
|
258
|
+
expect(notInIds.has("n00002")).toBe(true); // version "7" — kept
|
|
259
|
+
|
|
260
|
+
// The bare-number not_in must exclude exactly the same rows a
|
|
261
|
+
// string-valued not_in does (the placeholder-list contract).
|
|
262
|
+
const notInHitStr = await store.queryNotes({
|
|
263
|
+
metadata: { version: { not_in: ["5", "6"] } },
|
|
264
|
+
limit: 1000,
|
|
265
|
+
sort: "asc",
|
|
266
|
+
});
|
|
267
|
+
expect(notInHitStr.map((n) => n.id)).toEqual(notInHit.map((n) => n.id));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("integer-typed indexed column matches STRING in/not_in values (reverse direction)", async () => {
|
|
271
|
+
// `rank` is an INTEGER-affinity column populated 0..249; string-form
|
|
272
|
+
// membership (e.g. `"5"`) must still match under both shapes.
|
|
273
|
+
await store.upsertTagRecord("doc", {
|
|
274
|
+
fields: { rank: { type: "integer", indexed: true } },
|
|
275
|
+
});
|
|
276
|
+
for (let i = 0; i < 250; i++) {
|
|
277
|
+
await store.updateNote(`n${pad(i)}`, { tags: ["doc"], metadata: { rank: i } });
|
|
278
|
+
}
|
|
279
|
+
const inHit = await store.queryNotes({
|
|
280
|
+
metadata: { rank: { in: ["5", "6"] } },
|
|
281
|
+
limit: 100,
|
|
282
|
+
sort: "asc",
|
|
283
|
+
});
|
|
284
|
+
const inHitNum = await store.queryNotes({
|
|
285
|
+
metadata: { rank: { in: [5, 6] } },
|
|
286
|
+
limit: 100,
|
|
287
|
+
sort: "asc",
|
|
288
|
+
});
|
|
289
|
+
expect(inHit.map((n) => n.id)).toEqual(["n00005", "n00006"]);
|
|
290
|
+
expect(inHit.map((n) => n.id)).toEqual(inHitNum.map((n) => n.id));
|
|
291
|
+
|
|
292
|
+
const notInHit = await store.queryNotes({
|
|
293
|
+
metadata: { rank: { not_in: ["5"] } },
|
|
294
|
+
limit: 1000,
|
|
295
|
+
sort: "asc",
|
|
296
|
+
});
|
|
297
|
+
// 250 rows, only rank 5 excluded.
|
|
298
|
+
expect(notInHit.length).toBe(249);
|
|
299
|
+
expect(notInHit.some((n) => n.id === "n00005")).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
136
303
|
describe("export a >100-note vault", () => {
|
|
137
304
|
let outDir: string;
|
|
138
305
|
beforeEach(() => { outDir = mkdtempSync(join(tmpdir(), "do-cap-export-")); });
|