@lincyaw/kernel 0.0.0-stage → 1.0.0-rc.1

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 (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +32 -2
  3. package/package.json +41 -3
  4. package/src/access.ts +18 -0
  5. package/src/anchors.ts +58 -0
  6. package/src/authoring.ts +56 -0
  7. package/src/blocks.ts +19 -0
  8. package/src/checks.ts +35 -0
  9. package/src/component-code.ts +6 -0
  10. package/src/contracts/agent.ts +76 -0
  11. package/src/contracts/anchors.ts +46 -0
  12. package/src/contracts/authoring-actions.ts +60 -0
  13. package/src/contracts/authoring-clients.ts +213 -0
  14. package/src/contracts/authoring.ts +180 -0
  15. package/src/contracts/checks.ts +29 -0
  16. package/src/contracts/classroom.ts +49 -0
  17. package/src/contracts/diagnostics.ts +37 -0
  18. package/src/contracts/document-audio.ts +150 -0
  19. package/src/contracts/document-comments.ts +31 -0
  20. package/src/contracts/document-history.ts +63 -0
  21. package/src/contracts/document-image.ts +32 -0
  22. package/src/contracts/document-reads.ts +57 -0
  23. package/src/contracts/document-voice.ts +44 -0
  24. package/src/contracts/document.ts +240 -0
  25. package/src/contracts/execution.ts +118 -0
  26. package/src/contracts/locale.ts +3 -0
  27. package/src/contracts/offline.ts +2 -0
  28. package/src/contracts/operations.ts +8 -0
  29. package/src/contracts/plugin.ts +183 -0
  30. package/src/contracts/presenter-panels.ts +17 -0
  31. package/src/contracts/preview.ts +386 -0
  32. package/src/contracts/production-assets.ts +61 -0
  33. package/src/contracts/production.ts +109 -0
  34. package/src/contracts/projects.ts +59 -0
  35. package/src/contracts/server.ts +108 -0
  36. package/src/contracts/service.ts +8 -0
  37. package/src/contracts/speech-settings.ts +16 -0
  38. package/src/contracts/speech.ts +149 -0
  39. package/src/contracts/steps.ts +15 -0
  40. package/src/contracts/transcription-adoption.ts +79 -0
  41. package/src/contracts/transcription.ts +112 -0
  42. package/src/contracts/voice.ts +97 -0
  43. package/src/contracts/voiceover-execution.ts +68 -0
  44. package/src/contracts/workspace.ts +458 -0
  45. package/src/contracts.ts +35 -0
  46. package/src/diagnostics.ts +7 -0
  47. package/src/document-authoring-actions.ts +47 -0
  48. package/src/document-caption-timings.ts +64 -0
  49. package/src/document-colors.ts +25 -0
  50. package/src/document-comments.ts +118 -0
  51. package/src/document-components.ts +251 -0
  52. package/src/document-operations.ts +132 -0
  53. package/src/document-pages.ts +215 -0
  54. package/src/document-reads.ts +81 -0
  55. package/src/document-schema.ts +85 -0
  56. package/src/document-voices.ts +153 -0
  57. package/src/document.ts +252 -0
  58. package/src/durations.ts +7 -0
  59. package/src/errors.ts +15 -0
  60. package/src/events.ts +32 -0
  61. package/src/execution.ts +25 -0
  62. package/src/fingerprint.ts +31 -0
  63. package/src/follow.ts +33 -0
  64. package/src/git.ts +12 -0
  65. package/src/html.ts +41 -0
  66. package/src/index.ts +82 -0
  67. package/src/ir.ts +367 -0
  68. package/src/jupyter-kernel.ts +9 -0
  69. package/src/kernel.ts +190 -0
  70. package/src/keyboard.ts +43 -0
  71. package/src/locale.ts +13 -0
  72. package/src/lock.ts +81 -0
  73. package/src/logging.ts +11 -0
  74. package/src/lower-myst.ts +312 -0
  75. package/src/node/assets.ts +23 -0
  76. package/src/node/authoring-actions.ts +50 -0
  77. package/src/node/authoring-clients.ts +14 -0
  78. package/src/node/document-audio.ts +22 -0
  79. package/src/node/document-history.ts +11 -0
  80. package/src/node/document-image.ts +8 -0
  81. package/src/node/document-reads.ts +10 -0
  82. package/src/node/document-voice.ts +7 -0
  83. package/src/node/ffmpeg.ts +29 -0
  84. package/src/node/file-version.ts +6 -0
  85. package/src/node/fonts.ts +22 -0
  86. package/src/node/index.ts +29 -0
  87. package/src/node/installed-packages.ts +11 -0
  88. package/src/node/lines.ts +24 -0
  89. package/src/node/locale.ts +7 -0
  90. package/src/node/network.ts +11 -0
  91. package/src/node/operator-speech.ts +91 -0
  92. package/src/node/packages.ts +74 -0
  93. package/src/node/paths.ts +7 -0
  94. package/src/node/projects.ts +7 -0
  95. package/src/node/resources.ts +86 -0
  96. package/src/node/revision.ts +6 -0
  97. package/src/node/run-directory.ts +70 -0
  98. package/src/node/strip-types.mjs +15 -0
  99. package/src/node/transcription-adoption.ts +9 -0
  100. package/src/node/transcription.ts +10 -0
  101. package/src/node/voiceover-routes.ts +73 -0
  102. package/src/node/voiceover.ts +199 -0
  103. package/src/page-source.ts +359 -0
  104. package/src/parameters.ts +81 -0
  105. package/src/preview-edit.ts +18 -0
  106. package/src/preview-events.ts +57 -0
  107. package/src/preview-frame.ts +1220 -0
  108. package/src/props.ts +17 -0
  109. package/src/published.ts +11 -0
  110. package/src/push-stream.ts +67 -0
  111. package/src/reading.ts +96 -0
  112. package/src/recording.ts +38 -0
  113. package/src/renderer-components.ts +30 -0
  114. package/src/requests.ts +28 -0
  115. package/src/responses.ts +49 -0
  116. package/src/run-outputs.ts +15 -0
  117. package/src/runner.py +454 -0
  118. package/src/services.ts +146 -0
  119. package/src/slides.ts +36 -0
  120. package/src/speech-words.ts +76 -0
  121. package/src/speech.ts +13 -0
  122. package/src/step-keys.ts +38 -0
  123. package/src/steps.ts +7 -0
  124. package/src/transcription-adoption.ts +105 -0
  125. package/src/uri-decoding.ts +8 -0
  126. package/src/urls.ts +13 -0
  127. package/src/views.ts +47 -0
  128. package/src/voice.ts +86 -0
@@ -0,0 +1,118 @@
1
+ import { hasRole } from './access.ts'
2
+ import type { DocumentCommentAction, DocumentCommentContext, DocumentCommentEffect } from './contracts/document-comments.ts'
3
+ import type { DocumentComment, DocumentThread, InlineNode, JsonObject, ListItemNode, RichTextNode, TableCellNode, TableRowNode, TeachingDocument } from './contracts/document.ts'
4
+
5
+ export class DocumentCommentError extends Error {
6
+ readonly status: number
7
+ constructor(status: number, code: string) { super(code); this.status = status; this.name = 'DocumentCommentError' }
8
+ }
9
+ function fail(status: number, code: string): never { throw new DocumentCommentError(status, code) }
10
+
11
+ /** Canonical anchor v1 expands only nonempty styled-text links; other inline values are atoms. */
12
+ export function isDocumentTextLink(node: InlineNode): node is Extract<InlineNode, { type: 'link' }> & { children: Extract<InlineNode, { type: 'text' | 'break' }>[] } {
13
+ return node.type === 'link' && node.children.length > 0 && node.children.every(child => child.type === 'text' || child.type === 'break')
14
+ }
15
+ function inlineText(nodes: readonly InlineNode[]): string {
16
+ return nodes.map(node => node.type === 'text' ? node.text : node.type === 'break' ? '\n' : isDocumentTextLink(node) ? inlineText(node.children) : '\uFFFC').join('')
17
+ }
18
+
19
+ /** Only addressable canonical text identities, in native document order; opaque data is never traversed. */
20
+ export function documentCommentTargets(document: TeachingDocument): Map<string, { order: number, text: string }> {
21
+ const targets = new Map<string, { order: number, text: string }>()
22
+ const add = (id: string, text: string) => { targets.set(id, { order: targets.size, text }) }
23
+ const rich = (node: RichTextNode): void => {
24
+ if (node.type === 'paragraph' || node.type === 'heading') add(node.id, inlineText(node.children))
25
+ else if (node.type === 'code') add(node.id, node.value)
26
+ else if (node.type === 'list') for (const item of node.children) {
27
+ const [first, ...rest] = item.children
28
+ add(item.id, first?.type === 'paragraph' ? inlineText(first.children) : '')
29
+ ;(first?.type === 'paragraph' ? rest : item.children).forEach(rich)
30
+ }
31
+ else if (node.type === 'blockquote') {
32
+ const [first, ...rest] = node.children
33
+ if (first?.type === 'paragraph') { add(node.id, inlineText(first.children)); rest.forEach(rich) }
34
+ else node.children.forEach(rich)
35
+ } else if (node.type === 'table') for (const row of node.children) for (const cell of row.children) add(cell.id, inlineText(cell.children))
36
+ }
37
+ const block = (id: string): void => {
38
+ const value = document.blocks[id]
39
+ if (value?.type === 'text') value.content.forEach(rich)
40
+ else if (value?.type === 'code' && value.value !== undefined) add(id, value.value)
41
+ else if (value?.type === 'container' || value?.type === 'component') value.children?.forEach(block)
42
+ }
43
+ for (const id of document.sectionOrder) { const page = document.sections[id]!; add(`page:${id}`, page.title); page.children.forEach(block) }
44
+ return targets
45
+ }
46
+
47
+ /** No ordinal, offset clamping, surrogate splitting or guessed fallback identity. */
48
+ export function validateDocumentCommentAnchor(document: TeachingDocument, anchor: DocumentThread['anchor']): void {
49
+ if (!anchor) return
50
+ const targets = documentCommentTargets(document), a = targets.get(anchor.from.blockId), b = targets.get(anchor.to.blockId)
51
+ if (!a || !b) fail(422, 'comment-anchor-target-unavailable')
52
+ for (const [point, target] of [[anchor.from, a], [anchor.to, b]] as const) {
53
+ const offset = point.offset
54
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > target.text.length
55
+ || offset > 0 && offset < target.text.length && /[\uD800-\uDBFF]/.test(target.text[offset - 1]!) && /[\uDC00-\uDFFF]/.test(target.text[offset]!)) fail(422, 'comment-anchor-offset-invalid')
56
+ }
57
+ if (a.order > b.order || a.order === b.order && anchor.from.offset >= anchor.to.offset) fail(422, 'comment-anchor-range-invalid')
58
+ }
59
+
60
+ /** Shared native/editor permissions: editors may delete any discussion, but edit their own comment; a document owner may manage earlier authors. */
61
+ export function applyDocumentCommentAction(source: NonNullable<TeachingDocument['comments']>, action: DocumentCommentAction, context: DocumentCommentContext): { threads: NonNullable<TeachingDocument['comments']>, effect: DocumentCommentEffect } {
62
+ const member = context.member
63
+ if (!member?.id || !hasRole(member, 'editor')) fail(403, 'comment-permission-denied')
64
+ if (!Number.isFinite(Date.parse(context.now))) fail(422, 'comment-clock-invalid')
65
+ const allocate = () => { const id = context.allocate(); if (!id || id.length > 200 || ['__proto__', 'prototype', 'constructor'].includes(id)) fail(422, 'comment-id-invalid'); return id }
66
+ const content = (value: RichTextNode[]): RichTextNode[] => {
67
+ const result = structuredClone(value)
68
+ const fresh = (node: RichTextNode | ListItemNode | TableCellNode | TableRowNode): void => {
69
+ node.id = allocate()
70
+ if (node.type === 'list' || node.type === 'listItem' || node.type === 'blockquote' || node.type === 'table' || node.type === 'tableRow') node.children.forEach(fresh)
71
+ else if (node.type === 'extension') node.fallback.forEach(fresh)
72
+ }
73
+ if (context.allocateContentIds) result.forEach(fresh)
74
+ return result
75
+ }
76
+ const create = (value: RichTextNode[], metadata?: JsonObject): DocumentComment => ({ id: allocate(), type: 'comment', userId: member.id, createdAt: context.now, updatedAt: context.now, content: content(value), reactions: [], ...metadata ? { metadata: structuredClone(metadata) } : {} })
77
+ let thread: DocumentThread, commentId: string | undefined, changed = true
78
+ if (action.type === 'comment.create') {
79
+ const first = create(action.content, action.commentMetadata)
80
+ thread = { id: allocate(), type: 'thread', createdAt: context.now, updatedAt: context.now, resolved: false, comments: [first], ...action.anchor ? { anchor: structuredClone(action.anchor) } : {}, ...action.metadata ? { metadata: structuredClone(action.metadata) } : {} }
81
+ if (Object.hasOwn(source, thread.id)) fail(409, 'comment-id-repeated')
82
+ commentId = first.id
83
+ } else {
84
+ if (!Object.hasOwn(source, action.threadId)) fail(404, 'document-thread-missing')
85
+ if (source[action.threadId]!.deletedAt) fail(409, 'document-thread-deleted')
86
+ thread = structuredClone(source[action.threadId]!)
87
+ const find = (id: string) => { const comment = thread.comments.find(value => value.id === id); if (!comment) return fail(404, 'document-comment-missing'); if (comment.deletedAt) return fail(409, 'document-comment-deleted'); return comment }
88
+ switch (action.type) {
89
+ case 'comment.reply': { const comment = create(action.content, action.metadata); thread.comments.push(comment); commentId = comment.id; break }
90
+ case 'comment.update': {
91
+ const comment = find(action.commentId); if (comment.userId !== member.id && !hasRole(member, 'owner')) fail(403, 'comment-author-required')
92
+ commentId = comment.id; comment.content = content(action.content); comment.updatedAt = context.now
93
+ if (action.metadata) comment.metadata = structuredClone(action.metadata)
94
+ break
95
+ }
96
+ case 'comment.react': {
97
+ const comment = find(action.commentId), reaction = comment.reactions.find(value => value.emoji === action.emoji)
98
+ if (!action.emoji || action.emoji.length > 32) fail(422, 'comment-reaction-invalid')
99
+ commentId = comment.id; changed = Boolean(reaction?.userIds.includes(member.id)) !== action.active
100
+ if (changed && action.active) { if (reaction) reaction.userIds.push(member.id); else comment.reactions.push({ emoji: action.emoji, createdAt: context.now, userIds: [member.id] }) }
101
+ else if (changed && reaction) { reaction.userIds = reaction.userIds.filter(id => id !== member.id); comment.reactions = comment.reactions.filter(value => value.userIds.length) }
102
+ break
103
+ }
104
+ case 'comment.resolve': case 'comment.reopen':
105
+ changed = thread.resolved !== (action.type === 'comment.resolve')
106
+ if (changed) { thread.resolved = action.type === 'comment.resolve'; thread.resolvedBy = member.id; thread.resolvedUpdatedAt = context.now }
107
+ break
108
+ case 'comment.delete':
109
+ if (action.commentId) { find(action.commentId); commentId = action.commentId; thread.comments = thread.comments.filter(value => value.id !== commentId) }
110
+ else thread.comments = []
111
+ break
112
+ }
113
+ if (changed) thread.updatedAt = context.now
114
+ }
115
+ const threads = changed ? { ...source } : source, removed = !thread.comments.length
116
+ if (changed) { if (removed) delete threads[thread.id]; else Object.defineProperty(threads, thread.id, { value: thread, enumerable: true, configurable: true, writable: true }) }
117
+ return { threads, effect: { threadId: thread.id, ...commentId ? { commentId } : {}, changed, anchored: !removed && Boolean(thread.anchor), threadRemoved: removed, removedComments: Math.max(0, (source[thread.id]?.comments.length ?? 0) - thread.comments.length), comments: thread.comments.length, reactions: thread.comments.reduce((sum, comment) => sum + comment.reactions.length, 0) } }
118
+ }
@@ -0,0 +1,251 @@
1
+ import jsonPatch from 'fast-json-patch'
2
+ import type { Operation } from 'fast-json-patch'
3
+ import { validateDocument } from './document.ts'
4
+ import { documentParameterAccess } from './parameters.ts'
5
+ import { DOCUMENT_PARAMETER_MAX_CHANGES, DOCUMENT_PARAMETER_MAX_PATH_DEPTH } from './contracts/authoring-actions.ts'
6
+ import type { DocumentComponentAction, DocumentComponentEffect, DocumentParameterChange } from './contracts/authoring-actions.ts'
7
+ import type { ComponentBlock, Definition, JsonObject, JsonSchema, JsonValue, TeachingDocument, VisualState } from './contracts/document.ts'
8
+
9
+ const { applyPatch, compare } = jsonPatch
10
+
11
+ export class DocumentComponentError extends Error {
12
+ readonly status: number
13
+ readonly detail?: Record<string, unknown>
14
+ constructor(status: number, code: string, detail?: Record<string, unknown>) { super(code); this.status = status; this.detail = detail }
15
+ }
16
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
17
+ const own = (value: object, key: string) => Object.hasOwn(value, key)
18
+ const fail = (status: number, code: string, detail?: Record<string, unknown>): never => { throw new DocumentComponentError(status, code, detail) }
19
+ const pointer = (path: readonly string[]) => '/' + path.map(key => key.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')
20
+ const unpointer = (path: string) => path.slice(1).split('/').map(key => key.replaceAll('~1', '/').replaceAll('~0', '~'))
21
+ function component(document: TeachingDocument, id: string): ComponentBlock {
22
+ const block = own(document.blocks, id) ? document.blocks[id] : undefined
23
+ if (!block) return fail(404, 'component-block-missing', { blockId: id })
24
+ if (block.type !== 'component') return fail(422, 'component-block-type', { blockId: id })
25
+ return block
26
+ }
27
+ export function documentComponentSection(document: TeachingDocument, blockId: string): string {
28
+ component(document, blockId)
29
+ for (const sectionId of document.sectionOrder) {
30
+ const pending = [...document.sections[sectionId]!.children], seen = new Set<string>()
31
+ while (pending.length) {
32
+ const id = pending.pop()!
33
+ if (id === blockId) return sectionId
34
+ if (seen.has(id)) continue
35
+ seen.add(id)
36
+ const block = document.blocks[id]
37
+ if (block?.type === 'container' || block?.type === 'component') pending.push(...block.children ?? [])
38
+ }
39
+ }
40
+ return fail(422, 'component-block-owner-missing', { blockId })
41
+ }
42
+ function stepOf(document: TeachingDocument, stepId: string, sectionId: string) {
43
+ const step = own(document.steps, stepId) ? document.steps[stepId] : undefined
44
+ if (!step) return fail(404, 'component-step-missing', { stepId })
45
+ if (step.sectionId !== sectionId) return fail(422, 'component-step-owner', { stepId, sectionId })
46
+ return step
47
+ }
48
+
49
+ /** Resolve the canonical shallow overlays, including all stable-step ancestors; no runtime snapshot is read. */
50
+ export function effectiveComponentParameters(document: TeachingDocument, blockId: string, stepId?: string): { parameters: JsonObject, visual?: VisualState, ownParameters?: JsonObject } {
51
+ const block = component(document, blockId)
52
+ if (stepId === undefined) return { parameters: structuredClone(block.parameters) }
53
+ const sectionId = documentComponentSection(document, blockId), target = stepOf(document, stepId, sectionId), chain: string[] = [], seen = new Set<string>()
54
+ let id: string | undefined = stepId
55
+ while (id !== undefined) {
56
+ if (seen.has(id)) return fail(422, 'component-step-cycle', { stepId })
57
+ seen.add(id); chain.unshift(id); id = stepOf(document, id, sectionId).inherits
58
+ }
59
+ let visual: VisualState | undefined
60
+ for (const id of chain) {
61
+ const value = document.steps[id]!.visuals.find(value => value.blockId === blockId)
62
+ if (value) visual = { ...visual, ...structuredClone(value), parameters: { ...visual?.parameters, ...value.parameters } }
63
+ }
64
+ const ownParameters = target.visuals.find(value => value.blockId === blockId)?.parameters
65
+ return { parameters: { ...structuredClone(block.parameters), ...structuredClone(visual?.parameters ?? {}) }, ...visual ? { visual } : {}, ...ownParameters !== undefined ? { ownParameters: structuredClone(ownParameters) } : {} }
66
+ }
67
+
68
+ /** Validate domain parameter paths, then delegate actual object/array mutation to the public RFC6902 library. */
69
+ function patchParameters(source: JsonObject, changes: readonly DocumentParameterChange[]): JsonObject {
70
+ if (!changes.length || changes.length > DOCUMENT_PARAMETER_MAX_CHANGES) return fail(422, 'component-parameter-change-limit', { limits: { maxChanges: DOCUMENT_PARAMETER_MAX_CHANGES } })
71
+ const value = structuredClone(source)
72
+ for (const [index, change] of changes.entries()) {
73
+ const path = change.path
74
+ if (!path.length || path.length > DOCUMENT_PARAMETER_MAX_PATH_DEPTH || path.some(key => typeof key !== 'string' || key.length > 200 || key === '__proto__')) return fail(422, 'component-parameter-path-invalid', { index, limits: { maxPathDepth: DOCUMENT_PARAMETER_MAX_PATH_DEPTH, maxSegmentChars: 200 } })
75
+ let parent: JsonValue = value
76
+ for (const key of path.slice(0, -1)) {
77
+ if (!parent || typeof parent !== 'object' || !own(parent, key) || Array.isArray(parent) && !/^(0|[1-9][0-9]*)$/.test(key)) return fail(404, 'component-parameter-path-missing', { index, path })
78
+ parent = (parent as JsonObject)[key]!
79
+ }
80
+ const key = path.at(-1)!
81
+ if (!parent || typeof parent !== 'object') return fail(422, 'component-parameter-parent-type', { index, path })
82
+ if (Array.isArray(parent) && (!/^(0|[1-9][0-9]*)$/.test(key) || !Number.isSafeInteger(Number(key)) || Number(key) > parent.length)) return fail(422, 'component-parameter-array-index', { index, path })
83
+ if (change.op === 'remove' && !own(parent, key)) return fail(404, 'component-parameter-path-missing', { index, path })
84
+ const operation: Operation = change.op === 'remove' ? { op: 'remove', path: pointer(path) }
85
+ : { op: Array.isArray(parent) && own(parent, key) ? 'replace' : 'add', path: pointer(path), value: structuredClone(change.value) }
86
+ try { applyPatch(value, [operation], true, true, true) }
87
+ catch (error) { return fail(422, 'component-parameter-path-invalid', { index, path, reason: error instanceof Error ? error.name.slice(0, 80) : 'invalid-patch' }) }
88
+ }
89
+ return value
90
+ }
91
+
92
+ type ParameterValue = JsonValue | typeof absentParameter
93
+ const absentParameter = Symbol('absent-parameter')
94
+ /** Annotation checks complement JSON Schema validity; they do not interpret schema branches or references. */
95
+ function guardParameterMutation(before: JsonObject, after: JsonObject, beforeSchemas: readonly JsonSchema[], afterSchemas: readonly JsonSchema[], newDefinition = false, changes: readonly DocumentParameterChange[] = [], restoring = false): void {
96
+ let visited = 0
97
+ const location = (path: string[]) => ({ path: path.slice(0, 8).map(part => part.slice(0, 80)), ...path.length > 8 || path.some(part => part.length > 80) ? { pathTruncated: true } : {} })
98
+ const inspect = (schemas: readonly JsonSchema[], value: JsonObject, path: string[], publicRequired: boolean) => {
99
+ let declared = false
100
+ for (const schema of schemas) {
101
+ const access = documentParameterAccess(schema, value, path)
102
+ if (!access.available) return fail(422, 'component-parameter-access-unavailable', { ...location(path), reason: access.reason?.slice(0, 120), next: 'Keep the draft and use an explicitly supported public schema path.' })
103
+ if (access.readOnly) return fail(422, 'component-parameter-read-only', location(path))
104
+ declared ||= access.public
105
+ }
106
+ if (publicRequired && !declared) return fail(422, 'component-parameter-not-public', location(path))
107
+ }
108
+ const check = (left: ParameterValue, right: ParameterValue, path: string[], beforePath = path) => {
109
+ if (left !== absentParameter) inspect(beforeSchemas, before, beforePath, !newDefinition)
110
+ if (right !== absentParameter) inspect(afterSchemas, after, path, true)
111
+ else if (newDefinition) inspect(afterSchemas, after, path, true)
112
+ }
113
+ const itemKeys = (values: JsonValue[]) => values.map(value => JSON.stringify(value))
114
+ const subsequence = (shorter: string[], longer: string[]): number[] | undefined => {
115
+ const matched: number[] = []; let next = 0
116
+ for (let index = 0; index < longer.length && next < shorter.length; index++) if (longer[index] === shorter[next]) { matched.push(index); next++ }
117
+ return next === shorter.length ? matched : undefined
118
+ }
119
+ // Domain set never inserts in the middle: explicit remove/append gives an exact transient index mapping.
120
+ const arrayLineage = (left: JsonValue[], right: JsonValue[], path: string[]) => {
121
+ if (!changes.length || changes.some(change => change.path.length <= path.length && change.path.every((part, index) => path[index] === part))) return
122
+ const relevant = changes.filter(change => path.every((part, index) => change.path[index] === part))
123
+ if (!relevant.length) return
124
+ const mapping = left.map((_, index) => index)
125
+ for (const change of relevant) if (change.path.length === path.length + 1) {
126
+ const index = Number(change.path.at(-1))
127
+ if (!Number.isSafeInteger(index) || index < 0 || index > mapping.length) return
128
+ if (change.op === 'remove') mapping.splice(index, 1)
129
+ else if (index === mapping.length) mapping.push(-1)
130
+ }
131
+ return mapping.length === right.length ? mapping : undefined
132
+ }
133
+ const object = (value: ParameterValue): value is JsonObject | JsonValue[] => value !== absentParameter && value !== null && typeof value === 'object'
134
+ const walk = (left: ParameterValue, right: ParameterValue, path: string[], beforePath = path) => {
135
+ if (left !== absentParameter && right !== absentParameter && same(left, right)) return
136
+ if (++visited > 8192 || path.length > 128) return fail(422, 'component-parameter-access-unavailable', { reason: 'change-complexity-limit', limits: { maxChangedNodes: 8192, maxInspectionDepth: 128 }, next: 'Apply smaller public field changes separately.' })
137
+ check(left, right, path, beforePath)
138
+ if (Array.isArray(left) && Array.isArray(right)) {
139
+ const a = itemKeys(left), b = itemKeys(right)
140
+ // Reordering retains exact complete items, including their unknown and read-only data.
141
+ if (a.length === b.length && same([...a].sort(), [...b].sort())) return
142
+ const retained = subsequence(b, a)
143
+ if (retained) {
144
+ const keep = new Set(retained)
145
+ for (let index = 0; index < left.length; index++) if (!keep.has(index)) inspect(beforeSchemas, before, [...beforePath, String(index)], true)
146
+ return
147
+ }
148
+ const oldItems = subsequence(a, b)
149
+ if (oldItems) {
150
+ const keep = new Set(oldItems)
151
+ for (let index = 0; index < right.length; index++) if (!keep.has(index)) walk(absentParameter, right[index]!, [...path, String(index)], [...beforePath, String(index)])
152
+ return
153
+ }
154
+ const mapping = arrayLineage(left, right, path)
155
+ if (mapping) {
156
+ const retained = new Set(mapping)
157
+ for (let index = 0; index < left.length; index++) if (!retained.has(index)) inspect(beforeSchemas, before, [...beforePath, String(index)], true)
158
+ for (const [index, oldIndex] of mapping.entries()) walk(oldIndex < 0 ? absentParameter : left[oldIndex]!, right[index]!, [...path, String(index)], [...beforePath, String(oldIndex)])
159
+ return
160
+ }
161
+ const oldUnmatched = a.flatMap((value, index) => value === b[index] ? [] : [{ value, index }])
162
+ const newUnmatched = b.flatMap((value, index) => value === a[index] ? [] : [{ value, index }])
163
+ if (!restoring && newUnmatched.some(item => oldUnmatched.some(old => item.value === old.value && item.index !== old.index))) return fail(422, 'component-parameter-array-change-unavailable', { ...location(path), next: 'Apply the exact item reorder and field changes in separate source-bound plans.' })
164
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
165
+ if (index >= right.length) inspect(beforeSchemas, before, [...beforePath, String(index)], true)
166
+ else walk(index < left.length ? left[index]! : absentParameter, right[index]!, [...path, String(index)], [...beforePath, String(index)])
167
+ }
168
+ return
169
+ }
170
+ if (object(left) || object(right)) {
171
+ for (const key of new Set([...object(left) ? Object.keys(left) : [], ...object(right) ? Object.keys(right) : []])) {
172
+ walk(object(left) && own(left, key) ? (left as JsonObject)[key]! : absentParameter, object(right) && own(right, key) ? (right as JsonObject)[key]! : absentParameter, [...path, key], [...beforePath, key])
173
+ }
174
+ }
175
+ }
176
+ const changed = new Set(compare(before, after).map(operation => unpointer(operation.path)[0]!))
177
+ if (!changed.size) return
178
+ check(before, after, [])
179
+ for (const key of changed) walk(own(before, key) ? before[key]! : absentParameter, own(after, key) ? after[key]! : absentParameter, [key])
180
+ }
181
+
182
+ function componentSchemas(definition: Definition, visual?: VisualState): JsonSchema[] {
183
+ const state = visual?.state ? definition.states.find(state => state.id === visual.state)?.parameterSchema : undefined
184
+ return [definition.parameterSchema, ...state === undefined ? [] : [state]]
185
+ }
186
+
187
+ /** Diff only changed parameter values; unchanged opaque or large fields never become request arguments. */
188
+ export function createDocumentParameterChanges(before: JsonObject, after: JsonObject): DocumentParameterChange[] {
189
+ const changes: DocumentParameterChange[] = compare(before, after).map(operation => {
190
+ if (operation.op === 'remove') return { op: 'remove', path: unpointer(operation.path) }
191
+ if (operation.op === 'add' || operation.op === 'replace') return { op: 'set', path: unpointer(operation.path), value: structuredClone(operation.value) as JsonValue }
192
+ return fail(422, 'component-parameter-diff-unavailable')
193
+ })
194
+ if (!changes.length) return []
195
+ if (changes.length <= DOCUMENT_PARAMETER_MAX_CHANGES && same(patchParameters(before, changes), after)) return changes
196
+ // A compare implementation may express array insertion differently from this domain's set semantics.
197
+ // Replacing only the affected top-level fields remains exact and is still subject to the request budget.
198
+ const fields = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(key => !same(before[key], after[key])).map(key => own(after, key)
199
+ ? { op: 'set' as const, path: [key], value: structuredClone(after[key]!) } : { op: 'remove' as const, path: [key] })
200
+ if (fields.length > DOCUMENT_PARAMETER_MAX_CHANGES) return fail(422, 'component-parameter-change-limit', { limits: { maxChanges: DOCUMENT_PARAMETER_MAX_CHANGES } })
201
+ return fields
202
+ }
203
+
204
+ /** One source-bound component edit. Definition implementations and every unrelated authored field stay shared. */
205
+ export function applyDocumentComponentAction(source: TeachingDocument, action: DocumentComponentAction): { content: TeachingDocument, effect: DocumentComponentEffect } {
206
+ const original = component(source, action.blockId), sectionId = documentComponentSection(source, original.id), content = structuredClone(source), block = component(content, original.id)
207
+ const stepId = action.type === 'component.step-parameters.patch' || action.type === 'component.step-parameters.reset' ? action.stepId : undefined
208
+ if (action.type === 'component.parameters.patch') block.parameters = patchParameters(block.parameters, action.changes)
209
+ else if (action.type === 'component.definition.use') {
210
+ if (!own(content.definitions, action.definitionId)) return fail(404, 'component-definition-missing', { definitionId: action.definitionId })
211
+ block.definitionId = action.definitionId
212
+ if (action.changes?.length) block.parameters = patchParameters(block.parameters, action.changes)
213
+ } else {
214
+ const step = stepOf(content, action.stepId, sectionId), ownVisual = step.visuals.find(value => value.blockId === block.id)
215
+ if (action.type === 'component.step-parameters.reset') {
216
+ if (ownVisual) delete ownVisual.parameters
217
+ } else {
218
+ const effective = effectiveComponentParameters(source, block.id, action.stepId), patched = patchParameters(effective.parameters, action.changes)
219
+ const local = structuredClone(ownVisual?.parameters ?? {}), keys = new Set(action.changes.map(change => change.path[0]!))
220
+ const inherited = structuredClone(source), inheritedVisual = inherited.steps[action.stepId]!.visuals.find(value => value.blockId === block.id)
221
+ if (inheritedVisual) delete inheritedVisual.parameters
222
+ const lower = effectiveComponentParameters(inherited, block.id, action.stepId).parameters
223
+ for (const key of keys) {
224
+ if (!own(patched, key)) {
225
+ if (own(lower, key)) return fail(422, 'component-parameter-inherited-removal-unavailable', { path: [key], next: 'Reset the current parameter override to restore inheritance, or set a schema-valid value.' })
226
+ delete local[key]
227
+ } else Object.defineProperty(local, key, { value: structuredClone(patched[key]), configurable: true, enumerable: true, writable: true })
228
+ }
229
+ if (ownVisual) ownVisual.parameters = local
230
+ else step.visuals.push({ blockId: block.id, visible: effective.visual?.visible ?? false, parameters: local })
231
+ }
232
+ }
233
+ const checked = validateDocument(content)
234
+ if (!checked.document) return fail(422, 'component-parameters-invalid', { issueCount: checked.issues.length, issues: checked.issues.slice(0, 3).map(issue => ({ code: issue.code.slice(0, 64), path: issue.path.slice(0, 160), message: issue.message.slice(0, 160), severity: issue.severity })) })
235
+ const beforeDefinition = source.definitions[original.definitionId]!, afterDefinition = content.definitions[block.definitionId]!, newDefinition = original.definitionId !== block.definitionId
236
+ guardParameterMutation(original.parameters, block.parameters, componentSchemas(beforeDefinition), componentSchemas(afterDefinition), newDefinition, 'changes' in action ? action.changes : undefined, action.type === 'component.step-parameters.reset')
237
+ const steps: string[] = [], preserved: string[] = [], materialized: string[] = [], reset: string[] = []
238
+ for (const id of source.sections[sectionId]!.stepOrder) {
239
+ const before = effectiveComponentParameters(source, block.id, id), after = effectiveComponentParameters(content, block.id, id)
240
+ guardParameterMutation(before.parameters, after.parameters, componentSchemas(beforeDefinition, before.visual), componentSchemas(afterDefinition, after.visual), newDefinition, 'changes' in action ? action.changes : undefined, action.type === 'component.step-parameters.reset')
241
+ if (newDefinition || !same(before.parameters, after.parameters)) steps.push(id)
242
+ if (before.ownParameters !== undefined && same(before.ownParameters, after.ownParameters)) preserved.push(id)
243
+ else if (after.ownParameters !== undefined) materialized.push(id)
244
+ else if (before.ownParameters !== undefined) reset.push(id)
245
+ }
246
+ const changed = !same(source, content)
247
+ const references = (definitionId: string) => Object.values(source.blocks).filter(value => value.type === 'component' && value.definitionId === definitionId).map(value => value.id)
248
+ return { content, effect: { blockId: block.id, sectionId, layer: action.type === 'component.definition.use' ? 'definition' : stepId ? 'step' : 'base', ...stepId ? { stepId } : {}, definitionId: block.definitionId, ...block.definitionId !== original.definitionId ? { previousDefinitionId: original.definitionId } : {}, changed,
249
+ paths: 'changes' in action ? (action.changes ?? []).map(change => [...change.path]) : [], affected: { blocks: changed ? [block.id] : [], steps, pages: changed ? [sectionId] : [] }, overrides: { preserved, materialized, reset },
250
+ references: { before: references(original.definitionId), after: Object.values(content.blocks).filter(value => value.type === 'component' && value.definitionId === block.definitionId).map(value => value.id) } } }
251
+ }
@@ -0,0 +1,132 @@
1
+ import type { DocumentBlock, DocumentEditOperation, TeachingDocument } from './contracts/document.ts'
2
+
3
+ /** Operations describe author intent; the caller validates the resulting complete document before committing it. */
4
+ export function applyDocumentOperations(source: TeachingDocument, operations: readonly DocumentEditOperation[]): TeachingDocument {
5
+ if (!operations.length || operations.length > 1000) throw new Error('document-operation-count')
6
+ const document = structuredClone(source)
7
+ const block = (id: string): DocumentBlock => {
8
+ if (!Object.hasOwn(document.blocks, id)) throw new Error('document-block-missing')
9
+ return document.blocks[id]!
10
+ }
11
+ const section = (id: string) => {
12
+ if (!Object.hasOwn(document.sections, id)) throw new Error('document-section-missing')
13
+ return document.sections[id]!
14
+ }
15
+ const step = (id: string) => {
16
+ if (!Object.hasOwn(document.steps, id)) throw new Error('document-step-missing')
17
+ return document.steps[id]!
18
+ }
19
+ const insert = (items: string[], index: number, id: string) => {
20
+ if (!Number.isInteger(index) || index < 0 || index > items.length || items.includes(id)) throw new Error('document-order-invalid')
21
+ items.splice(index, 0, id)
22
+ }
23
+ const descendants = (id: string, seen = new Set<string>()): Set<string> => {
24
+ if (seen.has(id)) throw new Error('document-tree-cycle')
25
+ seen.add(id)
26
+ const value = block(id)
27
+ if (value.type === 'container' || value.type === 'component') for (const child of value.children ?? []) descendants(child, seen)
28
+ return seen
29
+ }
30
+ const children = (sectionId: string, parentId?: string): string[] => {
31
+ const owner = section(sectionId)
32
+ if (parentId === undefined) return owner.children
33
+ const parent = block(parentId)
34
+ if (parent.type !== 'container' || !owner.children.some(id => descendants(id).has(parentId))) throw new Error('document-parent-invalid')
35
+ return parent.children
36
+ }
37
+ const detach = (id: string) => {
38
+ const parents = [
39
+ ...Object.values(document.sections).map(value => value.children),
40
+ ...Object.values(document.blocks).flatMap(value => value.type === 'container' || value.type === 'component' && value.children ? [value.children!] : []),
41
+ ].filter(items => items.includes(id))
42
+ if (parents.length !== 1) throw new Error('document-parent-invalid')
43
+ parents[0]!.splice(parents[0]!.indexOf(id), 1)
44
+ }
45
+ const put = <T>(into: Record<string, T>, id: string, value: T) => {
46
+ if (!id || ['__proto__', 'prototype', 'constructor'].includes(id)) throw new Error('document-id-invalid')
47
+ Object.defineProperty(into, id, { value: structuredClone(value), enumerable: true, configurable: true, writable: true })
48
+ }
49
+ for (const operation of operations) {
50
+ switch (operation.type) {
51
+ case 'insertBlock': {
52
+ if (Object.hasOwn(document.blocks, operation.block.id)) throw new Error('document-id-repeated')
53
+ const list = children(operation.sectionId, operation.parentId)
54
+ insert(list, operation.index, operation.block.id)
55
+ put(document.blocks, operation.block.id, operation.block)
56
+ // New document content is visible by default. Later author operations
57
+ // may explicitly change its teaching states in this same transaction.
58
+ for (const id of section(operation.sectionId).stepOrder) step(id).visuals.push({ blockId: operation.block.id, visible: true })
59
+ break
60
+ }
61
+ case 'moveBlock': {
62
+ const moved = descendants(operation.blockId)
63
+ if (operation.parentId && moved.has(operation.parentId)) throw new Error('document-tree-cycle')
64
+ const list = children(operation.sectionId, operation.parentId)
65
+ detach(operation.blockId)
66
+ insert(list, operation.index, operation.blockId)
67
+ const targetSteps = new Set(section(operation.sectionId).stepOrder)
68
+ for (const value of Object.values(document.steps)) {
69
+ if (!targetSteps.has(value.id)) value.visuals = value.visuals.filter(visual => !moved.has(visual.blockId))
70
+ else for (const id of moved) if (!value.visuals.some(visual => visual.blockId === id)) value.visuals.push({ blockId: id, visible: true })
71
+ }
72
+ break
73
+ }
74
+ case 'deleteBlock': {
75
+ const removed = descendants(operation.blockId)
76
+ for (const narration of Object.values(document.narrations)) if (narration.content.some(node => node.type === 'cue' && node.target && removed.has(node.target.blockId))) throw new Error('document-block-referenced-by-cue')
77
+ const identities = new Set<string>(removed)
78
+ const visit = (value: unknown): void => {
79
+ if (Array.isArray(value)) { value.forEach(visit); return }
80
+ if (!value || typeof value !== 'object') return
81
+ for (const [key, child] of Object.entries(value)) { if (key === 'id' && typeof child === 'string') identities.add(child); else visit(child) }
82
+ }
83
+ for (const id of removed) visit(block(id))
84
+ // A deleted range becomes an unanchored thread; its discussion survives.
85
+ for (const thread of Object.values(document.comments ?? {})) if (thread.anchor && (identities.has(thread.anchor.from.blockId) || identities.has(thread.anchor.to.blockId))) delete thread.anchor
86
+ detach(operation.blockId)
87
+ for (const id of removed) delete document.blocks[id]
88
+ for (const value of Object.values(document.steps)) value.visuals = value.visuals.filter(visual => !removed.has(visual.blockId))
89
+ break
90
+ }
91
+ case 'updateText': {
92
+ const value = block(operation.blockId)
93
+ if (value.type !== 'text') throw new Error('document-block-type')
94
+ value.content = structuredClone(operation.content)
95
+ break
96
+ }
97
+ case 'updateSection': {
98
+ const value = section(operation.sectionId)
99
+ if (operation.title !== undefined) value.title = operation.title
100
+ if (operation.layout !== undefined) value.layout = structuredClone(operation.layout)
101
+ break
102
+ }
103
+ case 'setStepState': {
104
+ const value = step(operation.stepId)
105
+ value.visuals = structuredClone(operation.visuals)
106
+ if (operation.inherits !== undefined) value.inherits = operation.inherits
107
+ else delete value.inherits
108
+ break
109
+ }
110
+ case 'setNarration': {
111
+ put(document.narrations, operation.narration.id, operation.narration)
112
+ if (operation.stepId) {
113
+ const value = step(operation.stepId)
114
+ if (!value.narrationIds.includes(operation.narration.id)) insert(value.narrationIds, operation.index ?? value.narrationIds.length, operation.narration.id)
115
+ }
116
+ break
117
+ }
118
+ case 'setTiming':
119
+ if (operation.stepId && operation.sectionId) throw new Error('document-timing-scope')
120
+ if (operation.stepId) { step(operation.stepId); put(document.production.stepOverrides, operation.stepId, operation.value) }
121
+ else if (operation.sectionId) { section(operation.sectionId); document.production.sectionOverrides ??= {}; put(document.production.sectionOverrides, operation.sectionId, operation.value) }
122
+ else document.production.defaults = structuredClone(operation.value)
123
+ break
124
+ case 'replaceAsset':
125
+ if (!Object.hasOwn(document.assets, operation.asset.id)) throw new Error('document-asset-missing')
126
+ put(document.assets, operation.asset.id, operation.asset)
127
+ break
128
+ default: throw new Error('document-operation-unsupported')
129
+ }
130
+ }
131
+ return document
132
+ }