@openparachute/vault 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parachute/module.json +15 -0
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/core/src/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
|
+
type TagHierarchy,
|
|
15
|
+
} from "./tag-hierarchy.js";
|
|
16
|
+
import {
|
|
17
|
+
loadSchemaConfig,
|
|
18
|
+
validateNote as runValidateNote,
|
|
19
|
+
type ResolvedSchemas,
|
|
20
|
+
type ValidationStatus,
|
|
21
|
+
} from "./schema-defaults.js";
|
|
22
|
+
import * as noteSchemaOps from "./note-schemas.js";
|
|
10
23
|
|
|
11
24
|
/**
|
|
12
25
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
@@ -16,11 +29,68 @@ 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) and the post-v15 `note_schemas` + `schema_mappings`
|
|
34
|
+
// tables (validation). Null means "not yet loaded or invalidated"; the
|
|
35
|
+
// next read rebuilds. We invalidate synchronously inside the writers
|
|
36
|
+
// that mutate the source tables so reads after writes always see the
|
|
37
|
+
// post-write state.
|
|
38
|
+
private _tagHierarchy: TagHierarchy | null = null;
|
|
39
|
+
private _schemaConfig: ResolvedSchemas | null = null;
|
|
40
|
+
|
|
19
41
|
constructor(public readonly db: Database, opts?: { hooks?: HookRegistry }) {
|
|
20
42
|
initSchema(db);
|
|
21
43
|
this.hooks = opts?.hooks ?? new HookRegistry();
|
|
22
44
|
}
|
|
23
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Lazy accessor for the `_tags/*` config-note hierarchy. First call after
|
|
48
|
+
* boot or after an invalidation does the scan; subsequent calls hit the
|
|
49
|
+
* cache. Returns the same object until invalidated, so callers can rely
|
|
50
|
+
* on identity for memoizing per-tag descendant sets.
|
|
51
|
+
*/
|
|
52
|
+
private getTagHierarchy(): TagHierarchy {
|
|
53
|
+
if (!this._tagHierarchy) this._tagHierarchy = loadTagHierarchy(this.db);
|
|
54
|
+
return this._tagHierarchy;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Lazy accessor for the `note_schemas` + `schema_mappings` resolution.
|
|
59
|
+
* Same lifecycle as the tag hierarchy cache.
|
|
60
|
+
*/
|
|
61
|
+
private getSchemaConfig(): ResolvedSchemas {
|
|
62
|
+
if (!this._schemaConfig) this._schemaConfig = loadSchemaConfig(this.db);
|
|
63
|
+
return this._schemaConfig;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run the resolved schemas against a note and return the resulting
|
|
68
|
+
* validation status, or null when no schema applies. Public so the MCP
|
|
69
|
+
* layer can surface `validation_status` on create/update responses
|
|
70
|
+
* without re-importing the config loader.
|
|
71
|
+
*/
|
|
72
|
+
validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): ValidationStatus | null {
|
|
73
|
+
return runValidateNote(this.getSchemaConfig(), note);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Drop the tag-hierarchy cache if the mutated path is in the `_tags/*`
|
|
78
|
+
* namespace. Called from create/update/delete — old path is passed
|
|
79
|
+
* alongside new for rename cases (a note moved out of `_tags/` should
|
|
80
|
+
* still invalidate).
|
|
81
|
+
*
|
|
82
|
+
* Post-v15 the schema-config cache is no longer note-driven — its
|
|
83
|
+
* invalidation hook is on `upsertNoteSchema` / `setSchemaMapping` /
|
|
84
|
+
* `deleteNoteSchema` / `deleteSchemaMapping` instead.
|
|
85
|
+
*/
|
|
86
|
+
private invalidateConfigCachesForPath(path: string | null | undefined, oldPath?: string | null): void {
|
|
87
|
+
const isTagConfig = (p: string | null | undefined): boolean =>
|
|
88
|
+
typeof p === "string" && p.startsWith(TAG_CONFIG_PREFIX);
|
|
89
|
+
if (isTagConfig(path) || isTagConfig(oldPath)) {
|
|
90
|
+
this._tagHierarchy = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
24
94
|
// ---- Notes ----
|
|
25
95
|
|
|
26
96
|
async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
|
|
@@ -34,6 +104,7 @@ export class BunSqliteStore implements Store {
|
|
|
34
104
|
resolveUnresolvedWikilinks(this.db, note.path, note.id);
|
|
35
105
|
}
|
|
36
106
|
|
|
107
|
+
this.invalidateConfigCachesForPath(note.path);
|
|
37
108
|
this.hooks.dispatch("created", note, this);
|
|
38
109
|
|
|
39
110
|
return note;
|
|
@@ -55,6 +126,8 @@ export class BunSqliteStore implements Store {
|
|
|
55
126
|
id: string,
|
|
56
127
|
updates: {
|
|
57
128
|
content?: string;
|
|
129
|
+
append?: string;
|
|
130
|
+
prepend?: string;
|
|
58
131
|
path?: string;
|
|
59
132
|
metadata?: Record<string, unknown>;
|
|
60
133
|
created_at?: string;
|
|
@@ -70,8 +143,11 @@ export class BunSqliteStore implements Store {
|
|
|
70
143
|
|
|
71
144
|
const note = noteOps.updateNote(this.db, id, updates);
|
|
72
145
|
|
|
73
|
-
|
|
74
|
-
|
|
146
|
+
// Wikilink sync runs against the *resulting* content. For append/prepend
|
|
147
|
+
// we don't have the new value pre-write — read it back off the returned
|
|
148
|
+
// note so a `[[Foo]]` introduced via append still creates the link.
|
|
149
|
+
if (updates.content !== undefined || updates.append !== undefined || updates.prepend !== undefined) {
|
|
150
|
+
syncWikilinks(this.db, id, note.content);
|
|
75
151
|
}
|
|
76
152
|
|
|
77
153
|
if (updates.path !== undefined && note.path) {
|
|
@@ -81,6 +157,12 @@ export class BunSqliteStore implements Store {
|
|
|
81
157
|
resolveUnresolvedWikilinks(this.db, note.path, id);
|
|
82
158
|
}
|
|
83
159
|
|
|
160
|
+
// Invalidate before the hook dispatch so any handler that re-queries
|
|
161
|
+
// the hierarchy from inside its own logic sees post-write state.
|
|
162
|
+
// `metadata` updates can change the `parents` field on a config note
|
|
163
|
+
// even when the path didn't change, so always invalidate when the
|
|
164
|
+
// current path is in a config namespace.
|
|
165
|
+
this.invalidateConfigCachesForPath(note.path, oldPath);
|
|
84
166
|
this.hooks.dispatch("updated", note, this);
|
|
85
167
|
|
|
86
168
|
return note;
|
|
@@ -122,14 +204,50 @@ export class BunSqliteStore implements Store {
|
|
|
122
204
|
}
|
|
123
205
|
|
|
124
206
|
async deleteNote(id: string): Promise<void> {
|
|
207
|
+
// Read before delete so we can invalidate config caches on the way out.
|
|
208
|
+
const existing = noteOps.getNote(this.db, id);
|
|
125
209
|
noteOps.deleteNote(this.db, id);
|
|
210
|
+
if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
|
|
126
211
|
}
|
|
127
212
|
|
|
128
213
|
async queryNotes(opts: QueryOpts): Promise<Note[]> {
|
|
129
|
-
return noteOps.queryNotes(this.db, opts);
|
|
214
|
+
return noteOps.queryNotes(this.db, this.expandQueryTags(opts));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* If `tags` are present, attach a parallel `_tagsExpanded` array where
|
|
219
|
+
* each input tag is replaced with `{tag} ∪ descendants(tag)`. The SQL
|
|
220
|
+
* builder uses this to widen the tag join from `name = ?` to
|
|
221
|
+
* `name IN (...)`, so a query for `#manual` matches notes tagged with
|
|
222
|
+
* any descendant declared via `_tags/*` config notes.
|
|
223
|
+
*
|
|
224
|
+
* No-op when no `_tags/*` notes exist (empty hierarchy → each tag
|
|
225
|
+
* expands to just itself, identical to the pre-expansion behavior).
|
|
226
|
+
*/
|
|
227
|
+
private expandQueryTags(opts: QueryOpts): QueryOpts {
|
|
228
|
+
if (!opts.tags || opts.tags.length === 0) return opts;
|
|
229
|
+
const hierarchy = this.getTagHierarchy();
|
|
230
|
+
if (hierarchy.childrenOf.size === 0) return opts;
|
|
231
|
+
const expanded = opts.tags.map((t) => Array.from(getTagDescendants(hierarchy, t)));
|
|
232
|
+
return { ...opts, _tagsExpanded: expanded } as QueryOpts;
|
|
130
233
|
}
|
|
131
234
|
|
|
132
235
|
async searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]> {
|
|
236
|
+
// Same hierarchy-expansion treatment as queryNotes — searching `#manual`
|
|
237
|
+
// should match notes tagged with any descendant tag. The underlying
|
|
238
|
+
// FTS path already uses `IN (...)` for tags, so we flatten the
|
|
239
|
+
// per-input expansions into a single union (search semantics are
|
|
240
|
+
// "any tag matches").
|
|
241
|
+
if (opts?.tags && opts.tags.length > 0) {
|
|
242
|
+
const hierarchy = this.getTagHierarchy();
|
|
243
|
+
if (hierarchy.childrenOf.size > 0) {
|
|
244
|
+
const expanded = new Set<string>();
|
|
245
|
+
for (const t of opts.tags) {
|
|
246
|
+
for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
|
|
247
|
+
}
|
|
248
|
+
return noteOps.searchNotes(this.db, query, { ...opts, tags: Array.from(expanded) });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
133
251
|
return noteOps.searchNotes(this.db, query, opts);
|
|
134
252
|
}
|
|
135
253
|
|
|
@@ -143,23 +261,45 @@ export class BunSqliteStore implements Store {
|
|
|
143
261
|
noteOps.untagNote(this.db, noteId, tags);
|
|
144
262
|
}
|
|
145
263
|
|
|
264
|
+
async expandTagsWithDescendants(tags: string[]): Promise<Set<string>> {
|
|
265
|
+
const expanded = new Set<string>();
|
|
266
|
+
if (tags.length === 0) return expanded;
|
|
267
|
+
const hierarchy = this.getTagHierarchy();
|
|
268
|
+
for (const t of tags) {
|
|
269
|
+
for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
|
|
270
|
+
}
|
|
271
|
+
return expanded;
|
|
272
|
+
}
|
|
273
|
+
|
|
146
274
|
async listTags(): Promise<{ name: string; count: number }[]> {
|
|
147
275
|
return noteOps.listTags(this.db);
|
|
148
276
|
}
|
|
149
277
|
|
|
150
278
|
async deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }> {
|
|
151
|
-
|
|
279
|
+
const result = noteOps.deleteTag(this.db, name);
|
|
280
|
+
// The deleted tag may have been a parent or child in the hierarchy.
|
|
281
|
+
this._tagHierarchy = null;
|
|
282
|
+
return result;
|
|
152
283
|
}
|
|
153
284
|
|
|
154
285
|
async renameTag(oldName: string, newName: string): Promise<noteOps.RenameTagResult> {
|
|
155
|
-
|
|
286
|
+
const result = noteOps.renameTag(this.db, oldName, newName);
|
|
287
|
+
// Other tags' parent_names may reference oldName — though we don't
|
|
288
|
+
// rewrite those, the hierarchy cache should be rebuilt to pick up the
|
|
289
|
+
// new row identity.
|
|
290
|
+
this._tagHierarchy = null;
|
|
291
|
+
return result;
|
|
156
292
|
}
|
|
157
293
|
|
|
158
294
|
async mergeTags(
|
|
159
295
|
sources: string[],
|
|
160
296
|
target: string,
|
|
161
297
|
): Promise<{ merged: Record<string, number>; target: string }> {
|
|
162
|
-
|
|
298
|
+
const result = noteOps.mergeTags(this.db, sources, target);
|
|
299
|
+
// Source tags drop out of the hierarchy; downstream callers asking
|
|
300
|
+
// for descendants of target should pick up any merged children.
|
|
301
|
+
this._tagHierarchy = null;
|
|
302
|
+
return result;
|
|
163
303
|
}
|
|
164
304
|
|
|
165
305
|
// ---- Vault Stats ----
|
|
@@ -191,6 +331,11 @@ export class BunSqliteStore implements Store {
|
|
|
191
331
|
async createNotes(inputs: noteOps.BulkNoteInput[]): Promise<Note[]> {
|
|
192
332
|
const notes = noteOps.createNotes(this.db, inputs);
|
|
193
333
|
for (const note of notes) {
|
|
334
|
+
// Bulk path needs the same config-cache invalidation as singleton
|
|
335
|
+
// createNote — without it, a batch that includes `_tags/*` or
|
|
336
|
+
// `_schemas/*` notes would leave the cache stale until the next
|
|
337
|
+
// singleton write happened to bust it.
|
|
338
|
+
this.invalidateConfigCachesForPath(note.path);
|
|
194
339
|
this.hooks.dispatch("created", note, this);
|
|
195
340
|
}
|
|
196
341
|
return notes;
|
|
@@ -236,16 +381,113 @@ export class BunSqliteStore implements Store {
|
|
|
236
381
|
return tagSchemaOps.getTagSchemaMap(this.db);
|
|
237
382
|
}
|
|
238
383
|
|
|
384
|
+
// ---- Tag Records (post-v14: full identity row) ----
|
|
385
|
+
|
|
386
|
+
async listTagRecords() {
|
|
387
|
+
return tagSchemaOps.listTagRecords(this.db);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async getTagRecord(tag: string) {
|
|
391
|
+
return tagSchemaOps.getTagRecord(this.db, tag);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Partial upsert of the full tag record. Any patch field left undefined
|
|
396
|
+
* is preserved; pass null to clear. Invalidates the tag-hierarchy cache
|
|
397
|
+
* when `parent_names` is touched.
|
|
398
|
+
*/
|
|
399
|
+
async upsertTagRecord(
|
|
400
|
+
tag: string,
|
|
401
|
+
patch: {
|
|
402
|
+
description?: string | null;
|
|
403
|
+
fields?: Record<string, tagSchemaOps.TagFieldSchema> | null;
|
|
404
|
+
relationships?: Record<string, tagSchemaOps.TagRelationship> | null;
|
|
405
|
+
parent_names?: string[] | null;
|
|
406
|
+
},
|
|
407
|
+
) {
|
|
408
|
+
const result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
|
|
409
|
+
if (patch.parent_names !== undefined) {
|
|
410
|
+
this._tagHierarchy = null;
|
|
411
|
+
}
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
|
|
239
415
|
// ---- Batch Wikilink Sync ----
|
|
240
416
|
|
|
241
417
|
/**
|
|
242
418
|
* Create a note without triggering wikilink sync.
|
|
243
419
|
* Use this during bulk imports, then call syncAllWikilinks() after.
|
|
420
|
+
*
|
|
421
|
+
* Does **not** invalidate the `_tags/*` config cache — importers writing
|
|
422
|
+
* tag-hierarchy notes through this path must call `rebuildConfigCaches()`
|
|
423
|
+
* once the import is done. (Default importers follow `createNoteRaw` with
|
|
424
|
+
* `syncAllWikilinks`, so adding the cache rebuild there is the natural
|
|
425
|
+
* place.)
|
|
244
426
|
*/
|
|
245
427
|
async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
|
|
246
428
|
return noteOps.createNote(this.db, content, opts);
|
|
247
429
|
}
|
|
248
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Drop the config caches unconditionally. Used by bulk-import paths that
|
|
433
|
+
* skip per-note invalidation for throughput, and by importers that
|
|
434
|
+
* directly populate `note_schemas` / `schema_mappings`.
|
|
435
|
+
*/
|
|
436
|
+
rebuildConfigCaches(): void {
|
|
437
|
+
this._tagHierarchy = null;
|
|
438
|
+
this._schemaConfig = null;
|
|
439
|
+
}
|
|
440
|
+
|
|
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
|
+
|
|
249
491
|
/**
|
|
250
492
|
* Sync wikilinks for all notes in the vault.
|
|
251
493
|
* Efficient for bulk imports — call once after importing all notes.
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pre-v14 path prefix that marked a note as a tag-hierarchy declaration.
|
|
39
|
+
* Retained as an exported constant so call-sites that still need to know
|
|
40
|
+
* about historical `_tags/*` notes (cache-invalidation, importers) can
|
|
41
|
+
* reference a single source of truth.
|
|
42
|
+
*/
|
|
43
|
+
export const TAG_CONFIG_PREFIX = "_tags/";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decode a JSON-encoded `parent_names` column value, defending against
|
|
47
|
+
* malformed input. Non-string entries are dropped silently — the column
|
|
48
|
+
* is expected to be well-formed (we control all writers) but a single bad
|
|
49
|
+
* row shouldn't break the whole hierarchy resolution.
|
|
50
|
+
*/
|
|
51
|
+
function readParentNames(raw: string | null): string[] {
|
|
52
|
+
if (!raw) return [];
|
|
53
|
+
let parsed: unknown;
|
|
54
|
+
try { parsed = JSON.parse(raw); } catch { return []; }
|
|
55
|
+
if (!Array.isArray(parsed)) return [];
|
|
56
|
+
return parsed.filter((x): x is string => typeof x === "string" && x.length > 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Scan the `tags` table and build the parent→children adjacency map.
|
|
61
|
+
* Each row's `parent_names` JSON array contributes one edge per parent.
|
|
62
|
+
*/
|
|
63
|
+
export function loadTagHierarchy(db: Database): TagHierarchy {
|
|
64
|
+
const rows = db.prepare(
|
|
65
|
+
`SELECT name, parent_names FROM tags WHERE parent_names IS NOT NULL`,
|
|
66
|
+
).all() as { name: string; parent_names: string | null }[];
|
|
67
|
+
|
|
68
|
+
const childrenOf = new Map<string, Set<string>>();
|
|
69
|
+
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
if (!row.name) continue;
|
|
72
|
+
const parents = readParentNames(row.parent_names);
|
|
73
|
+
for (const parent of parents) {
|
|
74
|
+
let children = childrenOf.get(parent);
|
|
75
|
+
if (!children) {
|
|
76
|
+
children = new Set();
|
|
77
|
+
childrenOf.set(parent, children);
|
|
78
|
+
}
|
|
79
|
+
children.add(row.name);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { childrenOf, descendantsCache: new Map() };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Return the tag plus all transitive descendants. Always includes the tag
|
|
88
|
+
* itself, so callers can use the result as a drop-in replacement for the
|
|
89
|
+
* input tag when expanding queries.
|
|
90
|
+
*/
|
|
91
|
+
export function getTagDescendants(h: TagHierarchy, tag: string): Set<string> {
|
|
92
|
+
const cached = h.descendantsCache.get(tag);
|
|
93
|
+
if (cached) return cached;
|
|
94
|
+
|
|
95
|
+
const result = new Set<string>([tag]);
|
|
96
|
+
const stack = [tag];
|
|
97
|
+
while (stack.length > 0) {
|
|
98
|
+
const current = stack.pop()!;
|
|
99
|
+
const children = h.childrenOf.get(current);
|
|
100
|
+
if (!children) continue;
|
|
101
|
+
for (const child of children) {
|
|
102
|
+
if (result.has(child)) continue;
|
|
103
|
+
result.add(child);
|
|
104
|
+
stack.push(child);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
h.descendantsCache.set(tag, result);
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Detect cycles in the declared hierarchy. Returns the list of tags
|
|
114
|
+
* reachable from themselves via parent declarations. Used by
|
|
115
|
+
* `update-tag` write paths to surface a warning to the caller without
|
|
116
|
+
* blocking the write — cycles are tolerated at runtime (descendant
|
|
117
|
+
* traversal uses a visited set), but they're almost always a config bug.
|
|
118
|
+
*/
|
|
119
|
+
export function findHierarchyCycles(h: TagHierarchy): string[] {
|
|
120
|
+
const cycles: string[] = [];
|
|
121
|
+
for (const tag of h.childrenOf.keys()) {
|
|
122
|
+
const descendants = getTagDescendants(h, tag);
|
|
123
|
+
if (descendants.has(tag) && descendants.size > 1) {
|
|
124
|
+
// tag reaches itself through a non-trivial path
|
|
125
|
+
const ownChildren = h.childrenOf.get(tag);
|
|
126
|
+
if (ownChildren) {
|
|
127
|
+
for (const child of ownChildren) {
|
|
128
|
+
if (getTagDescendants(h, child).has(tag)) {
|
|
129
|
+
cycles.push(tag);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return cycles;
|
|
137
|
+
}
|