import { controllerMaxDraftAttachmentBytes, controllerMaxDraftAttachmentTotalBytes, controllerMaxDraftAttachments, controllerMaxDraftTextBytes, type IControllerDraftAttachment, type IControllerSessionDraft, type IControllerSessionDraftUpdate, } from '../ts_interfaces/interfaces.js'; const maximumDraftIdentities = 4_096; const maximumNonEmptyDrafts = 512; const maximumAggregateAttachmentBytes = 64 * 1024 * 1024; export class ControllerDraftConflictError extends Error { constructor(public readonly draft: IControllerSessionDraft) { super('The composer draft changed concurrently.'); this.name = 'ControllerDraftConflictError'; } } export class ControllerDraftLimitError extends Error { constructor(messageArg: string) { super(messageArg); this.name = 'ControllerDraftLimitError'; } } export interface IControllerDraftMutationResult { draft: IControllerSessionDraft; update?: IControllerSessionDraftUpdate; } const cloneAttachment = ( attachmentArg: IControllerDraftAttachment, ): IControllerDraftAttachment => ({ ...attachmentArg }); const cloneDraft = (draftArg: IControllerSessionDraft): IControllerSessionDraft => ({ text: draftArg.text, attachments: draftArg.attachments.map(cloneAttachment), revision: draftArg.revision, }); const emptyDraft = (): IControllerSessionDraft => ({ text: '', attachments: [], revision: 0 }); const attachmentBytes = (draftArg: IControllerSessionDraft): number => ( draftArg.attachments.reduce((sum, attachment) => sum + attachment.size, 0) ); const isNonEmpty = (draftArg: IControllerSessionDraft): boolean => ( draftArg.text.length > 0 || draftArg.attachments.length > 0 ); const attachmentsEqual = ( leftArg: readonly IControllerDraftAttachment[], rightArg: readonly IControllerDraftAttachment[], ): boolean => leftArg.length === rightArg.length && leftArg.every((left, index) => { const right = rightArg[index]; return right !== undefined && left.id === right.id && left.name === right.name && left.mediaType === right.mediaType && left.size === right.size && left.kind === right.kind && left.dataBase64 === right.dataBase64; }); export class ControllerDraftManager { private readonly drafts = new Map(); private nonEmptyDrafts = 0; private aggregateAttachmentBytes = 0; public get(keyArg: string): IControllerSessionDraft { return cloneDraft(this.drafts.get(keyArg) ?? emptyDraft()); } public update( keyArg: string, expectedRevisionArg: number, patchArg: { text?: string; attachments?: readonly IControllerDraftAttachment[] }, ): IControllerDraftMutationResult { const current = this.drafts.get(keyArg) ?? emptyDraft(); if (current.revision !== expectedRevisionArg) { throw new ControllerDraftConflictError(cloneDraft(current)); } if (patchArg.text === undefined && patchArg.attachments === undefined) { throw new Error('A draft update must replace text, attachments, or both.'); } const nextText = patchArg.text ?? current.text; const nextAttachments = patchArg.attachments === undefined ? current.attachments : patchArg.attachments.map(cloneAttachment); this.assertDraftFields(nextText, nextAttachments); const textChanged = nextText !== current.text; const attachmentsChanged = !attachmentsEqual(nextAttachments, current.attachments); if (!textChanged && !attachmentsChanged) return { draft: cloneDraft(current) }; if (current.revision >= Number.MAX_SAFE_INTEGER) { throw new ControllerDraftLimitError('The draft revision limit was reached.'); } const next: IControllerSessionDraft = { text: nextText, attachments: nextAttachments, revision: current.revision + 1, }; this.assertAdmission(keyArg, current, next); this.store(keyArg, current, next); return { draft: cloneDraft(next), update: { revision: next.revision, ...(textChanged ? { text: next.text } : {}), ...(attachmentsChanged ? { attachments: next.attachments.map(cloneAttachment) } : {}), }, }; } public snapshot(keyArg: string, revisionArg: number): IControllerSessionDraft { const current = this.drafts.get(keyArg) ?? emptyDraft(); if (current.revision !== revisionArg) { throw new ControllerDraftConflictError(cloneDraft(current)); } if (current.revision >= Number.MAX_SAFE_INTEGER) { throw new ControllerDraftLimitError('The draft revision limit was reached.'); } if (!current.text.trim()) throw new Error('The composer draft text must not be blank.'); return cloneDraft(current); } public clearExact( keyArg: string, revisionArg: number, ): IControllerDraftMutationResult { const current = this.drafts.get(keyArg); if (!current || current.revision !== revisionArg) { return { draft: cloneDraft(current ?? emptyDraft()) }; } if (current.revision >= Number.MAX_SAFE_INTEGER) { throw new ControllerDraftLimitError('The draft revision limit was reached.'); } const next: IControllerSessionDraft = { text: '', attachments: [], revision: current.revision + 1, }; this.store(keyArg, current, next); return { draft: cloneDraft(next), update: { revision: next.revision, text: '', attachments: [] }, }; } public purge(keyArg: string): void { const current = this.drafts.get(keyArg); if (!current) return; this.drafts.delete(keyArg); if (isNonEmpty(current)) this.nonEmptyDrafts -= 1; this.aggregateAttachmentBytes -= attachmentBytes(current); } public purgePrefix(prefixArg: string): void { for (const key of this.drafts.keys()) { if (key.startsWith(prefixArg)) this.purge(key); } } public clear(): void { this.drafts.clear(); this.nonEmptyDrafts = 0; this.aggregateAttachmentBytes = 0; } private assertDraftFields( textArg: string, attachmentsArg: readonly IControllerDraftAttachment[], ): void { if (Buffer.byteLength(textArg, 'utf8') > controllerMaxDraftTextBytes) { throw new ControllerDraftLimitError('The draft text exceeds the controller byte limit.'); } if (attachmentsArg.length > controllerMaxDraftAttachments) { throw new ControllerDraftLimitError('The draft attachment count exceeds the controller limit.'); } const ids = new Set(); let bytes = 0; for (const attachment of attachmentsArg) { if (ids.has(attachment.id)) throw new Error('Draft attachment IDs must be unique.'); ids.add(attachment.id); if (attachment.size > controllerMaxDraftAttachmentBytes) { throw new ControllerDraftLimitError('A draft attachment exceeds the controller byte limit.'); } bytes += attachment.size; } if (bytes > controllerMaxDraftAttachmentTotalBytes) { throw new ControllerDraftLimitError('The draft attachments exceed the controller total byte limit.'); } } private assertAdmission( keyArg: string, currentArg: IControllerSessionDraft, nextArg: IControllerSessionDraft, ): void { if (!this.drafts.has(keyArg) && this.drafts.size >= maximumDraftIdentities) { throw new ControllerDraftLimitError('The controller draft identity limit is exhausted.'); } const nextNonEmptyCount = this.nonEmptyDrafts - (isNonEmpty(currentArg) ? 1 : 0) + (isNonEmpty(nextArg) ? 1 : 0); if (nextNonEmptyCount > maximumNonEmptyDrafts) { throw new ControllerDraftLimitError('The controller non-empty draft limit is exhausted.'); } const nextAttachmentBytes = this.aggregateAttachmentBytes - attachmentBytes(currentArg) + attachmentBytes(nextArg); if (nextAttachmentBytes > maximumAggregateAttachmentBytes) { throw new ControllerDraftLimitError('The controller draft attachment memory limit is exhausted.'); } } private store( keyArg: string, currentArg: IControllerSessionDraft, nextArg: IControllerSessionDraft, ): void { this.nonEmptyDrafts += (isNonEmpty(nextArg) ? 1 : 0) - (isNonEmpty(currentArg) ? 1 : 0); this.aggregateAttachmentBytes += attachmentBytes(nextArg) - attachmentBytes(currentArg); this.drafts.set(keyArg, nextArg); } }