@openparachute/vault 0.4.8 → 0.4.9-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/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/core/src/hooks.ts
CHANGED
|
@@ -47,7 +47,34 @@
|
|
|
47
47
|
|
|
48
48
|
import type { Note, Store, Attachment } from "./types.js";
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Note-mutation events. `"created"` and `"updated"` carry the full post-write
|
|
52
|
+
* `Note`; `"deleted"` carries only `{ id, path }` — the row is gone by
|
|
53
|
+
* dispatch time, so handlers can't re-read it. Down-stream consumers (e.g.
|
|
54
|
+
* the git-mirror) match by id/path and react (remove file, etc.). Predicate
|
|
55
|
+
* authors writing a `when(note)` for `"deleted"` should rely on `note.id` /
|
|
56
|
+
* `note.path` only; everything else is undefined for the deleted shape.
|
|
57
|
+
*/
|
|
58
|
+
export type HookEvent = "created" | "updated" | "deleted";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Minimal identity payload for a deleted note. The row is gone by dispatch
|
|
62
|
+
* time, so handlers can't go back to the store for the rest. Path is
|
|
63
|
+
* optional because notes without a path are legal (e.g. fragments captured
|
|
64
|
+
* via API without a target slot).
|
|
65
|
+
*/
|
|
66
|
+
export interface DeletedNoteRef {
|
|
67
|
+
id: string;
|
|
68
|
+
path?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What a hook handler receives. For `"created"` / `"updated"` it's the full
|
|
73
|
+
* `Note` row; for `"deleted"` only the id/path remain. This union keeps the
|
|
74
|
+
* common predicates (path-prefix gates, tag checks for non-deleted shapes)
|
|
75
|
+
* working unchanged while making the "deleted has less" reality type-safe.
|
|
76
|
+
*/
|
|
77
|
+
export type NoteHookPayload = Note | DeletedNoteRef;
|
|
51
78
|
|
|
52
79
|
export interface NoteHook {
|
|
53
80
|
/** Events this hook listens for. Defaults to ["created", "updated"]. */
|
|
@@ -57,10 +84,26 @@ export interface NoteHook {
|
|
|
57
84
|
* Should be cheap and synchronous. Idempotency lives here: check
|
|
58
85
|
* whether a marker (e.g. `metadata.audio_rendered_at`) is already set
|
|
59
86
|
* and return false if so.
|
|
87
|
+
*
|
|
88
|
+
* For `"deleted"` events the payload is a `DeletedNoteRef` — only
|
|
89
|
+
* `id` / `path` are populated; tag/metadata/content fields are
|
|
90
|
+
* undefined. Predicates that read those fields effectively skip
|
|
91
|
+
* the deleted shape unless they handle the narrower payload.
|
|
92
|
+
*/
|
|
93
|
+
when?: (note: NoteHookPayload) => boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Handler — runs async, off the request path. Third arg is the event
|
|
96
|
+
* type. For `"deleted"` the payload is a `DeletedNoteRef`, not a full
|
|
97
|
+
* `Note` — the row is gone by dispatch time, so the store can't be
|
|
98
|
+
* queried for it. Handlers needing more context should stash it ahead
|
|
99
|
+
* of time on the note's `metadata` and read off the predicate / the
|
|
100
|
+
* tracking shape rather than re-querying.
|
|
60
101
|
*/
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
102
|
+
handler: (
|
|
103
|
+
note: NoteHookPayload,
|
|
104
|
+
store: Store,
|
|
105
|
+
event?: HookEvent,
|
|
106
|
+
) => Promise<void> | void;
|
|
64
107
|
/** Optional label for logs. */
|
|
65
108
|
name?: string;
|
|
66
109
|
}
|
|
@@ -70,22 +113,46 @@ interface RegisteredHook extends NoteHook {
|
|
|
70
113
|
}
|
|
71
114
|
|
|
72
115
|
/**
|
|
73
|
-
* Attachment-mutation events.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
116
|
+
* Attachment-mutation events. `"created"` carries the full attachment;
|
|
117
|
+
* `"deleted"` carries only `{ id, note_id, path }` (the row is gone by
|
|
118
|
+
* dispatch time, same shape rule as deleted notes).
|
|
119
|
+
*
|
|
120
|
+
* Consumers (transcription worker, git-mirror) subscribe here to react to
|
|
121
|
+
* lifecycle changes without polling. Keeping attachments separate from
|
|
122
|
+
* notes means a `NoteHook` predicate doesn't have to learn a second
|
|
123
|
+
* argument shape.
|
|
124
|
+
*/
|
|
125
|
+
export type AttachmentHookEvent = "created" | "deleted";
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Identity payload for a deleted attachment. The DB row is gone by
|
|
129
|
+
* dispatch time, so handlers can only react to id/note_id/path; metadata
|
|
130
|
+
* and timestamps are not preserved.
|
|
78
131
|
*/
|
|
79
|
-
export
|
|
132
|
+
export interface DeletedAttachmentRef {
|
|
133
|
+
id: string;
|
|
134
|
+
noteId: string;
|
|
135
|
+
path: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Union over what an attachment hook handler may receive. */
|
|
139
|
+
export type AttachmentHookPayload = Attachment | DeletedAttachmentRef;
|
|
80
140
|
|
|
81
141
|
export interface AttachmentHook {
|
|
82
142
|
/** Events this hook listens for. Defaults to ["created"]. */
|
|
83
143
|
event?: AttachmentHookEvent | AttachmentHookEvent[];
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Sync predicate. Same idempotency contract as `NoteHook.when`. For
|
|
146
|
+
* `"deleted"` the payload is a `DeletedAttachmentRef` — predicates
|
|
147
|
+
* relying on `metadata` / `createdAt` will see `undefined`.
|
|
148
|
+
*/
|
|
149
|
+
when?: (attachment: AttachmentHookPayload) => boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Handler — runs async, off the request path. For `"deleted"` the
|
|
152
|
+
* payload is a `DeletedAttachmentRef`, not a full `Attachment`.
|
|
153
|
+
*/
|
|
87
154
|
handler: (
|
|
88
|
-
attachment:
|
|
155
|
+
attachment: AttachmentHookPayload,
|
|
89
156
|
store: Store,
|
|
90
157
|
event?: AttachmentHookEvent,
|
|
91
158
|
) => Promise<void> | void;
|
|
@@ -97,6 +164,37 @@ interface RegisteredAttachmentHook extends AttachmentHook {
|
|
|
97
164
|
events: Set<AttachmentHookEvent>;
|
|
98
165
|
}
|
|
99
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Tag-mutation events. `"upserted"` fires on tag-record create/update
|
|
169
|
+
* (description, fields, relationships, parent_names — any mutation that
|
|
170
|
+
* could change the schema sidecar that the git-mirror writes). `"deleted"`
|
|
171
|
+
* fires when the tag row is removed.
|
|
172
|
+
*
|
|
173
|
+
* Why this exists: the export sidecars at `.parachute/schemas/<tag>.yaml`
|
|
174
|
+
* are part of the mirror's output. Without a tag-mutation event the mirror
|
|
175
|
+
* has no signal that those files might need to change.
|
|
176
|
+
*/
|
|
177
|
+
export type TagHookEvent = "upserted" | "deleted";
|
|
178
|
+
|
|
179
|
+
export interface TagHook {
|
|
180
|
+
/** Events this hook listens for. Defaults to ["upserted", "deleted"]. */
|
|
181
|
+
event?: TagHookEvent | TagHookEvent[];
|
|
182
|
+
/** Sync predicate keyed on the tag name. */
|
|
183
|
+
when?: (tag: string) => boolean;
|
|
184
|
+
/** Handler — runs async, off the request path. */
|
|
185
|
+
handler: (
|
|
186
|
+
tag: string,
|
|
187
|
+
store: Store,
|
|
188
|
+
event?: TagHookEvent,
|
|
189
|
+
) => Promise<void> | void;
|
|
190
|
+
/** Optional label for logs. */
|
|
191
|
+
name?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface RegisteredTagHook extends TagHook {
|
|
195
|
+
events: Set<TagHookEvent>;
|
|
196
|
+
}
|
|
197
|
+
|
|
100
198
|
/**
|
|
101
199
|
* Tiny async semaphore — FIFO waiters, no dependencies.
|
|
102
200
|
* Used to cap concurrent handler execution across all hooks.
|
|
@@ -139,6 +237,7 @@ export interface HookRegistryOptions {
|
|
|
139
237
|
export class HookRegistry {
|
|
140
238
|
private hooks: RegisteredHook[] = [];
|
|
141
239
|
private attachmentHooks: RegisteredAttachmentHook[] = [];
|
|
240
|
+
private tagHooks: RegisteredTagHook[] = [];
|
|
142
241
|
private semaphore: Semaphore;
|
|
143
242
|
private inFlight = new Set<Promise<void>>();
|
|
144
243
|
private logger: { error: (...args: unknown[]) => void };
|
|
@@ -150,7 +249,15 @@ export class HookRegistry {
|
|
|
150
249
|
this.logger = opts.logger ?? console;
|
|
151
250
|
}
|
|
152
251
|
|
|
153
|
-
/**
|
|
252
|
+
/**
|
|
253
|
+
* Register a note-mutation hook. Returns an unregister function.
|
|
254
|
+
*
|
|
255
|
+
* The default event set is `["created", "updated"]` — explicit
|
|
256
|
+
* `event: "deleted"` (or include it in the array) is required to
|
|
257
|
+
* subscribe to deletions. This keeps existing hooks that pre-date the
|
|
258
|
+
* `"deleted"` event from suddenly receiving a payload shape
|
|
259
|
+
* (`DeletedNoteRef`) they weren't typed for.
|
|
260
|
+
*/
|
|
154
261
|
onNote(hook: NoteHook): () => void {
|
|
155
262
|
const events = new Set<HookEvent>(
|
|
156
263
|
Array.isArray(hook.event)
|
|
@@ -167,7 +274,13 @@ export class HookRegistry {
|
|
|
167
274
|
};
|
|
168
275
|
}
|
|
169
276
|
|
|
170
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* Register an attachment-mutation hook. Returns an unregister function.
|
|
279
|
+
*
|
|
280
|
+
* The default event set is `["created"]` only (matches the historical
|
|
281
|
+
* shape pre-deletion-events). Subscribe to `"deleted"` explicitly when
|
|
282
|
+
* the handler needs to react to attachment removal.
|
|
283
|
+
*/
|
|
171
284
|
onAttachment(hook: AttachmentHook): () => void {
|
|
172
285
|
const events = new Set<AttachmentHookEvent>(
|
|
173
286
|
Array.isArray(hook.event)
|
|
@@ -184,15 +297,38 @@ export class HookRegistry {
|
|
|
184
297
|
};
|
|
185
298
|
}
|
|
186
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Register a tag-mutation hook. Returns an unregister function. Default
|
|
302
|
+
* event set is both `"upserted"` and `"deleted"` — symmetric with the
|
|
303
|
+
* sole consumer's needs (the git-mirror reacts to either to refresh its
|
|
304
|
+
* schema sidecars).
|
|
305
|
+
*/
|
|
306
|
+
onTag(hook: TagHook): () => void {
|
|
307
|
+
const events = new Set<TagHookEvent>(
|
|
308
|
+
Array.isArray(hook.event)
|
|
309
|
+
? hook.event
|
|
310
|
+
: hook.event
|
|
311
|
+
? [hook.event]
|
|
312
|
+
: (["upserted", "deleted"] as TagHookEvent[]),
|
|
313
|
+
);
|
|
314
|
+
const entry: RegisteredTagHook = { ...hook, events };
|
|
315
|
+
this.tagHooks.push(entry);
|
|
316
|
+
return () => {
|
|
317
|
+
const idx = this.tagHooks.indexOf(entry);
|
|
318
|
+
if (idx >= 0) this.tagHooks.splice(idx, 1);
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
187
322
|
/** Remove all registered hooks. Mostly for tests. */
|
|
188
323
|
clear(): void {
|
|
189
324
|
this.hooks = [];
|
|
190
325
|
this.attachmentHooks = [];
|
|
326
|
+
this.tagHooks = [];
|
|
191
327
|
}
|
|
192
328
|
|
|
193
|
-
/** Count of currently registered hooks (notes + attachments). */
|
|
329
|
+
/** Count of currently registered hooks (notes + attachments + tags). */
|
|
194
330
|
get size(): number {
|
|
195
|
-
return this.hooks.length + this.attachmentHooks.length;
|
|
331
|
+
return this.hooks.length + this.attachmentHooks.length + this.tagHooks.length;
|
|
196
332
|
}
|
|
197
333
|
|
|
198
334
|
/** Count of currently in-flight handler executions. */
|
|
@@ -201,13 +337,19 @@ export class HookRegistry {
|
|
|
201
337
|
}
|
|
202
338
|
|
|
203
339
|
/**
|
|
204
|
-
* Dispatch a mutation event. Matches hooks, schedules their
|
|
205
|
-
* onto a microtask, and returns immediately. The caller is
|
|
206
|
-
* blocked on handler execution.
|
|
340
|
+
* Dispatch a note-mutation event. Matches hooks, schedules their
|
|
341
|
+
* handlers onto a microtask, and returns immediately. The caller is
|
|
342
|
+
* never blocked on handler execution.
|
|
207
343
|
*
|
|
208
344
|
* Must only be called after the triggering SQLite write has committed.
|
|
345
|
+
*
|
|
346
|
+
* For `"deleted"` the `note` argument is a `DeletedNoteRef` ({ id,
|
|
347
|
+
* path }) — the row is gone, so the runtime can't re-read it before
|
|
348
|
+
* dispatching to handlers. For `"created"` / `"updated"` the full
|
|
349
|
+
* `Note` is expected; `runHandler` will re-read from the store for
|
|
350
|
+
* non-deleted events to pick up the latest committed state.
|
|
209
351
|
*/
|
|
210
|
-
dispatch(event: HookEvent, note:
|
|
352
|
+
dispatch(event: HookEvent, note: NoteHookPayload, store: Store): void {
|
|
211
353
|
if (this.hooks.length === 0) return;
|
|
212
354
|
|
|
213
355
|
// Snapshot matches synchronously so subsequent hook registration
|
|
@@ -241,13 +383,12 @@ export class HookRegistry {
|
|
|
241
383
|
|
|
242
384
|
/**
|
|
243
385
|
* Dispatch an attachment-mutation event. Same post-commit/microtask
|
|
244
|
-
* contract as `dispatch()` for notes
|
|
245
|
-
*
|
|
246
|
-
* committed.
|
|
386
|
+
* contract as `dispatch()` for notes. For `"deleted"` the payload is
|
|
387
|
+
* a `DeletedAttachmentRef`; for `"created"` it's the full `Attachment`.
|
|
247
388
|
*/
|
|
248
389
|
dispatchAttachment(
|
|
249
390
|
event: AttachmentHookEvent,
|
|
250
|
-
attachment:
|
|
391
|
+
attachment: AttachmentHookPayload,
|
|
251
392
|
store: Store,
|
|
252
393
|
): void {
|
|
253
394
|
if (this.attachmentHooks.length === 0) return;
|
|
@@ -277,19 +418,61 @@ export class HookRegistry {
|
|
|
277
418
|
});
|
|
278
419
|
}
|
|
279
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Dispatch a tag-mutation event. Same post-commit / microtask contract
|
|
423
|
+
* as the note + attachment dispatchers. `tag` is the bare tag name; the
|
|
424
|
+
* git-mirror handler matches on it to identify which `.parachute/schemas/<tag>.yaml`
|
|
425
|
+
* sidecar to rewrite or remove.
|
|
426
|
+
*/
|
|
427
|
+
dispatchTag(event: TagHookEvent, tag: string, store: Store): void {
|
|
428
|
+
if (this.tagHooks.length === 0) return;
|
|
429
|
+
|
|
430
|
+
const matches: RegisteredTagHook[] = [];
|
|
431
|
+
for (const hook of this.tagHooks) {
|
|
432
|
+
if (!hook.events.has(event)) continue;
|
|
433
|
+
try {
|
|
434
|
+
if (hook.when && !hook.when(tag)) continue;
|
|
435
|
+
} catch (err) {
|
|
436
|
+
this.logger.error(
|
|
437
|
+
`[hooks] predicate threw for ${hook.name ?? "anonymous"} on tag ${tag}:`,
|
|
438
|
+
err,
|
|
439
|
+
);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
matches.push(hook);
|
|
443
|
+
}
|
|
444
|
+
if (matches.length === 0) return;
|
|
445
|
+
|
|
446
|
+
queueMicrotask(() => {
|
|
447
|
+
for (const hook of matches) {
|
|
448
|
+
const task = this.runTagHandler(hook, event, tag, store);
|
|
449
|
+
this.inFlight.add(task);
|
|
450
|
+
task.finally(() => this.inFlight.delete(task));
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
280
455
|
private async runHandler(
|
|
281
456
|
hook: RegisteredHook,
|
|
282
457
|
event: HookEvent,
|
|
283
|
-
note:
|
|
458
|
+
note: NoteHookPayload,
|
|
284
459
|
store: Store,
|
|
285
460
|
): Promise<void> {
|
|
286
461
|
const release = await this.semaphore.acquire();
|
|
287
462
|
try {
|
|
288
|
-
//
|
|
289
|
-
// handler may have written back
|
|
290
|
-
// deleted
|
|
291
|
-
|
|
292
|
-
|
|
463
|
+
// For non-deleted events, re-read the note so the handler sees the
|
|
464
|
+
// latest committed state (another handler may have written back
|
|
465
|
+
// between dispatch and this acquisition). For "deleted" events the
|
|
466
|
+
// row is gone — pass the DeletedNoteRef payload straight through.
|
|
467
|
+
let payload: NoteHookPayload = note;
|
|
468
|
+
if (event !== "deleted") {
|
|
469
|
+
const fresh = await store.getNote(note.id);
|
|
470
|
+
if (fresh) payload = fresh;
|
|
471
|
+
// If the note was deleted between dispatch and re-read, fall
|
|
472
|
+
// back to the dispatch-time payload — same shape as before; the
|
|
473
|
+
// handler can sense the disappearance via its own predicate.
|
|
474
|
+
}
|
|
475
|
+
await hook.handler(payload, store, event);
|
|
293
476
|
} catch (err) {
|
|
294
477
|
this.logger.error(
|
|
295
478
|
`[hooks] handler ${hook.name ?? "anonymous"} threw on ${event} ${note.id}:`,
|
|
@@ -303,16 +486,19 @@ export class HookRegistry {
|
|
|
303
486
|
private async runAttachmentHandler(
|
|
304
487
|
hook: RegisteredAttachmentHook,
|
|
305
488
|
event: AttachmentHookEvent,
|
|
306
|
-
attachment:
|
|
489
|
+
attachment: AttachmentHookPayload,
|
|
307
490
|
store: Store,
|
|
308
491
|
): Promise<void> {
|
|
309
492
|
const release = await this.semaphore.acquire();
|
|
310
493
|
try {
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
494
|
+
// Symmetric with runHandler: re-read for non-deleted events,
|
|
495
|
+
// pass straight through on delete.
|
|
496
|
+
let payload: AttachmentHookPayload = attachment;
|
|
497
|
+
if (event !== "deleted") {
|
|
498
|
+
const fresh = await store.getAttachment(attachment.id);
|
|
499
|
+
if (fresh) payload = fresh;
|
|
500
|
+
}
|
|
501
|
+
await hook.handler(payload, store, event);
|
|
316
502
|
} catch (err) {
|
|
317
503
|
this.logger.error(
|
|
318
504
|
`[hooks] attachment handler ${hook.name ?? "anonymous"} threw on ${event} ${attachment.id}:`,
|
|
@@ -323,6 +509,25 @@ export class HookRegistry {
|
|
|
323
509
|
}
|
|
324
510
|
}
|
|
325
511
|
|
|
512
|
+
private async runTagHandler(
|
|
513
|
+
hook: RegisteredTagHook,
|
|
514
|
+
event: TagHookEvent,
|
|
515
|
+
tag: string,
|
|
516
|
+
store: Store,
|
|
517
|
+
): Promise<void> {
|
|
518
|
+
const release = await this.semaphore.acquire();
|
|
519
|
+
try {
|
|
520
|
+
await hook.handler(tag, store, event);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
this.logger.error(
|
|
523
|
+
`[hooks] tag handler ${hook.name ?? "anonymous"} threw on ${event} ${tag}:`,
|
|
524
|
+
err,
|
|
525
|
+
);
|
|
526
|
+
} finally {
|
|
527
|
+
release();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
326
531
|
/**
|
|
327
532
|
* Wait for all currently in-flight handlers to settle. Best-effort
|
|
328
533
|
* drain for graceful shutdown. New hooks dispatched during the drain
|
|
@@ -7,11 +7,13 @@ import {
|
|
|
7
7
|
declareField,
|
|
8
8
|
getIndexedField,
|
|
9
9
|
listIndexedFields,
|
|
10
|
+
pruneOrphanedIndexedFields,
|
|
10
11
|
rebuildIndexes,
|
|
11
12
|
releaseField,
|
|
12
13
|
TYPE_MAP,
|
|
13
14
|
validateFieldName,
|
|
14
15
|
} from "./indexed-fields.js";
|
|
16
|
+
import { buildVaultProjection } from "./vault-projection.js";
|
|
15
17
|
|
|
16
18
|
let db: Database;
|
|
17
19
|
let store: SqliteStore;
|
|
@@ -282,4 +284,153 @@ describe("delete-tag: indexed fields", () => {
|
|
|
282
284
|
expect(getIndexedField(db, "status")?.declarerTags).toEqual(["ticket"]);
|
|
283
285
|
expect(notesColumns()).toContain("meta_status");
|
|
284
286
|
});
|
|
287
|
+
|
|
288
|
+
// Bug 1b — the release lives in store.deleteTag now (not the MCP layer), so
|
|
289
|
+
// every delete entry point releases. Co-declaration sequencing: deleting the
|
|
290
|
+
// FIRST co-declarer keeps the column; deleting the SECOND drops it.
|
|
291
|
+
it("co-declaration: delete A keeps column (B holds), then delete B drops it", async () => {
|
|
292
|
+
const update = findTool("update-tag");
|
|
293
|
+
const del = findTool("delete-tag");
|
|
294
|
+
await update.execute({ tag: "asset", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
295
|
+
await update.execute({ tag: "storyboard", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
296
|
+
|
|
297
|
+
await del.execute({ tag: "asset" });
|
|
298
|
+
expect(getIndexedField(db, "aspect_ratio")?.declarerTags).toEqual(["storyboard"]);
|
|
299
|
+
expect(notesColumns()).toContain("meta_aspect_ratio");
|
|
300
|
+
|
|
301
|
+
await del.execute({ tag: "storyboard" });
|
|
302
|
+
expect(getIndexedField(db, "aspect_ratio")).toBeNull();
|
|
303
|
+
expect(notesColumns()).not.toContain("meta_aspect_ratio");
|
|
304
|
+
expect(notesIndexes()).not.toContain("idx_meta_aspect_ratio");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ===========================================================================
|
|
309
|
+
// Bug 1a — update-tag {fields: null} clears all of this tag's field schemas,
|
|
310
|
+
// dropping the exclusively-declared columns + indexes. `null` (clear-all) must
|
|
311
|
+
// be distinguished from `undefined` (no change). The gitcoin orphaned-fields
|
|
312
|
+
// bug was that `?? {}` collapsed null to a no-op.
|
|
313
|
+
// ===========================================================================
|
|
314
|
+
describe("update-tag: fields null vs undefined", () => {
|
|
315
|
+
it("fields:null drops the tag's exclusively-declared columns + indexed_fields rows", async () => {
|
|
316
|
+
const update = findTool("update-tag");
|
|
317
|
+
await update.execute({
|
|
318
|
+
tag: "project",
|
|
319
|
+
fields: { status: { type: "string", indexed: true }, priority: { type: "integer", indexed: true } },
|
|
320
|
+
});
|
|
321
|
+
expect(notesColumns()).toContain("meta_status");
|
|
322
|
+
expect(notesColumns()).toContain("meta_priority");
|
|
323
|
+
|
|
324
|
+
await update.execute({ tag: "project", fields: null });
|
|
325
|
+
|
|
326
|
+
expect(getIndexedField(db, "status")).toBeNull();
|
|
327
|
+
expect(getIndexedField(db, "priority")).toBeNull();
|
|
328
|
+
expect(notesColumns()).not.toContain("meta_status");
|
|
329
|
+
expect(notesColumns()).not.toContain("meta_priority");
|
|
330
|
+
expect(notesIndexes()).not.toContain("idx_meta_status");
|
|
331
|
+
// The tag's fields column is cleared too.
|
|
332
|
+
const rec = await store.getTagRecord("project");
|
|
333
|
+
expect(rec?.fields ?? null).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("fields:null no longer lists the fields in the vault-info indexed_fields catalog", async () => {
|
|
337
|
+
const update = findTool("update-tag");
|
|
338
|
+
await update.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
339
|
+
expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).toContain("status");
|
|
340
|
+
|
|
341
|
+
await update.execute({ tag: "project", fields: null });
|
|
342
|
+
expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).not.toContain("status");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("fields:undefined is a no-op — preserves existing field schemas + columns", async () => {
|
|
346
|
+
const update = findTool("update-tag");
|
|
347
|
+
await update.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
348
|
+
// Update only the description; omit fields entirely.
|
|
349
|
+
await update.execute({ tag: "project", description: "a project" });
|
|
350
|
+
expect(getIndexedField(db, "status")?.declarerTags).toEqual(["project"]);
|
|
351
|
+
expect(notesColumns()).toContain("meta_status");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("fields:null respects co-declaration — keeps a field another live tag still declares", async () => {
|
|
355
|
+
const update = findTool("update-tag");
|
|
356
|
+
await update.execute({ tag: "asset", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
357
|
+
await update.execute({ tag: "storyboard", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
358
|
+
|
|
359
|
+
await update.execute({ tag: "asset", fields: null });
|
|
360
|
+
expect(getIndexedField(db, "aspect_ratio")?.declarerTags).toEqual(["storyboard"]);
|
|
361
|
+
expect(notesColumns()).toContain("meta_aspect_ratio");
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ===========================================================================
|
|
366
|
+
// Bug 1c — prune for ALREADY-orphaned fields. The gitcoin case: an
|
|
367
|
+
// indexed_fields row whose every declarer tag has no `tags` row (orphaned by a
|
|
368
|
+
// pre-fix delete/clear that never released). prune finds + drops them; it must
|
|
369
|
+
// NOT touch fields with a live declarer.
|
|
370
|
+
// ===========================================================================
|
|
371
|
+
describe("pruneOrphanedIndexedFields", () => {
|
|
372
|
+
// Seed an orphaned field directly: declare via the API (creates the tag
|
|
373
|
+
// row + column), then delete the tag row out from under it WITHOUT going
|
|
374
|
+
// through the release path — exactly the pre-fix orphaned state.
|
|
375
|
+
function orphanField(field: string, type: "TEXT" | "INTEGER", tag: string) {
|
|
376
|
+
declareField(db, field, type, tag);
|
|
377
|
+
// Drop the tags row directly — simulating the pre-fix delete-tag that
|
|
378
|
+
// never released. The indexed_fields row + column survive (orphaned).
|
|
379
|
+
db.prepare("DELETE FROM tags WHERE name = ?").run(tag);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
it("dry-run reports the orphan without mutating", async () => {
|
|
383
|
+
orphanField("legacy_status", "TEXT", "ghost");
|
|
384
|
+
expect(notesColumns()).toContain("meta_legacy_status");
|
|
385
|
+
|
|
386
|
+
const plan = pruneOrphanedIndexedFields(db, { dryRun: true });
|
|
387
|
+
expect(plan).toEqual([{ field: "legacy_status", deadDeclarers: ["ghost"], dropped: true }]);
|
|
388
|
+
// Nothing changed.
|
|
389
|
+
expect(getIndexedField(db, "legacy_status")).not.toBeNull();
|
|
390
|
+
expect(notesColumns()).toContain("meta_legacy_status");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("apply drops the orphaned column + index + row", async () => {
|
|
394
|
+
orphanField("legacy_status", "TEXT", "ghost");
|
|
395
|
+
const plan = pruneOrphanedIndexedFields(db, { dryRun: false });
|
|
396
|
+
expect(plan).toEqual([{ field: "legacy_status", deadDeclarers: ["ghost"], dropped: true }]);
|
|
397
|
+
expect(getIndexedField(db, "legacy_status")).toBeNull();
|
|
398
|
+
expect(notesColumns()).not.toContain("meta_legacy_status");
|
|
399
|
+
expect(notesIndexes()).not.toContain("idx_meta_legacy_status");
|
|
400
|
+
// No longer advertised by vault-info.
|
|
401
|
+
expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).not.toContain("legacy_status");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("does NOT touch a field with a live declarer", async () => {
|
|
405
|
+
const update = findTool("update-tag");
|
|
406
|
+
await update.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
407
|
+
const plan = pruneOrphanedIndexedFields(db, { dryRun: false });
|
|
408
|
+
expect(plan).toEqual([]);
|
|
409
|
+
expect(getIndexedField(db, "status")?.declarerTags).toEqual(["project"]);
|
|
410
|
+
expect(notesColumns()).toContain("meta_status");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("trims dead declarers but keeps the column when a live co-declarer remains", async () => {
|
|
414
|
+
const update = findTool("update-tag");
|
|
415
|
+
// Two declarers, then orphan only one by deleting its tag row directly.
|
|
416
|
+
await update.execute({ tag: "asset", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
417
|
+
await update.execute({ tag: "storyboard", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
418
|
+
db.prepare("DELETE FROM tags WHERE name = ?").run("asset"); // orphan one declarer
|
|
419
|
+
|
|
420
|
+
const plan = pruneOrphanedIndexedFields(db, { dryRun: false });
|
|
421
|
+
expect(plan).toEqual([{ field: "aspect_ratio", deadDeclarers: ["asset"], dropped: false }]);
|
|
422
|
+
// Column kept; storyboard still declares it; asset trimmed from the set.
|
|
423
|
+
expect(getIndexedField(db, "aspect_ratio")?.declarerTags).toEqual(["storyboard"]);
|
|
424
|
+
expect(notesColumns()).toContain("meta_aspect_ratio");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("store.pruneIndexedFields surfaces the same plan", async () => {
|
|
428
|
+
orphanField("legacy_status", "TEXT", "ghost");
|
|
429
|
+
const dry = await store.pruneIndexedFields({ dryRun: true });
|
|
430
|
+
expect(dry).toEqual([{ field: "legacy_status", deadDeclarers: ["ghost"], dropped: true }]);
|
|
431
|
+
expect(getIndexedField(db, "legacy_status")).not.toBeNull(); // dry-run didn't mutate
|
|
432
|
+
const applied = await store.pruneIndexedFields({ dryRun: false });
|
|
433
|
+
expect(applied).toEqual([{ field: "legacy_status", deadDeclarers: ["ghost"], dropped: true }]);
|
|
434
|
+
expect(getIndexedField(db, "legacy_status")).toBeNull();
|
|
435
|
+
});
|
|
285
436
|
});
|
|
@@ -236,3 +236,101 @@ export function rebuildIndexes(db: Database): void {
|
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
|
+
|
|
240
|
+
export interface PrunedField {
|
|
241
|
+
/** The field whose `indexed_fields` row was affected. */
|
|
242
|
+
field: string;
|
|
243
|
+
/** Declarer tags that no longer have a `tags` row (removed from the set). */
|
|
244
|
+
deadDeclarers: string[];
|
|
245
|
+
/**
|
|
246
|
+
* True when the field had NO surviving live declarer and was fully dropped
|
|
247
|
+
* (row + generated column + index). False when at least one live declarer
|
|
248
|
+
* remained and only the dead declarers were pruned from the set.
|
|
249
|
+
*/
|
|
250
|
+
dropped: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Prune orphaned `indexed_fields` declarers — the gitcoin defect.
|
|
255
|
+
*
|
|
256
|
+
* A declarer tag is "dead" when no `tags` row carries that name (the tag was
|
|
257
|
+
* deleted, or its schema was cleared, without releasing the field). For every
|
|
258
|
+
* `indexed_fields` row:
|
|
259
|
+
*
|
|
260
|
+
* - Drop dead declarers from the set.
|
|
261
|
+
* - If NO live declarer remains, drop the whole field — row + generated
|
|
262
|
+
* column + index. This is the only data-loss-free drop: the generated
|
|
263
|
+
* column is `json_extract(metadata, …)` so the source values stay in
|
|
264
|
+
* `notes.metadata`; only the (now-dead) index is lost.
|
|
265
|
+
* - If at least one live declarer remains (co-declaration), keep the column
|
|
266
|
+
* and just trim the dead names from the declarer set.
|
|
267
|
+
*
|
|
268
|
+
* `dryRun` (default) computes the plan without mutating; pass `dryRun: false`
|
|
269
|
+
* to apply. Returns the per-field plan either way so the CLI / MCP surface can
|
|
270
|
+
* print what it would (or did) drop.
|
|
271
|
+
*/
|
|
272
|
+
export function pruneOrphanedIndexedFields(
|
|
273
|
+
db: Database,
|
|
274
|
+
opts: { dryRun?: boolean } = {},
|
|
275
|
+
): PrunedField[] {
|
|
276
|
+
const dryRun = opts.dryRun ?? true;
|
|
277
|
+
const liveTags = new Set(
|
|
278
|
+
(db.prepare("SELECT name FROM tags").all() as { name: string }[]).map((r) => r.name),
|
|
279
|
+
);
|
|
280
|
+
const plan: PrunedField[] = [];
|
|
281
|
+
for (const f of listIndexedFields(db)) {
|
|
282
|
+
const deadDeclarers = f.declarerTags.filter((t) => !liveTags.has(t));
|
|
283
|
+
if (deadDeclarers.length === 0) continue; // every declarer is live — leave it
|
|
284
|
+
const liveDeclarers = f.declarerTags.filter((t) => liveTags.has(t));
|
|
285
|
+
const dropped = liveDeclarers.length === 0;
|
|
286
|
+
plan.push({ field: f.field, deadDeclarers, dropped });
|
|
287
|
+
if (dryRun) continue;
|
|
288
|
+
if (dropped) {
|
|
289
|
+
db.prepare("DELETE FROM indexed_fields WHERE field = ?").run(f.field);
|
|
290
|
+
dropColumnAndIndex(db, f.field);
|
|
291
|
+
} else {
|
|
292
|
+
db.prepare("UPDATE indexed_fields SET declarer_tags = ? WHERE field = ?").run(
|
|
293
|
+
JSON.stringify(liveDeclarers),
|
|
294
|
+
f.field,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return plan;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Replay `declareField` for every field a tag schema marks `indexed: true`.
|
|
303
|
+
* Idempotent — used by the portable-md import path so a fresh import ends with
|
|
304
|
+
* the same generated columns a live vault would have (the import writes
|
|
305
|
+
* `tags.fields` via `upsertTagRecord` but never materializes the backing
|
|
306
|
+
* columns). Without this, an imported vault's schemas say `indexed: true` but
|
|
307
|
+
* queries fall back to full scans until each tag is next `update-tag`'d.
|
|
308
|
+
*
|
|
309
|
+
* `tagSchemas` is the post-import set of (tag, fields) pairs. Returns the
|
|
310
|
+
* number of (tag, field) declarations replayed.
|
|
311
|
+
*/
|
|
312
|
+
export function reconcileDeclaredIndexes(
|
|
313
|
+
db: Database,
|
|
314
|
+
tagSchemas: { tag: string; fields?: Record<string, { type: string; indexed?: boolean }> }[],
|
|
315
|
+
): number {
|
|
316
|
+
let declared = 0;
|
|
317
|
+
for (const schema of tagSchemas) {
|
|
318
|
+
if (!schema.fields) continue;
|
|
319
|
+
for (const [fieldName, spec] of Object.entries(schema.fields)) {
|
|
320
|
+
if (spec.indexed !== true) continue;
|
|
321
|
+
const mapped = mapFieldType(spec.type);
|
|
322
|
+
if (!mapped) continue; // unsupported type for indexing — skip, don't throw
|
|
323
|
+
try {
|
|
324
|
+
validateFieldName(fieldName);
|
|
325
|
+
declareField(db, fieldName, mapped, schema.tag);
|
|
326
|
+
declared++;
|
|
327
|
+
} catch (err) {
|
|
328
|
+
console.error(
|
|
329
|
+
`[indexed-fields] could not re-declare "${fieldName}" for tag "${schema.tag}" on import:`,
|
|
330
|
+
err,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return declared;
|
|
336
|
+
}
|