map/src/committedWorldDelta.js
2026-08-10 13:59:33 +09:00

430 lines
18 KiB
JavaScript

function typedArrayConstructor(name) {
return {
Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array,
Int32Array, Uint32Array, Float32Array, Float64Array,
}[name] || null;
}
function applyTypedRowDelta(current, delta) {
if (!delta) return current;
const Constructor = typedArrayConstructor(delta.constructorName);
if (!Constructor) throw new Error(`Unsupported delta field type ${delta.constructorName}.`);
if (delta.replace) return new Constructor(delta.replace);
const target = ArrayBuffer.isView(current) && current.constructor?.name === delta.constructorName && current.length === delta.length
? current
: new Constructor(delta.length);
for (const row of delta.rows || []) target.set(row.values, row.start);
return target;
}
function applyExactObjectDelta(target = {}, delta = {}, { cloneValues = true } = {}) {
for (const key of delta.removed || []) delete target[key];
for (const [key, value] of Object.entries(delta.set || {})) target[key] = cloneValues ? structuredClone(value) : value;
for (const [key, splice] of Object.entries(delta.arraySplices || {})) {
const current = target[key];
if (!Array.isArray(current)) throw new Error(`Cannot apply array splice delta to non-array metadata key ${key}.`);
const start = Math.max(0, Math.min(current.length, Math.floor(Number(splice?.start || 0))));
const deleteCount = Math.max(0, Math.min(current.length - start, Math.floor(Number(splice?.deleteCount || 0))));
const items = Array.isArray(splice?.items) ? splice.items : [];
const inserted = cloneValues ? structuredClone(items) : items;
target[key] = current.slice(0, start).concat(inserted, current.slice(start + deleteCount));
}
return target;
}
export function applyCommittedWorldDelta(world, delta, { consumeMetadata = false, copyOnWrite = false } = {}) {
if (!world || !delta) throw new Error("Committed mirror delta is missing.");
if (world.width !== delta.width || world.height !== delta.height) {
throw new Error(`Committed mirror dimensions changed unexpectedly (${world.width}x${world.height} -> ${delta.width}x${delta.height}).`);
}
for (const [name, fieldDelta] of Object.entries(delta.fields || {})) {
if (fieldDelta.remove) delete world.fields[name];
else {
const current = world.fields?.[name];
const writable = copyOnWrite && ArrayBuffer.isView(current) && !fieldDelta.replace
? new current.constructor(current)
: current;
world.fields[name] = applyTypedRowDelta(writable, fieldDelta);
}
}
const generatedMask = copyOnWrite && delta.generatedMask && ArrayBuffer.isView(world.generatedMask) && !delta.generatedMask.replace
? new world.generatedMask.constructor(world.generatedMask)
: world.generatedMask;
world.generatedMask = applyTypedRowDelta(generatedMask, delta.generatedMask);
if (delta.sourceMapDelta || delta.metaDelta) {
// Clone all changed metadata in one graph. sourceMap diagnostics and
// lastPatchResult often share the same seam/path objects; cloning each key
// separately multiplied both allocation and retained heap.
// postMessage already gives the main thread an isolated object graph, and
// the Worker ACK consumes its retained delta exactly once. Those hot paths
// can adopt the delta values directly instead of cloning every changed
// feature/path/diagnostic a second time at peak memory. Keep clone-by-default
// for reusable library callers.
const exactValues = consumeMetadata ? {
sourceSet: delta.sourceMapDelta?.set || {},
metaSet: delta.metaDelta?.set || {},
sourceArraySplices: delta.sourceMapDelta?.arraySplices || {},
metaArraySplices: delta.metaDelta?.arraySplices || {},
} : structuredClone({
sourceSet: delta.sourceMapDelta?.set || {},
metaSet: delta.metaDelta?.set || {},
sourceArraySplices: delta.sourceMapDelta?.arraySplices || {},
metaArraySplices: delta.metaDelta?.arraySplices || {},
});
if (delta.sourceMapDelta) {
world.sourceMap = applyExactObjectDelta(world.sourceMap || {}, {
...delta.sourceMapDelta,
set: exactValues.sourceSet,
arraySplices: exactValues.sourceArraySplices,
}, { cloneValues: false });
}
if (delta.metaDelta) {
applyExactObjectDelta(world, {
...delta.metaDelta,
set: exactValues.metaSet,
arraySplices: exactValues.metaArraySplices,
}, { cloneValues: false });
}
}
else world.sourceMap = structuredClone(delta.sourceMap || {});
if (!delta.metaDelta) {
for (const key of delta.removedMetaKeys || []) delete world[key];
for (const [key, value] of Object.entries(delta.meta || {})) world[key] = structuredClone(value);
}
return world;
}
export function materializeCommittedWorldDelta(baseWorld, delta, { consumeMetadata = false } = {}) {
if (!baseWorld) throw new Error("Committed base world is missing.");
// Patch previews are immutable views. Copy the world containers, then clone
// only typed fields touched by row deltas. Exact metadata application replaces
// changed roots/arrays, so untouched production metadata can remain shared.
const previewWorld = {
...baseWorld,
fields: { ...(baseWorld.fields || {}) },
sourceMap: { ...(baseWorld.sourceMap || {}) },
};
return applyCommittedWorldDelta(previewWorld, delta, { consumeMetadata, copyOnWrite: true });
}
function cancellationError(message = "Committed world delta materialization cancelled.") {
if (typeof DOMException === "function") return new DOMException(message, "AbortError");
const error = new Error(message);
error.name = "AbortError";
return error;
}
async function cloneTypedArrayCooperatively(source, yieldControl, shouldCancel, chunkBytes) {
if (!ArrayBuffer.isView(source) || source instanceof DataView) return source;
const target = new source.constructor(source.length);
const bytesPerElement = Math.max(1, source.BYTES_PER_ELEMENT || 1);
const elementsPerChunk = Math.max(1, Math.floor(chunkBytes / bytesPerElement));
for (let offset = 0; offset < source.length; offset += elementsPerChunk) {
if (shouldCancel()) throw cancellationError();
const end = Math.min(source.length, offset + elementsPerChunk);
target.set(source.subarray(offset, end), offset);
if (end < source.length) await yieldControl();
}
return target;
}
async function applyTypedRowDeltaCooperatively(current, delta, options) {
if (!delta) return current;
const Constructor = typedArrayConstructor(delta.constructorName);
if (!Constructor) throw new Error(`Unsupported delta field type ${delta.constructorName}.`);
const { yieldControl, shouldCancel, chunkBytes } = options;
if (delta.replace) {
// The main thread owns transferred replacement buffers exclusively. Adopt
// them directly instead of making a second full-size copy at peak memory.
if (ArrayBuffer.isView(delta.replace) && delta.replace.constructor === Constructor) return delta.replace;
return cloneTypedArrayCooperatively(new Constructor(delta.replace), yieldControl, shouldCancel, chunkBytes);
}
let target;
if (ArrayBuffer.isView(current) && current.constructor?.name === delta.constructorName && current.length === delta.length) {
target = await cloneTypedArrayCooperatively(current, yieldControl, shouldCancel, chunkBytes);
} else {
target = new Constructor(delta.length);
}
let bytesSinceYield = 0;
for (const row of delta.rows || []) {
if (shouldCancel()) throw cancellationError();
target.set(row.values, row.start);
bytesSinceYield += row.values?.byteLength || 0;
if (bytesSinceYield >= chunkBytes) {
bytesSinceYield = 0;
await yieldControl();
}
}
return target;
}
export async function materializeCommittedWorldDeltaCooperative(baseWorld, delta, {
consumeMetadata = false,
yieldControl = () => Promise.resolve(),
shouldCancel = () => false,
chunkBytes = 4 * 1024 * 1024,
} = {}) {
if (!baseWorld) throw new Error("Committed base world is missing.");
if (!delta) throw new Error("Committed mirror delta is missing.");
if (baseWorld.width !== delta.width || baseWorld.height !== delta.height) {
throw new Error(`Committed mirror dimensions changed unexpectedly (${baseWorld.width}x${baseWorld.height} -> ${delta.width}x${delta.height}).`);
}
const previewWorld = {
...baseWorld,
fields: { ...(baseWorld.fields || {}) },
sourceMap: { ...(baseWorld.sourceMap || {}) },
};
for (const [name, fieldDelta] of Object.entries(delta.fields || {})) {
if (shouldCancel()) throw cancellationError();
if (fieldDelta.remove) delete previewWorld.fields[name];
else previewWorld.fields[name] = await applyTypedRowDeltaCooperatively(previewWorld.fields[name], fieldDelta, {
yieldControl, shouldCancel, chunkBytes,
});
await yieldControl();
}
if (delta.generatedMask) {
previewWorld.generatedMask = await applyTypedRowDeltaCooperatively(previewWorld.generatedMask, delta.generatedMask, {
yieldControl, shouldCancel, chunkBytes,
});
await yieldControl();
}
if (shouldCancel()) throw cancellationError();
if (delta.sourceMapDelta || delta.metaDelta) {
const exactValues = consumeMetadata ? {
sourceSet: delta.sourceMapDelta?.set || {},
metaSet: delta.metaDelta?.set || {},
sourceArraySplices: delta.sourceMapDelta?.arraySplices || {},
metaArraySplices: delta.metaDelta?.arraySplices || {},
} : structuredClone({
sourceSet: delta.sourceMapDelta?.set || {},
metaSet: delta.metaDelta?.set || {},
sourceArraySplices: delta.sourceMapDelta?.arraySplices || {},
metaArraySplices: delta.metaDelta?.arraySplices || {},
});
if (delta.sourceMapDelta) {
previewWorld.sourceMap = applyExactObjectDelta(previewWorld.sourceMap || {}, {
...delta.sourceMapDelta,
set: exactValues.sourceSet,
arraySplices: exactValues.sourceArraySplices,
}, { cloneValues: false });
await yieldControl();
}
if (shouldCancel()) throw cancellationError();
if (delta.metaDelta) {
applyExactObjectDelta(previewWorld, {
...delta.metaDelta,
set: exactValues.metaSet,
arraySplices: exactValues.metaArraySplices,
}, { cloneValues: false });
await yieldControl();
}
} else {
previewWorld.sourceMap = consumeMetadata ? (delta.sourceMap || {}) : structuredClone(delta.sourceMap || {});
}
if (!delta.metaDelta) {
for (const key of delta.removedMetaKeys || []) delete previewWorld[key];
for (const [key, value] of Object.entries(delta.meta || {})) {
if (shouldCancel()) throw cancellationError();
previewWorld[key] = consumeMetadata ? value : structuredClone(value);
await yieldControl();
}
}
return previewWorld;
}
function mixHashText(hash, text) {
let value = hash >>> 0;
for (let index = 0; index < text.length; index++) {
value ^= text.charCodeAt(index);
value = Math.imul(value, 16777619) >>> 0;
}
return value;
}
function mixHashValue(hash, value) {
let next = hash >>> 0;
if (value == null) return mixHashText(next, String(value));
if (ArrayBuffer.isView(value)) {
next = mixHashText(next, `${value.constructor.name}:${value.length}:`);
const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
for (let index = 0; index < bytes.length; index++) {
next ^= bytes[index];
next = Math.imul(next, 16777619) >>> 0;
}
return next;
}
if (Array.isArray(value)) {
next = mixHashText(next, `[${value.length}:`);
for (const item of value) next = mixHashValue(next, item);
return next;
}
if (value instanceof Map) {
const entries = [...value.entries()].sort(([a], [b]) => String(a).localeCompare(String(b)));
return mixHashValue(mixHashText(next, `Map:${entries.length}:`), entries);
}
if (value instanceof Set) return mixHashValue(mixHashText(next, `Set:${value.size}:`), [...value].sort());
if (typeof value === "object") {
const keys = Object.keys(value).sort();
next = mixHashText(next, `{${keys.length}:`);
for (const key of keys) {
next = mixHashText(next, key);
next = mixHashValue(next, value[key]);
}
return next;
}
return mixHashText(next, `${typeof value}:${String(value)}`);
}
export function hashCommittedWorld(world) {
let hash = 2166136261 >>> 0;
hash = mixHashText(hash, `${world?.width || 0}x${world?.height || 0}|${world?.originX || 0},${world?.originY || 0}`);
for (const name of Object.keys(world?.fields || {}).sort()) {
hash = mixHashText(hash, `|${name}:`);
const field = world.fields[name];
if (!ArrayBuffer.isView(field)) continue;
const bytes = new Uint8Array(field.buffer, field.byteOffset, field.byteLength);
for (let index = 0; index < bytes.length; index++) {
hash ^= bytes[index];
hash = Math.imul(hash, 16777619) >>> 0;
}
}
if (ArrayBuffer.isView(world?.generatedMask)) {
hash = mixHashText(hash, "|generatedMask:");
const bytes = new Uint8Array(world.generatedMask.buffer, world.generatedMask.byteOffset, world.generatedMask.byteLength);
for (let index = 0; index < bytes.length; index++) {
hash ^= bytes[index];
hash = Math.imul(hash, 16777619) >>> 0;
}
}
hash = mixHashText(hash, "|sourceMap:");
hash = mixHashValue(hash, world?.sourceMap || {});
const meta = {};
for (const [key, value] of Object.entries(world || {})) {
if (key === "fields" || key === "generatedMask" || key === "sourceMap") continue;
meta[key] = value;
}
hash = mixHashText(hash, "|meta:");
hash = mixHashValue(hash, meta);
return hash.toString(16).padStart(8, "0");
}
async function cooperativeHashYield(state) {
if (state.shouldAbort()) throw cancellationError("Committed world hash cancelled.");
state.pending = 0;
await state.yieldControl();
if (state.shouldAbort()) throw cancellationError("Committed world hash cancelled.");
}
async function mixHashTextAsync(hash, text, state) {
let value = hash >>> 0;
let index = 0;
while (index < text.length) {
const start = index;
const capacity = Math.max(1, state.yieldEvery - state.pending);
const end = Math.min(text.length, index + capacity);
for (; index < end; index++) {
value ^= text.charCodeAt(index);
value = Math.imul(value, 16777619) >>> 0;
}
state.pending += end - start;
if (state.pending >= state.yieldEvery) await cooperativeHashYield(state);
}
return value;
}
async function mixHashBytesAsync(hash, bytes, state) {
let value = hash >>> 0;
let index = 0;
while (index < bytes.length) {
const start = index;
const capacity = Math.max(1, state.yieldEvery - state.pending);
const end = Math.min(bytes.length, index + capacity);
for (; index < end; index++) {
value ^= bytes[index];
value = Math.imul(value, 16777619) >>> 0;
}
state.pending += end - start;
if (state.pending >= state.yieldEvery) await cooperativeHashYield(state);
}
return value;
}
async function mixHashValueAsync(hash, value, state) {
let next = hash >>> 0;
if (state.shouldAbort()) throw cancellationError("Committed world hash cancelled.");
if (value == null) return mixHashTextAsync(next, String(value), state);
if (ArrayBuffer.isView(value)) {
next = await mixHashTextAsync(next, `${value.constructor.name}:${value.length}:`, state);
return mixHashBytesAsync(next, new Uint8Array(value.buffer, value.byteOffset, value.byteLength), state);
}
if (Array.isArray(value)) {
next = await mixHashTextAsync(next, `[${value.length}:`, state);
for (const item of value) next = await mixHashValueAsync(next, item, state);
return next;
}
if (value instanceof Map) {
const entries = [...value.entries()].sort(([a], [b]) => String(a).localeCompare(String(b)));
next = await mixHashTextAsync(next, `Map:${entries.length}:`, state);
return mixHashValueAsync(next, entries, state);
}
if (value instanceof Set) {
next = await mixHashTextAsync(next, `Set:${value.size}:`, state);
return mixHashValueAsync(next, [...value].sort(), state);
}
if (typeof value === "object") {
const keys = Object.keys(value).sort();
next = await mixHashTextAsync(next, `{${keys.length}:`, state);
for (const key of keys) {
next = await mixHashTextAsync(next, key, state);
next = await mixHashValueAsync(next, value[key], state);
}
return next;
}
return mixHashTextAsync(next, `${typeof value}:${String(value)}`, state);
}
// Bit-identical cooperative counterpart to hashCommittedWorld(). Yielding is
// only inserted between chunks; byte/text order and FNV-1a arithmetic are
// unchanged. This lets the main thread verify a transferred transactional
// preview without creating an uncancellable multi-megabyte long task.
export async function hashCommittedWorldAsync(world, {
yieldEvery = 262_144,
yieldControl = null,
shouldAbort = () => false,
} = {}) {
const fallbackYield = () => {
if (typeof globalThis.scheduler?.yield === "function") return globalThis.scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
};
const state = {
yieldEvery: Math.max(1, Math.floor(Number(yieldEvery) || 262_144)),
pending: 0,
shouldAbort: typeof shouldAbort === "function" ? shouldAbort : () => false,
yieldControl: typeof yieldControl === "function" ? yieldControl : fallbackYield,
};
if (state.shouldAbort()) throw cancellationError("Committed world hash cancelled.");
let hash = 2166136261 >>> 0;
hash = await mixHashTextAsync(hash, `${world?.width || 0}x${world?.height || 0}|${world?.originX || 0},${world?.originY || 0}`, state);
for (const name of Object.keys(world?.fields || {}).sort()) {
hash = await mixHashTextAsync(hash, `|${name}:`, state);
const field = world.fields[name];
if (!ArrayBuffer.isView(field)) continue;
hash = await mixHashBytesAsync(hash, new Uint8Array(field.buffer, field.byteOffset, field.byteLength), state);
}
if (ArrayBuffer.isView(world?.generatedMask)) {
hash = await mixHashTextAsync(hash, "|generatedMask:", state);
hash = await mixHashBytesAsync(hash, new Uint8Array(world.generatedMask.buffer, world.generatedMask.byteOffset, world.generatedMask.byteLength), state);
}
hash = await mixHashTextAsync(hash, "|sourceMap:", state);
hash = await mixHashValueAsync(hash, world?.sourceMap || {}, state);
const meta = {};
for (const [key, value] of Object.entries(world || {})) {
if (key === "fields" || key === "generatedMask" || key === "sourceMap") continue;
meta[key] = value;
}
hash = await mixHashTextAsync(hash, "|meta:", state);
hash = await mixHashValueAsync(hash, meta, state);
if (state.shouldAbort()) throw cancellationError("Committed world hash cancelled.");
return hash.toString(16).padStart(8, "0");
}