@openparachute/vault 0.2.4 → 0.3.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/.claude/settings.local.json +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -19
- package/core/src/hooks.ts +111 -3
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +153 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +95 -1
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +28 -1
- package/docs/HTTP_API.md +105 -1
- package/docs/auth-model.md +340 -0
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/bind.test.ts +28 -0
- package/src/bind.ts +19 -0
- package/src/cli.ts +228 -133
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +294 -0
- package/src/scopes.ts +253 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +73 -9
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +864 -0
- package/src/transcription-worker.ts +501 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -16
package/core/src/mcp.ts
CHANGED
|
@@ -4,6 +4,8 @@ import * as noteOps from "./notes.js";
|
|
|
4
4
|
import { filterMetadata } from "./notes.js";
|
|
5
5
|
import * as linkOps from "./links.js";
|
|
6
6
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
7
|
+
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
8
|
+
import * as indexedFieldOps from "./indexed-fields.js";
|
|
7
9
|
import {
|
|
8
10
|
expandContent,
|
|
9
11
|
DEFAULT_EXPAND_DEPTH,
|
|
@@ -101,10 +103,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
101
103
|
},
|
|
102
104
|
tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
|
|
103
105
|
exclude_tags: { type: "array", items: { type: "string" }, description: "Exclude notes with these tags" },
|
|
106
|
+
has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
|
|
107
|
+
has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction)." },
|
|
104
108
|
path: { type: "string", description: "Exact path match (case-insensitive)" },
|
|
105
109
|
path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
|
|
106
110
|
search: { type: "string", description: "Full-text search query" },
|
|
107
|
-
metadata: {
|
|
111
|
+
metadata: {
|
|
112
|
+
type: "object",
|
|
113
|
+
description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
|
|
114
|
+
},
|
|
115
|
+
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
|
|
108
116
|
date_from: { type: "string", description: "Start date (ISO, inclusive)" },
|
|
109
117
|
date_to: { type: "string", description: "End date (ISO, exclusive)" },
|
|
110
118
|
near: {
|
|
@@ -201,12 +209,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
201
209
|
tags,
|
|
202
210
|
tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
203
211
|
excludeTags: params.exclude_tags as string[] | undefined,
|
|
212
|
+
hasTags: params.has_tags as boolean | undefined,
|
|
213
|
+
hasLinks: params.has_links as boolean | undefined,
|
|
204
214
|
path: params.path as string | undefined,
|
|
205
215
|
pathPrefix: params.path_prefix as string | undefined,
|
|
206
216
|
metadata: params.metadata as Record<string, unknown> | undefined,
|
|
207
217
|
dateFrom: params.date_from as string | undefined,
|
|
208
218
|
dateTo: params.date_to as string | undefined,
|
|
209
219
|
sort: params.sort as "asc" | "desc" | undefined,
|
|
220
|
+
orderBy: params.order_by as string | undefined,
|
|
210
221
|
limit: (params.limit as number) ?? 50,
|
|
211
222
|
offset: params.offset as number | undefined,
|
|
212
223
|
});
|
|
@@ -348,7 +359,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
348
359
|
- \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
|
|
349
360
|
- When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
|
|
350
361
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
351
|
-
- Optimistic concurrency
|
|
362
|
+
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally.`,
|
|
352
363
|
inputSchema: {
|
|
353
364
|
type: "object",
|
|
354
365
|
properties: {
|
|
@@ -357,7 +368,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
357
368
|
path: { type: "string", description: "New path" },
|
|
358
369
|
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
|
|
359
370
|
created_at: { type: "string", description: "New created_at timestamp" },
|
|
360
|
-
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since." },
|
|
371
|
+
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set." },
|
|
372
|
+
force: { type: "boolean", description: "Override the required `if_updated_at` check and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe." },
|
|
361
373
|
tags: {
|
|
362
374
|
type: "object",
|
|
363
375
|
properties: {
|
|
@@ -405,7 +417,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
405
417
|
path: { type: "string" },
|
|
406
418
|
metadata: { type: "object" },
|
|
407
419
|
created_at: { type: "string" },
|
|
408
|
-
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since." },
|
|
420
|
+
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item." },
|
|
421
|
+
force: { type: "boolean", description: "Override the required `if_updated_at` check for this item." },
|
|
409
422
|
tags: { type: "object" },
|
|
410
423
|
links: { type: "object" },
|
|
411
424
|
},
|
|
@@ -423,6 +436,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
423
436
|
for (const item of items) {
|
|
424
437
|
const note = requireNote(db, item.id as string);
|
|
425
438
|
|
|
439
|
+
// --- Safety-by-default: refuse mutations without a precondition ---
|
|
440
|
+
// The caller must either echo the note's last-seen `updated_at`
|
|
441
|
+
// (`if_updated_at`) so the conditional UPDATE can catch lost
|
|
442
|
+
// writes, or explicitly opt out with `force: true`. This runs
|
|
443
|
+
// *before* any DB writes so a rejection leaves the note untouched.
|
|
444
|
+
if (item.if_updated_at === undefined && item.force !== true) {
|
|
445
|
+
throw new PreconditionRequiredError(note.id, note.path ?? null);
|
|
446
|
+
}
|
|
447
|
+
|
|
426
448
|
// --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
|
|
427
449
|
// We compute the cleaned content so we can do the core UPDATE first
|
|
428
450
|
// (with if_updated_at atomically) before any link deletions. If the
|
|
@@ -585,6 +607,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
585
607
|
type: { type: "string", description: "Field type: string, boolean, integer" },
|
|
586
608
|
description: { type: "string" },
|
|
587
609
|
enum: { type: "array", items: { type: "string" }, description: "Allowed values (first is default)" },
|
|
610
|
+
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed." },
|
|
588
611
|
},
|
|
589
612
|
required: ["type"],
|
|
590
613
|
},
|
|
@@ -595,11 +618,76 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
595
618
|
execute: (params) => {
|
|
596
619
|
const tag = params.tag as string;
|
|
597
620
|
const existing = tagSchemaOps.getTagSchema(db, tag);
|
|
598
|
-
const
|
|
599
|
-
|
|
621
|
+
const incomingFields = (params.fields as Record<string, TagFieldSchema> | undefined) ?? {};
|
|
622
|
+
const mergedFields: Record<string, TagFieldSchema> = {
|
|
623
|
+
...(existing?.fields ?? {}),
|
|
624
|
+
...incomingFields,
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
// Validate cross-tag consistency on fields being (re)declared in this
|
|
628
|
+
// call. `type` and `indexed` are global — all declarers must agree.
|
|
629
|
+
// `description` and `enum` are per-tag, so we don't compare them.
|
|
630
|
+
const otherSchemas = tagSchemaOps
|
|
631
|
+
.listTagSchemas(db)
|
|
632
|
+
.filter((s) => s.tag !== tag);
|
|
633
|
+
for (const [fieldName, spec] of Object.entries(incomingFields)) {
|
|
634
|
+
const incomingIndexed = spec.indexed === true;
|
|
635
|
+
for (const other of otherSchemas) {
|
|
636
|
+
const otherSpec = other.fields?.[fieldName];
|
|
637
|
+
if (!otherSpec) continue;
|
|
638
|
+
if (otherSpec.type !== spec.type) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`field "${fieldName}" type conflict: tag "${tag}" declares "${spec.type}"; tag "${other.tag}" declares "${otherSpec.type}". Types must agree across all declarers.`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
if ((otherSpec.indexed === true) !== incomingIndexed) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`field "${fieldName}" indexed-flag conflict: tag "${tag}" sets indexed=${incomingIndexed}; tag "${other.tag}" sets indexed=${otherSpec.indexed === true}. Must match across all declarers — change them atomically or not at all.`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (incomingIndexed) {
|
|
650
|
+
const mapped = indexedFieldOps.mapFieldType(spec.type);
|
|
651
|
+
if (!mapped) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
indexedFieldOps.validateFieldName(fieldName);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Persist the schema first, then reconcile indexing lifecycle. An
|
|
661
|
+
// error here would leave the on-disk schema untouched, matching
|
|
662
|
+
// prior behavior.
|
|
663
|
+
const result = tagSchemaOps.upsertTagSchema(db, tag, {
|
|
600
664
|
description: (params.description as string | undefined) ?? existing?.description,
|
|
601
665
|
fields: Object.keys(mergedFields).length > 0 ? mergedFields : undefined,
|
|
602
666
|
});
|
|
667
|
+
|
|
668
|
+
// Diff indexed state for this tag: what it indexed before vs. now.
|
|
669
|
+
const priorIndexed = new Set(
|
|
670
|
+
Object.entries(existing?.fields ?? {})
|
|
671
|
+
.filter(([, v]) => v.indexed === true)
|
|
672
|
+
.map(([k]) => k),
|
|
673
|
+
);
|
|
674
|
+
const nextIndexed = new Set(
|
|
675
|
+
Object.entries(mergedFields)
|
|
676
|
+
.filter(([, v]) => v.indexed === true)
|
|
677
|
+
.map(([k]) => k),
|
|
678
|
+
);
|
|
679
|
+
for (const fieldName of nextIndexed) {
|
|
680
|
+
const spec = mergedFields[fieldName]!;
|
|
681
|
+
const mapped = indexedFieldOps.mapFieldType(spec.type)!;
|
|
682
|
+
indexedFieldOps.declareField(db, fieldName, mapped, tag);
|
|
683
|
+
}
|
|
684
|
+
for (const fieldName of priorIndexed) {
|
|
685
|
+
if (!nextIndexed.has(fieldName)) {
|
|
686
|
+
indexedFieldOps.releaseField(db, fieldName, tag);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return result;
|
|
603
691
|
},
|
|
604
692
|
},
|
|
605
693
|
|
|
@@ -618,6 +706,17 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
618
706
|
},
|
|
619
707
|
execute: async (params) => {
|
|
620
708
|
const tag = params.tag as string;
|
|
709
|
+
// Release any indexed fields this tag declared before the schema
|
|
710
|
+
// row disappears. releaseField drops the generated column + index
|
|
711
|
+
// when the declarer set empties.
|
|
712
|
+
const schema = tagSchemaOps.getTagSchema(db, tag);
|
|
713
|
+
if (schema?.fields) {
|
|
714
|
+
for (const [fieldName, spec] of Object.entries(schema.fields)) {
|
|
715
|
+
if (spec.indexed === true) {
|
|
716
|
+
indexedFieldOps.releaseField(db, fieldName, tag);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
621
720
|
// Delete schema first (FK cascade would handle it, but be explicit)
|
|
622
721
|
tagSchemaOps.deleteTagSchema(db, tag);
|
|
623
722
|
return await store.deleteTag(tag);
|
|
@@ -733,3 +832,25 @@ function normalizeTags(tag: unknown): string[] | undefined {
|
|
|
733
832
|
// conditional-UPDATE implementation that raises it.
|
|
734
833
|
export { ConflictError } from "./notes.js";
|
|
735
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Thrown by the `update-note` MCP tool (and the REST PATCH handler) when a
|
|
837
|
+
* caller tries to mutate a note without either an `if_updated_at` token or
|
|
838
|
+
* an explicit `force: true` opt-out. The `if_updated_at` requirement is the
|
|
839
|
+
* safety-by-default posture — we'd rather refuse an ambiguous write than
|
|
840
|
+
* silently overwrite someone else's edit.
|
|
841
|
+
*/
|
|
842
|
+
export class PreconditionRequiredError extends Error {
|
|
843
|
+
code = "PRECONDITION_REQUIRED" as const;
|
|
844
|
+
note_id: string;
|
|
845
|
+
note_path: string | null;
|
|
846
|
+
|
|
847
|
+
constructor(noteId: string, notePath: string | null) {
|
|
848
|
+
super(
|
|
849
|
+
`precondition required: update-note rejects an item without \`if_updated_at\` (read the note's updated_at and echo it) or \`force: true\` (explicit override). note="${noteId}"`,
|
|
850
|
+
);
|
|
851
|
+
this.name = "PreconditionRequiredError";
|
|
852
|
+
this.note_id = noteId;
|
|
853
|
+
this.note_path = notePath;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
package/core/src/notes.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { Note, NoteIndex, QueryOpts, VaultStats } from "./types.js";
|
|
3
3
|
import { normalizePath } from "./paths.js";
|
|
4
|
+
import {
|
|
5
|
+
buildOperatorClause,
|
|
6
|
+
isOperatorObject,
|
|
7
|
+
requireIndexedField,
|
|
8
|
+
} from "./query-operators.js";
|
|
4
9
|
|
|
5
10
|
let idCounter = 0;
|
|
6
11
|
|
|
@@ -30,9 +35,15 @@ export function createNote(
|
|
|
30
35
|
const metadata = opts?.metadata ? JSON.stringify(opts.metadata) : "{}";
|
|
31
36
|
const path = normalizePath(opts?.path);
|
|
32
37
|
|
|
38
|
+
// `updated_at` is set to `created_at` on insert so a client whose optimistic
|
|
39
|
+
// concurrency check falls back to `createdAt` on a never-updated note
|
|
40
|
+
// (the common shape: `note.updatedAt ?? note.createdAt`) matches the stored
|
|
41
|
+
// value. Hook-style writes with `skipUpdatedAt` preserve this; real user
|
|
42
|
+
// edits bump it strictly upward, so `updated_at > created_at` still means
|
|
43
|
+
// "user-touched since creation."
|
|
33
44
|
db.prepare(
|
|
34
|
-
`INSERT INTO notes (id, content, path, metadata, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
35
|
-
).run(id, content, path, metadata, createdAt);
|
|
45
|
+
`INSERT INTO notes (id, content, path, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
46
|
+
).run(id, content, path, metadata, createdAt, createdAt);
|
|
36
47
|
|
|
37
48
|
if (opts?.tags && opts.tags.length > 0) {
|
|
38
49
|
tagNote(db, id, opts.tags);
|
|
@@ -81,15 +92,17 @@ export function getNotes(db: Database, ids: string[]): Note[] {
|
|
|
81
92
|
export class ConflictError extends Error {
|
|
82
93
|
code = "CONFLICT" as const;
|
|
83
94
|
note_id: string;
|
|
95
|
+
note_path: string | null;
|
|
84
96
|
current_updated_at: string | null;
|
|
85
97
|
expected_updated_at: string;
|
|
86
98
|
|
|
87
|
-
constructor(noteId: string, current: string | null, expected: string) {
|
|
99
|
+
constructor(noteId: string, notePath: string | null, current: string | null, expected: string) {
|
|
88
100
|
super(
|
|
89
101
|
`conflict: note "${noteId}" has been modified (current updated_at=${current ?? "null"}, expected=${expected})`,
|
|
90
102
|
);
|
|
91
103
|
this.name = "ConflictError";
|
|
92
104
|
this.note_id = noteId;
|
|
105
|
+
this.note_path = notePath;
|
|
93
106
|
this.current_updated_at = current;
|
|
94
107
|
this.expected_updated_at = expected;
|
|
95
108
|
}
|
|
@@ -184,13 +197,13 @@ export function updateNote(
|
|
|
184
197
|
}
|
|
185
198
|
|
|
186
199
|
function throwConflictOrMissing(db: Database, id: string, expected: string): never {
|
|
187
|
-
const row = db.prepare("SELECT updated_at FROM notes WHERE id = ?").get(id) as
|
|
188
|
-
| { updated_at: string | null }
|
|
200
|
+
const row = db.prepare("SELECT updated_at, path FROM notes WHERE id = ?").get(id) as
|
|
201
|
+
| { updated_at: string | null; path: string | null }
|
|
189
202
|
| undefined;
|
|
190
203
|
if (!row) {
|
|
191
204
|
throw new Error(`Note not found: "${id}"`);
|
|
192
205
|
}
|
|
193
|
-
throw new ConflictError(id, row.updated_at, expected);
|
|
206
|
+
throw new ConflictError(id, row.path, row.updated_at, expected);
|
|
194
207
|
}
|
|
195
208
|
|
|
196
209
|
export function deleteNote(db: Database, id: string): void {
|
|
@@ -226,6 +239,26 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
226
239
|
}
|
|
227
240
|
}
|
|
228
241
|
|
|
242
|
+
// Presence: has_tags. When specific tags were already supplied, skip —
|
|
243
|
+
// the existing join/condition already enforces that any result has tags.
|
|
244
|
+
const filterByTags = Boolean(opts.tags && opts.tags.length > 0);
|
|
245
|
+
if (opts.hasTags !== undefined && !filterByTags) {
|
|
246
|
+
conditions.push(
|
|
247
|
+
opts.hasTags
|
|
248
|
+
? `EXISTS (SELECT 1 FROM note_tags ht WHERE ht.note_id = n.id)`
|
|
249
|
+
: `NOT EXISTS (SELECT 1 FROM note_tags ht WHERE ht.note_id = n.id)`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Presence: has_links (either direction counts).
|
|
254
|
+
if (opts.hasLinks !== undefined) {
|
|
255
|
+
conditions.push(
|
|
256
|
+
opts.hasLinks
|
|
257
|
+
? `EXISTS (SELECT 1 FROM links hl WHERE hl.source_id = n.id OR hl.target_id = n.id)`
|
|
258
|
+
: `NOT EXISTS (SELECT 1 FROM links hl WHERE hl.source_id = n.id OR hl.target_id = n.id)`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
229
262
|
// Exact path match (case-insensitive)
|
|
230
263
|
if (opts.path) {
|
|
231
264
|
conditions.push("n.path = ? COLLATE NOCASE");
|
|
@@ -238,11 +271,23 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
238
271
|
params.push(opts.pathPrefix + "%");
|
|
239
272
|
}
|
|
240
273
|
|
|
241
|
-
// Metadata filters
|
|
274
|
+
// Metadata filters — operator objects route through the indexed generated
|
|
275
|
+
// column (fast, loud errors on non-indexed fields); primitives keep the
|
|
276
|
+
// existing JSON-scan exact-match behavior for backcompat.
|
|
242
277
|
if (opts.metadata) {
|
|
243
278
|
for (const [key, value] of Object.entries(opts.metadata)) {
|
|
244
|
-
|
|
245
|
-
|
|
279
|
+
if (isOperatorObject(value)) {
|
|
280
|
+
requireIndexedField(db, key);
|
|
281
|
+
const { sql, params: opParams } = buildOperatorClause(
|
|
282
|
+
key,
|
|
283
|
+
value as Record<string, unknown>,
|
|
284
|
+
);
|
|
285
|
+
conditions.push(sql);
|
|
286
|
+
params.push(...opParams);
|
|
287
|
+
} else {
|
|
288
|
+
conditions.push(`json_extract(n.metadata, '$.' || ?) = ?`);
|
|
289
|
+
params.push(key, typeof value === "string" ? value : JSON.stringify(value));
|
|
290
|
+
}
|
|
246
291
|
}
|
|
247
292
|
}
|
|
248
293
|
|
|
@@ -256,7 +301,18 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
256
301
|
params.push(opts.dateTo);
|
|
257
302
|
}
|
|
258
303
|
|
|
259
|
-
const
|
|
304
|
+
const direction = opts.sort === "desc" ? "DESC" : "ASC";
|
|
305
|
+
let orderBy: string;
|
|
306
|
+
if (opts.orderBy) {
|
|
307
|
+
requireIndexedField(db, opts.orderBy);
|
|
308
|
+
// `orderBy` came from indexed_fields (validated on declaration), so
|
|
309
|
+
// the column name is safe to interpolate. Append created_at as a
|
|
310
|
+
// stable tiebreaker so two rows with the same indexed value have a
|
|
311
|
+
// deterministic order.
|
|
312
|
+
orderBy = `"meta_${opts.orderBy}" ${direction}, n.created_at ${direction}`;
|
|
313
|
+
} else {
|
|
314
|
+
orderBy = `n.created_at ${direction}`;
|
|
315
|
+
}
|
|
260
316
|
const limit = typeof opts.limit === "number" ? opts.limit : 100;
|
|
261
317
|
const offset = typeof opts.offset === "number" ? opts.offset : 0;
|
|
262
318
|
|
|
@@ -375,6 +431,90 @@ export function deleteTag(db: Database, name: string): { deleted: boolean; notes
|
|
|
375
431
|
return { deleted: true, notes_untagged: notesUntagged };
|
|
376
432
|
}
|
|
377
433
|
|
|
434
|
+
// The UNIQUE PRIMARY KEY on tags.name means rename-to-existing is ambiguous:
|
|
435
|
+
// do you drop the source, or retag-and-drop? Callers must pick — rename errors
|
|
436
|
+
// out; mergeTags explicitly retags.
|
|
437
|
+
export type RenameTagResult =
|
|
438
|
+
| { renamed: number }
|
|
439
|
+
| { error: "not_found" }
|
|
440
|
+
| { error: "target_exists" };
|
|
441
|
+
|
|
442
|
+
export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
|
|
443
|
+
if (oldName === newName) {
|
|
444
|
+
const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
|
|
445
|
+
return exists ? { renamed: 0 } : { error: "not_found" };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const oldExists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
|
|
449
|
+
if (!oldExists) return { error: "not_found" };
|
|
450
|
+
|
|
451
|
+
const newExists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(newName);
|
|
452
|
+
if (newExists) return { error: "target_exists" };
|
|
453
|
+
|
|
454
|
+
db.exec("BEGIN");
|
|
455
|
+
try {
|
|
456
|
+
// Order matters: the note_tags FK points at tags(name), and tag_schemas'
|
|
457
|
+
// FK cascades on delete. Seed the new row, move the schema + note_tags
|
|
458
|
+
// onto it, then drop the old row.
|
|
459
|
+
db.prepare("INSERT INTO tags (name) VALUES (?)").run(newName);
|
|
460
|
+
db.prepare("UPDATE tag_schemas SET tag_name = ? WHERE tag_name = ?").run(newName, oldName);
|
|
461
|
+
const updated = db.prepare("UPDATE note_tags SET tag_name = ? WHERE tag_name = ?").run(newName, oldName);
|
|
462
|
+
db.prepare("DELETE FROM tags WHERE name = ?").run(oldName);
|
|
463
|
+
db.exec("COMMIT");
|
|
464
|
+
return { renamed: Number(updated.changes) };
|
|
465
|
+
} catch (err) {
|
|
466
|
+
db.exec("ROLLBACK");
|
|
467
|
+
throw err;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function mergeTags(
|
|
472
|
+
db: Database,
|
|
473
|
+
sources: string[],
|
|
474
|
+
target: string,
|
|
475
|
+
): { merged: Record<string, number>; target: string } {
|
|
476
|
+
// Dedup + drop target-in-sources (self-merge is a no-op).
|
|
477
|
+
const uniqueSources = Array.from(new Set(sources)).filter((s) => s !== target);
|
|
478
|
+
|
|
479
|
+
const merged: Record<string, number> = {};
|
|
480
|
+
|
|
481
|
+
db.exec("BEGIN");
|
|
482
|
+
try {
|
|
483
|
+
// Target might not exist yet. Seed it so INSERT OR IGNORE into note_tags
|
|
484
|
+
// can reference it; leave any existing schema on target untouched.
|
|
485
|
+
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(target);
|
|
486
|
+
|
|
487
|
+
const retagStmt = db.prepare(
|
|
488
|
+
"INSERT OR IGNORE INTO note_tags (note_id, tag_name) SELECT note_id, ? FROM note_tags WHERE tag_name = ?",
|
|
489
|
+
);
|
|
490
|
+
const deleteNoteTagsStmt = db.prepare("DELETE FROM note_tags WHERE tag_name = ?");
|
|
491
|
+
const deleteTagStmt = db.prepare("DELETE FROM tags WHERE name = ?");
|
|
492
|
+
const countStmt = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?");
|
|
493
|
+
|
|
494
|
+
for (const source of uniqueSources) {
|
|
495
|
+
const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(source);
|
|
496
|
+
if (!exists) {
|
|
497
|
+
merged[source] = 0;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const before = (countStmt.get(source) as { c: number }).c;
|
|
501
|
+
retagStmt.run(target, source);
|
|
502
|
+
deleteNoteTagsStmt.run(source);
|
|
503
|
+
// tag_schemas has ON DELETE CASCADE from tags(name), so dropping the
|
|
504
|
+
// tag row also drops its schema — which is what we want for a merge.
|
|
505
|
+
deleteTagStmt.run(source);
|
|
506
|
+
merged[source] = before;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
db.exec("COMMIT");
|
|
510
|
+
} catch (err) {
|
|
511
|
+
db.exec("ROLLBACK");
|
|
512
|
+
throw err;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { merged, target };
|
|
516
|
+
}
|
|
517
|
+
|
|
378
518
|
// ---- Lean note index shape ----
|
|
379
519
|
|
|
380
520
|
/** Max code points in a NoteIndex preview. */
|
|
@@ -597,6 +737,8 @@ function rowToNote(row: NoteRow): Note {
|
|
|
597
737
|
path: row.path ?? undefined,
|
|
598
738
|
metadata,
|
|
599
739
|
createdAt: row.created_at,
|
|
600
|
-
|
|
740
|
+
// Legacy notes (pre-#70) may have NULL updated_at. Fall back to created_at
|
|
741
|
+
// so the optimistic-concurrency contract always has a real token to echo.
|
|
742
|
+
updatedAt: row.updated_at ?? row.created_at,
|
|
601
743
|
};
|
|
602
744
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata operator objects for `query-notes`.
|
|
3
|
+
*
|
|
4
|
+
* A metadata value that is a plain object whose keys are all drawn from
|
|
5
|
+
* {@link SUPPORTED_OPS} is treated as an operator query. Otherwise the value
|
|
6
|
+
* falls through to the existing exact-match behavior (primitive → JSON
|
|
7
|
+
* stringify compare).
|
|
8
|
+
*
|
|
9
|
+
* Operator queries route through the generated columns maintained by
|
|
10
|
+
* `indexed-fields`: `meta_<field>` on `notes`, backed by a B-tree index. The
|
|
11
|
+
* field must be declared `indexed: true` in some tag schema; otherwise we
|
|
12
|
+
* refuse with an actionable error (no silent fallback to a JSON scan, per the
|
|
13
|
+
* decision doc).
|
|
14
|
+
*
|
|
15
|
+
* See `Parachute/Decisions/2026-04-19-metadata-indexing-via-tag-schemas`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Database } from "bun:sqlite";
|
|
19
|
+
import { getIndexedField, type IndexedField } from "./indexed-fields.js";
|
|
20
|
+
|
|
21
|
+
export const SUPPORTED_OPS = [
|
|
22
|
+
"eq",
|
|
23
|
+
"ne",
|
|
24
|
+
"gt",
|
|
25
|
+
"gte",
|
|
26
|
+
"lt",
|
|
27
|
+
"lte",
|
|
28
|
+
"in",
|
|
29
|
+
"not_in",
|
|
30
|
+
"exists",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export type QueryOp = (typeof SUPPORTED_OPS)[number];
|
|
34
|
+
|
|
35
|
+
const OPS_SET: ReadonlySet<string> = new Set<string>(SUPPORTED_OPS);
|
|
36
|
+
|
|
37
|
+
export class QueryError extends Error {
|
|
38
|
+
override name = "QueryError";
|
|
39
|
+
code: string;
|
|
40
|
+
constructor(message: string, code = "INVALID_QUERY") {
|
|
41
|
+
super(message);
|
|
42
|
+
this.code = code;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns true when `value` is a non-array, non-null, non-empty plain object.
|
|
48
|
+
* The presence of *any* plain-object value commits the caller to operator-
|
|
49
|
+
* parsing: a misspelled operator like `{ bogus: 5 }` is a loud error rather
|
|
50
|
+
* than a silent fallback to JSON-blob exact-match. Nested-object exact-match
|
|
51
|
+
* on the JSON blob was never a meaningful use case.
|
|
52
|
+
*/
|
|
53
|
+
export function isOperatorObject(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return Object.keys(value as object).length > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateOperatorObject(field: string, obj: Record<string, unknown>): void {
|
|
61
|
+
for (const key of Object.keys(obj)) {
|
|
62
|
+
if (!OPS_SET.has(key)) {
|
|
63
|
+
throw new QueryError(
|
|
64
|
+
`unknown operator "${key}" on metadata field "${field}". Supported: ${SUPPORTED_OPS.join(", ")}.`,
|
|
65
|
+
"UNKNOWN_OPERATOR",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Look up `field` in `indexed_fields` or throw a loud error suggesting the
|
|
73
|
+
* caller declare it via `update-tag` with `indexed: true`.
|
|
74
|
+
*/
|
|
75
|
+
export function requireIndexedField(db: Database, field: string): IndexedField {
|
|
76
|
+
const row = getIndexedField(db, field);
|
|
77
|
+
if (!row) {
|
|
78
|
+
throw new QueryError(
|
|
79
|
+
`metadata field "${field}" is not indexed. To use operator queries or order_by on this field, declare it via update-tag with indexed: true.`,
|
|
80
|
+
"FIELD_NOT_INDEXED",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return row;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a SQL fragment + bound params for an operator object on an indexed
|
|
88
|
+
* metadata field. Each operator maps to a single AND clause; an object like
|
|
89
|
+
* `{ gt: 5, lt: 10 }` composes as `meta_<field> > 5 AND meta_<field> < 10`.
|
|
90
|
+
*/
|
|
91
|
+
export function buildOperatorClause(
|
|
92
|
+
field: string,
|
|
93
|
+
opObj: Record<string, unknown>,
|
|
94
|
+
): { sql: string; params: unknown[] } {
|
|
95
|
+
validateOperatorObject(field, opObj);
|
|
96
|
+
// `field` came from indexed_fields (which validated it via FIELD_NAME_RE
|
|
97
|
+
// when the declaration was recorded), so interpolating it into the column
|
|
98
|
+
// name is safe.
|
|
99
|
+
const col = `"meta_${field}"`;
|
|
100
|
+
const parts: string[] = [];
|
|
101
|
+
const params: unknown[] = [];
|
|
102
|
+
|
|
103
|
+
for (const [op, value] of Object.entries(opObj)) {
|
|
104
|
+
switch (op as QueryOp) {
|
|
105
|
+
case "eq":
|
|
106
|
+
if (value === null) {
|
|
107
|
+
parts.push(`${col} IS NULL`);
|
|
108
|
+
} else {
|
|
109
|
+
parts.push(`${col} = ?`);
|
|
110
|
+
params.push(value);
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
case "ne":
|
|
114
|
+
if (value === null) {
|
|
115
|
+
parts.push(`${col} IS NOT NULL`);
|
|
116
|
+
} else {
|
|
117
|
+
// Preserve "field is set AND not equal" semantics — SQLite's `<>`
|
|
118
|
+
// returns NULL (not true) when the LHS is NULL, so a notes row
|
|
119
|
+
// that has no value for the field would be silently excluded. Be
|
|
120
|
+
// explicit: either the column is null, or the values differ.
|
|
121
|
+
parts.push(`(${col} IS NULL OR ${col} <> ?)`);
|
|
122
|
+
params.push(value);
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
case "gt":
|
|
126
|
+
case "gte":
|
|
127
|
+
case "lt":
|
|
128
|
+
case "lte": {
|
|
129
|
+
const sym = op === "gt" ? ">" : op === "gte" ? ">=" : op === "lt" ? "<" : "<=";
|
|
130
|
+
parts.push(`${col} ${sym} ?`);
|
|
131
|
+
params.push(value);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "in":
|
|
135
|
+
case "not_in": {
|
|
136
|
+
if (!Array.isArray(value)) {
|
|
137
|
+
throw new QueryError(
|
|
138
|
+
`operator "${op}" on metadata field "${field}" expects an array`,
|
|
139
|
+
"INVALID_OPERATOR_VALUE",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (value.length === 0) {
|
|
143
|
+
// Empty IN is a contradiction; empty NOT IN is a no-op. Emit SQL
|
|
144
|
+
// that matches these semantics without running a parameterless
|
|
145
|
+
// `IN ()` (which is a syntax error in SQLite).
|
|
146
|
+
parts.push(op === "in" ? "0" : "1");
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
const placeholders = value.map(() => "?").join(", ");
|
|
150
|
+
if (op === "in") {
|
|
151
|
+
parts.push(`${col} IN (${placeholders})`);
|
|
152
|
+
} else {
|
|
153
|
+
parts.push(`(${col} IS NULL OR ${col} NOT IN (${placeholders}))`);
|
|
154
|
+
}
|
|
155
|
+
for (const v of value) params.push(v);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case "exists":
|
|
159
|
+
if (typeof value !== "boolean") {
|
|
160
|
+
throw new QueryError(
|
|
161
|
+
`operator "exists" on metadata field "${field}" expects a boolean`,
|
|
162
|
+
"INVALID_OPERATOR_VALUE",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
parts.push(value ? `${col} IS NOT NULL` : `${col} IS NULL`);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
sql: parts.length === 1 ? parts[0]! : `(${parts.join(" AND ")})`,
|
|
172
|
+
params,
|
|
173
|
+
};
|
|
174
|
+
}
|