@lincyaw/workspace 1.0.0-rc.0 → 1.0.0-rc.2

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 (53) hide show
  1. package/README.md +5 -5
  2. package/package.json +10 -5
  3. package/src/domain/{revisions.ts → commit-message.ts} +7 -7
  4. package/src/domain/locks.ts +5 -0
  5. package/src/domain/paths.ts +2 -2
  6. package/src/index.ts +7 -2
  7. package/src/node/authoring-clients.ts +206 -0
  8. package/src/node/build.ts +4 -4
  9. package/src/node/compile-session.ts +187 -0
  10. package/src/node/config.ts +36 -21
  11. package/src/node/document-assets.ts +30 -0
  12. package/src/node/document-audio.ts +473 -0
  13. package/src/node/document-authoring.ts +436 -0
  14. package/src/node/document-editor.ts +157 -0
  15. package/src/node/document-history.ts +190 -0
  16. package/src/node/document-images.ts +241 -0
  17. package/src/node/document-operations.ts +191 -0
  18. package/src/node/document-transcription-adoption.ts +96 -0
  19. package/src/node/document-transcription.ts +471 -0
  20. package/src/node/document-voice.ts +115 -0
  21. package/src/node/errors.ts +23 -0
  22. package/src/node/git.ts +15 -15
  23. package/src/node/jobs.ts +361 -0
  24. package/src/node/locks.ts +10 -5
  25. package/src/node/page-delete.ts +106 -0
  26. package/src/node/project-files.ts +163 -0
  27. package/src/node/projects.ts +192 -0
  28. package/src/node/review/agent-session-reviews.ts +374 -0
  29. package/src/node/review/git-guard.ts +9 -8
  30. package/src/node/review/git-review.ts +102 -90
  31. package/src/node/review/record.ts +77 -0
  32. package/src/node/review/routes.ts +36 -35
  33. package/src/node/review/task-reviews.ts +50 -0
  34. package/src/node/revisions.ts +321 -68
  35. package/src/node/routes.ts +236 -146
  36. package/src/node/serve.ts +319 -118
  37. package/src/node/site.ts +34 -23
  38. package/src/node/source-editor.ts +332 -0
  39. package/src/node/speech-settings.ts +21 -0
  40. package/src/node/templates.ts +27 -0
  41. package/src/node/workbench-api.ts +176 -0
  42. package/src/node/writer.ts +389 -0
  43. package/templates/document/default/lecture.json +75 -0
  44. package/src/domain/api.ts +0 -102
  45. package/src/domain/index.ts +0 -6
  46. package/src/node/documents.ts +0 -185
  47. package/src/node/files.ts +0 -252
  48. package/src/node/review/review-record.ts +0 -73
  49. package/src/node/review/reviews.ts +0 -422
  50. package/src/node/service.ts +0 -237
  51. package/src/node/session.ts +0 -123
  52. package/src/node/source.ts +0 -148
  53. package/templates/document/default/lecture.md +0 -18
@@ -0,0 +1,436 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { applyDocumentOperations, AUTHORING_API_VERSION, createDocumentPage, DOCUMENT_ENTRY, DOCUMENT_OPERATION_TYPES, DOCUMENT_READ_MAX_REQUEST_CHARS, DOCUMENT_READ_MAX_RESPONSE_CHARS, DOCUMENT_SCHEMA_VERSION, documentOutline, documentPart, DocumentReadError, duplicateDocumentPage, fnv1a64, hasRole, moveDocumentPage, pageDeletionImpact, removeDocumentPage, renameDocumentPage, validateDocument } from '@lincyaw/kernel'
3
+ import { validateDocumentChange, validateDocumentPageChange, validateDocumentPageChangeApply, validateDocumentPageDuplication, validateDocumentPageDuplicationApply, validateDocumentOutlineQuery, validateDocumentPartQuery } from '@lincyaw/kernel/node'
4
+ import type { AppliedDocumentChange, ApplyDocumentChange, CanonicalSource, DocumentAnswer, DocumentAuthoring, DocumentChange, DocumentChangeAnswer, DocumentChangePlan, DocumentIssue, DocumentPageAction, DocumentPageChangePlan, DocumentPageDuplicationPlan, Member, RequestContext, SourceTransactionChange, TeachingDocument } from '@lincyaw/kernel'
5
+ import { RequestError } from './errors.ts'
6
+ import { applyDocumentAuthoringAction, DOCUMENT_ACTION_MAX_REQUEST_CHARS, DOCUMENT_ACTION_MAX_RESPONSE_CHARS, DOCUMENT_AUTHORING_ACTION_TYPES, DOCUMENT_PARAMETER_MAX_CHANGES, DOCUMENT_PARAMETER_MAX_PATH_DEPTH, DocumentCommentError, DocumentComponentError, DocumentCaptionTimingError, ProductionAudioRefusal } from '@lincyaw/kernel'
7
+ import { validateDocumentAuthoringAction, validateDocumentAuthoringActionReference, validateDocumentAuthoringActionImpact } from '@lincyaw/kernel/node'
8
+ import type { AppliedDocumentAuthoringAction, DocumentAuthoringActionReference, DocumentPageChangeReference, DocumentPageDuplicationReference, DocumentAuthoringActionPlan, DocumentComponentImpactAnswer, DocumentAuthoringAction, ProductionPlan } from '@lincyaw/kernel'
9
+ import { MAX_SOURCE, MAX_SOURCE_CHANGES } from './source-editor.ts'
10
+ import type { SourceEditor, SourceTransactionHistory } from './source-editor.ts'
11
+ import type { Revisions } from './revisions.ts'
12
+
13
+ const PLAN_LIFETIME_MS = 5 * 60_000
14
+ const MAX_RECEIPTS = 64
15
+ const entryOf = (directory: string) => directory ? `${directory}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY
16
+ const signatureOf = (change: DocumentChange) => createHash('sha256').update(JSON.stringify(change)).digest('hex')
17
+ const keyOf = (document: string, member: Member, id: string) => JSON.stringify([document, member.id, id])
18
+ const sourceOf = (answer: DocumentAnswer) => ({ documentId: answer.content.id, version: answer.version, fingerprint: answer.fingerprint })
19
+
20
+ export function decodeAuthoringDocument(content: string): { document?: TeachingDocument, issues: DocumentIssue[] } {
21
+ let value: unknown
22
+ try { value = JSON.parse(content.replace(/^\uFEFF/, '')) }
23
+ catch { return { issues: [{ path: '', code: 'json-syntax', message: 'The document is not valid JSON.', severity: 'error' }] } }
24
+ return validateDocument(value)
25
+ }
26
+
27
+ export function readAuthoringDocument(editor: SourceEditor, ctx: Pick<RequestContext, 'document' | 'member'>) {
28
+ if (!hasRole(ctx.member, 'editor')) throw new RequestError(403, 'authoring-permission-denied')
29
+ const source = editor.read(editor.scope(ctx.document), entryOf(ctx.document), ctx.member)
30
+ const checked = decodeAuthoringDocument(source.content)
31
+ if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) throw new RequestError(422, 'document-invalid', { issues: checked.issues })
32
+ return { source, content: checked.document, issues: checked.issues }
33
+ }
34
+
35
+ export interface DocumentEditor extends DocumentAuthoring {
36
+ /** Direct author saves retain the HTTP transaction boundary; agents apply a reviewed preflight. */
37
+ change(document: string, change: DocumentChange, member: Member): Promise<DocumentChangeAnswer>
38
+ /** Only the authenticated editor HTTP adapter selects this origin; no request field can select it. */
39
+ applyEditorAction(document: string, reference: DocumentAuthoringActionReference, member: Member): Promise<AppliedDocumentAuthoringAction>
40
+ }
41
+
42
+ export function createDocumentEditor({ editor, revisions, reviewRequired = false, productionPlan }: { editor: SourceEditor, revisions?: Revisions, reviewRequired?: boolean, productionPlan?: (document: string, source: CanonicalSource) => Promise<ProductionPlan> }): DocumentEditor {
43
+ const validateCaption = async (document: string, source: CanonicalSource, action: DocumentAuthoringAction) => {
44
+ if (action.type !== 'caption.timing.set') return
45
+ if (!productionPlan) throw new RequestError(503, 'caption-production-unavailable')
46
+ let plan: ProductionPlan
47
+ try { plan = await productionPlan(document, source) }
48
+ catch (error) { if (error instanceof ProductionAudioRefusal) throw new RequestError(error.status, error.code); throw error }
49
+ const basis = plan.pages.flatMap(page => page.steps).find(step => step.stepId === action.stepId)?.captionTiming
50
+ if (!basis?.available) throw new RequestError(422, basis?.reason ?? 'caption-timing-unavailable')
51
+ if (JSON.stringify(plan.binding.source) !== JSON.stringify(source) || basis.sourceFingerprint !== action.value.sourceFingerprint || basis.playbackFingerprint !== action.value.playbackFingerprint || basis.durationMs !== action.value.durationMs || JSON.stringify(basis.narrationIds) !== JSON.stringify(action.value.narrationIds)) throw new RequestError(409, 'caption-playback-changed')
52
+ }
53
+ const receipts = new Map<string, { signature: string, version: string, files: DocumentChangeAnswer['files'], commit?: DocumentChangeAnswer['commit'] }>()
54
+ type Plan = { key: string, signature: string, expires: number, ready: boolean, duplication?: { signature: string, result: Promise<DocumentPageDuplicationPlan> }, page?: { signature: string, result: Promise<DocumentPageChangePlan> }, action?: { signature: string, result: Promise<DocumentAuthoringActionPlan> } }
55
+ const plans = new Map<string, Plan>()
56
+ const remember = <T>(table: Map<string, T>, key: string, value: T) => {
57
+ table.set(key, value)
58
+ while (table.size > MAX_RECEIPTS) table.delete(table.keys().next().value!)
59
+ }
60
+ const read = async (document: string, member: Member): Promise<DocumentAnswer> => {
61
+ const { source, content, issues } = readAuthoringDocument(editor, { document, member })
62
+ return { document, version: source.version, fingerprint: fnv1a64(source.content), content, issues }
63
+ }
64
+ const readSelection = <T>(document: string, expected: CanonicalSource | undefined, member: Member, select: (content: TeachingDocument, source: CanonicalSource) => T): T => {
65
+ if (!hasRole(member, 'editor')) throw new RequestError(403, 'authoring-permission-denied')
66
+ const scope = editor.scope(document), path = entryOf(document)
67
+ const readSource = () => {
68
+ try { return editor.read(scope, path, member) }
69
+ catch (error) {
70
+ if (expected && error instanceof RequestError && error.status === 413) throw new RequestError(409, 'document-version-changed')
71
+ throw error
72
+ }
73
+ }
74
+ const raw = readSource(), fingerprint = fnv1a64(raw.content)
75
+ // Compare raw bytes first: a changed source that has become invalid is still a stale read.
76
+ if (expected && (expected.version !== raw.version || expected.fingerprint !== fingerprint)) throw new RequestError(409, 'document-version-changed', { version: raw.version, fingerprint })
77
+ const checked = decodeAuthoringDocument(raw.content)
78
+ if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) throw new RequestError(422, 'document-invalid', {
79
+ issueCount: checked.issues.length,
80
+ issues: checked.issues.slice(0, 3).map(issue => ({ code: issue.code.slice(0, 64), path: issue.path.slice(0, 100), message: issue.message.slice(0, 120), severity: issue.severity })),
81
+ })
82
+ const source: CanonicalSource = { documentId: checked.document.id, version: raw.version, fingerprint }
83
+ if (expected && expected.documentId !== source.documentId) throw new RequestError(409, 'document-version-changed', { source })
84
+ let answer: T
85
+ try { answer = select(checked.document, source) }
86
+ catch (error) { if (error instanceof DocumentReadError) throw new RequestError(error.status, error.message); throw error }
87
+ const after = readSource()
88
+ if (after.version !== raw.version || after.content !== raw.content) throw new RequestError(409, 'document-version-changed', { version: after.version, fingerprint: fnv1a64(after.content) })
89
+ return answer
90
+ }
91
+ const checkedChange = (raw: unknown): DocumentChange => {
92
+ const checked = validateDocumentChange(raw)
93
+ if (!checked.change) throw new RequestError(400, 'document-change-invalid', { issues: checked.issues })
94
+ return checked.change
95
+ }
96
+ const filesOf = async (document: string, current: DocumentAnswer, change: DocumentChange): Promise<{ content: TeachingDocument, issues: DocumentIssue[], changes: SourceTransactionChange[] }> => {
97
+ if (current.version !== change.baseDocumentVersion) throw new RequestError(409, 'document-version-changed', { version: current.version })
98
+ let candidate: unknown = change.replacement
99
+ if (change.operations) {
100
+ try { candidate = applyDocumentOperations(current.content, change.operations) }
101
+ catch (error) { throw new RequestError(422, 'document-operation-invalid', { reason: error instanceof Error ? error.message : 'invalid-operation' }) }
102
+ }
103
+ const checked = validateDocument(candidate)
104
+ if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) throw new RequestError(422, 'document-invalid', { issues: checked.issues })
105
+ if (checked.document.id !== current.content.id) throw new RequestError(422, 'document-identity-changed')
106
+ const path = entryOf(document), fileChanges = change.fileChanges ?? []
107
+ if (fileChanges.some(file => file.path === path)) throw new RequestError(422, 'document-file-repeated')
108
+ const changes = [{ path, content: `${JSON.stringify(checked.document, null, 2)}\n`, base: current.version }, ...fileChanges]
109
+ if (revisions) {
110
+ const ignored = (await Promise.all(changes.map(async file => await revisions.ignores(file.path) ? file.path : undefined))).filter((path): path is string => Boolean(path))
111
+ if (ignored.length) throw new RequestError(409, 'document-files-ignored', { ignored })
112
+ }
113
+ return { content: checked.document, issues: checked.issues, changes }
114
+ }
115
+ /** The receipt this exact change replays while its saved files are still current. */
116
+ const replayOf = (document: string, change: DocumentChange, member: Member, current: DocumentAnswer) => {
117
+ const receipt = receipts.get(keyOf(document, member, change.id))
118
+ if (!receipt) return undefined
119
+ if (receipt.signature !== signatureOf(change)) throw new RequestError(409, 'document-change-id-reused')
120
+ if (receipt.version !== current.version) throw new RequestError(409, 'document-version-changed', { version: current.version })
121
+ if (receipt.files.some(file => editor.read(editor.scope(document), file.path, member).version !== file.version)) throw new RequestError(409, 'document-files-changed')
122
+ return receipt
123
+ }
124
+ const checkSave = async (document: string, change: DocumentChange, member: Member) => {
125
+ const current = await read(document, member)
126
+ if (replayOf(document, change, member, current)) return
127
+ const candidate = await filesOf(document, current, change)
128
+ await editor.planTransaction(editor.scope(document), candidate.changes, member)
129
+ }
130
+ const saveChange = async (document: string, raw: DocumentChange, member: Member, origin: 'editor' | 'agent' = 'editor'): Promise<DocumentChangeAnswer> => {
131
+ const change = checkedChange(raw), current = await read(document, member)
132
+ const signature = signatureOf(change), key = keyOf(document, member, change.id), receipt = replayOf(document, change, member, current)
133
+ if (receipt) return { ...current, changeId: change.id, files: receipt.files, ...receipt.commit ? { commit: receipt.commit } : {} }
134
+ const candidate = await filesOf(document, current, change)
135
+ const ownHistory: SourceTransactionHistory | undefined = revisions ? { run: work => revisions.transaction(work), commit: (paths, lang) => revisions.commitWhileLocked({ paths, summary: lang?.startsWith('en') ? 'Edit document' : '编辑文稿', body: '', origin: { kind: origin, member } }) } : undefined
136
+ const saved = await editor.transaction(editor.scope(document), candidate.changes, member, ownHistory)
137
+ const version = saved.versions.find(file => file.path === entryOf(document))!.version
138
+ remember(receipts, key, { signature, version, files: saved.versions, ...saved.revision ? { commit: saved.revision } : {} })
139
+ const verified = await read(document, member)
140
+ if (verified.version !== version || saved.versions.some(file => editor.read(editor.scope(document), file.path, member).version !== file.version)) throw new RequestError(409, 'document-readback-changed', { changeId: change.id, committed: true, version, currentVersion: verified.version })
141
+ return { ...verified, changeId: change.id, files: saved.versions, ...saved.revision ? { commit: saved.revision } : {} }
142
+ }
143
+ const planChange = async (document: string, raw: DocumentChange, member: Member, reserved?: { id: string, record: Plan }): Promise<DocumentChangePlan> => {
144
+ const change = checkedChange(raw), current = await read(document, member)
145
+ const candidate = await filesOf(document, current, change)
146
+ const planned = await editor.planTransaction(editor.scope(document), candidate.changes, member)
147
+ const impact: DocumentChangePlan['impact'] = { sections: [], blocks: [], steps: [], narrations: [], assets: [], definitions: [], sectionOrder: false, production: false, comments: false, metadata: false }
148
+ for (const field of ['sections', 'blocks', 'steps', 'narrations', 'assets', 'definitions'] as const) impact[field] = [...new Set([...Object.keys(current.content[field]), ...Object.keys(candidate.content[field])])].filter(id => JSON.stringify(current.content[field][id]) !== JSON.stringify(candidate.content[field][id]))
149
+ for (const field of ['sectionOrder', 'production', 'comments', 'metadata'] as const) impact[field] = JSON.stringify(current.content[field]) !== JSON.stringify(candidate.content[field])
150
+ const planId = reserved?.id ?? randomUUID(), digest = signatureOf(change), expires = reserved?.record.expires ?? Date.now() + PLAN_LIFETIME_MS
151
+ const record: Plan = reserved?.record ?? { key: keyOf(document, member, change.id), signature: digest, expires, ready: false }
152
+ if (reserved && plans.get(planId) !== record) throw new RequestError(409, 'document-plan-changed')
153
+ record.ready = true
154
+ remember(plans, planId, record)
155
+ return { apiVersion: AUTHORING_API_VERSION, planId, changeId: change.id, document, source: sourceOf(current), digest, expiresAt: new Date(expires).toISOString(), content: candidate.content, issues: candidate.issues, files: planned.versions.map(file => ({ ...file, base: candidate.changes.find(change => change.path === file.path)!.base })), impact }
156
+ }
157
+ const duplicationOf = async (document: string, planId: string, digest: string, member: Member, replay = false): Promise<DocumentPageDuplicationPlan> => {
158
+ if (!hasRole(member, 'editor')) throw new RequestError(403, 'authoring-permission-denied')
159
+ const record = plans.get(planId)
160
+ if (!record?.ready || !record.duplication) throw new RequestError(409, 'document-plan-changed')
161
+ const plan = await record.duplication.result
162
+ if (record.key !== keyOf(document, member, plan.changeId) || record.signature !== digest || plan.digest !== digest) throw new RequestError(409, 'document-plan-changed')
163
+ if (record.expires < Date.now() && !(replay && receipts.get(record.key)?.signature === digest)) throw new RequestError(409, 'document-plan-expired')
164
+ return plan
165
+ }
166
+ const pageOf = async (document: string, planId: string, digest: string, member: Member, replay = false): Promise<DocumentPageChangePlan> => {
167
+ if (!hasRole(member, 'editor')) throw new RequestError(403, 'authoring-permission-denied')
168
+ const record = plans.get(planId)
169
+ if (!record?.ready || !record.page) throw new RequestError(409, 'document-plan-changed')
170
+ const plan = await record.page.result
171
+ if (record.key !== keyOf(document, member, plan.changeId) || record.signature !== digest || plan.digest !== digest) throw new RequestError(409, 'document-plan-changed')
172
+ if (record.expires < Date.now() && !(replay && receipts.get(record.key)?.signature === digest)) throw new RequestError(409, 'document-plan-expired')
173
+ return plan
174
+ }
175
+ const actionOf = async (document: string, planId: string, digest: string, member: Member, replay = false): Promise<DocumentAuthoringActionPlan> => {
176
+ if (!hasRole(member, 'editor')) throw new RequestError(403, 'authoring-permission-denied')
177
+ const record = plans.get(planId)
178
+ if (!record?.ready || !record.action) throw new RequestError(409, 'document-plan-family-changed')
179
+ const plan = await record.action.result
180
+ if (record.key !== keyOf(document, member, plan.changeId) || record.signature !== digest || plan.digest !== digest) throw new RequestError(409, 'document-plan-changed')
181
+ if (record.expires < Date.now() && !(replay && receipts.get(record.key)?.signature === digest)) throw new RequestError(409, 'document-plan-expired')
182
+ return plan
183
+ }
184
+ const plannedChange = (document: string, request: ApplyDocumentChange, member: Member, family: 'generic' | 'page' | 'duplication' | 'action', origin: 'agent' | 'editor'): DocumentChange => {
185
+ if (reviewRequired && origin === 'agent') throw new RequestError(409, 'authoring-review-required')
186
+ const change = checkedChange(request.change), key = keyOf(document, member, change.id), plan = plans.get(request.planId)
187
+ if (!plan?.ready || plan.key !== key || plan.signature !== signatureOf(change)) throw new RequestError(409, 'document-plan-changed')
188
+ const actual = plan.action ? 'action' : plan.page ? 'page' : plan.duplication ? 'duplication' : 'generic'
189
+ if (actual !== family) throw new RequestError(409, 'document-plan-family-changed')
190
+ if (plan.expires < Date.now() && receipts.get(key)?.signature !== plan.signature) throw new RequestError(409, 'document-plan-expired')
191
+ return change
192
+ }
193
+ const applyPlannedChange = async (document: string, request: ApplyDocumentChange, member: Member, family: 'generic' | 'page' | 'duplication' | 'action', origin: 'agent' | 'editor' = 'agent'): Promise<AppliedDocumentChange> => {
194
+ const answer = await saveChange(document, plannedChange(document, request, member, family, origin), member, origin)
195
+ return { ...answer, verification: { saved: true, source: sourceOf(answer), compiled: 'not-requested', runtime: 'not-requested' } }
196
+ }
197
+ const actionPreflight = async (document: string, reference: DocumentAuthoringActionReference, member: Member, origin: 'agent' | 'editor') => {
198
+ if (reviewRequired && origin === 'agent') throw new RequestError(409, 'authoring-review-required')
199
+ if (!validateDocumentAuthoringActionReference(reference)) throw new RequestError(400, 'authoring-action-reference-invalid')
200
+ const plan = await actionOf(document, reference.planId, reference.digest, member, true)
201
+ if (reference.source.documentId !== plan.source.documentId || reference.source.version !== plan.source.version || reference.source.fingerprint !== plan.source.fingerprint) throw new RequestError(409, 'document-plan-changed')
202
+ if (plan.action.type === 'comment.delete' && !reference.confirmDeletion) throw new RequestError(409, 'comment-deletion-confirmation-required', { planId: plan.planId, digest: plan.digest, next: 'Review this exact discussion deletion, then retry its reference with confirmDeletion:true.' })
203
+ if (plan.action.type !== 'comment.delete' && reference.confirmDeletion) throw new RequestError(400, 'comment-deletion-confirmation-unexpected')
204
+ const expected = plan.files.find(file => file.path === entryOf(document))!.version, receipt = receipts.get(keyOf(document, member, plan.changeId))
205
+ if (receipt && receipt.signature !== plan.digest) throw new RequestError(409, 'document-change-id-reused')
206
+ readSelection(document, receipt ? { documentId: plan.content.id, version: expected, fingerprint: fnv1a64(`${JSON.stringify(plan.content, null, 2)}\n`) } : plan.source, member, () => undefined)
207
+ if (!receipt) await validateCaption(document, plan.source, plan.action)
208
+ return { plan, expected }
209
+ }
210
+ const applyAction = async (document: string, reference: DocumentAuthoringActionReference, member: Member, origin: 'agent' | 'editor'): Promise<AppliedDocumentAuthoringAction> => {
211
+ const { plan, expected } = await actionPreflight(document, reference, member, origin)
212
+ const answer = await applyPlannedChange(document, { change: plan.change, planId: plan.planId }, member, 'action', origin)
213
+ if (answer.version !== expected || JSON.stringify(answer.content) !== JSON.stringify(plan.content)) throw new RequestError(409, 'document-readback-changed', { committed: true, changeId: plan.changeId, version: answer.version })
214
+ const { content: _content, issues: _issues, commit, ...saved } = answer
215
+ return { ...saved, ...commit ? { commit: { id: commit.id, origin: commit.origin.kind } } : {}, planId: plan.planId, digest: plan.digest, action: plan.action.type, effect: plan.effect,
216
+ verification: { ...answer.verification, plannedVersion: expected, actionStateVerified: true } }
217
+ }
218
+ const duplicationPreflight = async (document: string, reference: DocumentPageDuplicationReference, member: Member) => {
219
+ if (reviewRequired) throw new RequestError(409, 'authoring-review-required')
220
+ if (!validateDocumentPageDuplicationApply(reference)) throw new RequestError(400, 'page-duplication-reference-invalid')
221
+ const plan = await duplicationOf(document, reference.planId, reference.digest, member, true)
222
+ if (reference.source.documentId !== plan.source.documentId || reference.source.version !== plan.source.version || reference.source.fingerprint !== plan.source.fingerprint) throw new RequestError(409, 'document-plan-changed')
223
+ return plan
224
+ }
225
+ const pagePreflight = async (document: string, reference: DocumentPageChangeReference, member: Member) => {
226
+ if (reviewRequired) throw new RequestError(409, 'authoring-review-required')
227
+ if (!validateDocumentPageChangeApply(reference)) throw new RequestError(400, 'page-change-reference-invalid')
228
+ const plan = await pageOf(document, reference.planId, reference.digest, member, true)
229
+ if (reference.source.documentId !== plan.source.documentId || reference.source.version !== plan.source.version || reference.source.fingerprint !== plan.source.fingerprint) throw new RequestError(409, 'document-plan-changed')
230
+ if (plan.action.type === 'delete' && reference.confirmDeletion !== true) throw new RequestError(409, 'page-deletion-confirmation-required', { planId: plan.planId, digest: plan.digest, next: 'Review this plan deletion impact and repeat the same reference with confirmDeletion:true.' })
231
+ if (plan.action.type !== 'delete' && reference.confirmDeletion) throw new RequestError(400, 'page-deletion-confirmation-unexpected')
232
+ const expected = plan.files.find(file => file.path === entryOf(document))!.version, receipt = receipts.get(keyOf(document, member, plan.changeId))
233
+ if (receipt && receipt.signature !== plan.digest) throw new RequestError(409, 'document-change-id-reused')
234
+ readSelection(document, receipt ? { documentId: plan.content.id, version: expected, fingerprint: fnv1a64(`${JSON.stringify(plan.content, null, 2)}\n`) } : plan.source, member, () => undefined)
235
+ return { plan, expected }
236
+ }
237
+ const service: DocumentEditor = {
238
+ capabilities(_document, member) {
239
+ const allowed = hasRole(member, 'editor')
240
+ return { apiVersion: AUTHORING_API_VERSION, documentSchemaVersion: DOCUMENT_SCHEMA_VERSION, available: allowed, ...allowed ? {} : { reason: 'permission-denied' as const }, actions: { read: allowed, outline: allowed, readPart: allowed, plan: allowed, apply: allowed && !reviewRequired, planPageDuplication: allowed, applyPageDuplication: allowed && !reviewRequired, planPageChange: allowed, applyPageChange: allowed && !reviewRequired, planAction: allowed, applyAction: allowed && !reviewRequired }, readLimits: { maxRequestChars: DOCUMENT_READ_MAX_REQUEST_CHARS, maxResponseChars: DOCUMENT_READ_MAX_RESPONSE_CHARS, responseSerialization: 'JSON.stringify(value,null,2)', offsetUnit: 'utf16' }, ...reviewRequired ? { applyUnavailableReason: 'review-required' as const } : {}, pageLimits: { maxRequestChars: DOCUMENT_READ_MAX_REQUEST_CHARS, maxResponseChars: DOCUMENT_READ_MAX_RESPONSE_CHARS, responseSerialization: 'JSON.stringify(value,null,2)' }, semanticActions: DOCUMENT_AUTHORING_ACTION_TYPES, components: { parameters: true, stepParameters: true, restoreStepParameters: true, useExistingDefinition: true, impactRead: true, maxChanges: DOCUMENT_PARAMETER_MAX_CHANGES, maxPathDepth: DOCUMENT_PARAMETER_MAX_PATH_DEPTH, independentCopy: { available: false, reason: 'implementation-closure-unavailable' } }, actionLimits: { maxRequestChars: DOCUMENT_ACTION_MAX_REQUEST_CHARS, maxResponseChars: DOCUMENT_ACTION_MAX_RESPONSE_CHARS, responseSerialization: 'JSON.stringify(value,null,2)' }, operations: DOCUMENT_OPERATION_TYPES, replacement: true, fileChanges: true, limits: { maxFiles: MAX_SOURCE_CHANGES, maxTextBytes: MAX_SOURCE, maxOperations: 1000, planLifetimeMs: PLAN_LIFETIME_MS, maxPlans: MAX_RECEIPTS, maxReceipts: MAX_RECEIPTS }, idempotency: 'process-current-source', review: reviewRequired ? 'required-unsupported' : revisions ? 'locks-and-history' : 'locks-only' }
241
+ },
242
+ read,
243
+ async outline(document, query, member) {
244
+ if (!validateDocumentOutlineQuery(query)) throw new RequestError(400, 'document-outline-query-invalid')
245
+ return readSelection(document, query.source, member, (content, source) => documentOutline(content, source, query))
246
+ },
247
+ async readPart(document, query, member) {
248
+ if (!validateDocumentPartQuery(query)) throw new RequestError(400, 'document-part-query-invalid')
249
+ return readSelection(document, query.source, member, (content, source) => documentPart(content, source, query))
250
+ },
251
+ change: saveChange,
252
+ plan: planChange,
253
+ async planPageDuplication(document, raw, member) {
254
+ if (!validateDocumentPageDuplication(raw)) throw new RequestError(400, 'page-duplication-invalid')
255
+ const request = { id: raw.id, source: { documentId: raw.source.documentId, version: raw.source.version, fingerprint: raw.source.fingerprint }, sectionId: raw.sectionId, title: raw.title }
256
+ const current = await read(document, member), source = sourceOf(current)
257
+ if (source.documentId !== request.source.documentId || source.version !== request.source.version || source.fingerprint !== request.source.fingerprint) throw new RequestError(409, 'document-version-changed', { source })
258
+ const key = keyOf(document, member, request.id), signature = createHash('sha256').update(JSON.stringify(request)).digest('hex'), receipt = receipts.get(key)
259
+ if (receipt) throw new RequestError(409, receipt.version === current.version ? 'document-change-id-reused' : 'document-version-changed', { version: current.version })
260
+ for (const plan of plans.values()) if (plan.key === key && plan.duplication) {
261
+ if (plan.duplication.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
262
+ if (plan.expires < Date.now()) throw new RequestError(409, 'document-plan-expired')
263
+ return structuredClone(await plan.duplication.result)
264
+ }
265
+ const planId = randomUUID(), record: Plan = { key, signature: '', expires: Date.now() + PLAN_LIFETIME_MS, ready: false }
266
+ const result = (async (): Promise<DocumentPageDuplicationPlan> => {
267
+ let copied
268
+ try { copied = duplicateDocumentPage(current.content, request.sectionId, request.title, () => randomUUID()) }
269
+ catch (error) { throw new RequestError(422, 'page-duplication-invalid', { reason: error instanceof Error ? error.message : 'invalid-page' }) }
270
+ const change: DocumentChange = { id: request.id, baseDocumentVersion: source.version, replacement: copied.document }
271
+ record.signature = signatureOf(change)
272
+ const plan = await planChange(document, change, member, { id: planId, record })
273
+ return { ...plan, action: 'duplicatePage', section: { from: request.sectionId, to: copied.section }, mapping: copied.mapping, change }
274
+ })()
275
+ record.duplication = { signature, result }; remember(plans, planId, record)
276
+ try { return structuredClone(await result) }
277
+ catch (error) { if (plans.get(planId) === record) plans.delete(planId); throw error }
278
+ },
279
+ async readPageDuplicationPlan(document, reference, member) {
280
+ const plan = await duplicationOf(document, reference.planId, reference.digest, member)
281
+ const current = await read(document, member), currentSource = sourceOf(current), receipt = receipts.get(keyOf(document, member, plan.changeId))
282
+ const sourceCurrent = plan.source.documentId === currentSource.documentId && plan.source.version === currentSource.version && plan.source.fingerprint === currentSource.fingerprint
283
+ return { plan: structuredClone(plan), currentSource, state: receipt ? receipt.signature === plan.digest && receipt.version === current.version ? 'applied' : 'old' : sourceCurrent ? 'current' : 'old' }
284
+ },
285
+ async checkPageDuplication(document, reference, member) {
286
+ const plan = await duplicationPreflight(document, reference, member)
287
+ await checkSave(document, plannedChange(document, { change: plan.change, planId: plan.planId }, member, 'duplication', 'agent'), member)
288
+ },
289
+ async applyPageDuplication(document, reference, member) {
290
+ const plan = await duplicationPreflight(document, reference, member)
291
+ const answer = await applyPlannedChange(document, { change: plan.change, planId: plan.planId }, member, 'duplication')
292
+ const expected = plan.files.find(file => file.path === entryOf(document))!.version
293
+ if (answer.version !== expected || !answer.content.sections[plan.section.to]) throw new RequestError(409, 'document-readback-changed', { committed: true, changeId: plan.changeId, version: answer.version })
294
+ const { content: _content, issues: _issues, ...receipt } = answer
295
+ return { ...receipt, planId: plan.planId, digest: plan.digest, section: plan.section,
296
+ copied: { identities: Object.keys(plan.mapping).length, sections: plan.impact.sections.length, blocks: plan.impact.blocks.length, steps: plan.impact.steps.length, narrations: plan.impact.narrations.length },
297
+ verification: { ...answer.verification, plannedVersion: expected, pagePresent: true } }
298
+ },
299
+ async planPageChange(document, raw, member) {
300
+ if (!validateDocumentPageChange(raw)) throw new RequestError(400, 'page-change-invalid')
301
+ if (JSON.stringify(raw).length > DOCUMENT_READ_MAX_REQUEST_CHARS) throw new RequestError(413, 'page-change-request-too-large')
302
+ const source = { documentId: raw.source.documentId, version: raw.source.version, fingerprint: raw.source.fingerprint }
303
+ const action: DocumentPageAction = raw.action.type === 'create' ? { type: 'create', title: raw.action.title.trim(), after: raw.action.after ?? null }
304
+ : raw.action.type === 'rename' ? { type: 'rename', sectionId: raw.action.sectionId, title: raw.action.title.trim() }
305
+ : raw.action.type === 'move' ? { type: 'move', sectionId: raw.action.sectionId, before: raw.action.before }
306
+ : { type: 'delete', sectionId: raw.action.sectionId }
307
+ const current = readSelection(document, source, member, content => content)
308
+ if (action.type !== 'create' && !current.sections[action.sectionId] || action.type === 'create' && action.after != null && !current.sections[action.after] || action.type === 'move' && action.before !== null && !current.sections[action.before]) throw new RequestError(404, 'document-page-missing')
309
+ const key = keyOf(document, member, raw.id), signature = createHash('sha256').update(JSON.stringify({ id: raw.id, source, action })).digest('hex'), receipt = receipts.get(key)
310
+ if (receipt) throw new RequestError(409, receipt.version === source.version ? 'document-change-id-reused' : 'document-version-changed')
311
+ for (const known of plans.values()) if (known.key === key) {
312
+ if (!known.page || known.page.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
313
+ if (known.expires < Date.now()) throw new RequestError(409, 'document-plan-expired')
314
+ return structuredClone(await known.page.result)
315
+ }
316
+ const deletion = action.type === 'delete' ? pageDeletionImpact(current, action.sectionId) : undefined
317
+ if (deletion?.references.length) throw new RequestError(409, 'page-referenced', {
318
+ referenceCount: deletion.references.length, references: deletion.references.slice(0, 3), complete: deletion.references.length <= 3,
319
+ next: 'Resolve references in retained narrations or steps, then read the new source and explicitly preflight again. Use read_document_part for the named entities; no references were removed.',
320
+ })
321
+ const planId = randomUUID(), record: Plan = { key, signature: '', expires: Date.now() + PLAN_LIFETIME_MS, ready: false }
322
+ const result = (async (): Promise<DocumentPageChangePlan> => {
323
+ let candidate: TeachingDocument, sectionId: string, created
324
+ try {
325
+ if (action.type === 'create') { const next = createDocumentPage(current, action.after ?? null, action.title, randomUUID); candidate = next.document; created = next.ids; sectionId = next.ids.sectionId }
326
+ else {
327
+ sectionId = action.sectionId
328
+ candidate = action.type === 'rename' ? renameDocumentPage(current, sectionId, action.title)
329
+ : action.type === 'move' ? moveDocumentPage(current, sectionId, action.before) : removeDocumentPage(current, sectionId, randomUUID)
330
+ }
331
+ } catch (error) { throw new RequestError(422, 'page-change-invalid', { reason: error instanceof Error ? error.message : 'invalid-page' }) }
332
+ const change: DocumentChange = { id: raw.id, baseDocumentVersion: source.version, replacement: candidate }
333
+ record.signature = signatureOf(change)
334
+ const plan = await planChange(document, change, member, { id: planId, record })
335
+ return { ...plan, action, sectionId, ...created ? { created } : {}, ...deletion ? { deletion } : {}, change }
336
+ })()
337
+ record.page = { signature, result }; remember(plans, planId, record)
338
+ try { return structuredClone(await result) }
339
+ catch (error) { if (plans.get(planId) === record) plans.delete(planId); throw error }
340
+ },
341
+ async readPageChangePlan(document, reference, member) {
342
+ const plan = await pageOf(document, reference.planId, reference.digest, member)
343
+ const current = await read(document, member), currentSource = sourceOf(current), receipt = receipts.get(keyOf(document, member, plan.changeId))
344
+ const sameSource = plan.source.documentId === currentSource.documentId && plan.source.version === currentSource.version && plan.source.fingerprint === currentSource.fingerprint
345
+ return { plan: structuredClone(plan), currentSource, state: receipt ? receipt.signature === plan.digest && receipt.version === current.version ? 'applied' : 'old' : sameSource ? 'current' : 'old' }
346
+ },
347
+ async checkPageChange(document, reference, member) {
348
+ const { plan } = await pagePreflight(document, reference, member)
349
+ await checkSave(document, plannedChange(document, { change: plan.change, planId: plan.planId }, member, 'page', 'agent'), member)
350
+ },
351
+ async applyPageChange(document, reference, member) {
352
+ const { plan, expected } = await pagePreflight(document, reference, member)
353
+ const answer = await applyPlannedChange(document, { change: plan.change, planId: plan.planId }, member, 'page')
354
+ if (answer.version !== expected || JSON.stringify(answer.content) !== JSON.stringify(plan.content)) throw new RequestError(409, 'document-readback-changed', { committed: true, changeId: plan.changeId, version: answer.version })
355
+ const { content: _content, issues: _issues, commit, ...saved } = answer
356
+ return { ...saved, planId: plan.planId, digest: plan.digest, action: plan.action.type, sectionId: plan.sectionId, retainedEmpty: Boolean(plan.deletion?.retainedEmpty),
357
+ ...commit ? { commit: { id: commit.id, origin: commit.origin.kind } } : {},
358
+ changed: plan.impact.sectionOrder || Object.values(plan.impact).some(value => Array.isArray(value) ? value.length > 0 : value),
359
+ impact: Object.fromEntries(Object.entries(plan.impact).map(([key, value]) => [key, Array.isArray(value) ? value.length : value])) as Record<keyof DocumentChangePlan['impact'], number | boolean>,
360
+ verification: { ...answer.verification, plannedVersion: expected, pageStateVerified: true } }
361
+ },
362
+ async planAction(document, raw, member) {
363
+ if (!validateDocumentAuthoringAction(raw)) throw new RequestError(400, 'authoring-action-invalid')
364
+ if (JSON.stringify(raw).length > DOCUMENT_ACTION_MAX_REQUEST_CHARS) throw new RequestError(413, 'authoring-action-request-too-large', { limits: { maxRequestChars: DOCUMENT_ACTION_MAX_REQUEST_CHARS }, next: 'Submit only changed parameter fields. Retained values do not belong in the request; keep the local draft when an edited value exceeds this limit.' })
365
+ const source = { documentId: raw.source.documentId, version: raw.source.version, fingerprint: raw.source.fingerprint }
366
+ const current = readSelection(document, source, member, content => content), action = structuredClone(raw.action), key = keyOf(document, member, raw.id)
367
+ const signature = createHash('sha256').update(JSON.stringify({ id: raw.id, source, action }, (_, value) => value && typeof value === 'object' && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value)).digest('hex')
368
+ const receipt = receipts.get(key)
369
+ if (receipt) throw new RequestError(409, receipt.version === source.version ? 'document-change-id-reused' : 'document-version-changed')
370
+ for (const known of plans.values()) if (known.key === key) {
371
+ if (!known.action || known.action.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
372
+ if (known.expires < Date.now()) throw new RequestError(409, 'document-plan-expired')
373
+ return structuredClone(await known.action.result)
374
+ }
375
+ const planId = randomUUID(), record: Plan = { key, signature: '', expires: Date.now() + PLAN_LIFETIME_MS, ready: false }
376
+ // Reserve before any asynchronous materialization: this intent owns one set of IDs and dates.
377
+ const result = Promise.resolve().then(async (): Promise<DocumentAuthoringActionPlan> => {
378
+ if (plans.get(planId) !== record) throw new RequestError(409, 'document-plan-expired')
379
+ await validateCaption(document, source, action)
380
+ let materialized: ReturnType<typeof applyDocumentAuthoringAction>
381
+ try { materialized = applyDocumentAuthoringAction(current, action, { member, now: new Date().toISOString(), allocate: randomUUID, allocateContentIds: true }) }
382
+ catch (error) {
383
+ if (error instanceof DocumentCommentError) throw new RequestError(error.status, error.message)
384
+ if (error instanceof DocumentCaptionTimingError) throw new RequestError(error.status, error.message)
385
+ if (error instanceof DocumentComponentError) throw new RequestError(error.status, error.message, error.detail)
386
+ throw new RequestError(422, 'authoring-action-invalid', { reason: error instanceof Error ? error.message : 'invalid-action' })
387
+ }
388
+ const change: DocumentChange = { id: raw.id, baseDocumentVersion: source.version, replacement: materialized.content }
389
+ record.signature = signatureOf(change)
390
+ const plan = await planChange(document, change, member, { id: planId, record })
391
+ return { ...plan, action, change, effect: materialized.effect }
392
+ })
393
+ record.action = { signature, result }; remember(plans, planId, record)
394
+ try { return structuredClone(await result) }
395
+ catch (error) { if (plans.get(planId) === record) plans.delete(planId); throw error }
396
+ },
397
+ async readActionPlan(document, reference, member) {
398
+ if (!reference || typeof reference !== 'object' || Object.keys(reference).some(key => key !== 'planId' && key !== 'digest')
399
+ || typeof reference.planId !== 'string' || !reference.planId || reference.planId.length > 200 || typeof reference.digest !== 'string' || !/^[a-f0-9]{64}$/.test(reference.digest)) throw new RequestError(400, 'authoring-action-read-invalid')
400
+ const plan = await actionOf(document, reference.planId, reference.digest, member)
401
+ const current = await read(document, member), currentSource = sourceOf(current), receipt = receipts.get(keyOf(document, member, plan.changeId))
402
+ const sameSource = JSON.stringify(plan.source) === JSON.stringify(currentSource)
403
+ return { plan: structuredClone(plan), currentSource, state: receipt ? receipt.signature === plan.digest && receipt.version === current.version ? 'applied' : 'old' : sameSource ? 'current' : 'old' }
404
+ },
405
+ async readActionImpact(document, query, member) {
406
+ if (!validateDocumentAuthoringActionImpact(query)) throw new RequestError(400, 'authoring-action-impact-invalid')
407
+ const { plan, ...state } = await service.readActionPlan(document, { planId: query.planId, digest: query.digest }, member)
408
+ if (plan.effect.kind !== 'component') throw new RequestError(422, 'authoring-action-impact-unavailable')
409
+ const [group, name] = query.collection.split('.') as ['affected' | 'overrides' | 'references', string]
410
+ const values = (plan.effect.value[group] as unknown as Record<string, string[]>)[name]!
411
+ const offset = query.offset ?? 0
412
+ if (offset > values.length) throw new RequestError(400, 'authoring-action-impact-offset')
413
+ const answer: DocumentComponentImpactAnswer = { planId: plan.planId, digest: plan.digest, source: plan.source, ...state, collection: query.collection, offset, total: values.length, entries: [], complete: offset === values.length }
414
+ for (let index = offset; index < values.length; index++) {
415
+ const next = index + 1, candidate = { ...answer, entries: [...answer.entries, values[index]!], complete: next === values.length, ...next < values.length ? { nextOffset: next } : { nextOffset: undefined } }
416
+ if (JSON.stringify(candidate, null, 2).length > DOCUMENT_ACTION_MAX_RESPONSE_CHARS) break
417
+ Object.assign(answer, candidate)
418
+ }
419
+ if (!answer.entries.length && offset < values.length || JSON.stringify(answer, null, 2).length > DOCUMENT_ACTION_MAX_RESPONSE_CHARS) throw new RequestError(413, 'authoring-action-impact-too-large')
420
+ return answer
421
+ },
422
+ async checkAction(document, reference, member) {
423
+ const { plan } = await actionPreflight(document, reference, member, 'agent')
424
+ await checkSave(document, plannedChange(document, { change: plan.change, planId: plan.planId }, member, 'action', 'agent'), member)
425
+ },
426
+ applyAction: (document, reference, member) => applyAction(document, reference, member, 'agent'),
427
+ applyEditorAction: (document, reference, member) => applyAction(document, reference, member, 'editor'),
428
+ async checkApply(document, request, member) {
429
+ await checkSave(document, plannedChange(document, request, member, 'generic', 'agent'), member)
430
+ },
431
+ async apply(document, request: ApplyDocumentChange, member): Promise<AppliedDocumentChange> {
432
+ return applyPlannedChange(document, request, member, 'generic')
433
+ },
434
+ }
435
+ return service
436
+ }
@@ -0,0 +1,157 @@
1
+ import { DOCUMENT_ENTRY, DOCUMENT_READ_MAX_REQUEST_CHARS, jsonResponse, pushStream, sseResponse } from '@lincyaw/kernel'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { watch } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import type { DocumentChange, DocumentEvent, DocumentOutlineQuery, DocumentPartQuery, DocumentPageChangeRequest, DocumentPageChangeReference, RequestContext, Route } from '@lincyaw/kernel'
6
+ import { catchRequestErrors, RequestError } from './errors.ts'
7
+ import type { SourceEditor } from './source-editor.ts'
8
+ import type { WorkbenchApi } from './workbench-api.ts'
9
+ import { documentAssetResponse } from './document-assets.ts'
10
+ import { decodeAuthoringDocument as decode, readAuthoringDocument } from './document-authoring.ts'
11
+ import type { DocumentEditor } from './document-authoring.ts'
12
+ import type { DocumentAuthoringActionRequest, DocumentAuthoringActionReference } from '@lincyaw/kernel'
13
+
14
+ export { createDocumentEditor, readAuthoringDocument } from './document-authoring.ts'
15
+ export type { DocumentEditor } from './document-authoring.ts'
16
+
17
+ interface Options { root: string, editor: SourceEditor, documentEditor: DocumentEditor, workbench?: WorkbenchApi }
18
+ const entryOf = (directory: string): string => directory ? `${directory}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY
19
+
20
+ /** A document save validates author intent and never waits for a compiled preview. */
21
+ export function documentEditorRoutes({ root, editor, documentEditor, workbench }: Options): Record<string, Route> {
22
+ const opened = (ctx: RequestContext) => readAuthoringDocument(editor, ctx)
23
+ const queryOf = (request: Request): unknown => {
24
+ const query = new URL(request.url).searchParams.get('query') ?? '{}'
25
+ if (query.length > DOCUMENT_READ_MAX_REQUEST_CHARS) throw new RequestError(413, 'document-read-request-too-large')
26
+ try { return JSON.parse(query) } catch { throw new RequestError(400, 'document-read-query-invalid') }
27
+ }
28
+ return {
29
+ 'document/capabilities': {
30
+ role: 'editor',
31
+ handle: (request, ctx) => catchRequestErrors(async () => {
32
+ if (request.method !== 'GET') throw new RequestError(405, 'GET')
33
+ return jsonResponse(documentEditor.capabilities(ctx.document, ctx.member))
34
+ }),
35
+ },
36
+ document: {
37
+ role: 'editor',
38
+ handle: (request, ctx) => catchRequestErrors(async () => {
39
+ if (request.method !== 'GET') throw new RequestError(405, 'GET')
40
+ return jsonResponse(await documentEditor.read(ctx.document, ctx.member))
41
+ }),
42
+ },
43
+ 'document/outline': {
44
+ role: 'editor',
45
+ handle: (request, ctx) => catchRequestErrors(async () => {
46
+ if (request.method !== 'GET') throw new RequestError(405, 'GET')
47
+ return jsonResponse(await documentEditor.outline(ctx.document, queryOf(request) as DocumentOutlineQuery, ctx.member))
48
+ }),
49
+ },
50
+ 'document/part': {
51
+ role: 'editor',
52
+ handle: (request, ctx) => catchRequestErrors(async () => {
53
+ if (request.method !== 'GET') throw new RequestError(405, 'GET')
54
+ return jsonResponse(await documentEditor.readPart(ctx.document, queryOf(request) as DocumentPartQuery, ctx.member))
55
+ }),
56
+ },
57
+ 'document/asset': {
58
+ role: 'editor',
59
+ handle: (request, ctx) => catchRequestErrors(async () => {
60
+ if (request.method !== 'GET') throw new RequestError(405, 'GET')
61
+ const { content } = opened(ctx)
62
+ const id = new URL(request.url).searchParams.get('asset')
63
+ if (!id) throw new RequestError(400, 'document-asset-id-required')
64
+ return documentAssetResponse(root, ctx.document, content, id, path => editor.readableFile(editor.scope(ctx.document), path, ctx.member), request.signal)
65
+ }),
66
+ },
67
+ 'document/events': {
68
+ role: 'editor',
69
+ handle: (request, ctx) => catchRequestErrors(async () => {
70
+ if (request.method !== 'GET') throw new RequestError(405, 'GET')
71
+ // Check the same document permission before observing its directory.
72
+ editor.read(editor.scope(ctx.document), entryOf(ctx.document), ctx.member)
73
+ const stream = pushStream<{ event: string, data: DocumentEvent }>(push => {
74
+ let timer: ReturnType<typeof setTimeout> | undefined, last: string | undefined
75
+ const observation = randomUUID()
76
+ let project = 0
77
+ const emit = () => {
78
+ let event: DocumentEvent
79
+ try {
80
+ const source = editor.read(editor.scope(ctx.document), entryOf(ctx.document), ctx.member), checked = decode(source.content)
81
+ event = { document: ctx.document, version: source.version, valid: Boolean(checked.document) && !checked.issues.some(issue => issue.severity === 'error'), issues: checked.issues }
82
+ } catch {
83
+ event = { document: ctx.document, version: '', valid: false, issues: [{ path: '', code: 'document-unavailable', message: 'The document is unavailable.', severity: 'error' }] }
84
+ }
85
+ event.projectRevision = `${observation}:${project}`
86
+ const signature = JSON.stringify(event)
87
+ if (signature !== last) { last = signature; push({ event: 'document', data: event }) }
88
+ }
89
+ const watcher = watch(join(root, ctx.document), { persistent: false }, (_, name) => {
90
+ if (name !== null && String(name) !== DOCUMENT_ENTRY) return
91
+ clearTimeout(timer)
92
+ timer = setTimeout(emit, 30)
93
+ })
94
+ watcher.on('error', emit)
95
+ const stopProject = workbench?.onCompiled(ctx.document, event => {
96
+ if (!event.files.some(file => file !== entryOf(ctx.document))) return
97
+ project++; clearTimeout(timer); timer = setTimeout(emit, 30)
98
+ })
99
+ emit()
100
+ return () => { clearTimeout(timer); watcher.close(); stopProject?.() }
101
+ }, { signal: request.signal, latestOnly: true })
102
+ return sseResponse(stream, request.signal)
103
+ }),
104
+ },
105
+ 'document/action/plan': {
106
+ role: 'editor', maxBody: 16 << 10,
107
+ handle: (request, ctx) => catchRequestErrors(async () => {
108
+ if (request.method === 'GET') {
109
+ const query = new URL(request.url).searchParams
110
+ return jsonResponse(await documentEditor.readActionPlan(ctx.document, { planId: query.get('planId') ?? '', digest: query.get('digest') ?? '' }, ctx.member))
111
+ }
112
+ if (request.method !== 'POST') throw new RequestError(405, 'POST or GET')
113
+ return jsonResponse(await documentEditor.planAction(ctx.document, await request.json().catch(() => undefined) as DocumentAuthoringActionRequest, ctx.member))
114
+ }),
115
+ },
116
+ 'document/action/impact': {
117
+ role: 'editor',
118
+ handle: (request, ctx) => catchRequestErrors(async () => {
119
+ if (request.method !== 'GET') throw new RequestError(405, 'GET')
120
+ return jsonResponse(await documentEditor.readActionImpact(ctx.document, queryOf(request) as import('@lincyaw/kernel').DocumentComponentImpactQuery, ctx.member))
121
+ }),
122
+ },
123
+ 'document/action/apply': {
124
+ role: 'editor', maxBody: 16 << 10,
125
+ handle: (request, ctx) => catchRequestErrors(async () => {
126
+ if (request.method !== 'POST') throw new RequestError(405, 'POST')
127
+ return jsonResponse(await documentEditor.applyEditorAction(ctx.document, await request.json().catch(() => undefined) as DocumentAuthoringActionReference, ctx.member))
128
+ }),
129
+ },
130
+ 'document/page/plan': {
131
+ role: 'editor', maxBody: 16 << 10,
132
+ handle: (request, ctx) => catchRequestErrors(async () => {
133
+ if (request.method === 'GET') {
134
+ const query = new URL(request.url).searchParams
135
+ return jsonResponse(await documentEditor.readPageChangePlan(ctx.document, { planId: query.get('planId') ?? '', digest: query.get('digest') ?? '' }, ctx.member))
136
+ }
137
+ if (request.method !== 'POST') throw new RequestError(405, 'POST or GET')
138
+ return jsonResponse(await documentEditor.planPageChange(ctx.document, await request.json().catch(() => undefined) as DocumentPageChangeRequest, ctx.member))
139
+ }),
140
+ },
141
+ 'document/page/apply': {
142
+ role: 'editor', maxBody: 16 << 10,
143
+ handle: (request, ctx) => catchRequestErrors(async () => {
144
+ if (request.method !== 'POST') throw new RequestError(405, 'POST')
145
+ return jsonResponse(await documentEditor.applyPageChange(ctx.document, await request.json().catch(() => undefined) as DocumentPageChangeReference, ctx.member))
146
+ }),
147
+ },
148
+ 'document/change': {
149
+ role: 'editor', maxBody: 6 << 20,
150
+ handle: (request, ctx) => catchRequestErrors(async () => {
151
+ if (request.method !== 'POST') throw new RequestError(405, 'POST')
152
+ const raw: unknown = await request.json().catch(() => undefined)
153
+ return jsonResponse(await documentEditor.change(ctx.document, raw as DocumentChange, ctx.member))
154
+ }),
155
+ },
156
+ }
157
+ }