wikimemory 0.2.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/LICENSE +21 -0
- package/README.md +111 -0
- package/dist/npm-cli/scripts/cli-options.js +29 -0
- package/dist/npm-cli/scripts/cli.js +79 -0
- package/dist/npm-cli/scripts/client-tools.js +57 -0
- package/dist/npm-cli/scripts/deployment-record.js +72 -0
- package/dist/npm-cli/scripts/dev.js +49 -0
- package/dist/npm-cli/scripts/lifecycle-runtime.js +38 -0
- package/dist/npm-cli/scripts/package-root.js +15 -0
- package/dist/npm-cli/scripts/passkeys.js +197 -0
- package/dist/npm-cli/scripts/setup.js +523 -0
- package/dist/npm-cli/scripts/status.js +49 -0
- package/dist/npm-cli/scripts/uninstall.js +220 -0
- package/dist/npm-cli/scripts/upgrade.js +301 -0
- package/dist/npm-cli/src/version.js +2 -0
- package/dist/web/assets/index-CjnFBnXp.css +1 -0
- package/dist/web/assets/index-buhGyrxi.js +72 -0
- package/dist/web/index.html +14 -0
- package/migrations/0001_initial.sql +297 -0
- package/migrations/0002_passkeys.sql +30 -0
- package/migrations/0003_passkey_management.sql +38 -0
- package/migrations/0004_credential_bound_registration_tokens.sql +18 -0
- package/package.json +89 -0
- package/release-manifest.json +22 -0
- package/skills/wikimemory-ingest/SKILL.md +24 -0
- package/skills/wikimemory-ingest/agents/openai.yaml +4 -0
- package/skills/wikimemory-install/SKILL.md +81 -0
- package/skills/wikimemory-install/agents/openai.yaml +4 -0
- package/skills/wikimemory-lint/SKILL.md +18 -0
- package/skills/wikimemory-lint/agents/openai.yaml +4 -0
- package/skills/wikimemory-recall/SKILL.md +20 -0
- package/skills/wikimemory-recall/agents/openai.yaml +4 -0
- package/src/auth/local.ts +131 -0
- package/src/auth/passkey-api.ts +100 -0
- package/src/auth/passkey-management.ts +150 -0
- package/src/auth/passkey.ts +908 -0
- package/src/auth/props.ts +96 -0
- package/src/auth/resource.ts +36 -0
- package/src/domain/crypto.ts +27 -0
- package/src/domain/errors.ts +32 -0
- package/src/domain/export-service.ts +363 -0
- package/src/domain/guards.ts +9 -0
- package/src/domain/memory-service.ts +1092 -0
- package/src/domain/secret-scanner.ts +45 -0
- package/src/domain/text-chunk.ts +24 -0
- package/src/domain/types.ts +169 -0
- package/src/env.ts +20 -0
- package/src/index.ts +138 -0
- package/src/mcp/schemas.ts +117 -0
- package/src/mcp/server.ts +506 -0
- package/src/version.ts +2 -0
- package/src/web/app.ts +299 -0
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
import { requestHash } from "./crypto";
|
|
2
|
+
import { DomainError } from "./errors";
|
|
3
|
+
import { scanSecrets } from "./secret-scanner";
|
|
4
|
+
import {
|
|
5
|
+
type ActorContext,
|
|
6
|
+
DOCUMENT_TYPES,
|
|
7
|
+
type DocumentIndexEntry,
|
|
8
|
+
type DocumentSnapshot,
|
|
9
|
+
type DocumentType,
|
|
10
|
+
type IngestRequest,
|
|
11
|
+
type IngestResult,
|
|
12
|
+
LINK_KINDS,
|
|
13
|
+
type LinkRequest,
|
|
14
|
+
type LinkValue,
|
|
15
|
+
type LintFinding,
|
|
16
|
+
type MemoryScope,
|
|
17
|
+
type MetadataValue,
|
|
18
|
+
type OwnerContext,
|
|
19
|
+
type PurgeAuthorization,
|
|
20
|
+
type RecallHit,
|
|
21
|
+
type RestoreRequest,
|
|
22
|
+
type RevisionHeader,
|
|
23
|
+
type StoredLink
|
|
24
|
+
} from "./types";
|
|
25
|
+
|
|
26
|
+
interface CurrentRow {
|
|
27
|
+
document_id: string;
|
|
28
|
+
workspace_id: string;
|
|
29
|
+
slug: string;
|
|
30
|
+
type: DocumentType;
|
|
31
|
+
revision_id: string;
|
|
32
|
+
revision_number: number;
|
|
33
|
+
parent_revision_id: string | null;
|
|
34
|
+
title: string;
|
|
35
|
+
body: string;
|
|
36
|
+
summary: string | null;
|
|
37
|
+
created_at: string;
|
|
38
|
+
principal_id: string;
|
|
39
|
+
client_id: string;
|
|
40
|
+
agent_label: string | null;
|
|
41
|
+
reason: string;
|
|
42
|
+
restored_from_revision_id: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface OperationRow {
|
|
46
|
+
request_hash: string;
|
|
47
|
+
principal_id: string;
|
|
48
|
+
kind: string;
|
|
49
|
+
status: "completed" | "purged";
|
|
50
|
+
result_document_id: string | null;
|
|
51
|
+
result_revision_id: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SINGLETON_KEYS = new Set([
|
|
55
|
+
"status",
|
|
56
|
+
"last_active",
|
|
57
|
+
"project",
|
|
58
|
+
"priority",
|
|
59
|
+
"confidence",
|
|
60
|
+
"source_url",
|
|
61
|
+
"source_type",
|
|
62
|
+
"trust"
|
|
63
|
+
]);
|
|
64
|
+
const MULTI_KEYS = new Set(["tag"]);
|
|
65
|
+
const METADATA_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
|
66
|
+
|
|
67
|
+
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
68
|
+
|
|
69
|
+
function requireScope(
|
|
70
|
+
actor: ActorContext,
|
|
71
|
+
scope: "memory:read" | "memory:write" | "memory:admin"
|
|
72
|
+
): void {
|
|
73
|
+
if (!actor.scopes.has(scope))
|
|
74
|
+
throw new DomainError("forbidden", `Missing required scope ${scope}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateRequest(request: IngestRequest): void {
|
|
78
|
+
if (!request.operationId || request.operationId.length > 200) {
|
|
79
|
+
throw new DomainError(
|
|
80
|
+
"validation_failed",
|
|
81
|
+
"operationId is required and must be at most 200 characters"
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (!SLUG.test(request.slug))
|
|
85
|
+
throw new DomainError("validation_failed", "slug must be lowercase kebab-case");
|
|
86
|
+
if (!request.reason || request.reason.length > 500) {
|
|
87
|
+
throw new DomainError(
|
|
88
|
+
"validation_failed",
|
|
89
|
+
"reason is required and must be at most 500 characters"
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (request.type !== undefined && !DOCUMENT_TYPES.includes(request.type)) {
|
|
93
|
+
throw new DomainError("validation_failed", "unsupported document type");
|
|
94
|
+
}
|
|
95
|
+
if (request.title !== undefined && request.title.length > 300) {
|
|
96
|
+
throw new DomainError("limit_exceeded", "title exceeds 300 characters");
|
|
97
|
+
}
|
|
98
|
+
if (request.summary !== undefined && request.summary !== null && request.summary.length > 1000) {
|
|
99
|
+
throw new DomainError("limit_exceeded", "summary exceeds 1000 characters");
|
|
100
|
+
}
|
|
101
|
+
if (request.body !== undefined && new TextEncoder().encode(request.body).byteLength > 262_144) {
|
|
102
|
+
throw new DomainError("limit_exceeded", "body exceeds 256 KiB");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function metadataMap(values: MetadataValue[]): Map<string, Set<string>> {
|
|
107
|
+
const result = new Map<string, Set<string>>();
|
|
108
|
+
for (const item of values) {
|
|
109
|
+
const bucket = result.get(item.key) ?? new Set<string>();
|
|
110
|
+
bucket.add(item.value);
|
|
111
|
+
result.set(item.key, bucket);
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function applyMetadataPatch(current: MetadataValue[], request: IngestRequest): MetadataValue[] {
|
|
117
|
+
const map = metadataMap(current);
|
|
118
|
+
const cardinalities = new Map(current.map((item) => [item.key, item.cardinality]));
|
|
119
|
+
for (const [key, value] of Object.entries(request.metadata?.set ?? {})) {
|
|
120
|
+
if (!METADATA_KEY.test(key))
|
|
121
|
+
throw new DomainError("validation_failed", `invalid metadata key ${key}`);
|
|
122
|
+
if (MULTI_KEYS.has(key) || cardinalities.get(key) === "multi") {
|
|
123
|
+
throw new DomainError("validation_failed", `metadata key ${key} is multivalued`);
|
|
124
|
+
}
|
|
125
|
+
if (value === null) {
|
|
126
|
+
map.delete(key);
|
|
127
|
+
cardinalities.delete(key);
|
|
128
|
+
} else {
|
|
129
|
+
map.set(key, new Set([value]));
|
|
130
|
+
cardinalities.set(key, "singleton");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const [key, patch] of Object.entries(request.metadata?.multi ?? {})) {
|
|
134
|
+
if (!METADATA_KEY.test(key))
|
|
135
|
+
throw new DomainError("validation_failed", `invalid metadata key ${key}`);
|
|
136
|
+
if (SINGLETON_KEYS.has(key) || cardinalities.get(key) === "singleton") {
|
|
137
|
+
throw new DomainError("validation_failed", `metadata key ${key} is singleton`);
|
|
138
|
+
}
|
|
139
|
+
const values =
|
|
140
|
+
patch.replace === undefined ? new Set(map.get(key) ?? []) : new Set(patch.replace);
|
|
141
|
+
for (const value of patch.add ?? []) values.add(value);
|
|
142
|
+
for (const value of patch.remove ?? []) values.delete(value);
|
|
143
|
+
if (values.size === 0) {
|
|
144
|
+
map.delete(key);
|
|
145
|
+
cardinalities.delete(key);
|
|
146
|
+
} else {
|
|
147
|
+
map.set(key, values);
|
|
148
|
+
cardinalities.set(key, "multi");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const result: MetadataValue[] = [];
|
|
152
|
+
for (const [key, values] of [...map].sort(([a], [b]) => a.localeCompare(b))) {
|
|
153
|
+
for (const value of [...values].sort()) {
|
|
154
|
+
if (value.length > 4096)
|
|
155
|
+
throw new DomainError("limit_exceeded", `metadata value for ${key} exceeds 4 KiB`);
|
|
156
|
+
const cardinality = cardinalities.get(key);
|
|
157
|
+
if (cardinality === undefined)
|
|
158
|
+
throw new DomainError("internal_error", `metadata cardinality missing for ${key}`);
|
|
159
|
+
result.push({ key, value, cardinality });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (result.length > 100)
|
|
163
|
+
throw new DomainError("limit_exceeded", "revision exceeds 100 metadata values");
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function linkKey(link: LinkValue): string {
|
|
168
|
+
return `${link.kind}\u0000${link.targetSlug}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function explicitLinks(current: StoredLink[], request: IngestRequest): LinkValue[] {
|
|
172
|
+
const map = new Map(
|
|
173
|
+
current
|
|
174
|
+
.filter((link) => link.origin === "explicit")
|
|
175
|
+
.map((link) => [linkKey(link), { kind: link.kind, targetSlug: link.targetSlug }])
|
|
176
|
+
);
|
|
177
|
+
for (const link of request.links?.remove ?? []) map.delete(linkKey(link));
|
|
178
|
+
for (const link of request.links?.add ?? []) {
|
|
179
|
+
if (!LINK_KINDS.includes(link.kind) || !SLUG.test(link.targetSlug)) {
|
|
180
|
+
throw new DomainError("validation_failed", "invalid link");
|
|
181
|
+
}
|
|
182
|
+
map.set(linkKey(link), link);
|
|
183
|
+
}
|
|
184
|
+
return [...map.values()].sort((a, b) => linkKey(a).localeCompare(linkKey(b)));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function derivedSlugs(body: string, ownSlug: string): string[] {
|
|
188
|
+
const slugs = new Set<string>();
|
|
189
|
+
for (const match of body.matchAll(/\[\[([a-z0-9]+(?:-[a-z0-9]+)*)\]\]/g)) {
|
|
190
|
+
const slug = match[1];
|
|
191
|
+
if (slug !== undefined && slug !== ownSlug) slugs.add(slug);
|
|
192
|
+
}
|
|
193
|
+
return [...slugs].sort();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export class MemoryService {
|
|
197
|
+
constructor(private readonly db: D1Database) {}
|
|
198
|
+
|
|
199
|
+
async get(actor: ActorContext, slug: string, revisionId?: string): Promise<DocumentSnapshot> {
|
|
200
|
+
requireScope(actor, "memory:read");
|
|
201
|
+
const row = await this.db
|
|
202
|
+
.prepare(
|
|
203
|
+
revisionId === undefined
|
|
204
|
+
? `SELECT d.id document_id, d.workspace_id, d.slug, d.type,
|
|
205
|
+
r.id revision_id, r.revision_number, r.parent_revision_id,
|
|
206
|
+
r.title, r.body, r.summary, r.created_at, r.principal_id,
|
|
207
|
+
r.client_id, r.agent_label, r.reason, r.restored_from_revision_id
|
|
208
|
+
FROM documents d JOIN current_revisions r ON r.doc_id = d.id
|
|
209
|
+
WHERE d.workspace_id = ? AND d.slug = ?`
|
|
210
|
+
: `SELECT d.id document_id, d.workspace_id, d.slug, d.type,
|
|
211
|
+
r.id revision_id, r.revision_number, r.parent_revision_id,
|
|
212
|
+
r.title, r.body, r.summary, r.created_at, r.principal_id,
|
|
213
|
+
r.client_id, r.agent_label, r.reason, r.restored_from_revision_id
|
|
214
|
+
FROM documents d JOIN revisions r ON r.doc_id = d.id
|
|
215
|
+
WHERE d.workspace_id = ? AND d.slug = ? AND r.id = ?`
|
|
216
|
+
)
|
|
217
|
+
.bind(
|
|
218
|
+
...(revisionId === undefined
|
|
219
|
+
? [actor.workspaceId, slug]
|
|
220
|
+
: [actor.workspaceId, slug, revisionId])
|
|
221
|
+
)
|
|
222
|
+
.first<CurrentRow>();
|
|
223
|
+
if (row === null) throw new DomainError("not_found", `No document named ${slug}`);
|
|
224
|
+
const [metadata, links] = await Promise.all([
|
|
225
|
+
this.readMetadata(actor.workspaceId, row.revision_id),
|
|
226
|
+
this.readLinks(actor.workspaceId, row.revision_id)
|
|
227
|
+
]);
|
|
228
|
+
return {
|
|
229
|
+
documentId: row.document_id,
|
|
230
|
+
workspaceId: row.workspace_id,
|
|
231
|
+
slug: row.slug,
|
|
232
|
+
type: row.type,
|
|
233
|
+
revisionId: row.revision_id,
|
|
234
|
+
revisionNumber: row.revision_number,
|
|
235
|
+
parentRevisionId: row.parent_revision_id,
|
|
236
|
+
title: row.title,
|
|
237
|
+
body: row.body,
|
|
238
|
+
summary: row.summary,
|
|
239
|
+
createdAt: row.created_at,
|
|
240
|
+
principalId: row.principal_id,
|
|
241
|
+
clientId: row.client_id,
|
|
242
|
+
agentLabel: row.agent_label,
|
|
243
|
+
reason: row.reason,
|
|
244
|
+
restoredFromRevisionId: row.restored_from_revision_id,
|
|
245
|
+
metadata,
|
|
246
|
+
links
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async recall(actor: ActorContext, query: string, limit = 8): Promise<RecallHit[]> {
|
|
251
|
+
requireScope(actor, "memory:read");
|
|
252
|
+
const terms = [...query.matchAll(/[\p{L}\p{N}]+/gu)].map((match) => match[0]);
|
|
253
|
+
if (terms.length === 0) return this.recallSymbols(actor, query.trim(), limit);
|
|
254
|
+
const ftsQuery = terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
|
|
255
|
+
const phrase = terms.join(" ").toLowerCase();
|
|
256
|
+
const bounded = Math.max(1, Math.min(limit, 20));
|
|
257
|
+
const result = await this.db
|
|
258
|
+
.prepare(
|
|
259
|
+
`SELECT f.document_id, r.id revision_id, f.slug, d.type, f.title,
|
|
260
|
+
NULLIF(f.summary, '') summary,
|
|
261
|
+
snippet(current_fts, 5, '[', ']', ' … ', 16) snippet,
|
|
262
|
+
bm25(current_fts, 2.0, 8.0, 4.0, 1.0) raw_score,
|
|
263
|
+
CASE
|
|
264
|
+
WHEN lower(f.title) = ? THEN 4
|
|
265
|
+
WHEN instr(lower(f.title), ?) > 0 THEN 3
|
|
266
|
+
WHEN instr(lower(f.summary), ?) > 0 THEN 2
|
|
267
|
+
WHEN instr(lower(f.body), ?) > 0 THEN 1
|
|
268
|
+
ELSE 0
|
|
269
|
+
END phrase_boost
|
|
270
|
+
FROM current_fts f
|
|
271
|
+
JOIN documents d ON d.id = f.document_id AND d.workspace_id = f.workspace_id
|
|
272
|
+
JOIN current_revisions r ON r.doc_id = d.id
|
|
273
|
+
WHERE current_fts MATCH ? AND f.workspace_id = ?
|
|
274
|
+
ORDER BY phrase_boost DESC, raw_score, f.document_id
|
|
275
|
+
LIMIT ?`
|
|
276
|
+
)
|
|
277
|
+
.bind(phrase, phrase, phrase, phrase, ftsQuery, actor.workspaceId, bounded)
|
|
278
|
+
.all<{
|
|
279
|
+
document_id: string;
|
|
280
|
+
revision_id: string;
|
|
281
|
+
slug: string;
|
|
282
|
+
type: DocumentType;
|
|
283
|
+
title: string;
|
|
284
|
+
summary: string | null;
|
|
285
|
+
snippet: string;
|
|
286
|
+
raw_score: number;
|
|
287
|
+
phrase_boost: number;
|
|
288
|
+
}>();
|
|
289
|
+
return result.results.map((row) => ({
|
|
290
|
+
documentId: row.document_id,
|
|
291
|
+
revisionId: row.revision_id,
|
|
292
|
+
slug: row.slug,
|
|
293
|
+
type: row.type,
|
|
294
|
+
title: row.title,
|
|
295
|
+
summary: row.summary,
|
|
296
|
+
snippet: row.snippet,
|
|
297
|
+
score: Math.min(1, row.phrase_boost * 0.2 + 0.2 / (1 + Math.exp(row.raw_score)))
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async recallSymbols(
|
|
302
|
+
actor: ActorContext,
|
|
303
|
+
query: string,
|
|
304
|
+
limit: number
|
|
305
|
+
): Promise<RecallHit[]> {
|
|
306
|
+
if (query === "") return [];
|
|
307
|
+
const bounded = Math.max(1, Math.min(limit, 20));
|
|
308
|
+
const rows = await this.db
|
|
309
|
+
.prepare(
|
|
310
|
+
`SELECT f.document_id, r.id revision_id, f.slug, d.type, f.title,
|
|
311
|
+
NULLIF(f.summary, '') summary,
|
|
312
|
+
CASE WHEN f.summary <> '' THEN f.summary ELSE substr(f.body, 1, 160) END snippet,
|
|
313
|
+
CASE
|
|
314
|
+
WHEN f.title = ? THEN 4
|
|
315
|
+
WHEN instr(f.title, ?) > 0 THEN 3
|
|
316
|
+
WHEN instr(f.summary, ?) > 0 THEN 2
|
|
317
|
+
ELSE 1
|
|
318
|
+
END phrase_boost
|
|
319
|
+
FROM current_fts f
|
|
320
|
+
JOIN documents d ON d.id = f.document_id AND d.workspace_id = f.workspace_id
|
|
321
|
+
JOIN current_revisions r ON r.doc_id = d.id
|
|
322
|
+
WHERE f.workspace_id = ?
|
|
323
|
+
AND (instr(f.title, ?) > 0 OR instr(f.summary, ?) > 0 OR instr(f.body, ?) > 0)
|
|
324
|
+
ORDER BY phrase_boost DESC, f.document_id LIMIT ?`
|
|
325
|
+
)
|
|
326
|
+
.bind(query, query, query, actor.workspaceId, query, query, query, bounded)
|
|
327
|
+
.all<{
|
|
328
|
+
document_id: string;
|
|
329
|
+
revision_id: string;
|
|
330
|
+
slug: string;
|
|
331
|
+
type: DocumentType;
|
|
332
|
+
title: string;
|
|
333
|
+
summary: string | null;
|
|
334
|
+
snippet: string;
|
|
335
|
+
phrase_boost: number;
|
|
336
|
+
}>();
|
|
337
|
+
return rows.results.map((row) => ({
|
|
338
|
+
documentId: row.document_id,
|
|
339
|
+
revisionId: row.revision_id,
|
|
340
|
+
slug: row.slug,
|
|
341
|
+
type: row.type,
|
|
342
|
+
title: row.title,
|
|
343
|
+
summary: row.summary,
|
|
344
|
+
snippet: row.snippet,
|
|
345
|
+
score: Math.min(1, 0.2 + row.phrase_boost * 0.2)
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async recallBySourceUrl(actor: ActorContext, sourceUrl: string, limit = 8): Promise<RecallHit[]> {
|
|
350
|
+
requireScope(actor, "memory:read");
|
|
351
|
+
const bounded = Math.max(1, Math.min(limit, 20));
|
|
352
|
+
const rows = await this.db
|
|
353
|
+
.prepare(
|
|
354
|
+
`SELECT d.id document_id, r.id revision_id, d.slug, d.type, r.title, r.summary
|
|
355
|
+
FROM documents d
|
|
356
|
+
JOIN current_revisions r ON r.doc_id = d.id
|
|
357
|
+
JOIN revision_metadata rm ON rm.workspace_id = d.workspace_id
|
|
358
|
+
AND rm.revision_id = r.id AND rm.key = 'source_url' AND rm.value = ?
|
|
359
|
+
WHERE d.workspace_id = ?
|
|
360
|
+
ORDER BY d.slug LIMIT ?`
|
|
361
|
+
)
|
|
362
|
+
.bind(sourceUrl, actor.workspaceId, bounded)
|
|
363
|
+
.all<{
|
|
364
|
+
document_id: string;
|
|
365
|
+
revision_id: string;
|
|
366
|
+
slug: string;
|
|
367
|
+
type: DocumentType;
|
|
368
|
+
title: string;
|
|
369
|
+
summary: string | null;
|
|
370
|
+
}>();
|
|
371
|
+
return rows.results.map((row) => ({
|
|
372
|
+
documentId: row.document_id,
|
|
373
|
+
revisionId: row.revision_id,
|
|
374
|
+
slug: row.slug,
|
|
375
|
+
type: row.type,
|
|
376
|
+
title: row.title,
|
|
377
|
+
summary: row.summary,
|
|
378
|
+
snippet: row.summary ?? row.title,
|
|
379
|
+
score: 1
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async index(
|
|
384
|
+
actor: ActorContext,
|
|
385
|
+
options: { type?: DocumentType; limit?: number; afterSlug?: string } = {}
|
|
386
|
+
): Promise<DocumentIndexEntry[]> {
|
|
387
|
+
requireScope(actor, "memory:read");
|
|
388
|
+
const bounded = Math.max(1, Math.min(options.limit ?? 50, 100));
|
|
389
|
+
const clauses = ["d.workspace_id = ?", "d.slug > ?"];
|
|
390
|
+
const bindings: unknown[] = [actor.workspaceId, options.afterSlug ?? ""];
|
|
391
|
+
if (options.type !== undefined) {
|
|
392
|
+
clauses.push("d.type = ?");
|
|
393
|
+
bindings.push(options.type);
|
|
394
|
+
}
|
|
395
|
+
const rows = await this.db
|
|
396
|
+
.prepare(
|
|
397
|
+
`SELECT d.id document_id, d.slug, d.type, r.id revision_id, r.revision_number,
|
|
398
|
+
r.title, r.summary, r.created_at,
|
|
399
|
+
(SELECT rm.value FROM revision_metadata rm
|
|
400
|
+
WHERE rm.workspace_id = d.workspace_id AND rm.revision_id = r.id AND rm.key = 'status'
|
|
401
|
+
LIMIT 1) status
|
|
402
|
+
FROM documents d JOIN current_revisions r ON r.doc_id = d.id
|
|
403
|
+
WHERE ${clauses.join(" AND ")}
|
|
404
|
+
ORDER BY d.slug LIMIT ?`
|
|
405
|
+
)
|
|
406
|
+
.bind(...bindings, bounded)
|
|
407
|
+
.all<{
|
|
408
|
+
document_id: string;
|
|
409
|
+
slug: string;
|
|
410
|
+
type: DocumentType;
|
|
411
|
+
revision_id: string;
|
|
412
|
+
revision_number: number;
|
|
413
|
+
title: string;
|
|
414
|
+
summary: string | null;
|
|
415
|
+
created_at: string;
|
|
416
|
+
status: string | null;
|
|
417
|
+
}>();
|
|
418
|
+
return rows.results.map((row) => ({
|
|
419
|
+
documentId: row.document_id,
|
|
420
|
+
revisionId: row.revision_id,
|
|
421
|
+
revisionNumber: row.revision_number,
|
|
422
|
+
slug: row.slug,
|
|
423
|
+
type: row.type,
|
|
424
|
+
title: row.title,
|
|
425
|
+
summary: row.summary,
|
|
426
|
+
updatedAt: row.created_at,
|
|
427
|
+
status: row.status
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async history(actor: ActorContext, slug: string, limit = 50): Promise<RevisionHeader[]> {
|
|
432
|
+
requireScope(actor, "memory:read");
|
|
433
|
+
const bounded = Math.max(1, Math.min(limit, 100));
|
|
434
|
+
const rows = await this.db
|
|
435
|
+
.prepare(
|
|
436
|
+
`SELECT r.id revision_id, r.revision_number, r.parent_revision_id, r.created_at,
|
|
437
|
+
r.principal_id, r.client_id, r.agent_label, r.reason,
|
|
438
|
+
r.restored_from_revision_id, r.request_hash
|
|
439
|
+
FROM documents d JOIN revisions r ON r.doc_id = d.id
|
|
440
|
+
WHERE d.workspace_id = ? AND d.slug = ?
|
|
441
|
+
ORDER BY r.revision_number DESC LIMIT ?`
|
|
442
|
+
)
|
|
443
|
+
.bind(actor.workspaceId, slug, bounded)
|
|
444
|
+
.all<{
|
|
445
|
+
revision_id: string;
|
|
446
|
+
revision_number: number;
|
|
447
|
+
parent_revision_id: string | null;
|
|
448
|
+
created_at: string;
|
|
449
|
+
principal_id: string;
|
|
450
|
+
client_id: string;
|
|
451
|
+
agent_label: string | null;
|
|
452
|
+
reason: string;
|
|
453
|
+
restored_from_revision_id: string | null;
|
|
454
|
+
request_hash: string;
|
|
455
|
+
}>();
|
|
456
|
+
return rows.results.map((row) => ({
|
|
457
|
+
revisionId: row.revision_id,
|
|
458
|
+
revisionNumber: row.revision_number,
|
|
459
|
+
parentRevisionId: row.parent_revision_id,
|
|
460
|
+
createdAt: row.created_at,
|
|
461
|
+
principalId: row.principal_id,
|
|
462
|
+
clientId: row.client_id,
|
|
463
|
+
agentLabel: row.agent_label,
|
|
464
|
+
reason: row.reason,
|
|
465
|
+
restoredFromRevisionId: row.restored_from_revision_id,
|
|
466
|
+
requestHash: row.request_hash
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async lint(actor: ActorContext, limit = 100): Promise<LintFinding[]> {
|
|
471
|
+
requireScope(actor, "memory:read");
|
|
472
|
+
const bounded = Math.max(1, Math.min(limit, 200));
|
|
473
|
+
const [unresolved, missing, orphans, stale] = await Promise.all([
|
|
474
|
+
this.db
|
|
475
|
+
.prepare(
|
|
476
|
+
`SELECT d.slug, rl.target_slug
|
|
477
|
+
FROM documents d JOIN current_revisions r ON r.doc_id = d.id
|
|
478
|
+
JOIN revision_links rl ON rl.revision_id = r.id AND rl.workspace_id = d.workspace_id
|
|
479
|
+
WHERE d.workspace_id = ? AND NOT EXISTS (
|
|
480
|
+
SELECT 1 FROM documents target
|
|
481
|
+
WHERE target.workspace_id = d.workspace_id AND target.slug = rl.target_slug
|
|
482
|
+
)
|
|
483
|
+
ORDER BY d.slug, rl.target_slug LIMIT ?`
|
|
484
|
+
)
|
|
485
|
+
.bind(actor.workspaceId, bounded)
|
|
486
|
+
.all<{ slug: string; target_slug: string }>(),
|
|
487
|
+
this.db
|
|
488
|
+
.prepare(
|
|
489
|
+
`SELECT d.slug FROM documents d JOIN current_revisions r ON r.doc_id = d.id
|
|
490
|
+
WHERE d.workspace_id = ? AND d.type != 'system' AND (r.summary IS NULL OR trim(r.summary) = '')
|
|
491
|
+
ORDER BY d.slug LIMIT ?`
|
|
492
|
+
)
|
|
493
|
+
.bind(actor.workspaceId, bounded)
|
|
494
|
+
.all<{ slug: string }>(),
|
|
495
|
+
this.db
|
|
496
|
+
.prepare(
|
|
497
|
+
`SELECT d.slug FROM documents d
|
|
498
|
+
WHERE d.workspace_id = ? AND d.type != 'system'
|
|
499
|
+
AND NOT EXISTS (
|
|
500
|
+
SELECT 1 FROM revision_links rl JOIN current_revisions sr ON sr.id = rl.revision_id
|
|
501
|
+
WHERE rl.workspace_id = d.workspace_id AND (rl.target_document_id = d.id OR sr.doc_id = d.id)
|
|
502
|
+
)
|
|
503
|
+
ORDER BY d.slug LIMIT ?`
|
|
504
|
+
)
|
|
505
|
+
.bind(actor.workspaceId, bounded)
|
|
506
|
+
.all<{ slug: string }>(),
|
|
507
|
+
this.db
|
|
508
|
+
.prepare(
|
|
509
|
+
`SELECT d.slug, rm2.value last_active
|
|
510
|
+
FROM documents d JOIN current_revisions r ON r.doc_id = d.id
|
|
511
|
+
JOIN revision_metadata rm ON rm.revision_id = r.id AND rm.key = 'status' AND rm.value = 'active'
|
|
512
|
+
JOIN revision_metadata rm2 ON rm2.revision_id = r.id AND rm2.key = 'last_active'
|
|
513
|
+
WHERE d.workspace_id = ? AND d.type = 'project' AND date(rm2.value) < date('now', '-90 days')
|
|
514
|
+
ORDER BY d.slug LIMIT ?`
|
|
515
|
+
)
|
|
516
|
+
.bind(actor.workspaceId, bounded)
|
|
517
|
+
.all<{ slug: string; last_active: string }>()
|
|
518
|
+
]);
|
|
519
|
+
return [
|
|
520
|
+
...unresolved.results.map(
|
|
521
|
+
(row): LintFinding => ({
|
|
522
|
+
kind: "unresolved_reference",
|
|
523
|
+
slug: row.slug,
|
|
524
|
+
detail: `Target ${row.target_slug} does not exist`
|
|
525
|
+
})
|
|
526
|
+
),
|
|
527
|
+
...missing.results.map(
|
|
528
|
+
(row): LintFinding => ({
|
|
529
|
+
kind: "missing_summary",
|
|
530
|
+
slug: row.slug,
|
|
531
|
+
detail: "Current revision has no summary"
|
|
532
|
+
})
|
|
533
|
+
),
|
|
534
|
+
...orphans.results.map(
|
|
535
|
+
(row): LintFinding => ({
|
|
536
|
+
kind: "orphan",
|
|
537
|
+
slug: row.slug,
|
|
538
|
+
detail: "No current incoming or outgoing links"
|
|
539
|
+
})
|
|
540
|
+
),
|
|
541
|
+
...stale.results.map(
|
|
542
|
+
(row): LintFinding => ({
|
|
543
|
+
kind: "stale_active_project",
|
|
544
|
+
slug: row.slug,
|
|
545
|
+
detail: `Active project was last active ${row.last_active}`
|
|
546
|
+
})
|
|
547
|
+
)
|
|
548
|
+
].slice(0, bounded);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async ingest(actor: ActorContext, request: IngestRequest): Promise<IngestResult> {
|
|
552
|
+
requireScope(actor, "memory:write");
|
|
553
|
+
return this.writeRevision(actor, request, "ingest", null);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private async writeRevision(
|
|
557
|
+
actor: ActorContext,
|
|
558
|
+
request: IngestRequest,
|
|
559
|
+
operationKind: "ingest" | "link" | "restore",
|
|
560
|
+
restoredFromRevisionId: string | null
|
|
561
|
+
): Promise<IngestResult> {
|
|
562
|
+
validateRequest(request);
|
|
563
|
+
const hash = await requestHash(request);
|
|
564
|
+
const replay = await this.findOperation(actor.workspaceId, request.operationId);
|
|
565
|
+
if (replay !== null) return this.replayResult(actor, request, hash, operationKind, replay);
|
|
566
|
+
|
|
567
|
+
let current: DocumentSnapshot | null = null;
|
|
568
|
+
try {
|
|
569
|
+
current = await this.get({ ...actor, scopes: new Set(["memory:read"]) }, request.slug);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (!(error instanceof DomainError) || error.code !== "not_found") throw error;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const creating = current === null;
|
|
575
|
+
if (current === null) {
|
|
576
|
+
if (request.expectedRevisionId !== undefined) {
|
|
577
|
+
throw new DomainError(
|
|
578
|
+
"revision_conflict",
|
|
579
|
+
"New documents cannot have an expected revision"
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
if (request.type === undefined || request.title === undefined || request.body === undefined) {
|
|
583
|
+
throw new DomainError("validation_failed", "New documents require type, title, and body");
|
|
584
|
+
}
|
|
585
|
+
} else {
|
|
586
|
+
if (request.type !== undefined && request.type !== current.type) {
|
|
587
|
+
throw new DomainError("validation_failed", "Document type is immutable");
|
|
588
|
+
}
|
|
589
|
+
if (request.expectedRevisionId !== current.revisionId) {
|
|
590
|
+
throw new DomainError("revision_conflict", "Expected revision is stale", {
|
|
591
|
+
currentRevisionId: current.revisionId,
|
|
592
|
+
currentRevisionNumber: current.revisionNumber
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const title = request.title ?? current?.title;
|
|
598
|
+
const body = request.body ?? current?.body;
|
|
599
|
+
const summary = request.summary === undefined ? (current?.summary ?? null) : request.summary;
|
|
600
|
+
if (title === undefined || body === undefined)
|
|
601
|
+
throw new DomainError("validation_failed", "title and body are required");
|
|
602
|
+
let metadata = applyMetadataPatch(current?.metadata ?? [], request);
|
|
603
|
+
if (creating && request.type === "source" && !metadata.some((item) => item.key === "trust")) {
|
|
604
|
+
const trustMetadata: MetadataValue = {
|
|
605
|
+
key: "trust",
|
|
606
|
+
value: "untrusted",
|
|
607
|
+
cardinality: "singleton"
|
|
608
|
+
};
|
|
609
|
+
metadata = [...metadata, trustMetadata].sort(
|
|
610
|
+
(a, b) => a.key.localeCompare(b.key) || a.value.localeCompare(b.value)
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const explicit = explicitLinks(current?.links ?? [], request);
|
|
614
|
+
|
|
615
|
+
const secretFields: Record<string, string> = { title, body, summary: summary ?? "" };
|
|
616
|
+
for (const item of metadata) secretFields[`metadata.${item.key}`] = item.value;
|
|
617
|
+
const findings = await scanSecrets(secretFields);
|
|
618
|
+
if (findings.length > 0) {
|
|
619
|
+
throw new DomainError("secret_detected", "Likely secret material was detected", {
|
|
620
|
+
findings: findings.map(({ field, category, fingerprint }) => ({
|
|
621
|
+
field,
|
|
622
|
+
category,
|
|
623
|
+
fingerprint
|
|
624
|
+
}))
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const targetSlugs = new Set([
|
|
629
|
+
...explicit.map((link) => link.targetSlug),
|
|
630
|
+
...derivedSlugs(body, request.slug)
|
|
631
|
+
]);
|
|
632
|
+
const resolved = new Map<string, string>();
|
|
633
|
+
for (const slug of targetSlugs) {
|
|
634
|
+
const target = await this.db
|
|
635
|
+
.prepare("SELECT id FROM documents WHERE workspace_id = ? AND slug = ?")
|
|
636
|
+
.bind(actor.workspaceId, slug)
|
|
637
|
+
.first<{ id: string }>();
|
|
638
|
+
if (target !== null) resolved.set(slug, target.id);
|
|
639
|
+
}
|
|
640
|
+
for (const link of explicit) {
|
|
641
|
+
if (!resolved.has(link.targetSlug)) {
|
|
642
|
+
throw new DomainError(
|
|
643
|
+
"validation_failed",
|
|
644
|
+
`Explicit link target ${link.targetSlug} does not exist`
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const links: StoredLink[] = [
|
|
650
|
+
...explicit.map(
|
|
651
|
+
(link): StoredLink => ({
|
|
652
|
+
...link,
|
|
653
|
+
origin: "explicit",
|
|
654
|
+
targetDocumentId: resolved.get(link.targetSlug) ?? null
|
|
655
|
+
})
|
|
656
|
+
),
|
|
657
|
+
...derivedSlugs(body, request.slug).map(
|
|
658
|
+
(targetSlug): StoredLink => ({
|
|
659
|
+
kind: "related",
|
|
660
|
+
targetSlug,
|
|
661
|
+
origin: "body",
|
|
662
|
+
targetDocumentId: resolved.get(targetSlug) ?? null
|
|
663
|
+
})
|
|
664
|
+
)
|
|
665
|
+
];
|
|
666
|
+
if (links.length > 100) throw new DomainError("limit_exceeded", "revision exceeds 100 links");
|
|
667
|
+
|
|
668
|
+
const documentId = current?.documentId ?? crypto.randomUUID();
|
|
669
|
+
const revisionId = crypto.randomUUID();
|
|
670
|
+
const revisionNumber = (current?.revisionNumber ?? 0) + 1;
|
|
671
|
+
const createdAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
672
|
+
const statements: D1PreparedStatement[] = [];
|
|
673
|
+
if (creating) {
|
|
674
|
+
statements.push(
|
|
675
|
+
this.db
|
|
676
|
+
.prepare(
|
|
677
|
+
"INSERT INTO documents(id, workspace_id, type, slug, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
678
|
+
)
|
|
679
|
+
.bind(documentId, actor.workspaceId, request.type, request.slug, createdAt)
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
statements.push(
|
|
683
|
+
this.db
|
|
684
|
+
.prepare(
|
|
685
|
+
`INSERT INTO operations(workspace_id, operation_id, request_hash, principal_id, kind, status,
|
|
686
|
+
result_document_id, result_revision_id, created_at)
|
|
687
|
+
VALUES (?, ?, ?, ?, ?, 'completed', ?, ?, ?)`
|
|
688
|
+
)
|
|
689
|
+
.bind(
|
|
690
|
+
actor.workspaceId,
|
|
691
|
+
request.operationId,
|
|
692
|
+
hash,
|
|
693
|
+
actor.principalId,
|
|
694
|
+
operationKind,
|
|
695
|
+
documentId,
|
|
696
|
+
revisionId,
|
|
697
|
+
createdAt
|
|
698
|
+
),
|
|
699
|
+
this.db
|
|
700
|
+
.prepare(
|
|
701
|
+
`INSERT INTO revisions(id, workspace_id, doc_id, revision_number, parent_revision_id,
|
|
702
|
+
title, body, summary, created_at, principal_id, client_id,
|
|
703
|
+
agent_label, reason, operation_id, request_hash,
|
|
704
|
+
restored_from_revision_id)
|
|
705
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
706
|
+
)
|
|
707
|
+
.bind(
|
|
708
|
+
revisionId,
|
|
709
|
+
actor.workspaceId,
|
|
710
|
+
documentId,
|
|
711
|
+
revisionNumber,
|
|
712
|
+
current?.revisionId ?? null,
|
|
713
|
+
title,
|
|
714
|
+
body,
|
|
715
|
+
summary,
|
|
716
|
+
createdAt,
|
|
717
|
+
actor.principalId,
|
|
718
|
+
actor.clientId,
|
|
719
|
+
actor.agentLabel ?? null,
|
|
720
|
+
request.reason,
|
|
721
|
+
request.operationId,
|
|
722
|
+
hash,
|
|
723
|
+
restoredFromRevisionId
|
|
724
|
+
)
|
|
725
|
+
);
|
|
726
|
+
for (const item of metadata) {
|
|
727
|
+
statements.push(
|
|
728
|
+
this.db
|
|
729
|
+
.prepare(
|
|
730
|
+
"INSERT INTO revision_metadata(workspace_id, revision_id, key, value, cardinality) VALUES (?, ?, ?, ?, ?)"
|
|
731
|
+
)
|
|
732
|
+
.bind(actor.workspaceId, revisionId, item.key, item.value, item.cardinality)
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
for (const link of links) {
|
|
736
|
+
statements.push(
|
|
737
|
+
this.db
|
|
738
|
+
.prepare(
|
|
739
|
+
`INSERT INTO revision_links(workspace_id, revision_id, kind, target_slug, target_document_id, origin)
|
|
740
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
741
|
+
)
|
|
742
|
+
.bind(
|
|
743
|
+
actor.workspaceId,
|
|
744
|
+
revisionId,
|
|
745
|
+
link.kind,
|
|
746
|
+
link.targetSlug,
|
|
747
|
+
link.targetDocumentId,
|
|
748
|
+
link.origin
|
|
749
|
+
)
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
statements.push(
|
|
753
|
+
this.db
|
|
754
|
+
.prepare(
|
|
755
|
+
`INSERT INTO audit_events(id, workspace_id, kind, created_at, principal_id, client_id,
|
|
756
|
+
agent_label, document_id, revision_id, request_id, detail_json)
|
|
757
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
758
|
+
)
|
|
759
|
+
.bind(
|
|
760
|
+
crypto.randomUUID(),
|
|
761
|
+
actor.workspaceId,
|
|
762
|
+
operationKind,
|
|
763
|
+
createdAt,
|
|
764
|
+
actor.principalId,
|
|
765
|
+
actor.clientId,
|
|
766
|
+
actor.agentLabel ?? null,
|
|
767
|
+
documentId,
|
|
768
|
+
revisionId,
|
|
769
|
+
actor.requestId,
|
|
770
|
+
JSON.stringify({ revisionNumber, creating })
|
|
771
|
+
)
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
try {
|
|
775
|
+
await this.db.batch(statements);
|
|
776
|
+
} catch (error) {
|
|
777
|
+
const racedReplay = await this.findOperation(actor.workspaceId, request.operationId);
|
|
778
|
+
if (racedReplay !== null)
|
|
779
|
+
return this.replayResult(actor, request, hash, operationKind, racedReplay);
|
|
780
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
781
|
+
if (message.includes("revision_conflict")) {
|
|
782
|
+
let latest: DocumentSnapshot | null = null;
|
|
783
|
+
try {
|
|
784
|
+
latest = await this.get({ ...actor, scopes: new Set(["memory:read"]) }, request.slug);
|
|
785
|
+
} catch {
|
|
786
|
+
// The transaction may have raced a purge. Return a conflict without content.
|
|
787
|
+
}
|
|
788
|
+
throw new DomainError("revision_conflict", "A concurrent revision won the write", {
|
|
789
|
+
currentRevisionId: latest?.revisionId,
|
|
790
|
+
currentRevisionNumber: latest?.revisionNumber
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
if (message.includes("UNIQUE constraint failed: documents.workspace_id, documents.slug")) {
|
|
794
|
+
throw new DomainError("already_exists", `Document ${request.slug} already exists`);
|
|
795
|
+
}
|
|
796
|
+
throw error;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return {
|
|
800
|
+
documentId,
|
|
801
|
+
revisionId,
|
|
802
|
+
revisionNumber,
|
|
803
|
+
slug: request.slug,
|
|
804
|
+
idempotentReplay: false,
|
|
805
|
+
unresolvedReferences: derivedSlugs(body, request.slug).filter((slug) => !resolved.has(slug))
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async link(actor: ActorContext, request: LinkRequest): Promise<IngestResult> {
|
|
810
|
+
requireScope(actor, "memory:write");
|
|
811
|
+
return this.writeRevision(
|
|
812
|
+
actor,
|
|
813
|
+
{
|
|
814
|
+
operationId: request.operationId,
|
|
815
|
+
reason: request.reason,
|
|
816
|
+
slug: request.sourceSlug,
|
|
817
|
+
expectedRevisionId: request.expectedRevisionId,
|
|
818
|
+
links: request.action === "add" ? { add: [request.link] } : { remove: [request.link] }
|
|
819
|
+
},
|
|
820
|
+
"link",
|
|
821
|
+
null
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async restore(actor: ActorContext, request: RestoreRequest): Promise<IngestResult> {
|
|
826
|
+
requireScope(actor, "memory:admin");
|
|
827
|
+
const readActor: ActorContext = { ...actor, scopes: new Set<MemoryScope>(["memory:read"]) };
|
|
828
|
+
let base: DocumentSnapshot;
|
|
829
|
+
try {
|
|
830
|
+
base = await this.get(readActor, request.slug, request.expectedRevisionId);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
if (!(error instanceof DomainError) || error.code !== "not_found") throw error;
|
|
833
|
+
const current = await this.get(readActor, request.slug);
|
|
834
|
+
throw new DomainError("revision_conflict", "Expected revision is stale", {
|
|
835
|
+
currentRevisionId: current.revisionId,
|
|
836
|
+
currentRevisionNumber: current.revisionNumber
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
const target = await this.get(readActor, request.slug, request.targetRevisionId);
|
|
840
|
+
|
|
841
|
+
const currentMetadata = metadataMap(base.metadata);
|
|
842
|
+
const targetMetadata = metadataMap(target.metadata);
|
|
843
|
+
const currentCardinalities = new Map(base.metadata.map((item) => [item.key, item.cardinality]));
|
|
844
|
+
const targetCardinalities = new Map(
|
|
845
|
+
target.metadata.map((item) => [item.key, item.cardinality])
|
|
846
|
+
);
|
|
847
|
+
const set: Record<string, string | null> = {};
|
|
848
|
+
const multi: NonNullable<IngestRequest["metadata"]>["multi"] = {};
|
|
849
|
+
for (const key of new Set([...currentMetadata.keys(), ...targetMetadata.keys()])) {
|
|
850
|
+
const targetValues = [...(targetMetadata.get(key) ?? [])];
|
|
851
|
+
const cardinality = targetCardinalities.get(key) ?? currentCardinalities.get(key);
|
|
852
|
+
if (cardinality === "singleton") set[key] = targetValues[0] ?? null;
|
|
853
|
+
else if (cardinality === "multi") multi[key] = { replace: targetValues };
|
|
854
|
+
else throw new DomainError("internal_error", `metadata cardinality missing for ${key}`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const currentExplicit = base.links
|
|
858
|
+
.filter((item) => item.origin === "explicit")
|
|
859
|
+
.map(({ kind, targetSlug }) => ({ kind, targetSlug }));
|
|
860
|
+
const targetExplicit = target.links
|
|
861
|
+
.filter((item) => item.origin === "explicit")
|
|
862
|
+
.map(({ kind, targetSlug }) => ({ kind, targetSlug }));
|
|
863
|
+
|
|
864
|
+
return this.writeRevision(
|
|
865
|
+
actor,
|
|
866
|
+
{
|
|
867
|
+
operationId: request.operationId,
|
|
868
|
+
reason: request.reason,
|
|
869
|
+
slug: request.slug,
|
|
870
|
+
expectedRevisionId: request.expectedRevisionId,
|
|
871
|
+
title: target.title,
|
|
872
|
+
body: target.body,
|
|
873
|
+
summary: target.summary,
|
|
874
|
+
metadata: { set, multi },
|
|
875
|
+
links: { remove: currentExplicit, add: targetExplicit }
|
|
876
|
+
},
|
|
877
|
+
"restore",
|
|
878
|
+
target.revisionId
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async authorizePurge(
|
|
883
|
+
actor: OwnerContext,
|
|
884
|
+
slug: string,
|
|
885
|
+
confirmation: string
|
|
886
|
+
): Promise<PurgeAuthorization> {
|
|
887
|
+
if (confirmation !== slug)
|
|
888
|
+
throw new DomainError("validation_failed", "Purge confirmation must exactly match the slug");
|
|
889
|
+
const authenticatedAt = Date.parse(actor.reauthenticatedAt);
|
|
890
|
+
const now = Date.now();
|
|
891
|
+
if (
|
|
892
|
+
!Number.isFinite(authenticatedAt) ||
|
|
893
|
+
authenticatedAt > now + 60_000 ||
|
|
894
|
+
now - authenticatedAt > 300_000
|
|
895
|
+
) {
|
|
896
|
+
throw new DomainError(
|
|
897
|
+
"reauthentication_required",
|
|
898
|
+
"Passkey authentication must be less than five minutes old"
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
const document = await this.db
|
|
902
|
+
.prepare("SELECT id FROM documents WHERE workspace_id = ? AND slug = ?")
|
|
903
|
+
.bind(actor.workspaceId, slug)
|
|
904
|
+
.first<{ id: string }>();
|
|
905
|
+
if (document === null) throw new DomainError("not_found", `No document named ${slug}`);
|
|
906
|
+
const id = crypto.randomUUID();
|
|
907
|
+
const createdAt = new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
908
|
+
const expiresAt = new Date(now + 300_000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
909
|
+
const hash = await requestHash({
|
|
910
|
+
workspaceId: actor.workspaceId,
|
|
911
|
+
documentId: document.id,
|
|
912
|
+
principalId: actor.principalId,
|
|
913
|
+
slug
|
|
914
|
+
});
|
|
915
|
+
await this.db
|
|
916
|
+
.prepare(
|
|
917
|
+
`INSERT INTO purge_authorizations(id, workspace_id, document_id, principal_id, request_hash, expires_at, created_at)
|
|
918
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
919
|
+
)
|
|
920
|
+
.bind(id, actor.workspaceId, document.id, actor.principalId, hash, expiresAt, createdAt)
|
|
921
|
+
.run();
|
|
922
|
+
return { id, documentId: document.id, slug, expiresAt };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async purge(
|
|
926
|
+
actor: OwnerContext,
|
|
927
|
+
authorizationId: string,
|
|
928
|
+
slug: string
|
|
929
|
+
): Promise<{ purgedRevisions: number }> {
|
|
930
|
+
const authorization = await this.db
|
|
931
|
+
.prepare(
|
|
932
|
+
`SELECT p.document_id, p.principal_id, p.request_hash, p.expires_at
|
|
933
|
+
FROM purge_authorizations p
|
|
934
|
+
JOIN documents d ON d.id = p.document_id AND d.workspace_id = p.workspace_id
|
|
935
|
+
WHERE p.id = ? AND p.workspace_id = ? AND d.slug = ?`
|
|
936
|
+
)
|
|
937
|
+
.bind(authorizationId, actor.workspaceId, slug)
|
|
938
|
+
.first<{
|
|
939
|
+
document_id: string;
|
|
940
|
+
principal_id: string;
|
|
941
|
+
request_hash: string;
|
|
942
|
+
expires_at: string;
|
|
943
|
+
}>();
|
|
944
|
+
if (authorization === null || authorization.principal_id !== actor.principalId) {
|
|
945
|
+
throw new DomainError("forbidden", "Invalid purge authorization");
|
|
946
|
+
}
|
|
947
|
+
if (Date.parse(authorization.expires_at) < Date.now()) {
|
|
948
|
+
throw new DomainError("reauthentication_required", "Purge authorization expired");
|
|
949
|
+
}
|
|
950
|
+
const count = await this.db
|
|
951
|
+
.prepare("SELECT COUNT(*) count FROM revisions WHERE workspace_id = ? AND doc_id = ?")
|
|
952
|
+
.bind(actor.workspaceId, authorization.document_id)
|
|
953
|
+
.first<{ count: number }>();
|
|
954
|
+
const purgedRevisions = count?.count ?? 0;
|
|
955
|
+
const createdAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
956
|
+
await this.db.batch([
|
|
957
|
+
this.db.prepare("PRAGMA defer_foreign_keys = ON"),
|
|
958
|
+
this.db
|
|
959
|
+
.prepare(
|
|
960
|
+
`INSERT INTO audit_events(id, workspace_id, kind, created_at, principal_id, client_id,
|
|
961
|
+
agent_label, document_id, request_id, detail_json)
|
|
962
|
+
VALUES (?, ?, 'purge', ?, ?, ?, ?, ?, ?, ?)`
|
|
963
|
+
)
|
|
964
|
+
.bind(
|
|
965
|
+
crypto.randomUUID(),
|
|
966
|
+
actor.workspaceId,
|
|
967
|
+
createdAt,
|
|
968
|
+
actor.principalId,
|
|
969
|
+
actor.clientId,
|
|
970
|
+
actor.agentLabel ?? null,
|
|
971
|
+
authorization.document_id,
|
|
972
|
+
actor.requestId,
|
|
973
|
+
JSON.stringify({ purgedRevisions, requestHash: authorization.request_hash })
|
|
974
|
+
),
|
|
975
|
+
this.db
|
|
976
|
+
.prepare(
|
|
977
|
+
`UPDATE operations SET status = 'purged', result_document_id = NULL, result_revision_id = NULL
|
|
978
|
+
WHERE workspace_id = ? AND result_document_id = ?`
|
|
979
|
+
)
|
|
980
|
+
.bind(actor.workspaceId, authorization.document_id),
|
|
981
|
+
this.db
|
|
982
|
+
.prepare(
|
|
983
|
+
`DELETE FROM revision_links WHERE workspace_id = ? AND revision_id IN
|
|
984
|
+
(SELECT id FROM revisions WHERE workspace_id = ? AND doc_id = ?)`
|
|
985
|
+
)
|
|
986
|
+
.bind(actor.workspaceId, actor.workspaceId, authorization.document_id),
|
|
987
|
+
this.db
|
|
988
|
+
.prepare(
|
|
989
|
+
`DELETE FROM revision_metadata WHERE workspace_id = ? AND revision_id IN
|
|
990
|
+
(SELECT id FROM revisions WHERE workspace_id = ? AND doc_id = ?)`
|
|
991
|
+
)
|
|
992
|
+
.bind(actor.workspaceId, actor.workspaceId, authorization.document_id),
|
|
993
|
+
this.db
|
|
994
|
+
.prepare("DELETE FROM current_fts WHERE workspace_id = ? AND document_id = ?")
|
|
995
|
+
.bind(actor.workspaceId, authorization.document_id),
|
|
996
|
+
this.db
|
|
997
|
+
.prepare("DELETE FROM revisions WHERE workspace_id = ? AND doc_id = ?")
|
|
998
|
+
.bind(actor.workspaceId, authorization.document_id),
|
|
999
|
+
this.db
|
|
1000
|
+
.prepare("DELETE FROM documents WHERE workspace_id = ? AND id = ?")
|
|
1001
|
+
.bind(actor.workspaceId, authorization.document_id),
|
|
1002
|
+
this.db
|
|
1003
|
+
.prepare("DELETE FROM purge_authorizations WHERE id = ? AND workspace_id = ?")
|
|
1004
|
+
.bind(authorizationId, actor.workspaceId),
|
|
1005
|
+
this.db.prepare("PRAGMA defer_foreign_keys = OFF")
|
|
1006
|
+
]);
|
|
1007
|
+
return { purgedRevisions };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
private async readMetadata(workspaceId: string, revisionId: string): Promise<MetadataValue[]> {
|
|
1011
|
+
const rows = await this.db
|
|
1012
|
+
.prepare(
|
|
1013
|
+
`SELECT key, value, cardinality FROM revision_metadata
|
|
1014
|
+
WHERE workspace_id = ? AND revision_id = ? ORDER BY key, value`
|
|
1015
|
+
)
|
|
1016
|
+
.bind(workspaceId, revisionId)
|
|
1017
|
+
.all<MetadataValue>();
|
|
1018
|
+
return rows.results;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private async readLinks(workspaceId: string, revisionId: string): Promise<StoredLink[]> {
|
|
1022
|
+
const rows = await this.db
|
|
1023
|
+
.prepare(
|
|
1024
|
+
`SELECT rl.kind, rl.target_slug targetSlug,
|
|
1025
|
+
COALESCE(rl.target_document_id, target.id) targetDocumentId, rl.origin
|
|
1026
|
+
FROM revision_links rl
|
|
1027
|
+
LEFT JOIN documents target
|
|
1028
|
+
ON target.workspace_id = rl.workspace_id AND target.slug = rl.target_slug
|
|
1029
|
+
WHERE rl.workspace_id = ? AND rl.revision_id = ?
|
|
1030
|
+
ORDER BY rl.kind, rl.target_slug, rl.origin`
|
|
1031
|
+
)
|
|
1032
|
+
.bind(workspaceId, revisionId)
|
|
1033
|
+
.all<{
|
|
1034
|
+
kind: StoredLink["kind"];
|
|
1035
|
+
targetSlug: string;
|
|
1036
|
+
targetDocumentId: string | null;
|
|
1037
|
+
origin: StoredLink["origin"];
|
|
1038
|
+
}>();
|
|
1039
|
+
return rows.results;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
private async findOperation(
|
|
1043
|
+
workspaceId: string,
|
|
1044
|
+
operationId: string
|
|
1045
|
+
): Promise<OperationRow | null> {
|
|
1046
|
+
return this.db
|
|
1047
|
+
.prepare(
|
|
1048
|
+
`SELECT request_hash, principal_id, kind, status, result_document_id, result_revision_id
|
|
1049
|
+
FROM operations WHERE workspace_id = ? AND operation_id = ?`
|
|
1050
|
+
)
|
|
1051
|
+
.bind(workspaceId, operationId)
|
|
1052
|
+
.first<OperationRow>();
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
private async replayResult(
|
|
1056
|
+
actor: ActorContext,
|
|
1057
|
+
request: IngestRequest,
|
|
1058
|
+
hash: string,
|
|
1059
|
+
operationKind: "ingest" | "link" | "restore",
|
|
1060
|
+
operation: OperationRow
|
|
1061
|
+
): Promise<IngestResult> {
|
|
1062
|
+
if (
|
|
1063
|
+
operation.principal_id !== actor.principalId ||
|
|
1064
|
+
operation.kind !== operationKind ||
|
|
1065
|
+
operation.request_hash !== hash
|
|
1066
|
+
) {
|
|
1067
|
+
throw new DomainError(
|
|
1068
|
+
"idempotency_mismatch",
|
|
1069
|
+
"Operation ID was already used for a different request"
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
if (operation.status === "purged")
|
|
1073
|
+
throw new DomainError("gone", "The operation's document was permanently purged");
|
|
1074
|
+
if (operation.result_revision_id === null || operation.result_document_id === null) {
|
|
1075
|
+
throw new DomainError("internal_error", "Completed operation has no result");
|
|
1076
|
+
}
|
|
1077
|
+
const row = await this.db
|
|
1078
|
+
.prepare("SELECT revision_number FROM revisions WHERE workspace_id = ? AND id = ?")
|
|
1079
|
+
.bind(actor.workspaceId, operation.result_revision_id)
|
|
1080
|
+
.first<{ revision_number: number }>();
|
|
1081
|
+
if (row === null)
|
|
1082
|
+
throw new DomainError("internal_error", "Completed operation result is missing");
|
|
1083
|
+
return {
|
|
1084
|
+
documentId: operation.result_document_id,
|
|
1085
|
+
revisionId: operation.result_revision_id,
|
|
1086
|
+
revisionNumber: row.revision_number,
|
|
1087
|
+
slug: request.slug,
|
|
1088
|
+
idempotentReplay: true,
|
|
1089
|
+
unresolvedReferences: []
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
}
|