feat: consolidate local-first mnote web runtime

This commit is contained in:
lix-2026
2026-05-28 22:01:44 +08:00
parent 7354807ee9
commit 39b9a0183a
154 changed files with 13591 additions and 12728 deletions
@@ -5,6 +5,7 @@ import {
hydrateMindmapAttrsFromDom,
legacyBlocksFromEditorDocument,
pageBodyTiptapDocument,
pageBodyTiptapDocumentSource,
revisionFromConflictKey,
textToTiptapDocument,
toTiptapDocument,
@@ -102,7 +103,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const normalizeSessionSourceKind = (bootstrap) => {
const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : '';
return value || 'convex_workspace';
return value || 'local_folder';
};
const buildDocumentSessionKey = (bootstrap) => {
@@ -115,7 +116,90 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const sessionViews = (session) => Array.from(session.views.values());
const localFolderEventChannelKey = (session) => `${String(session?.rootUri || '').trim()}#${String(session?.documentId || '').trim()}`;
const resourceWatchPath = (session) => String(session?.resourcePath || session?.relativePath || '').trim();
const localFolderEventChannelKey = (session) => {
const scope = session?.sessionKind === 'resource'
? `resource:${resourceWatchPath(session)}`
: String(session?.documentId || '').trim();
return `${String(session?.rootUri || '').trim()}#${scope}`;
};
const localMarkdownRelativePathFromDocumentId = (documentId) => {
const value = String(documentId || '').trim();
if (!value.startsWith('local-md:')) return '';
return value.slice('local-md:'.length).replace(/~2F/g, '/');
};
const sessionRelativePath = (session) => (
String(session?.relativePath || '').trim()
|| localMarkdownRelativePathFromDocumentId(session?.documentId)
);
const sessionBufferStateUrl = (session) => {
if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;
const url = new URL('/api/documents/buffer-state', window.location.origin);
url.searchParams.set('documentId', session.documentId);
url.searchParams.set('sourceKind', session.sourceKind);
url.searchParams.set('rootUri', session.rootUri);
if (session.workspaceId) url.searchParams.set('workspaceId', session.workspaceId);
const relativePath = sessionRelativePath(session);
if (relativePath) url.searchParams.set('relativePath', relativePath);
return url;
};
const applyBufferStateToSession = (session, bufferState) => {
if (!session || !bufferState || typeof bufferState !== 'object') return;
const dirtyState = String(bufferState.dirtyState || '').trim();
if (dirtyState) session.bufferDirtyState = dirtyState;
if (typeof bufferState.fileVersion === 'string' && bufferState.fileVersion.trim()) {
session.fileVersion = bufferState.fileVersion.trim();
if (!session.conflictDetectionKey) session.conflictDetectionKey = session.fileVersion;
}
if (typeof bufferState.externalActor === 'string' && bufferState.externalActor.trim()) {
session.lastExternalWriteSource = bufferState.externalActor.trim();
}
};
const fetchSessionBufferState = async (session) => {
const url = sessionBufferStateUrl(session);
if (!url) return null;
try {
const response = await fetch(url.toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) return null;
const bufferState = payload.result || null;
applyBufferStateToSession(session, bufferState);
return bufferState;
} catch (_error) {
return null;
}
};
const markSessionBufferDirty = (session) => {
if (!session || session.sourceKind !== 'local_folder' || session.sessionKind === 'resource') return;
const relativePath = sessionRelativePath(session);
if (!relativePath) return;
const serialized = String(session.currentSerialized || '');
const contentHash = `browser-dirty:${serialized.length}:${serialized.charCodeAt(0) || 0}:${serialized.charCodeAt(serialized.length - 1) || 0}`;
void fetch('/api/documents/buffer-state/dirty', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
documentId: session.documentId,
workspaceId: session.workspaceId,
sourceKind: session.sourceKind,
rootUri: session.rootUri,
relativePath,
contentHash,
}),
}).then((response) => response.json().catch(() => null)).then((payload) => {
if (payload && payload.ok === true) applyBufferStateToSession(session, payload.result);
}).catch(() => undefined);
};
const detachSessionFromLocalFolderChannel = (session) => {
const channel = session.localFolderChannel;
@@ -416,16 +500,21 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const ensureSessionConflictEnvelope = (session, message) => {
if (!session) return null;
if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope;
const isResourceSession = session.sessionKind === 'resource';
const defaultMessage = isResourceSession
? '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突'
: externalConflictMessage;
const envelope = {
code: 'local_markdown_external_change',
code: isResourceSession ? 'local_resource_external_change' : 'local_markdown_external_change',
documentId: session.documentId,
rootUri: session.rootUri || null,
path: isResourceSession ? (session.resourcePath || '') : undefined,
currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null,
editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null,
externalActor: session.lastExternalWriteSource || null,
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null,
message: message || externalConflictMessage,
message: isResourceSession && (!message || message === externalConflictMessage) ? defaultMessage : (message || defaultMessage),
suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'],
};
session.lastExternalConflictEnvelope = envelope;
@@ -802,13 +891,13 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const markSessionExternalConflict = (session, message) => {
session.externalChangePending = false;
ensureSessionConflictEnvelope(session, message);
const envelope = ensureSessionConflictEnvelope(session, message);
session.hasExternalConflict = true;
if (session.saveTimer) {
window.clearTimeout(session.saveTimer);
session.saveTimer = 0;
}
const nextMessage = message || externalConflictMessage;
const nextMessage = envelope?.message || message || externalConflictMessage;
setSessionStatus(session, 'external-change-conflict', nextMessage);
renderSessionConflictSurface(session, nextMessage);
};
@@ -817,12 +906,23 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
if (session.readOnly || session.hasExternalConflict) return;
if (session.saveTimer) window.clearTimeout(session.saveTimer);
setSessionStatus(session, 'dirty');
markSessionBufferDirty(session);
session.saveTimer = window.setTimeout(() => {
session.saveTimer = 0;
void persistSession(session);
}, 650);
};
const makeSaveOperationToken = (prefix, session) => {
const doc = String(session?.documentId || session?.resourcePath || 'unknown')
.replace(/[^a-zA-Z0-9._:-]+/g, '-')
.slice(0, 80);
const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return `${prefix}:${doc}:${random}`;
};
const persistSession = async (session) => {
if (session.readOnly || session.saving || session.hasExternalConflict) return;
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
@@ -841,11 +941,30 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
const content = legacyBlocksFromEditorDocument(editorDocument);
const saveEndpoint = session.saveEndpoint || '/api/documents/save';
const expectedFileVersion = (
session.sourceKind === 'local_folder'
&& !session.lastExternalConflictEnvelope
&& session.lastExternalConflictDetectionKey
)
? session.lastExternalConflictDetectionKey
: session.conflictDetectionKey;
if (session.sourceKind === 'local_folder' && expectedFileVersion && expectedFileVersion !== session.conflictDetectionKey) {
session.conflictDetectionKey = expectedFileVersion;
session.fileVersion = expectedFileVersion;
}
const writeIntentId = session.sourceKind === 'local_folder'
? makeSaveOperationToken('intent:tiptap', session)
: '';
const saveOperationId = session.sourceKind === 'local_folder'
? makeSaveOperationToken('save:tiptap', session)
: '';
const savePayload = session.sessionKind === 'resource'
? {
rootUri: session.rootUri,
path: session.resourcePath,
expectedFileVersion: session.conflictDetectionKey,
expectedFileVersion: expectedFileVersion,
writeIntentId,
saveOperationId,
contentFormat: 'editorBlocks',
editorSource: 'tiptap-resource-tab',
editorDocument,
@@ -859,7 +978,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
sourceKind: session.sourceKind,
rootUri: session.rootUri,
revision: session.revision,
expectedFileVersion: session.conflictDetectionKey,
expectedFileVersion: expectedFileVersion,
writeIntentId,
saveOperationId,
contentFormat: 'editorBlocks',
editorSource: 'tiptap',
editorDocument,
@@ -889,6 +1010,16 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
throw new Error(message);
}
const saved = result.result || {};
if (typeof saved.writeIntentId === 'string' && saved.writeIntentId.trim()) {
session.lastWriteIntentId = saved.writeIntentId.trim();
} else if (writeIntentId) {
session.lastWriteIntentId = writeIntentId;
}
if (typeof saved.saveOperationId === 'string' && saved.saveOperationId.trim()) {
session.lastSaveOperationId = saved.saveOperationId.trim();
} else if (saveOperationId) {
session.lastSaveOperationId = saveOperationId;
}
if (Number.isInteger(saved.revision)) session.revision = saved.revision;
if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) {
session.conflictDetectionKey = saved.conflict_detection_key.trim();
@@ -902,6 +1033,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey;
session.hasExternalConflict = false;
session.externalChangePending = false;
session.bufferDirtyState = 'Clean';
session.lastUserInputAt = 0;
syncSessionMetaToViews(session);
session.lastPersistedSerialized = serialized;
@@ -951,7 +1083,47 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
if (document.hidden) return;
if (!session || session.views.size === 0) return;
if (session.sourceKind === 'local_folder' && !session.rootUri) return;
if (session.sessionKind === 'resource') {
await refreshResourceSessionFromExternalChange(session, source);
return;
}
try {
const bufferState = await fetchSessionBufferState(session);
const dirtyState = String(bufferState?.dirtyState || session.bufferDirtyState || '').trim();
if (dirtyState === 'Deleted') {
session.lastExternalConflictEnvelope = {
code: 'local_markdown_deleted',
documentId: session.documentId,
rootUri: session.rootUri || null,
currentDiskVersion: bufferState?.fileVersion || session.conflictDetectionKey || session.fileVersion || null,
editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null,
externalActor: bufferState?.externalActor || null,
dirtyState,
bufferFileVersion: bufferState?.fileVersion || session.fileVersion || null,
message: '当前本地 Markdown 文件已被删除,请恢复文件或关闭当前标签',
suggestedActions: ['keep_editor', 'open_diff'],
};
markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message);
return;
}
if (session.hasExternalConflict) {
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, {
message: externalConflictMessage,
dirtyState,
fileVersion: bufferState?.fileVersion || null,
externalActor: bufferState?.externalActor || null,
}));
return;
}
if (sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
session.externalChangePending = true;
if (session.dirty && !session.saveTimer && !session.saving) {
queueSessionSave(session);
} else if (!session.saving) {
setSessionStatus(session, 'dirty');
}
return;
}
const response = await fetch(pageAggregateUrl({
documentId: session.documentId,
sourceKind: session.sourceKind,
@@ -999,13 +1171,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false;
session.bufferDirtyState = 'Clean';
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
if (!contentChanged) return;
}
if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, externalConflictMessage);
if (nextConflictKey === session.lastExternalConflictDetectionKey) {
if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) {
session.conflictDetectionKey = nextConflictKey;
session.fileVersion = nextConflictKey;
syncSessionMetaToViews(session);
}
return;
}
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
@@ -1031,6 +1207,78 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
}
};
const refreshResourceSessionFromExternalChange = async (session, source) => {
if (document.hidden) return;
if (!session || session.views.size === 0) return;
if (session.sourceKind !== 'local_folder' || !session.rootUri || !session.resourcePath) return;
let nextResource = null;
try {
nextResource = await fetchLatestResourceSnapshot(session);
} catch (error) {
session.lastExternalConflictEnvelope = {
code: 'local_resource_deleted',
documentId: session.documentId,
rootUri: session.rootUri || null,
path: session.resourcePath || '',
currentDiskVersion: null,
editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null,
externalActor: 'external-editor',
dirtyState: 'Deleted',
message: '当前本地资源文件已被删除或移动,请恢复文件或关闭当前标签',
suggestedActions: ['keep_editor', 'open_diff'],
};
markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message);
return;
}
const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim();
const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || '');
const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false;
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
if (!contentChanged) return;
}
if (nextConflictKey === session.lastExternalConflictDetectionKey) {
if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) {
session.conflictDetectionKey = nextConflictKey;
session.fileVersion = nextConflictKey;
syncSessionMetaToViews(session);
}
return;
}
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
session.lastExternalConflictEnvelope = {
code: 'local_resource_external_change',
documentId: session.documentId,
rootUri: session.rootUri || null,
path: session.resourcePath || '',
currentDiskVersion: nextConflictKey || null,
editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null,
externalActor: 'external-editor',
dirtyState: session.dirty ? 'Dirty' : 'Clean',
message: '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突',
suggestedActions: ['accept_disk', 'keep_editor', 'open_diff'],
};
markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message);
return;
}
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
session.lastPersistedSerialized = nextSerialized;
session.conflictDetectionKey = nextConflictKey;
session.fileVersion = nextConflictKey;
session.lastExternalConflictDetectionKey = nextConflictKey || '';
session.dirty = false;
session.hasExternalConflict = false;
session.lastUserInputAt = 0;
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-watch');
});
setSessionStatus(session, 'synced-external-change');
syncResourceSessionTabGuards(session);
};
const refreshSessionFromExternalFileChange = async (session) => {
if (session.sourceKind !== 'local_folder') return;
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
@@ -1045,12 +1293,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
if (!channel) {
const url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', session.rootUri);
url.searchParams.set('documentId', session.documentId);
if (session.sessionKind === 'resource') {
url.searchParams.set('resourcePath', resourceWatchPath(session));
} else {
url.searchParams.set('documentId', session.documentId);
}
const eventSource = new EventSource(url.toString());
channel = {
key: channelKey,
rootUri: session.rootUri,
documentId: session.documentId,
resourcePath: session.sessionKind === 'resource' ? resourceWatchPath(session) : '',
eventSource,
sessions: new Map(),
};
@@ -1060,6 +1313,19 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
Array.from(channel.sessions.values()).forEach((targetSession) => {
if (!targetSession || targetSession.views.size === 0) return;
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
const relativePath = typeof payload.relativePath === 'string' ? payload.relativePath.trim() : '';
if (targetSession.sessionKind === 'resource') {
if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return;
if (targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch');
return;
}
if (!documentId) return;
const eventKind = String(payload.eventKind || '');
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
@@ -1076,10 +1342,6 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) {
markSessionExternalConflict(targetSession, externalConflictMessage);
return;
}
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
});
});
@@ -1277,11 +1539,67 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
return scheduled;
};
const applyLocalUploadEditorSave = (detail) => {
const documentId = String(detail?.documentId || '').trim();
const rootUri = String(detail?.rootUri || '').trim();
const fileVersion = String(detail?.fileVersion || '').trim();
const writeIntentId = String(detail?.writeIntentId || '').trim();
const saveOperationId = String(detail?.saveOperationId || '').trim();
const serialized = String(detail?.serialized || '');
if (!documentId || !rootUri || !fileVersion) return 0;
let applied = 0;
Array.from(documentSessionRegistry.values()).forEach((session) => {
if (session.sourceKind !== 'local_folder') return;
if (session.documentId !== documentId) return;
if (String(session.rootUri || '').trim() !== rootUri) return;
if (session.saveTimer) {
window.clearTimeout(session.saveTimer);
session.saveTimer = 0;
}
session.conflictDetectionKey = fileVersion;
session.fileVersion = fileVersion;
session.lastExternalConflictDetectionKey = fileVersion;
if (writeIntentId) session.lastWriteIntentId = writeIntentId;
if (saveOperationId) session.lastSaveOperationId = saveOperationId;
if (session.latestAggregate && typeof session.latestAggregate === 'object') {
const nextAggregate = { ...session.latestAggregate };
const nextBody = nextAggregate.body && typeof nextAggregate.body === 'object'
? { ...nextAggregate.body }
: {};
nextBody.fileVersion = fileVersion;
nextBody.conflictDetectionKey = fileVersion;
nextBody.conflict_detection_key = fileVersion;
nextAggregate.body = nextBody;
session.latestAggregate = nextAggregate;
syncPageAggregateScript(session, nextAggregate);
}
session.externalChangePending = false;
session.hasExternalConflict = false;
session.lastExternalConflictEnvelope = null;
session.bufferDirtyState = 'Clean';
if (!serialized || serialized === session.currentSerialized) {
session.lastPersistedSerialized = session.currentSerialized;
session.dirty = false;
setSessionStatus(session, 'saved');
} else {
session.dirty = true;
setSessionStatus(session, 'dirty');
queueSessionSave(session);
}
syncResourceSessionTabGuards(session);
applied += 1;
});
return applied;
};
window.addEventListener('tree:delta', handleTreeExternalChange);
window.addEventListener('tree:resync', handleTreeExternalChange);
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool');
});
window.addEventListener('mnote:local-upload-editor-save-completed', (event) => {
applyLocalUploadEditorSave(event?.detail || {});
});
const createDocumentSession = (runtimeDescriptor) => {
const pageBody = runtimeDescriptor.aggregate.body || {};
@@ -1291,6 +1609,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
const pageBodySource = pageBodyTiptapDocumentSource(pageBody, '');
const session = {
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
documentId: runtimeDescriptor.bootstrap.documentId,
@@ -1301,6 +1620,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
latestAggregate: runtimeDescriptor.aggregate,
title: runtimeDescriptor.aggregate.head?.title || '无标题',
pageBodySource,
projectionSource: String(pageBody.projectionSource || pageBody.projection_source || '').trim(),
blockProjectionVersion: String(pageBody.blockProjectionVersion || pageBody.block_projection_version || '').trim(),
currentTiptapDocument: tiptapDocument,
currentSerialized: JSON.stringify(tiptapDocument),
lastPersistedSerialized: JSON.stringify(tiptapDocument),
@@ -1308,6 +1630,8 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
conflictDetectionKey,
fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey,
lastExternalConflictDetectionKey: conflictDetectionKey || '',
relativePath: localMarkdownRelativePathFromDocumentId(runtimeDescriptor.bootstrap.documentId),
bufferDirtyState: 'Clean',
readOnly: Boolean(permissions.readOnly),
dirty: false,
saving: false,
@@ -1355,6 +1679,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
markSessionExternalConflict,
queueSessionSave,
refreshSessionFromExternalChange,
ensureLocalFolderEventChannel,
releaseDocumentSession,
scheduleDocumentSessionRelease,
sessionHasRecentExternalSignal,