@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,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
|
+
}
|