@openparachute/vault 0.5.3-rc.3 → 0.6.0
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 +14 -3
- package/core/src/mcp.ts +20 -0
- package/core/src/schema.ts +45 -1
- package/core/src/store.ts +66 -19
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +27 -1
- package/package.json +1 -1
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/cli.ts +45 -18
- package/src/config.test.ts +27 -0
- package/src/config.ts +87 -0
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/routes.ts +192 -78
- package/src/routing.test.ts +64 -0
- package/src/routing.ts +48 -1
- package/src/server.ts +49 -3
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/vault-create.test.ts +35 -1
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +194 -0
- package/web/ui/dist/assets/index-CGL256oe.js +60 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DJL6Az--.js +0 -60
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process query matcher for live subscriptions (live-query SSE — design
|
|
3
|
+
* `design/2026-06-08-live-query-sse.md`).
|
|
4
|
+
*
|
|
5
|
+
* The snapshot path evaluates a `QueryOpts` against the DB via the SQL query
|
|
6
|
+
* engine (`core/src/notes.ts queryNotes`). The live path can't go back to the
|
|
7
|
+
* DB for every mutation — the changed note is already in hand from the
|
|
8
|
+
* post-commit hook — so this module re-implements the *supported subset* of
|
|
9
|
+
* the query predicate against a single in-memory `Note`.
|
|
10
|
+
*
|
|
11
|
+
* **Predicate parity is the contract.** For any supported query, the set of
|
|
12
|
+
* notes the snapshot SQL returns MUST equal the set this matcher accepts.
|
|
13
|
+
* `subscribe.test.ts` enforces that over a seeded corpus. Every clause below
|
|
14
|
+
* is written to mirror the exact SQL semantics in `queryNotes`:
|
|
15
|
+
*
|
|
16
|
+
* - `tags` ("all"/"any") with descendant expansion — mirrors the per-input
|
|
17
|
+
* `_tagsExpanded` JOINs (each input tag matches the input OR any declared
|
|
18
|
+
* descendant). Expansion is resolved ONCE at subscribe time (see
|
|
19
|
+
* `buildLiveMatcher`) and frozen into the matcher, exactly as the engine
|
|
20
|
+
* freezes it for the snapshot query.
|
|
21
|
+
* - `excludeTags` — raw exact-name match (engine does NOT expand excludes).
|
|
22
|
+
* - `path` — case-insensitive exact (engine: `n.path = ? COLLATE NOCASE`).
|
|
23
|
+
* - `pathPrefix` — prefix (engine: `n.path LIKE prefix || '%'`).
|
|
24
|
+
* - `extension` — lower-cased, default "md" (engine: `LOWER(n.extension)`),
|
|
25
|
+
* a note with no extension is treated as "md".
|
|
26
|
+
* - `metadata` operator objects (eq/ne/gt/gte/lt/lte/in/not_in/exists) +
|
|
27
|
+
* primitive exact-match — mirrors `buildOperatorClause` / the json_extract
|
|
28
|
+
* shorthand. NULL-aware exactly like the SQL (`ne`/`not_in` match the
|
|
29
|
+
* field-absent row; `eq null` matches absence).
|
|
30
|
+
*
|
|
31
|
+
* - `hasTags` (presence) — `note.tags?.length > 0`, trivial + parity-safe.
|
|
32
|
+
*
|
|
33
|
+
* **Unsupported (rejected upstream with 400, never reach the matcher):**
|
|
34
|
+
* `search` (FTS) and `near` (graph BFS) — not evaluable against a single note;
|
|
35
|
+
* `hasLinks` — needs the `links` table (not on the in-hand note); date filters
|
|
36
|
+
* (`dateFrom`/`dateTo`/`dateFilter`) — parity risk + some need indexed columns;
|
|
37
|
+
* `cursor` — paging is meaningless for a live set. The subscribe route returns
|
|
38
|
+
* 400 for these BEFORE a subscription is created (see
|
|
39
|
+
* `unsupportedSubscriptionReason` + the route's raw-param checks).
|
|
40
|
+
*
|
|
41
|
+
* **Ignored (irrelevant to set membership):** `orderBy`, `limit`, `offset`,
|
|
42
|
+
* `ids`. The route strips `limit`/`offset` from the snapshot query so the
|
|
43
|
+
* snapshot is the COMPLETE matching set (parity with the unbounded matcher).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import type { Note, QueryOpts, Store } from "../core/src/types.ts";
|
|
47
|
+
import { SUPPORTED_OPS } from "../core/src/query-operators.ts";
|
|
48
|
+
|
|
49
|
+
const OPS_SET: ReadonlySet<string> = new Set<string>(SUPPORTED_OPS);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Frozen, pre-resolved form of a `QueryOpts` for fast per-note matching.
|
|
53
|
+
* Tag descendant expansion (a DB read) is done once here, not per event.
|
|
54
|
+
*/
|
|
55
|
+
export interface LiveMatcher {
|
|
56
|
+
/** The original supported opts (for diagnostics / parity tests). */
|
|
57
|
+
readonly opts: QueryOpts;
|
|
58
|
+
/**
|
|
59
|
+
* Per-input-tag expanded sets: `tagSets[i]` = `{tags[i]} ∪ descendants`.
|
|
60
|
+
* Mirrors `_tagsExpanded` in the engine. Empty when no `tags` filter.
|
|
61
|
+
* A note matches under "all" iff it carries ≥1 tag from EACH set; under
|
|
62
|
+
* "any" iff it carries ≥1 tag from the UNION.
|
|
63
|
+
*/
|
|
64
|
+
readonly tagSets: string[][];
|
|
65
|
+
readonly tagMatch: "all" | "any";
|
|
66
|
+
readonly excludeTags: string[];
|
|
67
|
+
match(note: Note): boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Query shapes a live subscription can't evaluate against a single note.
|
|
72
|
+
* The route layer rejects these with 400 before creating a subscription.
|
|
73
|
+
*/
|
|
74
|
+
export function unsupportedSubscriptionReason(opts: QueryOpts): string | null {
|
|
75
|
+
// `search` / `near` are parsed/handled by the notes route separately; the
|
|
76
|
+
// subscribe route detects them from raw query params. These guards catch the
|
|
77
|
+
// remaining shapes that the matcher can't faithfully evaluate against a
|
|
78
|
+
// single in-hand note (so snapshot and live would disagree).
|
|
79
|
+
if (opts.cursor) {
|
|
80
|
+
return "cursor pagination is not supported for live subscriptions";
|
|
81
|
+
}
|
|
82
|
+
if (opts.hasLinks !== undefined) {
|
|
83
|
+
return "has_links is not supported for live subscriptions (requires the links table)";
|
|
84
|
+
}
|
|
85
|
+
if (opts.dateFilter || opts.dateFrom || opts.dateTo) {
|
|
86
|
+
return "date filters are not supported for live subscriptions";
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function metaValue(note: Note, key: string): unknown {
|
|
92
|
+
const m = note.metadata;
|
|
93
|
+
if (!m || typeof m !== "object") return undefined;
|
|
94
|
+
return (m as Record<string, unknown>)[key];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Compare two primitives the way SQLite's generated `meta_<field>` column
|
|
99
|
+
* would for ordering operators. Values stored via the indexed column are
|
|
100
|
+
* primitives; we compare numbers numerically and strings lexically, matching
|
|
101
|
+
* SQLite's type affinity for a column holding the JSON-extracted scalar.
|
|
102
|
+
*/
|
|
103
|
+
function cmp(a: unknown, b: unknown): number | null {
|
|
104
|
+
if (typeof a === "number" && typeof b === "number") return a < b ? -1 : a > b ? 1 : 0;
|
|
105
|
+
// Booleans compare as their numeric form (SQLite stores 1/0).
|
|
106
|
+
const an = typeof a === "boolean" ? (a ? 1 : 0) : a;
|
|
107
|
+
const bn = typeof b === "boolean" ? (b ? 1 : 0) : b;
|
|
108
|
+
if (typeof an === "number" && typeof bn === "number") return an < bn ? -1 : an > bn ? 1 : 0;
|
|
109
|
+
const as = String(an);
|
|
110
|
+
const bs = String(bn);
|
|
111
|
+
return as < bs ? -1 : as > bs ? 1 : 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Loose equality mirroring the json_extract shorthand + operator `eq`. */
|
|
115
|
+
function looseEq(actual: unknown, expected: unknown): boolean {
|
|
116
|
+
if (actual === expected) return true;
|
|
117
|
+
// Numbers vs numeric strings: the engine's operator path binds the raw
|
|
118
|
+
// value to an indexed numeric column, so `5` and `5` match; the shorthand
|
|
119
|
+
// path stringifies. Normalize via string compare as a backstop.
|
|
120
|
+
if (actual == null || expected == null) return actual === expected;
|
|
121
|
+
if (typeof actual === "boolean" || typeof expected === "boolean") {
|
|
122
|
+
return (actual ? 1 : 0) === (expected ? 1 : 0) || String(actual) === String(expected);
|
|
123
|
+
}
|
|
124
|
+
return String(actual) === String(expected);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function evalOperatorObject(actual: unknown, opObj: Record<string, unknown>): boolean {
|
|
128
|
+
for (const [op, expected] of Object.entries(opObj)) {
|
|
129
|
+
switch (op) {
|
|
130
|
+
case "eq":
|
|
131
|
+
if (expected === null) {
|
|
132
|
+
if (actual !== undefined && actual !== null) return false;
|
|
133
|
+
} else if (!looseEq(actual, expected)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
case "ne":
|
|
138
|
+
// SQL: (col IS NULL OR col <> ?) — absent field passes; equal fails.
|
|
139
|
+
if (expected === null) {
|
|
140
|
+
if (actual === undefined || actual === null) return false;
|
|
141
|
+
} else if (actual !== undefined && actual !== null && looseEq(actual, expected)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
case "gt":
|
|
146
|
+
case "gte":
|
|
147
|
+
case "lt":
|
|
148
|
+
case "lte": {
|
|
149
|
+
// SQL comparison operators yield NULL (→ excluded) when the column
|
|
150
|
+
// is NULL/absent.
|
|
151
|
+
if (actual === undefined || actual === null) return false;
|
|
152
|
+
const c = cmp(actual, expected);
|
|
153
|
+
if (c === null) return false;
|
|
154
|
+
if (op === "gt" && !(c > 0)) return false;
|
|
155
|
+
if (op === "gte" && !(c >= 0)) return false;
|
|
156
|
+
if (op === "lt" && !(c < 0)) return false;
|
|
157
|
+
if (op === "lte" && !(c <= 0)) return false;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case "in": {
|
|
161
|
+
if (!Array.isArray(expected)) return false;
|
|
162
|
+
if (expected.length === 0) return false; // engine emits `0`
|
|
163
|
+
if (actual === undefined || actual === null) return false;
|
|
164
|
+
if (!expected.some((v) => looseEq(actual, v))) return false;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "not_in": {
|
|
168
|
+
if (!Array.isArray(expected)) return false;
|
|
169
|
+
if (expected.length === 0) break; // engine emits `1` (no-op pass)
|
|
170
|
+
// SQL: (col IS NULL OR col NOT IN (...)) — absent passes.
|
|
171
|
+
if (actual === undefined || actual === null) break;
|
|
172
|
+
if (expected.some((v) => looseEq(actual, v))) return false;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case "exists": {
|
|
176
|
+
const present = actual !== undefined && actual !== null;
|
|
177
|
+
if (expected === true && !present) return false;
|
|
178
|
+
if (expected === false && present) return false;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
// Unknown operator — the snapshot path would have thrown
|
|
183
|
+
// UNKNOWN_OPERATOR at query time, so this never matches.
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** True iff every key of `value` is a supported operator (operator-object). */
|
|
191
|
+
function isOperatorObject(value: unknown): value is Record<string, unknown> {
|
|
192
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
193
|
+
const keys = Object.keys(value as object);
|
|
194
|
+
if (keys.length === 0) return false;
|
|
195
|
+
return keys.every((k) => OPS_SET.has(k));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve a `QueryOpts` into a frozen `LiveMatcher`, expanding tag descendants
|
|
200
|
+
* once via the store (the same hierarchy the snapshot query uses). Call at
|
|
201
|
+
* subscribe time; the returned matcher is pure + sync for per-event use.
|
|
202
|
+
*/
|
|
203
|
+
export async function buildLiveMatcher(store: Store, opts: QueryOpts): Promise<LiveMatcher> {
|
|
204
|
+
const tags = opts.tags ?? [];
|
|
205
|
+
const tagMatch: "all" | "any" = opts.tagMatch ?? "all";
|
|
206
|
+
// Per-input expansion along the SAME axis the snapshot query uses
|
|
207
|
+
// (vault tag `expand` axis). `opts.expand` (default "subtypes") MUST be
|
|
208
|
+
// threaded through here so the live matcher and the snapshot query engine
|
|
209
|
+
// lower the IDENTICAL expansion — otherwise a subscription's snapshot and
|
|
210
|
+
// its live events would disagree on which notes match. `expandTags` already
|
|
211
|
+
// includes each input tag.
|
|
212
|
+
const tagSets: string[][] = [];
|
|
213
|
+
for (const t of tags) {
|
|
214
|
+
const set = await store.expandTags([t], opts.expand);
|
|
215
|
+
// Be defensive: ensure the root is present (expandTags always includes it
|
|
216
|
+
// for non-empty input, but the contract should not rely on it here).
|
|
217
|
+
set.add(t);
|
|
218
|
+
tagSets.push(Array.from(set));
|
|
219
|
+
}
|
|
220
|
+
const excludeTags = opts.excludeTags ?? [];
|
|
221
|
+
|
|
222
|
+
const matcher: LiveMatcher = {
|
|
223
|
+
opts,
|
|
224
|
+
tagSets,
|
|
225
|
+
tagMatch,
|
|
226
|
+
excludeTags,
|
|
227
|
+
match(note: Note): boolean {
|
|
228
|
+
return matchAgainst(note, opts, tagSets, tagMatch, excludeTags);
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
return matcher;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function matchAgainst(
|
|
235
|
+
note: Note,
|
|
236
|
+
opts: QueryOpts,
|
|
237
|
+
tagSets: string[][],
|
|
238
|
+
tagMatch: "all" | "any",
|
|
239
|
+
excludeTags: string[],
|
|
240
|
+
): boolean {
|
|
241
|
+
const noteTags = note.tags ?? [];
|
|
242
|
+
|
|
243
|
+
// ---- tags ----
|
|
244
|
+
if (tagSets.length > 0) {
|
|
245
|
+
if (tagMatch === "any") {
|
|
246
|
+
const union = new Set(tagSets.flat());
|
|
247
|
+
if (!noteTags.some((t) => union.has(t))) return false;
|
|
248
|
+
} else {
|
|
249
|
+
// "all": for each input tag's expanded set, the note must carry ≥1.
|
|
250
|
+
for (const set of tagSets) {
|
|
251
|
+
if (set.length === 0) continue;
|
|
252
|
+
const s = new Set(set);
|
|
253
|
+
if (!noteTags.some((t) => s.has(t))) return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---- excludeTags (raw exact, no expansion — mirrors engine) ----
|
|
259
|
+
for (const ex of excludeTags) {
|
|
260
|
+
if (noteTags.includes(ex)) return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---- hasTags (presence) — ignored by the engine when a `tags` filter is
|
|
264
|
+
// also set (the tag filter already constrains to tagged notes), so mirror
|
|
265
|
+
// that: only apply when there's no `tags` filter. See queryNotes
|
|
266
|
+
// `filterByTags` short-circuit.
|
|
267
|
+
if (opts.hasTags !== undefined && tagSets.length === 0) {
|
|
268
|
+
const has = noteTags.length > 0;
|
|
269
|
+
if (opts.hasTags !== has) return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- path (case-insensitive exact) ----
|
|
273
|
+
if (opts.path) {
|
|
274
|
+
if (!note.path || note.path.toLowerCase() !== opts.path.toLowerCase()) return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- pathPrefix (case-insensitive — the engine uses `LIKE prefix || '%'`,
|
|
278
|
+
// and SQLite LIKE is ASCII-case-insensitive by default) ----
|
|
279
|
+
if (opts.pathPrefix) {
|
|
280
|
+
if (!note.path || !note.path.toLowerCase().startsWith(opts.pathPrefix.toLowerCase())) return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- extension (lower-cased; default "md") ----
|
|
284
|
+
if (opts.extension !== undefined) {
|
|
285
|
+
const exts = Array.isArray(opts.extension) ? opts.extension : [opts.extension];
|
|
286
|
+
const cleaned = exts
|
|
287
|
+
.filter((e): e is string => typeof e === "string" && e.length > 0)
|
|
288
|
+
.map((e) => e.toLowerCase());
|
|
289
|
+
if (cleaned.length > 0) {
|
|
290
|
+
const noteExt = (note.extension ?? "md").toLowerCase();
|
|
291
|
+
if (!cleaned.includes(noteExt)) return false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---- metadata ----
|
|
296
|
+
if (opts.metadata) {
|
|
297
|
+
for (const [key, value] of Object.entries(opts.metadata)) {
|
|
298
|
+
const actual = metaValue(note, key);
|
|
299
|
+
if (isOperatorObject(value)) {
|
|
300
|
+
if (!evalOperatorObject(actual, value)) return false;
|
|
301
|
+
} else {
|
|
302
|
+
// Primitive exact-match (json_extract shorthand). The engine compares
|
|
303
|
+
// the JSON-extracted scalar against the string/JSON form.
|
|
304
|
+
if (!looseEq(actual, value)) return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return true;
|
|
310
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* and the Request, and returns a Response.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { Store, Note } from "../core/src/types.ts";
|
|
14
|
+
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
15
|
+
import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
15
16
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
16
17
|
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
17
18
|
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
@@ -46,7 +47,7 @@ import {
|
|
|
46
47
|
} from "../core/src/expand.ts";
|
|
47
48
|
import { join, extname, normalize } from "path";
|
|
48
49
|
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
49
|
-
import { assetsDir } from "./config.ts";
|
|
50
|
+
import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
|
|
50
51
|
import { shouldAutoTranscribe } from "./auto-transcribe.ts";
|
|
51
52
|
// usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
|
|
52
53
|
// of invalidateUsageCache does NOT form a cycle — routes.ts → usage.ts only.
|
|
@@ -435,6 +436,124 @@ function parseMetadataJsonAlias(url: URL): {
|
|
|
435
436
|
return { metadata: parsed as Record<string, unknown> };
|
|
436
437
|
}
|
|
437
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Parse + validate the `?expand=` tag-expansion axis (vault tag `expand` axis).
|
|
441
|
+
* Shared by `parseNotesQueryOpts` (structured + subscribe) AND the full-text
|
|
442
|
+
* search branch of `handleNotes` (which bypasses `parseNotesQueryOpts`), so the
|
|
443
|
+
* enum lives in exactly one place and `GET /notes?search=...&expand=bogus` is
|
|
444
|
+
* validated identically to the structured path.
|
|
445
|
+
*
|
|
446
|
+
* Returns `{ expand }` (undefined when absent/empty → store defaults to
|
|
447
|
+
* "subtypes") or `{ error }` (a 400 Response) on an unknown value.
|
|
448
|
+
*/
|
|
449
|
+
export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Response } {
|
|
450
|
+
const expandParam = parseQuery(url, "expand");
|
|
451
|
+
if (expandParam === null || expandParam === "") return {};
|
|
452
|
+
if (!(TAG_EXPAND_MODES as readonly string[]).includes(expandParam)) {
|
|
453
|
+
return {
|
|
454
|
+
error: json(
|
|
455
|
+
{
|
|
456
|
+
error: `invalid \`expand\` value "${expandParam}" — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes": parent_names descendants).`,
|
|
457
|
+
code: "INVALID_QUERY",
|
|
458
|
+
},
|
|
459
|
+
400,
|
|
460
|
+
),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return { expand: expandParam as TagExpandMode };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Parse the shared notes-query parameters (tags / excludeTags / path /
|
|
468
|
+
* pathPrefix / extension / metadata filters / date filters / sort / paging /
|
|
469
|
+
* cursor) into a `QueryOpts`, plus flags for the query shapes that the live
|
|
470
|
+
* subscription endpoint must reject (`search`, `near`, `cursor`).
|
|
471
|
+
*
|
|
472
|
+
* Factored out of `handleNotesInner`'s structured-query branch so the
|
|
473
|
+
* `/subscribe` route evaluates the SAME predicate the snapshot query does —
|
|
474
|
+
* predicate parity by construction, not copy-paste. `handleNotesInner` keeps
|
|
475
|
+
* its own inline parsing for the single-note (`id`) and full-text (`search`)
|
|
476
|
+
* branches; this helper covers the structured-query shape both endpoints share.
|
|
477
|
+
*
|
|
478
|
+
* Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
|
|
479
|
+
* as the inline code did. `hasSearch` is surfaced from the raw `search` param
|
|
480
|
+
* (this helper does not itself build a search query — the caller routes that).
|
|
481
|
+
*/
|
|
482
|
+
export function parseNotesQueryOpts(url: URL): {
|
|
483
|
+
queryOpts?: QueryOpts;
|
|
484
|
+
hasSearch: boolean;
|
|
485
|
+
hasNear: boolean;
|
|
486
|
+
hasCursor: boolean;
|
|
487
|
+
error?: Response;
|
|
488
|
+
} {
|
|
489
|
+
const hasSearch = parseQuery(url, "search") !== null && parseQuery(url, "search") !== "";
|
|
490
|
+
const hasNear = parseQuery(url, "near[note_id]") !== null;
|
|
491
|
+
const cursorParam = parseQuery(url, "cursor");
|
|
492
|
+
const hasCursor = cursorParam !== null && cursorParam !== "";
|
|
493
|
+
|
|
494
|
+
const tags = parseQueryList(url, "tag");
|
|
495
|
+
const bracket = parseMetaBrackets(url);
|
|
496
|
+
if (bracket.error) return { hasSearch, hasNear, hasCursor, error: bracket.error };
|
|
497
|
+
const metadataAlias = parseMetadataJsonAlias(url);
|
|
498
|
+
if (metadataAlias.error) return { hasSearch, hasNear, hasCursor, error: metadataAlias.error };
|
|
499
|
+
if (metadataAlias.metadata && bracket.metadata) {
|
|
500
|
+
return {
|
|
501
|
+
hasSearch,
|
|
502
|
+
hasNear,
|
|
503
|
+
hasCursor,
|
|
504
|
+
error: json(
|
|
505
|
+
{
|
|
506
|
+
error: "pass metadata filters as either the JSON `metadata=` param or bracket `meta[field][op]=` form, not both.",
|
|
507
|
+
code: "INVALID_QUERY",
|
|
508
|
+
},
|
|
509
|
+
400,
|
|
510
|
+
),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Tag-expansion axis (vault tag `expand` axis). Optional; absent →
|
|
515
|
+
// "subtypes" (resolved at the store, kept undefined here so it stays
|
|
516
|
+
// byte-identical to pre-axis behavior). Unknown value → 400 via the shared
|
|
517
|
+
// helper (same shape the search branch uses).
|
|
518
|
+
const expandParsed = parseExpandParam(url);
|
|
519
|
+
if (expandParsed.error) return { hasSearch, hasNear, hasCursor, error: expandParsed.error };
|
|
520
|
+
const expand = expandParsed.expand;
|
|
521
|
+
|
|
522
|
+
const queryOpts: QueryOpts = {
|
|
523
|
+
tags,
|
|
524
|
+
tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
525
|
+
expand,
|
|
526
|
+
excludeTags: parseQueryList(url, "exclude_tag"),
|
|
527
|
+
hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
|
|
528
|
+
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
529
|
+
path: parseQuery(url, "path") ?? undefined,
|
|
530
|
+
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
531
|
+
extension: parseExtensionFilter(url),
|
|
532
|
+
metadata: bracket.metadata ?? metadataAlias.metadata,
|
|
533
|
+
...(bracket.dateFilter
|
|
534
|
+
? { dateFilter: bracket.dateFilter }
|
|
535
|
+
: parseQuery(url, "date_field")
|
|
536
|
+
? {
|
|
537
|
+
dateFilter: {
|
|
538
|
+
field: parseQuery(url, "date_field")!,
|
|
539
|
+
from: parseQuery(url, "date_from") ?? undefined,
|
|
540
|
+
to: parseQuery(url, "date_to") ?? undefined,
|
|
541
|
+
},
|
|
542
|
+
}
|
|
543
|
+
: {
|
|
544
|
+
dateFrom: parseQuery(url, "date_from") ?? undefined,
|
|
545
|
+
dateTo: parseQuery(url, "date_to") ?? undefined,
|
|
546
|
+
}),
|
|
547
|
+
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
548
|
+
orderBy: parseQuery(url, "order_by") ?? undefined,
|
|
549
|
+
limit: parseInt10(parseQuery(url, "limit")) ?? 50,
|
|
550
|
+
offset: parseInt10(parseQuery(url, "offset")),
|
|
551
|
+
cursor: cursorParam ?? undefined,
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
return { queryOpts, hasSearch, hasNear, hasCursor };
|
|
555
|
+
}
|
|
556
|
+
|
|
438
557
|
/**
|
|
439
558
|
* Parse include_metadata query param.
|
|
440
559
|
* - absent/null → undefined (all metadata, default)
|
|
@@ -634,7 +753,17 @@ async function handleNotesInner(
|
|
|
634
753
|
if (search) {
|
|
635
754
|
const searchTags = parseQueryList(url, "tag");
|
|
636
755
|
const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
|
|
637
|
-
|
|
756
|
+
// Tag-expansion axis (vault tag `expand` axis). This branch bypasses
|
|
757
|
+
// `parseNotesQueryOpts`, so validate `?expand=` here too — otherwise
|
|
758
|
+
// `GET /notes?search=x&expand=bogus` would silently ignore the bad
|
|
759
|
+
// value. The validated mode is threaded into the search tag-narrowing.
|
|
760
|
+
const tagExpand = parseExpandParam(url);
|
|
761
|
+
if (tagExpand.error) return tagExpand.error;
|
|
762
|
+
const rawResults = await store.searchNotes(search, {
|
|
763
|
+
tags: searchTags,
|
|
764
|
+
limit,
|
|
765
|
+
expand: tagExpand.expand,
|
|
766
|
+
});
|
|
638
767
|
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
639
768
|
// happens after the store query so an empty post-filter list still
|
|
640
769
|
// returns 200 [] (consistent with "no matches"), not 403.
|
|
@@ -694,36 +823,16 @@ async function handleNotesInner(
|
|
|
694
823
|
// Surface asymmetry: REST flattens to a query string; MCP takes a
|
|
695
824
|
// nested `date_filter: { field, from, to }` object directly. Both
|
|
696
825
|
// lower to the same store-level `dateFilter` shape.
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
// matched it), so the query returned ALL tag-matching notes.
|
|
704
|
-
const metadataAlias = parseMetadataJsonAlias(url);
|
|
705
|
-
if (metadataAlias.error) return metadataAlias.error;
|
|
706
|
-
// Reject "both forms" loudly. If a caller passes BOTH the JSON
|
|
707
|
-
// `metadata=` param AND any `meta[...]` bracket param, there's no
|
|
708
|
-
// well-defined merge and silently picking a winner is exactly the
|
|
709
|
-
// class of bug we're fixing. Symmetric with the mixed shorthand/
|
|
710
|
-
// operator rejection inside parseMetaBrackets. Guard stays narrow —
|
|
711
|
-
// only the named `metadata` param triggers it.
|
|
712
|
-
if (metadataAlias.metadata && bracket.metadata) {
|
|
713
|
-
return json(
|
|
714
|
-
{
|
|
715
|
-
error: "pass metadata filters as either the JSON `metadata=` param or bracket `meta[field][op]=` form, not both.",
|
|
716
|
-
code: "INVALID_QUERY",
|
|
717
|
-
},
|
|
718
|
-
400,
|
|
719
|
-
);
|
|
720
|
-
}
|
|
721
|
-
// Opaque cursor for "since last checked" agent loops (vault#313).
|
|
722
|
-
// When present, switches the response shape to {notes, next_cursor}
|
|
723
|
-
// and routes through queryNotesPaged for keyset pagination. Mutually
|
|
724
|
-
// exclusive with the `near` graph-neighborhood scope (rebuilding the
|
|
725
|
-
// neighborhood per page isn't stable) — rejected below.
|
|
826
|
+
// Structured-query parsing is shared with the live `/subscribe` route
|
|
827
|
+
// (see `parseNotesQueryOpts`) so both endpoints lower an identical query
|
|
828
|
+
// string to the same `QueryOpts` — predicate parity by construction.
|
|
829
|
+
const parsed = parseNotesQueryOpts(url);
|
|
830
|
+
if (parsed.error) return parsed.error;
|
|
831
|
+
const queryOpts = parsed.queryOpts!;
|
|
726
832
|
const cursorParam = parseQuery(url, "cursor");
|
|
833
|
+
// Opaque cursor for "since last checked" agent loops (vault#313) is
|
|
834
|
+
// mutually exclusive with the `near` graph-neighborhood scope (rebuilding
|
|
835
|
+
// the neighborhood per page isn't stable).
|
|
727
836
|
const nearNoteIdEarly = parseQuery(url, "near[note_id]");
|
|
728
837
|
if (cursorParam && nearNoteIdEarly) {
|
|
729
838
|
return json(
|
|
@@ -736,50 +845,6 @@ async function handleNotesInner(
|
|
|
736
845
|
}
|
|
737
846
|
let results: Note[];
|
|
738
847
|
let nextCursor: string | null = null;
|
|
739
|
-
const queryOpts = {
|
|
740
|
-
tags,
|
|
741
|
-
tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
742
|
-
excludeTags: parseQueryList(url, "exclude_tag"),
|
|
743
|
-
hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
|
|
744
|
-
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
745
|
-
path: parseQuery(url, "path") ?? undefined,
|
|
746
|
-
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
747
|
-
// Extension filter (vault#328). Accepts repeated `extension=`
|
|
748
|
-
// params for the array form: `?extension=csv&extension=yaml`.
|
|
749
|
-
// `parseQueryList` already returns undefined when no params
|
|
750
|
-
// are present, so the filter is silently skipped on a plain
|
|
751
|
-
// GET without the extension query.
|
|
752
|
-
extension: parseExtensionFilter(url),
|
|
753
|
-
// Bracket form and JSON-alias form are mutually exclusive (guarded
|
|
754
|
-
// above), so at most one of these is set.
|
|
755
|
-
metadata: bracket.metadata ?? metadataAlias.metadata,
|
|
756
|
-
// Date-range precedence chain (highest to lowest):
|
|
757
|
-
// 1. Bracket-style `meta[created_at][gte]=…` (canonical).
|
|
758
|
-
// 2. Flat `date_field=…&date_from=…&date_to=…` (deprecated).
|
|
759
|
-
// 3. Legacy `date_from=…&date_to=…` (no date_field, deprecated)
|
|
760
|
-
// — filters on `n.created_at` by definition.
|
|
761
|
-
// The engine rejects combinations of `dateFilter` with the legacy
|
|
762
|
-
// `dateFrom`/`dateTo`, so we never set both shapes simultaneously.
|
|
763
|
-
...(bracket.dateFilter
|
|
764
|
-
? { dateFilter: bracket.dateFilter }
|
|
765
|
-
: parseQuery(url, "date_field")
|
|
766
|
-
? {
|
|
767
|
-
dateFilter: {
|
|
768
|
-
field: parseQuery(url, "date_field")!,
|
|
769
|
-
from: parseQuery(url, "date_from") ?? undefined,
|
|
770
|
-
to: parseQuery(url, "date_to") ?? undefined,
|
|
771
|
-
},
|
|
772
|
-
}
|
|
773
|
-
: {
|
|
774
|
-
dateFrom: parseQuery(url, "date_from") ?? undefined,
|
|
775
|
-
dateTo: parseQuery(url, "date_to") ?? undefined,
|
|
776
|
-
}),
|
|
777
|
-
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
778
|
-
orderBy: parseQuery(url, "order_by") ?? undefined,
|
|
779
|
-
limit: parseInt10(parseQuery(url, "limit")) ?? 50,
|
|
780
|
-
offset: parseInt10(parseQuery(url, "offset")),
|
|
781
|
-
cursor: cursorParam ?? undefined,
|
|
782
|
-
};
|
|
783
848
|
try {
|
|
784
849
|
if (cursorParam) {
|
|
785
850
|
const page = await store.queryNotesPaged(queryOpts);
|
|
@@ -1079,7 +1144,14 @@ async function handleNotesInner(
|
|
|
1079
1144
|
// Explicit `transcribe: true` wins — if the caller asked, we honor that
|
|
1080
1145
|
// regardless of the auto-transcribe toggle (back-compat).
|
|
1081
1146
|
const explicitOptIn = body.transcribe === true;
|
|
1082
|
-
|
|
1147
|
+
// Per-vault auto-transcribe: read THIS vault's `auto_transcribe.enabled`
|
|
1148
|
+
// (vault.yaml) and pass it as the precedence-winning toggle. A vault that
|
|
1149
|
+
// set its own value uses it; one that left it unset falls through to the
|
|
1150
|
+
// global toggle inside `shouldAutoTranscribe` (per-vault → global → true).
|
|
1151
|
+
const perVaultEnabled = vault
|
|
1152
|
+
? readVaultConfig(vault)?.auto_transcribe?.enabled
|
|
1153
|
+
: undefined;
|
|
1154
|
+
const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType, { perVaultEnabled });
|
|
1083
1155
|
const attMeta = (explicitOptIn || autoOptIn)
|
|
1084
1156
|
? {
|
|
1085
1157
|
transcribe_status: "pending" as const,
|
|
@@ -1895,16 +1967,36 @@ type VaultConfigLike = {
|
|
|
1895
1967
|
name: string;
|
|
1896
1968
|
description?: string;
|
|
1897
1969
|
audio_retention?: "keep" | "until_transcribed" | "never";
|
|
1970
|
+
auto_transcribe?: { enabled?: boolean };
|
|
1898
1971
|
};
|
|
1899
1972
|
|
|
1900
1973
|
const VALID_AUDIO_RETENTION = ["keep", "until_transcribed", "never"] as const;
|
|
1901
1974
|
|
|
1975
|
+
/**
|
|
1976
|
+
* Resolve the effective auto-transcribe toggle for a vault's GET response, the
|
|
1977
|
+
* SAME resolution `shouldAutoTranscribe` uses at the decision point:
|
|
1978
|
+
* **per-vault → global → true**. A vault that set its own `auto_transcribe`
|
|
1979
|
+
* shows that; one that left it unset shows the server-wide default (itself
|
|
1980
|
+
* default-ON). This keeps the GET in lock-step with what the worker actually
|
|
1981
|
+
* does, with no field-name drift.
|
|
1982
|
+
*/
|
|
1983
|
+
function resolveAutoTranscribeEnabled(vaultConfig: VaultConfigLike): boolean {
|
|
1984
|
+
return (
|
|
1985
|
+
vaultConfig.auto_transcribe?.enabled
|
|
1986
|
+
?? readGlobalConfig().auto_transcribe?.enabled
|
|
1987
|
+
?? true
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1902
1991
|
function vaultResponse(vaultConfig: VaultConfigLike): Record<string, unknown> {
|
|
1903
1992
|
return {
|
|
1904
1993
|
name: vaultConfig.name,
|
|
1905
1994
|
description: vaultConfig.description ?? null,
|
|
1906
1995
|
config: {
|
|
1907
1996
|
audio_retention: vaultConfig.audio_retention ?? "keep",
|
|
1997
|
+
auto_transcribe: {
|
|
1998
|
+
enabled: resolveAutoTranscribeEnabled(vaultConfig),
|
|
1999
|
+
},
|
|
1908
2000
|
},
|
|
1909
2001
|
};
|
|
1910
2002
|
}
|
|
@@ -1928,7 +2020,7 @@ export async function handleVault(
|
|
|
1928
2020
|
if (req.method === "PATCH") {
|
|
1929
2021
|
const body = await req.json() as {
|
|
1930
2022
|
description?: string;
|
|
1931
|
-
config?: { audio_retention?: string };
|
|
2023
|
+
config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
|
|
1932
2024
|
};
|
|
1933
2025
|
let dirty = false;
|
|
1934
2026
|
|
|
@@ -1952,6 +2044,28 @@ export async function handleVault(
|
|
|
1952
2044
|
dirty = true;
|
|
1953
2045
|
}
|
|
1954
2046
|
|
|
2047
|
+
// auto_transcribe.enabled — PER-VAULT toggle persisted to THIS vault's
|
|
2048
|
+
// vault.yaml (via `persist`, the same writeVaultConfig path as
|
|
2049
|
+
// description/audio_retention). Flipping it for vault X affects only X;
|
|
2050
|
+
// scribe's "link to vault X" PATCHes this and never touches other vaults.
|
|
2051
|
+
// The worker reads the same per-vault field (per-vault → global → true).
|
|
2052
|
+
// Validate the shape: when `auto_transcribe` is present it must carry a
|
|
2053
|
+
// boolean `enabled`.
|
|
2054
|
+
if (body.config?.auto_transcribe !== undefined) {
|
|
2055
|
+
const enabled = body.config.auto_transcribe?.enabled;
|
|
2056
|
+
if (typeof enabled !== "boolean") {
|
|
2057
|
+
return json(
|
|
2058
|
+
{
|
|
2059
|
+
error: "invalid_auto_transcribe",
|
|
2060
|
+
message: "auto_transcribe.enabled must be a boolean",
|
|
2061
|
+
},
|
|
2062
|
+
400,
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
vaultConfig.auto_transcribe = { ...vaultConfig.auto_transcribe, enabled };
|
|
2066
|
+
dirty = true;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
1955
2069
|
if (dirty && persist) persist();
|
|
1956
2070
|
return json(vaultResponse(vaultConfig));
|
|
1957
2071
|
}
|