@openparachute/vault 0.3.3 → 0.4.3
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/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- 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 +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- 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 +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -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 +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- 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 +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -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 +727 -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 +1626 -183
- 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/store.ts
CHANGED
|
@@ -7,6 +7,19 @@ import * as tagSchemaOps from "./tag-schemas.js";
|
|
|
7
7
|
import { syncWikilinks, resolveUnresolvedWikilinks } from "./wikilinks.js";
|
|
8
8
|
import { pathTitle } from "./paths.js";
|
|
9
9
|
import { HookRegistry } from "./hooks.js";
|
|
10
|
+
import {
|
|
11
|
+
loadTagHierarchy,
|
|
12
|
+
getTagDescendants,
|
|
13
|
+
TAG_CONFIG_PREFIX,
|
|
14
|
+
DEFAULT_TAG_NAME,
|
|
15
|
+
type TagHierarchy,
|
|
16
|
+
} from "./tag-hierarchy.js";
|
|
17
|
+
import {
|
|
18
|
+
loadSchemaConfig,
|
|
19
|
+
validateNote as runValidateNote,
|
|
20
|
+
type ResolvedSchemas,
|
|
21
|
+
type ValidationStatus,
|
|
22
|
+
} from "./schema-defaults.js";
|
|
10
23
|
|
|
11
24
|
/**
|
|
12
25
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
@@ -16,11 +29,67 @@ import { HookRegistry } from "./hooks.js";
|
|
|
16
29
|
export class BunSqliteStore implements Store {
|
|
17
30
|
public readonly hooks: HookRegistry;
|
|
18
31
|
|
|
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.
|
|
37
|
+
private _tagHierarchy: TagHierarchy | null = null;
|
|
38
|
+
private _schemaConfig: ResolvedSchemas | null = null;
|
|
39
|
+
|
|
19
40
|
constructor(public readonly db: Database, opts?: { hooks?: HookRegistry }) {
|
|
20
41
|
initSchema(db);
|
|
21
42
|
this.hooks = opts?.hooks ?? new HookRegistry();
|
|
22
43
|
}
|
|
23
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Lazy accessor for the `_tags/*` config-note hierarchy. First call after
|
|
47
|
+
* boot or after an invalidation does the scan; subsequent calls hit the
|
|
48
|
+
* cache. Returns the same object until invalidated, so callers can rely
|
|
49
|
+
* on identity for memoizing per-tag descendant sets.
|
|
50
|
+
*/
|
|
51
|
+
private getTagHierarchy(): TagHierarchy {
|
|
52
|
+
if (!this._tagHierarchy) this._tagHierarchy = loadTagHierarchy(this.db);
|
|
53
|
+
return this._tagHierarchy;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Lazy accessor for the per-tag `fields` resolution. Same lifecycle as
|
|
58
|
+
* the tag hierarchy cache.
|
|
59
|
+
*/
|
|
60
|
+
private getSchemaConfig(): ResolvedSchemas {
|
|
61
|
+
if (!this._schemaConfig) this._schemaConfig = loadSchemaConfig(this.db);
|
|
62
|
+
return this._schemaConfig;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run the resolved schemas against a note and return the resulting
|
|
67
|
+
* validation status, or null when no schema applies. Public so the MCP
|
|
68
|
+
* layer can surface `validation_status` on create/update responses
|
|
69
|
+
* without re-importing the config loader.
|
|
70
|
+
*/
|
|
71
|
+
validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): ValidationStatus | null {
|
|
72
|
+
return runValidateNote(this.getSchemaConfig(), note);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Drop the tag-hierarchy cache if the mutated path is in the `_tags/*`
|
|
77
|
+
* namespace. Called from create/update/delete — old path is passed
|
|
78
|
+
* alongside new for rename cases (a note moved out of `_tags/` should
|
|
79
|
+
* still invalidate).
|
|
80
|
+
*
|
|
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`).
|
|
84
|
+
*/
|
|
85
|
+
private invalidateConfigCachesForPath(path: string | null | undefined, oldPath?: string | null): void {
|
|
86
|
+
const isTagConfig = (p: string | null | undefined): boolean =>
|
|
87
|
+
typeof p === "string" && p.startsWith(TAG_CONFIG_PREFIX);
|
|
88
|
+
if (isTagConfig(path) || isTagConfig(oldPath)) {
|
|
89
|
+
this._tagHierarchy = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
24
93
|
// ---- Notes ----
|
|
25
94
|
|
|
26
95
|
async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
|
|
@@ -34,6 +103,7 @@ export class BunSqliteStore implements Store {
|
|
|
34
103
|
resolveUnresolvedWikilinks(this.db, note.path, note.id);
|
|
35
104
|
}
|
|
36
105
|
|
|
106
|
+
this.invalidateConfigCachesForPath(note.path);
|
|
37
107
|
this.hooks.dispatch("created", note, this);
|
|
38
108
|
|
|
39
109
|
return note;
|
|
@@ -55,6 +125,8 @@ export class BunSqliteStore implements Store {
|
|
|
55
125
|
id: string,
|
|
56
126
|
updates: {
|
|
57
127
|
content?: string;
|
|
128
|
+
append?: string;
|
|
129
|
+
prepend?: string;
|
|
58
130
|
path?: string;
|
|
59
131
|
metadata?: Record<string, unknown>;
|
|
60
132
|
created_at?: string;
|
|
@@ -70,8 +142,11 @@ export class BunSqliteStore implements Store {
|
|
|
70
142
|
|
|
71
143
|
const note = noteOps.updateNote(this.db, id, updates);
|
|
72
144
|
|
|
73
|
-
|
|
74
|
-
|
|
145
|
+
// Wikilink sync runs against the *resulting* content. For append/prepend
|
|
146
|
+
// we don't have the new value pre-write — read it back off the returned
|
|
147
|
+
// note so a `[[Foo]]` introduced via append still creates the link.
|
|
148
|
+
if (updates.content !== undefined || updates.append !== undefined || updates.prepend !== undefined) {
|
|
149
|
+
syncWikilinks(this.db, id, note.content);
|
|
75
150
|
}
|
|
76
151
|
|
|
77
152
|
if (updates.path !== undefined && note.path) {
|
|
@@ -81,6 +156,12 @@ export class BunSqliteStore implements Store {
|
|
|
81
156
|
resolveUnresolvedWikilinks(this.db, note.path, id);
|
|
82
157
|
}
|
|
83
158
|
|
|
159
|
+
// Invalidate before the hook dispatch so any handler that re-queries
|
|
160
|
+
// the hierarchy from inside its own logic sees post-write state.
|
|
161
|
+
// `metadata` updates can change the `parents` field on a config note
|
|
162
|
+
// even when the path didn't change, so always invalidate when the
|
|
163
|
+
// current path is in a config namespace.
|
|
164
|
+
this.invalidateConfigCachesForPath(note.path, oldPath);
|
|
84
165
|
this.hooks.dispatch("updated", note, this);
|
|
85
166
|
|
|
86
167
|
return note;
|
|
@@ -122,14 +203,86 @@ export class BunSqliteStore implements Store {
|
|
|
122
203
|
}
|
|
123
204
|
|
|
124
205
|
async deleteNote(id: string): Promise<void> {
|
|
206
|
+
// Read before delete so we can invalidate config caches on the way out.
|
|
207
|
+
const existing = noteOps.getNote(this.db, id);
|
|
125
208
|
noteOps.deleteNote(this.db, id);
|
|
209
|
+
if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
|
|
126
210
|
}
|
|
127
211
|
|
|
128
212
|
async queryNotes(opts: QueryOpts): Promise<Note[]> {
|
|
129
|
-
return noteOps.queryNotes(this.db, opts);
|
|
213
|
+
return noteOps.queryNotes(this.db, this.expandQueryTags(opts));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* If `tags` are present, attach a parallel `_tagsExpanded` array where
|
|
218
|
+
* each input tag is replaced with `{tag} ∪ descendants(tag)`. The SQL
|
|
219
|
+
* builder uses this to widen the tag join from `name = ?` to
|
|
220
|
+
* `name IN (...)`, so a query for `#manual` matches notes tagged with
|
|
221
|
+
* any descendant declared via `tags.parent_names`.
|
|
222
|
+
*
|
|
223
|
+
* `_default` magic (vault#270): when a `_default` tag row exists in the
|
|
224
|
+
* vault, it's the implicit parent of every note (tagged or not). A query
|
|
225
|
+
* filter that names `_default` is therefore equivalent to "no tag filter
|
|
226
|
+
* at all" — but the precise treatment depends on `tagMatch`:
|
|
227
|
+
*
|
|
228
|
+
* - `all` (default, AND-semantics): `_default` is universally satisfied,
|
|
229
|
+
* so it can be dropped from the tag list. Other tags' AND-semantics
|
|
230
|
+
* still apply. If `_default` was the only entry, drop the filter
|
|
231
|
+
* entirely so untagged notes match.
|
|
232
|
+
* - `any` (OR-semantics): `_default` matches every note, so the disjunction
|
|
233
|
+
* collapses to "every note." Drop the filter entirely regardless of
|
|
234
|
+
* what else was in the list (otherwise we'd narrow to the union of
|
|
235
|
+
* the other tags' notes — wrong).
|
|
236
|
+
*
|
|
237
|
+
* Other filters (path, metadata, dates) still apply in both cases.
|
|
238
|
+
*/
|
|
239
|
+
private expandQueryTags(opts: QueryOpts): QueryOpts {
|
|
240
|
+
if (!opts.tags || opts.tags.length === 0) return opts;
|
|
241
|
+
const hierarchy = this.getTagHierarchy();
|
|
242
|
+
|
|
243
|
+
let tags = opts.tags;
|
|
244
|
+
if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && tags.includes(DEFAULT_TAG_NAME)) {
|
|
245
|
+
const match = opts.tagMatch ?? "all";
|
|
246
|
+
if (match === "any") {
|
|
247
|
+
const { tags: _drop, ..._rest } = opts;
|
|
248
|
+
return _rest as QueryOpts;
|
|
249
|
+
}
|
|
250
|
+
tags = tags.filter((t) => t !== DEFAULT_TAG_NAME);
|
|
251
|
+
if (tags.length === 0) {
|
|
252
|
+
const { tags: _drop, ..._rest } = opts;
|
|
253
|
+
return _rest as QueryOpts;
|
|
254
|
+
}
|
|
255
|
+
opts = { ...opts, tags };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (hierarchy.childrenOf.size === 0) return opts;
|
|
259
|
+
const expanded = tags.map((t) => Array.from(getTagDescendants(hierarchy, t)));
|
|
260
|
+
return { ...opts, _tagsExpanded: expanded } as QueryOpts;
|
|
130
261
|
}
|
|
131
262
|
|
|
132
263
|
async searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]> {
|
|
264
|
+
// Same hierarchy-expansion treatment as queryNotes — searching `#manual`
|
|
265
|
+
// should match notes tagged with any descendant tag. The underlying
|
|
266
|
+
// FTS path already uses `IN (...)` for tags, so we flatten the
|
|
267
|
+
// per-input expansions into a single union (search semantics are
|
|
268
|
+
// "any tag matches"). When `_default` is among the requested tags
|
|
269
|
+
// (and a `_default` row exists), the OR collapses to "every note" —
|
|
270
|
+
// drop the tag filter entirely so the search hits the full corpus
|
|
271
|
+
// and untagged notes are reachable.
|
|
272
|
+
if (opts?.tags && opts.tags.length > 0) {
|
|
273
|
+
const hierarchy = this.getTagHierarchy();
|
|
274
|
+
if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
|
|
275
|
+
const { tags: _drop, ..._rest } = opts;
|
|
276
|
+
return noteOps.searchNotes(this.db, query, _rest);
|
|
277
|
+
}
|
|
278
|
+
if (hierarchy.childrenOf.size > 0) {
|
|
279
|
+
const expanded = new Set<string>();
|
|
280
|
+
for (const t of opts.tags) {
|
|
281
|
+
for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
|
|
282
|
+
}
|
|
283
|
+
return noteOps.searchNotes(this.db, query, { ...opts, tags: Array.from(expanded) });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
133
286
|
return noteOps.searchNotes(this.db, query, opts);
|
|
134
287
|
}
|
|
135
288
|
|
|
@@ -143,23 +296,52 @@ export class BunSqliteStore implements Store {
|
|
|
143
296
|
noteOps.untagNote(this.db, noteId, tags);
|
|
144
297
|
}
|
|
145
298
|
|
|
299
|
+
async expandTagsWithDescendants(tags: string[]): Promise<Set<string>> {
|
|
300
|
+
const expanded = new Set<string>();
|
|
301
|
+
if (tags.length === 0) return expanded;
|
|
302
|
+
const hierarchy = this.getTagHierarchy();
|
|
303
|
+
for (const t of tags) {
|
|
304
|
+
for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
|
|
305
|
+
}
|
|
306
|
+
return expanded;
|
|
307
|
+
}
|
|
308
|
+
|
|
146
309
|
async listTags(): Promise<{ name: string; count: number }[]> {
|
|
147
310
|
return noteOps.listTags(this.db);
|
|
148
311
|
}
|
|
149
312
|
|
|
150
313
|
async deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }> {
|
|
151
|
-
|
|
314
|
+
const result = noteOps.deleteTag(this.db, name);
|
|
315
|
+
// The deleted tag may have been a parent or child in the hierarchy
|
|
316
|
+
// and may have declared `fields` powering schema validation.
|
|
317
|
+
this._tagHierarchy = null;
|
|
318
|
+
this._schemaConfig = null;
|
|
319
|
+
return result;
|
|
152
320
|
}
|
|
153
321
|
|
|
154
322
|
async renameTag(oldName: string, newName: string): Promise<noteOps.RenameTagResult> {
|
|
155
|
-
|
|
323
|
+
const result = noteOps.renameTag(this.db, oldName, newName);
|
|
324
|
+
// Vault#240: the cascade rewrites parent_names in OTHER tag rows as
|
|
325
|
+
// part of the same transaction, plus tokens.scoped_tags and
|
|
326
|
+
// indexed_fields.declarer_tags. Both caches are tag-keyed, so they
|
|
327
|
+
// must be rebuilt regardless — the hierarchy by tag-set identity,
|
|
328
|
+
// the schema-config by parent_names + fields content.
|
|
329
|
+
this._tagHierarchy = null;
|
|
330
|
+
this._schemaConfig = null;
|
|
331
|
+
return result;
|
|
156
332
|
}
|
|
157
333
|
|
|
158
334
|
async mergeTags(
|
|
159
335
|
sources: string[],
|
|
160
336
|
target: string,
|
|
161
337
|
): Promise<{ merged: Record<string, number>; target: string }> {
|
|
162
|
-
|
|
338
|
+
const result = noteOps.mergeTags(this.db, sources, target);
|
|
339
|
+
// Source tags drop out of the hierarchy; downstream callers asking
|
|
340
|
+
// for descendants of target should pick up any merged children. Also
|
|
341
|
+
// bust the schema cache — `fields` declarations follow tag identity.
|
|
342
|
+
this._tagHierarchy = null;
|
|
343
|
+
this._schemaConfig = null;
|
|
344
|
+
return result;
|
|
163
345
|
}
|
|
164
346
|
|
|
165
347
|
// ---- Vault Stats ----
|
|
@@ -191,6 +373,11 @@ export class BunSqliteStore implements Store {
|
|
|
191
373
|
async createNotes(inputs: noteOps.BulkNoteInput[]): Promise<Note[]> {
|
|
192
374
|
const notes = noteOps.createNotes(this.db, inputs);
|
|
193
375
|
for (const note of notes) {
|
|
376
|
+
// Bulk path needs the same config-cache invalidation as singleton
|
|
377
|
+
// createNote — without it, a batch that includes `_tags/*` notes
|
|
378
|
+
// would leave the hierarchy cache stale until the next singleton
|
|
379
|
+
// write happened to bust it.
|
|
380
|
+
this.invalidateConfigCachesForPath(note.path);
|
|
194
381
|
this.hooks.dispatch("created", note, this);
|
|
195
382
|
}
|
|
196
383
|
return notes;
|
|
@@ -225,27 +412,94 @@ export class BunSqliteStore implements Store {
|
|
|
225
412
|
}
|
|
226
413
|
|
|
227
414
|
async upsertTagSchema(tag: string, schema: { description?: string; fields?: Record<string, tagSchemaOps.TagFieldSchema> }) {
|
|
228
|
-
|
|
415
|
+
const result = tagSchemaOps.upsertTagSchema(this.db, tag, schema);
|
|
416
|
+
// `fields` drives validation — bust the schema cache so the next
|
|
417
|
+
// create/update sees the new declarations.
|
|
418
|
+
this._schemaConfig = null;
|
|
419
|
+
return result;
|
|
229
420
|
}
|
|
230
421
|
|
|
231
422
|
async deleteTagSchema(tag: string) {
|
|
232
|
-
|
|
423
|
+
const result = tagSchemaOps.deleteTagSchema(this.db, tag);
|
|
424
|
+
if (result) this._schemaConfig = null;
|
|
425
|
+
return result;
|
|
233
426
|
}
|
|
234
427
|
|
|
235
428
|
async getTagSchemaMap() {
|
|
236
429
|
return tagSchemaOps.getTagSchemaMap(this.db);
|
|
237
430
|
}
|
|
238
431
|
|
|
432
|
+
// ---- Tag Records (post-v14: full identity row) ----
|
|
433
|
+
|
|
434
|
+
async listTagRecords() {
|
|
435
|
+
return tagSchemaOps.listTagRecords(this.db);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async getTagRecord(tag: string) {
|
|
439
|
+
return tagSchemaOps.getTagRecord(this.db, tag);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Partial upsert of the full tag record. Any patch field left undefined
|
|
444
|
+
* is preserved; pass null to clear. Invalidates the tag-hierarchy cache
|
|
445
|
+
* when `parent_names` is touched.
|
|
446
|
+
*/
|
|
447
|
+
async upsertTagRecord(
|
|
448
|
+
tag: string,
|
|
449
|
+
patch: {
|
|
450
|
+
description?: string | null;
|
|
451
|
+
fields?: Record<string, tagSchemaOps.TagFieldSchema> | null;
|
|
452
|
+
relationships?: Record<string, tagSchemaOps.TagRelationship> | null;
|
|
453
|
+
parent_names?: string[] | null;
|
|
454
|
+
},
|
|
455
|
+
) {
|
|
456
|
+
const result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
|
|
457
|
+
if (patch.parent_names !== undefined) {
|
|
458
|
+
// parent_names drives both query expansion (tag hierarchy) AND, post
|
|
459
|
+
// vault#270, schema inheritance — bust both caches.
|
|
460
|
+
this._tagHierarchy = null;
|
|
461
|
+
this._schemaConfig = null;
|
|
462
|
+
}
|
|
463
|
+
if (patch.fields !== undefined) {
|
|
464
|
+
this._schemaConfig = null;
|
|
465
|
+
}
|
|
466
|
+
// First-time creation of a tag row (e.g. an empty `_default` placeholder)
|
|
467
|
+
// changes the `_default` universal-parent gate even when no fields or
|
|
468
|
+
// parent_names are touched. Cheap to bust: caches rebuild on next read.
|
|
469
|
+
if (tag === "_default") {
|
|
470
|
+
this._tagHierarchy = null;
|
|
471
|
+
this._schemaConfig = null;
|
|
472
|
+
}
|
|
473
|
+
return result;
|
|
474
|
+
}
|
|
475
|
+
|
|
239
476
|
// ---- Batch Wikilink Sync ----
|
|
240
477
|
|
|
241
478
|
/**
|
|
242
479
|
* Create a note without triggering wikilink sync.
|
|
243
480
|
* Use this during bulk imports, then call syncAllWikilinks() after.
|
|
481
|
+
*
|
|
482
|
+
* Does **not** invalidate the `_tags/*` config cache — importers writing
|
|
483
|
+
* tag-hierarchy notes through this path must call `rebuildConfigCaches()`
|
|
484
|
+
* once the import is done. (Default importers follow `createNoteRaw` with
|
|
485
|
+
* `syncAllWikilinks`, so adding the cache rebuild there is the natural
|
|
486
|
+
* place.)
|
|
244
487
|
*/
|
|
245
488
|
async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
|
|
246
489
|
return noteOps.createNote(this.db, content, opts);
|
|
247
490
|
}
|
|
248
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Drop the config caches unconditionally. Used by bulk-import paths that
|
|
494
|
+
* skip per-note invalidation for throughput, and by importers that
|
|
495
|
+
* directly mutate `tags` / `tags.fields` outside the singleton write
|
|
496
|
+
* methods.
|
|
497
|
+
*/
|
|
498
|
+
rebuildConfigCaches(): void {
|
|
499
|
+
this._tagHierarchy = null;
|
|
500
|
+
this._schemaConfig = null;
|
|
501
|
+
}
|
|
502
|
+
|
|
249
503
|
/**
|
|
250
504
|
* Sync wikilinks for all notes in the vault.
|
|
251
505
|
* Efficient for bulk imports — call once after importing all notes.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tag hierarchy resolution from the `tags.parent_names` column.
|
|
3
|
+
*
|
|
4
|
+
* A `tags` row named `voice` with `parent_names = ["manual", "note"]`
|
|
5
|
+
* registers `voice` as a child of `manual` and `note`. Queries that ask for
|
|
6
|
+
* `tags: ["manual"]` then transparently match notes tagged `#voice` (or any
|
|
7
|
+
* other transitive descendant of `#manual`).
|
|
8
|
+
*
|
|
9
|
+
* History: pre-v14 vaults stored hierarchy in notes-as-config at
|
|
10
|
+
* `_tags/<name>`. The v14 migration (see core/src/schema.ts:migrateToV14)
|
|
11
|
+
* lifts those parent declarations onto the tags row and the resolver here
|
|
12
|
+
* was swapped accordingly. See parachute-patterns/patterns/tag-data-model.md.
|
|
13
|
+
*
|
|
14
|
+
* Resolution model:
|
|
15
|
+
* - Lazy: built on first access, cached on the store.
|
|
16
|
+
* - Invalidated synchronously when a tag's parent_names changes (see
|
|
17
|
+
* `BunSqliteStore.invalidateTagCaches`).
|
|
18
|
+
* - Tags without parent_names are treated as root-level (no parents, no
|
|
19
|
+
* children). They still match queries by their own name.
|
|
20
|
+
*
|
|
21
|
+
* Cycle handling:
|
|
22
|
+
* - Cycles in declared parents are tolerated at load — we don't reject the
|
|
23
|
+
* config (we don't have a "fail loud" signal at boot from inside a query).
|
|
24
|
+
* Descendant traversal uses a visited-set so a cycle can't loop forever;
|
|
25
|
+
* the resolved descendant set is well-defined regardless.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Database } from "bun:sqlite";
|
|
29
|
+
|
|
30
|
+
export interface TagHierarchy {
|
|
31
|
+
/** tag → set of immediate child tags (those that declared `tag` as a parent). */
|
|
32
|
+
childrenOf: Map<string, Set<string>>;
|
|
33
|
+
/** Memoization cache: tag → set including the tag itself plus all transitive descendants. */
|
|
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>;
|
|
41
|
+
}
|
|
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
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pre-v14 path prefix that marked a note as a tag-hierarchy declaration.
|
|
54
|
+
* Retained as an exported constant so call-sites that still need to know
|
|
55
|
+
* about historical `_tags/*` notes (cache-invalidation, importers) can
|
|
56
|
+
* reference a single source of truth.
|
|
57
|
+
*/
|
|
58
|
+
export const TAG_CONFIG_PREFIX = "_tags/";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decode a JSON-encoded `parent_names` column value, defending against
|
|
62
|
+
* malformed input. Non-string entries are dropped silently — the column
|
|
63
|
+
* is expected to be well-formed (we control all writers) but a single bad
|
|
64
|
+
* row shouldn't break the whole hierarchy resolution.
|
|
65
|
+
*/
|
|
66
|
+
function readParentNames(raw: string | null): string[] {
|
|
67
|
+
if (!raw) return [];
|
|
68
|
+
let parsed: unknown;
|
|
69
|
+
try { parsed = JSON.parse(raw); } catch { return []; }
|
|
70
|
+
if (!Array.isArray(parsed)) return [];
|
|
71
|
+
return parsed.filter((x): x is string => typeof x === "string" && x.length > 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Scan the `tags` table and build the parent→children adjacency map.
|
|
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.
|
|
80
|
+
*/
|
|
81
|
+
export function loadTagHierarchy(db: Database): TagHierarchy {
|
|
82
|
+
const rows = db.prepare(
|
|
83
|
+
`SELECT name, parent_names FROM tags`,
|
|
84
|
+
).all() as { name: string; parent_names: string | null }[];
|
|
85
|
+
|
|
86
|
+
const childrenOf = new Map<string, Set<string>>();
|
|
87
|
+
const allTags = new Set<string>();
|
|
88
|
+
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
if (!row.name) continue;
|
|
91
|
+
allTags.add(row.name);
|
|
92
|
+
const parents = readParentNames(row.parent_names);
|
|
93
|
+
for (const parent of parents) {
|
|
94
|
+
let children = childrenOf.get(parent);
|
|
95
|
+
if (!children) {
|
|
96
|
+
children = new Set();
|
|
97
|
+
childrenOf.set(parent, children);
|
|
98
|
+
}
|
|
99
|
+
children.add(row.name);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { childrenOf, descendantsCache: new Map(), allTags };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Return the tag plus all transitive descendants. Always includes the tag
|
|
108
|
+
* itself, so callers can use the result as a drop-in replacement for the
|
|
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).
|
|
118
|
+
*/
|
|
119
|
+
export function getTagDescendants(h: TagHierarchy, tag: string): Set<string> {
|
|
120
|
+
const cached = h.descendantsCache.get(tag);
|
|
121
|
+
if (cached) return cached;
|
|
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
|
+
|
|
129
|
+
const result = new Set<string>([tag]);
|
|
130
|
+
const stack = [tag];
|
|
131
|
+
while (stack.length > 0) {
|
|
132
|
+
const current = stack.pop()!;
|
|
133
|
+
const children = h.childrenOf.get(current);
|
|
134
|
+
if (!children) continue;
|
|
135
|
+
for (const child of children) {
|
|
136
|
+
if (result.has(child)) continue;
|
|
137
|
+
result.add(child);
|
|
138
|
+
stack.push(child);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
h.descendantsCache.set(tag, result);
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Detect cycles in the declared hierarchy. Returns the list of tags
|
|
148
|
+
* reachable from themselves via parent declarations. Used by
|
|
149
|
+
* `update-tag` write paths to surface a warning to the caller without
|
|
150
|
+
* blocking the write — cycles are tolerated at runtime (descendant
|
|
151
|
+
* traversal uses a visited set), but they're almost always a config bug.
|
|
152
|
+
*/
|
|
153
|
+
export function findHierarchyCycles(h: TagHierarchy): string[] {
|
|
154
|
+
const cycles: string[] = [];
|
|
155
|
+
for (const tag of h.childrenOf.keys()) {
|
|
156
|
+
const descendants = getTagDescendants(h, tag);
|
|
157
|
+
if (descendants.has(tag) && descendants.size > 1) {
|
|
158
|
+
// tag reaches itself through a non-trivial path
|
|
159
|
+
const ownChildren = h.childrenOf.get(tag);
|
|
160
|
+
if (ownChildren) {
|
|
161
|
+
for (const child of ownChildren) {
|
|
162
|
+
if (getTagDescendants(h, child).has(tag)) {
|
|
163
|
+
cycles.push(tag);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return cycles;
|
|
171
|
+
}
|