@openparachute/vault 0.4.0 → 0.4.4-rc.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -2
- package/core/src/core.test.ts +1295 -526
- package/core/src/mcp.ts +129 -428
- package/core/src/notes.ts +405 -32
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +233 -171
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +103 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +52 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +330 -207
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +1052 -333
- package/core/src/note-schemas.ts +0 -232
package/core/src/schema.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
3
|
import { rebuildIndexes } from "./indexed-fields.js";
|
|
4
4
|
|
|
5
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 17;
|
|
6
6
|
|
|
7
7
|
export const SCHEMA_SQL = `
|
|
8
8
|
-- Notes: the universal record
|
|
@@ -69,37 +69,12 @@ CREATE TABLE IF NOT EXISTS links (
|
|
|
69
69
|
-- tags row directly. The CREATE TABLE was removed from SCHEMA_SQL after the
|
|
70
70
|
-- v14 data migration drops the table; existing v6+ vaults pick up the
|
|
71
71
|
-- migration on next boot. See migrateToV14.
|
|
72
|
-
|
|
73
|
-
-- Note schemas (v15): schema definitions used to validate notes by path
|
|
74
|
-
-- prefix or tag. Replaces the v6-era _schemas/NAME notes-as-config
|
|
75
|
-
-- convention. Validation is non-blocking — schemas surface warnings on
|
|
76
|
-
-- create/update responses, never reject the write. See
|
|
77
|
-
-- core/src/schema-defaults.ts and patterns/tag-data-model.md §Note schemas.
|
|
78
72
|
--
|
|
79
|
-
--
|
|
80
|
-
--
|
|
81
|
-
-- fields
|
|
82
|
-
--
|
|
83
|
-
|
|
84
|
-
name TEXT PRIMARY KEY,
|
|
85
|
-
description TEXT,
|
|
86
|
-
fields TEXT,
|
|
87
|
-
required TEXT,
|
|
88
|
-
created_at TEXT,
|
|
89
|
-
updated_at TEXT
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
-- Schema mappings (v15): replaces the singleton _schema_defaults note. One
|
|
93
|
-
-- row per match rule; the resolver walks the table at note-write time.
|
|
94
|
-
-- match_kind is constrained to 'path_prefix' or 'tag'. Composite PK so
|
|
95
|
-
-- (schema, kind, value) is naturally unique without an extra surrogate id.
|
|
96
|
-
-- ON DELETE CASCADE: dropping a schema cleans up its mappings.
|
|
97
|
-
CREATE TABLE IF NOT EXISTS schema_mappings (
|
|
98
|
-
schema_name TEXT NOT NULL REFERENCES note_schemas(name) ON DELETE CASCADE,
|
|
99
|
-
match_kind TEXT NOT NULL CHECK (match_kind IN ('path_prefix', 'tag')),
|
|
100
|
-
match_value TEXT NOT NULL,
|
|
101
|
-
PRIMARY KEY (schema_name, match_kind, match_value)
|
|
102
|
-
);
|
|
73
|
+
-- note_schemas + schema_mappings (v15) were retired in v17 (vault#267).
|
|
74
|
+
-- The two-table validation subsystem turned out to be a parallel path to
|
|
75
|
+
-- the per-tag fields column with zero operator usage; v17 drops both
|
|
76
|
+
-- tables and the six MCP tools that managed them. Validation now reads
|
|
77
|
+
-- tags.fields exclusively — see core/src/schema-defaults.ts.
|
|
103
78
|
|
|
104
79
|
-- Indexed fields: SSOT for generated columns and indexes on notes derived
|
|
105
80
|
-- from tag-declared fields with indexed=true. One row per indexed metadata
|
|
@@ -209,7 +184,6 @@ CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag_name, note_id);
|
|
|
209
184
|
CREATE INDEX IF NOT EXISTS idx_attachments_note ON attachments(note_id);
|
|
210
185
|
CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_id);
|
|
211
186
|
CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_id);
|
|
212
|
-
CREATE INDEX IF NOT EXISTS idx_schema_mappings_match ON schema_mappings(match_kind, match_value);
|
|
213
187
|
-- idx_tokens_vault_name is created in migrateToV16, not here. SCHEMA_SQL
|
|
214
188
|
-- runs BEFORE migrations; an upgrading v15 vault doesn't yet have the
|
|
215
189
|
-- vault_name column when this section evaluates, so the index has to
|
|
@@ -285,6 +259,13 @@ export function initSchema(db: Database): void {
|
|
|
285
259
|
// New mints via per-vault routes write the column explicitly. See vault#257.
|
|
286
260
|
migrateToV16(db);
|
|
287
261
|
|
|
262
|
+
// Migrate v16 → v17: rip the standalone `note_schemas` + `schema_mappings`
|
|
263
|
+
// subsystem. Validation now reads `tags.fields` exclusively. The two
|
|
264
|
+
// tables are dropped wholesale; if a vault carried rows we log a warning
|
|
265
|
+
// naming the dropped schemas/mappings so the operator can recreate them
|
|
266
|
+
// as `tags.fields` if needed. See vault#267.
|
|
267
|
+
migrateToV17(db);
|
|
268
|
+
|
|
288
269
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
289
270
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
290
271
|
rebuildIndexes(db);
|
|
@@ -572,6 +553,10 @@ function migrateToV14(db: Database): void {
|
|
|
572
553
|
* leaves the DB in either pre-v15 or post-v15 state, never partial.
|
|
573
554
|
*/
|
|
574
555
|
function migrateToV15(db: Database): void {
|
|
556
|
+
// note_schemas was dropped in v17; on any v17+ vault (or a v14→v17 skip),
|
|
557
|
+
// this guard returns immediately and the function is effectively dead code.
|
|
558
|
+
// Left in place rather than deleted because removing it would change initSchema's
|
|
559
|
+
// migration call ordering. Safe to delete in a future cleanup.
|
|
575
560
|
if (!hasTable(db, "note_schemas") || !hasTable(db, "notes")) return;
|
|
576
561
|
|
|
577
562
|
// Short-circuit: if either destination table already has data, the
|
|
@@ -721,6 +706,93 @@ function migrateToV16(db: Database): void {
|
|
|
721
706
|
db.exec("CREATE INDEX IF NOT EXISTS idx_tokens_vault_name ON tokens(vault_name)");
|
|
722
707
|
}
|
|
723
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Migrate v16 → v17: rip `note_schemas` + `schema_mappings` (vault#267).
|
|
711
|
+
*
|
|
712
|
+
* The two-table validation subsystem from v15 turned out to be a parallel
|
|
713
|
+
* path to `tags.fields` with zero operator usage. v17 drops both tables
|
|
714
|
+
* outright. Fresh vaults never see them; upgrading vaults lose them.
|
|
715
|
+
*
|
|
716
|
+
* If an upgrading vault DID carry rows (which Aaron's didn't, but a future
|
|
717
|
+
* operator's might), the migration logs the dropped names + mapping rules
|
|
718
|
+
* so the operator can re-create them as `tags.fields` declarations on the
|
|
719
|
+
* relevant tag rows. We don't try to auto-migrate path_prefix mappings —
|
|
720
|
+
* the new validation surface is tag-axis only, and a path_prefix → tag
|
|
721
|
+
* translation has no faithful one-to-one shape.
|
|
722
|
+
*
|
|
723
|
+
* Wrapped in BEGIN IMMEDIATE / COMMIT / ROLLBACK per the v14/v15/v16
|
|
724
|
+
* pattern from vault#251 — DROP TABLE statements are individually atomic,
|
|
725
|
+
* but the wrap means a crash mid-migration leaves either pre-v17 or
|
|
726
|
+
* post-v17 state, never partial.
|
|
727
|
+
*/
|
|
728
|
+
function migrateToV17(db: Database): void {
|
|
729
|
+
const hasNoteSchemas = hasTable(db, "note_schemas");
|
|
730
|
+
const hasSchemaMappings = hasTable(db, "schema_mappings");
|
|
731
|
+
if (!hasNoteSchemas && !hasSchemaMappings) return;
|
|
732
|
+
|
|
733
|
+
// Snapshot any data so the operator can recreate as `tags.fields` if
|
|
734
|
+
// needed. Read BEFORE the transaction so we don't lose the warning if
|
|
735
|
+
// the DROP fails (the COMMIT below atomically swaps state).
|
|
736
|
+
let droppedSchemas: { name: string; description: string | null }[] = [];
|
|
737
|
+
let droppedMappings: { schema_name: string; match_kind: string; match_value: string }[] = [];
|
|
738
|
+
if (hasNoteSchemas) {
|
|
739
|
+
droppedSchemas = db.prepare(
|
|
740
|
+
"SELECT name, description FROM note_schemas",
|
|
741
|
+
).all() as { name: string; description: string | null }[];
|
|
742
|
+
}
|
|
743
|
+
if (hasSchemaMappings) {
|
|
744
|
+
droppedMappings = db.prepare(
|
|
745
|
+
"SELECT schema_name, match_kind, match_value FROM schema_mappings",
|
|
746
|
+
).all() as { schema_name: string; match_kind: string; match_value: string }[];
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
db.exec("BEGIN IMMEDIATE");
|
|
750
|
+
try {
|
|
751
|
+
// Drop the index first — the index references the table; SQLite would
|
|
752
|
+
// tear it down on DROP TABLE but the explicit DROP keeps the order
|
|
753
|
+
// obvious if a future migration reads from sqlite_master mid-flight.
|
|
754
|
+
db.exec("DROP INDEX IF EXISTS idx_schema_mappings_match");
|
|
755
|
+
// schema_mappings has an FK to note_schemas — drop it first.
|
|
756
|
+
if (hasSchemaMappings) {
|
|
757
|
+
db.exec("DROP TABLE schema_mappings");
|
|
758
|
+
}
|
|
759
|
+
if (hasNoteSchemas) {
|
|
760
|
+
db.exec("DROP TABLE note_schemas");
|
|
761
|
+
}
|
|
762
|
+
db.exec("COMMIT");
|
|
763
|
+
} catch (err) {
|
|
764
|
+
db.exec("ROLLBACK");
|
|
765
|
+
throw err;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (droppedSchemas.length > 0 || droppedMappings.length > 0) {
|
|
769
|
+
const schemaNames = droppedSchemas.map((s) => s.name).join(", ");
|
|
770
|
+
const tagMappings = droppedMappings.filter((m) => m.match_kind === "tag");
|
|
771
|
+
const pathMappings = droppedMappings.filter((m) => m.match_kind === "path_prefix");
|
|
772
|
+
const lines: string[] = [
|
|
773
|
+
`[vault] migrated to schema v17 (vault#267): note_schemas + schema_mappings retired.`,
|
|
774
|
+
];
|
|
775
|
+
if (droppedSchemas.length > 0) {
|
|
776
|
+
lines.push(` dropped schemas (${droppedSchemas.length}): ${schemaNames}`);
|
|
777
|
+
}
|
|
778
|
+
if (tagMappings.length > 0) {
|
|
779
|
+
const list = tagMappings.map((m) => `${m.match_value}→${m.schema_name}`).join(", ");
|
|
780
|
+
lines.push(
|
|
781
|
+
` dropped tag mappings (${tagMappings.length}): ${list}`,
|
|
782
|
+
` recreate as \`tags.fields\` declarations on the relevant tag rows.`,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
if (pathMappings.length > 0) {
|
|
786
|
+
const list = pathMappings.map((m) => `${m.match_value}→${m.schema_name}`).join(", ");
|
|
787
|
+
lines.push(
|
|
788
|
+
` dropped path_prefix mappings (${pathMappings.length}): ${list}`,
|
|
789
|
+
` no path-prefix-driven validation in v17 — file vault#267 if you need this.`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
console.log(lines.join("\n"));
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
724
796
|
function hasTable(db: Database, name: string): boolean {
|
|
725
797
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
726
798
|
return !!row;
|
package/core/src/store.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
loadTagHierarchy,
|
|
12
12
|
getTagDescendants,
|
|
13
13
|
TAG_CONFIG_PREFIX,
|
|
14
|
+
DEFAULT_TAG_NAME,
|
|
14
15
|
type TagHierarchy,
|
|
15
16
|
} from "./tag-hierarchy.js";
|
|
16
17
|
import {
|
|
@@ -19,7 +20,6 @@ import {
|
|
|
19
20
|
type ResolvedSchemas,
|
|
20
21
|
type ValidationStatus,
|
|
21
22
|
} from "./schema-defaults.js";
|
|
22
|
-
import * as noteSchemaOps from "./note-schemas.js";
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
@@ -29,12 +29,11 @@ import * as noteSchemaOps from "./note-schemas.js";
|
|
|
29
29
|
export class BunSqliteStore implements Store {
|
|
30
30
|
public readonly hooks: HookRegistry;
|
|
31
31
|
|
|
32
|
-
// Lazy-built caches over the post-v14 `tags` table
|
|
33
|
-
// parent_names
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// post-write state.
|
|
32
|
+
// Lazy-built caches over the post-v14 `tags` table — hierarchy via
|
|
33
|
+
// parent_names, schema validation via `fields`. Null means "not yet
|
|
34
|
+
// loaded or invalidated"; the next read rebuilds. We invalidate
|
|
35
|
+
// synchronously inside the writers that mutate the source rows so reads
|
|
36
|
+
// after writes always see the post-write state.
|
|
38
37
|
private _tagHierarchy: TagHierarchy | null = null;
|
|
39
38
|
private _schemaConfig: ResolvedSchemas | null = null;
|
|
40
39
|
|
|
@@ -55,8 +54,8 @@ export class BunSqliteStore implements Store {
|
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
/**
|
|
58
|
-
* Lazy accessor for the
|
|
59
|
-
*
|
|
57
|
+
* Lazy accessor for the per-tag `fields` resolution. Same lifecycle as
|
|
58
|
+
* the tag hierarchy cache.
|
|
60
59
|
*/
|
|
61
60
|
private getSchemaConfig(): ResolvedSchemas {
|
|
62
61
|
if (!this._schemaConfig) this._schemaConfig = loadSchemaConfig(this.db);
|
|
@@ -79,9 +78,9 @@ export class BunSqliteStore implements Store {
|
|
|
79
78
|
* alongside new for rename cases (a note moved out of `_tags/` should
|
|
80
79
|
* still invalidate).
|
|
81
80
|
*
|
|
82
|
-
* Post-
|
|
83
|
-
* invalidation hook is on `
|
|
84
|
-
* `
|
|
81
|
+
* Post-v17 the schema-config cache is purely tag-driven — its
|
|
82
|
+
* invalidation hook is on `upsertTagSchema` / `upsertTagRecord` /
|
|
83
|
+
* `deleteTagSchema` / `deleteTag` (mutations of `tags.fields`).
|
|
85
84
|
*/
|
|
86
85
|
private invalidateConfigCachesForPath(path: string | null | undefined, oldPath?: string | null): void {
|
|
87
86
|
const isTagConfig = (p: string | null | undefined): boolean =>
|
|
@@ -203,6 +202,19 @@ export class BunSqliteStore implements Store {
|
|
|
203
202
|
}
|
|
204
203
|
}
|
|
205
204
|
|
|
205
|
+
async restoreNoteTimestamps(id: string, createdAt: string, updatedAt: string): Promise<void> {
|
|
206
|
+
// Import-only: direct UPDATE so the importer can restore a note's
|
|
207
|
+
// historical `created_at`/`updated_at` from the portable-md export
|
|
208
|
+
// bytes. `updateNote` either bumps `updated_at` to wall-clock-now or
|
|
209
|
+
// (with `skipUpdatedAt: true`) leaves it untouched — neither lets
|
|
210
|
+
// the importer write a specific historical timestamp. Skips hooks
|
|
211
|
+
// by design: this isn't a user-edit, it's a state restoration.
|
|
212
|
+
// See vault#308 PR 2.
|
|
213
|
+
this.db
|
|
214
|
+
.prepare("UPDATE notes SET created_at = ?, updated_at = ? WHERE id = ?")
|
|
215
|
+
.run(createdAt, updatedAt, id);
|
|
216
|
+
}
|
|
217
|
+
|
|
206
218
|
async deleteNote(id: string): Promise<void> {
|
|
207
219
|
// Read before delete so we can invalidate config caches on the way out.
|
|
208
220
|
const existing = noteOps.getNote(this.db, id);
|
|
@@ -219,16 +231,45 @@ export class BunSqliteStore implements Store {
|
|
|
219
231
|
* each input tag is replaced with `{tag} ∪ descendants(tag)`. The SQL
|
|
220
232
|
* builder uses this to widen the tag join from `name = ?` to
|
|
221
233
|
* `name IN (...)`, so a query for `#manual` matches notes tagged with
|
|
222
|
-
* any descendant declared via `
|
|
234
|
+
* any descendant declared via `tags.parent_names`.
|
|
235
|
+
*
|
|
236
|
+
* `_default` magic (vault#270): when a `_default` tag row exists in the
|
|
237
|
+
* vault, it's the implicit parent of every note (tagged or not). A query
|
|
238
|
+
* filter that names `_default` is therefore equivalent to "no tag filter
|
|
239
|
+
* at all" — but the precise treatment depends on `tagMatch`:
|
|
223
240
|
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
241
|
+
* - `all` (default, AND-semantics): `_default` is universally satisfied,
|
|
242
|
+
* so it can be dropped from the tag list. Other tags' AND-semantics
|
|
243
|
+
* still apply. If `_default` was the only entry, drop the filter
|
|
244
|
+
* entirely so untagged notes match.
|
|
245
|
+
* - `any` (OR-semantics): `_default` matches every note, so the disjunction
|
|
246
|
+
* collapses to "every note." Drop the filter entirely regardless of
|
|
247
|
+
* what else was in the list (otherwise we'd narrow to the union of
|
|
248
|
+
* the other tags' notes — wrong).
|
|
249
|
+
*
|
|
250
|
+
* Other filters (path, metadata, dates) still apply in both cases.
|
|
226
251
|
*/
|
|
227
252
|
private expandQueryTags(opts: QueryOpts): QueryOpts {
|
|
228
253
|
if (!opts.tags || opts.tags.length === 0) return opts;
|
|
229
254
|
const hierarchy = this.getTagHierarchy();
|
|
255
|
+
|
|
256
|
+
let tags = opts.tags;
|
|
257
|
+
if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && tags.includes(DEFAULT_TAG_NAME)) {
|
|
258
|
+
const match = opts.tagMatch ?? "all";
|
|
259
|
+
if (match === "any") {
|
|
260
|
+
const { tags: _drop, ..._rest } = opts;
|
|
261
|
+
return _rest as QueryOpts;
|
|
262
|
+
}
|
|
263
|
+
tags = tags.filter((t) => t !== DEFAULT_TAG_NAME);
|
|
264
|
+
if (tags.length === 0) {
|
|
265
|
+
const { tags: _drop, ..._rest } = opts;
|
|
266
|
+
return _rest as QueryOpts;
|
|
267
|
+
}
|
|
268
|
+
opts = { ...opts, tags };
|
|
269
|
+
}
|
|
270
|
+
|
|
230
271
|
if (hierarchy.childrenOf.size === 0) return opts;
|
|
231
|
-
const expanded =
|
|
272
|
+
const expanded = tags.map((t) => Array.from(getTagDescendants(hierarchy, t)));
|
|
232
273
|
return { ...opts, _tagsExpanded: expanded } as QueryOpts;
|
|
233
274
|
}
|
|
234
275
|
|
|
@@ -237,9 +278,16 @@ export class BunSqliteStore implements Store {
|
|
|
237
278
|
// should match notes tagged with any descendant tag. The underlying
|
|
238
279
|
// FTS path already uses `IN (...)` for tags, so we flatten the
|
|
239
280
|
// per-input expansions into a single union (search semantics are
|
|
240
|
-
// "any tag matches").
|
|
281
|
+
// "any tag matches"). When `_default` is among the requested tags
|
|
282
|
+
// (and a `_default` row exists), the OR collapses to "every note" —
|
|
283
|
+
// drop the tag filter entirely so the search hits the full corpus
|
|
284
|
+
// and untagged notes are reachable.
|
|
241
285
|
if (opts?.tags && opts.tags.length > 0) {
|
|
242
286
|
const hierarchy = this.getTagHierarchy();
|
|
287
|
+
if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
|
|
288
|
+
const { tags: _drop, ..._rest } = opts;
|
|
289
|
+
return noteOps.searchNotes(this.db, query, _rest);
|
|
290
|
+
}
|
|
243
291
|
if (hierarchy.childrenOf.size > 0) {
|
|
244
292
|
const expanded = new Set<string>();
|
|
245
293
|
for (const t of opts.tags) {
|
|
@@ -277,17 +325,22 @@ export class BunSqliteStore implements Store {
|
|
|
277
325
|
|
|
278
326
|
async deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }> {
|
|
279
327
|
const result = noteOps.deleteTag(this.db, name);
|
|
280
|
-
// The deleted tag may have been a parent or child in the hierarchy
|
|
328
|
+
// The deleted tag may have been a parent or child in the hierarchy
|
|
329
|
+
// and may have declared `fields` powering schema validation.
|
|
281
330
|
this._tagHierarchy = null;
|
|
331
|
+
this._schemaConfig = null;
|
|
282
332
|
return result;
|
|
283
333
|
}
|
|
284
334
|
|
|
285
335
|
async renameTag(oldName: string, newName: string): Promise<noteOps.RenameTagResult> {
|
|
286
336
|
const result = noteOps.renameTag(this.db, oldName, newName);
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
337
|
+
// Vault#240: the cascade rewrites parent_names in OTHER tag rows as
|
|
338
|
+
// part of the same transaction, plus tokens.scoped_tags and
|
|
339
|
+
// indexed_fields.declarer_tags. Both caches are tag-keyed, so they
|
|
340
|
+
// must be rebuilt regardless — the hierarchy by tag-set identity,
|
|
341
|
+
// the schema-config by parent_names + fields content.
|
|
290
342
|
this._tagHierarchy = null;
|
|
343
|
+
this._schemaConfig = null;
|
|
291
344
|
return result;
|
|
292
345
|
}
|
|
293
346
|
|
|
@@ -297,8 +350,10 @@ export class BunSqliteStore implements Store {
|
|
|
297
350
|
): Promise<{ merged: Record<string, number>; target: string }> {
|
|
298
351
|
const result = noteOps.mergeTags(this.db, sources, target);
|
|
299
352
|
// Source tags drop out of the hierarchy; downstream callers asking
|
|
300
|
-
// for descendants of target should pick up any merged children.
|
|
353
|
+
// for descendants of target should pick up any merged children. Also
|
|
354
|
+
// bust the schema cache — `fields` declarations follow tag identity.
|
|
301
355
|
this._tagHierarchy = null;
|
|
356
|
+
this._schemaConfig = null;
|
|
302
357
|
return result;
|
|
303
358
|
}
|
|
304
359
|
|
|
@@ -332,9 +387,9 @@ export class BunSqliteStore implements Store {
|
|
|
332
387
|
const notes = noteOps.createNotes(this.db, inputs);
|
|
333
388
|
for (const note of notes) {
|
|
334
389
|
// Bulk path needs the same config-cache invalidation as singleton
|
|
335
|
-
// createNote — without it, a batch that includes `_tags/*`
|
|
336
|
-
//
|
|
337
|
-
//
|
|
390
|
+
// createNote — without it, a batch that includes `_tags/*` notes
|
|
391
|
+
// would leave the hierarchy cache stale until the next singleton
|
|
392
|
+
// write happened to bust it.
|
|
338
393
|
this.invalidateConfigCachesForPath(note.path);
|
|
339
394
|
this.hooks.dispatch("created", note, this);
|
|
340
395
|
}
|
|
@@ -370,11 +425,17 @@ export class BunSqliteStore implements Store {
|
|
|
370
425
|
}
|
|
371
426
|
|
|
372
427
|
async upsertTagSchema(tag: string, schema: { description?: string; fields?: Record<string, tagSchemaOps.TagFieldSchema> }) {
|
|
373
|
-
|
|
428
|
+
const result = tagSchemaOps.upsertTagSchema(this.db, tag, schema);
|
|
429
|
+
// `fields` drives validation — bust the schema cache so the next
|
|
430
|
+
// create/update sees the new declarations.
|
|
431
|
+
this._schemaConfig = null;
|
|
432
|
+
return result;
|
|
374
433
|
}
|
|
375
434
|
|
|
376
435
|
async deleteTagSchema(tag: string) {
|
|
377
|
-
|
|
436
|
+
const result = tagSchemaOps.deleteTagSchema(this.db, tag);
|
|
437
|
+
if (result) this._schemaConfig = null;
|
|
438
|
+
return result;
|
|
378
439
|
}
|
|
379
440
|
|
|
380
441
|
async getTagSchemaMap() {
|
|
@@ -407,7 +468,20 @@ export class BunSqliteStore implements Store {
|
|
|
407
468
|
) {
|
|
408
469
|
const result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
|
|
409
470
|
if (patch.parent_names !== undefined) {
|
|
471
|
+
// parent_names drives both query expansion (tag hierarchy) AND, post
|
|
472
|
+
// vault#270, schema inheritance — bust both caches.
|
|
410
473
|
this._tagHierarchy = null;
|
|
474
|
+
this._schemaConfig = null;
|
|
475
|
+
}
|
|
476
|
+
if (patch.fields !== undefined) {
|
|
477
|
+
this._schemaConfig = null;
|
|
478
|
+
}
|
|
479
|
+
// First-time creation of a tag row (e.g. an empty `_default` placeholder)
|
|
480
|
+
// changes the `_default` universal-parent gate even when no fields or
|
|
481
|
+
// parent_names are touched. Cheap to bust: caches rebuild on next read.
|
|
482
|
+
if (tag === "_default") {
|
|
483
|
+
this._tagHierarchy = null;
|
|
484
|
+
this._schemaConfig = null;
|
|
411
485
|
}
|
|
412
486
|
return result;
|
|
413
487
|
}
|
|
@@ -431,63 +505,14 @@ export class BunSqliteStore implements Store {
|
|
|
431
505
|
/**
|
|
432
506
|
* Drop the config caches unconditionally. Used by bulk-import paths that
|
|
433
507
|
* skip per-note invalidation for throughput, and by importers that
|
|
434
|
-
* directly
|
|
508
|
+
* directly mutate `tags` / `tags.fields` outside the singleton write
|
|
509
|
+
* methods.
|
|
435
510
|
*/
|
|
436
511
|
rebuildConfigCaches(): void {
|
|
437
512
|
this._tagHierarchy = null;
|
|
438
513
|
this._schemaConfig = null;
|
|
439
514
|
}
|
|
440
515
|
|
|
441
|
-
// ---- Note schemas (post-v15: validation by path-prefix or tag) ----
|
|
442
|
-
|
|
443
|
-
async listNoteSchemas() {
|
|
444
|
-
return noteSchemaOps.listNoteSchemas(this.db);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
async getNoteSchema(name: string) {
|
|
448
|
-
return noteSchemaOps.getNoteSchema(this.db, name);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Partial-upsert a note schema. Auto-creates the row if missing. Any
|
|
453
|
-
* patch field left undefined is preserved; pass null to clear. Empty
|
|
454
|
-
* `required: []` collapses to null. Invalidates the schema-config cache.
|
|
455
|
-
*/
|
|
456
|
-
async upsertNoteSchema(name: string, patch: noteSchemaOps.NoteSchemaPatch) {
|
|
457
|
-
const result = noteSchemaOps.upsertNoteSchema(this.db, name, patch);
|
|
458
|
-
this._schemaConfig = null;
|
|
459
|
-
return result;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
async deleteNoteSchema(name: string) {
|
|
463
|
-
const removed = noteSchemaOps.deleteNoteSchema(this.db, name);
|
|
464
|
-
if (removed) this._schemaConfig = null;
|
|
465
|
-
return removed;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
async listSchemaMappings(opts?: noteSchemaOps.ListMappingsOpts) {
|
|
469
|
-
return noteSchemaOps.listSchemaMappings(this.db, opts ?? {});
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
async setSchemaMapping(
|
|
473
|
-
schema_name: string,
|
|
474
|
-
match_kind: noteSchemaOps.SchemaMappingKind,
|
|
475
|
-
match_value: string,
|
|
476
|
-
) {
|
|
477
|
-
noteSchemaOps.setSchemaMapping(this.db, schema_name, match_kind, match_value);
|
|
478
|
-
this._schemaConfig = null;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
async deleteSchemaMapping(
|
|
482
|
-
schema_name: string,
|
|
483
|
-
match_kind: noteSchemaOps.SchemaMappingKind,
|
|
484
|
-
match_value: string,
|
|
485
|
-
) {
|
|
486
|
-
const removed = noteSchemaOps.deleteSchemaMapping(this.db, schema_name, match_kind, match_value);
|
|
487
|
-
if (removed) this._schemaConfig = null;
|
|
488
|
-
return removed;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
516
|
/**
|
|
492
517
|
* Sync wikilinks for all notes in the vault.
|
|
493
518
|
* Efficient for bulk imports — call once after importing all notes.
|
|
@@ -32,8 +32,23 @@ export interface TagHierarchy {
|
|
|
32
32
|
childrenOf: Map<string, Set<string>>;
|
|
33
33
|
/** Memoization cache: tag → set including the tag itself plus all transitive descendants. */
|
|
34
34
|
descendantsCache: Map<string, Set<string>>;
|
|
35
|
+
/**
|
|
36
|
+
* Every known tag name in the vault. Loaded once at hierarchy build so the
|
|
37
|
+
* `_default` universal-parent magic in `getTagDescendants` can answer
|
|
38
|
+
* "expand `_default` → every tag" without a second DB scan.
|
|
39
|
+
*/
|
|
40
|
+
allTags: Set<string>;
|
|
35
41
|
}
|
|
36
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Reserved tag name treated as the implicit universal parent of every other
|
|
45
|
+
* tag (vault#270). When a row named `_default` exists in the `tags` table,
|
|
46
|
+
* its schema applies to all notes — tagged or not — and tag-hierarchy queries
|
|
47
|
+
* for `_default` expand to the full tag list. The `tags.parent_names` column
|
|
48
|
+
* is never auto-mutated; the universal-parent property lives at resolve time.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_TAG_NAME = "_default";
|
|
51
|
+
|
|
37
52
|
/**
|
|
38
53
|
* Pre-v14 path prefix that marked a note as a tag-hierarchy declaration.
|
|
39
54
|
* Retained as an exported constant so call-sites that still need to know
|
|
@@ -59,16 +74,21 @@ function readParentNames(raw: string | null): string[] {
|
|
|
59
74
|
/**
|
|
60
75
|
* Scan the `tags` table and build the parent→children adjacency map.
|
|
61
76
|
* Each row's `parent_names` JSON array contributes one edge per parent.
|
|
77
|
+
* Also collects every known tag name in `allTags` so the `_default`
|
|
78
|
+
* universal-parent leg in `getTagDescendants` can return the full tag list
|
|
79
|
+
* without a second scan.
|
|
62
80
|
*/
|
|
63
81
|
export function loadTagHierarchy(db: Database): TagHierarchy {
|
|
64
82
|
const rows = db.prepare(
|
|
65
|
-
`SELECT name, parent_names FROM tags
|
|
83
|
+
`SELECT name, parent_names FROM tags`,
|
|
66
84
|
).all() as { name: string; parent_names: string | null }[];
|
|
67
85
|
|
|
68
86
|
const childrenOf = new Map<string, Set<string>>();
|
|
87
|
+
const allTags = new Set<string>();
|
|
69
88
|
|
|
70
89
|
for (const row of rows) {
|
|
71
90
|
if (!row.name) continue;
|
|
91
|
+
allTags.add(row.name);
|
|
72
92
|
const parents = readParentNames(row.parent_names);
|
|
73
93
|
for (const parent of parents) {
|
|
74
94
|
let children = childrenOf.get(parent);
|
|
@@ -80,18 +100,32 @@ export function loadTagHierarchy(db: Database): TagHierarchy {
|
|
|
80
100
|
}
|
|
81
101
|
}
|
|
82
102
|
|
|
83
|
-
return { childrenOf, descendantsCache: new Map() };
|
|
103
|
+
return { childrenOf, descendantsCache: new Map(), allTags };
|
|
84
104
|
}
|
|
85
105
|
|
|
86
106
|
/**
|
|
87
107
|
* Return the tag plus all transitive descendants. Always includes the tag
|
|
88
108
|
* itself, so callers can use the result as a drop-in replacement for the
|
|
89
109
|
* input tag when expanding queries.
|
|
110
|
+
*
|
|
111
|
+
* Special case: `_default` is treated as the implicit parent of every tag
|
|
112
|
+
* (vault#270). When a row named `_default` exists in the `tags` table, this
|
|
113
|
+
* function returns the full set of known tag names — symmetric with the
|
|
114
|
+
* schema-inheritance model where `_default`'s fields apply to every note.
|
|
115
|
+
* When `_default` is *not* declared as a tag row, the call falls through to
|
|
116
|
+
* normal descendant traversal (which yields just `{_default}` since nothing
|
|
117
|
+
* lists it as a parent).
|
|
90
118
|
*/
|
|
91
119
|
export function getTagDescendants(h: TagHierarchy, tag: string): Set<string> {
|
|
92
120
|
const cached = h.descendantsCache.get(tag);
|
|
93
121
|
if (cached) return cached;
|
|
94
122
|
|
|
123
|
+
if (tag === DEFAULT_TAG_NAME && h.allTags.has(DEFAULT_TAG_NAME)) {
|
|
124
|
+
const universal = new Set<string>(h.allTags);
|
|
125
|
+
h.descendantsCache.set(tag, universal);
|
|
126
|
+
return universal;
|
|
127
|
+
}
|
|
128
|
+
|
|
95
129
|
const result = new Set<string>([tag]);
|
|
96
130
|
const stack = [tag];
|
|
97
131
|
while (stack.length > 0) {
|