Index runtime hook subscriptions and share scoped sync counts

This commit is contained in:
2026-11-09 21:38:26 +08:00
parent b833f0a2fc
commit bb5bc9d8e9
5 changed files with 166 additions and 16 deletions
+8 -9
View File
@@ -3,12 +3,13 @@ import {createContext,createElement,useCallback,useContext,useEffect,useMemo,use
import {createSelectorStore,type ExternalSource} from './selectors.ts';
import {createElementSources} from './element-sources.ts';
import {createSessionSources,type GestureSelection} from './session-sources.ts';
import {createRuntimeSources,type OperationView} from './runtime-sources.ts';
export type {OperationView} from './runtime-sources.ts';
import type {ProjectEngine} from '../canvas-runtime/workspace.ts';
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
import type {Target,Id,Data,Geometry,ElementRecord,Availability,Result} from '../canvas-core/types.ts';
import type {ElementLookup} from '../canvas-core/queries.ts';
import {equal,fault} from '../canvas-core/values.ts';
import type {OperationRecord,FrozenRequest} from '../canvas-runtime/ledger.ts';
import type {DisplayHandle} from '../canvas-runtime/resources.ts';
export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
@@ -68,24 +69,22 @@ export function useGestureBindings(target:Target){
},[engine,viewId,id,incarnation]);
useEffect(()=>()=>controls.cancel(),[controls]);return useMemo(()=>Object.freeze({...state,...controls}),[state,controls]);
}
export type OperationView={readonly operation:OperationRecord;readonly request?:FrozenRequest}|undefined;
export function useOperation(id:Id):OperationView{return useCanvas(useCallback(root=>{const operation=root.runtime.ledger.operations.get(id);return operation?{operation,...(operation.requestId?{request:root.runtime.ledger.requests.get(operation.requestId)}:{})}:undefined;},[id]),equal);}
const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>();
function runtimeSourcesFor(engine:ProjectEngine){let sources=runtimeSources.get(engine);if(!sources){sources=createRuntimeSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)});runtimeSources.set(engine,sources);}return sources;}
export function useOperation(id:Id):OperationView{const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).operation(id),[engine,id]);return useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);}
type Durability=ReturnType<ProjectEngine['getDurability']>;
const syncDurability=(value:Durability)=>({dirty:value.rootRevision>value.durableRootRevision,error:value.error,closing:value.closing,closed:value.closed});
const inactiveOwner=(value:Durability)=>value.closing||value.closed;
function useDurability<T>(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);}
export function useSyncStatus(canvasId?:Id){
const durable=useDurability(syncDurability,equal),counts=useCanvas(useCallback(root=>{
const operations=[...root.runtime.ledger.operations.values()].filter(row=>row.phase!=='resolved'&&(canvasId===undefined||row.intent.canvasIds.includes(canvasId))),ids=new Set(operations.map(row=>row.intent.id)),requests=[...root.runtime.ledger.requests.values()].filter(row=>row.phase!=='resolved'&&row.operationIds.some(id=>ids.has(id)));
const blocked=operations.filter(row=>row.phase==='blocked'&&row.blockers.some(reason=>reason!=='waiting_digest')).length+requests.filter(row=>row.error&&row.retryAt===undefined).length,unknown=requests.filter(row=>row.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(row.phase)).length;
return {pending:operations.length,blocked,unknown,loading:!!root.runtime.loading};
},[canvasId]),equal);
const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).counts(canvasId),[engine,canvasId]);
const durable=useDurability(syncDurability,equal),counts=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
const dirty=durable.dirty;
return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]);
}
export function useAsset(id:Id){
const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner);
const metadata=useCanvas(useCallback(root=>({record:root.runtime.resources.get(id),binding:root.runtime.assetBindings?.get(id)}),[id]),equal);
const source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
const key=JSON.stringify([metadata.record?.operationId,metadata.record?.retirement?.purgedAt,metadata.binding?.authority,metadata.binding?.digest]);
const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>();
useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]);
@@ -0,0 +1,79 @@
import type {Id} from '../canvas-core/types.ts';
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
import type {OperationRecord,FrozenRequest} from '../canvas-runtime/ledger.ts';
import type {ResourceRecord} from '../canvas-runtime/resources.ts';
import type {SharedAssetBinding} from '../canvas-runtime/asset-depot.ts';
import {equal} from '../canvas-core/values.ts';
import type {ExternalSource} from './selectors.ts';
export type OperationView={readonly operation:OperationRecord;readonly request?:FrozenRequest}|undefined;
export type AssetMetadata={readonly record:ResourceRecord|undefined;readonly binding:SharedAssetBinding|undefined};
export type SyncCounts={readonly pending:number;readonly blocked:number;readonly unknown:number;readonly loading:boolean};
type Dependencies=readonly(object|boolean|undefined)[];
/** Read-only table projections: one upstream listener, no per-hook Root/table snapshots. */
export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
type Group={update(root:EngineSnapshot,notifications:Set<()=>void>):void};
const active=new Set<Group>(),identities=new WeakMap<object,number>();let nextIdentity=0,stop:(()=>void)|undefined;
function signature(values:Dependencies){return values.map(value=>{if(value===undefined)return'u';if(typeof value==='boolean')return value?'t':'f';let id=identities.get(value);if(id===undefined){id=++nextIdentity;identities.set(value,id);}return`o${id}`;}).join(':');}
function changed(){
if(!active.size)return;const root=source.getSnapshot(),notifications=new Set<()=>void>();
for(const group of active)group.update(root,notifications);
let failed=false,error:unknown;for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
}
function group<K,V>(dependencies:(root:EngineSnapshot)=>Dependencies,read:(root:EngineSnapshot,key:K)=>V){
// Direct reads may precede notification, so read caches cannot replace observed baselines.
type Bucket={cached:V;cachedSignature:string;observed:V;listeners:Set<()=>void>};
const buckets=new Map<K,Bucket>(),failed=new Set<K>();let observedSignature:string|undefined;
function readCached(root:EngineSnapshot,key:K,bucket:Bucket,stamp:string){
if(bucket.cachedSignature!==stamp){const value=read(root,key);if(!equal(bucket.cached,value))bucket.cached=value;bucket.cachedSignature=stamp;}
return bucket.cached;
}
const owner:Group={update(root,notifications){
const stamp=signature(dependencies(root)),all=stamp!==observedSignature;if(!all&&!failed.size)return;observedSignature=stamp;
for(const key of all?buckets.keys():[...failed]){
const bucket=buckets.get(key);if(!bucket)continue;
try{const value=readCached(root,key,bucket,stamp);if(!failed.has(key)&&equal(bucket.observed,value))continue;bucket.observed=value;failed.delete(key);}catch{failed.add(key);}
for(const notify of bucket.listeners)notifications.add(notify);
}
}};
return{forKey(key:K):ExternalSource<V>{
let initialized=false,local:V,localSignature:string;
function getSnapshot(){
const root=source.getSnapshot(),stamp=signature(dependencies(root)),bucket=buckets.get(key);if(bucket)return readCached(root,key,bucket,stamp);
if(!initialized||stamp!==localSignature){const value=read(root,key);if(!initialized||!equal(local,value))local=value;localSignature=stamp;initialized=true;}
return local;
}
return Object.freeze({getSnapshot,subscribe(notify:()=>void){
let bucket=buckets.get(key);if(!bucket){const value=getSnapshot();if(!buckets.size)observedSignature=signature(dependencies(source.getSnapshot()));bucket={cached:value,cachedSignature:signature(dependencies(source.getSnapshot())),observed:value,listeners:new Set()};buckets.set(key,bucket);active.add(owner);}
const listener=()=>notify();bucket.listeners.add(listener);
const remove=()=>{bucket!.listeners.delete(listener);if(!bucket!.listeners.size){buckets.delete(key);failed.delete(key);}if(!buckets.size){active.delete(owner);observedSignature=undefined;}};
if(!stop)try{stop=source.subscribe(changed);}catch(error){remove();throw error;}
let live=true;return()=>{if(!live)return;live=false;remove();if(!active.size){stop?.();stop=undefined;}};
}});
}};
}
const operations=group<Id,OperationView>(root=>[root.runtime.ledger.operations,root.runtime.ledger.requests],(root,id)=>{const operation=root.runtime.ledger.operations.get(id);return operation?Object.freeze({operation,...(operation.requestId?{request:root.runtime.ledger.requests.get(operation.requestId)}:{})}):undefined;});
const assets=group<Id,AssetMetadata>(root=>[root.runtime.resources,root.runtime.assetBindings],(root,id)=>Object.freeze({record:root.runtime.resources.get(id),binding:root.runtime.assetBindings?.get(id)}));
let countStamp:string|undefined,projectCounts:SyncCounts,emptyCounts:SyncCounts,canvasCounts=new Map<Id,SyncCounts>();
const countDependencies=(root:EngineSnapshot):Dependencies=>[root.runtime.ledger.operations,root.runtime.ledger.requests,!!root.runtime.loading];
const counts=group<Id|undefined,SyncCounts>(countDependencies,(root,canvasId)=>{
const stamp=signature(countDependencies(root));
if(stamp!==countStamp){
const loading=!!root.runtime.loading,empty=()=>({pending:0,blocked:0,unknown:0,loading}),project=empty(),byCanvas=new Map<Id,ReturnType<typeof empty>>();
const scope=(id:Id)=>{let row=byCanvas.get(id);if(!row){row=empty();byCanvas.set(id,row);}return row;};
for(const operation of root.runtime.ledger.operations.values())if(operation.phase!=='resolved'){
const ids=operation.intent.canvasIds.length>1?new Set(operation.intent.canvasIds):operation.intent.canvasIds,blocked=operation.phase==='blocked'&&operation.blockers.some(reason=>reason!=='waiting_digest');project.pending++;if(blocked)project.blocked++;
for(const id of ids){const row=scope(id);row.pending++;if(blocked)row.blocked++;}
}
for(const request of root.runtime.ledger.requests.values())if(request.phase!=='resolved'){
let matched=false;const ids=new Set<Id>();for(const id of request.operationIds){const operation=root.runtime.ledger.operations.get(id);if(operation&&operation.phase!=='resolved'){matched=true;for(const canvas of operation.intent.canvasIds)ids.add(canvas);}}
if(!matched)continue;const blocked=!!request.error&&request.retryAt===undefined,unknown=request.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(request.phase);
if(blocked)project.blocked++;if(unknown)project.unknown++;for(const id of ids){const row=scope(id);if(blocked)row.blocked++;if(unknown)row.unknown++;}
}
projectCounts=Object.freeze(project);emptyCounts=Object.freeze(empty());canvasCounts=new Map([...byCanvas].map(([id,row])=>[id,Object.freeze(row)]));countStamp=stamp;
}
return canvasId===undefined?projectCounts:canvasCounts.get(canvasId)??emptyCounts;
});
return Object.freeze({operation:operations.forKey,asset:assets.forKey,counts:(canvasId?:Id)=>counts.forKey(canvasId)});
}