@stoneforge/shared-routes 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +69 -0
  3. package/dist/channels.d.ts +9 -0
  4. package/dist/channels.d.ts.map +1 -0
  5. package/dist/channels.js +413 -0
  6. package/dist/channels.js.map +1 -0
  7. package/dist/documents.d.ts +9 -0
  8. package/dist/documents.d.ts.map +1 -0
  9. package/dist/documents.js +1205 -0
  10. package/dist/documents.js.map +1 -0
  11. package/dist/elements.d.ts +9 -0
  12. package/dist/elements.d.ts.map +1 -0
  13. package/dist/elements.js +87 -0
  14. package/dist/elements.js.map +1 -0
  15. package/dist/entities.d.ts +9 -0
  16. package/dist/entities.d.ts.map +1 -0
  17. package/dist/entities.js +92 -0
  18. package/dist/entities.js.map +1 -0
  19. package/dist/inbox.d.ts +10 -0
  20. package/dist/inbox.d.ts.map +1 -0
  21. package/dist/inbox.js +538 -0
  22. package/dist/inbox.js.map +1 -0
  23. package/dist/index.d.ts +16 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +17 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/libraries.d.ts +9 -0
  28. package/dist/libraries.d.ts.map +1 -0
  29. package/dist/libraries.js +467 -0
  30. package/dist/libraries.js.map +1 -0
  31. package/dist/messages.d.ts +9 -0
  32. package/dist/messages.d.ts.map +1 -0
  33. package/dist/messages.js +338 -0
  34. package/dist/messages.js.map +1 -0
  35. package/dist/plans.d.ts +10 -0
  36. package/dist/plans.d.ts.map +1 -0
  37. package/dist/plans.js +495 -0
  38. package/dist/plans.js.map +1 -0
  39. package/dist/types.d.ts +128 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +8 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/ws/broadcaster.d.ts +62 -0
  44. package/dist/ws/broadcaster.d.ts.map +1 -0
  45. package/dist/ws/broadcaster.js +146 -0
  46. package/dist/ws/broadcaster.js.map +1 -0
  47. package/dist/ws/handler.d.ts +11 -0
  48. package/dist/ws/handler.d.ts.map +1 -0
  49. package/dist/ws/handler.js +38 -0
  50. package/dist/ws/handler.js.map +1 -0
  51. package/dist/ws/index.d.ts +11 -0
  52. package/dist/ws/index.d.ts.map +1 -0
  53. package/dist/ws/index.js +11 -0
  54. package/dist/ws/index.js.map +1 -0
  55. package/dist/ws/types.d.ts +85 -0
  56. package/dist/ws/types.d.ts.map +1 -0
  57. package/dist/ws/types.js +63 -0
  58. package/dist/ws/types.js.map +1 -0
  59. package/package.json +40 -0
@@ -0,0 +1,1205 @@
1
+ /**
2
+ * Document Routes Factory
3
+ *
4
+ * CRUD operations for documents, versioning, links, and comments.
5
+ */
6
+ import { Hono } from 'hono';
7
+ import { createDocument, isValidDocumentCategory, isValidDocumentStatus, DocumentStatus as DocumentStatusEnum, ContentType, validateContent, createEvent, createTimestamp } from '@stoneforge/core';
8
+ export function createDocumentRoutes(services) {
9
+ const { api, storageBackend } = services;
10
+ const app = new Hono();
11
+ // GET /api/documents - List documents
12
+ app.get('/api/documents', async (c) => {
13
+ try {
14
+ const url = new URL(c.req.url);
15
+ // Parse pagination and filter parameters
16
+ const limitParam = url.searchParams.get('limit');
17
+ const offsetParam = url.searchParams.get('offset');
18
+ const orderByParam = url.searchParams.get('orderBy');
19
+ const orderDirParam = url.searchParams.get('orderDir');
20
+ const searchParam = url.searchParams.get('search');
21
+ const categoryParam = url.searchParams.get('category');
22
+ const statusParam = url.searchParams.get('status');
23
+ // Build filter
24
+ const filter = {
25
+ type: 'document',
26
+ };
27
+ // Category filter
28
+ if (categoryParam) {
29
+ const categories = categoryParam.split(',');
30
+ for (const cat of categories) {
31
+ if (!isValidDocumentCategory(cat)) {
32
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid category: ${cat}` } }, 400);
33
+ }
34
+ }
35
+ filter.category = categories.length === 1 ? categories[0] : categories;
36
+ }
37
+ // Status filter (default: active only, unless explicitly specified)
38
+ if (statusParam) {
39
+ const statuses = statusParam.split(',');
40
+ for (const s of statuses) {
41
+ if (!isValidDocumentStatus(s)) {
42
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid status: ${s}` } }, 400);
43
+ }
44
+ }
45
+ filter.status = statuses.length === 1 ? statuses[0] : statuses;
46
+ }
47
+ // Validate limit and offset
48
+ const MAX_LIMIT = 500;
49
+ let requestedLimit = 50;
50
+ let requestedOffset = 0;
51
+ if (limitParam) {
52
+ requestedLimit = parseInt(limitParam, 10);
53
+ if (isNaN(requestedLimit) || requestedLimit < 1 || requestedLimit > MAX_LIMIT) {
54
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid limit: must be 1-${MAX_LIMIT}` } }, 400);
55
+ }
56
+ }
57
+ if (offsetParam) {
58
+ requestedOffset = parseInt(offsetParam, 10);
59
+ if (isNaN(requestedOffset) || requestedOffset < 0) {
60
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid offset: must be >= 0' } }, 400);
61
+ }
62
+ }
63
+ // Validate orderBy — whitelist allowed columns
64
+ const ALLOWED_ORDER_COLUMNS = ['updated_at', 'created_at', 'title', 'type'];
65
+ if (orderByParam) {
66
+ if (!ALLOWED_ORDER_COLUMNS.includes(orderByParam)) {
67
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid orderBy: ${orderByParam}. Must be one of: ${ALLOWED_ORDER_COLUMNS.join(', ')}` } }, 400);
68
+ }
69
+ filter.orderBy = orderByParam;
70
+ }
71
+ else {
72
+ filter.orderBy = 'updated_at';
73
+ }
74
+ // Validate orderDir
75
+ if (orderDirParam) {
76
+ if (orderDirParam !== 'asc' && orderDirParam !== 'desc') {
77
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid orderDir: ${orderDirParam}. Must be 'asc' or 'desc'` } }, 400);
78
+ }
79
+ filter.orderDir = orderDirParam;
80
+ }
81
+ else {
82
+ filter.orderDir = 'desc';
83
+ }
84
+ // If search param is provided, use the search API for better results
85
+ if (searchParam && searchParam.trim()) {
86
+ const searchResults = await api.search(searchParam.trim(), filter);
87
+ const slicedResults = searchResults.slice(requestedOffset, requestedOffset + requestedLimit);
88
+ return c.json({
89
+ items: slicedResults,
90
+ total: searchResults.length,
91
+ offset: requestedOffset,
92
+ limit: requestedLimit,
93
+ hasMore: requestedOffset + requestedLimit < searchResults.length,
94
+ });
95
+ }
96
+ // Standard paginated query when no search
97
+ filter.limit = requestedLimit;
98
+ filter.offset = requestedOffset;
99
+ const result = await api.listPaginated(filter);
100
+ // Return paginated response format
101
+ return c.json({
102
+ items: result.items,
103
+ total: result.total,
104
+ offset: result.offset,
105
+ limit: result.limit,
106
+ hasMore: result.hasMore,
107
+ });
108
+ }
109
+ catch (error) {
110
+ console.error('[stoneforge] Failed to get documents:', error);
111
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get documents' } }, 500);
112
+ }
113
+ });
114
+ /**
115
+ * GET /api/documents/search
116
+ * Search documents using FTS5 full-text search with BM25 ranking.
117
+ *
118
+ * Query params:
119
+ * - q: Search query (required)
120
+ * - limit: Hard cap on results (default: 50)
121
+ * - category: Filter by category
122
+ * - status: Filter by status (default: active)
123
+ * - sensitivity: Elbow detection sensitivity (default: 1.5)
124
+ * - mode: Search mode — 'relevance' (FTS5 only, default), 'semantic' (vector), 'hybrid' (RRF fusion)
125
+ */
126
+ app.get('/api/documents/search', async (c) => {
127
+ try {
128
+ const url = new URL(c.req.url);
129
+ const query = url.searchParams.get('q');
130
+ const limitParam = url.searchParams.get('limit');
131
+ const categoryParam = url.searchParams.get('category');
132
+ const statusParam = url.searchParams.get('status');
133
+ const sensitivityParam = url.searchParams.get('sensitivity');
134
+ const mode = url.searchParams.get('mode') ?? 'relevance';
135
+ if (!query || query.trim().length === 0) {
136
+ return c.json({ results: [] });
137
+ }
138
+ // Validate mode
139
+ if (!['relevance', 'semantic', 'hybrid'].includes(mode)) {
140
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid mode: ${mode}. Must be relevance, semantic, or hybrid` } }, 400);
141
+ }
142
+ // Semantic and hybrid modes require a registered EmbeddingService (currently CLI-only)
143
+ if (mode === 'semantic' || mode === 'hybrid') {
144
+ return c.json({ error: { code: 'NOT_IMPLEMENTED', message: `${mode} search requires an embedding provider to be configured. Use mode=relevance for FTS5 keyword search. See documentation for embedding setup instructions.` } }, 501);
145
+ }
146
+ // Validate category if provided
147
+ if (categoryParam && !isValidDocumentCategory(categoryParam)) {
148
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid category: ${categoryParam}` } }, 400);
149
+ }
150
+ // Validate status if provided
151
+ if (statusParam && !isValidDocumentStatus(statusParam)) {
152
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid status: ${statusParam}` } }, 400);
153
+ }
154
+ // Validate limit
155
+ let searchLimit = 50;
156
+ if (limitParam) {
157
+ searchLimit = parseInt(limitParam, 10);
158
+ if (isNaN(searchLimit) || searchLimit < 1 || searchLimit > 500) {
159
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid limit: must be 1-500' } }, 400);
160
+ }
161
+ }
162
+ // Validate sensitivity
163
+ let sensitivity = 1.5;
164
+ if (sensitivityParam) {
165
+ sensitivity = parseFloat(sensitivityParam);
166
+ if (isNaN(sensitivity) || sensitivity <= 0 || sensitivity > 10) {
167
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid sensitivity: must be between 0 and 10' } }, 400);
168
+ }
169
+ }
170
+ const results = await api.searchDocumentsFTS(query.trim(), {
171
+ hardCap: searchLimit,
172
+ ...(categoryParam && { category: categoryParam }),
173
+ ...(statusParam && { status: statusParam }),
174
+ elbowSensitivity: sensitivity,
175
+ });
176
+ return c.json({
177
+ results: results.map((r) => ({
178
+ id: r.document.id,
179
+ title: r.document.title,
180
+ contentType: r.document.contentType,
181
+ category: r.document.category,
182
+ status: r.document.status,
183
+ score: r.score,
184
+ snippet: r.snippet,
185
+ updatedAt: r.document.updatedAt,
186
+ })),
187
+ query: query.trim(),
188
+ mode,
189
+ total: results.length,
190
+ });
191
+ }
192
+ catch (error) {
193
+ console.error('[stoneforge] Failed to search documents:', error);
194
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to search documents' } }, 500);
195
+ }
196
+ });
197
+ // GET /api/documents/:id - Get single document
198
+ app.get('/api/documents/:id', async (c) => {
199
+ try {
200
+ const id = c.req.param('id');
201
+ const document = await api.get(id);
202
+ if (!document) {
203
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
204
+ }
205
+ if (document.type !== 'document') {
206
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
207
+ }
208
+ return c.json(document);
209
+ }
210
+ catch (error) {
211
+ console.error('[stoneforge] Failed to get document:', error);
212
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get document' } }, 500);
213
+ }
214
+ });
215
+ // POST /api/documents - Create document
216
+ app.post('/api/documents', async (c) => {
217
+ try {
218
+ const body = await c.req.json();
219
+ // Validate required fields
220
+ if (!body.createdBy || typeof body.createdBy !== 'string') {
221
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'createdBy is required' } }, 400);
222
+ }
223
+ // Default content type to 'text' if not provided
224
+ const contentType = body.contentType || 'text';
225
+ // Validate contentType
226
+ const validContentTypes = Object.values(ContentType);
227
+ if (!validContentTypes.includes(contentType)) {
228
+ return c.json({
229
+ error: {
230
+ code: 'VALIDATION_ERROR',
231
+ message: `Invalid contentType. Must be one of: ${validContentTypes.join(', ')}`,
232
+ },
233
+ }, 400);
234
+ }
235
+ // Default content to empty string if not provided
236
+ const content = body.content || '';
237
+ // Validate JSON content if contentType is json
238
+ if (contentType === 'json' && content) {
239
+ try {
240
+ JSON.parse(content);
241
+ }
242
+ catch {
243
+ return c.json({
244
+ error: {
245
+ code: 'VALIDATION_ERROR',
246
+ message: 'Invalid JSON content',
247
+ },
248
+ }, 400);
249
+ }
250
+ }
251
+ // Validate category if provided
252
+ if (body.category !== undefined && !isValidDocumentCategory(body.category)) {
253
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid category: ${body.category}` } }, 400);
254
+ }
255
+ // Validate status if provided
256
+ if (body.status !== undefined && !isValidDocumentStatus(body.status)) {
257
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid status: ${body.status}` } }, 400);
258
+ }
259
+ // Build CreateDocumentInput
260
+ const docInput = {
261
+ contentType,
262
+ content,
263
+ createdBy: body.createdBy,
264
+ ...(body.title !== undefined && { title: body.title }),
265
+ ...(body.tags !== undefined && { tags: body.tags }),
266
+ ...(body.metadata !== undefined && { metadata: body.metadata }),
267
+ ...(body.category !== undefined && { category: body.category }),
268
+ ...(body.status !== undefined && { status: body.status }),
269
+ };
270
+ // Validate libraryId before creating the document
271
+ if (body.libraryId) {
272
+ const library = await api.get(body.libraryId);
273
+ if (!library || library.type !== 'library') {
274
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid libraryId: library not found' } }, 400);
275
+ }
276
+ }
277
+ // Create the document using the factory function
278
+ const document = await createDocument(docInput);
279
+ // Create in database
280
+ const created = await api.create(document);
281
+ // Add to library (already validated above)
282
+ if (body.libraryId) {
283
+ await api.addDependency({
284
+ blockedId: created.id,
285
+ blockerId: body.libraryId,
286
+ type: 'parent-child',
287
+ });
288
+ }
289
+ return c.json(created, 201);
290
+ }
291
+ catch (error) {
292
+ if (error.code === 'VALIDATION_ERROR') {
293
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: error.message } }, 400);
294
+ }
295
+ console.error('[stoneforge] Failed to create document:', error);
296
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to create document' } }, 500);
297
+ }
298
+ });
299
+ // PATCH /api/documents/:id - Update document
300
+ app.patch('/api/documents/:id', async (c) => {
301
+ try {
302
+ const id = c.req.param('id');
303
+ const body = await c.req.json();
304
+ // First verify it's a document
305
+ const existing = await api.get(id);
306
+ if (!existing) {
307
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
308
+ }
309
+ if (existing.type !== 'document') {
310
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
311
+ }
312
+ // Reject content updates on immutable documents
313
+ const existingDoc = existing;
314
+ if (existingDoc.immutable === true && body.content !== undefined) {
315
+ return c.json({ error: { code: 'IMMUTABLE', message: 'Cannot update content of immutable document' } }, 403);
316
+ }
317
+ // Extract allowed updates (prevent changing immutable fields)
318
+ const updates = {};
319
+ const allowedFields = ['title', 'content', 'contentType', 'tags', 'metadata', 'category', 'status'];
320
+ for (const field of allowedFields) {
321
+ if (body[field] !== undefined) {
322
+ updates[field] = body[field];
323
+ }
324
+ }
325
+ // Validate contentType if provided
326
+ if (updates.contentType) {
327
+ const validTypes = Object.values(ContentType);
328
+ if (!validTypes.includes(updates.contentType)) {
329
+ return c.json({
330
+ error: {
331
+ code: 'VALIDATION_ERROR',
332
+ message: `Invalid contentType. Must be one of: ${validTypes.join(', ')}`,
333
+ },
334
+ }, 400);
335
+ }
336
+ }
337
+ // Validate category if provided
338
+ if (updates.category !== undefined && !isValidDocumentCategory(updates.category)) {
339
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid category: ${updates.category}` } }, 400);
340
+ }
341
+ // Validate status if provided
342
+ if (updates.status !== undefined && !isValidDocumentStatus(updates.status)) {
343
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid status: ${updates.status}` } }, 400);
344
+ }
345
+ // Validate content (size limit + JSON validation if applicable)
346
+ if (updates.content !== undefined) {
347
+ const contentTypeVal = (updates.contentType || existing.contentType);
348
+ try {
349
+ validateContent(updates.content, contentTypeVal);
350
+ }
351
+ catch (err) {
352
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: err.message } }, 400);
353
+ }
354
+ }
355
+ // Update the document
356
+ const updated = await api.update(id, updates);
357
+ return c.json(updated);
358
+ }
359
+ catch (error) {
360
+ if (error.code === 'NOT_FOUND') {
361
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
362
+ }
363
+ if (error.code === 'CONCURRENT_MODIFICATION') {
364
+ return c.json({ error: { code: 'CONFLICT', message: 'Document was modified by another process' } }, 409);
365
+ }
366
+ if (error.code === 'VALIDATION_ERROR') {
367
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: error.message } }, 400);
368
+ }
369
+ console.error('[stoneforge] Failed to update document:', error);
370
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to update document' } }, 500);
371
+ }
372
+ });
373
+ // POST /api/documents/:id/archive - Archive a document
374
+ app.post('/api/documents/:id/archive', async (c) => {
375
+ try {
376
+ const id = c.req.param('id');
377
+ const existing = await api.get(id);
378
+ if (!existing) {
379
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
380
+ }
381
+ if (existing.type !== 'document') {
382
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
383
+ }
384
+ const updated = await api.update(id, { status: DocumentStatusEnum.ARCHIVED });
385
+ return c.json(updated);
386
+ }
387
+ catch (error) {
388
+ if (error.code === 'NOT_FOUND') {
389
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
390
+ }
391
+ console.error('[stoneforge] Failed to archive document:', error);
392
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to archive document' } }, 500);
393
+ }
394
+ });
395
+ // POST /api/documents/:id/unarchive - Unarchive a document
396
+ app.post('/api/documents/:id/unarchive', async (c) => {
397
+ try {
398
+ const id = c.req.param('id');
399
+ const existing = await api.get(id);
400
+ if (!existing) {
401
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
402
+ }
403
+ if (existing.type !== 'document') {
404
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
405
+ }
406
+ const updated = await api.update(id, { status: DocumentStatusEnum.ACTIVE });
407
+ return c.json(updated);
408
+ }
409
+ catch (error) {
410
+ if (error.code === 'NOT_FOUND') {
411
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
412
+ }
413
+ console.error('[stoneforge] Failed to unarchive document:', error);
414
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to unarchive document' } }, 500);
415
+ }
416
+ });
417
+ // DELETE /api/documents/:id - Delete a document (soft-delete via tombstone)
418
+ app.delete('/api/documents/:id', async (c) => {
419
+ try {
420
+ const id = c.req.param('id');
421
+ const existing = await api.get(id);
422
+ if (!existing) {
423
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
424
+ }
425
+ if (existing.type !== 'document') {
426
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
427
+ }
428
+ await api.delete(id);
429
+ return c.json({ success: true, id }, 200);
430
+ }
431
+ catch (error) {
432
+ if (error.code === 'NOT_FOUND') {
433
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
434
+ }
435
+ console.error('[stoneforge] Failed to delete document:', error);
436
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to delete document' } }, 500);
437
+ }
438
+ });
439
+ // GET /api/documents/:id/versions - Get document version history
440
+ app.get('/api/documents/:id/versions', async (c) => {
441
+ try {
442
+ const id = c.req.param('id');
443
+ // First verify it's a document
444
+ const existing = await api.get(id);
445
+ if (!existing) {
446
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
447
+ }
448
+ if (existing.type !== 'document') {
449
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
450
+ }
451
+ // Get version history using the API method
452
+ const versions = await api.getDocumentHistory(id);
453
+ return c.json(versions);
454
+ }
455
+ catch (error) {
456
+ console.error('[stoneforge] Failed to get document versions:', error);
457
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get document versions' } }, 500);
458
+ }
459
+ });
460
+ // GET /api/documents/:id/versions/:version - Get specific version
461
+ app.get('/api/documents/:id/versions/:version', async (c) => {
462
+ try {
463
+ const id = c.req.param('id');
464
+ const versionParam = c.req.param('version');
465
+ const version = parseInt(versionParam, 10);
466
+ if (isNaN(version) || version < 1) {
467
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid version number' } }, 400);
468
+ }
469
+ // Get the specific version
470
+ const document = await api.getDocumentVersion(id, version);
471
+ if (!document) {
472
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document version not found' } }, 404);
473
+ }
474
+ return c.json(document);
475
+ }
476
+ catch (error) {
477
+ console.error('[stoneforge] Failed to get document version:', error);
478
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get document version' } }, 500);
479
+ }
480
+ });
481
+ // POST /api/documents/:id/restore - Restore document to a specific version
482
+ app.post('/api/documents/:id/restore', async (c) => {
483
+ try {
484
+ const id = c.req.param('id');
485
+ const body = await c.req.json();
486
+ const version = body.version;
487
+ if (typeof version !== 'number' || version < 1) {
488
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid version number' } }, 400);
489
+ }
490
+ // First verify it's a document
491
+ const existing = await api.get(id);
492
+ if (!existing) {
493
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
494
+ }
495
+ if (existing.type !== 'document') {
496
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
497
+ }
498
+ // Get the version to restore
499
+ const versionToRestore = await api.getDocumentVersion(id, version);
500
+ if (!versionToRestore) {
501
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document version not found' } }, 404);
502
+ }
503
+ // Update the document with the restored content (including optional fields if present in snapshot)
504
+ const restorePayload = {
505
+ content: versionToRestore.content,
506
+ contentType: versionToRestore.contentType,
507
+ };
508
+ if (versionToRestore.tags !== undefined)
509
+ restorePayload.tags = versionToRestore.tags;
510
+ if (versionToRestore.metadata !== undefined)
511
+ restorePayload.metadata = versionToRestore.metadata;
512
+ if (versionToRestore.title !== undefined)
513
+ restorePayload.title = versionToRestore.title;
514
+ if (versionToRestore.category !== undefined)
515
+ restorePayload.category = versionToRestore.category;
516
+ const restored = await api.update(id, restorePayload);
517
+ return c.json(restored);
518
+ }
519
+ catch (error) {
520
+ if (error.code === 'NOT_FOUND') {
521
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
522
+ }
523
+ console.error('[stoneforge] Failed to restore document version:', error);
524
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to restore document version' } }, 500);
525
+ }
526
+ });
527
+ // POST /api/documents/:id/clone - Clone a document
528
+ app.post('/api/documents/:id/clone', async (c) => {
529
+ try {
530
+ const id = c.req.param('id');
531
+ const body = await c.req.json();
532
+ // Get the source document
533
+ const sourceDoc = await api.get(id);
534
+ if (!sourceDoc) {
535
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
536
+ }
537
+ if (sourceDoc.type !== 'document') {
538
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
539
+ }
540
+ const sourceDocument = sourceDoc;
541
+ // Validate createdBy
542
+ if (!body.createdBy || typeof body.createdBy !== 'string') {
543
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'createdBy is required' } }, 400);
544
+ }
545
+ // Use the new title or generate one from the original
546
+ const originalTitle = sourceDocument.title || `Document ${sourceDocument.id}`;
547
+ const newTitle = body.title || `${originalTitle} (Copy)`;
548
+ // Create a new document with the same content
549
+ const docInput = {
550
+ contentType: sourceDocument.contentType,
551
+ content: sourceDocument.content || '',
552
+ createdBy: body.createdBy,
553
+ title: newTitle,
554
+ tags: sourceDocument.tags || [],
555
+ metadata: sourceDocument.metadata || {},
556
+ category: sourceDocument.category,
557
+ };
558
+ // Validate libraryId before creating the document
559
+ if (body.libraryId) {
560
+ const library = await api.get(body.libraryId);
561
+ if (!library || library.type !== 'library') {
562
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid libraryId: library not found' } }, 400);
563
+ }
564
+ }
565
+ const newDoc = await createDocument(docInput);
566
+ // Create in database
567
+ const created = await api.create(newDoc);
568
+ // Add to library (already validated above)
569
+ if (body.libraryId) {
570
+ await api.addDependency({
571
+ blockedId: created.id,
572
+ blockerId: body.libraryId,
573
+ type: 'parent-child',
574
+ });
575
+ }
576
+ return c.json(created, 201);
577
+ }
578
+ catch (error) {
579
+ if (error.code === 'NOT_FOUND') {
580
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
581
+ }
582
+ console.error('[stoneforge] Failed to clone document:', error);
583
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to clone document' } }, 500);
584
+ }
585
+ });
586
+ /**
587
+ * GET /api/documents/:id/links
588
+ * Returns documents linked from this document (outgoing) and documents linking to it (incoming)
589
+ * Query params:
590
+ * - direction: 'outgoing' | 'incoming' | 'both' (default: 'both')
591
+ */
592
+ app.get('/api/documents/:id/links', async (c) => {
593
+ try {
594
+ const documentId = c.req.param('id');
595
+ const url = new URL(c.req.url);
596
+ const direction = url.searchParams.get('direction') || 'both';
597
+ // Verify document exists
598
+ const doc = await api.get(documentId);
599
+ if (!doc) {
600
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
601
+ }
602
+ if (doc.type !== 'document') {
603
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
604
+ }
605
+ // Fetch document details based on direction
606
+ let outgoing = [];
607
+ let incoming = [];
608
+ if (direction === 'outgoing' || direction === 'both') {
609
+ // Outgoing links: documents this document references (blockedId = this document)
610
+ const outgoingDeps = await api.getDependencies(documentId, ['references']);
611
+ const outgoingDocs = await Promise.all(outgoingDeps.map(async (dep) => {
612
+ const linkedDoc = await api.get(dep.blockerId);
613
+ if (linkedDoc && linkedDoc.type === 'document') {
614
+ return linkedDoc;
615
+ }
616
+ return null;
617
+ }));
618
+ outgoing = outgoingDocs.filter(Boolean);
619
+ }
620
+ if (direction === 'incoming' || direction === 'both') {
621
+ // Incoming links: documents that reference this document (blockerId = this document)
622
+ const incomingDeps = await api.getDependents(documentId, ['references']);
623
+ const incomingDocs = await Promise.all(incomingDeps.map(async (dep) => {
624
+ const linkedDoc = await api.get(dep.blockedId);
625
+ if (linkedDoc && linkedDoc.type === 'document') {
626
+ return linkedDoc;
627
+ }
628
+ return null;
629
+ }));
630
+ incoming = incomingDocs.filter(Boolean);
631
+ }
632
+ return c.json({ outgoing, incoming });
633
+ }
634
+ catch (error) {
635
+ console.error('[stoneforge] Failed to get document links:', error);
636
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get document links' } }, 500);
637
+ }
638
+ });
639
+ /**
640
+ * POST /api/documents/:id/links
641
+ * Creates a link from this document to another document
642
+ * Body: { targetDocumentId: string, actor?: string }
643
+ */
644
+ app.post('/api/documents/:id/links', async (c) => {
645
+ try {
646
+ const sourceId = c.req.param('id');
647
+ const body = await c.req.json();
648
+ // Validate target document ID
649
+ if (!body.targetDocumentId || typeof body.targetDocumentId !== 'string') {
650
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'targetDocumentId is required' } }, 400);
651
+ }
652
+ const targetId = body.targetDocumentId;
653
+ // Prevent self-reference
654
+ if (sourceId === targetId) {
655
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Cannot link a document to itself' } }, 400);
656
+ }
657
+ // Verify source document exists
658
+ const sourceDoc = await api.get(sourceId);
659
+ if (!sourceDoc) {
660
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Source document not found' } }, 404);
661
+ }
662
+ if (sourceDoc.type !== 'document') {
663
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Source document not found' } }, 404);
664
+ }
665
+ // Verify target document exists
666
+ const targetDoc = await api.get(targetId);
667
+ if (!targetDoc) {
668
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Target document not found' } }, 404);
669
+ }
670
+ if (targetDoc.type !== 'document') {
671
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Target document not found' } }, 404);
672
+ }
673
+ // Check if link already exists
674
+ const existingDeps = await api.getDependencies(sourceId);
675
+ const alreadyLinked = existingDeps.some((dep) => dep.blockedId === sourceId && dep.blockerId === targetId && dep.type === 'references');
676
+ if (alreadyLinked) {
677
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Link already exists between these documents' } }, 400);
678
+ }
679
+ // Create the references dependency (source document references target document)
680
+ await api.addDependency({
681
+ blockedId: sourceId,
682
+ blockerId: targetId,
683
+ type: 'references',
684
+ actor: body.actor || 'el-0000',
685
+ });
686
+ return c.json({ sourceId, targetId, targetDocument: targetDoc }, 201);
687
+ }
688
+ catch (error) {
689
+ console.error('[stoneforge] Failed to link documents:', error);
690
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to link documents' } }, 500);
691
+ }
692
+ });
693
+ /**
694
+ * DELETE /api/documents/:sourceId/links/:targetId
695
+ * Removes a link between two documents
696
+ */
697
+ app.delete('/api/documents/:sourceId/links/:targetId', async (c) => {
698
+ try {
699
+ const sourceId = c.req.param('sourceId');
700
+ const targetId = c.req.param('targetId');
701
+ // Verify source document exists
702
+ const sourceDoc = await api.get(sourceId);
703
+ if (!sourceDoc) {
704
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Source document not found' } }, 404);
705
+ }
706
+ if (sourceDoc.type !== 'document') {
707
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Source document not found' } }, 404);
708
+ }
709
+ // Find the link dependency
710
+ const dependencies = await api.getDependencies(sourceId);
711
+ const linkDep = dependencies.find((dep) => dep.blockedId === sourceId && dep.blockerId === targetId && dep.type === 'references');
712
+ if (!linkDep) {
713
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Link not found between these documents' } }, 404);
714
+ }
715
+ // Remove the dependency
716
+ await api.removeDependency(sourceId, targetId, 'references');
717
+ return c.json({ success: true, sourceId, targetId });
718
+ }
719
+ catch (error) {
720
+ console.error('[stoneforge] Failed to remove document link:', error);
721
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to remove document link' } }, 500);
722
+ }
723
+ });
724
+ /**
725
+ * PUT /api/documents/:id/library
726
+ * Move a document to a library (or between libraries)
727
+ * Body: { libraryId: string, actor?: string }
728
+ */
729
+ app.put('/api/documents/:id/library', async (c) => {
730
+ try {
731
+ const documentId = c.req.param('id');
732
+ const body = await c.req.json();
733
+ // Verify document exists and is not tombstoned
734
+ const doc = await api.get(documentId);
735
+ if (!doc) {
736
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
737
+ }
738
+ if (doc.type !== 'document') {
739
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
740
+ }
741
+ const docData = doc;
742
+ if (docData.status === 'tombstone' || docData.deletedAt) {
743
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
744
+ }
745
+ // Validate libraryId
746
+ if (!body.libraryId || typeof body.libraryId !== 'string') {
747
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'libraryId is required' } }, 400);
748
+ }
749
+ const libraryId = body.libraryId;
750
+ // Verify library exists
751
+ const library = await api.get(libraryId);
752
+ if (!library || library.type !== 'library') {
753
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid libraryId: library not found' } }, 400);
754
+ }
755
+ const actor = body.actor || 'el-0000';
756
+ // Find current library
757
+ const deps = await api.getDependencies(documentId, ['parent-child']);
758
+ let previousLibraryId = null;
759
+ for (const dep of deps) {
760
+ // The blocker is the parent (library)
761
+ const parent = await api.get(dep.blockerId);
762
+ if (parent && parent.type === 'library') {
763
+ previousLibraryId = dep.blockerId;
764
+ break;
765
+ }
766
+ }
767
+ // Already in requested library — no-op
768
+ if (previousLibraryId === libraryId) {
769
+ return c.json({ documentId, libraryId, previousLibraryId });
770
+ }
771
+ // Remove from old library if present
772
+ if (previousLibraryId) {
773
+ await api.removeDependency(documentId, previousLibraryId, 'parent-child');
774
+ }
775
+ // Add to new library
776
+ await api.addDependency({
777
+ blockedId: documentId,
778
+ blockerId: libraryId,
779
+ type: 'parent-child',
780
+ actor,
781
+ });
782
+ return c.json({ documentId, libraryId, previousLibraryId });
783
+ }
784
+ catch (error) {
785
+ console.error('[stoneforge] Failed to move document to library:', error);
786
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to move document to library' } }, 500);
787
+ }
788
+ });
789
+ /**
790
+ * DELETE /api/documents/:id/library
791
+ * Remove a document from its library
792
+ */
793
+ app.delete('/api/documents/:id/library', async (c) => {
794
+ try {
795
+ const documentId = c.req.param('id');
796
+ // Verify document exists and is not tombstoned
797
+ const doc = await api.get(documentId);
798
+ if (!doc) {
799
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
800
+ }
801
+ if (doc.type !== 'document') {
802
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
803
+ }
804
+ const docData = doc;
805
+ if (docData.status === 'tombstone' || docData.deletedAt) {
806
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
807
+ }
808
+ // Find current library
809
+ const deps = await api.getDependencies(documentId, ['parent-child']);
810
+ let libraryDep = null;
811
+ for (const dep of deps) {
812
+ const parent = await api.get(dep.blockerId);
813
+ if (parent && parent.type === 'library') {
814
+ libraryDep = dep;
815
+ break;
816
+ }
817
+ }
818
+ if (!libraryDep) {
819
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document is not in any library' } }, 404);
820
+ }
821
+ await api.removeDependency(documentId, libraryDep.blockerId, 'parent-child');
822
+ return c.json({ success: true, documentId, removedFromLibrary: libraryDep.blockerId });
823
+ }
824
+ catch (error) {
825
+ console.error('[stoneforge] Failed to remove document from library:', error);
826
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to remove document from library' } }, 500);
827
+ }
828
+ });
829
+ /**
830
+ * GET /api/documents/:id/comments
831
+ * Returns all comments for a document
832
+ * Query params:
833
+ * - includeResolved: 'true' to include resolved comments (default: false)
834
+ */
835
+ app.get('/api/documents/:id/comments', async (c) => {
836
+ try {
837
+ const documentId = c.req.param('id');
838
+ const url = new URL(c.req.url);
839
+ const includeResolved = url.searchParams.get('includeResolved') === 'true';
840
+ // Pagination params
841
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 200);
842
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10);
843
+ // Verify document exists
844
+ const doc = await api.get(documentId);
845
+ if (!doc) {
846
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
847
+ }
848
+ if (doc.type !== 'document') {
849
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
850
+ }
851
+ const docData = doc;
852
+ if (docData.status === 'tombstone' || docData.deletedAt) {
853
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
854
+ }
855
+ // Count total comments
856
+ let countQuery = 'SELECT COUNT(*) as total FROM comments WHERE document_id = ? AND deleted_at IS NULL';
857
+ if (!includeResolved)
858
+ countQuery += ' AND resolved = 0';
859
+ const total = storageBackend.queryOne(countQuery, [documentId])?.total ?? 0;
860
+ // Query comments from the database with pagination
861
+ let query = `
862
+ SELECT * FROM comments
863
+ WHERE document_id = ? AND deleted_at IS NULL
864
+ `;
865
+ if (!includeResolved) {
866
+ query += ' AND resolved = 0';
867
+ }
868
+ query += ' ORDER BY created_at ASC LIMIT ? OFFSET ?';
869
+ const comments = storageBackend.query(query, [documentId, limit, offset]);
870
+ // Batch-fetch entities (Fix 3: N+1 → 1 query)
871
+ const entityIds = new Set();
872
+ for (const comment of comments) {
873
+ entityIds.add(comment.author_id);
874
+ if (comment.resolved_by)
875
+ entityIds.add(comment.resolved_by);
876
+ }
877
+ const entityMap = new Map();
878
+ if (entityIds.size > 0) {
879
+ const ids = [...entityIds];
880
+ const placeholders = ids.map(() => '?').join(', ');
881
+ const rows = storageBackend.query(`SELECT id, data FROM elements WHERE id IN (${placeholders})`, ids);
882
+ for (const row of rows) {
883
+ try {
884
+ const data = JSON.parse(row.data);
885
+ entityMap.set(row.id, { id: row.id, name: data.name ?? 'Unknown', entityType: data.entityType ?? 'unknown' });
886
+ }
887
+ catch { /* skip corrupt */ }
888
+ }
889
+ }
890
+ // Hydrate comments synchronously using entityMap
891
+ const hydratedComments = comments.map((comment) => {
892
+ const author = entityMap.get(comment.author_id);
893
+ const resolvedByEntity = comment.resolved_by ? entityMap.get(comment.resolved_by) : null;
894
+ return {
895
+ id: comment.id,
896
+ documentId: comment.document_id,
897
+ author: author
898
+ ? { id: author.id, name: author.name, entityType: author.entityType }
899
+ : { id: comment.author_id, name: 'Unknown', entityType: 'unknown' },
900
+ content: comment.content,
901
+ anchor: (() => {
902
+ try {
903
+ return JSON.parse(comment.anchor);
904
+ }
905
+ catch {
906
+ console.warn(`[stoneforge] Malformed anchor JSON for comment ${comment.id}`);
907
+ return { hash: '', prefix: '', text: comment.anchor, suffix: '' };
908
+ }
909
+ })(),
910
+ startOffset: comment.start_offset,
911
+ endOffset: comment.end_offset,
912
+ resolved: comment.resolved === 1,
913
+ resolvedBy: resolvedByEntity
914
+ ? { id: resolvedByEntity.id, name: resolvedByEntity.name }
915
+ : null,
916
+ resolvedAt: comment.resolved_at,
917
+ createdAt: comment.created_at,
918
+ updatedAt: comment.updated_at,
919
+ };
920
+ });
921
+ return c.json({
922
+ comments: hydratedComments,
923
+ total,
924
+ limit,
925
+ offset,
926
+ hasMore: offset + comments.length < total,
927
+ });
928
+ }
929
+ catch (error) {
930
+ console.error('[stoneforge] Failed to get document comments:', error);
931
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get document comments' } }, 500);
932
+ }
933
+ });
934
+ /**
935
+ * POST /api/documents/:id/comments
936
+ * Creates a new comment on a document
937
+ * Body: {
938
+ * authorId: string,
939
+ * content: string,
940
+ * anchor: { hash: string, prefix: string, text: string, suffix: string },
941
+ * startOffset?: number,
942
+ * endOffset?: number
943
+ * }
944
+ */
945
+ app.post('/api/documents/:id/comments', async (c) => {
946
+ try {
947
+ const documentId = c.req.param('id');
948
+ const body = await c.req.json();
949
+ // Validate required fields
950
+ if (!body.authorId || typeof body.authorId !== 'string') {
951
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'authorId is required' } }, 400);
952
+ }
953
+ if (!body.content || typeof body.content !== 'string' || body.content.trim().length === 0) {
954
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'content is required' } }, 400);
955
+ }
956
+ if (!body.anchor || typeof body.anchor !== 'object') {
957
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'anchor is required' } }, 400);
958
+ }
959
+ if (!body.anchor.hash || !body.anchor.text) {
960
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'anchor must include hash and text' } }, 400);
961
+ }
962
+ // Verify document exists
963
+ const doc = await api.get(documentId);
964
+ if (!doc) {
965
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
966
+ }
967
+ if (doc.type !== 'document') {
968
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
969
+ }
970
+ const docData = doc;
971
+ if (docData.status === 'tombstone' || docData.deletedAt) {
972
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
973
+ }
974
+ // Verify author exists
975
+ const author = await api.get(body.authorId);
976
+ if (!author) {
977
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Author not found' } }, 404);
978
+ }
979
+ if (author.type !== 'entity') {
980
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'authorId must be an entity' } }, 400);
981
+ }
982
+ // Generate comment ID
983
+ const commentId = `cmt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
984
+ const now = new Date().toISOString();
985
+ // Insert comment
986
+ storageBackend.run(`
987
+ INSERT INTO comments (id, document_id, author_id, content, anchor, start_offset, end_offset, resolved, created_at, updated_at)
988
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
989
+ `, [
990
+ commentId,
991
+ documentId,
992
+ body.authorId,
993
+ body.content.trim(),
994
+ JSON.stringify(body.anchor),
995
+ body.startOffset ?? null,
996
+ body.endOffset ?? null,
997
+ now,
998
+ now,
999
+ ]);
1000
+ // Record comment_added event
1001
+ const event = createEvent({
1002
+ elementId: documentId,
1003
+ eventType: 'comment_added',
1004
+ actor: body.authorId,
1005
+ oldValue: null,
1006
+ newValue: { commentId, content: body.content.trim(), anchor: body.anchor },
1007
+ });
1008
+ storageBackend.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [event.elementId, event.eventType, event.actor, null, JSON.stringify(event.newValue), event.createdAt]);
1009
+ return c.json({
1010
+ id: commentId,
1011
+ documentId,
1012
+ author: {
1013
+ id: author.id,
1014
+ name: author.name,
1015
+ entityType: author.entityType,
1016
+ },
1017
+ content: body.content.trim(),
1018
+ anchor: body.anchor,
1019
+ startOffset: body.startOffset ?? null,
1020
+ endOffset: body.endOffset ?? null,
1021
+ resolved: false,
1022
+ resolvedBy: null,
1023
+ resolvedAt: null,
1024
+ createdAt: now,
1025
+ updatedAt: now,
1026
+ }, 201);
1027
+ }
1028
+ catch (error) {
1029
+ console.error('[stoneforge] Failed to create comment:', error);
1030
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to create comment' } }, 500);
1031
+ }
1032
+ });
1033
+ /**
1034
+ * PATCH /api/documents/:id/comments/:commentId
1035
+ * Update a comment's content
1036
+ * Body: { content: string }
1037
+ */
1038
+ app.patch('/api/documents/:id/comments/:commentId', async (c) => {
1039
+ try {
1040
+ const documentId = c.req.param('id');
1041
+ const commentId = c.req.param('commentId');
1042
+ const body = await c.req.json();
1043
+ // Verify document exists and is not tombstoned
1044
+ const doc = await api.get(documentId);
1045
+ if (!doc) {
1046
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
1047
+ }
1048
+ if (doc.type !== 'document') {
1049
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
1050
+ }
1051
+ const docData = doc;
1052
+ if (docData.status === 'tombstone' || docData.deletedAt) {
1053
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
1054
+ }
1055
+ // Validate content
1056
+ if (!body.content || typeof body.content !== 'string' || body.content.trim().length === 0) {
1057
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'content is required' } }, 400);
1058
+ }
1059
+ // Verify comment exists
1060
+ const existing = storageBackend.queryOne(`SELECT * FROM comments WHERE id = ? AND document_id = ? AND deleted_at IS NULL`, [commentId, documentId]);
1061
+ if (!existing) {
1062
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Comment not found' } }, 404);
1063
+ }
1064
+ const now = createTimestamp();
1065
+ storageBackend.run(`UPDATE comments SET content = ?, updated_at = ? WHERE id = ?`, [body.content.trim(), now, commentId]);
1066
+ // Record comment_updated event
1067
+ const event = createEvent({
1068
+ elementId: documentId,
1069
+ eventType: 'comment_updated',
1070
+ actor: existing.author_id,
1071
+ oldValue: { commentId, content: existing.content },
1072
+ newValue: { commentId, content: body.content.trim() },
1073
+ });
1074
+ storageBackend.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [event.elementId, event.eventType, event.actor, JSON.stringify(event.oldValue), JSON.stringify(event.newValue), event.createdAt]);
1075
+ return c.json({
1076
+ id: commentId,
1077
+ documentId,
1078
+ content: body.content.trim(),
1079
+ updatedAt: now,
1080
+ });
1081
+ }
1082
+ catch (error) {
1083
+ console.error('[stoneforge] Failed to update comment:', error);
1084
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to update comment' } }, 500);
1085
+ }
1086
+ });
1087
+ /**
1088
+ * DELETE /api/documents/:id/comments/:commentId
1089
+ * Soft-delete a comment
1090
+ */
1091
+ app.delete('/api/documents/:id/comments/:commentId', async (c) => {
1092
+ try {
1093
+ const documentId = c.req.param('id');
1094
+ const commentId = c.req.param('commentId');
1095
+ // Verify document exists and is not tombstoned
1096
+ const doc = await api.get(documentId);
1097
+ if (!doc) {
1098
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
1099
+ }
1100
+ if (doc.type !== 'document') {
1101
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
1102
+ }
1103
+ const docData = doc;
1104
+ if (docData.status === 'tombstone' || docData.deletedAt) {
1105
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
1106
+ }
1107
+ // Verify comment exists and not already deleted
1108
+ const existing = storageBackend.queryOne(`SELECT * FROM comments WHERE id = ? AND document_id = ? AND deleted_at IS NULL`, [commentId, documentId]);
1109
+ if (!existing) {
1110
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Comment not found' } }, 404);
1111
+ }
1112
+ const now = createTimestamp();
1113
+ storageBackend.run(`UPDATE comments SET deleted_at = ?, updated_at = ? WHERE id = ?`, [now, now, commentId]);
1114
+ // Record comment_deleted event
1115
+ const event = createEvent({
1116
+ elementId: documentId,
1117
+ eventType: 'comment_deleted',
1118
+ actor: existing.author_id,
1119
+ oldValue: { commentId, content: existing.content },
1120
+ newValue: null,
1121
+ });
1122
+ storageBackend.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [event.elementId, event.eventType, event.actor, JSON.stringify(event.oldValue), null, event.createdAt]);
1123
+ return c.json({ success: true, id: commentId });
1124
+ }
1125
+ catch (error) {
1126
+ console.error('[stoneforge] Failed to delete comment:', error);
1127
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to delete comment' } }, 500);
1128
+ }
1129
+ });
1130
+ /**
1131
+ * POST /api/documents/:id/comments/:commentId/resolve
1132
+ * Resolve or unresolve a comment
1133
+ * Body: { resolved: boolean, actor: string }
1134
+ */
1135
+ app.post('/api/documents/:id/comments/:commentId/resolve', async (c) => {
1136
+ try {
1137
+ const documentId = c.req.param('id');
1138
+ const commentId = c.req.param('commentId');
1139
+ const body = await c.req.json();
1140
+ // Verify document exists and is not tombstoned
1141
+ const doc = await api.get(documentId);
1142
+ if (!doc) {
1143
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
1144
+ }
1145
+ if (doc.type !== 'document') {
1146
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
1147
+ }
1148
+ const docData = doc;
1149
+ if (docData.status === 'tombstone' || docData.deletedAt) {
1150
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
1151
+ }
1152
+ // Validate body
1153
+ if (typeof body.resolved !== 'boolean') {
1154
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'resolved (boolean) is required' } }, 400);
1155
+ }
1156
+ if (!body.actor || typeof body.actor !== 'string') {
1157
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'actor is required' } }, 400);
1158
+ }
1159
+ // Verify actor is an entity
1160
+ const actorEntity = await api.get(body.actor);
1161
+ if (!actorEntity) {
1162
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Actor not found' } }, 404);
1163
+ }
1164
+ if (actorEntity.type !== 'entity') {
1165
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'actor must be an entity' } }, 400);
1166
+ }
1167
+ // Verify comment exists and not deleted
1168
+ const existing = storageBackend.queryOne(`SELECT * FROM comments WHERE id = ? AND document_id = ? AND deleted_at IS NULL`, [commentId, documentId]);
1169
+ if (!existing) {
1170
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Comment not found' } }, 404);
1171
+ }
1172
+ const now = createTimestamp();
1173
+ if (body.resolved) {
1174
+ storageBackend.run(`UPDATE comments SET resolved = 1, resolved_by = ?, resolved_at = ?, updated_at = ? WHERE id = ?`, [body.actor, now, now, commentId]);
1175
+ }
1176
+ else {
1177
+ storageBackend.run(`UPDATE comments SET resolved = 0, resolved_by = NULL, resolved_at = NULL, updated_at = ? WHERE id = ?`, [now, commentId]);
1178
+ }
1179
+ // Record resolve/unresolve event
1180
+ const eventType = body.resolved ? 'comment_resolved' : 'comment_unresolved';
1181
+ const event = createEvent({
1182
+ elementId: documentId,
1183
+ eventType: eventType,
1184
+ actor: body.actor,
1185
+ oldValue: { commentId, resolved: existing.resolved === 1 },
1186
+ newValue: { commentId, resolved: body.resolved },
1187
+ });
1188
+ storageBackend.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [event.elementId, event.eventType, event.actor, JSON.stringify(event.oldValue), JSON.stringify(event.newValue), event.createdAt]);
1189
+ return c.json({
1190
+ id: commentId,
1191
+ documentId,
1192
+ resolved: body.resolved,
1193
+ resolvedBy: body.resolved ? body.actor : null,
1194
+ resolvedAt: body.resolved ? now : null,
1195
+ updatedAt: now,
1196
+ });
1197
+ }
1198
+ catch (error) {
1199
+ console.error('[stoneforge] Failed to resolve comment:', error);
1200
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to resolve comment' } }, 500);
1201
+ }
1202
+ });
1203
+ return app;
1204
+ }
1205
+ //# sourceMappingURL=documents.js.map