@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/core/src/schema.ts
CHANGED
|
@@ -388,6 +388,9 @@ CREATE INDEX IF NOT EXISTS idx_mcp_mint_ledger_session ON mcp_mint_ledger(parent
|
|
|
388
388
|
* on every open. Migrations occasionally disable it transiently (see
|
|
389
389
|
* migrateToV14's BEGIN IMMEDIATE block); the boot path re-enables.
|
|
390
390
|
*
|
|
391
|
+
* `busy_timeout` is likewise per-connection. See {@link BUSY_TIMEOUT_MS} for
|
|
392
|
+
* why a zero default is wrong for a daemon-plus-CLI deployment.
|
|
393
|
+
*
|
|
391
394
|
* WAL requires a filesystem that supports memory-mapped shared-memory
|
|
392
395
|
* (the `-shm` sidecar). NFS, some FUSE mounts, and a few Docker volume
|
|
393
396
|
* drivers don't qualify and silently fall back to the prior journal mode
|
|
@@ -397,6 +400,30 @@ CREATE INDEX IF NOT EXISTS idx_mcp_mint_ledger_session ON mcp_mint_ledger(parent
|
|
|
397
400
|
*/
|
|
398
401
|
const APPLY_PRAGMAS_LOGGED = new WeakSet<Database>();
|
|
399
402
|
|
|
403
|
+
/**
|
|
404
|
+
* How long SQLite parks on a locked database before giving up with
|
|
405
|
+
* SQLITE_BUSY (vault#527).
|
|
406
|
+
*
|
|
407
|
+
* The default is 0 — a contended write fails INSTANTLY. That's the wrong
|
|
408
|
+
* default for this product's actual deployment shape: the daemon holds a live
|
|
409
|
+
* WAL connection on :1940 while the operator runs a CLI command (`add-pack`,
|
|
410
|
+
* `schema migrate-field`, an import) against the same file from another
|
|
411
|
+
* process. Under WAL, readers never block, but two WRITERS still serialize —
|
|
412
|
+
* and with no timeout the CLI's write loses the instant it overlaps the
|
|
413
|
+
* daemon's, surfacing as an error whose only remedy is "please re-run".
|
|
414
|
+
*
|
|
415
|
+
* Five seconds is far longer than any single vault write (these are
|
|
416
|
+
* millisecond-scale row inserts) so it costs nothing on an uncontended box,
|
|
417
|
+
* while comfortably covering an overlapping daemon transaction. It is not a
|
|
418
|
+
* substitute for retry logic on a genuinely long-held lock — it's the
|
|
419
|
+
* difference between "wait your turn" and "fail immediately", which is the
|
|
420
|
+
* behaviour a single-file embedded database should have had from the start.
|
|
421
|
+
*
|
|
422
|
+
* Per-connection (not persistent), so it must be re-applied on every open —
|
|
423
|
+
* which is why it lives here rather than in a migration.
|
|
424
|
+
*/
|
|
425
|
+
export const BUSY_TIMEOUT_MS = 5000;
|
|
426
|
+
|
|
400
427
|
export interface ConnectionPragmaResult {
|
|
401
428
|
/** True when the connection ended up in WAL mode. False means the FS doesn't support WAL. */
|
|
402
429
|
wal: boolean;
|
|
@@ -457,6 +484,11 @@ export function applyConnectionPragmas(db: Database): ConnectionPragmaResult {
|
|
|
457
484
|
|
|
458
485
|
try { db.exec("PRAGMA foreign_keys = ON"); } catch {}
|
|
459
486
|
|
|
487
|
+
// Deliberately NOT gated on the WAL branch: SQLITE_BUSY is not WAL-specific,
|
|
488
|
+
// and a rollback-journal DB (the NFS/FUSE fallback above) serializes harder,
|
|
489
|
+
// so it needs the grace more, not less.
|
|
490
|
+
try { db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`); } catch {}
|
|
491
|
+
|
|
460
492
|
return { wal, journalMode };
|
|
461
493
|
}
|
|
462
494
|
|
package/core/src/seed-packs.ts
CHANGED
|
@@ -780,6 +780,32 @@ matter to the operator; surface the indexed fields they filter on. If the vault
|
|
|
780
780
|
doesn't yet have the structure a surface wants, that's a signal to design tags +
|
|
781
781
|
schemas first.
|
|
782
782
|
|
|
783
|
+
## Deploying your surface
|
|
784
|
+
|
|
785
|
+
A surface is a **static build** — there's no server to run. \`bun run build\`
|
|
786
|
+
produces a \`dist/\` of plain HTML/JS/CSS that any static host serves: GitHub
|
|
787
|
+
Pages, Cloudflare Pages, Netlify, an S3 bucket.
|
|
788
|
+
|
|
789
|
+
For **GitHub Pages**, push the BUILT FILES to a \`gh-pages\` branch (or a
|
|
790
|
+
\`docs/\` folder on your default branch) and point Settings → Pages at it.
|
|
791
|
+
Deliberately: **don't reach for an Actions workflow.** Some agent GitHub
|
|
792
|
+
integrations can't write \`.github/workflows/*\` — the push comes back 403 —
|
|
793
|
+
and a static build doesn't need CI at all. Build locally, publish the output.
|
|
794
|
+
|
|
795
|
+
Two things must line up or the deployed surface will load and then fail to
|
|
796
|
+
sign in:
|
|
797
|
+
|
|
798
|
+
- the **hub origin** your surface points at has to be reachable from wherever
|
|
799
|
+
the browser is (a \`localhost\` hub won't answer a page served from GitHub
|
|
800
|
+
Pages);
|
|
801
|
+
- that surface's **origin must be trusted by the hub** for CORS + as an OAuth
|
|
802
|
+
redirect origin.
|
|
803
|
+
|
|
804
|
+
Both trace back to the same place the \`createVaultSurface\` config above does:
|
|
805
|
+
\`vault-info\`'s **"This vault's coordinates"** block reports this vault's hub
|
|
806
|
+
origin and name. If sign-in fails from the deployed URL but works from
|
|
807
|
+
\`localhost\`, suspect the origin allowlist before the code.
|
|
808
|
+
|
|
783
809
|
## Adapt this note
|
|
784
810
|
|
|
785
811
|
When you build a surface for this vault, record it here: what it's for, the
|
|
@@ -1045,6 +1071,12 @@ export interface ApplySeedPackResult {
|
|
|
1045
1071
|
* rows — so a re-run can never duplicate, and never clobbers a note the
|
|
1046
1072
|
* operator/AI has since edited or recreated.
|
|
1047
1073
|
*
|
|
1074
|
+
* The per-note check is check-then-create, so two appliers running at once
|
|
1075
|
+
* can both see "absent" and both try to create. The loser's path-UNIQUE
|
|
1076
|
+
* rejection is caught and reported as a SKIP (vault#527) — same outcome the
|
|
1077
|
+
* non-racing branch reports, because the note it would have written is there.
|
|
1078
|
+
* Only a path conflict is absorbed; see `isPathConflict`.
|
|
1079
|
+
*
|
|
1048
1080
|
* **Tag description preservation** (Aaron-ratified 2026-07-17): a pack writes
|
|
1049
1081
|
* a tag's `description` only when (a) the tag has no prior description (new
|
|
1050
1082
|
* tag, or an existing bare row nothing ever described), or (b) the prior
|
|
@@ -1112,13 +1144,50 @@ export async function applySeedPack(
|
|
|
1112
1144
|
result.skippedNotes.push(note.path);
|
|
1113
1145
|
continue;
|
|
1114
1146
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1147
|
+
try {
|
|
1148
|
+
await store.createNote(note.content, {
|
|
1149
|
+
path: note.path,
|
|
1150
|
+
tags: note.tags,
|
|
1151
|
+
metadata: note.metadata,
|
|
1152
|
+
});
|
|
1153
|
+
} catch (err) {
|
|
1154
|
+
// Lost a check-then-create race (vault#527). The `getNoteByPath` above
|
|
1155
|
+
// said absent, but another applier committed that path before our
|
|
1156
|
+
// insert landed, and the path UNIQUE index rejected us. The OUTCOME is
|
|
1157
|
+
// the one this applier already promises — a note exists at that path
|
|
1158
|
+
// and we didn't clobber it — so report it the same way the non-racing
|
|
1159
|
+
// branch does, as a skip, rather than aborting the pack mid-way with a
|
|
1160
|
+
// raw constraint error the operator can only answer by re-running.
|
|
1161
|
+
//
|
|
1162
|
+
// Narrow by design: ONLY a path conflict is absorbed. Any other create
|
|
1163
|
+
// failure still propagates, per this function's "errors propagate"
|
|
1164
|
+
// contract above.
|
|
1165
|
+
if (!isPathConflict(err)) throw err;
|
|
1166
|
+
result.skippedNotes.push(note.path);
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1120
1169
|
result.seededNotes.push(note.path);
|
|
1121
1170
|
}
|
|
1122
1171
|
|
|
1123
1172
|
return result;
|
|
1124
1173
|
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Is this the store's "a note already uses that path" rejection?
|
|
1177
|
+
*
|
|
1178
|
+
* Duck-typed on the STABLE `error_type` / `code` contract (vault#554) rather
|
|
1179
|
+
* than `instanceof PathConflictError`, because this module is deliberately
|
|
1180
|
+
* import-type-only — it carries no runtime dependency on `notes.ts`/`bun:sqlite`
|
|
1181
|
+
* so the cloud Durable Object can share it. Both fields are pinned public API
|
|
1182
|
+
* on the self-host class and on the REST/MCP error mapping, so matching them
|
|
1183
|
+
* is not a guess about internals.
|
|
1184
|
+
*/
|
|
1185
|
+
function isPathConflict(err: unknown): boolean {
|
|
1186
|
+
if (typeof err !== "object" || err === null) return false;
|
|
1187
|
+
const e = err as { error_type?: unknown; code?: unknown; name?: unknown };
|
|
1188
|
+
return (
|
|
1189
|
+
e.error_type === "path_conflict" ||
|
|
1190
|
+
e.code === "PATH_CONFLICT" ||
|
|
1191
|
+
e.name === "PathConflictError"
|
|
1192
|
+
);
|
|
1193
|
+
}
|
package/core/src/sql-in.ts
CHANGED
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
* and is the conservative floor everywhere.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
import type { SqliteType } from "./indexed-fields.js";
|
|
40
|
+
|
|
39
41
|
/**
|
|
40
42
|
* Chunk size for standalone `IN (?, ?, …)` id-lists. 90 leaves headroom
|
|
41
43
|
* under the DO 100-param cap for statements whose only bound params are the
|
|
@@ -70,7 +72,35 @@ export function chunkForInClause<T>(items: readonly T[], size = IN_PARAM_CHUNK):
|
|
|
70
72
|
*/
|
|
71
73
|
export const IN_VIA_JSON_EACH = "(SELECT value FROM json_each(?))";
|
|
72
74
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Like {@link IN_VIA_JSON_EACH}, but CASTs each `json_each` value to the
|
|
77
|
+
* target column's declared SQLite storage type before the `IN` comparison.
|
|
78
|
+
*
|
|
79
|
+
* A placeholder list `IN (?, ?, …)` applied the LEFT column's type affinity
|
|
80
|
+
* to each bound value for free — a bound `5` compared against a TEXT-affinity
|
|
81
|
+
* column matched the stored `'5'`. Values arriving out of a `json_each`
|
|
82
|
+
* subquery do NOT get that conversion, so a numeric `5` from `{ in: [5] }`
|
|
83
|
+
* silently stopped matching a TEXT-affinity indexed column (vault#676). The
|
|
84
|
+
* explicit CAST restores the old cross-type match in BOTH directions
|
|
85
|
+
* (TEXT column / numeric value AND INTEGER column / string value), mirroring
|
|
86
|
+
* exactly what the placeholder list got implicitly. `sqliteType` is a fixed
|
|
87
|
+
* enum ("TEXT" | "INTEGER") from `indexed_fields`, never user input, so
|
|
88
|
+
* interpolating it into the SQL is safe.
|
|
89
|
+
*/
|
|
90
|
+
export function inViaJsonEachCast(sqliteType: SqliteType): string {
|
|
91
|
+
return `(SELECT CAST(value AS ${sqliteType}) FROM json_each(?))`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Serialize a value-set for the single {@link IN_VIA_JSON_EACH} bound param.
|
|
96
|
+
*
|
|
97
|
+
* Accepts `boolean`/`null` as well as ids because the metadata `in`/`not_in`
|
|
98
|
+
* operators route through this too (vault#536), and those take any primitive
|
|
99
|
+
* the caller can put in a metadata field. `bigint` is deliberately NOT
|
|
100
|
+
* accepted — `JSON.stringify` throws on it — so callers must narrow first.
|
|
101
|
+
*/
|
|
102
|
+
export function jsonEachParam(
|
|
103
|
+
values: readonly (string | number | boolean | null)[],
|
|
104
|
+
): string {
|
|
75
105
|
return JSON.stringify(values);
|
|
76
106
|
}
|
package/core/src/store.ts
CHANGED
|
@@ -832,55 +832,22 @@ export class BunSqliteStore implements Store {
|
|
|
832
832
|
return { ...opts, _tagsExpanded: expanded } as QueryOpts;
|
|
833
833
|
}
|
|
834
834
|
|
|
835
|
-
async searchNotes(query: string, opts?:
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
// search hits the full corpus and untagged notes are reachable. It fires
|
|
852
|
-
// only on the subtypes/both axes (mirrors `expandQueryTags`).
|
|
853
|
-
if (opts?.tags && opts.tags.length > 0) {
|
|
854
|
-
const mode: TagExpandMode = opts.expand ?? DEFAULT_TAG_EXPAND_MODE;
|
|
855
|
-
const subtypeAxis = mode === "subtypes" || mode === "both";
|
|
856
|
-
const hierarchy = this.getTagHierarchy();
|
|
857
|
-
if (subtypeAxis && hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
|
|
858
|
-
const { tags: _drop, expand: _e, ..._rest } = opts;
|
|
859
|
-
return noteOps.searchNotes(this.db, query, _rest);
|
|
860
|
-
}
|
|
861
|
-
// Subtypes fast-path: with no declared hierarchy there are no
|
|
862
|
-
// descendants, so the tags pass through unchanged (byte-identical to
|
|
863
|
-
// pre-axis behavior). `exact` likewise needs no expansion.
|
|
864
|
-
// Namespace/both must still run (lexical expansion is independent of
|
|
865
|
-
// `parent_names`).
|
|
866
|
-
const skipExpansion =
|
|
867
|
-
mode === "exact" || (mode === "subtypes" && hierarchy.childrenOf.size === 0);
|
|
868
|
-
if (!skipExpansion) {
|
|
869
|
-
const expanded = new Set<string>();
|
|
870
|
-
for (const t of opts.tags) {
|
|
871
|
-
for (const x of getTagExpansion(hierarchy, t, mode)) expanded.add(x);
|
|
872
|
-
}
|
|
873
|
-
const { expand: _e, ..._rest } = opts;
|
|
874
|
-
return noteOps.searchNotes(this.db, query, { ..._rest, tags: Array.from(expanded) });
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
// Strip the internal `expand` before passing to noteOps (it has no field
|
|
878
|
-
// for it; harmless but keep the boundary clean).
|
|
879
|
-
if (opts && "expand" in opts) {
|
|
880
|
-
const { expand: _e, ..._rest } = opts;
|
|
881
|
-
return noteOps.searchNotes(this.db, query, _rest);
|
|
882
|
-
}
|
|
883
|
-
return noteOps.searchNotes(this.db, query, opts);
|
|
835
|
+
async searchNotes(query: string, opts?: QueryOpts & { mode?: SearchMode }): Promise<Note[]> {
|
|
836
|
+
// Same bare-tag strip + hierarchy expansion queryNotes uses, so
|
|
837
|
+
// `search` + `tag: "manual"` still matches declared descendants
|
|
838
|
+
// (vault#227) and `#tag` / `tag` resolve identically. Default
|
|
839
|
+
// `tagMatch` to `"any"` when the caller didn't set it — historical FTS
|
|
840
|
+
// tag semantics are a single IN (...) ("any tag matches"), which also
|
|
841
|
+
// makes `_default` collapse drop the whole tag filter (OR + universal
|
|
842
|
+
// parent = every note).
|
|
843
|
+
const incoming = opts ?? {};
|
|
844
|
+
const withTagMatch: QueryOpts & { mode?: SearchMode } = {
|
|
845
|
+
...incoming,
|
|
846
|
+
tagMatch: incoming.tagMatch ?? (incoming.tags && incoming.tags.length > 0 ? "any" : undefined),
|
|
847
|
+
};
|
|
848
|
+
const expanded = this.expandQueryTags(this.normalizeQueryTags(withTagMatch));
|
|
849
|
+
const { expand: _e, ...rest } = expanded as QueryOpts & { mode?: SearchMode };
|
|
850
|
+
return noteOps.searchNotes(this.db, query, rest);
|
|
884
851
|
}
|
|
885
852
|
|
|
886
853
|
/**
|
package/core/src/test-preload.ts
CHANGED
|
@@ -1,8 +1,53 @@
|
|
|
1
1
|
// Isolate PARACHUTE_HOME so tests never touch the real ~/.parachute directory.
|
|
2
2
|
// This must run before any `./config.ts` import resolves CONFIG_DIR.
|
|
3
|
+
//
|
|
4
|
+
// The override is UNCONDITIONAL, and that is the entire point. This file used
|
|
5
|
+
// to read `if (!process.env.PARACHUTE_HOME)` — polite, and exactly backwards:
|
|
6
|
+
// the dangerous case is an *inherited* PARACHUTE_HOME, because on a developer
|
|
7
|
+
// box the inherited value is the live install. A machine whose shell profile
|
|
8
|
+
// carries `export PARACHUTE_HOME="$HOME/.parachute"` (a normal thing to have —
|
|
9
|
+
// it is also how parachute-hub launches the daemon) handed `bun test` a
|
|
10
|
+
// pointer straight at the real vault data dir, and this guard stepped politely
|
|
11
|
+
// aside. On 2026-08-22 two runs wrote ~158 real vault directories into a live
|
|
12
|
+
// install — `tagscope-*`, `mint-*`, `retier-*`, `ledger-*`, plus a `solo`
|
|
13
|
+
// vault from the mirror-routes tests.
|
|
14
|
+
//
|
|
15
|
+
// Nothing legitimate depends on the ambient value surviving. `config.ts`
|
|
16
|
+
// re-reads `process.env.PARACHUTE_HOME` on every call (see the "Historical
|
|
17
|
+
// note" above `configDirPath`), and every test that needs a particular home
|
|
18
|
+
// either assigns it at run time or passes it explicitly to the child it
|
|
19
|
+
// spawns. Docker and CI set PARACHUTE_HOME for the *server*, never for
|
|
20
|
+
// `bun test`.
|
|
3
21
|
import { mkdtempSync } from "fs";
|
|
4
|
-
import { tmpdir } from "os";
|
|
22
|
+
import { homedir, tmpdir } from "os";
|
|
5
23
|
import { join } from "path";
|
|
6
|
-
|
|
7
|
-
|
|
24
|
+
|
|
25
|
+
const TEST_HOME = mkdtempSync(join(tmpdir(), "parachute-test-home-"));
|
|
26
|
+
process.env.PARACHUTE_HOME = TEST_HOME;
|
|
27
|
+
|
|
28
|
+
// Tripwire, and a live one: `mkdtempSync` builds on `tmpdir()`, which honors
|
|
29
|
+
// TMPDIR. An operator or CI that points TMPDIR inside the parachute home
|
|
30
|
+
// (`TMPDIR=~/.parachute/tmp` is not exotic) would have this file dutifully
|
|
31
|
+
// create the suite's "isolated" home *inside the live install*, which is the
|
|
32
|
+
// failure this preload exists to prevent, arrived at by another road. Assert
|
|
33
|
+
// the temp home is neither the real home nor nested under it.
|
|
34
|
+
//
|
|
35
|
+
// The check on `process.env.PARACHUTE_HOME` itself is deliberately NOT here:
|
|
36
|
+
// it was just assigned two lines up, so any such comparison is dead code that
|
|
37
|
+
// reads as live defense. `configDirPath()` in `src/config.ts` carries the real
|
|
38
|
+
// runtime tripwire, at the one function that resolves the root.
|
|
39
|
+
const REAL_HOME = join(homedir(), ".parachute");
|
|
40
|
+
if (TEST_HOME === REAL_HOME || TEST_HOME.startsWith(REAL_HOME + "/")) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`[test-preload] refusing to run the test suite: the temp home ${TEST_HOME} is inside ` +
|
|
43
|
+
`the live install at ${REAL_HOME} — check TMPDIR. Tests would create real vaults ` +
|
|
44
|
+
`there; that happened on 2026-08-22.`,
|
|
45
|
+
);
|
|
8
46
|
}
|
|
47
|
+
|
|
48
|
+
// No cleanup handler on purpose. `bun test` dispatches neither `exit` nor
|
|
49
|
+
// `beforeExit` (verified on Bun 1.3.14 — a handler registered here never runs,
|
|
50
|
+
// even on a clean green exit), so a cleanup hook would be a comment promising
|
|
51
|
+
// something that does not happen. Each run therefore leaves one empty-ish
|
|
52
|
+
// `parachute-test-home-*` under tmpdir(); the OS reaps them, and a dropping in
|
|
53
|
+
// tmpdir is a rounding error next to a dropping in someone's real vault.
|
package/core/src/types.ts
CHANGED
|
@@ -188,6 +188,13 @@ export interface QueryOpts {
|
|
|
188
188
|
hasBrokenLinks?: boolean;
|
|
189
189
|
path?: string; // exact path match (case-insensitive)
|
|
190
190
|
pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
|
|
191
|
+
/**
|
|
192
|
+
* Exclude notes whose path matches any of these prefixes. Same matching
|
|
193
|
+
* as `pathPrefix` (`n.path LIKE prefix || '%'`, ASCII case-insensitive).
|
|
194
|
+
* Repeatable. A note with no path is not excluded (it isn't under the
|
|
195
|
+
* prefix). vault#628 — `.parachute/` system-space is the first client.
|
|
196
|
+
*/
|
|
197
|
+
excludePathPrefix?: string[];
|
|
191
198
|
/**
|
|
192
199
|
* Filter by file extension. Pass a single extension (e.g. `"csv"`) or
|
|
193
200
|
* an array (e.g. `["csv", "yaml", "json"]`). Extension is compared
|
|
@@ -504,8 +511,13 @@ export interface Store {
|
|
|
504
511
|
* rather than silently returning `[]`. `sort` (vault#551): omitted stays
|
|
505
512
|
* FTS5 relevance ranking (default); an explicit "asc"/"desc" switches to
|
|
506
513
|
* `created_at` ordering. See `core/src/search-query.ts`.
|
|
514
|
+
*
|
|
515
|
+
* Every other `QueryOpts` filter (excludeTags, dateFrom/dateFilter, path,
|
|
516
|
+
* metadata, …) composes the same way `queryNotes` / `semanticSearch` do
|
|
517
|
+
* (vault#647). Unspecified `tagMatch` defaults to `"any"` so historical
|
|
518
|
+
* FTS tag semantics (a single IN (...)) stay put.
|
|
507
519
|
*/
|
|
508
|
-
searchNotes(query: string, opts?:
|
|
520
|
+
searchNotes(query: string, opts?: QueryOpts & { mode?: SearchMode }): Promise<Note[]>;
|
|
509
521
|
/**
|
|
510
522
|
* Semantic search (EXPERIMENTAL — see `QueryOpts.nearText`/`semantic`).
|
|
511
523
|
* The one invocation point for the store's `EmbeddingProvider`: embeds
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
resolveWikilink,
|
|
8
8
|
resolveWikilinkDetailed,
|
|
9
9
|
resolveUnresolvedWikilinks,
|
|
10
|
+
queueUnresolvedLink,
|
|
10
11
|
listUnresolvedWikilinks,
|
|
11
12
|
getContentWikilinkWarnings,
|
|
12
13
|
resolveOrQueueLink,
|
|
@@ -630,6 +631,18 @@ describe("delete → recreate re-resolves inbound wikilinks (LB6)", () => {
|
|
|
630
631
|
expect(getUnresolvedLinksForNote(db, b.id)).toHaveLength(0);
|
|
631
632
|
});
|
|
632
633
|
|
|
634
|
+
it("re-heals a non-ASCII title that differs only in case (vault#589 COLLATE NOCASE is ASCII-only)", async () => {
|
|
635
|
+
const source = await store.createNote("see [[CAFÉ]]", { path: "A" });
|
|
636
|
+
expect(getUnresolvedLinksForNote(db, source.id).map((r) => r.target_path ?? r.target)).toEqual(["CAFÉ"]);
|
|
637
|
+
expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
|
|
638
|
+
|
|
639
|
+
const target = await store.createNote("# café\n\nbody", { path: "people/cafe" });
|
|
640
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
641
|
+
expect(links).toHaveLength(1);
|
|
642
|
+
expect(links[0]!.targetId).toBe(target.id);
|
|
643
|
+
expect(getUnresolvedLinksForNote(db, source.id)).toHaveLength(0);
|
|
644
|
+
});
|
|
645
|
+
|
|
633
646
|
// The completed sweep must NOT mis-resolve an AMBIGUOUS target — matching
|
|
634
647
|
// write-time's "don't guess" contract. A pending [[John Doe]] is swept when
|
|
635
648
|
// a same-titled note is created; if TWO notes already share that H1 by the
|
|
@@ -766,3 +779,47 @@ describe("ensureRelationshipColumn — crash-safe rebuild", () => {
|
|
|
766
779
|
expect(links.some((l) => l.targetId === targetA!.id && l.relationship === "wikilink")).toBe(true);
|
|
767
780
|
});
|
|
768
781
|
});
|
|
782
|
+
|
|
783
|
+
describe("deferred resolution — ID leg (vault#591)", () => {
|
|
784
|
+
it("heals an ID-valued pending row when the target note is later created", async () => {
|
|
785
|
+
const source = await store.createNote("src", { path: "Src" });
|
|
786
|
+
const futureId = "tgt-id-591";
|
|
787
|
+
queueUnresolvedLink(db, source.id, futureId, "reference");
|
|
788
|
+
expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
|
|
789
|
+
|
|
790
|
+
const target = await store.createNote("tgt", { id: futureId, path: "Tgt" });
|
|
791
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
792
|
+
expect(links).toHaveLength(1);
|
|
793
|
+
expect(links[0]!.targetId).toBe(target.id);
|
|
794
|
+
expect(links[0]!.relationship).toBe("reference");
|
|
795
|
+
expect(getUnresolvedLinksForNote(db, source.id)).toHaveLength(0);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
// Watch-fail: a shared ID-first verify lets a decoy whose id equals the
|
|
799
|
+
// pending bracket text short-circuit resolveLinkTargetDetailed. The
|
|
800
|
+
// titled note's sweep then sees detail.note_id !== noteId and leaves the
|
|
801
|
+
// row queued forever. Write-time resolveWikilinkDetailed has no ID leg,
|
|
802
|
+
// so a fresh save would heal to the titled note.
|
|
803
|
+
it("does not let an ID-named decoy steal a pending wikilink from a later titled note", async () => {
|
|
804
|
+
await store.createNote("decoy body", { id: "shadow", path: "people/decoy" });
|
|
805
|
+
const source = await store.createNote("see [[shadow]]", { path: "Src" });
|
|
806
|
+
expect(getUnresolvedLinksForNote(db, source.id).map((l) => l.target)).toEqual(["shadow"]);
|
|
807
|
+
expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
|
|
808
|
+
|
|
809
|
+
const titled = await store.createNote("# shadow\n\nreal.", { path: "people/shadow" });
|
|
810
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
811
|
+
expect(links).toHaveLength(1);
|
|
812
|
+
expect(links[0]!.targetId).toBe(titled.id);
|
|
813
|
+
expect(links[0]!.relationship).toBe("wikilink");
|
|
814
|
+
expect(getUnresolvedLinksForNote(db, source.id)).toHaveLength(0);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("does not heal a pending wikilink against a later note whose id equals the bracket text", async () => {
|
|
818
|
+
const source = await store.createNote("see [[foo591]]", { path: "Src" });
|
|
819
|
+
expect(getUnresolvedLinksForNote(db, source.id).map((l) => l.target)).toEqual(["foo591"]);
|
|
820
|
+
|
|
821
|
+
await store.createNote("unrelated", { id: "foo591", path: "elsewhere" });
|
|
822
|
+
expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
|
|
823
|
+
expect(getUnresolvedLinksForNote(db, source.id).map((l) => l.target)).toEqual(["foo591"]);
|
|
824
|
+
});
|
|
825
|
+
});
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -624,26 +624,27 @@ function syncUnresolvedWikilinks(
|
|
|
624
624
|
* (a structured link queued via {@link queueUnresolvedLink} backfills with
|
|
625
625
|
* the caller's original relationship, not "wikilink").
|
|
626
626
|
*
|
|
627
|
-
* Deferred resolution
|
|
628
|
-
* {@link resolveWikilinkDetailed}
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
* the
|
|
637
|
-
*
|
|
638
|
-
*
|
|
627
|
+
* Deferred resolution covers all four legs of
|
|
628
|
+
* {@link resolveWikilinkDetailed} (exact path, basename, H1 title, explicit
|
|
629
|
+
* `path.ext`) plus, for structured-link pending rows, the ID leg of
|
|
630
|
+
* {@link resolveLinkTargetDetailed} (vault#591). Verify picks the resolver
|
|
631
|
+
* by `row.relationship` so a wikilink row cannot heal via ID. (Caveat: the
|
|
632
|
+
* candidate pre-filter below matches the title/ext legs via SQL
|
|
633
|
+
* `COLLATE NOCASE`, which case-folds ASCII only — a non-ASCII title
|
|
634
|
+
* differing from the target only in letter case is missed at the candidate
|
|
635
|
+
* stage and re-heals on the source's next save instead. Rare; tracked as a
|
|
636
|
+
* follow-up.) Before this, the sweep matched a pending row to the new note
|
|
637
|
+
* by PATH TEXT ONLY (`target_path = path OR path LIKE '%/'||target_path`)
|
|
638
|
+
* — so a `[[John Doe]]` that resolved at write time via the H1-title
|
|
639
|
+
* fallback (its note's displayed title differs from its path, e.g.
|
|
640
|
+
* `people/jdoe`), or a `[[Foo.csv]]` that resolved via the extension leg,
|
|
641
|
+
* silently never re-healed on a delete→recreate (the exact LB6 gap) and
|
|
639
642
|
* more broadly never backfilled when the target note was created AFTER the
|
|
640
|
-
* referencing note.
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* pre-existing asymmetry where the path-only sweep would link an ambiguous
|
|
646
|
-
* `[[Foo]]` to whichever colliding note happened to be created.
|
|
643
|
+
* referencing note. An AMBIGUOUS target (≥2 notes now share the
|
|
644
|
+
* path/title) resolves to neither and stays queued, identical to
|
|
645
|
+
* write-time's "don't guess" contract — this also closes the pre-existing
|
|
646
|
+
* asymmetry where the path-only sweep would link an ambiguous `[[Foo]]` to
|
|
647
|
+
* whichever colliding note happened to be created.
|
|
647
648
|
*
|
|
648
649
|
* Returns the number of links resolved.
|
|
649
650
|
*/
|
|
@@ -666,19 +667,45 @@ export function resolveUnresolvedWikilinks(
|
|
|
666
667
|
let rows: { source_id: string; target_path: string; relationship: string }[];
|
|
667
668
|
try {
|
|
668
669
|
// Candidate pre-filter: every pending row whose `target_path` COULD
|
|
669
|
-
// resolve to this note under any
|
|
670
|
-
// path, basename (target is the last path segment), H1 title,
|
|
671
|
-
// `path.ext` form
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
670
|
+
// resolve to this note under any resolveLinkTargetDetailed leg — exact
|
|
671
|
+
// path, basename (target is the last path segment), H1 title, the
|
|
672
|
+
// `path.ext` form, or a raw note ID (vault#591: typed `reference` fields
|
|
673
|
+
// and ID-form structured links). A `null` bind (no H1 heading / no
|
|
674
|
+
// extension) makes its clause never match (`target_path = NULL` is NULL,
|
|
675
|
+
// i.e. falsy in SQL). The verify step below is what enforces correctness;
|
|
676
|
+
// this clause only BOUNDS how many rows reach the resolver.
|
|
675
677
|
rows = db.prepare(`
|
|
676
678
|
SELECT source_id, target_path, relationship FROM unresolved_wikilinks
|
|
677
679
|
WHERE target_path = ? COLLATE NOCASE
|
|
678
680
|
OR ? LIKE '%/' || target_path
|
|
679
681
|
OR target_path = ? COLLATE NOCASE
|
|
680
682
|
OR target_path = ? COLLATE NOCASE
|
|
681
|
-
|
|
683
|
+
OR target_path = ?
|
|
684
|
+
`).all(notePath, notePath, h1Title, pathDotExt, noteId) as typeof rows;
|
|
685
|
+
|
|
686
|
+
// vault#589: SQLite COLLATE NOCASE is ASCII-only. `[[CAFÉ]]` vs H1
|
|
687
|
+
// `café` is excluded by the SQL pre-filter even though
|
|
688
|
+
// findNotesByTitle folds with JS toLowerCase. Union remaining pending
|
|
689
|
+
// rows whose target Unicode-folds equal to the H1; verify still
|
|
690
|
+
// decides. The path.ext fold is omitted: write-time's extension leg
|
|
691
|
+
// also uses COLLATE NOCASE on path, so a unicode-only path.ext miss
|
|
692
|
+
// would fail verify too. (`[[FOO.CSV]]` vs `foo.csv` is ASCII and
|
|
693
|
+
// already caught by the SQL clause.)
|
|
694
|
+
if (h1Title) {
|
|
695
|
+
const all = db.prepare(
|
|
696
|
+
"SELECT source_id, target_path, relationship FROM unresolved_wikilinks",
|
|
697
|
+
).all() as typeof rows;
|
|
698
|
+
const seen = new Set(rows.map((r) => `${r.source_id}\0${r.target_path}\0${r.relationship}`));
|
|
699
|
+
const h1 = h1Title.toLowerCase();
|
|
700
|
+
for (const row of all) {
|
|
701
|
+
const key = `${row.source_id}\0${row.target_path}\0${row.relationship}`;
|
|
702
|
+
if (seen.has(key)) continue;
|
|
703
|
+
if (row.target_path.toLowerCase() === h1) {
|
|
704
|
+
rows.push(row);
|
|
705
|
+
seen.add(key);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
682
709
|
} catch {
|
|
683
710
|
return 0; // Table doesn't exist
|
|
684
711
|
}
|
|
@@ -694,10 +721,17 @@ export function resolveUnresolvedWikilinks(
|
|
|
694
721
|
// target string actually resolves to THIS note now. A miss or an
|
|
695
722
|
// ambiguous result leaves the row queued (surfaced as a visible broken
|
|
696
723
|
// link, and re-tried on the next matching note create).
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
724
|
+
// Resolver is picked by relationship (vault#591): wikilink rows go
|
|
725
|
+
// through resolveWikilinkDetailed (write-time has no ID leg).
|
|
726
|
+
// Structured-link rows go through resolveLinkTargetDetailed
|
|
727
|
+
// (ID-then-path). A shared ID-first resolver over-heals `[[foo591]]`
|
|
728
|
+
// against a later note with that id, and lets a decoy whose id equals
|
|
729
|
+
// the bracket text steal a pending wikilink from a later titled note.
|
|
700
730
|
const relationship = row.relationship || WIKILINK_REL;
|
|
731
|
+
const detail = relationship === WIKILINK_REL
|
|
732
|
+
? resolveWikilinkDetailed(db, row.target_path)
|
|
733
|
+
: resolveLinkTargetDetailed(db, row.target_path);
|
|
734
|
+
if (!detail.resolved || detail.note_id !== noteId) continue;
|
|
701
735
|
linkOps.createLink(db, row.source_id, noteId, relationship);
|
|
702
736
|
resolved++;
|
|
703
737
|
|
package/package.json
CHANGED
|
@@ -431,6 +431,68 @@ describe("attachment tickets — download lifecycle", () => {
|
|
|
431
431
|
expect(secondRes.status).toBe(404);
|
|
432
432
|
});
|
|
433
433
|
|
|
434
|
+
test("spend Content-Type is extension-derived, not the caller-asserted row mime (vault#617)", async () => {
|
|
435
|
+
const vaultName = freshVault("tickets-download-mime");
|
|
436
|
+
const store = getVaultStore(vaultName);
|
|
437
|
+
const note = await store.createNote("# T\n", { path: "t" });
|
|
438
|
+
|
|
439
|
+
const uploadMint = await callTool(vaultName, "request-attachment-upload", {
|
|
440
|
+
note: note.id,
|
|
441
|
+
filename: "photo.png",
|
|
442
|
+
size_bytes: 4,
|
|
443
|
+
mime_type: "text/html",
|
|
444
|
+
});
|
|
445
|
+
const uploadRes = await routeReq(
|
|
446
|
+
new Request(uploadMint.url, {
|
|
447
|
+
method: "PUT",
|
|
448
|
+
headers: { "content-type": "text/html" },
|
|
449
|
+
body: new Uint8Array([137, 80, 78, 71]),
|
|
450
|
+
}),
|
|
451
|
+
);
|
|
452
|
+
const attachment = (await uploadRes.json()) as any;
|
|
453
|
+
expect(attachment.mimeType).toBe("text/html");
|
|
454
|
+
|
|
455
|
+
const downloadMint = await callTool(vaultName, "request-attachment-download", {
|
|
456
|
+
attachment_id: attachment.id,
|
|
457
|
+
});
|
|
458
|
+
const downloadRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
|
|
459
|
+
expect(downloadRes.status).toBe(200);
|
|
460
|
+
expect(downloadRes.headers.get("content-type")).toBe("image/png");
|
|
461
|
+
expect(downloadRes.headers.get("x-content-type-options")).toBe("nosniff");
|
|
462
|
+
expect(downloadMint.mime_type).toBe("image/png");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("uncurated extension with caller-asserted text/html serves octet-stream (vault#617)", async () => {
|
|
466
|
+
const vaultName = freshVault("tickets-download-octet");
|
|
467
|
+
const store = getVaultStore(vaultName);
|
|
468
|
+
const note = await store.createNote("# T\n", { path: "t" });
|
|
469
|
+
|
|
470
|
+
const uploadMint = await callTool(vaultName, "request-attachment-upload", {
|
|
471
|
+
note: note.id,
|
|
472
|
+
filename: "payload.bin",
|
|
473
|
+
size_bytes: 4,
|
|
474
|
+
mime_type: "text/html",
|
|
475
|
+
});
|
|
476
|
+
const uploadRes = await routeReq(
|
|
477
|
+
new Request(uploadMint.url, {
|
|
478
|
+
method: "PUT",
|
|
479
|
+
headers: { "content-type": "text/html" },
|
|
480
|
+
body: new Uint8Array([10, 20, 30, 40]),
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
expect(uploadRes.status).toBe(201);
|
|
484
|
+
const attachment = (await uploadRes.json()) as any;
|
|
485
|
+
|
|
486
|
+
const downloadMint = await callTool(vaultName, "request-attachment-download", {
|
|
487
|
+
attachment_id: attachment.id,
|
|
488
|
+
});
|
|
489
|
+
const downloadRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
|
|
490
|
+
expect(downloadRes.status).toBe(200);
|
|
491
|
+
expect(downloadRes.headers.get("content-type")).toBe("application/octet-stream");
|
|
492
|
+
expect(downloadRes.headers.get("x-content-type-options")).toBe("nosniff");
|
|
493
|
+
expect(downloadMint.mime_type).toBe("application/octet-stream");
|
|
494
|
+
});
|
|
495
|
+
|
|
434
496
|
test("path confinement: an attachment row pointing outside assetsDir can't be walked to via a download ticket", async () => {
|
|
435
497
|
const vaultName = freshVault("tickets-confinement");
|
|
436
498
|
const store = getVaultStore(vaultName);
|