@openparachute/vault 0.3.3 → 0.4.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 +15 -0
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/core/src/notes.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite";
|
|
1
|
+
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
2
2
|
import type { Note, NoteIndex, QueryOpts, VaultStats } from "./types.js";
|
|
3
3
|
import { normalizePath } from "./paths.js";
|
|
4
4
|
import {
|
|
5
5
|
buildOperatorClause,
|
|
6
6
|
isOperatorObject,
|
|
7
|
+
QueryError,
|
|
7
8
|
requireIndexedField,
|
|
8
9
|
} from "./query-operators.js";
|
|
9
10
|
|
|
@@ -35,15 +36,32 @@ export function createNote(
|
|
|
35
36
|
const metadata = opts?.metadata ? JSON.stringify(opts.metadata) : "{}";
|
|
36
37
|
const path = normalizePath(opts?.path);
|
|
37
38
|
|
|
39
|
+
// Empty-note invariant (#213): reject `content+path both absent`. Three
|
|
40
|
+
// legit shapes — content-only, path-only, both — only the empty+empty
|
|
41
|
+
// combo is the runaway-client signature that flooded a deployment with
|
|
42
|
+
// 7,453 pathless empty notes in one MCP burst. `content` only is a
|
|
43
|
+
// legitimate un-pathed jot; `path` only is a wikilink placeholder or
|
|
44
|
+
// `_schemas/*` config note.
|
|
45
|
+
if (!content.trim() && path === null) {
|
|
46
|
+
throw new EmptyNoteError();
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
// `updated_at` is set to `created_at` on insert so a client whose optimistic
|
|
39
50
|
// concurrency check falls back to `createdAt` on a never-updated note
|
|
40
51
|
// (the common shape: `note.updatedAt ?? note.createdAt`) matches the stored
|
|
41
52
|
// value. Hook-style writes with `skipUpdatedAt` preserve this; real user
|
|
42
53
|
// edits bump it strictly upward, so `updated_at > created_at` still means
|
|
43
54
|
// "user-touched since creation."
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
try {
|
|
56
|
+
db.prepare(
|
|
57
|
+
`INSERT INTO notes (id, content, path, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
58
|
+
).run(id, content, path, metadata, createdAt, createdAt);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (path !== null && isPathUniqueError(err)) {
|
|
61
|
+
throw new PathConflictError(path);
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
47
65
|
|
|
48
66
|
if (opts?.tags && opts.tags.length > 0) {
|
|
49
67
|
tagNote(db, id, opts.tags);
|
|
@@ -108,11 +126,98 @@ export class ConflictError extends Error {
|
|
|
108
126
|
}
|
|
109
127
|
}
|
|
110
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Thrown by `createNote` / `updateNote` when the requested path is already
|
|
131
|
+
* taken by another note. Surfaces as 409 at the HTTP layer so clients can
|
|
132
|
+
* distinguish "path taken — pick another" from a generic 500.
|
|
133
|
+
*
|
|
134
|
+
* Detected by catching SQLite's UNIQUE-constraint error on the
|
|
135
|
+
* `idx_notes_path_unique` partial index (schema v5+). Matches the tag
|
|
136
|
+
* "UNIQUE constraint failed: notes.path" rather than a numeric code so
|
|
137
|
+
* we keep working if bun:sqlite changes its error class hierarchy.
|
|
138
|
+
*/
|
|
139
|
+
export class PathConflictError extends Error {
|
|
140
|
+
code = "PATH_CONFLICT" as const;
|
|
141
|
+
path: string;
|
|
142
|
+
|
|
143
|
+
constructor(path: string) {
|
|
144
|
+
super(`path_conflict: another note already uses path "${path}"`);
|
|
145
|
+
this.name = "PathConflictError";
|
|
146
|
+
this.path = path;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Per-call item cap on `createNote`/`updateNote` batch entry points
|
|
152
|
+
* (MCP `create-note` / `update-note` and HTTP `POST /api/notes`).
|
|
153
|
+
* Single source of truth — both transports import from here so the cap
|
|
154
|
+
* can never silently drift between them. See #213 for the runaway-client
|
|
155
|
+
* incident that motivated the cap (7,453 empty notes in one MCP burst).
|
|
156
|
+
*/
|
|
157
|
+
export const MAX_BATCH_SIZE = 500;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Thrown by `createNote` / `updateNote` when the proposed note state has
|
|
161
|
+
* neither content nor path. The vault accepts un-pathed jots (content only)
|
|
162
|
+
* and path-only placeholders (wikilink stubs, `_schemas/*`), but a note
|
|
163
|
+
* with neither is the runaway-client signature flagged in #213 — one MCP
|
|
164
|
+
* burst flooded a deployment with 7,453 empty pathless rows. Surfaces as
|
|
165
|
+
* 400 at the HTTP layer.
|
|
166
|
+
*/
|
|
167
|
+
export class EmptyNoteError extends Error {
|
|
168
|
+
code = "EMPTY_NOTE" as const;
|
|
169
|
+
note_id: string | null;
|
|
170
|
+
/**
|
|
171
|
+
* Zero-based position in a batch call when the empty entry is rejected via
|
|
172
|
+
* the transport-layer pre-validation pass (HTTP `POST /api/notes` or MCP
|
|
173
|
+
* `create-note` with `notes: [...]`). `null` for single-update rejections
|
|
174
|
+
* and for Store-level throws that don't know their batch context.
|
|
175
|
+
*/
|
|
176
|
+
item_index: number | null;
|
|
177
|
+
|
|
178
|
+
constructor(noteId: string | null = null, itemIndex: number | null = null) {
|
|
179
|
+
super(
|
|
180
|
+
noteId
|
|
181
|
+
? `empty_note: update would leave note "${noteId}" with neither content nor path`
|
|
182
|
+
: itemIndex !== null
|
|
183
|
+
? `empty_note: a note must have either content or a path (item index ${itemIndex})`
|
|
184
|
+
: `empty_note: a note must have either content or a path`,
|
|
185
|
+
);
|
|
186
|
+
this.name = "EmptyNoteError";
|
|
187
|
+
this.note_id = noteId;
|
|
188
|
+
this.item_index = itemIndex;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Match bun:sqlite's UNIQUE-constraint error on the notes.path index. The
|
|
194
|
+
* error class is `SQLiteError` but matching on the message is sufficient
|
|
195
|
+
* here — the index name and column are stable parts of the schema, and
|
|
196
|
+
* bun:sqlite has carried this exact message text since 1.0.
|
|
197
|
+
*/
|
|
198
|
+
function isPathUniqueError(err: unknown): boolean {
|
|
199
|
+
if (!(err instanceof Error)) return false;
|
|
200
|
+
return err.message.includes("UNIQUE constraint failed: notes.path");
|
|
201
|
+
}
|
|
202
|
+
|
|
111
203
|
export function updateNote(
|
|
112
204
|
db: Database,
|
|
113
205
|
id: string,
|
|
114
206
|
updates: {
|
|
115
207
|
content?: string;
|
|
208
|
+
/**
|
|
209
|
+
* Atomic content append. Computed via SQL string concatenation
|
|
210
|
+
* (`content = content || ?`), so two concurrent appends never
|
|
211
|
+
* overwrite each other — the second simply lands after the first.
|
|
212
|
+
* Mutually exclusive with `content`.
|
|
213
|
+
*/
|
|
214
|
+
append?: string;
|
|
215
|
+
/**
|
|
216
|
+
* Atomic content prepend. Same SQL-level guarantee as `append`.
|
|
217
|
+
* Mutually exclusive with `content`. May be combined with `append`
|
|
218
|
+
* in a single call (both contributions land).
|
|
219
|
+
*/
|
|
220
|
+
prepend?: string;
|
|
116
221
|
path?: string;
|
|
117
222
|
metadata?: Record<string, unknown>;
|
|
118
223
|
created_at?: string;
|
|
@@ -125,8 +230,45 @@ export function updateNote(
|
|
|
125
230
|
if_updated_at?: string;
|
|
126
231
|
},
|
|
127
232
|
): Note {
|
|
233
|
+
if (updates.content !== undefined && (updates.append !== undefined || updates.prepend !== undefined)) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
"update-note: `content` is mutually exclusive with `append`/`prepend`. Pick full-replace or additive — not both in the same call.",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Empty-note invariant (#213): when this update touches content or path,
|
|
240
|
+
// reject if the post-state would be empty content + null path. We only
|
|
241
|
+
// enforce on transitions that actually touch the relevant fields, so
|
|
242
|
+
// metadata-only or tag-only updates against legacy empty rows still pass.
|
|
243
|
+
// Hook-style writes (skipUpdatedAt) are exempted — they're machine-level
|
|
244
|
+
// marker writes that legitimately may run against any shape of row.
|
|
245
|
+
const touchesContent = updates.content !== undefined
|
|
246
|
+
|| updates.append !== undefined
|
|
247
|
+
|| updates.prepend !== undefined;
|
|
248
|
+
const touchesPath = updates.path !== undefined;
|
|
249
|
+
if ((touchesContent || touchesPath) && !updates.skipUpdatedAt) {
|
|
250
|
+
const current = getNote(db, id);
|
|
251
|
+
if (current) {
|
|
252
|
+
let finalContent: string;
|
|
253
|
+
if (updates.content !== undefined) {
|
|
254
|
+
finalContent = updates.content;
|
|
255
|
+
} else if (touchesContent) {
|
|
256
|
+
finalContent = (updates.prepend ?? "") + current.content + (updates.append ?? "");
|
|
257
|
+
} else {
|
|
258
|
+
finalContent = current.content;
|
|
259
|
+
}
|
|
260
|
+
const finalPath = touchesPath ? normalizePath(updates.path) : (current.path ?? null);
|
|
261
|
+
if (!finalContent.trim() && !finalPath) {
|
|
262
|
+
throw new EmptyNoteError(id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// If `current` is null we fall through — existing code paths handle the
|
|
266
|
+
// missing-row case downstream (the conditional UPDATE returns 0 rows;
|
|
267
|
+
// OC throws ConflictError; non-OC returns silently).
|
|
268
|
+
}
|
|
269
|
+
|
|
128
270
|
const sets: string[] = [];
|
|
129
|
-
const values:
|
|
271
|
+
const values: (string | null)[] = [];
|
|
130
272
|
|
|
131
273
|
// Hooks and other machine-level writers pass `skipUpdatedAt: true` so
|
|
132
274
|
// their metadata markers don't look like user activity. See issue #44.
|
|
@@ -150,6 +292,34 @@ export function updateNote(
|
|
|
150
292
|
sets.push("content = ?");
|
|
151
293
|
values.push(updates.content);
|
|
152
294
|
}
|
|
295
|
+
if (updates.append !== undefined || updates.prepend !== undefined) {
|
|
296
|
+
// Atomic concat at the SQL layer. SQLite's `||` operator on the
|
|
297
|
+
// existing `content` column means a concurrent reader-then-writer
|
|
298
|
+
// race window is impossible: each `UPDATE` evaluates `content`
|
|
299
|
+
// under the write lock, so two simultaneous appends both land
|
|
300
|
+
// (in some order) instead of one clobbering the other.
|
|
301
|
+
//
|
|
302
|
+
// Frontmatter-aware prepend (#203): if the note opens with a YAML
|
|
303
|
+
// frontmatter block (`---\n...\n---\n`), the prepend is injected
|
|
304
|
+
// *after* the closing `---\n` so parsers that expect frontmatter
|
|
305
|
+
// at byte 0 still find it. Detection uses `instr(content, '\n---\n')`
|
|
306
|
+
// — the closing fence is whatever `\n---\n` appears after the
|
|
307
|
+
// opening one. If no frontmatter is detected, prepend goes at
|
|
308
|
+
// byte 0 as before. Atomicity is preserved: the entire transform
|
|
309
|
+
// is one UPDATE expression evaluated under the write lock.
|
|
310
|
+
sets.push(
|
|
311
|
+
"content = CASE "
|
|
312
|
+
+ "WHEN substr(content, 1, 4) = '---' || char(10) "
|
|
313
|
+
+ "AND instr(content, char(10) || '---' || char(10)) > 0 "
|
|
314
|
+
+ "THEN substr(content, 1, instr(content, char(10) || '---' || char(10)) + 4) || ? "
|
|
315
|
+
+ "|| substr(content, instr(content, char(10) || '---' || char(10)) + 5) || ? "
|
|
316
|
+
+ "ELSE ? || content || ? "
|
|
317
|
+
+ "END",
|
|
318
|
+
);
|
|
319
|
+
const prependVal = updates.prepend ?? "";
|
|
320
|
+
const appendVal = updates.append ?? "";
|
|
321
|
+
values.push(prependVal, appendVal, prependVal, appendVal);
|
|
322
|
+
}
|
|
153
323
|
if (updates.path !== undefined) {
|
|
154
324
|
sets.push("path = ?");
|
|
155
325
|
values.push(normalizePath(updates.path));
|
|
@@ -167,13 +337,15 @@ export function updateNote(
|
|
|
167
337
|
// need to validate the precondition; a conditional UPDATE that sets
|
|
168
338
|
// updated_at to itself does exactly that atomically — even a no-net-
|
|
169
339
|
// change UPDATE takes the write lock in WAL mode, so it still serializes
|
|
170
|
-
// with other writers
|
|
340
|
+
// with other writers. `RETURNING id` reports the row only when WHERE
|
|
341
|
+
// matched — `.changes` is unreliable inside multi-statement transactions
|
|
342
|
+
// (vault#261).
|
|
171
343
|
if (sets.length === 0) {
|
|
172
344
|
if (updates.if_updated_at !== undefined) {
|
|
173
345
|
const probe = db.prepare(
|
|
174
|
-
"UPDATE notes SET updated_at = updated_at WHERE id = ? AND updated_at IS ?",
|
|
175
|
-
).
|
|
176
|
-
if (probe
|
|
346
|
+
"UPDATE notes SET updated_at = updated_at WHERE id = ? AND updated_at IS ? RETURNING id",
|
|
347
|
+
).get(id, updates.if_updated_at) as { id: string } | null;
|
|
348
|
+
if (probe === null) {
|
|
177
349
|
throwConflictOrMissing(db, id, updates.if_updated_at);
|
|
178
350
|
}
|
|
179
351
|
}
|
|
@@ -187,9 +359,23 @@ export function updateNote(
|
|
|
187
359
|
values.push(updates.if_updated_at);
|
|
188
360
|
}
|
|
189
361
|
|
|
190
|
-
|
|
362
|
+
let matched: { id: string } | null = null;
|
|
363
|
+
try {
|
|
364
|
+
if (updates.if_updated_at !== undefined) {
|
|
365
|
+
matched = db.prepare(`${sql} RETURNING id`).get(...values) as
|
|
366
|
+
| { id: string }
|
|
367
|
+
| null;
|
|
368
|
+
} else {
|
|
369
|
+
db.prepare(sql).run(...values);
|
|
370
|
+
}
|
|
371
|
+
} catch (err) {
|
|
372
|
+
if (updates.path !== undefined && isPathUniqueError(err)) {
|
|
373
|
+
throw new PathConflictError(normalizePath(updates.path) ?? updates.path);
|
|
374
|
+
}
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
191
377
|
|
|
192
|
-
if (updates.if_updated_at !== undefined &&
|
|
378
|
+
if (updates.if_updated_at !== undefined && matched === null) {
|
|
193
379
|
throwConflictOrMissing(db, id, updates.if_updated_at);
|
|
194
380
|
}
|
|
195
381
|
|
|
@@ -212,21 +398,38 @@ export function deleteNote(db: Database, id: string): void {
|
|
|
212
398
|
|
|
213
399
|
export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
214
400
|
const conditions: string[] = [];
|
|
215
|
-
const params:
|
|
401
|
+
const params: SQLQueryBindings[] = [];
|
|
216
402
|
const joins: string[] = [];
|
|
217
403
|
|
|
218
|
-
// Include tags — "all" (default): must have ALL tags; "any": must have ANY tag
|
|
404
|
+
// Include tags — "all" (default): must have ALL tags; "any": must have ANY tag.
|
|
405
|
+
// The `_tagsExpanded` internal field carries per-input-tag descendant sets
|
|
406
|
+
// when the tag-hierarchy resolver (see core/src/tag-hierarchy.ts) has
|
|
407
|
+
// expanded the input — `tags: ["manual"]` becomes the set
|
|
408
|
+
// `{manual, voice, text, ...}` per declared `_tags/*` config notes. Falls
|
|
409
|
+
// back to `[opts.tags[i]]` (single-element set) when no expansion is set,
|
|
410
|
+
// preserving the original semantics.
|
|
219
411
|
if (opts.tags && opts.tags.length > 0) {
|
|
412
|
+
const tagSets: string[][] = (opts as QueryOpts & { _tagsExpanded?: string[][] })._tagsExpanded
|
|
413
|
+
?? opts.tags.map((t) => [t]);
|
|
220
414
|
const match = opts.tagMatch ?? "all";
|
|
221
415
|
if (match === "any") {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
416
|
+
// Flatten all expanded sets and dedupe — a note tagged with any one
|
|
417
|
+
// matches the input.
|
|
418
|
+
const flat = Array.from(new Set(tagSets.flat()));
|
|
419
|
+
if (flat.length > 0) {
|
|
420
|
+
const placeholders = flat.map(() => "?").join(", ");
|
|
421
|
+
joins.push(`JOIN note_tags nt_or ON nt_or.note_id = n.id AND nt_or.tag_name IN (${placeholders})`);
|
|
422
|
+
params.push(...flat);
|
|
423
|
+
}
|
|
225
424
|
} else {
|
|
226
|
-
|
|
425
|
+
// "all": one JOIN per input tag, each accepting the input or any descendant.
|
|
426
|
+
for (let i = 0; i < tagSets.length; i++) {
|
|
427
|
+
const set = tagSets[i] ?? [];
|
|
428
|
+
if (set.length === 0) continue;
|
|
227
429
|
const alias = `nt${i}`;
|
|
228
|
-
|
|
229
|
-
|
|
430
|
+
const placeholders = set.map(() => "?").join(", ");
|
|
431
|
+
joins.push(`JOIN note_tags ${alias} ON ${alias}.note_id = n.id AND ${alias}.tag_name IN (${placeholders})`);
|
|
432
|
+
params.push(...set);
|
|
230
433
|
}
|
|
231
434
|
}
|
|
232
435
|
}
|
|
@@ -259,6 +462,20 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
259
462
|
);
|
|
260
463
|
}
|
|
261
464
|
|
|
465
|
+
// ID set filter — used by `near` to push neighborhood scoping into SQL so
|
|
466
|
+
// that LIMIT applies to the neighborhood, not the whole notes table.
|
|
467
|
+
if (opts.ids !== undefined) {
|
|
468
|
+
if (opts.ids.length === 0) {
|
|
469
|
+
// Caller asked for "in this empty set" — no rows match. Short-circuit
|
|
470
|
+
// with an always-false condition; building `IN ()` would be a SQL error.
|
|
471
|
+
conditions.push("0 = 1");
|
|
472
|
+
} else {
|
|
473
|
+
const placeholders = opts.ids.map(() => "?").join(", ");
|
|
474
|
+
conditions.push(`n.id IN (${placeholders})`);
|
|
475
|
+
params.push(...opts.ids);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
262
479
|
// Exact path match (case-insensitive)
|
|
263
480
|
if (opts.path) {
|
|
264
481
|
conditions.push("n.path = ? COLLATE NOCASE");
|
|
@@ -291,14 +508,51 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
291
508
|
}
|
|
292
509
|
}
|
|
293
510
|
|
|
294
|
-
// Date range
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
511
|
+
// Date range. Two accepted shapes:
|
|
512
|
+
// - Legacy `dateFrom` / `dateTo` — always filters on `n.created_at`
|
|
513
|
+
// (vault ingestion time).
|
|
514
|
+
// - Generalized `dateFilter: { field, from, to }` — filters on the
|
|
515
|
+
// named field. `created_at` (default) maps to `n.created_at`; any
|
|
516
|
+
// other field must be declared `indexed: true` so the SQL targets
|
|
517
|
+
// a real B-tree index. The two shapes are mutually exclusive — the
|
|
518
|
+
// combination would silently AND, which would be surprising.
|
|
519
|
+
const hasLegacyDate = opts.dateFrom !== undefined || opts.dateTo !== undefined;
|
|
520
|
+
const hasDateFilter = opts.dateFilter !== undefined;
|
|
521
|
+
if (hasLegacyDate && hasDateFilter) {
|
|
522
|
+
throw new QueryError(
|
|
523
|
+
`cannot combine top-level date_from/date_to with date_filter — pass one or the other`,
|
|
524
|
+
"INVALID_QUERY",
|
|
525
|
+
);
|
|
298
526
|
}
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
527
|
+
if (hasDateFilter) {
|
|
528
|
+
const filter = opts.dateFilter!;
|
|
529
|
+
const field = filter.field ?? "created_at";
|
|
530
|
+
let column: string;
|
|
531
|
+
if (field === "created_at") {
|
|
532
|
+
column = "n.created_at";
|
|
533
|
+
} else {
|
|
534
|
+
// Re-uses the same indexed-field gate as `metadata` operator queries
|
|
535
|
+
// and `orderBy` so the error message and contract are consistent.
|
|
536
|
+
requireIndexedField(db, field);
|
|
537
|
+
column = `"meta_${field}"`;
|
|
538
|
+
}
|
|
539
|
+
if (filter.from !== undefined) {
|
|
540
|
+
conditions.push(`${column} >= ?`);
|
|
541
|
+
params.push(filter.from);
|
|
542
|
+
}
|
|
543
|
+
if (filter.to !== undefined) {
|
|
544
|
+
conditions.push(`${column} < ?`);
|
|
545
|
+
params.push(filter.to);
|
|
546
|
+
}
|
|
547
|
+
} else if (hasLegacyDate) {
|
|
548
|
+
if (opts.dateFrom) {
|
|
549
|
+
conditions.push("n.created_at >= ?");
|
|
550
|
+
params.push(opts.dateFrom);
|
|
551
|
+
}
|
|
552
|
+
if (opts.dateTo) {
|
|
553
|
+
conditions.push("n.created_at < ?");
|
|
554
|
+
params.push(opts.dateTo);
|
|
555
|
+
}
|
|
302
556
|
}
|
|
303
557
|
|
|
304
558
|
const direction = opts.sort === "desc" ? "DESC" : "ASC";
|
|
@@ -453,15 +707,34 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
453
707
|
|
|
454
708
|
db.exec("BEGIN");
|
|
455
709
|
try {
|
|
456
|
-
// Order matters:
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
710
|
+
// Order matters: note_tags' FK points at tags(name). Copy the old row's
|
|
711
|
+
// identity columns onto a new row keyed by `newName`, repoint note_tags,
|
|
712
|
+
// then drop the old row. Description/fields/relationships/parent_names
|
|
713
|
+
// travel with the rename — they're tag-identity data.
|
|
714
|
+
const old = db.prepare(
|
|
715
|
+
"SELECT description, fields, relationships, parent_names, created_at FROM tags WHERE name = ?",
|
|
716
|
+
).get(oldName) as
|
|
717
|
+
| { description: string | null; fields: string | null; relationships: string | null; parent_names: string | null; created_at: string | null }
|
|
718
|
+
| undefined;
|
|
719
|
+
const now = new Date().toISOString();
|
|
720
|
+
db.prepare(
|
|
721
|
+
`INSERT INTO tags (name, description, fields, relationships, parent_names, created_at, updated_at)
|
|
722
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
723
|
+
).run(
|
|
724
|
+
newName,
|
|
725
|
+
old?.description ?? null,
|
|
726
|
+
old?.fields ?? null,
|
|
727
|
+
old?.relationships ?? null,
|
|
728
|
+
old?.parent_names ?? null,
|
|
729
|
+
old?.created_at ?? now,
|
|
730
|
+
now,
|
|
731
|
+
);
|
|
732
|
+
const renamed = db.prepare(
|
|
733
|
+
"UPDATE note_tags SET tag_name = ? WHERE tag_name = ? RETURNING note_id",
|
|
734
|
+
).all(newName, oldName) as { note_id: string }[];
|
|
462
735
|
db.prepare("DELETE FROM tags WHERE name = ?").run(oldName);
|
|
463
736
|
db.exec("COMMIT");
|
|
464
|
-
return { renamed:
|
|
737
|
+
return { renamed: renamed.length };
|
|
465
738
|
} catch (err) {
|
|
466
739
|
db.exec("ROLLBACK");
|
|
467
740
|
throw err;
|
|
@@ -500,8 +773,9 @@ export function mergeTags(
|
|
|
500
773
|
const before = (countStmt.get(source) as { c: number }).c;
|
|
501
774
|
retagStmt.run(target, source);
|
|
502
775
|
deleteNoteTagsStmt.run(source);
|
|
503
|
-
//
|
|
504
|
-
//
|
|
776
|
+
// Dropping the tag row drops its identity (description, fields,
|
|
777
|
+
// relationships, parent_names) along with it — which is what we want
|
|
778
|
+
// for a merge: the source's identity is consumed by the target.
|
|
505
779
|
deleteTagStmt.run(source);
|
|
506
780
|
merged[source] = before;
|
|
507
781
|
}
|
|
@@ -615,6 +889,9 @@ export function getVaultStats(
|
|
|
615
889
|
const tagCountRow = db.prepare("SELECT COUNT(DISTINCT tag_name) as c FROM note_tags").get() as { c: number };
|
|
616
890
|
const tagCount = tagCountRow.c;
|
|
617
891
|
|
|
892
|
+
const attachmentCountRow = db.prepare("SELECT COUNT(*) as c FROM attachments").get() as { c: number };
|
|
893
|
+
const attachmentCount = attachmentCountRow.c;
|
|
894
|
+
|
|
618
895
|
const linkCountRow = db.prepare("SELECT COUNT(*) as c FROM links").get() as { c: number };
|
|
619
896
|
const linkCount = linkCountRow.c;
|
|
620
897
|
|
|
@@ -629,6 +906,7 @@ export function getVaultStats(
|
|
|
629
906
|
notesByMonth: monthRows,
|
|
630
907
|
topTags: topTagRows,
|
|
631
908
|
tagCount,
|
|
909
|
+
attachmentCount,
|
|
632
910
|
linkCount,
|
|
633
911
|
};
|
|
634
912
|
}
|
package/core/src/obsidian.ts
CHANGED
|
@@ -82,8 +82,8 @@ export function parseFrontmatter(raw: string): {
|
|
|
82
82
|
// Key: value pair — keys must be YAML-valid (word chars and hyphens, no spaces)
|
|
83
83
|
const kvMatch = line.match(/^([\w][\w-]*):\s*(.*)/);
|
|
84
84
|
if (kvMatch) {
|
|
85
|
-
const key = kvMatch[1]
|
|
86
|
-
const value = kvMatch[2]
|
|
85
|
+
const key = kvMatch[1]!;
|
|
86
|
+
const value = kvMatch[2]!.trim();
|
|
87
87
|
|
|
88
88
|
if (value === "[]") {
|
|
89
89
|
frontmatter[key] = [];
|
|
@@ -143,7 +143,7 @@ export function extractInlineTags(content: string): string[] {
|
|
|
143
143
|
const regex = /(?:^|\s)#([\w][\w/-]*[\w]|[\w])/gm;
|
|
144
144
|
let match: RegExpExecArray | null;
|
|
145
145
|
while ((match = regex.exec(stripped)) !== null) {
|
|
146
|
-
tags.add(match[1]
|
|
146
|
+
tags.add(match[1]!.toLowerCase());
|
|
147
147
|
}
|
|
148
148
|
return [...tags];
|
|
149
149
|
}
|
package/core/src/paths.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function normalizePath(path: string | null | undefined): string | null {
|
|
|
39
39
|
*/
|
|
40
40
|
export function pathTitle(path: string): string {
|
|
41
41
|
const segments = path.split("/");
|
|
42
|
-
return segments[segments.length - 1]
|
|
42
|
+
return segments[segments.length - 1]!;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* See `Parachute/Decisions/2026-04-19-metadata-indexing-via-tag-schemas`.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { Database } from "bun:sqlite";
|
|
18
|
+
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
19
19
|
import { getIndexedField, type IndexedField } from "./indexed-fields.js";
|
|
20
20
|
|
|
21
21
|
export const SUPPORTED_OPS = [
|
|
@@ -68,6 +68,22 @@ function validateOperatorObject(field: string, obj: Record<string, unknown>): vo
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function toBinding(field: string, op: string, value: unknown): SQLQueryBindings {
|
|
72
|
+
if (
|
|
73
|
+
value === null ||
|
|
74
|
+
typeof value === "string" ||
|
|
75
|
+
typeof value === "number" ||
|
|
76
|
+
typeof value === "boolean" ||
|
|
77
|
+
typeof value === "bigint"
|
|
78
|
+
) {
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
throw new QueryError(
|
|
82
|
+
`operator "${op}" on metadata field "${field}" expects a primitive value (string, number, boolean, bigint, or null), got ${typeof value}`,
|
|
83
|
+
"INVALID_OPERATOR_VALUE",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
/**
|
|
72
88
|
* Look up `field` in `indexed_fields` or throw a loud error suggesting the
|
|
73
89
|
* caller declare it via `update-tag` with `indexed: true`.
|
|
@@ -91,14 +107,14 @@ export function requireIndexedField(db: Database, field: string): IndexedField {
|
|
|
91
107
|
export function buildOperatorClause(
|
|
92
108
|
field: string,
|
|
93
109
|
opObj: Record<string, unknown>,
|
|
94
|
-
): { sql: string; params:
|
|
110
|
+
): { sql: string; params: SQLQueryBindings[] } {
|
|
95
111
|
validateOperatorObject(field, opObj);
|
|
96
112
|
// `field` came from indexed_fields (which validated it via FIELD_NAME_RE
|
|
97
113
|
// when the declaration was recorded), so interpolating it into the column
|
|
98
114
|
// name is safe.
|
|
99
115
|
const col = `"meta_${field}"`;
|
|
100
116
|
const parts: string[] = [];
|
|
101
|
-
const params:
|
|
117
|
+
const params: SQLQueryBindings[] = [];
|
|
102
118
|
|
|
103
119
|
for (const [op, value] of Object.entries(opObj)) {
|
|
104
120
|
switch (op as QueryOp) {
|
|
@@ -107,7 +123,7 @@ export function buildOperatorClause(
|
|
|
107
123
|
parts.push(`${col} IS NULL`);
|
|
108
124
|
} else {
|
|
109
125
|
parts.push(`${col} = ?`);
|
|
110
|
-
params.push(value);
|
|
126
|
+
params.push(toBinding(field, op, value));
|
|
111
127
|
}
|
|
112
128
|
break;
|
|
113
129
|
case "ne":
|
|
@@ -119,7 +135,7 @@ export function buildOperatorClause(
|
|
|
119
135
|
// that has no value for the field would be silently excluded. Be
|
|
120
136
|
// explicit: either the column is null, or the values differ.
|
|
121
137
|
parts.push(`(${col} IS NULL OR ${col} <> ?)`);
|
|
122
|
-
params.push(value);
|
|
138
|
+
params.push(toBinding(field, op, value));
|
|
123
139
|
}
|
|
124
140
|
break;
|
|
125
141
|
case "gt":
|
|
@@ -128,7 +144,7 @@ export function buildOperatorClause(
|
|
|
128
144
|
case "lte": {
|
|
129
145
|
const sym = op === "gt" ? ">" : op === "gte" ? ">=" : op === "lt" ? "<" : "<=";
|
|
130
146
|
parts.push(`${col} ${sym} ?`);
|
|
131
|
-
params.push(value);
|
|
147
|
+
params.push(toBinding(field, op, value));
|
|
132
148
|
break;
|
|
133
149
|
}
|
|
134
150
|
case "in":
|
|
@@ -152,7 +168,7 @@ export function buildOperatorClause(
|
|
|
152
168
|
} else {
|
|
153
169
|
parts.push(`(${col} IS NULL OR ${col} NOT IN (${placeholders}))`);
|
|
154
170
|
}
|
|
155
|
-
for (const v of value) params.push(v);
|
|
171
|
+
for (const v of value) params.push(toBinding(field, op, v));
|
|
156
172
|
break;
|
|
157
173
|
}
|
|
158
174
|
case "exists":
|