Persist compact history transition evidence with recovery and resource pins
This commit is contained in:
@@ -6,6 +6,7 @@ import { canvasTransform, convertGeometry } from './coordinates.ts';
|
||||
import { unitScale, withUnitScale } from './units.ts';
|
||||
import { removalChanges, repairConnectionOwners } from './structure.ts';
|
||||
import { planHistoryChanges } from './history.ts';
|
||||
import { describeHistoryTransition } from './history-transition.ts';
|
||||
|
||||
export type EditorOptions<Runtime = undefined> = {
|
||||
registry: Registry; document: Document; createId?: () => Id; historyLimit?: number;
|
||||
@@ -132,6 +133,11 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
const operations = new Map(state.operations);
|
||||
const nextSequence = outbound ? options.nextOperationSequence?.(state.runtime, sequence + 1) ?? sequence + 1 : sequence;
|
||||
if (!Number.isSafeInteger(nextSequence) || outbound && nextSequence <= sequence) throw fault('invalid_operation_sequence');
|
||||
if (historyEntryId) {
|
||||
const entries = new Map(history.entries), entry = entries.get(historyEntryId)!;
|
||||
entries.set(entry.id, freeze({ ...entry, operationIds: [...entry.operationIds, ...operationIds] }));
|
||||
history = Object.freeze({ ...history, entries: readMap(entries) });
|
||||
}
|
||||
if (outbound) {
|
||||
const canvasIds = new Set<Id>();
|
||||
for (const change of changes) {
|
||||
@@ -162,16 +168,12 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: nextSequence, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [] });
|
||||
const historyTransition=describeHistoryTransition(state.history,history,state.rootRevision+1,historyEntryId);
|
||||
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: nextSequence, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [],...(historyTransition?{historyTransition}:{}) });
|
||||
const prepared=options.prepareIntent?.(intent,state.runtime) ?? intent;
|
||||
if(prepared.operationType==='run.apply'&&!validJson(prepared.action)||!['document.change','run.apply'].includes(prepared.operationType)||['id','operationSchemaVersion','sequence','origin','canvasIds','changes','dependencies'].some(key=>!equal((prepared as unknown as Record<string,unknown>)[key],(intent as unknown as Record<string,unknown>)[key])))throw fault('invalid_prepared_intent');
|
||||
if(prepared.operationType==='run.apply'&&!validJson(prepared.action)||!['document.change','run.apply'].includes(prepared.operationType)||['id','operationSchemaVersion','sequence','origin','canvasIds','changes','dependencies','historyTransition'].some(key=>!equal((prepared as unknown as Record<string,unknown>)[key],(intent as unknown as Record<string,unknown>)[key])))throw fault('invalid_prepared_intent');
|
||||
operations.set(intent.id, freeze(clone(prepared)));
|
||||
}
|
||||
if (historyEntryId) {
|
||||
const entries = new Map(history.entries), entry = entries.get(historyEntryId)!;
|
||||
entries.set(entry.id, freeze({ ...entry, operationIds: [...entry.operationIds, ...operationIds] }));
|
||||
history = Object.freeze({ ...history, entries: readMap(entries) });
|
||||
}
|
||||
const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) });
|
||||
const commit = freeze({ revision: next.revision, origin, changes, operationIds });
|
||||
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Change, HistoryEntry, HistoryState, Id } from './types.ts';
|
||||
import { equal, fault, freeze, readMap, validJson } from './values.ts';
|
||||
|
||||
type Splice<T>={readonly offset:number;readonly beforeLength:number;readonly afterLength:number;readonly before:readonly T[];readonly after:readonly T[]};
|
||||
type Meta={readonly label:string;readonly groupId?:Id};
|
||||
type EntryPatch={readonly id:Id;readonly before:Meta|null;readonly after:Meta|null;readonly changes:Splice<Change>;readonly operationIds:Splice<Id>};
|
||||
export type HistoryTransition={readonly version:1;readonly rootRevision:number;readonly entryId?:Id;readonly before:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly after:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly entries:readonly EntryPatch[]};
|
||||
const meta=(entry:HistoryEntry|undefined):Meta|null=>{if(!entry)return null;const{id:_id,changes:_changes,operationIds:_operations,...remaining}=entry;return remaining;};
|
||||
function splice<T>(before:readonly T[],after:readonly T[]):Splice<T>{
|
||||
let offset=0,suffix=0;while(offset<before.length&&offset<after.length&&equal(before[offset],after[offset]))offset++;
|
||||
while(suffix<before.length-offset&&suffix<after.length-offset&&equal(before[before.length-suffix-1],after[after.length-suffix-1]))suffix++;
|
||||
return{offset,beforeLength:before.length,afterLength:after.length,before:before.slice(offset,before.length-suffix),after:after.slice(offset,after.length-suffix)};
|
||||
}
|
||||
/** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */
|
||||
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id):HistoryTransition|undefined{
|
||||
const entries:EntryPatch[]=[];
|
||||
for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});}
|
||||
if(!entries.length&&equal(before.undo,after.undo)&&equal(before.redo,after.redo))return undefined;
|
||||
return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries});
|
||||
}
|
||||
export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{
|
||||
const object=(v:unknown):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record<string,unknown>;};
|
||||
const integer=(v:unknown)=>{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw fault('invalid_history_transition');};
|
||||
const id=(v:unknown)=>{if(typeof v!=='string'||!v)throw fault('invalid_history_transition');};
|
||||
const ids=(v:unknown):string[]=>{if(!Array.isArray(v))throw fault('invalid_history_transition');v.forEach(id);return v;};
|
||||
const raw=object(value);if(raw.version!==1)throw fault('history_transition_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_transition');integer(raw.rootRevision);if(raw.entryId!==undefined)id(raw.entryId);
|
||||
for(const side of [raw.before,raw.after]){const s=object(side),list=[...ids(s.undo),...ids(s.redo)];if(new Set(list).size!==list.length)throw fault('invalid_history_transition');}
|
||||
if(!Array.isArray(raw.entries))throw fault('invalid_history_transition');const seen=new Set<string>();
|
||||
for(const item of raw.entries){const p=object(item);id(p.id);if(seen.has(p.id as string))throw fault('invalid_history_transition');seen.add(p.id as string);
|
||||
for(const side of [p.before,p.after])if(side!==null){const m=object(side);if(typeof m.label!=='string'||['id','changes','operationIds'].some(key=>Object.hasOwn(m,key)))throw fault('invalid_history_transition');if(m.groupId!==undefined)id(m.groupId);}
|
||||
for(const key of ['changes','operationIds']){const patch=object(p[key]);for(const field of ['offset','beforeLength','afterLength'])integer(patch[field]);if(!Array.isArray(patch.before)||!Array.isArray(patch.after))throw fault('invalid_history_transition');const{offset,beforeLength,afterLength}=patch as unknown as Splice<unknown>;if(offset+patch.before.length>beforeLength||offset+patch.after.length>afterLength||beforeLength-patch.before.length!==afterLength-patch.after.length)throw fault('invalid_history_transition');if(key==='operationIds'){ids(patch.before);ids(patch.after);}else{checkChanges?.(patch.before);checkChanges?.(patch.after);}
|
||||
if(p.before===null&&beforeLength!==0||p.after===null&&afterLength!==0)throw fault('invalid_history_transition');
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Conditional metadata-only replay. A mismatch leaves the supplied history untouched. */
|
||||
export function applyHistoryTransition(history:HistoryState,transition:HistoryTransition,direction:'forward'|'reverse'):HistoryState{
|
||||
validateHistoryTransition(transition);const reverse=direction==='reverse',source=reverse?transition.after:transition.before,destination=reverse?transition.before:transition.after;
|
||||
if(!equal({undo:history.undo,redo:history.redo},source))throw fault('history_transition_conflict');
|
||||
function apply<T>(current:readonly T[],patch:Splice<T>):readonly T[]{const expected=reverse?patch.after:patch.before,insert=reverse?patch.before:patch.after,length=reverse?patch.afterLength:patch.beforeLength;if(current.length!==length||!equal(current.slice(patch.offset,patch.offset+expected.length),expected))throw fault('history_transition_conflict');return[...current.slice(0,patch.offset),...insert,...current.slice(patch.offset+expected.length)];}
|
||||
const entries=new Map(history.entries);
|
||||
for(const patch of transition.entries){const entry=entries.get(patch.id),expected=reverse?patch.after:patch.before,next=reverse?patch.before:patch.after;if(!equal(meta(entry),expected))throw fault('history_transition_conflict');const changes=apply(entry?.changes??[],patch.changes),operationIds=apply(entry?.operationIds??[],patch.operationIds);if(next)entries.set(patch.id,freeze({id:patch.id,...next,changes,operationIds}));else entries.delete(patch.id);}
|
||||
const ids=[...destination.undo,...destination.redo];if(ids.length!==entries.size||ids.some(id=>!entries.has(id)))throw fault('history_transition_conflict');
|
||||
return Object.freeze({undo:freeze([...destination.undo]),redo:freeze([...destination.redo]),entries:readMap(entries)});
|
||||
}
|
||||
export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{validateHistoryTransition(transition);return transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);}
|
||||
@@ -8,3 +8,4 @@ export * from './gestures.ts';
|
||||
export * from './coordinates.ts';
|
||||
export * from './structure.ts';
|
||||
export * from './units.ts';
|
||||
export * from './history-transition.ts';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HistoryTransition } from './history-transition.ts';
|
||||
/** Canonical, serializable contracts. No renderer, browser, or service types. */
|
||||
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||||
export type Data = { readonly [key: string]: Json };
|
||||
@@ -35,7 +36,7 @@ export type Applied = { readonly status: 'applied'; readonly revision: number; r
|
||||
export type Result = Applied | Rejected;
|
||||
export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id };
|
||||
export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> };
|
||||
type IntentBase = { readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
|
||||
type IntentBase = { readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
|
||||
export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean };
|
||||
export type RunCancelInput = { readonly runId: Id; readonly boardId: Id };
|
||||
export type RunApplyInput = RunCancelInput & { readonly targetCanvasId: Id; readonly target: Target; readonly outputId: Id };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Change, Document, FieldChange, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import { describeHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts';
|
||||
import { invert } from '../canvas-core/document.ts';
|
||||
import { planHistoryChanges } from '../canvas-core/history.ts';
|
||||
import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
|
||||
@@ -11,7 +12,7 @@ import { referencedResources } from './resources.ts';
|
||||
|
||||
export type RecoveryChoice = 'keep-local' | 'discard-local';
|
||||
export type RecoveryDecision = { readonly operationId: Id; readonly choice: RecoveryChoice; readonly replacementId?: Id; readonly resolution: 'superseded' | 'discarded' | 'satisfied' };
|
||||
export type RecoveryAudit = { readonly id: Id; readonly formatVersion: 1; readonly baseRootRevision: number; readonly decisions: readonly RecoveryDecision[]; readonly backup: Json };
|
||||
export type RecoveryAudit = { readonly id: Id; readonly formatVersion: 1; readonly baseRootRevision: number; readonly decisions: readonly RecoveryDecision[]; readonly backup: Json; readonly historyTransition?:HistoryTransition };
|
||||
export type ConflictPlan = { readonly id: Id; readonly baseRootRevision: number; readonly document: Document; readonly history: HistoryState; readonly operations: EngineSnapshot['operations']; readonly ledger: EngineSnapshot['runtime']['ledger']; readonly audit: RecoveryAudit; readonly resourceIds: readonly Id[]; readonly operationIds: readonly Id[] };
|
||||
const fieldKey = (change: FieldChange) => JSON.stringify([change.target.id,change.target.incarnation,change.field]);
|
||||
function value(document: Document, change: FieldChange, allowMissing=false): {before:Json;beforePresent?:boolean;missing?:boolean} {
|
||||
@@ -59,7 +60,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
const dependencySet=new Set(dependencyIds);
|
||||
for(const previous of replacements.values())if(previous)dependencySet.add(previous.id);
|
||||
const replacementId=unique();
|
||||
const basic={...intent,id:replacementId,sequence:++sequence,changes:freeze(changes),dependencies:[...dependencySet],canvasIds:operationCanvases({...intent,canvasIds:[],changes},before,document)};
|
||||
const {historyTransition:_oldHistory,...originalFact}=intent;
|
||||
const basic={...originalFact,id:replacementId,sequence:++sequence,changes:freeze(changes),dependencies:[...dependencySet],canvasIds:operationCanvases({...intent,canvasIds:[],changes},before,document)};
|
||||
replacement=intent.operationType==='run.apply'?freeze({...basic,operationType:'run.apply',action:{...intent.action,targetCanvasId:document.elements.get(intent.action.target.id)!.ownerCanvasId}}) as OperationIntent:freeze(basic);
|
||||
operations.set(replacement.id,replacement);
|
||||
}
|
||||
@@ -78,7 +80,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
for(const record of records)for(const resource of referencedResources(record.intent.changes,registry))resourceIds.add(resource);
|
||||
for(const replacement of replacements.values())if(replacement)for(const resource of referencedResources(replacement.changes,registry))resourceIds.add(resource);
|
||||
const backup=JSON.parse(JSON.stringify({document:documentValue(snapshot.document),acknowledged:documentValue(snapshot.runtime.acknowledged),versions:[...snapshot.runtime.versions],resourceIds:[...resourceIds],history:{undo:snapshot.history.undo,redo:snapshot.history.redo,entries:[...snapshot.history.entries]}})) as Json;
|
||||
const audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup});
|
||||
const historyTransition=describeHistoryTransition(snapshot.history,history,snapshot.rootRevision+1);
|
||||
const audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup,...(historyTransition?{historyTransition}:{})});
|
||||
return Object.freeze({id,baseRootRevision:snapshot.rootRevision,document,history,operations:readMap(operations),ledger:ledger.getSnapshot(),audit,resourceIds:Object.freeze([...resourceIds]),operationIds:Object.freeze([...replacements.values()].flatMap(intent=>intent?[intent.id]:[]))});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Change, CoreSnapshot, Document, HistoryEntry, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import type { RecoveryAudit } from './conflicts.ts';
|
||||
import { validateHistoryTransition } from '../canvas-core/history-transition.ts';
|
||||
import { createDocument, validateDocument } from '../canvas-core/document.ts';
|
||||
import { fault, freeze, readMap, validJson, equal } from '../canvas-core/values.ts';
|
||||
import type { EngineSnapshot, RuntimeState } from './contracts.ts';
|
||||
@@ -53,6 +54,11 @@ function changes(value: unknown): readonly Change[] {
|
||||
}
|
||||
return freeze(value as Change[]);
|
||||
}
|
||||
function historyTransition(value:unknown){
|
||||
const raw=object(value);integer(raw.version,1);
|
||||
// Preserve future evidence with its original bytes. Its interpreter must be checked before any replay.
|
||||
if(raw.version===1){try{validateHistoryTransition(value,changes);}catch{throw fault('corrupt_recovery_image');}}
|
||||
}
|
||||
function intent(value: unknown, id: Id): OperationIntent {
|
||||
const raw = object(value);
|
||||
if (string(raw.id) !== id) throw fault('corrupt_recovery_image');
|
||||
@@ -64,6 +70,7 @@ function intent(value: unknown, id: Id): OperationIntent {
|
||||
if(kind==='run.cancel'){if(array(raw.changes).length||array(raw.canvasIds).length)throw fault('corrupt_recovery_image');}
|
||||
else {const target=object(action.target);string(target.id);integer(target.incarnation,1);string(action.outputId);if(!strings(raw.canvasIds).includes(string(action.targetCanvasId)))throw fault('corrupt_recovery_image');}
|
||||
}
|
||||
if(raw.historyTransition!==undefined)historyTransition(raw.historyTransition);
|
||||
integer(raw.operationSchemaVersion, 1); integer(raw.sequence, 1); string(raw.origin);
|
||||
strings(raw.canvasIds); strings(raw.dependencies); changes(raw.changes);
|
||||
return freeze(value as OperationIntent);
|
||||
@@ -81,9 +88,11 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
// Own the parsed records; mutating a caller's RecoveryImage must not mutate live state.
|
||||
const raw = object(JSON.parse(JSON.stringify(image.payload))), document = readDocument(raw.document, registry), history = object(raw.history), runtime = object(raw.runtime), ledger = object(runtime.ledger);
|
||||
const operations = table(raw.operations, intent),restoredHistory=readHistory(history,operations);
|
||||
for(const operation of operations.values())if(operation.historyTransition?.version===1&&(operation.historyTransition.rootRevision<1||operation.historyTransition.rootRevision>image.rootRevision))throw fault('corrupt_recovery_image');
|
||||
const records = table<OperationRecord>(ledger.operations, (value, id) => {
|
||||
const raw = object(value); intent(raw.intent, id); strings(raw.blockers); object(raw.basePresence);
|
||||
if (((object(raw.intent).operationType === 'document.change'||object(raw.intent).operationType==='run.apply'&&array(object(raw.intent).changes).length>0) && !operations.has(id)) || !['pending', 'frozen', 'resolved', 'blocked'].includes(string(raw.phase))) throw fault('corrupt_recovery_image');
|
||||
if(operations.has(id)&&!equal(operations.get(id)!.historyTransition,(raw.intent as OperationIntent).historyTransition))throw fault('corrupt_recovery_image');
|
||||
if(raw.resolution!==undefined&&!['applied','not-applied','superseded','discarded','satisfied'].includes(string(raw.resolution)))throw fault('corrupt_recovery_image');
|
||||
return freeze(value as OperationRecord);
|
||||
});
|
||||
@@ -153,7 +162,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
const resourcePins = table(runtime.resourcePins ?? [], strings);
|
||||
const replacementOwners=new Set<Id>();
|
||||
const recoveries=table<RecoveryAudit>(runtime.recoveries??[],(value,id)=>{
|
||||
const raw=object(value),backup=object(raw.backup);if(string(raw.id)!==id||raw.formatVersion!==1||integer(raw.baseRootRevision)>=image.rootRevision)throw fault('corrupt_recovery_image');
|
||||
const raw=object(value),backup=object(raw.backup);if(raw.historyTransition!==undefined){historyTransition(raw.historyTransition);if(object(raw.historyTransition).version===1&&object(raw.historyTransition).rootRevision!==(raw.baseRootRevision as number)+1)throw fault('corrupt_recovery_image');}if(string(raw.id)!==id||raw.formatVersion!==1||integer(raw.baseRootRevision)>=image.rootRevision)throw fault('corrupt_recovery_image');
|
||||
const working=readDocument(backup.document,registry),base=readDocument(backup.acknowledged,registry),versions=table(backup.versions,value=>integer(value,1));
|
||||
if(working.projectId!==document.projectId||base.projectId!==document.projectId||[...base.canvases.keys()].some(id=>!versions.has(id)))throw fault('corrupt_recovery_image');
|
||||
readHistory(object(backup.history),operations);if(!equal(strings(backup.resourceIds),resourcePins.get(`recovery:${id}`)))throw fault('corrupt_recovery_image');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Change, CoreSnapshot, Document, ElementRecord, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import { historyTransitionChanges } from '../canvas-core/history-transition.ts';
|
||||
import { ancestors } from '../canvas-core/document.ts';
|
||||
import type { FrozenRequest, LedgerSnapshot } from './ledger.ts';
|
||||
import type { OperationStatus } from './scheduler.ts';
|
||||
@@ -88,6 +89,11 @@ export function resourcePins(snapshot: CoreSnapshot, ledger: LedgerSnapshot, reg
|
||||
if (record.intent.operationType === 'asset.upload') pin(record.intent.resourceId, `operation:${record.intent.id}`);
|
||||
else changes(record.intent.changes, `operation:${record.intent.id}`);
|
||||
}
|
||||
const pending=[...ledger.operations.values()].filter(record=>record.phase!=='resolved'),earliest=pending.reduce((value,record)=>Math.min(value,record.intent.sequence),Infinity);
|
||||
for(const record of ledger.operations.values())if(record.intent.sequence>=earliest&&record.intent.historyTransition){
|
||||
if(record.intent.historyTransition.version!==1){complete=false;continue;}
|
||||
changes(historyTransitionChanges(record.intent.historyTransition),`history-transition:${record.intent.id}`);
|
||||
}
|
||||
for (const [owner, ids] of explicit) for (const id of ids) pin(id, owner);
|
||||
return Object.freeze({ complete, pins: readMap(new Map([...pins].map(([id, owners]) => [id, readSet(owners)]))) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user