@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.
- package/README.md +85 -0
- package/bin/emdee.js +196 -0
- package/package.json +78 -0
- package/src/cli/read-commands.ts +64 -0
- package/src/core/indexer.ts +346 -0
- package/src/core/parseEdges.ts +106 -0
- package/src/core/resolveLink.ts +136 -0
- package/src/core/siblings.ts +65 -0
- package/src/core/syncDocEdges.ts +427 -0
- package/src/lib/cache/bust.ts +44 -0
- package/src/lib/cache/invalidation.ts +28 -0
- package/src/lib/mcp/activity.ts +254 -0
- package/src/lib/mcp/tools/add_association.ts +311 -0
- package/src/lib/mcp/tools/append_doc.ts +70 -0
- package/src/lib/mcp/tools/append_section.ts +121 -0
- package/src/lib/mcp/tools/create_child.ts +319 -0
- package/src/lib/mcp/tools/delete_doc.ts +68 -0
- package/src/lib/mcp/tools/distill_doc.ts +262 -0
- package/src/lib/mcp/tools/filename.ts +63 -0
- package/src/lib/mcp/tools/get_context.ts +227 -0
- package/src/lib/mcp/tools/get_doc.ts +91 -0
- package/src/lib/mcp/tools/get_image.ts +62 -0
- package/src/lib/mcp/tools/get_neighbors.ts +96 -0
- package/src/lib/mcp/tools/get_summary.ts +18 -0
- package/src/lib/mcp/tools/index.ts +26 -0
- package/src/lib/mcp/tools/lint.ts +552 -0
- package/src/lib/mcp/tools/lint_doc.ts +76 -0
- package/src/lib/mcp/tools/lint_gate.ts +49 -0
- package/src/lib/mcp/tools/list_docs.ts +18 -0
- package/src/lib/mcp/tools/list_summary_drift.ts +95 -0
- package/src/lib/mcp/tools/materialize_subgroup.ts +274 -0
- package/src/lib/mcp/tools/move_doc.ts +439 -0
- package/src/lib/mcp/tools/patch_preamble.ts +145 -0
- package/src/lib/mcp/tools/patch_section.ts +113 -0
- package/src/lib/mcp/tools/read_doc_section.ts +70 -0
- package/src/lib/mcp/tools/rename_doc.ts +167 -0
- package/src/lib/mcp/tools/restore_doc.ts +41 -0
- package/src/lib/mcp/tools/search.ts +36 -0
- package/src/lib/mcp/tools/sections.ts +130 -0
- package/src/lib/mcp/tools/split_doc.ts +129 -0
- package/src/lib/mcp/tools/trash_doc.ts +116 -0
- package/src/lib/mcp/tools/types.ts +7 -0
- package/src/lib/mcp/tools/upload_image.ts +77 -0
- package/src/lib/mcp/tools/validate_args.ts +36 -0
- package/src/lib/mcp/tools/vault.ts +430 -0
- package/src/lib/mcp/tools/write_doc.ts +81 -0
- package/src/lib/mcp/tools/write_doc_preview.ts +59 -0
- package/src/lib/owner/identity.ts +65 -0
- package/src/lib/storage/FilesystemStorage.ts +88 -0
- package/src/lib/storage/SupabaseStorage.ts +358 -0
- package/src/lib/storage/VaultStorage.ts +35 -0
- package/src/lib/storage/index.ts +41 -0
- package/src/lib/supabase/admin.ts +16 -0
- package/src/lib/supabase/client.ts +8 -0
- package/src/lib/supabase/oauth.ts +296 -0
- package/src/lib/supabase/server.ts +22 -0
- package/src/lib/system-nodes.ts +68 -0
- package/src/lib/trash/state.ts +88 -0
- package/src/mcp/server.ts +380 -0
- package/templates/types/CONCEPT.md +21 -0
- package/templates/types/HACKATHON.md +28 -0
- package/templates/types/NOVEL/CHARACTERS.md +29 -0
- package/templates/types/NOVEL/DRAFT.md +15 -0
- package/templates/types/NOVEL/EDITS.md +19 -0
- package/templates/types/NOVEL/INBOX.md +15 -0
- package/templates/types/NOVEL/INSTRUCTIONS.md +32 -0
- package/templates/types/NOVEL/LEARNINGS.md +9 -0
- package/templates/types/NOVEL/OUTBOX.md +15 -0
- package/templates/types/NOVEL/PLOT.md +27 -0
- package/templates/types/NOVEL/WORLDBUILDING.md +23 -0
- package/templates/types/NOVEL.md +32 -0
- package/templates/types/PERSON.md +27 -0
- package/templates/types/PROJECT/BRAND.md +23 -0
- package/templates/types/PROJECT/BUILD.md +9 -0
- package/templates/types/PROJECT/IDEAS.md +11 -0
- package/templates/types/PROJECT/INBOX.md +15 -0
- package/templates/types/PROJECT/INSTRUCTIONS.md +23 -0
- package/templates/types/PROJECT/LEARNINGS.md +9 -0
- package/templates/types/PROJECT/LOGS.md +9 -0
- package/templates/types/PROJECT/OUTBOX.md +15 -0
- package/templates/types/PROJECT/SPRINT.md +56 -0
- 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
|
+
}
|