@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.
- package/README.md +5 -5
- package/package.json +10 -5
- package/src/domain/{revisions.ts → commit-message.ts} +7 -7
- package/src/domain/locks.ts +5 -0
- package/src/domain/paths.ts +2 -2
- package/src/index.ts +7 -2
- package/src/node/authoring-clients.ts +206 -0
- package/src/node/build.ts +4 -4
- package/src/node/compile-session.ts +187 -0
- package/src/node/config.ts +36 -21
- package/src/node/document-assets.ts +30 -0
- package/src/node/document-audio.ts +473 -0
- package/src/node/document-authoring.ts +436 -0
- package/src/node/document-editor.ts +157 -0
- package/src/node/document-history.ts +190 -0
- package/src/node/document-images.ts +241 -0
- package/src/node/document-operations.ts +191 -0
- package/src/node/document-transcription-adoption.ts +96 -0
- package/src/node/document-transcription.ts +471 -0
- package/src/node/document-voice.ts +115 -0
- package/src/node/errors.ts +23 -0
- package/src/node/git.ts +15 -15
- package/src/node/jobs.ts +361 -0
- package/src/node/locks.ts +10 -5
- package/src/node/page-delete.ts +106 -0
- package/src/node/project-files.ts +163 -0
- package/src/node/projects.ts +192 -0
- package/src/node/review/agent-session-reviews.ts +374 -0
- package/src/node/review/git-guard.ts +9 -8
- package/src/node/review/git-review.ts +102 -90
- package/src/node/review/record.ts +77 -0
- package/src/node/review/routes.ts +36 -35
- package/src/node/review/task-reviews.ts +50 -0
- package/src/node/revisions.ts +321 -68
- package/src/node/routes.ts +236 -146
- package/src/node/serve.ts +319 -118
- package/src/node/site.ts +34 -23
- package/src/node/source-editor.ts +332 -0
- package/src/node/speech-settings.ts +21 -0
- package/src/node/templates.ts +27 -0
- package/src/node/workbench-api.ts +176 -0
- package/src/node/writer.ts +389 -0
- package/templates/document/default/lecture.json +75 -0
- package/src/domain/api.ts +0 -102
- package/src/domain/index.ts +0 -6
- package/src/node/documents.ts +0 -185
- package/src/node/files.ts +0 -252
- package/src/node/review/review-record.ts +0 -73
- package/src/node/review/reviews.ts +0 -422
- package/src/node/service.ts +0 -237
- package/src/node/session.ts +0 -123
- package/src/node/source.ts +0 -148
- package/templates/document/default/lecture.md +0 -18
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { DOCUMENT_ENTRY, DOCUMENT_HISTORY_MAX_FILE_BYTES, DOCUMENT_HISTORY_MAX_REQUEST_CHARS, DOCUMENT_HISTORY_MAX_RESPONSE_CHARS, DOCUMENT_HISTORY_PAGE_SIZE, fnv1a64, hasRole, jsonResponse, problemResponse } from '@lincyaw/kernel'
|
|
3
|
+
import type { DocumentHistory, DocumentHistoryAnswer, DocumentHistoryBinding, DocumentHistoryChange, DocumentHistoryChanged, DocumentHistoryFile, DocumentHistorySource, HistoryRevision, Member, Route } from '@lincyaw/kernel'
|
|
4
|
+
import { validateDocumentHistoryChange, validateDocumentHistoryQuery, validateDocumentHistoryRead } from '@lincyaw/kernel/node'
|
|
5
|
+
import { listDocuments } from './config.ts'
|
|
6
|
+
import type { WorkspaceConfig } from './config.ts'
|
|
7
|
+
import { catchRequestErrors, RequestError } from './errors.ts'
|
|
8
|
+
import { lockedError } from './locks.ts'
|
|
9
|
+
import type { DocumentLocks } from './locks.ts'
|
|
10
|
+
import type { Revisions } from './revisions.ts'
|
|
11
|
+
import type { SourceEditor } from './source-editor.ts'
|
|
12
|
+
import { documentDirOf } from './writer.ts'
|
|
13
|
+
|
|
14
|
+
const MAX_RECEIPTS = 64
|
|
15
|
+
const entryOf = (document: string) => document ? `${document}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY
|
|
16
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
17
|
+
const digest = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex')
|
|
18
|
+
const sameBinding = (a: DocumentHistoryBinding, b: DocumentHistoryBinding) => a.snapshot === b.snapshot && a.source.version === b.source.version && a.source.fingerprint === b.source.fingerprint
|
|
19
|
+
const size = (value: unknown) => JSON.stringify(value, null, 2).length
|
|
20
|
+
const requestSize = (value: unknown) => { if (JSON.stringify(value).length > DOCUMENT_HISTORY_MAX_REQUEST_CHARS) throw new RequestError(413, 'history-request-too-large') }
|
|
21
|
+
|
|
22
|
+
export interface DocumentHistoryOptions {
|
|
23
|
+
config: WorkspaceConfig
|
|
24
|
+
editor: SourceEditor
|
|
25
|
+
locks: DocumentLocks
|
|
26
|
+
revisions?: Revisions
|
|
27
|
+
reviewRequired?: boolean
|
|
28
|
+
changed(before: string[], after: string[]): Promise<void> | void
|
|
29
|
+
reverted?(target: string, revision: string): Promise<void> | void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The one scoped history application boundary, borrowing the existing git transaction owner. */
|
|
33
|
+
export function createDocumentHistory(options: DocumentHistoryOptions): { service: DocumentHistory, routes: Record<string, Route>, close(): Promise<void> } {
|
|
34
|
+
const { config, editor, locks, revisions } = options
|
|
35
|
+
let closed = false
|
|
36
|
+
const open = () => { if (closed) throw new RequestError(503, 'history-owner-closed') }
|
|
37
|
+
const receipts = new Map<string, { signature: string, answer: DocumentHistoryChanged }>()
|
|
38
|
+
const pending = new Map<string, { signature: string, work: Promise<DocumentHistoryChanged> }>()
|
|
39
|
+
const scope = (document: string, member: Member) => {
|
|
40
|
+
if (!hasRole(member, 'editor')) throw new RequestError(403, 'history-permission-denied')
|
|
41
|
+
if (!listDocuments(config).some(item => item.dir === document)) throw new RequestError(404, 'history-document-not-found')
|
|
42
|
+
return { document, home: false }
|
|
43
|
+
}
|
|
44
|
+
const source = (document: string, member: Member): DocumentHistorySource => {
|
|
45
|
+
const raw = editor.read(scope(document, member), entryOf(document), member)
|
|
46
|
+
return { version: raw.version, fingerprint: fnv1a64(raw.content) }
|
|
47
|
+
}
|
|
48
|
+
const binding = async (document: string, member: Member): Promise<DocumentHistoryBinding> => { const snapshot = await history().head(); if (!snapshot) throw new RequestError(409, 'history-empty-repository'); return { source: source(document, member), snapshot } }
|
|
49
|
+
const history = () => { if (!revisions) throw new RequestError(409, 'history-git-unavailable'); return revisions }
|
|
50
|
+
const pathAllowed = (document: string, path: string, member: Member) => {
|
|
51
|
+
const owned = scope(document, member)
|
|
52
|
+
editor.assertEditable(owned, path, member)
|
|
53
|
+
if ((/[\x00-\x1f\x7f]/.test(path) || path.split('/').some(part => part.startsWith('.'))) || documentDirOf(config, path) !== document) throw new RequestError(403, 'history-file-outside-document')
|
|
54
|
+
}
|
|
55
|
+
const compact = (document: string, entry: HistoryRevision, member: Member): HistoryRevision => {
|
|
56
|
+
const files = entry.files.filter(path => { try { pathAllowed(document, path, member); return path.length <= 500 } catch { return false } })
|
|
57
|
+
const message = (entry.message.split('\n')[0] ?? '').slice(0, 240)
|
|
58
|
+
return { id: entry.id, message, author: entry.author.slice(0, 120), date: entry.date, origin: { kind: entry.origin.kind }, files: files.slice(0, 6),
|
|
59
|
+
...entry.revertedBy ? { revertedBy: entry.revertedBy } : {}, ...entry.reverts ? { reverts: entry.reverts } : {}, ...entry.redo ? { redo: true } : {},
|
|
60
|
+
...entry.operation ? { operation: entry.operation } : {}, ...entry.revertible === false ? { revertible: false } : {}, ...entry.undone !== undefined ? { undone: entry.undone, revertTarget: entry.revertTarget } : {} }
|
|
61
|
+
}
|
|
62
|
+
const intent = (document: string, which: 'undo' | 'redo' | 'revert', request: DocumentHistoryChange, member: Member, origin: 'editor' | 'agent') => {
|
|
63
|
+
open()
|
|
64
|
+
requestSize(request)
|
|
65
|
+
if (!validateDocumentHistoryChange(request)) throw new RequestError(400, 'history-change-invalid')
|
|
66
|
+
scope(document, member)
|
|
67
|
+
if (origin === 'agent' && options.reviewRequired) throw new RequestError(409, 'authoring-review-required')
|
|
68
|
+
return { key: JSON.stringify([document, member.id, request.id]), signature: digest(JSON.stringify([which, request.id, request.snapshot, request.source.version, request.source.fingerprint, request.expect])) }
|
|
69
|
+
}
|
|
70
|
+
const targetPaths = async (document: string, request: DocumentHistoryChange, member: Member) => {
|
|
71
|
+
const paths = await history().filesOf(request.expect)
|
|
72
|
+
if (!paths.length) throw new RequestError(404, 'history-revision-not-found')
|
|
73
|
+
for (const path of paths) {
|
|
74
|
+
pathAllowed(document, path, member)
|
|
75
|
+
const owners = await Promise.all([history().ownerAt(request.expect, path), history().ownerAt(`${request.expect}^1`, path)])
|
|
76
|
+
if (!owners.includes(document) || owners.some(owner => owner !== undefined && owner !== document)) throw new RequestError(403, 'history-file-outside-document')
|
|
77
|
+
}
|
|
78
|
+
return paths
|
|
79
|
+
}
|
|
80
|
+
const guard = (document: string, request: DocumentHistoryChange, member: Member) => async (changed: string[]) => {
|
|
81
|
+
for (const path of changed) pathAllowed(document, path, member)
|
|
82
|
+
if (!sameBinding(await binding(document, member), { source: request.source, snapshot: request.snapshot })) throw new RequestError(409, 'history-source-changed')
|
|
83
|
+
}
|
|
84
|
+
const service: DocumentHistory = {
|
|
85
|
+
async list(document, query, member) {
|
|
86
|
+
open()
|
|
87
|
+
requestSize(query)
|
|
88
|
+
if (!validateDocumentHistoryQuery(query)) throw new RequestError(400, 'history-query-invalid')
|
|
89
|
+
scope(document, member)
|
|
90
|
+
if (!revisions) return { document, available: false, revisions: [] }
|
|
91
|
+
if (!await revisions.head()) { if (query.snapshot !== undefined || (query.offset ?? 0) > 0) throw new RequestError(409, 'history-snapshot-changed'); return { document, available: true, unborn: true, revisions: [] } }
|
|
92
|
+
const before = await binding(document, member)
|
|
93
|
+
if ((query.offset ?? 0) > 0 && !query.snapshot) throw new RequestError(400, 'history-snapshot-required')
|
|
94
|
+
if (query.snapshot !== undefined && query.snapshot !== before.snapshot) throw new RequestError(409, 'history-snapshot-changed')
|
|
95
|
+
const full = await revisions.historyWithUndo({ document }), changes = full.revisions.filter(entry => !entry.reverts), offset = query.offset ?? 0
|
|
96
|
+
if (offset > changes.length) throw new RequestError(400, 'history-offset-invalid')
|
|
97
|
+
const answer: DocumentHistoryAnswer = { document, available: true, historyBinding: before, revisions: [], ...full.undo ? { undo: full.undo } : {}, ...full.redo ? { redo: full.redo } : {} }
|
|
98
|
+
let index = offset
|
|
99
|
+
for (const entry of changes.slice(offset, offset + DOCUMENT_HISTORY_PAGE_SIZE)) {
|
|
100
|
+
const row = compact(document, entry, member)
|
|
101
|
+
const candidate = { ...answer, revisions: [...answer.revisions, row], nextOffset: index + 1, truncated: true }
|
|
102
|
+
if (size(candidate) > DOCUMENT_HISTORY_MAX_RESPONSE_CHARS) break
|
|
103
|
+
answer.revisions.push(row); index++
|
|
104
|
+
if (!same(row, entry)) answer.truncated = true
|
|
105
|
+
}
|
|
106
|
+
if (index < changes.length) { if (index === offset) throw new RequestError(413, 'history-entry-too-large'); answer.nextOffset = index }
|
|
107
|
+
if (!sameBinding(before, await binding(document, member))) throw new RequestError(409, 'history-snapshot-changed')
|
|
108
|
+
return answer
|
|
109
|
+
},
|
|
110
|
+
async read(document, query, member) {
|
|
111
|
+
open()
|
|
112
|
+
requestSize(query)
|
|
113
|
+
if (!validateDocumentHistoryRead(query)) throw new RequestError(400, 'history-read-invalid')
|
|
114
|
+
const path = query.path ?? entryOf(document)
|
|
115
|
+
pathAllowed(document, path, member)
|
|
116
|
+
const bytes = await history().readFile(query.revision, path, DOCUMENT_HISTORY_MAX_FILE_BYTES, document)
|
|
117
|
+
let content: string
|
|
118
|
+
try { content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes) } catch { throw new RequestError(422, 'history-file-not-utf8') }
|
|
119
|
+
if (content.includes('\0')) throw new RequestError(422, 'history-file-not-utf8-text')
|
|
120
|
+
const offset = query.offset ?? 0
|
|
121
|
+
if (offset > content.length || offset > 0 && /[\uD800-\uDBFF]/.test(content[offset - 1]!) && /[\uDC00-\uDFFF]/.test(content[offset] ?? '')) throw new RequestError(400, 'history-offset-invalid')
|
|
122
|
+
const reply = (end: number): DocumentHistoryFile => ({ document, revision: query.revision, path, encoding: 'utf8-text-fragment', text: content.slice(offset, end), offset, ...end < content.length ? { nextOffset: end } : {}, totalChars: content.length, complete: end === content.length, sha256: digest(bytes), verification: 'historical-file-only' })
|
|
123
|
+
let low = offset, high = Math.min(content.length, offset + DOCUMENT_HISTORY_MAX_RESPONSE_CHARS)
|
|
124
|
+
while (low < high) { const middle = Math.ceil((low + high) / 2); if (size(reply(middle)) <= DOCUMENT_HISTORY_MAX_RESPONSE_CHARS) low = middle; else high = middle - 1 }
|
|
125
|
+
if (low < content.length && low > offset && /[\uD800-\uDBFF]/.test(content[low - 1]!) && /[\uDC00-\uDFFF]/.test(content[low]!)) low--
|
|
126
|
+
if (low === offset && offset < content.length) throw new RequestError(413, 'history-file-reply-too-large')
|
|
127
|
+
return reply(low)
|
|
128
|
+
},
|
|
129
|
+
async checkChange(document, which, request, member, origin = 'editor') {
|
|
130
|
+
const { key, signature } = intent(document, which, request, member, origin)
|
|
131
|
+
const known = receipts.get(key), flight = pending.get(key)
|
|
132
|
+
if (known || flight) {
|
|
133
|
+
if ((known ?? flight)!.signature !== signature) throw new RequestError(409, 'history-intent-reused')
|
|
134
|
+
if (known && !flight && !sameBinding(await binding(document, member), { source: known.answer.source, snapshot: known.answer.snapshot })) throw new RequestError(409, 'history-result-no-longer-current')
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const paths = await targetPaths(document, request, member)
|
|
138
|
+
if (!sameBinding(await binding(document, member), { source: request.source, snapshot: request.snapshot })) throw new RequestError(409, 'history-source-changed')
|
|
139
|
+
const claimed = locks.claimAll(paths.map(path => documentDirOf(config, path)!), 'revision:revert')
|
|
140
|
+
if ('holder' in claimed) throw lockedError(claimed.document, claimed.holder)
|
|
141
|
+
try {
|
|
142
|
+
const result = await history().checkRevert(request.expect, member, which === 'revert' ? undefined : { which, document }, guard(document, request, member))
|
|
143
|
+
if (!result.ok) throw new RequestError(409, 'history-conflict', { conflicts: result.conflicts.slice(0, 6).map(path => path.slice(0, 500)), conflictCount: result.conflicts.length })
|
|
144
|
+
} finally { claimed.release() }
|
|
145
|
+
},
|
|
146
|
+
async change(document, which, request, member, origin = 'editor') {
|
|
147
|
+
const { key, signature } = intent(document, which, request, member, origin)
|
|
148
|
+
const known = receipts.get(key), flight = pending.get(key)
|
|
149
|
+
if (known || flight) {
|
|
150
|
+
if ((known ?? flight)!.signature !== signature) throw new RequestError(409, 'history-intent-reused')
|
|
151
|
+
if (flight) return flight.work
|
|
152
|
+
if (!sameBinding(await binding(document, member), { source: known!.answer.source, snapshot: known!.answer.snapshot })) throw new RequestError(409, 'history-result-no-longer-current')
|
|
153
|
+
return structuredClone(known!.answer)
|
|
154
|
+
}
|
|
155
|
+
if (pending.size >= MAX_RECEIPTS) throw new RequestError(429, 'history-busy')
|
|
156
|
+
const work = Promise.resolve().then(async () => {
|
|
157
|
+
const paths = await targetPaths(document, request, member)
|
|
158
|
+
const claimed = locks.claimAll(paths.map(path => documentDirOf(config, path)!), 'revision:revert')
|
|
159
|
+
if ('holder' in claimed) throw lockedError(claimed.document, claimed.holder)
|
|
160
|
+
try {
|
|
161
|
+
const beforeDocuments = listDocuments(config).map(item => item.dir)
|
|
162
|
+
const result = await history().revert(request.expect, member, which === 'revert' ? undefined : { which, document }, guard(document, request, member), origin)
|
|
163
|
+
if (!result.ok) throw new RequestError(409, 'history-conflict', { conflicts: result.conflicts.slice(0, 6).map(path => path.slice(0, 500)), conflictCount: result.conflicts.length })
|
|
164
|
+
const answer: DocumentHistoryChanged = { document, ok: true, revision: { id: result.revision.id, origin: { kind: result.revision.origin.kind } }, ...await binding(document, member), verification: { saved: true, compiled: 'not-requested', runtime: 'not-requested' } }
|
|
165
|
+
receipts.set(key, { signature, answer }); while (receipts.size > MAX_RECEIPTS) receipts.delete(receipts.keys().next().value!)
|
|
166
|
+
await options.changed(beforeDocuments, listDocuments(config).map(item => item.dir))
|
|
167
|
+
void Promise.resolve(options.reverted?.(request.expect, result.revision.id)).catch(() => undefined)
|
|
168
|
+
return structuredClone(answer)
|
|
169
|
+
} finally { claimed.release() }
|
|
170
|
+
})
|
|
171
|
+
pending.set(key, { signature, work })
|
|
172
|
+
try { return await work } finally { if (pending.get(key)?.work === work) pending.delete(key) }
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
const readRoute = (read: 'list' | 'read'): Route => ({ role: 'editor', handle: (request, ctx) => catchRequestErrors(async () => {
|
|
176
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
177
|
+
let query: unknown
|
|
178
|
+
try { query = JSON.parse(new URL(request.url).searchParams.get('query') ?? '{}') } catch { throw new RequestError(400, 'history-query-invalid') }
|
|
179
|
+
if (read === 'list') { if (!validateDocumentHistoryQuery(query)) throw new RequestError(400, 'history-query-invalid'); return jsonResponse(await service.list(ctx.document, query, ctx.member)) }
|
|
180
|
+
if (!validateDocumentHistoryRead(query)) throw new RequestError(400, 'history-read-invalid')
|
|
181
|
+
return jsonResponse(await service.read(ctx.document, query, ctx.member))
|
|
182
|
+
}) })
|
|
183
|
+
const changeRoute = (which: 'undo' | 'redo' | 'revert'): Route => ({ role: 'editor', maxBody: 8192, handle: (request, ctx) => catchRequestErrors(async () => {
|
|
184
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
185
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
186
|
+
if (!validateDocumentHistoryChange(body)) throw new RequestError(400, 'history-change-invalid')
|
|
187
|
+
return jsonResponse(await service.change(ctx.document, which, body, ctx.member))
|
|
188
|
+
}) })
|
|
189
|
+
return { service, async close() { closed = true; await Promise.allSettled([...pending.values()].map(item => item.work)); receipts.clear() }, routes: { 'revisions': readRoute('list'), 'history/list': readRoute('list'), 'history/file': readRoute('read'), 'history/undo': changeRoute('undo'), 'history/redo': changeRoute('redo'), 'revisions/revert': changeRoute('revert') } }
|
|
190
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, open, stat } from 'node:fs/promises'
|
|
4
|
+
import { basename, extname, join, relative } from 'node:path'
|
|
5
|
+
import { imageSize } from 'image-size'
|
|
6
|
+
import { ASSET_TITLE_MAX_LENGTH, DOCUMENT_IMAGE_MAX_BYTES, fnv1a64, hasRole, isRecord, jsonResponse } from '@lincyaw/kernel'
|
|
7
|
+
import type { Asset, CanonicalSource, DocumentImageAuthoring, DocumentImageImport, DocumentImageImportAnswer, DocumentWorkspaceImageImport, Member, RequestContext, Route, TeachingDocument, VerifiedDocumentImageImport } from '@lincyaw/kernel'
|
|
8
|
+
import { productionAssetFile, validateDocumentWorkspaceImageImport, validateDocumentWorkspaceImageInspect } from '@lincyaw/kernel/node'
|
|
9
|
+
import { readAuthoringDocument } from './document-editor.ts'
|
|
10
|
+
import { catchRequestErrors, RequestError } from './errors.ts'
|
|
11
|
+
import type { Revisions } from './revisions.ts'
|
|
12
|
+
import type { SourceEditor, SourceTransactionHistory } from './source-editor.ts'
|
|
13
|
+
|
|
14
|
+
interface Options { root: string, editor: SourceEditor, revisions?: Revisions, reviewRequired?: boolean }
|
|
15
|
+
interface ImageFormat { extension: string, mediaType: string }
|
|
16
|
+
interface Receipt { signature: string, assetId: string, version: string, files?: DocumentImageImportAnswer['files'], commit?: DocumentImageImportAnswer['commit'] }
|
|
17
|
+
const FORMATS: Record<string, ImageFormat> = {
|
|
18
|
+
png: { extension: 'png', mediaType: 'image/png' }, jpg: { extension: 'jpg', mediaType: 'image/jpeg' },
|
|
19
|
+
gif: { extension: 'gif', mediaType: 'image/gif' }, webp: { extension: 'webp', mediaType: 'image/webp' },
|
|
20
|
+
svg: { extension: 'svg', mediaType: 'image/svg+xml' }, bmp: { extension: 'bmp', mediaType: 'image/bmp' },
|
|
21
|
+
}
|
|
22
|
+
const MAX_DIMENSION = 16384, MAX_PIXELS = 40_000_000
|
|
23
|
+
const header = (value: unknown): value is DocumentImageImport => isRecord(value) && typeof value.id === 'string' && value.id.length > 0 && value.id.length <= 200 && typeof value.baseDocumentVersion === 'string' && /^[\da-f]{20}$/.test(value.baseDocumentVersion) && Object.keys(value).every(key => key === 'id' || key === 'baseDocumentVersion')
|
|
24
|
+
|
|
25
|
+
/** Only an allowed header and bounded dimensions are inspected; this does not fully decode an image. */
|
|
26
|
+
function inspect(bytes: Uint8Array) {
|
|
27
|
+
let found: ReturnType<typeof imageSize>
|
|
28
|
+
try { found = imageSize(bytes) } catch { throw new RequestError(422, 'document-image-format') }
|
|
29
|
+
const format = found.type && Object.hasOwn(FORMATS, found.type) ? FORMATS[found.type] : undefined
|
|
30
|
+
if (!format) throw new RequestError(422, 'document-image-format')
|
|
31
|
+
const { width, height } = found
|
|
32
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 || width > MAX_DIMENSION || height > MAX_DIMENSION || width * height > MAX_PIXELS) throw new RequestError(422, 'document-image-dimensions')
|
|
33
|
+
return { ...format, dimensions: { width, height } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Original bytes and the canonical asset bank are committed or rolled back together. */
|
|
37
|
+
export function createDocumentImageAuthoring({ root, editor, revisions, reviewRequired = false }: Options): { service: DocumentImageAuthoring, routes: Record<string, Route> } {
|
|
38
|
+
const receipts = new Map<string, Receipt>(), pending = new Map<string, { signature: string, done: Promise<DocumentImageImportAnswer> }>()
|
|
39
|
+
const binding = (current: ReturnType<typeof readAuthoringDocument>): CanonicalSource => ({ documentId: current.content.id, version: current.source.version, fingerprint: fnv1a64(current.source.content) })
|
|
40
|
+
const opened = (ctx: RequestContext, version?: string, source?: CanonicalSource) => {
|
|
41
|
+
const current = readAuthoringDocument(editor, ctx)
|
|
42
|
+
if (version !== undefined && current.source.version !== version || source && (current.content.id !== source.documentId || fnv1a64(current.source.content) !== source.fingerprint)) throw new RequestError(409, 'document-version-changed', { version: current.source.version })
|
|
43
|
+
return current
|
|
44
|
+
}
|
|
45
|
+
const matches = async (ctx: RequestContext, asset: Asset, bytes: Uint8Array, format: ImageFormat, signal: AbortSignal): Promise<boolean> => {
|
|
46
|
+
if (asset.kind !== 'image' || asset.mediaType !== undefined && asset.mediaType !== format.mediaType) return false
|
|
47
|
+
const file = productionAssetFile(join(root, ctx.document), asset.location)
|
|
48
|
+
if (!file || ![`.${format.extension}`, ...(format.extension === 'jpg' ? ['.jpeg'] : [])].includes(extname(file).toLowerCase())) return false
|
|
49
|
+
const path = relative(root, file).split('\\').join('/')
|
|
50
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined
|
|
51
|
+
try {
|
|
52
|
+
editor.readableFile({ document: ctx.document, home: false }, path, ctx.member)
|
|
53
|
+
signal.throwIfAborted()
|
|
54
|
+
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
55
|
+
const before = await handle.stat()
|
|
56
|
+
if (!before.isFile() || before.size !== bytes.byteLength) return false
|
|
57
|
+
const actual = Buffer.alloc(bytes.byteLength)
|
|
58
|
+
let offset = 0
|
|
59
|
+
while (offset < actual.length) {
|
|
60
|
+
signal.throwIfAborted()
|
|
61
|
+
const next = await handle.read(actual, offset, actual.length - offset, offset)
|
|
62
|
+
if (!next.bytesRead) return false
|
|
63
|
+
offset += next.bytesRead
|
|
64
|
+
}
|
|
65
|
+
const after = await handle.stat(), pathNow = await stat(editor.readableFile({ document: ctx.document, home: false }, path, ctx.member))
|
|
66
|
+
return before.size === after.size && before.mtimeMs === after.mtimeMs && before.ino === pathNow.ino && before.dev === pathNow.dev && actual.equals(bytes)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
signal.throwIfAborted()
|
|
69
|
+
if (error instanceof RequestError || error && typeof error === 'object' && 'code' in error && ['ENOENT', 'ELOOP', 'EACCES'].includes(String(error.code))) return false
|
|
70
|
+
throw error
|
|
71
|
+
} finally { await handle?.close() }
|
|
72
|
+
}
|
|
73
|
+
const answer = (ctx: RequestContext, change: DocumentImageImport, current: ReturnType<typeof readAuthoringDocument>, assetId: string, dimensions: { width: number, height: number }): DocumentImageImportAnswer => ({
|
|
74
|
+
changeId: change.id, document: ctx.document, version: current.source.version, fingerprint: fnv1a64(current.source.content), content: current.content, issues: current.issues, files: [], assetId, dimensions,
|
|
75
|
+
})
|
|
76
|
+
const remember = (key: string, receipt: Receipt) => {
|
|
77
|
+
receipts.set(key, receipt)
|
|
78
|
+
while (receipts.size > 64) receipts.delete(receipts.keys().next().value!)
|
|
79
|
+
}
|
|
80
|
+
const history = (ctx: RequestContext, document: TeachingDocument, signal: AbortSignal, origin: 'editor' | 'agent'): SourceTransactionHistory => ({
|
|
81
|
+
run: work => {
|
|
82
|
+
const guarded = () => { signal.throwIfAborted(); return work() }
|
|
83
|
+
return revisions ? revisions.transaction(guarded) : guarded()
|
|
84
|
+
},
|
|
85
|
+
commit: async paths => {
|
|
86
|
+
if (!revisions) return undefined
|
|
87
|
+
const ignored = (await Promise.all(paths.map(async path => await revisions.ignores(path) ? path : undefined))).filter((path): path is string => Boolean(path))
|
|
88
|
+
if (ignored.length) throw new RequestError(409, 'document-files-ignored', { ignored })
|
|
89
|
+
return revisions.commitWhileLocked({ paths, summary: document.metadata.lang.startsWith('en') ? 'Import image' : '导入图片', body: '', origin: { kind: origin, member: ctx.member } })
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
const signatureOf = (change: DocumentImageImport, title: string, hash: string, origin: 'editor' | 'agent', source?: CanonicalSource, inputPath?: string) => createHash('sha256').update(JSON.stringify([change.id, change.baseDocumentVersion, source?.documentId ?? null, source?.fingerprint ?? null, origin, inputPath ?? null, title, hash])).digest('hex')
|
|
93
|
+
const keyOf = (ctx: RequestContext, change: DocumentImageImport) => JSON.stringify([ctx.document, ctx.member.id, change.id])
|
|
94
|
+
const apply = async (ctx: RequestContext, change: DocumentImageImport, title: string, bytes: Uint8Array, image: ReturnType<typeof inspect>, hash: string, signal: AbortSignal, origin: 'editor' | 'agent', source?: CanonicalSource, inputPath?: string): Promise<DocumentImageImportAnswer> => {
|
|
95
|
+
const signature = signatureOf(change, title, hash, origin, source, inputPath), key = keyOf(ctx, change)
|
|
96
|
+
const replay = async () => {
|
|
97
|
+
const previous = receipts.get(key)
|
|
98
|
+
if (!previous) return undefined
|
|
99
|
+
if (previous.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
|
|
100
|
+
const current = opened(ctx, previous.version), asset = current.content.assets[previous.assetId]
|
|
101
|
+
if (!asset || !await matches(ctx, asset, bytes, image, signal)) throw new RequestError(409, 'document-image-asset-changed')
|
|
102
|
+
return { ...answer(ctx, change, opened(ctx, previous.version), previous.assetId, image.dimensions), ...origin === 'agent' && previous.files ? { files: previous.files } : {}, ...origin === 'agent' && previous.commit ? { commit: previous.commit } : {} }
|
|
103
|
+
}
|
|
104
|
+
const previous = await replay()
|
|
105
|
+
if (previous) return previous
|
|
106
|
+
const active = pending.get(key)
|
|
107
|
+
if (active) {
|
|
108
|
+
if (active.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
|
|
109
|
+
await active.done
|
|
110
|
+
const repeated = await replay()
|
|
111
|
+
if (!repeated) throw new RequestError(409, 'document-image-receipt-expired')
|
|
112
|
+
return repeated
|
|
113
|
+
}
|
|
114
|
+
const work = async (): Promise<DocumentImageImportAnswer> => {
|
|
115
|
+
const current = opened(ctx, change.baseDocumentVersion, source)
|
|
116
|
+
const candidates = Object.values(current.content.assets).filter(asset => asset.kind === 'image' && (asset.fingerprint === undefined || asset.fingerprint === `sha256:${hash}`)).sort((a, b) => Number(b.fingerprint !== undefined) - Number(a.fingerprint !== undefined))
|
|
117
|
+
for (const asset of candidates) if (await matches(ctx, asset, bytes, image, signal)) {
|
|
118
|
+
const fresh = opened(ctx, change.baseDocumentVersion, source)
|
|
119
|
+
remember(key, { signature, assetId: asset.id, version: fresh.source.version })
|
|
120
|
+
return answer(ctx, change, fresh, asset.id, image.dimensions)
|
|
121
|
+
}
|
|
122
|
+
const assetId = `image_${randomUUID().replaceAll('-', '')}`, location = `assets/images/${assetId}.${image.extension}`, path = ctx.document ? `${ctx.document}/${location}` : location
|
|
123
|
+
const candidate = structuredClone(current.content)
|
|
124
|
+
candidate.assets[assetId] = { id: assetId, kind: 'image', location, ...title ? { title } : {}, fingerprint: `sha256:${hash}`, mediaType: image.mediaType }
|
|
125
|
+
const content = `${JSON.stringify(candidate, null, 2)}\n`
|
|
126
|
+
signal.throwIfAborted()
|
|
127
|
+
const saved = await editor.transactionWithBinary({ document: ctx.document, home: false }, { path: current.source.path, content, base: current.source.version }, { path, content: bytes }, ctx.member, history(ctx, candidate, signal, origin))
|
|
128
|
+
const version = saved.versions.find(file => file.path === current.source.path)!.version
|
|
129
|
+
remember(key, { signature, assetId, version, files: saved.versions, ...saved.revision ? { commit: saved.revision } : {} })
|
|
130
|
+
return { changeId: change.id, document: ctx.document, version, fingerprint: fnv1a64(content), content: candidate, issues: current.issues, files: saved.versions, assetId, dimensions: image.dimensions, ...saved.revision ? { commit: saved.revision } : {} }
|
|
131
|
+
}
|
|
132
|
+
const done = Promise.resolve().then(work)
|
|
133
|
+
pending.set(key, { signature, done })
|
|
134
|
+
try { return await done }
|
|
135
|
+
finally { if (pending.get(key)?.done === done) pending.delete(key) }
|
|
136
|
+
}
|
|
137
|
+
const ctxOf = (document: string, member: Member): RequestContext => ({ document, member, rest: '' })
|
|
138
|
+
const authorize = (member: Member) => { if (!hasRole(member, 'editor')) throw new RequestError(403, 'image-authoring-permission-denied') }
|
|
139
|
+
const workspacePath = (ctx: RequestContext, path: string) => {
|
|
140
|
+
editor.assertEditable({ document: ctx.document, home: false }, path, ctx.member)
|
|
141
|
+
if (path.split('/').some(part => part.startsWith('.'))) throw new RequestError(403, 'image-file-path')
|
|
142
|
+
}
|
|
143
|
+
const reader = async (ctx: RequestContext, path: string, signal?: AbortSignal) => {
|
|
144
|
+
workspacePath(ctx, path); signal?.throwIfAborted()
|
|
145
|
+
const file = editor.readableFile({ document: ctx.document, home: false }, path, ctx.member)
|
|
146
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(error => {
|
|
147
|
+
if (error.code === 'ELOOP') throw new RequestError(403, 'image-file-path')
|
|
148
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') throw new RequestError(404, 'image-file-missing')
|
|
149
|
+
throw error
|
|
150
|
+
})
|
|
151
|
+
try {
|
|
152
|
+
const before = await handle.stat(), named = await lstat(editor.readableFile({ document: ctx.document, home: false }, path, ctx.member))
|
|
153
|
+
if (!before.isFile() || before.dev !== named.dev || before.ino !== named.ino) throw new RequestError(403, 'image-file-path')
|
|
154
|
+
if (!before.size || before.size > DOCUMENT_IMAGE_MAX_BYTES) throw new RequestError(413, 'document-image-size')
|
|
155
|
+
const bytes = new Uint8Array(before.size + 1); let length = 0
|
|
156
|
+
while (length < bytes.length) { signal?.throwIfAborted(); const read = await handle.read(bytes, length, bytes.length - length, length); if (!read.bytesRead) break; length += read.bytesRead }
|
|
157
|
+
const after = await handle.stat(), current = await lstat(editor.readableFile({ document: ctx.document, home: false }, path, ctx.member))
|
|
158
|
+
const stamp = (value: typeof before) => [value.dev, value.ino, value.size, value.mtimeMs, value.ctimeMs].join(':')
|
|
159
|
+
if (length !== before.size || stamp(before) !== stamp(after) || after.dev !== current.dev || after.ino !== current.ino) throw new RequestError(409, 'image-file-changed')
|
|
160
|
+
signal?.throwIfAborted(); return bytes.slice(0, length)
|
|
161
|
+
} finally { await handle.close() }
|
|
162
|
+
}
|
|
163
|
+
const workspaceImport = async (ctx: RequestContext, request: DocumentWorkspaceImageImport, signal?: AbortSignal) => {
|
|
164
|
+
const bytes = await reader(ctx, request.file.path, signal), sha256 = createHash('sha256').update(bytes).digest('hex')
|
|
165
|
+
if (bytes.length !== request.file.byteLength || sha256 !== request.file.sha256) throw new RequestError(409, 'image-file-changed')
|
|
166
|
+
const image = inspect(bytes), title = [...basename(request.file.path).replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()].slice(0, ASSET_TITLE_MAX_LENGTH).join('')
|
|
167
|
+
return { bytes, image, sha256, title }
|
|
168
|
+
}
|
|
169
|
+
let active = 0
|
|
170
|
+
const limited = async <T>(work: () => Promise<T>): Promise<T> => {
|
|
171
|
+
if (active >= 4) throw new RequestError(503, 'image-authoring-busy')
|
|
172
|
+
active++
|
|
173
|
+
try { return await work() } finally { active-- }
|
|
174
|
+
}
|
|
175
|
+
const service: DocumentImageAuthoring = {
|
|
176
|
+
async capabilities(document, member) {
|
|
177
|
+
authorize(member)
|
|
178
|
+
const source = binding(opened(ctxOf(document, member)))
|
|
179
|
+
return { source, available: true, verification: 'configuration-only', actions: { inspectWorkspace: true, importWorkspace: !reviewRequired }, ...reviewRequired ? { importUnavailableReason: 'review-required' as const } : {}, limits: { imageBytes: DOCUMENT_IMAGE_MAX_BYTES, receipts: 64, concurrent: 4 }, workspaceFileScope: 'same-document', idempotency: 'process-current-source', review: reviewRequired ? 'required-unsupported' : revisions ? 'locks-and-history' : 'locks-only' }
|
|
180
|
+
},
|
|
181
|
+
async inspectWorkspace(document, request, member, signal) {
|
|
182
|
+
authorize(member)
|
|
183
|
+
if (!validateDocumentWorkspaceImageInspect(request)) throw new RequestError(400, 'document-image-query-invalid')
|
|
184
|
+
return limited(async () => {
|
|
185
|
+
const ctx = ctxOf(document, member); opened(ctx, request.source.version, request.source)
|
|
186
|
+
const bytes = await reader(ctx, request.path, signal), image = inspect(bytes), sha256 = createHash('sha256').update(bytes).digest('hex')
|
|
187
|
+
signal?.throwIfAborted(); opened(ctx, request.source.version, request.source)
|
|
188
|
+
return { document, source: request.source, file: { path: request.path, sha256, byteLength: bytes.length }, image: { mediaType: image.mediaType, dimensions: image.dimensions } }
|
|
189
|
+
})
|
|
190
|
+
},
|
|
191
|
+
async checkWorkspaceImport(document, request, member, signal) {
|
|
192
|
+
authorize(member)
|
|
193
|
+
if (reviewRequired) throw new RequestError(409, 'authoring-review-required')
|
|
194
|
+
if (!validateDocumentWorkspaceImageImport(request)) throw new RequestError(400, 'document-image-import-invalid')
|
|
195
|
+
return limited(async () => {
|
|
196
|
+
const ctx = ctxOf(document, member), change = { id: request.id, baseDocumentVersion: request.source.version }, key = keyOf(ctx, change)
|
|
197
|
+
if (!receipts.has(key) && !pending.has(key)) opened(ctx, request.source.version, request.source)
|
|
198
|
+
const { sha256, title } = await workspaceImport(ctx, request, signal), signature = signatureOf(change, title, sha256, 'agent', request.source, request.file.path)
|
|
199
|
+
const receipt = receipts.get(key), known = receipt ?? pending.get(key)
|
|
200
|
+
if (known && known.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
|
|
201
|
+
// A receipt of this exact import replays while the source is still the one it saved; an import in flight decides itself.
|
|
202
|
+
if (receipt) opened(ctx, receipt.version)
|
|
203
|
+
else if (!known) opened(ctx, request.source.version, request.source)
|
|
204
|
+
})
|
|
205
|
+
},
|
|
206
|
+
async importWorkspace(document, request, member, signal) {
|
|
207
|
+
authorize(member)
|
|
208
|
+
if (reviewRequired) throw new RequestError(409, 'authoring-review-required')
|
|
209
|
+
if (!validateDocumentWorkspaceImageImport(request)) throw new RequestError(400, 'document-image-import-invalid')
|
|
210
|
+
return limited(async () => {
|
|
211
|
+
const ctx = ctxOf(document, member), { bytes, image, sha256, title } = await workspaceImport(ctx, request, signal)
|
|
212
|
+
const result = await apply(ctx, { id: request.id, baseDocumentVersion: request.source.version }, title, bytes, image, sha256, signal ?? new AbortController().signal, 'agent', request.source, request.file.path)
|
|
213
|
+
try {
|
|
214
|
+
const current = opened(ctx, result.version), asset = current.content.assets[result.assetId], file = asset && productionAssetFile(join(root, document), asset.location)
|
|
215
|
+
if (!asset || !file || !await matches(ctx, asset, bytes, image, new AbortController().signal)) throw new Error('asset-changed')
|
|
216
|
+
const verified = opened(ctx, result.version)
|
|
217
|
+
return { ...result, content: verified.content, fingerprint: fnv1a64(verified.source.content), verification: { saved: true, source: binding(verified), asset: { assetId: result.assetId, path: relative(root, file).split('\\').join('/'), sha256, byteLength: bytes.length }, compiled: 'not-requested', runtime: 'not-requested' } } satisfies VerifiedDocumentImageImport
|
|
218
|
+
} catch (error) {
|
|
219
|
+
throw new RequestError(409, 'image-readback-changed', { committed: result.files.length > 0, changeId: request.id, version: result.version, assetId: result.assetId, reason: error instanceof Error ? error.message : 'readback-failed' })
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
const routes: Record<string, Route> = {
|
|
225
|
+
'document/image': { role: 'editor', maxBody: DOCUMENT_IMAGE_MAX_BYTES + (64 << 10), handle: (request, ctx) => catchRequestErrors(async () => {
|
|
226
|
+
if (request.method !== 'POST') throw new RequestError(405, 'POST')
|
|
227
|
+
const form = await request.formData().catch(() => undefined)
|
|
228
|
+
if (!form || [...form.keys()].some(key => key !== 'change' && key !== 'file') || form.getAll('change').length !== 1 || form.getAll('file').length !== 1) throw new RequestError(400, 'document-image-form-invalid')
|
|
229
|
+
const text = form.get('change'), file = form.get('file')
|
|
230
|
+
if (typeof text !== 'string' || text.length > 4096 || !(file instanceof File)) throw new RequestError(400, 'document-image-form-invalid')
|
|
231
|
+
let change: unknown
|
|
232
|
+
try { change = JSON.parse(text) } catch { throw new RequestError(400, 'document-image-change-invalid') }
|
|
233
|
+
if (!header(change)) throw new RequestError(400, 'document-image-change-invalid')
|
|
234
|
+
if (!file.size || file.size > DOCUMENT_IMAGE_MAX_BYTES) throw new RequestError(413, 'document-image-size')
|
|
235
|
+
const bytes = new Uint8Array(await file.arrayBuffer()), image = inspect(bytes), hash = createHash('sha256').update(bytes).digest('hex')
|
|
236
|
+
const title = [...(file.name.replaceAll('\\', '/').split('/').at(-1) ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()].slice(0, ASSET_TITLE_MAX_LENGTH).join('')
|
|
237
|
+
return jsonResponse(await apply(ctx, change, title, bytes, image, hash, request.signal, 'editor'))
|
|
238
|
+
}) },
|
|
239
|
+
}
|
|
240
|
+
return { service, routes }
|
|
241
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync, rmdirSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import type { DocumentOperation, DocumentFileChange, Member, OperationAnswer, RevisionOperation } from '@lincyaw/kernel'
|
|
4
|
+
import { hasRole } from '@lincyaw/kernel'
|
|
5
|
+
import type { CommitOrigin } from '../domain/commit-message.ts'
|
|
6
|
+
import { invalidDocumentDir, isWithin } from '../domain/paths.ts'
|
|
7
|
+
import { isDocumentDir, listDocuments } from './config.ts'
|
|
8
|
+
import type { WorkspaceConfig } from './config.ts'
|
|
9
|
+
import { RequestError } from './errors.ts'
|
|
10
|
+
import type { Git } from './git.ts'
|
|
11
|
+
import { lockedError } from './locks.ts'
|
|
12
|
+
import type { DocumentLocks } from './locks.ts'
|
|
13
|
+
import type { Revisions } from './revisions.ts'
|
|
14
|
+
import { DEFAULT_TEMPLATE, listTemplates } from './templates.ts'
|
|
15
|
+
import { DocumentCheckpointRefusal, makeDocumentDir } from './writer.ts'
|
|
16
|
+
import type { DocumentWriter } from './writer.ts'
|
|
17
|
+
|
|
18
|
+
export interface DocumentsChange {
|
|
19
|
+
removed: string[]
|
|
20
|
+
added: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function documentsDiff(before: string[], after: string[]): DocumentsChange {
|
|
24
|
+
return { removed: before.filter(dir => !after.includes(dir)), added: after.filter(dir => !before.includes(dir)) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DocumentOperationsOptions {
|
|
28
|
+
config: WorkspaceConfig
|
|
29
|
+
git: Git
|
|
30
|
+
revisions: Revisions
|
|
31
|
+
writer: DocumentWriter
|
|
32
|
+
changed?: (change: DocumentsChange) => void | Promise<void>
|
|
33
|
+
locks?: DocumentLocks
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function touched(operation: DocumentOperation): string[] {
|
|
37
|
+
return operation.op === 'create' || operation.op === 'delete' ? [operation.dir] : [operation.dir, operation.to]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Creating, renaming, copying and deleting a document, each under the document locks and as one commit. */
|
|
41
|
+
export class DocumentOperations {
|
|
42
|
+
readonly #config: WorkspaceConfig
|
|
43
|
+
readonly #revisions: Revisions
|
|
44
|
+
readonly #writer: DocumentWriter
|
|
45
|
+
readonly #changed: (change: DocumentsChange) => void | Promise<void>
|
|
46
|
+
readonly #locks?: DocumentLocks
|
|
47
|
+
|
|
48
|
+
constructor(options: DocumentOperationsOptions) {
|
|
49
|
+
this.#config = options.config
|
|
50
|
+
this.#revisions = options.revisions
|
|
51
|
+
this.#writer = options.writer
|
|
52
|
+
this.#changed = options.changed ?? (() => {})
|
|
53
|
+
this.#locks = options.locks
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async apply(operation: DocumentOperation, member: Member, prepared?: { documentId?: string, origin: 'editor' | 'agent', changes?: DocumentFileChange[], verify(): Promise<void> }): Promise<OperationAnswer> {
|
|
57
|
+
if (!hasRole(member, 'editor')) throw new RequestError(403, 'document operations need the role editor')
|
|
58
|
+
if (this.#config.singleDeck) throw new RequestError(409, 'this workspace is a single deck: it has no document operations')
|
|
59
|
+
if (operation.op !== 'create' && operation.dir === '') throw new RequestError(409, 'project-root-operation-unavailable')
|
|
60
|
+
let release: (() => void) | undefined, committedError: RequestError | undefined, cleanupError: unknown
|
|
61
|
+
try {
|
|
62
|
+
const answer = await this.#revisions.transaction(async () => {
|
|
63
|
+
if (prepared) {
|
|
64
|
+
const claimed = this.#locks?.claimAll([''], `document:${operation.op}`)
|
|
65
|
+
if (claimed && 'holder' in claimed) throw lockedError(claimed.document, claimed.holder)
|
|
66
|
+
release ??= claimed?.release
|
|
67
|
+
}
|
|
68
|
+
await prepared?.verify()
|
|
69
|
+
const head = await this.#revisions.head()
|
|
70
|
+
const before = await this.#writer.checkpoint(touched(operation), operation.op === 'rename' ? { from: operation.dir, to: operation.to } : undefined, prepared?.changes?.map(change => change.path)).catch(error => {
|
|
71
|
+
if (error instanceof DocumentCheckpointRefusal) throw new RequestError(error.status, error.message, error.detail)
|
|
72
|
+
throw error
|
|
73
|
+
})
|
|
74
|
+
let discardCheckpoint = true
|
|
75
|
+
try {
|
|
76
|
+
const claimed = prepared ? undefined : this.#locks?.claimAll(before.documents, `document:${operation.op}`)
|
|
77
|
+
if (claimed && 'holder' in claimed) throw lockedError(claimed.document, claimed.holder)
|
|
78
|
+
release ??= claimed?.release
|
|
79
|
+
let mutationStarted = false
|
|
80
|
+
try {
|
|
81
|
+
const result = await this.#apply(operation, member, () => { mutationStarted = true; discardCheckpoint = false }, prepared)
|
|
82
|
+
discardCheckpoint = true
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (!mutationStarted) throw error
|
|
87
|
+
let after: string | undefined
|
|
88
|
+
try { after = await this.#revisions.head() }
|
|
89
|
+
catch { throw new RequestError(500, 'project-commit-state-unavailable', { committed: 'unknown', recovery: before.recovery }) }
|
|
90
|
+
if (after === undefined) throw new RequestError(500, 'project-commit-state-unavailable', { committed: 'unknown', recovery: before.recovery })
|
|
91
|
+
if (after !== head) { discardCheckpoint = true; committedError = new RequestError(409, 'project-readback-changed', { committed: true, snapshot: after }); return undefined }
|
|
92
|
+
try { await before.restore() }
|
|
93
|
+
catch { throw new RequestError(500, 'project-rollback-failed', { committed: false, recovery: before.recovery }) }
|
|
94
|
+
discardCheckpoint = true
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
} finally { if (discardCheckpoint) try { before.dispose() } catch (error) { cleanupError = error } }
|
|
98
|
+
})
|
|
99
|
+
if (committedError) throw committedError
|
|
100
|
+
if (!answer) throw new RequestError(409, 'project-result-unavailable')
|
|
101
|
+
if (cleanupError) throw new RequestError(500, 'project-checkpoint-cleanup-failed', { committed: Boolean(answer.revision), snapshot: await this.#revisions.head() })
|
|
102
|
+
const added = operation.op === 'delete' ? [] : [answer.dir!]
|
|
103
|
+
const removed = operation.op === 'rename' || operation.op === 'delete' ? [operation.dir] : []
|
|
104
|
+
await this.#changed({ removed, added })
|
|
105
|
+
return answer
|
|
106
|
+
} finally {
|
|
107
|
+
release?.()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async #fill(dir: string, fill: () => Promise<void>): Promise<void> {
|
|
112
|
+
makeDocumentDir(this.#config.root, dir)
|
|
113
|
+
try {
|
|
114
|
+
await fill()
|
|
115
|
+
} catch (error) {
|
|
116
|
+
await this.#remove(dir).catch(() => undefined)
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async #remove(dir: string): Promise<void> {
|
|
122
|
+
for (let attempt = 0; ; attempt++) {
|
|
123
|
+
await this.#writer.empty(dir)
|
|
124
|
+
try {
|
|
125
|
+
rmdirSync(join(this.#config.root, dir))
|
|
126
|
+
return
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (attempt > 0 || (error as NodeJS.ErrnoException).code !== 'ENOTEMPTY') throw new RequestError(409, `${dir} could not be removed: ${error instanceof Error ? error.message : String(error)}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async #apply(operation: DocumentOperation, member: Member, beforeWrite: () => void, prepared?: { documentId?: string, origin: 'editor' | 'agent', changes?: DocumentFileChange[] }): Promise<OperationAnswer> {
|
|
134
|
+
const config = this.#config
|
|
135
|
+
const origin = (done: RevisionOperation): CommitOrigin => ({ kind: prepared?.origin === 'agent' ? 'agent' : 'document', member, operation: done })
|
|
136
|
+
const commit = (paths: string[], summary: string, body: string, done: RevisionOperation) => this.#revisions.commitWhileLocked({ paths, summary, body, origin: origin(done) })
|
|
137
|
+
const known = listDocuments(config).map(doc => doc.dir)
|
|
138
|
+
const existing = (dir: string) => {
|
|
139
|
+
if (!known.includes(dir)) throw new RequestError(404, `no document ${JSON.stringify(dir)}`)
|
|
140
|
+
return dir
|
|
141
|
+
}
|
|
142
|
+
const fresh = (dir: string) => {
|
|
143
|
+
const invalid = invalidDocumentDir(dir)
|
|
144
|
+
if (invalid) throw new RequestError(400, invalid)
|
|
145
|
+
if (!isDocumentDir(config, dir)) throw new RequestError(400, `"${dir}" is not where the workspace configuration's documents are (${config.documents.join(', ')})`)
|
|
146
|
+
const holder = known.find(doc => doc !== '' && (isWithin(dir, doc) || isWithin(doc, dir)))
|
|
147
|
+
if (holder !== undefined) throw new RequestError(409, `${dir} would hold or be inside the document ${holder}`)
|
|
148
|
+
if (existsSync(join(config.root, dir))) throw new RequestError(409, `${dir} exists already`)
|
|
149
|
+
return dir
|
|
150
|
+
}
|
|
151
|
+
switch (operation.op) {
|
|
152
|
+
case 'create': {
|
|
153
|
+
const dir = fresh(operation.dir)
|
|
154
|
+
const templates = listTemplates(config)
|
|
155
|
+
const name = operation.template ?? (templates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : templates[0])
|
|
156
|
+
if (!name || !templates.includes(name)) throw new RequestError(400, `no document template ${JSON.stringify(name ?? '')}`)
|
|
157
|
+
const title = operation.title?.trim() || dir.split('/').at(-1)!
|
|
158
|
+
beforeWrite()
|
|
159
|
+
await this.#fill(dir, async () => { await this.#writer.template(dir, name, title, prepared?.documentId); if (prepared?.changes) await this.#writer.write(prepared.changes) })
|
|
160
|
+
const revision = await commit([dir], `Create document ${dir}`, `Title: ${title}\nTemplate: ${name}`, { op: 'create', dir })
|
|
161
|
+
return { revision, dir }
|
|
162
|
+
}
|
|
163
|
+
case 'rename': {
|
|
164
|
+
const from = existing(operation.dir)
|
|
165
|
+
const to = fresh(operation.to)
|
|
166
|
+
beforeWrite()
|
|
167
|
+
mkdirSync(dirname(join(config.root, to)), { recursive: true })
|
|
168
|
+
renameSync(join(config.root, from), join(config.root, to))
|
|
169
|
+
const rewritten = (await this.#writer.relink(from, to)).filter(path => !isWithin(path, to))
|
|
170
|
+
if (prepared?.changes) { await this.#writer.write(prepared.changes); rewritten.push(...prepared.changes.map(change => change.path)) }
|
|
171
|
+
const revision = await commit([from, to, ...rewritten], `Rename document ${from} to ${to}`, rewritten.length ? `Links rewritten in: ${rewritten.join(', ')}` : '', { op: 'rename', dir: from, to })
|
|
172
|
+
return { revision, dir: to }
|
|
173
|
+
}
|
|
174
|
+
case 'copy': {
|
|
175
|
+
const from = existing(operation.dir)
|
|
176
|
+
const to = fresh(operation.to)
|
|
177
|
+
beforeWrite()
|
|
178
|
+
await this.#fill(to, async () => { await this.#writer.copy(from, to, prepared?.documentId, operation.title); if (prepared?.changes) await this.#writer.write(prepared.changes) })
|
|
179
|
+
const revision = await commit([to], `Copy document ${from} to ${to}`, '', { op: 'copy', dir: from, to })
|
|
180
|
+
return { revision, dir: to }
|
|
181
|
+
}
|
|
182
|
+
case 'delete': {
|
|
183
|
+
const dir = existing(operation.dir)
|
|
184
|
+
beforeWrite()
|
|
185
|
+
await this.#remove(dir)
|
|
186
|
+
const revision = await commit([dir], `Delete document ${dir}`, '', { op: 'delete', dir })
|
|
187
|
+
return { revision }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|