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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -0
  3. package/dist/npm-cli/scripts/cli-options.js +29 -0
  4. package/dist/npm-cli/scripts/cli.js +79 -0
  5. package/dist/npm-cli/scripts/client-tools.js +57 -0
  6. package/dist/npm-cli/scripts/deployment-record.js +72 -0
  7. package/dist/npm-cli/scripts/dev.js +49 -0
  8. package/dist/npm-cli/scripts/lifecycle-runtime.js +38 -0
  9. package/dist/npm-cli/scripts/package-root.js +15 -0
  10. package/dist/npm-cli/scripts/passkeys.js +197 -0
  11. package/dist/npm-cli/scripts/setup.js +523 -0
  12. package/dist/npm-cli/scripts/status.js +49 -0
  13. package/dist/npm-cli/scripts/uninstall.js +220 -0
  14. package/dist/npm-cli/scripts/upgrade.js +301 -0
  15. package/dist/npm-cli/src/version.js +2 -0
  16. package/dist/web/assets/index-CjnFBnXp.css +1 -0
  17. package/dist/web/assets/index-buhGyrxi.js +72 -0
  18. package/dist/web/index.html +14 -0
  19. package/migrations/0001_initial.sql +297 -0
  20. package/migrations/0002_passkeys.sql +30 -0
  21. package/migrations/0003_passkey_management.sql +38 -0
  22. package/migrations/0004_credential_bound_registration_tokens.sql +18 -0
  23. package/package.json +89 -0
  24. package/release-manifest.json +22 -0
  25. package/skills/wikimemory-ingest/SKILL.md +24 -0
  26. package/skills/wikimemory-ingest/agents/openai.yaml +4 -0
  27. package/skills/wikimemory-install/SKILL.md +81 -0
  28. package/skills/wikimemory-install/agents/openai.yaml +4 -0
  29. package/skills/wikimemory-lint/SKILL.md +18 -0
  30. package/skills/wikimemory-lint/agents/openai.yaml +4 -0
  31. package/skills/wikimemory-recall/SKILL.md +20 -0
  32. package/skills/wikimemory-recall/agents/openai.yaml +4 -0
  33. package/src/auth/local.ts +131 -0
  34. package/src/auth/passkey-api.ts +100 -0
  35. package/src/auth/passkey-management.ts +150 -0
  36. package/src/auth/passkey.ts +908 -0
  37. package/src/auth/props.ts +96 -0
  38. package/src/auth/resource.ts +36 -0
  39. package/src/domain/crypto.ts +27 -0
  40. package/src/domain/errors.ts +32 -0
  41. package/src/domain/export-service.ts +363 -0
  42. package/src/domain/guards.ts +9 -0
  43. package/src/domain/memory-service.ts +1092 -0
  44. package/src/domain/secret-scanner.ts +45 -0
  45. package/src/domain/text-chunk.ts +24 -0
  46. package/src/domain/types.ts +169 -0
  47. package/src/env.ts +20 -0
  48. package/src/index.ts +138 -0
  49. package/src/mcp/schemas.ts +117 -0
  50. package/src/mcp/server.ts +506 -0
  51. package/src/version.ts +2 -0
  52. 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
+ }