Improve autosave reliability and implement size limits for local draft storage

This commit is contained in:
2026-04-30 02:56:17 +02:00
parent 3136a47bb7
commit ee7fcce1b3
3 changed files with 129 additions and 23 deletions

View File

@@ -14,9 +14,9 @@ import {
import {
parseSceneDocumentJson,
parseProjectDocumentJson,
serializeProjectDocument
parseProjectDocumentJson
} from "./scene-document-json";
import { assertProjectDocumentIsValid } from "../document/scene-document-validation";
export interface KeyValueStorage {
getItem(key: string): string | null;
@@ -31,6 +31,7 @@ export interface BrowserStorageAccessResult {
export type SaveSceneDocumentDraftResult =
| { status: "saved"; message: string }
| { status: "skipped"; message: string }
| { status: "error"; message: string };
export type LoadSceneDocumentDraftResult =
@@ -45,8 +46,15 @@ export interface LoadOrCreateSceneDocumentResult {
}
export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft";
export const DEFAULT_SCENE_DRAFT_MAX_SERIALIZED_BYTES = 4 * 1024 * 1024;
const ESTIMATED_TERRAIN_SAMPLE_JSON_BYTES = 8;
const ESTIMATED_PROJECT_DRAFT_BASE_BYTES = 64 * 1024;
const EDITOR_DRAFT_ENVELOPE_FORMAT = "webeditor3d.editor-draft.v1";
export interface SaveSceneDocumentDraftOptions {
maxSerializedBytes?: number;
}
interface StoredEditorDraftEnvelope {
format: typeof EDITOR_DRAFT_ENVELOPE_FORMAT;
document: unknown;
@@ -65,6 +73,48 @@ function formatStorageDiagnostic(prefix: string, error: unknown): string {
return `${prefix} ${getErrorDetail(error)}`;
}
function formatByteSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getAutosaveTooLargeMessage(sizeBytes: number, maxBytes: number): string {
return `Autosave skipped because this project draft is about ${formatByteSize(sizeBytes)}, above the ${formatByteSize(maxBytes)} browser autosave limit. Use project save/export for terrain-heavy scenes.`;
}
function getTerrainDraftSampleValueCount(document: ProjectDocument): number {
let sampleValueCount = 0;
for (const scene of Object.values(document.scenes)) {
for (const terrain of Object.values(scene.terrains)) {
sampleValueCount += terrain.heights.length + terrain.paintWeights.length;
}
}
return sampleValueCount;
}
export function estimateProjectDraftSerializedBytes(
document: ProjectDocument,
viewportLayoutState: ViewportLayoutState | null = null
): number {
const terrainSampleValueCount = getTerrainDraftSampleValueCount(document);
const viewportBytes = viewportLayoutState === null ? 0 : 16 * 1024;
return (
ESTIMATED_PROJECT_DRAFT_BASE_BYTES +
viewportBytes +
terrainSampleValueCount * ESTIMATED_TERRAIN_SAMPLE_JSON_BYTES
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -199,19 +249,56 @@ export function saveSceneDocumentDraft(
storage: KeyValueStorage,
document: ProjectDocument,
viewportLayoutState: ViewportLayoutState | null = null,
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY,
options: SaveSceneDocumentDraftOptions = {}
): SaveSceneDocumentDraftResult {
try {
const rawDocument = serializeProjectDocument(document);
storage.setItem(
key,
JSON.stringify({
format: EDITOR_DRAFT_ENVELOPE_FORMAT,
document: JSON.parse(rawDocument),
viewportLayoutState: viewportLayoutState === null ? null : cloneViewportLayoutState(viewportLayoutState)
} satisfies StoredEditorDraftEnvelope)
assertProjectDocumentIsValid(document);
const maxSerializedBytes =
options.maxSerializedBytes ?? DEFAULT_SCENE_DRAFT_MAX_SERIALIZED_BYTES;
const estimatedDraftBytes = estimateProjectDraftSerializedBytes(
document,
viewportLayoutState
);
if (
Number.isFinite(maxSerializedBytes) &&
maxSerializedBytes > 0 &&
estimatedDraftBytes > maxSerializedBytes
) {
storage.removeItem(key);
return {
status: "skipped",
message: getAutosaveTooLargeMessage(
estimatedDraftBytes,
maxSerializedBytes
)
};
}
const rawDraft = JSON.stringify({
format: EDITOR_DRAFT_ENVELOPE_FORMAT,
document,
viewportLayoutState: viewportLayoutState === null ? null : cloneViewportLayoutState(viewportLayoutState)
} satisfies StoredEditorDraftEnvelope);
if (
Number.isFinite(maxSerializedBytes) &&
maxSerializedBytes > 0 &&
rawDraft.length > maxSerializedBytes
) {
storage.removeItem(key);
return {
status: "skipped",
message: getAutosaveTooLargeMessage(rawDraft.length, maxSerializedBytes)
};
}
storage.setItem(key, rawDraft);
return {
status: "saved",
message: "Autosave updated."