@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,65 @@
1
+ // SPRINT-058 (SIG-006): owner-node naming.
2
+ //
3
+ // The owner node is the user's personal subtree under EMDEE root. Per the
4
+ // canonical 4-node vault structure (VAULT / SHARED / GRAVEYARD / MINE),
5
+ // MINE is named per-user — by default derived from the user's primary
6
+ // email local-part, then renameable any time via the standard rename_doc
7
+ // MCP tool.
8
+
9
+ /**
10
+ * Derive the default owner-node title from an email address.
11
+ *
12
+ * "elz.work@gmail.com" → "ELZ-WORK"
13
+ * "junior_lin@..." → "JUNIOR-LIN"
14
+ * "song.wenjuan@..." → "SONG-WENJUAN"
15
+ * "" → "OWNER" (fallback)
16
+ *
17
+ * Rules:
18
+ * - take local-part (everything before @)
19
+ * - uppercase ASCII
20
+ * - dots / underscores → hyphens
21
+ * - drop everything that isn't [A-Z0-9-]
22
+ * - collapse repeated hyphens
23
+ * - strip leading/trailing hyphens
24
+ * - fall back to "OWNER" if the result is empty (e.g. all-non-ASCII)
25
+ *
26
+ * Matches SPRINT-055's filename uppercase convention so the file written
27
+ * to the vault is `<TITLE>.md`, identical shape to other EMDEE filenames.
28
+ */
29
+ /**
30
+ * Normalise any free-text name into a valid owner-node title.
31
+ * Same rules as email-derived titles so renames stay consistent.
32
+ */
33
+ export function normalizeOwnerTitle(input: string): string {
34
+ const normalized = input
35
+ .trim()
36
+ .toUpperCase()
37
+ .replace(/[._\s]/g, "-")
38
+ .replace(/[^A-Z0-9-]/g, "")
39
+ .replace(/-+/g, "-")
40
+ .replace(/^-|-$/g, "");
41
+ return normalized || "OWNER";
42
+ }
43
+
44
+ export function ownerTitleFromEmail(email: string): string {
45
+ const localPart = (email.split("@")[0] ?? "").trim();
46
+ return normalizeOwnerTitle(localPart);
47
+ }
48
+
49
+ /** The default markdown scaffold for a fresh owner node. */
50
+ export function ownerNodeScaffold(title: string): string {
51
+ return `# ${title}
52
+
53
+ > Your personal subtree. Top-level content (projects, people, notes, etc.) lives here. Renameable any time via \`rename_doc\` — inbound wiki-link references update atomically across the vault.
54
+
55
+ ## Child of
56
+
57
+ * [[EMDEE]]
58
+
59
+ ## Parent of
60
+
61
+ ## Associated with
62
+
63
+ ## Notes
64
+ `;
65
+ }
@@ -0,0 +1,88 @@
1
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { VaultFile, VaultStorage } from "./VaultStorage";
4
+
5
+ /**
6
+ * Filesystem-backed vault used in local development. Paths are relative to
7
+ * `rootDir`. Used by /api/index, /api/doc, etc. when `EMDEE_DOCS` is set so
8
+ * the dev experience reads/writes ./docs directly instead of round-tripping
9
+ * through Supabase.
10
+ */
11
+ export class FilesystemStorage implements VaultStorage {
12
+ constructor(private rootDir: string) {}
13
+
14
+ async list(prefix?: string): Promise<VaultFile[]> {
15
+ const start = prefix
16
+ ? path.join(this.rootDir, prefix.replace(/\/$/, ""))
17
+ : this.rootDir;
18
+ try {
19
+ await stat(start);
20
+ } catch {
21
+ return [];
22
+ }
23
+ const collected: VaultFile[] = [];
24
+ await this.walk(start, collected);
25
+ return collected;
26
+ }
27
+
28
+ private async walk(dir: string, out: VaultFile[]): Promise<void> {
29
+ const entries = await readdir(dir, { withFileTypes: true });
30
+ for (const e of entries) {
31
+ if (e.name.startsWith(".")) continue;
32
+ const full = path.join(dir, e.name);
33
+ if (e.isDirectory()) {
34
+ await this.walk(full, out);
35
+ } else if (e.isFile() && e.name.endsWith(".md")) {
36
+ const s = await stat(full);
37
+ out.push({
38
+ path: path.relative(this.rootDir, full),
39
+ content: "",
40
+ updatedAt: s.mtime.toISOString(),
41
+ });
42
+ }
43
+ }
44
+ }
45
+
46
+ /** FS already lists metadata without bodies — listMeta is an alias. */
47
+ async listMeta(prefix?: string): Promise<VaultFile[]> {
48
+ return this.list(prefix);
49
+ }
50
+
51
+ async listWithContent(prefix?: string): Promise<VaultFile[]> {
52
+ const listed = await this.list(prefix);
53
+ return Promise.all(
54
+ listed.map(async (f) => ({
55
+ path: f.path,
56
+ content: (await this.read(f.path)) ?? "",
57
+ updatedAt: f.updatedAt,
58
+ }))
59
+ );
60
+ }
61
+
62
+ async read(filePath: string): Promise<string | null> {
63
+ try {
64
+ return await readFile(path.join(this.rootDir, filePath), "utf8");
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ async write(filePath: string, content: string): Promise<void> {
71
+ const full = path.join(this.rootDir, filePath);
72
+ await mkdir(path.dirname(full), { recursive: true });
73
+ await writeFile(full, content, "utf8");
74
+ }
75
+
76
+ async delete(filePath: string): Promise<void> {
77
+ await rm(path.join(this.rootDir, filePath), { force: true });
78
+ }
79
+
80
+ async exists(filePath: string): Promise<boolean> {
81
+ try {
82
+ await stat(path.join(this.rootDir, filePath));
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,358 @@
1
+ import { adminClient } from "../supabase/admin";
2
+ import { syncDocEdges, deleteDocEdges } from "../../core/syncDocEdges";
3
+ import { bustVaultCache } from "../cache/bust";
4
+ import {
5
+ publishNamespaceInvalidate,
6
+ subscribeNamespaceInvalidate,
7
+ } from "../cache/invalidation";
8
+ import { hashBody } from "../mcp/tools/sections";
9
+ import { deriveSummary } from "../../core/indexer";
10
+ import type { VaultFile, VaultStorage } from "./VaultStorage";
11
+
12
+ const BUCKET = "vaults";
13
+ const CACHE_TABLE = "vault_files";
14
+
15
+ // SPRINT-035: module-scope TTL cache for list / listMeta / listWithContent.
16
+ // Eliminates the storage.search amplification documented in the
17
+ // 2026-05-31 IDEAS investigation. Promise-cached so concurrent misses on
18
+ // the same key dedupe to one underlying call. Writes invalidate via the
19
+ // shared pub/sub hub.
20
+ //
21
+ // Key format: `${method}:${prefix ?? ""}`. Methods: "list", "listMeta",
22
+ // "listWithContent". TTL bounded so silent external mutations are
23
+ // visible within one minute.
24
+ interface ListCacheEntry {
25
+ promise: Promise<VaultFile[]>;
26
+ expiresAt: number;
27
+ }
28
+ const listCache = new Map<string, ListCacheEntry>();
29
+ const LIST_CACHE_TTL_MS = 60_000;
30
+
31
+ const cacheStats = {
32
+ listHits: 0,
33
+ listMisses: 0,
34
+ invalidations: 0,
35
+ };
36
+
37
+ function namespaceFromKey(key: string): string {
38
+ // key = `${method}:${prefix}`; prefix = `${ns}/...` or `${ns}` or "".
39
+ const colon = key.indexOf(":");
40
+ const prefix = key.slice(colon + 1).replace(/\/$/, "");
41
+ const slash = prefix.indexOf("/");
42
+ return slash === -1 ? prefix : prefix.slice(0, slash);
43
+ }
44
+
45
+ function evictListCacheForNamespace(namespace: string): void {
46
+ let n = 0;
47
+ for (const key of listCache.keys()) {
48
+ if (namespaceFromKey(key) === namespace) {
49
+ listCache.delete(key);
50
+ n++;
51
+ }
52
+ }
53
+ cacheStats.invalidations += n;
54
+ }
55
+
56
+ // Subscribe at module init so any write through any SupabaseStorage
57
+ // instance — or any other namespace-publishing path — clears the cache.
58
+ subscribeNamespaceInvalidate(evictListCacheForNamespace);
59
+
60
+ async function withListCache(
61
+ method: string,
62
+ prefix: string | undefined,
63
+ slowPath: () => Promise<VaultFile[]>,
64
+ ): Promise<VaultFile[]> {
65
+ const key = `${method}:${prefix ?? ""}`;
66
+ const hit = listCache.get(key);
67
+ if (hit && hit.expiresAt > Date.now()) {
68
+ cacheStats.listHits++;
69
+ return hit.promise;
70
+ }
71
+ const promise = slowPath().catch((e) => {
72
+ // Don't cache failures — a transient blip shouldn't poison the cache.
73
+ listCache.delete(key);
74
+ throw e;
75
+ });
76
+ listCache.set(key, { promise, expiresAt: Date.now() + LIST_CACHE_TTL_MS });
77
+ cacheStats.listMisses++;
78
+ return promise;
79
+ }
80
+
81
+ export function getStorageCacheStats(): {
82
+ hits: number;
83
+ misses: number;
84
+ invalidations: number;
85
+ size: number;
86
+ hitRate: number;
87
+ } {
88
+ const total = cacheStats.listHits + cacheStats.listMisses;
89
+ return {
90
+ hits: cacheStats.listHits,
91
+ misses: cacheStats.listMisses,
92
+ invalidations: cacheStats.invalidations,
93
+ size: listCache.size,
94
+ hitRate: total === 0 ? 0 : cacheStats.listHits / total,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Split a full storage path ("user_X/foo/bar.md") into the cache table's
100
+ * composite key (namespace, file_path). Returns null for unprefixed paths
101
+ * which shouldn't happen in practice but we tolerate.
102
+ */
103
+ function splitNs(p: string): { namespace: string; file_path: string } | null {
104
+ const i = p.indexOf("/");
105
+ if (i === -1) return null;
106
+ return { namespace: p.slice(0, i), file_path: p.slice(i + 1) };
107
+ }
108
+
109
+ export class SupabaseStorage implements VaultStorage {
110
+ private bucket() {
111
+ return adminClient().storage.from(BUCKET);
112
+ }
113
+
114
+ async list(prefix?: string): Promise<VaultFile[]> {
115
+ return withListCache("list", prefix, async () => {
116
+ const folder = prefix ? prefix.replace(/\/$/, "") : "";
117
+ return this.walkFolder(folder);
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Metadata-only list. SPRINT-024 Phase 2 — selects only the columns the
123
+ * caller needs (namespace, file_path, updated_at) so the wire payload is
124
+ * a fraction of `listWithContent`'s. Used by change-polling and other
125
+ * surfaces that just want "what files exist + when did they last move".
126
+ *
127
+ * Like `listWithContent`, the fast path requires a single-segment
128
+ * namespace prefix; deeper prefixes fall back to walking Storage and
129
+ * stamping empty bodies.
130
+ */
131
+ async listMeta(prefix?: string): Promise<VaultFile[]> {
132
+ return withListCache("listMeta", prefix, async () => {
133
+ const folder = prefix ? prefix.replace(/\/$/, "") : "";
134
+ if (!folder || folder.includes("/")) {
135
+ return this.walkFolder(folder);
136
+ }
137
+ const { data, error } = await adminClient()
138
+ .from(CACHE_TABLE)
139
+ .select("file_path, updated_at")
140
+ .eq("namespace", folder);
141
+ if (!error && data) {
142
+ return data.map((r) => ({
143
+ path: `${folder}/${r.file_path}`,
144
+ content: "",
145
+ updatedAt: r.updated_at as string,
146
+ }));
147
+ }
148
+ return this.walkFolder(folder);
149
+ });
150
+ }
151
+
152
+ private async walkFolder(folder: string): Promise<VaultFile[]> {
153
+ const { data, error } = await this.bucket().list(folder || undefined, { limit: 1000 });
154
+ if (error || !data) return [];
155
+
156
+ const results: VaultFile[] = [];
157
+ await Promise.all(
158
+ data.map(async (item) => {
159
+ const itemPath = folder ? `${folder}/${item.name}` : item.name;
160
+ if (item.id === null) {
161
+ results.push(...(await this.walkFolder(itemPath)));
162
+ } else if (item.name.endsWith(".md")) {
163
+ results.push({
164
+ path: itemPath,
165
+ content: "",
166
+ updatedAt: item.updated_at ?? new Date(0).toISOString(),
167
+ });
168
+ }
169
+ })
170
+ );
171
+ return results;
172
+ }
173
+
174
+ /**
175
+ * Bulk-fetch every file under `prefix` via the vault_files cache. The
176
+ * cache is a derived index of Storage — if it's empty for a namespace
177
+ * we fall back to listing Storage and downloading each file, then
178
+ * repopulate the cache so subsequent reads are fast.
179
+ */
180
+ async listWithContent(prefix?: string): Promise<VaultFile[]> {
181
+ return withListCache("listWithContent", prefix, async () => {
182
+ const folder = prefix ? prefix.replace(/\/$/, "") : "";
183
+ // Postgres-cache is keyed by namespace; only fast-path when we have one.
184
+ if (!folder || folder.includes("/")) {
185
+ return this.bulkReadFromStorage(folder);
186
+ }
187
+
188
+ const admin = adminClient();
189
+ const { data, error } = await admin
190
+ .from(CACHE_TABLE)
191
+ .select("file_path, content, updated_at")
192
+ .eq("namespace", folder);
193
+
194
+ if (!error && data && data.length > 0) {
195
+ return data.map((r) => ({
196
+ path: `${folder}/${r.file_path}`,
197
+ content: r.content,
198
+ updatedAt: r.updated_at,
199
+ }));
200
+ }
201
+
202
+ // Cache miss (or error) — pay the slow path once and repopulate.
203
+ const files = await this.bulkReadFromStorage(folder);
204
+ if (files.length > 0) {
205
+ const rows = files
206
+ .map((f) => {
207
+ const split = splitNs(f.path);
208
+ if (!split) return null;
209
+ return { namespace: split.namespace, file_path: split.file_path, content: f.content };
210
+ })
211
+ .filter((r): r is { namespace: string; file_path: string; content: string } => r !== null);
212
+ if (rows.length > 0) {
213
+ await admin.from(CACHE_TABLE).upsert(rows, { onConflict: "namespace,file_path" });
214
+ }
215
+ }
216
+ return files;
217
+ });
218
+ }
219
+
220
+ private async bulkReadFromStorage(folder: string): Promise<VaultFile[]> {
221
+ const listed = await this.walkFolder(folder);
222
+ return Promise.all(
223
+ listed.map(async (f) => ({
224
+ path: f.path,
225
+ content: (await this.read(f.path)) ?? "",
226
+ updatedAt: f.updatedAt,
227
+ }))
228
+ );
229
+ }
230
+
231
+ async read(filePath: string): Promise<string | null> {
232
+ const split = splitNs(filePath);
233
+ if (split) {
234
+ const { data: cacheRow } = await adminClient()
235
+ .from(CACHE_TABLE)
236
+ .select("content")
237
+ .match({ namespace: split.namespace, file_path: split.file_path })
238
+ .maybeSingle();
239
+ if (cacheRow) return (cacheRow.content as string) ?? "";
240
+ }
241
+ // Fall back to Storage for unsplit paths or cache misses; repopulate
242
+ // the cache on success so subsequent reads stay fast.
243
+ const { data, error } = await this.bucket().download(filePath);
244
+ if (error || !data) return null;
245
+ const content = await data.text();
246
+ if (split) {
247
+ try {
248
+ await adminClient()
249
+ .from(CACHE_TABLE)
250
+ .upsert(
251
+ { namespace: split.namespace, file_path: split.file_path, content, updated_at: new Date().toISOString() },
252
+ { onConflict: "namespace,file_path" }
253
+ );
254
+ } catch {}
255
+ }
256
+ return content;
257
+ }
258
+
259
+ async write(filePath: string, content: string): Promise<void> {
260
+ // Storage is canonical — fail here propagates.
261
+ const blob = new Blob([content], { type: "text/markdown; charset=utf-8" });
262
+ const { error } = await this.bucket().upload(filePath, blob, {
263
+ upsert: true,
264
+ contentType: "text/markdown; charset=utf-8",
265
+ });
266
+ if (error) throw new Error(`storage write failed: ${error.message}`);
267
+
268
+ // Cache mirror — best-effort. If this fails the cache is stale; on
269
+ // next read we'd return stale content, but a rebuild script
270
+ // (scripts/backfill-vault-files.mjs) restores parity.
271
+ const split = splitNs(filePath);
272
+ if (!split) return;
273
+ try {
274
+ const admin = adminClient();
275
+
276
+ // SPRINT-081: record summary drift hashes. Snapshot the current
277
+ // content hash into `content_hash_at_summary_write` only when the
278
+ // summary itself changed — that's how drift accretes (body edits
279
+ // shift the live content hash without shifting the snapshot).
280
+ const summary = deriveSummary(content);
281
+ const summaryHash = summary ? hashBody(summary) : "";
282
+ const contentHash = hashBody(content);
283
+
284
+ const { data: existing } = await admin
285
+ .from(CACHE_TABLE)
286
+ .select("summary_hash")
287
+ .match({ namespace: split.namespace, file_path: split.file_path })
288
+ .maybeSingle();
289
+ const oldSummaryHash = (existing?.summary_hash as string | null) ?? null;
290
+ const summaryChanged = oldSummaryHash !== summaryHash;
291
+
292
+ const row: Record<string, unknown> = {
293
+ namespace: split.namespace,
294
+ file_path: split.file_path,
295
+ content,
296
+ updated_at: new Date().toISOString(),
297
+ summary_hash: summaryHash,
298
+ };
299
+ if (summaryChanged) row.content_hash_at_summary_write = contentHash;
300
+
301
+ const { error: cacheErr } = await admin
302
+ .from(CACHE_TABLE)
303
+ .upsert(row, { onConflict: "namespace,file_path" });
304
+ if (cacheErr) console.error(`vault_files cache write failed for ${filePath}:`, cacheErr.message);
305
+ } catch (e) {
306
+ console.error(`vault_files cache write threw for ${filePath}:`, e);
307
+ }
308
+
309
+ // SPRINT-018 Phase 2: keep doc_edges in sync after the cache mirror.
310
+ // Sync errors propagate — the bucket + cache have already committed
311
+ // (documented asymmetry), but a failed sync means the edges are
312
+ // wrong and the caller should know about it (HTTP 500).
313
+ await syncDocEdges(adminClient(), split.namespace, split.file_path, content);
314
+
315
+ // SPRINT-024 Phase 3: bust the edge cache for this namespace + path.
316
+ // Personal namespaces aren't cached (no-store) so this is effectively
317
+ // a no-op for them; for `public` it invalidates the s-maxage tier.
318
+ bustVaultCache(split.namespace, split.file_path);
319
+
320
+ // SPRINT-035: clear the in-memory list cache + any subscriber's
321
+ // namespace memo (e.g. vault.ts loadVaultIndex memo) so subsequent
322
+ // reads see the write without waiting for TTL.
323
+ publishNamespaceInvalidate(split.namespace);
324
+ }
325
+
326
+ async delete(filePath: string): Promise<void> {
327
+ await this.bucket().remove([filePath]);
328
+ const split = splitNs(filePath);
329
+ if (!split) return;
330
+ try {
331
+ const { error: cacheErr } = await adminClient()
332
+ .from(CACHE_TABLE)
333
+ .delete()
334
+ .match({ namespace: split.namespace, file_path: split.file_path });
335
+ if (cacheErr) console.error(`vault_files cache delete failed for ${filePath}:`, cacheErr.message);
336
+ } catch (e) {
337
+ console.error(`vault_files cache delete threw for ${filePath}:`, e);
338
+ }
339
+
340
+ // SPRINT-018 Phase 2: drop every edge touching this doc. Sync errors
341
+ // propagate — see comment on write() above for the asymmetry note.
342
+ await deleteDocEdges(adminClient(), split.namespace, split.file_path);
343
+
344
+ // SPRINT-024 Phase 3: see write() for cache busting rationale.
345
+ bustVaultCache(split.namespace, split.file_path);
346
+
347
+ // SPRINT-035: see write() — invalidate in-memory caches for this ns.
348
+ publishNamespaceInvalidate(split.namespace);
349
+ }
350
+
351
+ async exists(filePath: string): Promise<boolean> {
352
+ const parts = filePath.split("/");
353
+ const name = parts.pop()!;
354
+ const folder = parts.join("/");
355
+ const { data } = await this.bucket().list(folder || undefined, { search: name, limit: 1 });
356
+ return (data ?? []).some((f) => f.name === name && f.id !== null);
357
+ }
358
+ }
@@ -0,0 +1,35 @@
1
+ export interface VaultFile {
2
+ path: string; // relative path, e.g. "projects/SILENTMANE/BUILD.md"
3
+ content: string;
4
+ updatedAt: string; // ISO 8601
5
+ }
6
+
7
+ export interface VaultStorage {
8
+ /** List all .md files, optionally filtered to a path prefix. */
9
+ list(prefix?: string): Promise<VaultFile[]>;
10
+ /**
11
+ * Metadata-only list (path + updatedAt, no body). Cheaper than `list()`
12
+ * for cloud — pulls from the vault_files cache instead of recursing
13
+ * Storage. Use this whenever the caller doesn't need bodies (change
14
+ * polling, summary endpoints). Returns the same VaultFile shape with
15
+ * `content: ""` for compat.
16
+ *
17
+ * SPRINT-024 Phase 2.
18
+ */
19
+ listMeta(prefix?: string): Promise<VaultFile[]>;
20
+ /**
21
+ * Bulk-read every .md file under `prefix` in one shot. Implementations
22
+ * are encouraged to use a single round-trip — SupabaseStorage hits the
23
+ * vault_files Postgres cache so /api/index doesn't pay per-file HTTPS.
24
+ * FilesystemStorage just reads from disk.
25
+ */
26
+ listWithContent(prefix?: string): Promise<VaultFile[]>;
27
+ /** Read a single file. Returns null if not found. */
28
+ read(path: string): Promise<string | null>;
29
+ /** Write (create or overwrite) a file. */
30
+ write(path: string, content: string): Promise<void>;
31
+ /** Delete a file. No-op if not found. */
32
+ delete(path: string): Promise<void>;
33
+ /** Returns true if a file exists at path. */
34
+ exists(path: string): Promise<boolean>;
35
+ }
@@ -0,0 +1,41 @@
1
+ import path from "node:path";
2
+ import { SupabaseStorage } from "./SupabaseStorage";
3
+ import { FilesystemStorage } from "./FilesystemStorage";
4
+ import type { VaultStorage } from "./VaultStorage";
5
+
6
+ export type { VaultStorage, VaultFile } from "./VaultStorage";
7
+ export { SupabaseStorage } from "./SupabaseStorage";
8
+ export { FilesystemStorage } from "./FilesystemStorage";
9
+
10
+ /**
11
+ * Resolve the active vault storage based on the environment.
12
+ *
13
+ * - **Local dev** (`EMDEE_DOCS` set): every namespace maps to the same
14
+ * on-disk folder, so the dev server reads/writes ./docs directly. The
15
+ * {userId} in URLs is ignored — there's one local vault.
16
+ * - **Cloud** (no `EMDEE_DOCS`): SupabaseStorage scoped to `{ns}/`, so
17
+ * each Clerk user has an isolated namespace inside the `vaults` bucket.
18
+ *
19
+ * Returns the storage, the path-prefix to prepend to relative paths, and
20
+ * a flag callers can use to skip auth gates / namespace checks in local
21
+ * mode (the filesystem is single-tenant by definition).
22
+ */
23
+ export function getVaultStorage(ns: string): {
24
+ storage: VaultStorage;
25
+ prefix: string;
26
+ isLocal: boolean;
27
+ } {
28
+ const docsDir = process.env.EMDEE_DOCS;
29
+ if (docsDir) {
30
+ return {
31
+ storage: new FilesystemStorage(path.resolve(docsDir)),
32
+ prefix: "",
33
+ isLocal: true,
34
+ };
35
+ }
36
+ return {
37
+ storage: new SupabaseStorage(),
38
+ prefix: `${ns}/`,
39
+ isLocal: false,
40
+ };
41
+ }
@@ -0,0 +1,16 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+
3
+ // Service-role client — bypasses RLS, only used in server-side API routes.
4
+ export function adminClient() {
5
+ const key = process.env.SUPABASE_SECRET_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
6
+ return createClient(
7
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
8
+ key!,
9
+ { auth: { autoRefreshToken: false, persistSession: false } }
10
+ );
11
+ }
12
+
13
+ export async function hashToken(token: string): Promise<string> {
14
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
15
+ return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
16
+ }
@@ -0,0 +1,8 @@
1
+ import { createBrowserClient } from "@supabase/ssr";
2
+
3
+ export function createClient() {
4
+ return createBrowserClient(
5
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
6
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
7
+ );
8
+ }