import type { IControllerDraftAttachment, IControllerRuntimeId, IControllerSessionDraft, IControllerSessionDraftUpdate, } from '../ts_interfaces/interfaces.js'; const defaultDebounceMs = 150; const maximumConflictRetries = 4; export interface ISessionDraftSyncTransport { getDraft( projectIdArg: string, sessionIdArg: IControllerRuntimeId, ): Promise; updateDraft( projectIdArg: string, sessionIdArg: IControllerRuntimeId, expectedRevisionArg: number, patchArg: { text?: string; attachments?: IControllerDraftAttachment[] }, ): Promise; isConflict(errorArg: unknown): boolean; } export interface ISessionDraftSyncState extends IControllerSessionDraft { projectId: string; sessionId: IControllerRuntimeId; loading: boolean; saving: boolean; error: string; } /** * One exact draft revision prepared for an operation which may consume it. The token keeps the * settlement paired with the active draft instance even if navigation activates another session. */ export interface ISessionDraftSubmission extends IControllerSessionDraft { readonly token: symbol; readonly projectId: string; readonly sessionId: IControllerRuntimeId; } export interface ISessionDraftSyncOptions { transport: ISessionDraftSyncTransport; onState: (stateArg: ISessionDraftSyncState | undefined) => void; onError?: (errorArg: unknown) => void; debounceMs?: number; } interface IActiveDraft { projectId: string; sessionId: IControllerRuntimeId; generation: number; authoritative: IControllerSessionDraft; desiredText: string; desiredAttachments: IControllerDraftAttachment[]; dirtyText: boolean; dirtyAttachments: boolean; drainAfterDeactivate: boolean; loading: boolean; saving: boolean; error: string; debounceTimer?: ReturnType; hydrateTask?: Promise; writeTask?: Promise; submissionToken?: symbol; } interface IPreparedSubmission { active: IActiveDraft; snapshot: IControllerSessionDraft; } const cloneAttachments = ( attachmentsArg: readonly IControllerDraftAttachment[], ): IControllerDraftAttachment[] => attachmentsArg.map((attachment) => ({ ...attachment })); const cloneDraft = (draftArg: IControllerSessionDraft): IControllerSessionDraft => ({ text: draftArg.text, attachments: cloneAttachments(draftArg.attachments), revision: draftArg.revision, }); 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; }); const runtimeIdsEqual = (leftArg: IControllerRuntimeId, rightArg: IControllerRuntimeId): boolean => ( leftArg.harnessId === rightArg.harnessId && leftArg.nativeId === rightArg.nativeId ); export class SessionDraftSync { private readonly debounceMs: number; private generation = 0; private active?: IActiveDraft; private readonly submissions = new Map(); private disposed = false; constructor(private readonly options: ISessionDraftSyncOptions) { this.debounceMs = options.debounceMs ?? defaultDebounceMs; } public get state(): ISessionDraftSyncState | undefined { return this.active ? this.publicState(this.active) : undefined; } public async activate( projectIdArg: string, sessionIdArg: IControllerRuntimeId, ): Promise { if (this.disposed) return undefined; this.deactivate(); const generation = ++this.generation; const active: IActiveDraft = { projectId: projectIdArg, sessionId: { ...sessionIdArg }, generation, authoritative: { text: '', attachments: [], revision: 0 }, desiredText: '', desiredAttachments: [], dirtyText: false, dirtyAttachments: false, drainAfterDeactivate: false, loading: true, saving: false, error: '', }; this.active = active; this.emit(active); await this.hydrate(active); return this.isCurrent(active) ? this.publicState(active) : undefined; } public deactivate(): void { const active = this.active; if (active?.debounceTimer) clearTimeout(active.debounceTimer); if ( active && !active.loading && (active.dirtyText || active.dirtyAttachments || active.submissionToken) ) { active.drainAfterDeactivate = true; if (!active.submissionToken && (active.dirtyText || active.dirtyAttachments) && !active.writeTask) { const writeTask = this.flushActive(active); active.writeTask = writeTask; void writeTask .catch((errorArg) => this.options.onError?.(errorArg)) .finally(() => { if (active.writeTask === writeTask) active.writeTask = undefined; }); } } this.generation += 1; this.active = undefined; this.options.onState(undefined); } public setText(textArg: string): void { const active = this.active; if (!active || active.loading) return; active.desiredText = textArg; active.dirtyText = active.desiredText !== active.authoritative.text; active.error = ''; this.emit(active); this.schedule(active); } public setAttachments(attachmentsArg: readonly IControllerDraftAttachment[]): void { const active = this.active; if (!active || active.loading) return; active.desiredAttachments = cloneAttachments(attachmentsArg); active.dirtyAttachments = !attachmentsEqual( active.desiredAttachments, active.authoritative.attachments, ); active.error = ''; this.emit(active); this.schedule(active); } public async flush(): Promise { const active = this.active; if (!active) throw new Error('No session draft is active.'); if (active.submissionToken) throw new Error('A draft submission is already pending.'); if (active.debounceTimer) { clearTimeout(active.debounceTimer); active.debounceTimer = undefined; } if (active.loading) await this.hydrate(active); if (!this.isCurrent(active)) throw new Error('The session draft changed during synchronization.'); if (active.writeTask) return active.writeTask; const writeTask = this.flushActive(active); active.writeTask = writeTask; try { return await writeTask; } finally { if (active.writeTask === writeTask) active.writeTask = undefined; } } /** * Saves one exact click-time snapshot and suspends later draft writes until its consumer settles. * This prevents a later keystroke from winning the server revision race before the operation can * reserve the returned revision. */ public async prepareSubmission( textArg: string, attachmentsArg: readonly IControllerDraftAttachment[], ): Promise { const active = this.active; if (!active) throw new Error('No session draft is active.'); if (active.loading) throw new Error('The session draft is still loading.'); if (active.submissionToken) throw new Error('A draft submission is already pending.'); if (active.debounceTimer) { clearTimeout(active.debounceTimer); active.debounceTimer = undefined; } active.desiredText = textArg; active.desiredAttachments = cloneAttachments(attachmentsArg); active.dirtyText = active.desiredText !== active.authoritative.text; active.dirtyAttachments = !attachmentsEqual( active.desiredAttachments, active.authoritative.attachments, ); active.error = ''; const token = Symbol('draft-submission'); active.submissionToken = token; this.emit(active); try { await active.writeTask; const snapshot = await this.persistSubmissionSnapshot(active, { text: textArg, attachments: cloneAttachments(attachmentsArg), }); this.submissions.set(token, { active, snapshot }); return { token, projectId: active.projectId, sessionId: { ...active.sessionId }, ...cloneDraft(snapshot), }; } catch (errorArg) { active.submissionToken = undefined; throw errorArg; } } /** * Rebases after the operation and releases edits made since its click snapshot. Acceptance * removes only snapshot fields the user has not changed again; rejection keeps the snapshot. */ public async settleSubmission( submissionArg: ISessionDraftSubmission, acceptedArg: boolean, resumeWritesArg = true, ): Promise { const prepared = this.submissions.get(submissionArg.token); if (!prepared || prepared.active.submissionToken !== submissionArg.token) return; const { active, snapshot } = prepared; try { await this.hydrate(active); } catch (errorArg) { this.options.onError?.(errorArg); } finally { this.submissions.delete(submissionArg.token); active.submissionToken = undefined; if (acceptedArg) { if (active.desiredText === snapshot.text) { active.desiredText = active.authoritative.text; } if (attachmentsEqual(active.desiredAttachments, snapshot.attachments)) { active.desiredAttachments = cloneAttachments(active.authoritative.attachments); } } active.dirtyText = active.desiredText !== active.authoritative.text; active.dirtyAttachments = !attachmentsEqual( active.desiredAttachments, active.authoritative.attachments, ); this.emit(active); if ( resumeWritesArg && this.canSynchronize(active) && (active.dirtyText || active.dirtyAttachments) ) { this.resumeWrites(active); } else { active.drainAfterDeactivate = false; } } } /** * Clears a prepared draft only while its click-time revision is still authoritative. This is * used after a client-owned action has actually opened: the controller authorizes the action * without consuming its slash text, so the browser performs the same exact-revision CAS before * releasing later keystrokes. A concurrent remote change is never overwritten or retried. */ public async consumeSubmission( submissionArg: ISessionDraftSubmission, flushNewerEditsArg = false, ): Promise { const prepared = this.submissions.get(submissionArg.token); if (!prepared || prepared.active.submissionToken !== submissionArg.token) { throw new Error('The prepared draft submission is no longer active.'); } const { active, snapshot } = prepared; try { const response = await this.options.transport.updateDraft( active.projectId, active.sessionId, snapshot.revision, { text: '', attachments: [] }, ); active.authoritative = cloneDraft(response); await this.settleSubmission(submissionArg, true); if (flushNewerEditsArg) await this.flushPreparedEdits(active); } catch (errorArg) { await this.settleSubmission(submissionArg, false, false); throw errorArg; } } public async refresh(): Promise { const active = this.active; if (!active) return undefined; await this.hydrate(active); return this.isCurrent(active) ? this.publicState(active) : undefined; } public applyRemoteUpdate( projectIdArg: string, sessionIdArg: IControllerRuntimeId, updateArg: IControllerSessionDraftUpdate, ): boolean { const active = this.active; if ( !active || active.projectId !== projectIdArg || !runtimeIdsEqual(active.sessionId, sessionIdArg) || updateArg.revision <= active.authoritative.revision ) return false; if (updateArg.revision !== active.authoritative.revision + 1) { void this.hydrate(active).catch((errorArg) => this.options.onError?.(errorArg)); return true; } active.authoritative = { text: updateArg.text ?? active.authoritative.text, attachments: updateArg.attachments === undefined ? active.authoritative.attachments : cloneAttachments(updateArg.attachments), revision: updateArg.revision, }; if (!active.dirtyText && updateArg.text !== undefined) active.desiredText = updateArg.text; if (!active.dirtyAttachments && updateArg.attachments !== undefined) { active.desiredAttachments = cloneAttachments(updateArg.attachments); } active.dirtyText = active.desiredText !== active.authoritative.text; active.dirtyAttachments = !attachmentsEqual( active.desiredAttachments, active.authoritative.attachments, ); this.emit(active); if (active.dirtyText || active.dirtyAttachments) this.schedule(active); return true; } public dispose(): void { if (this.disposed) return; this.disposed = true; this.deactivate(); } private async hydrate(activeArg: IActiveDraft): Promise { if (activeArg.hydrateTask) return activeArg.hydrateTask; let hydrateTask!: Promise; hydrateTask = (async () => { activeArg.loading = true; this.emit(activeArg); try { const draft = await this.options.transport.getDraft(activeArg.projectId, activeArg.sessionId); if (!this.canSynchronize(activeArg)) return; activeArg.authoritative = cloneDraft(draft); if (!activeArg.dirtyText) activeArg.desiredText = draft.text; if (!activeArg.dirtyAttachments) { activeArg.desiredAttachments = cloneAttachments(draft.attachments); } activeArg.dirtyText = activeArg.desiredText !== draft.text; activeArg.dirtyAttachments = !attachmentsEqual( activeArg.desiredAttachments, draft.attachments, ); activeArg.error = ''; } catch (errorArg) { if (this.isCurrent(activeArg)) activeArg.error = this.errorMessage(errorArg); throw errorArg; } finally { activeArg.loading = false; this.emit(activeArg); } })(); activeArg.hydrateTask = hydrateTask; try { await hydrateTask; } finally { if (activeArg.hydrateTask === hydrateTask) activeArg.hydrateTask = undefined; } } private async flushActive(activeArg: IActiveDraft): Promise { activeArg.saving = true; this.emit(activeArg); let conflictAttempts = 0; try { while ( this.canSynchronize(activeArg) && !activeArg.submissionToken && (activeArg.dirtyText || activeArg.dirtyAttachments) ) { const sentText = activeArg.desiredText; const sentAttachments = cloneAttachments(activeArg.desiredAttachments); const sentTextField = activeArg.dirtyText; const sentAttachmentField = activeArg.dirtyAttachments; const expectedRevision = activeArg.authoritative.revision; let response: IControllerSessionDraft; try { response = await this.options.transport.updateDraft( activeArg.projectId, activeArg.sessionId, expectedRevision, { ...(sentTextField ? { text: sentText } : {}), ...(sentAttachmentField ? { attachments: sentAttachments } : {}), }, ); } catch (errorArg) { if ( this.options.transport.isConflict(errorArg) && conflictAttempts < maximumConflictRetries ) { conflictAttempts += 1; await this.hydrate(activeArg); continue; } throw errorArg; } if (!this.canSynchronize(activeArg)) break; conflictAttempts = 0; if (response.revision >= activeArg.authoritative.revision) { activeArg.authoritative = cloneDraft(response); if (sentTextField && activeArg.desiredText === sentText) activeArg.dirtyText = false; if ( sentAttachmentField && attachmentsEqual(activeArg.desiredAttachments, sentAttachments) ) activeArg.dirtyAttachments = false; } activeArg.dirtyText = activeArg.desiredText !== activeArg.authoritative.text; activeArg.dirtyAttachments = !attachmentsEqual( activeArg.desiredAttachments, activeArg.authoritative.attachments, ); activeArg.error = ''; this.emit(activeArg); } return { text: activeArg.desiredText, attachments: cloneAttachments(activeArg.desiredAttachments), revision: activeArg.authoritative.revision, }; } catch (errorArg) { if (this.isCurrent(activeArg)) { activeArg.error = this.errorMessage(errorArg); this.emit(activeArg); } throw errorArg; } finally { activeArg.saving = false; this.emit(activeArg); if (!activeArg.submissionToken) activeArg.drainAfterDeactivate = false; } } private schedule(activeArg: IActiveDraft): void { if (!this.isCurrent(activeArg) || activeArg.loading || activeArg.submissionToken) return; if (activeArg.debounceTimer) clearTimeout(activeArg.debounceTimer); activeArg.debounceTimer = setTimeout(() => { activeArg.debounceTimer = undefined; void this.flush().catch((errorArg) => this.options.onError?.(errorArg)); }, this.debounceMs); } private async persistSubmissionSnapshot( activeArg: IActiveDraft, snapshotArg: Pick, ): Promise { activeArg.saving = true; this.emit(activeArg); let conflictAttempts = 0; try { while (this.canSynchronize(activeArg)) { const textChanged = snapshotArg.text !== activeArg.authoritative.text; const attachmentsChanged = !attachmentsEqual( snapshotArg.attachments, activeArg.authoritative.attachments, ); if (!textChanged && !attachmentsChanged) { return { text: snapshotArg.text, attachments: cloneAttachments(snapshotArg.attachments), revision: activeArg.authoritative.revision, }; } try { const response = await this.options.transport.updateDraft( activeArg.projectId, activeArg.sessionId, activeArg.authoritative.revision, { ...(textChanged ? { text: snapshotArg.text } : {}), ...(attachmentsChanged ? { attachments: cloneAttachments(snapshotArg.attachments) } : {}), }, ); if (!this.canSynchronize(activeArg)) { throw new Error('The session draft changed during submission preparation.'); } activeArg.authoritative = cloneDraft(response); activeArg.dirtyText = activeArg.desiredText !== activeArg.authoritative.text; activeArg.dirtyAttachments = !attachmentsEqual( activeArg.desiredAttachments, activeArg.authoritative.attachments, ); activeArg.error = ''; this.emit(activeArg); return { text: snapshotArg.text, attachments: cloneAttachments(snapshotArg.attachments), revision: response.revision, }; } catch (errorArg) { if ( this.options.transport.isConflict(errorArg) && conflictAttempts < maximumConflictRetries ) { conflictAttempts += 1; await this.hydrate(activeArg); continue; } throw errorArg; } } throw new Error('The session draft changed during submission preparation.'); } finally { activeArg.saving = false; this.emit(activeArg); } } private resumeWrites(activeArg: IActiveDraft): void { if (this.isCurrent(activeArg)) { this.schedule(activeArg); return; } if (!activeArg.drainAfterDeactivate || activeArg.writeTask) return; const writeTask = this.flushActive(activeArg); activeArg.writeTask = writeTask; void writeTask .catch((errorArg) => this.options.onError?.(errorArg)) .finally(() => { if (activeArg.writeTask === writeTask) activeArg.writeTask = undefined; }); } /** * A navigation-producing action must leave edits typed after its click snapshot durably saved * on the source conversation before another draft is activated. Settlement normally resumes * those edits through the debounce path; this explicit drain gives that navigation a completion * boundary without exposing the inactive draft as the current one. */ private async flushPreparedEdits(activeArg: IActiveDraft): Promise { if (activeArg.debounceTimer) { clearTimeout(activeArg.debounceTimer); activeArg.debounceTimer = undefined; } if (activeArg.writeTask) await activeArg.writeTask; if ( !this.canSynchronize(activeArg) || activeArg.submissionToken || (!activeArg.dirtyText && !activeArg.dirtyAttachments) ) return; const writeTask = this.flushActive(activeArg); activeArg.writeTask = writeTask; try { await writeTask; } finally { if (activeArg.writeTask === writeTask) activeArg.writeTask = undefined; } } private isCurrent(activeArg: IActiveDraft): boolean { return !this.disposed && this.active === activeArg && this.generation === activeArg.generation; } private canSynchronize(activeArg: IActiveDraft): boolean { return this.isCurrent(activeArg) || activeArg.drainAfterDeactivate; } private emit(activeArg: IActiveDraft): void { if (this.isCurrent(activeArg)) this.options.onState(this.publicState(activeArg)); } private publicState(activeArg: IActiveDraft): ISessionDraftSyncState { return { projectId: activeArg.projectId, sessionId: { ...activeArg.sessionId }, text: activeArg.desiredText, attachments: cloneAttachments(activeArg.desiredAttachments), revision: activeArg.authoritative.revision, loading: activeArg.loading, saving: activeArg.saving, error: activeArg.error, }; } private errorMessage(errorArg: unknown): string { return errorArg instanceof Error ? errorArg.message : 'Draft synchronization failed.'; } }