@aisystemresources/emdee 0.1.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.
Files changed (82) hide show
  1. package/README.md +85 -0
  2. package/bin/emdee.js +196 -0
  3. package/package.json +78 -0
  4. package/src/cli/read-commands.ts +64 -0
  5. package/src/core/indexer.ts +346 -0
  6. package/src/core/parseEdges.ts +106 -0
  7. package/src/core/resolveLink.ts +136 -0
  8. package/src/core/siblings.ts +65 -0
  9. package/src/core/syncDocEdges.ts +427 -0
  10. package/src/lib/cache/bust.ts +44 -0
  11. package/src/lib/cache/invalidation.ts +28 -0
  12. package/src/lib/mcp/activity.ts +254 -0
  13. package/src/lib/mcp/tools/add_association.ts +311 -0
  14. package/src/lib/mcp/tools/append_doc.ts +70 -0
  15. package/src/lib/mcp/tools/append_section.ts +121 -0
  16. package/src/lib/mcp/tools/create_child.ts +319 -0
  17. package/src/lib/mcp/tools/delete_doc.ts +68 -0
  18. package/src/lib/mcp/tools/distill_doc.ts +262 -0
  19. package/src/lib/mcp/tools/filename.ts +63 -0
  20. package/src/lib/mcp/tools/get_context.ts +227 -0
  21. package/src/lib/mcp/tools/get_doc.ts +91 -0
  22. package/src/lib/mcp/tools/get_image.ts +62 -0
  23. package/src/lib/mcp/tools/get_neighbors.ts +96 -0
  24. package/src/lib/mcp/tools/get_summary.ts +18 -0
  25. package/src/lib/mcp/tools/index.ts +26 -0
  26. package/src/lib/mcp/tools/lint.ts +552 -0
  27. package/src/lib/mcp/tools/lint_doc.ts +76 -0
  28. package/src/lib/mcp/tools/lint_gate.ts +49 -0
  29. package/src/lib/mcp/tools/list_docs.ts +18 -0
  30. package/src/lib/mcp/tools/list_summary_drift.ts +95 -0
  31. package/src/lib/mcp/tools/materialize_subgroup.ts +274 -0
  32. package/src/lib/mcp/tools/move_doc.ts +439 -0
  33. package/src/lib/mcp/tools/patch_preamble.ts +145 -0
  34. package/src/lib/mcp/tools/patch_section.ts +113 -0
  35. package/src/lib/mcp/tools/read_doc_section.ts +70 -0
  36. package/src/lib/mcp/tools/rename_doc.ts +167 -0
  37. package/src/lib/mcp/tools/restore_doc.ts +41 -0
  38. package/src/lib/mcp/tools/search.ts +36 -0
  39. package/src/lib/mcp/tools/sections.ts +130 -0
  40. package/src/lib/mcp/tools/split_doc.ts +129 -0
  41. package/src/lib/mcp/tools/trash_doc.ts +116 -0
  42. package/src/lib/mcp/tools/types.ts +7 -0
  43. package/src/lib/mcp/tools/upload_image.ts +77 -0
  44. package/src/lib/mcp/tools/validate_args.ts +36 -0
  45. package/src/lib/mcp/tools/vault.ts +430 -0
  46. package/src/lib/mcp/tools/write_doc.ts +81 -0
  47. package/src/lib/mcp/tools/write_doc_preview.ts +59 -0
  48. package/src/lib/owner/identity.ts +65 -0
  49. package/src/lib/storage/FilesystemStorage.ts +88 -0
  50. package/src/lib/storage/SupabaseStorage.ts +358 -0
  51. package/src/lib/storage/VaultStorage.ts +35 -0
  52. package/src/lib/storage/index.ts +41 -0
  53. package/src/lib/supabase/admin.ts +16 -0
  54. package/src/lib/supabase/client.ts +8 -0
  55. package/src/lib/supabase/oauth.ts +296 -0
  56. package/src/lib/supabase/server.ts +22 -0
  57. package/src/lib/system-nodes.ts +68 -0
  58. package/src/lib/trash/state.ts +88 -0
  59. package/src/mcp/server.ts +380 -0
  60. package/templates/types/CONCEPT.md +21 -0
  61. package/templates/types/HACKATHON.md +28 -0
  62. package/templates/types/NOVEL/CHARACTERS.md +29 -0
  63. package/templates/types/NOVEL/DRAFT.md +15 -0
  64. package/templates/types/NOVEL/EDITS.md +19 -0
  65. package/templates/types/NOVEL/INBOX.md +15 -0
  66. package/templates/types/NOVEL/INSTRUCTIONS.md +32 -0
  67. package/templates/types/NOVEL/LEARNINGS.md +9 -0
  68. package/templates/types/NOVEL/OUTBOX.md +15 -0
  69. package/templates/types/NOVEL/PLOT.md +27 -0
  70. package/templates/types/NOVEL/WORLDBUILDING.md +23 -0
  71. package/templates/types/NOVEL.md +32 -0
  72. package/templates/types/PERSON.md +27 -0
  73. package/templates/types/PROJECT/BRAND.md +23 -0
  74. package/templates/types/PROJECT/BUILD.md +9 -0
  75. package/templates/types/PROJECT/IDEAS.md +11 -0
  76. package/templates/types/PROJECT/INBOX.md +15 -0
  77. package/templates/types/PROJECT/INSTRUCTIONS.md +23 -0
  78. package/templates/types/PROJECT/LEARNINGS.md +9 -0
  79. package/templates/types/PROJECT/LOGS.md +9 -0
  80. package/templates/types/PROJECT/OUTBOX.md +15 -0
  81. package/templates/types/PROJECT/SPRINT.md +56 -0
  82. package/templates/types/PROJECT.md +26 -0
@@ -0,0 +1,427 @@
1
+ // SPRINT-018 Phase 2: incremental doc_edges sync triggered by the
2
+ // storage write hook. Given the new content of one doc, compute the
3
+ // desired set of edge rows that touch this doc (outgoing AND inbound,
4
+ // since editing a child's `## Child of` can flip a hierarchy edge that
5
+ // lives as a row on the parent) and diff against what's currently in
6
+ // the table — minimum-churn DELETE/UPSERT.
7
+ //
8
+ // Suppression mirrors src/core/indexer.ts: hierarchy first; then assocs
9
+ // drop pairs that are already linked hierarchically or share a parent
10
+ // (siblings). Resolution mirrors src/core/resolveLink.ts → pickByLocality.
11
+
12
+ import type { SupabaseClient } from "@supabase/supabase-js";
13
+ import { parseEdges } from "./parseEdges";
14
+ import { pickByLocality, filenameSlug } from "./resolveLink";
15
+ import { SYSTEM_NODES, missingSystemNodeFiles } from "../lib/system-nodes";
16
+
17
+ interface EdgeRow {
18
+ namespace: string;
19
+ from_path: string;
20
+ to_path: string;
21
+ kind: "hierarchy" | "assoc";
22
+ label: string | null;
23
+ position: number;
24
+ }
25
+
26
+ interface DocMeta {
27
+ path: string;
28
+ title: string;
29
+ content: string;
30
+ }
31
+
32
+ // Append virtual system nodes to a doc set so [[EMDEE]] / [[VAULT]] etc.
33
+ // resolve in the edge resolver even when those nodes have no stored file.
34
+ // Uses the shared `missingSystemNodeFiles` helper for the file-shape payload,
35
+ // then wraps each result as a DocMeta with the canonical title.
36
+ function injectSystemNodes(docs: DocMeta[]): DocMeta[] {
37
+ const extras: DocMeta[] = missingSystemNodeFiles(docs.map((d) => d.path)).map((f) => {
38
+ const node = SYSTEM_NODES.find((n) => n.path === f.path)!;
39
+ return { path: f.path, title: node.title, content: f.content };
40
+ });
41
+ return extras.length > 0 ? [...docs, ...extras] : docs;
42
+ }
43
+
44
+ function deriveTitle(rel: string, content: string): string {
45
+ const m = content.match(/^#\s+(.+)$/m);
46
+ if (m) return m[1].trim();
47
+ const last = rel.split("/").pop() ?? rel;
48
+ return last.replace(/\.md$/i, "");
49
+ }
50
+
51
+ /**
52
+ * Build a title-or-slug resolver across the namespace. Same precedence
53
+ * order as buildIndexFromContents: H1 title first, filename slug as
54
+ * fallback; ambiguous matches broken by pickByLocality(fromPath).
55
+ */
56
+ function makeResolver(docs: DocMeta[]) {
57
+ const titleMap = new Map<string, string[]>();
58
+ const slugMap = new Map<string, string[]>();
59
+ for (const d of docs) {
60
+ const tKey = d.title.toLowerCase();
61
+ const sKey = filenameSlug(d.path).toLowerCase();
62
+ const tArr = titleMap.get(tKey) ?? [];
63
+ tArr.push(d.path);
64
+ titleMap.set(tKey, tArr);
65
+ const sArr = slugMap.get(sKey) ?? [];
66
+ sArr.push(d.path);
67
+ slugMap.set(sKey, sArr);
68
+ }
69
+ return (target: string, fromPath: string): string | undefined => {
70
+ const lower = target.toLowerCase();
71
+ const titles = titleMap.get(lower);
72
+ if (titles && titles.length > 0) {
73
+ return titles.length === 1 ? titles[0] : pickByLocality(titles.map((path) => ({ path })), fromPath).path;
74
+ }
75
+ const slugs = slugMap.get(lower);
76
+ if (slugs && slugs.length > 0) {
77
+ return slugs.length === 1 ? slugs[0] : pickByLocality(slugs.map((path) => ({ path })), fromPath).path;
78
+ }
79
+ return undefined;
80
+ };
81
+ }
82
+
83
+ interface DesiredEdges {
84
+ /** Hierarchy rows keyed by `from::to`. */
85
+ hierMap: Map<string, EdgeRow>;
86
+ /** Assoc rows keyed by `from::to` (already expanded to two rows per pair). */
87
+ assocMap: Map<string, EdgeRow>;
88
+ }
89
+
90
+ /**
91
+ * Derive every hierarchy + assoc row implied by `docs`. No filter applied —
92
+ * useful for namespace-wide backfill. The single-doc sync path wraps this
93
+ * with a `from_path/to_path === affectedPath` filter (see `computeDesired`).
94
+ */
95
+ function computeAllEdges(
96
+ namespace: string,
97
+ docs: DocMeta[],
98
+ ): DesiredEdges {
99
+ const resolve = makeResolver(docs);
100
+ const hierMap = new Map<string, EdgeRow>();
101
+
102
+ // Pass 1a: parent_of bullets across all docs (authoritative for position).
103
+ for (const d of docs) {
104
+ const bullets = parseEdges(d.content);
105
+ let pos = 0;
106
+ for (const b of bullets) {
107
+ if (b.kind !== "parent_of") continue;
108
+ const target = resolve(b.target, d.path);
109
+ if (!target || target === d.path) continue;
110
+ hierMap.set(`${d.path}::${target}`, {
111
+ namespace,
112
+ from_path: d.path,
113
+ to_path: target,
114
+ kind: "hierarchy",
115
+ label: b.label,
116
+ position: pos++,
117
+ });
118
+ }
119
+ }
120
+ // Pass 1b: child_of bullets fill in asymmetric stragglers.
121
+ for (const d of docs) {
122
+ const bullets = parseEdges(d.content);
123
+ for (const b of bullets) {
124
+ if (b.kind !== "child_of") continue;
125
+ const target = resolve(b.target, d.path);
126
+ if (!target || target === d.path) continue;
127
+ const key = `${target}::${d.path}`;
128
+ if (hierMap.has(key)) continue;
129
+ hierMap.set(key, {
130
+ namespace,
131
+ from_path: target,
132
+ to_path: d.path,
133
+ kind: "hierarchy",
134
+ label: b.label,
135
+ position: 9999,
136
+ });
137
+ }
138
+ }
139
+
140
+ // Build pair-set + parents-of for assoc suppression.
141
+ const hierPairs = new Set<string>();
142
+ const parentsOf = new Map<string, Set<string>>();
143
+ for (const r of hierMap.values()) {
144
+ const [lo, hi] = r.from_path < r.to_path ? [r.from_path, r.to_path] : [r.to_path, r.from_path];
145
+ hierPairs.add(`${lo}::${hi}`);
146
+ const set = parentsOf.get(r.to_path) ?? new Set<string>();
147
+ set.add(r.from_path);
148
+ parentsOf.set(r.to_path, set);
149
+ }
150
+ const shareParent = (a: string, b: string) => {
151
+ const pa = parentsOf.get(a);
152
+ const pb = parentsOf.get(b);
153
+ if (!pa || !pb) return false;
154
+ for (const p of pa) if (pb.has(p)) return true;
155
+ return false;
156
+ };
157
+
158
+ // Pass 2: assocs, deduped per pair, suppressed by hierarchy + siblings.
159
+ interface AssocPair { a: string; b: string; label: string | null; position: number; }
160
+ const assocPairs = new Map<string, AssocPair>();
161
+ for (const d of docs) {
162
+ const bullets = parseEdges(d.content);
163
+ let pos = 0;
164
+ for (const b of bullets) {
165
+ if (b.kind !== "associated") continue;
166
+ const target = resolve(b.target, d.path);
167
+ if (!target || target === d.path) continue;
168
+ const [lo, hi] = d.path < target ? [d.path, target] : [target, d.path];
169
+ const key = `${lo}::${hi}`;
170
+ if (hierPairs.has(key)) continue;
171
+ if (shareParent(d.path, target)) continue;
172
+ if (!assocPairs.has(key)) {
173
+ assocPairs.set(key, { a: d.path, b: target, label: b.label, position: pos });
174
+ pos++;
175
+ }
176
+ }
177
+ }
178
+
179
+ const assocMap = new Map<string, EdgeRow>();
180
+ for (const { a, b, label, position } of assocPairs.values()) {
181
+ assocMap.set(`${a}::${b}`, { namespace, from_path: a, to_path: b, kind: "assoc", label, position });
182
+ assocMap.set(`${b}::${a}`, { namespace, from_path: b, to_path: a, kind: "assoc", label, position });
183
+ }
184
+
185
+ return { hierMap, assocMap };
186
+ }
187
+
188
+ /**
189
+ * Recompute every hierarchy + assoc row in the namespace whose `from_path`
190
+ * OR `to_path` equals `affectedPath`. This is the set of rows that could
191
+ * have changed as a result of editing `affectedPath`. Returns rows the
192
+ * caller will diff against the current DB state.
193
+ *
194
+ * Edges originating from other docs (e.g. another doc's `## Parent of`
195
+ * still mentioning this doc) need to be evaluated against this doc's new
196
+ * title in case it changed — that's why we re-derive all edges touching
197
+ * affectedPath, not just edges declared in its own bullets.
198
+ */
199
+ function computeDesired(
200
+ namespace: string,
201
+ docs: DocMeta[],
202
+ affectedPath: string,
203
+ ): DesiredEdges {
204
+ const all = computeAllEdges(namespace, docs);
205
+ const filterTouching = <T extends EdgeRow>(map: Map<string, T>) => {
206
+ const out = new Map<string, T>();
207
+ for (const [k, r] of map) {
208
+ if (r.from_path === affectedPath || r.to_path === affectedPath) out.set(k, r);
209
+ }
210
+ return out;
211
+ };
212
+ return {
213
+ hierMap: filterTouching(all.hierMap),
214
+ assocMap: filterTouching(all.assocMap),
215
+ };
216
+ }
217
+
218
+ function rowKey(r: { from_path: string; to_path: string; kind: string }): string {
219
+ return `${r.kind}::${r.from_path}::${r.to_path}`;
220
+ }
221
+
222
+ function rowEqual(a: EdgeRow, b: EdgeRow): boolean {
223
+ return a.label === b.label && a.position === b.position;
224
+ }
225
+
226
+ /**
227
+ * Sync the doc_edges rows that touch `docPath` in `namespace` to match
228
+ * the post-write state implied by `newContent`. Loads every other doc
229
+ * in the namespace from `vault_files` (title + path only — no body for
230
+ * those) so the resolver can resolve cross-doc wiki-links.
231
+ *
232
+ * Throws on database errors — the caller (SupabaseStorage.write) lets
233
+ * this propagate so the API PUT returns 500 and the user knows the
234
+ * bucket+cache succeeded but the edges did not.
235
+ */
236
+ export async function syncDocEdges(
237
+ admin: SupabaseClient,
238
+ namespace: string,
239
+ docPath: string,
240
+ newContent: string,
241
+ ): Promise<void> {
242
+ // Pull every doc in the namespace from vault_files. Bodies of OTHER
243
+ // docs aren't needed for edge derivation off this doc's content, but
244
+ // we DO need them: a parent's `## Parent of` bullet declaring [[B]]
245
+ // produces a hierarchy row even when B itself was just edited.
246
+ // Cheapest correct path is to read content for every doc and re-run
247
+ // the whole namespace's edge derivation, filtered down to rows that
248
+ // touch docPath.
249
+ const { data: rows, error: readErr } = await admin
250
+ .from("vault_files")
251
+ .select("file_path, content")
252
+ .eq("namespace", namespace);
253
+ if (readErr) throw new Error(`syncDocEdges: vault_files read failed: ${readErr.message}`);
254
+
255
+ const docs: DocMeta[] = (rows ?? []).map((r) => {
256
+ const content = (r.content as string) ?? "";
257
+ return {
258
+ path: r.file_path as string,
259
+ title: deriveTitle(r.file_path as string, content),
260
+ content,
261
+ };
262
+ });
263
+
264
+ // If docPath is missing from vault_files (deletion path), drop it from
265
+ // the doc set so resolution doesn't pin to a vanished target.
266
+ const filteredDocs = docs.filter((d) => d.path !== docPath || newContent !== "");
267
+ // Ensure the doc we're syncing reflects the new content (vault_files
268
+ // mirror may or may not have been updated yet depending on call order).
269
+ if (newContent) {
270
+ const existing = filteredDocs.find((d) => d.path === docPath);
271
+ if (existing) {
272
+ existing.content = newContent;
273
+ existing.title = deriveTitle(docPath, newContent);
274
+ } else {
275
+ filteredDocs.push({ path: docPath, title: deriveTitle(docPath, newContent), content: newContent });
276
+ }
277
+ }
278
+
279
+ const desired = computeDesired(namespace, injectSystemNodes(filteredDocs), docPath);
280
+ const desiredRows: EdgeRow[] = [...desired.hierMap.values(), ...desired.assocMap.values()];
281
+
282
+ // Load current rows that touch docPath (outgoing + inbound).
283
+ // SPRINT-024 Phase 2 audit: doc_edges reads MUST stay independent of
284
+ // vault_files — never JOIN them. Any "I need the linked doc's body
285
+ // alongside the edge" caller should fetch the body separately through
286
+ // SupabaseStorage.read so a future Postgres-only edge store can drop
287
+ // the vault_files dependency entirely.
288
+ const { data: curFrom, error: e1 } = await admin
289
+ .from("doc_edges")
290
+ .select("from_path, to_path, kind, label, position")
291
+ .eq("namespace", namespace)
292
+ .eq("from_path", docPath);
293
+ if (e1) throw new Error(`syncDocEdges: doc_edges read (from) failed: ${e1.message}`);
294
+ const { data: curTo, error: e2 } = await admin
295
+ .from("doc_edges")
296
+ .select("from_path, to_path, kind, label, position")
297
+ .eq("namespace", namespace)
298
+ .eq("to_path", docPath);
299
+ if (e2) throw new Error(`syncDocEdges: doc_edges read (to) failed: ${e2.message}`);
300
+
301
+ const currentMap = new Map<string, EdgeRow>();
302
+ for (const r of [...(curFrom ?? []), ...(curTo ?? [])]) {
303
+ const row: EdgeRow = {
304
+ namespace,
305
+ from_path: r.from_path as string,
306
+ to_path: r.to_path as string,
307
+ kind: r.kind as "hierarchy" | "assoc",
308
+ label: (r.label as string | null) ?? null,
309
+ position: (r.position as number) ?? 0,
310
+ };
311
+ currentMap.set(rowKey(row), row);
312
+ }
313
+
314
+ const desiredMap = new Map<string, EdgeRow>();
315
+ for (const r of desiredRows) desiredMap.set(rowKey(r), r);
316
+
317
+ // Compute diff: rows to delete (in current, not in desired) and rows
318
+ // to upsert (in desired and either missing or changed in current).
319
+ const toDelete: EdgeRow[] = [];
320
+ for (const [k, r] of currentMap) {
321
+ if (!desiredMap.has(k)) toDelete.push(r);
322
+ }
323
+ const toUpsert: EdgeRow[] = [];
324
+ for (const [k, r] of desiredMap) {
325
+ const cur = currentMap.get(k);
326
+ if (!cur || !rowEqual(cur, r)) toUpsert.push(r);
327
+ }
328
+
329
+ // Apply deletes one at a time (PK = composite, .delete().match() per row).
330
+ // Volume is typically <10 — not worth a stored-proc round trip.
331
+ for (const r of toDelete) {
332
+ const { error } = await admin
333
+ .from("doc_edges")
334
+ .delete()
335
+ .match({ namespace: r.namespace, from_path: r.from_path, to_path: r.to_path, kind: r.kind });
336
+ if (error) throw new Error(`syncDocEdges: delete failed: ${error.message}`);
337
+ }
338
+ if (toUpsert.length > 0) {
339
+ const { error } = await admin.from("doc_edges").upsert(toUpsert);
340
+ if (error) throw new Error(`syncDocEdges: upsert failed: ${error.message}`);
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Wipe and rebuild every doc_edges row for `namespace` from the current
346
+ * `vault_files` snapshot. Idempotent. Use this when:
347
+ *
348
+ * - The namespace was just seeded from `public/` and per-file `syncDocEdges`
349
+ * calls raced each other (parallel writes → incomplete cross-doc visibility
350
+ * in vault_files → some cross-doc wiki-links never resolved at sync time).
351
+ * - You need to repair a namespace whose `doc_edges` looks suspiciously sparse.
352
+ *
353
+ * Uses paginated vault_files reads (Supabase enforces a 1000-row server cap)
354
+ * and chunked inserts (500 rows per round trip).
355
+ */
356
+ export async function backfillNamespace(
357
+ admin: SupabaseClient,
358
+ namespace: string,
359
+ ): Promise<{ docs: number; rows: number }> {
360
+ const PAGE = 1000;
361
+ const docs: DocMeta[] = [];
362
+ let pageStart = 0;
363
+ while (true) {
364
+ const { data, error } = await admin
365
+ .from("vault_files")
366
+ .select("file_path, content")
367
+ .eq("namespace", namespace)
368
+ .range(pageStart, pageStart + PAGE - 1);
369
+ if (error) throw new Error(`backfillNamespace: vault_files read failed: ${error.message}`);
370
+ if (!data || data.length === 0) break;
371
+ for (const r of data) {
372
+ const content = (r.content as string) ?? "";
373
+ docs.push({
374
+ path: r.file_path as string,
375
+ title: deriveTitle(r.file_path as string, content),
376
+ content,
377
+ });
378
+ }
379
+ if (data.length < PAGE) break;
380
+ pageStart += PAGE;
381
+ }
382
+
383
+ const all = computeAllEdges(namespace, injectSystemNodes(docs));
384
+ const edgeRows: EdgeRow[] = [...all.hierMap.values(), ...all.assocMap.values()];
385
+
386
+ // Wipe + replace. Clearing first avoids stale rows surviving the upsert
387
+ // when an edge no longer exists in the new doc set.
388
+ const { error: delErr } = await admin
389
+ .from("doc_edges")
390
+ .delete()
391
+ .eq("namespace", namespace);
392
+ if (delErr) throw new Error(`backfillNamespace: clear failed: ${delErr.message}`);
393
+
394
+ const CHUNK = 500;
395
+ for (let i = 0; i < edgeRows.length; i += CHUNK) {
396
+ const { error: insErr } = await admin
397
+ .from("doc_edges")
398
+ .upsert(edgeRows.slice(i, i + CHUNK));
399
+ if (insErr) throw new Error(`backfillNamespace: insert failed: ${insErr.message}`);
400
+ }
401
+
402
+ return { docs: docs.length, rows: edgeRows.length };
403
+ }
404
+
405
+ /**
406
+ * Delete every edge touching `docPath` in the namespace. Called from
407
+ * SupabaseStorage.delete to keep doc_edges in sync with file removals.
408
+ */
409
+ export async function deleteDocEdges(
410
+ admin: SupabaseClient,
411
+ namespace: string,
412
+ docPath: string,
413
+ ): Promise<void> {
414
+ // OR-conditional delete: rows where from_path = X OR to_path = X.
415
+ const { error: e1 } = await admin
416
+ .from("doc_edges")
417
+ .delete()
418
+ .eq("namespace", namespace)
419
+ .eq("from_path", docPath);
420
+ if (e1) throw new Error(`deleteDocEdges: from delete failed: ${e1.message}`);
421
+ const { error: e2 } = await admin
422
+ .from("doc_edges")
423
+ .delete()
424
+ .eq("namespace", namespace)
425
+ .eq("to_path", docPath);
426
+ if (e2) throw new Error(`deleteDocEdges: to delete failed: ${e2.message}`);
427
+ }
@@ -0,0 +1,44 @@
1
+ // SPRINT-024 Phase 3: write-path cache busting.
2
+ //
3
+ // `revalidateTag` only invalidates the Next.js Data Cache (`unstable_cache`,
4
+ // tagged fetch calls). On Vercel, route-handler responses cached at the
5
+ // edge via `Cache-Control: s-maxage=…` are purged when the response
6
+ // carries a matching `Cache-Tag` header AND `revalidateTag` is invoked
7
+ // against that tag. Off Vercel the s-maxage TTL is the only invalidator —
8
+ // 60s eventual consistency, which is acceptable for the index/summary
9
+ // surfaces this hooks.
10
+ //
11
+ // Personal namespaces aren't cached (`no-store`), so calling
12
+ // `bustVaultCache("<userId>", …)` is effectively a no-op — but invoking
13
+ // it unconditionally from the storage write path keeps the call site
14
+ // simple and makes the public namespace work without special-casing.
15
+
16
+ import { revalidateTag } from "next/cache";
17
+
18
+ export function vaultListTag(ns: string): string {
19
+ return `${ns}:list`;
20
+ }
21
+
22
+ export function vaultPathTag(ns: string, path: string): string {
23
+ return `${ns}:${path}`;
24
+ }
25
+
26
+ /**
27
+ * Bust the index-list tag for `ns` and (optionally) the specific path
28
+ * tag. Wrapped in try/catch by the caller — a revalidation failure must
29
+ * not surface as a 500 on the write that succeeded.
30
+ */
31
+ export function bustVaultCache(ns: string, path?: string): void {
32
+ // This Next.js requires a second profile arg to `revalidateTag`. `'max'`
33
+ // uses stale-while-revalidate semantics so reads don't block on the
34
+ // refresh — the doc list / summary will briefly show stale before the
35
+ // fresh response is regenerated. For our editing surfaces that's the
36
+ // right tradeoff (writes complete fast, reads don't stall).
37
+ try {
38
+ if (path) revalidateTag(vaultPathTag(ns, path), "max");
39
+ revalidateTag(vaultListTag(ns), "max");
40
+ } catch {
41
+ // revalidateTag throws when called outside a request scope (e.g.
42
+ // local stdio MCP server, scripts). Swallow — TTL is the fallback.
43
+ }
44
+ }
@@ -0,0 +1,28 @@
1
+ // SPRINT-035: tiny pub/sub so module-scope caches (storage list cache in
2
+ // SupabaseStorage, loadVaultIndex memo in vault.ts) stay in sync without
3
+ // importing each other. SupabaseStorage publishes on write/delete;
4
+ // subscribers clear their own state.
5
+ //
6
+ // Module-scope: subscribers register at import time, so the registry is
7
+ // shared across all callers within one Vercel function instance. Cold
8
+ // starts re-run the module init, re-registering subscribers — no manual
9
+ // teardown needed.
10
+
11
+ type NamespaceInvalidator = (namespace: string) => void;
12
+
13
+ const subscribers: NamespaceInvalidator[] = [];
14
+
15
+ export function subscribeNamespaceInvalidate(fn: NamespaceInvalidator): void {
16
+ subscribers.push(fn);
17
+ }
18
+
19
+ export function publishNamespaceInvalidate(namespace: string): void {
20
+ for (const fn of subscribers) {
21
+ try {
22
+ fn(namespace);
23
+ } catch {
24
+ // Subscriber failures must not abort the publish loop or surface
25
+ // as write errors. Caches will time out via TTL anyway.
26
+ }
27
+ }
28
+ }