@milaboratories/pl-tree 1.15.4 → 1.15.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/sync.cjs +2 -2
- package/dist/sync.cjs.map +1 -1
- package/dist/sync.d.ts +2 -2
- package/dist/sync.js +2 -2
- package/dist/sync.js.map +1 -1
- package/dist/synchronized_tree.cjs +2 -2
- package/dist/synchronized_tree.cjs.map +1 -1
- package/dist/synchronized_tree.d.ts +1 -1
- package/dist/synchronized_tree.js +2 -2
- package/dist/synchronized_tree.js.map +1 -1
- package/package.json +4 -4
- package/src/algorithm_equivalence.test.ts +2 -2
- package/src/delta_benchmark.test.ts +3 -3
- package/src/delta_sync_live.test.ts +3 -3
- package/src/sync.test.ts +5 -5
- package/src/sync.ts +4 -4
- package/src/synchronized_tree.ts +2 -2
package/dist/sync.cjs
CHANGED
|
@@ -15,7 +15,7 @@ function resolveTreeLoadingAlgorithm(mode, capabilities = [], logger) {
|
|
|
15
15
|
case "client-bfs": return "client-bfs";
|
|
16
16
|
case "backend-delta":
|
|
17
17
|
if (delta) return "backend-delta";
|
|
18
|
-
(logger ?? console).warn("traversalMode=backend-delta but backend lacks treeChangedSince:
|
|
18
|
+
(logger ?? console).warn("traversalMode=backend-delta but backend lacks treeChangedSince:v2 capability; falling back to " + (streaming ? "backend-streaming" : "client-bfs"));
|
|
19
19
|
return streaming ? "backend-streaming" : "client-bfs";
|
|
20
20
|
case "backend-streaming":
|
|
21
21
|
if (streaming) return "backend-streaming";
|
|
@@ -119,7 +119,7 @@ function supportsResourceTreeTraversal(capabilities = []) {
|
|
|
119
119
|
return (0, _milaboratories_pl_client.hasCapability)(capabilities, "treeFilter:v2");
|
|
120
120
|
}
|
|
121
121
|
function supportsTreeDelta(capabilities = []) {
|
|
122
|
-
return (0, _milaboratories_pl_client.hasCapability)(capabilities, "treeChangedSince:
|
|
122
|
+
return (0, _milaboratories_pl_client.hasCapability)(capabilities, "treeChangedSince:v2");
|
|
123
123
|
}
|
|
124
124
|
function collectStatsForResource(resource, stats) {
|
|
125
125
|
if (!stats) return;
|
package/dist/sync.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync.cjs","names":["ConcurrencyLimitingExecutor","Denque","loadDeltaTreeState"],"sources":["../src/sync.ts"],"sourcesContent":["import type {\n FieldData,\n Filter,\n OptionalSignedResourceId,\n PlTransaction,\n ResourceTreeFrame,\n SignedResourceId,\n} from \"@milaboratories/pl-client\";\nimport Denque from \"denque\";\nimport { hasCapability, isNullSignedResourceId } from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData, PlTreeState, ResourceUpdateStat } from \"./state\";\nimport { ConcurrencyLimitingExecutor, msToHumanReadable } from \"@milaboratories/ts-helpers\";\nimport { loadDeltaTreeState } from \"./delta_sync\";\n\n/** Applied to list of fields in resource data. */\nexport type PruningFunction = (resource: ExtendedResourceData) => FieldData[];\n\nexport interface TreeLoadingRequest {\n /** Resource to prime the traversal algorithm. It is ok, if some of them\n * doesn't exist anymore. Should not contain elements from final resource\n * set. */\n readonly seedResources: SignedResourceId[];\n\n /** Resource ids for which state is already known and not expected to change.\n * Algorithm will not continue traversal over those ids, and states will not\n * be retrieved for them. */\n readonly finalResources: Set<SignedResourceId>;\n\n /** Applied to each resource field list in fallback BFS mode and to streamed results. */\n readonly pruningFunction?: PruningFunction;\n\n /** ResourceTree field filter passed to the backend when supported. */\n readonly fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules passed to the backend when supported.\n * @deprecated prune with {@link changedSinceToken} instead; ignored by the backend under one. */\n readonly traverseStopRules?: Filter;\n\n /** The tree's roots. Delta seeds at these only when the non-final frontier is empty;\n * resourceTree requires at least one seed. */\n readonly roots: readonly SignedResourceId[];\n\n /** Every id the mirror currently holds, final or not. Delta uses it to tell a reference it\n * must resolve from one already satisfied locally. */\n readonly knownResources: ReadonlySet<SignedResourceId>;\n\n /** Change token from the transaction this request will run in, for a delta walk. Absent\n * means \"send the full tree\", which is also what a token the server cannot use gets. */\n readonly changedSinceToken?: Uint8Array;\n}\n\n/** Controls which tree-loading path is used.\n * - `\"auto\"` (default): use delta polling when the backend advertises `treeChangedSince:v1`,\n * else backend streaming when it advertises `treeFilter:v2`, else client-side BFS.\n * - `\"client-bfs\"`: always use client-side BFS, even on capable backends.\n * - `\"backend-streaming\"`: always prefer backend streaming; if the capability is absent,\n * logs a warning and falls back to BFS (never throws).\n * - `\"backend-delta\"`: always prefer delta polling, which hands the backend the transaction's\n * change token and takes only what changed since it; if `treeChangedSince:v1` is absent,\n * logs a warning and falls back to the best available path (never throws).\n */\nexport type TraversalMode = \"auto\" | \"client-bfs\" | \"backend-streaming\" | \"backend-delta\";\n\n/** A concrete loading algorithm: a {@link TraversalMode} with `\"auto\"` and any unsupported\n * preference already resolved against the server's capabilities. */\nexport type TreeLoadingAlgorithmName = \"client-bfs\" | \"backend-streaming\" | \"backend-delta\";\n\n/** Resolves a traversal mode into the algorithm a tree will run. A tree calls this once, when\n * it is made, and keeps the answer for its whole life, so the choice (and the fallback warning\n * for a preference the backend cannot serve) happens once rather than on every poll. */\nexport function resolveTreeLoadingAlgorithm(\n mode: TraversalMode,\n capabilities: readonly string[] = [],\n logger?: { warn: (msg: string) => void },\n): TreeLoadingAlgorithmName {\n const streaming = supportsResourceTreeTraversal(capabilities);\n const delta = supportsTreeDelta(capabilities);\n switch (mode) {\n case \"client-bfs\":\n return \"client-bfs\";\n case \"backend-delta\":\n if (delta) return \"backend-delta\";\n (logger ?? console).warn(\n \"traversalMode=backend-delta but backend lacks treeChangedSince:v1 capability; falling back to \" +\n (streaming ? \"backend-streaming\" : \"client-bfs\"),\n );\n return streaming ? \"backend-streaming\" : \"client-bfs\";\n case \"backend-streaming\":\n if (streaming) return \"backend-streaming\";\n (logger ?? console).warn(\n \"traversalMode=backend-streaming but backend lacks treeFilter:v2 capability; falling back to BFS\",\n );\n return \"client-bfs\";\n case \"auto\":\n // Delta first: its cost tracks what changed rather than what the tree holds. Streaming\n // is the fallback for a backend that can shape a walk but not date one.\n if (delta) return \"backend-delta\";\n return streaming ? \"backend-streaming\" : \"client-bfs\";\n }\n}\n\n/** Given the current tree state, build the request object to pass to\n * {@link loadTreeState} to load updated state. */\nexport function constructTreeLoadingRequest(\n tree: PlTreeState,\n options: Pick<\n TreeLoadingRequest,\n \"pruningFunction\" | \"fieldFilter\" | \"traverseStopRules\" | \"changedSinceToken\"\n > = {},\n): TreeLoadingRequest {\n const seedResources: SignedResourceId[] = [];\n const finalResources = new Set<SignedResourceId>();\n const materialized = new Set<SignedResourceId>();\n tree.forEachResource((res) => {\n materialized.add(res.id);\n if (res.finalState) finalResources.add(res.id);\n else seedResources.push(res.id);\n });\n\n for (const root of tree.roots) if (!materialized.has(root)) seedResources.push(root);\n\n return {\n seedResources,\n finalResources,\n roots: [...tree.roots],\n knownResources: materialized,\n pruningFunction: options.pruningFunction,\n fieldFilter: options.fieldFilter,\n traverseStopRules: options.traverseStopRules,\n changedSinceToken: options.changedSinceToken,\n };\n}\n\nexport type TreeLoadingStat = ResourceUpdateStat & {\n requests: number;\n roundTrips: number;\n retrievedResources: number;\n retrievedFields: number;\n retrievedKeyValues: number;\n retrievedResourceDataBytes: number;\n retrievedKeyValueBytes: number;\n prunedFields: number;\n finalResourcesSkipped: number;\n millisSpent: number;\n /** Stop-marker frames whose id was already final locally and were skipped. */\n stopMarkersSkipped: number;\n /** Number of follow-up resourceTree() calls issued to resolve unknown stop markers. */\n stopMarkerFollowUpRoundTrips: number;\n /** Backend paths: resourceTree() streams consumed. Streaming spends 1, or 2 with a\n * follow-up; delta spends 1 plus one per resolution round. */\n streamRounds: number;\n /** Backend paths: resource frames received. */\n resourceFrames: number;\n /** Streaming path: stopMarker frames received (both skipped and follow-up). */\n stopMarkerFrames: number;\n /** Streaming path: stop markers that were not final locally and triggered a follow-up fetch. */\n stopMarkersFollowUp: number;\n /** Streaming path: frames where the backend stopped traversal (final or traverseWasStopped). */\n traverseWasStoppedCount: number;\n /** BFS path: resource states actually requested from the backend this cycle (after intra-cycle dedup). */\n bfsResourcesRequested: number;\n /** BFS path: requested resources that no longer exist (undefined reply). */\n bfsResourcesNotFound: number;\n /** Delta path: seed ids handed to the backend, summed over every round of the poll.\n * This is what seeding the whole frontier costs on the uplink. */\n deltaSeedsSent: number;\n /** Delta path: extra rounds spent resolving references a delta body pointed at but the\n * response did not carry. */\n deltaResolutionRounds: number;\n /** Delta path: polls that sent a token and got back a response the size of the whole\n * mirror, which is what a refused token looks like from here - rejection is silent, so this\n * is the only tell. Heuristic: a genuinely large change set trips it too. */\n deltaSuspectedFullAnswers: number;\n};\n\nexport function initialTreeLoadingStat(): TreeLoadingStat {\n return {\n requests: 0,\n roundTrips: 0,\n retrievedResources: 0,\n retrievedFields: 0,\n retrievedKeyValues: 0,\n retrievedResourceDataBytes: 0,\n retrievedKeyValueBytes: 0,\n prunedFields: 0,\n finalResourcesSkipped: 0,\n millisSpent: 0,\n stopMarkersSkipped: 0,\n stopMarkerFollowUpRoundTrips: 0,\n streamRounds: 0,\n resourceFrames: 0,\n stopMarkerFrames: 0,\n stopMarkersFollowUp: 0,\n traverseWasStoppedCount: 0,\n bfsResourcesRequested: 0,\n bfsResourcesNotFound: 0,\n deltaSeedsSent: 0,\n deltaResolutionRounds: 0,\n deltaSuspectedFullAnswers: 0,\n resourcesNew: 0,\n resourcesChanged: 0,\n resourcesUnchanged: 0,\n bytesUnchanged: 0,\n metadataStableChanged: 0,\n bfsRequestsWasted: 0,\n usedStreaming: false,\n fieldsAdded: 0,\n fieldsRemoved: 0,\n fieldsChanged: 0,\n kvChanged: 0,\n readyFlips: 0,\n locksChanged: 0,\n errorsAttached: 0,\n duplicatesResolved: 0,\n resourcesMarkedFinal: 0,\n };\n}\n\nexport function formatTreeLoadingStat(stat: TreeLoadingStat): string {\n return `Requests: ${stat.requests}\nTotal time: ${msToHumanReadable(stat.millisSpent)}\nRound-trips: ${stat.roundTrips}\nResources: ${stat.retrievedResources}\nFields: ${stat.retrievedFields}\nKV: ${stat.retrievedKeyValues}\nData Bytes: ${stat.retrievedResourceDataBytes}\nKV Bytes: ${stat.retrievedKeyValueBytes}\nPruned fields: ${stat.prunedFields}\nFinal resources skipped: ${stat.finalResourcesSkipped}\nStop markers skipped: ${stat.stopMarkersSkipped}\nStop marker follow-up round-trips: ${stat.stopMarkerFollowUpRoundTrips}\nNew resources: ${stat.resourcesNew}\nChanged resources: ${stat.resourcesChanged}\nUnchanged (duplicate re-fetch) resources: ${stat.resourcesUnchanged}\nUnchanged bytes (wasted downlink): ${stat.bytesUnchanged}\nChanged with stable metadata: ${stat.metadataStableChanged}\nBFS fetches wasted on unchanged: ${stat.bfsRequestsWasted}\nUsed streaming: ${stat.usedStreaming}\n[backend] rounds: ${stat.streamRounds}, resource frames: ${stat.resourceFrames}, stop-marker frames: ${stat.stopMarkerFrames}, stop->follow-up: ${stat.stopMarkersFollowUp}, traverse-stopped: ${stat.traverseWasStoppedCount}\n[bfs] resources requested: ${stat.bfsResourcesRequested}, not found: ${stat.bfsResourcesNotFound}\n[delta] seeds sent: ${stat.deltaSeedsSent}, resolution rounds: ${stat.deltaResolutionRounds}, suspected full answers: ${stat.deltaSuspectedFullAnswers}`;\n}\n\nexport function supportsResourceTreeTraversal(capabilities: readonly string[] = []): boolean {\n return hasCapability(capabilities, \"treeFilter:v2\");\n}\n\nfunction supportsTreeDelta(capabilities: readonly string[] = []): boolean {\n return hasCapability(capabilities, \"treeChangedSince:v1\");\n}\n\nexport function collectStatsForResource(resource: ExtendedResourceData, stats?: TreeLoadingStat) {\n if (!stats) return;\n stats.retrievedResources++;\n stats.retrievedFields += resource.fields.length;\n stats.retrievedKeyValues += resource.kv.length;\n stats.retrievedResourceDataBytes += resource.data?.length ?? 0;\n for (const kv of resource.kv) stats.retrievedKeyValueBytes += kv.value.length;\n}\n\nasync function loadTreeStateViaBfs(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n): Promise<ExtendedResourceData[]> {\n const { seedResources, finalResources, pruningFunction } = loadingRequest;\n\n // Limits the number of concurrent gRPC fetches to bound peak memory\n // from in-flight request/response buffers.\n const limiter = new ConcurrencyLimitingExecutor(100);\n\n // Promises of resource states, in the order they were requested.\n const pending = new Denque<Promise<ExtendedResourceData | undefined>>();\n\n // vars to calculate number of roundtrips for stats\n let roundTripToggle: boolean = true;\n let numberOfRoundTrips = 0;\n\n // tracking resources we already requested or queued\n const requested = new Set<SignedResourceId>();\n\n /** Mark a resource for fetching. Deduplicates and respects final-resource set. */\n const requestState = (rid: OptionalSignedResourceId) => {\n if (isNullSignedResourceId(rid) || requested.has(rid)) return;\n\n if (finalResources.has(rid)) {\n if (stats) stats.finalResourcesSkipped++;\n return;\n }\n\n requested.add(rid);\n if (stats) stats.bfsResourcesRequested++;\n\n pending.push(\n limiter.run(async () => {\n const resourceData = tx.getResourceDataIfExists(rid, true);\n const kvData = tx.listKeyValuesIfResourceExists(rid);\n\n // counting round-trip (begin)\n const addRT = roundTripToggle;\n if (roundTripToggle) roundTripToggle = false;\n\n const [resource, kv] = await Promise.all([resourceData, kvData]);\n\n // counting round-trip, actually incrementing counter and returning toggle back,\n // so the next request can acquire it\n if (addRT) {\n numberOfRoundTrips++;\n roundTripToggle = true;\n }\n\n if (resource === undefined) {\n if (stats) stats.bfsResourcesNotFound++;\n return undefined;\n }\n if (kv === undefined) throw new Error(\"Inconsistent replies\");\n\n return { ...resource, kv };\n }),\n );\n };\n\n // sending seed requests\n seedResources.forEach((rid) => requestState(rid));\n\n const result: ExtendedResourceData[] = [];\n let nextPromise: Promise<ExtendedResourceData | undefined> | undefined;\n while ((nextPromise = pending.shift()) !== undefined) {\n // at this point we pause and wait for the next requested resource state to arrive\n let nextResource = await nextPromise;\n if (nextResource === undefined)\n // ignoring resources that were not found (this may happen for seed resource ids)\n continue;\n\n if (pruningFunction !== undefined) {\n // apply field pruning, if requested\n const fieldsAfterPruning = pruningFunction(nextResource);\n // collecting stats\n if (stats) stats.prunedFields += nextResource.fields.length - fieldsAfterPruning.length;\n nextResource = { ...nextResource, fields: fieldsAfterPruning };\n }\n\n // continue traversal over the referenced resources\n requestState(nextResource.error);\n for (const field of nextResource.fields) {\n requestState(field.value);\n requestState(field.error);\n }\n\n // collecting stats\n collectStatsForResource(nextResource, stats);\n\n // aggregating the state\n result.push(nextResource);\n }\n\n if (stats) stats.roundTrips += numberOfRoundTrips;\n\n return result;\n}\n\nasync function processResourceTreeStream(\n treeItems: AsyncIterable<ResourceTreeFrame>,\n finalResources: Set<SignedResourceId>,\n pruningFunction: PruningFunction | undefined,\n stats: TreeLoadingStat | undefined,\n): Promise<{ result: ExtendedResourceData[]; followUpSeeds: SignedResourceId[] }> {\n const result: ExtendedResourceData[] = [];\n const followUpSeeds: SignedResourceId[] = [];\n\n // backend returns two types of frames:\n // - 'resource' frames contain the resource state and are processed normally\n // - 'stopMarker' frames indicate resources that are ignored due stop rules fired\n //\n // Usually stop rules indicates the resources with final state. In that case middle layer\n // should make a decision: has it already loaded the resource or should it be requested for get the latest state?\n for await (const frame of treeItems) {\n if (frame.frameKind === \"stopMarker\") {\n if (stats) stats.stopMarkerFrames++;\n if (finalResources.has(frame.id)) {\n if (stats) stats.stopMarkersSkipped++;\n continue;\n }\n if (stats) stats.stopMarkersFollowUp++;\n followUpSeeds.push(frame.id);\n continue;\n }\n\n // Normal resource frame.\n if (stats) {\n stats.resourceFrames++;\n if (frame.traverseWasStopped) stats.traverseWasStoppedCount++;\n }\n if (finalResources.has(frame.id)) {\n if (stats) stats.finalResourcesSkipped++;\n continue;\n }\n\n let nextResource: ExtendedResourceData = {\n id: frame.id,\n type: frame.type,\n kind: frame.kind,\n data: frame.data,\n resourceReady: frame.resourceReady,\n error: frame.error,\n originalResourceId: frame.originalResourceId,\n // traverseWasStopped: backend matched traverse stop rules — children were not streamed.\n // Mark as terminal; fields are resolved below.\n final: frame.final || frame.traverseWasStopped,\n inputsLocked: frame.inputsLocked,\n outputsLocked: frame.outputsLocked,\n fields: frame.fields,\n kv: frame.kv,\n };\n\n // Apply field rules: traverseWasStopped drops all fields to keep the refCount\n // invariant; pruning function further filters the remaining fields.\n const rawFields = frame.traverseWasStopped ? [] : nextResource.fields;\n const resolvedFields =\n pruningFunction !== undefined\n ? pruningFunction({ ...nextResource, fields: rawFields })\n : rawFields;\n if (stats) stats.prunedFields += nextResource.fields.length - resolvedFields.length;\n nextResource = { ...nextResource, fields: resolvedFields };\n\n collectStatsForResource(nextResource, stats);\n result.push(nextResource);\n }\n\n return { result, followUpSeeds };\n}\n\nasync function loadTreeStateViaResourceTree(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n logger?: { warn: (msg: string) => void; info?: (msg: unknown) => void },\n): Promise<ExtendedResourceData[]> {\n const { seedResources, finalResources, pruningFunction, fieldFilter, traverseStopRules } =\n loadingRequest;\n\n // Round 0: initial tree traversal.\n const treeItems = tx.resourceTree(seedResources, {\n includeKv: true,\n fieldFilter,\n traverseStopRules,\n });\n\n let { result, followUpSeeds } = await processResourceTreeStream(\n treeItems,\n finalResources,\n pruningFunction,\n stats,\n );\n if (stats) {\n stats.roundTrips++;\n stats.streamRounds++;\n }\n\n // Resolve stop-marker seeds by fetching them (see the note below on why the stop rule is not\n // reapplied). Loop in case a fetch still yields stop markers; dedup fetched ids so shared refs\n // or diamonds cannot loop forever (the id set is finite, so the loop terminates). A referenced\n // resource left unloaded would make updateFromResourceData throw \"orphan resource\".\n let pendingSeeds = followUpSeeds;\n const fetchedSeeds = new Set<SignedResourceId>();\n while (pendingSeeds.length > 0) {\n const roundSeeds = pendingSeeds.filter((id) => !fetchedSeeds.has(id));\n if (roundSeeds.length === 0) break;\n for (const id of roundSeeds) fetchedSeeds.add(id);\n\n // No traverseStopRules here on purpose: the seeds are the stop-marked resources, and the\n // stop rule (finality-based) would re-flag them as stop markers instead of loading their\n // state, leaving resources that reference them as orphans. The retry must fetch them plainly.\n const followUpItems = tx.resourceTree(roundSeeds, {\n includeKv: true,\n fieldFilter,\n });\n const { result: followUpResult, followUpSeeds: nextSeeds } = await processResourceTreeStream(\n followUpItems,\n finalResources,\n pruningFunction,\n stats,\n );\n // spreading fails due to exceeding stack argument size\n result = result.concat(followUpResult);\n if (stats) {\n logger?.info?.(\n `loadTreeStateViaResourceTree: follow-up request for ${roundSeeds.length} stop-marker seeds: ${JSON.stringify(roundSeeds)}`,\n );\n stats.roundTrips++;\n stats.streamRounds++;\n stats.stopMarkerFollowUpRoundTrips++;\n }\n pendingSeeds = nextSeeds;\n }\n\n return result;\n}\n\n/** Given the transaction (preferably read-only) and loading request, executes\n * the tree traversal algorithm, and collects fresh states of resources\n * to update the tree state. */\nexport async function loadTreeState(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n capabilities: readonly string[] = [],\n mode: TraversalMode = \"auto\",\n logger?: { warn: (msg: string) => void; info?: (msg: unknown) => void },\n): Promise<ExtendedResourceData[]> {\n const startTimestamp = Date.now();\n if (stats) stats.requests++;\n\n try {\n // A tree passes its pinned algorithm here, which resolves to itself. The mode form is\n // kept for callers that run a single load.\n const algorithm = resolveTreeLoadingAlgorithm(mode, capabilities, logger);\n // Both backend paths, not just streaming: state.ts reads this to attribute a BFS-only\n // wasted-fetch counter, and leaving it false for delta reports phantom waste.\n if (stats) stats.usedStreaming = algorithm !== \"client-bfs\";\n\n switch (algorithm) {\n case \"backend-delta\":\n return await loadDeltaTreeState(tx, loadingRequest, stats, logger);\n case \"backend-streaming\":\n return await loadTreeStateViaResourceTree(tx, loadingRequest, stats, logger);\n case \"client-bfs\":\n return await loadTreeStateViaBfs(tx, loadingRequest, stats);\n default:\n throw new Error(`unknown tree loading algorithm: ${algorithm as string}`);\n }\n } finally {\n if (stats) stats.millisSpent += Date.now() - startTimestamp;\n }\n}\n"],"mappings":";;;;;;;;;;AAsEA,SAAgB,4BACd,MACA,eAAkC,CAAC,GACnC,QAC0B;CAC1B,MAAM,YAAY,8BAA8B,YAAY;CAC5D,MAAM,QAAQ,kBAAkB,YAAY;CAC5C,QAAQ,MAAR;EACE,KAAK,cACH,OAAO;EACT,KAAK;GACH,IAAI,OAAO,OAAO;GAClB,CAAC,UAAU,QAAA,CAAS,KAClB,oGACG,YAAY,sBAAsB,aACvC;GACA,OAAO,YAAY,sBAAsB;EAC3C,KAAK;GACH,IAAI,WAAW,OAAO;GACtB,CAAC,UAAU,QAAA,CAAS,KAClB,iGACF;GACA,OAAO;EACT,KAAK;GAGH,IAAI,OAAO,OAAO;GAClB,OAAO,YAAY,sBAAsB;CAC7C;AACF;;;AAIA,SAAgB,4BACd,MACA,UAGI,CAAC,GACe;CACpB,MAAM,gBAAoC,CAAC;CAC3C,MAAM,iCAAiB,IAAI,IAAsB;CACjD,MAAM,+BAAe,IAAI,IAAsB;CAC/C,KAAK,iBAAiB,QAAQ;EAC5B,aAAa,IAAI,IAAI,EAAE;EACvB,IAAI,IAAI,YAAY,eAAe,IAAI,IAAI,EAAE;OACxC,cAAc,KAAK,IAAI,EAAE;CAChC,CAAC;CAED,KAAK,MAAM,QAAQ,KAAK,OAAO,IAAI,CAAC,aAAa,IAAI,IAAI,GAAG,cAAc,KAAK,IAAI;CAEnF,OAAO;EACL;EACA;EACA,OAAO,CAAC,GAAG,KAAK,KAAK;EACrB,gBAAgB;EAChB,iBAAiB,QAAQ;EACzB,aAAa,QAAQ;EACrB,mBAAmB,QAAQ;EAC3B,mBAAmB,QAAQ;CAC7B;AACF;AA4CA,SAAgB,yBAA0C;CACxD,OAAO;EACL,UAAU;EACV,YAAY;EACZ,oBAAoB;EACpB,iBAAiB;EACjB,oBAAoB;EACpB,4BAA4B;EAC5B,wBAAwB;EACxB,cAAc;EACd,uBAAuB;EACvB,aAAa;EACb,oBAAoB;EACpB,8BAA8B;EAC9B,cAAc;EACd,gBAAgB;EAChB,kBAAkB;EAClB,qBAAqB;EACrB,yBAAyB;EACzB,uBAAuB;EACvB,sBAAsB;EACtB,gBAAgB;EAChB,uBAAuB;EACvB,2BAA2B;EAC3B,cAAc;EACd,kBAAkB;EAClB,oBAAoB;EACpB,gBAAgB;EAChB,uBAAuB;EACvB,mBAAmB;EACnB,eAAe;EACf,aAAa;EACb,eAAe;EACf,eAAe;EACf,WAAW;EACX,YAAY;EACZ,cAAc;EACd,gBAAgB;EAChB,oBAAoB;EACpB,sBAAsB;CACxB;AACF;AAEA,SAAgB,sBAAsB,MAA+B;CACnE,OAAO,aAAa,KAAK,SAAS;gEACJ,KAAK,WAAW,EAAE;eACnC,KAAK,WAAW;aAClB,KAAK,mBAAmB;UAC3B,KAAK,gBAAgB;MACzB,KAAK,mBAAmB;cAChB,KAAK,2BAA2B;YAClC,KAAK,uBAAuB;iBACvB,KAAK,aAAa;2BACR,KAAK,sBAAsB;wBAC9B,KAAK,mBAAmB;qCACX,KAAK,6BAA6B;iBACtD,KAAK,aAAa;qBACd,KAAK,iBAAiB;4CACC,KAAK,mBAAmB;qCAC/B,KAAK,eAAe;gCACzB,KAAK,sBAAsB;mCACxB,KAAK,kBAAkB;kBACxC,KAAK,cAAc;oBACjB,KAAK,aAAa,qBAAqB,KAAK,eAAe,wBAAwB,KAAK,iBAAiB,qBAAqB,KAAK,oBAAoB,sBAAsB,KAAK,wBAAwB;6BACjM,KAAK,sBAAsB,eAAe,KAAK,qBAAqB;sBAC3E,KAAK,eAAe,uBAAuB,KAAK,sBAAsB,4BAA4B,KAAK;AAC7H;AAEA,SAAgB,8BAA8B,eAAkC,CAAC,GAAY;CAC3F,QAAA,GAAA,0BAAA,cAAA,CAAqB,cAAc,eAAe;AACpD;AAEA,SAAS,kBAAkB,eAAkC,CAAC,GAAY;CACxE,QAAA,GAAA,0BAAA,cAAA,CAAqB,cAAc,qBAAqB;AAC1D;AAEA,SAAgB,wBAAwB,UAAgC,OAAyB;CAC/F,IAAI,CAAC,OAAO;CACZ,MAAM;CACN,MAAM,mBAAmB,SAAS,OAAO;CACzC,MAAM,sBAAsB,SAAS,GAAG;CACxC,MAAM,8BAA8B,SAAS,MAAM,UAAU;CAC7D,KAAK,MAAM,MAAM,SAAS,IAAI,MAAM,0BAA0B,GAAG,MAAM;AACzE;AAEA,eAAe,oBACb,IACA,gBACA,OACiC;CACjC,MAAM,EAAE,eAAe,gBAAgB,oBAAoB;CAI3D,MAAM,UAAU,IAAIA,2BAAAA,4BAA4B,GAAG;CAGnD,MAAM,UAAU,IAAIC,OAAAA,QAAkD;CAGtE,IAAI,kBAA2B;CAC/B,IAAI,qBAAqB;CAGzB,MAAM,4BAAY,IAAI,IAAsB;;CAG5C,MAAM,gBAAgB,QAAkC;EACtD,KAAA,GAAA,0BAAA,uBAAA,CAA2B,GAAG,KAAK,UAAU,IAAI,GAAG,GAAG;EAEvD,IAAI,eAAe,IAAI,GAAG,GAAG;GAC3B,IAAI,OAAO,MAAM;GACjB;EACF;EAEA,UAAU,IAAI,GAAG;EACjB,IAAI,OAAO,MAAM;EAEjB,QAAQ,KACN,QAAQ,IAAI,YAAY;GACtB,MAAM,eAAe,GAAG,wBAAwB,KAAK,IAAI;GACzD,MAAM,SAAS,GAAG,8BAA8B,GAAG;GAGnD,MAAM,QAAQ;GACd,IAAI,iBAAiB,kBAAkB;GAEvC,MAAM,CAAC,UAAU,MAAM,MAAM,QAAQ,IAAI,CAAC,cAAc,MAAM,CAAC;GAI/D,IAAI,OAAO;IACT;IACA,kBAAkB;GACpB;GAEA,IAAI,aAAa,KAAA,GAAW;IAC1B,IAAI,OAAO,MAAM;IACjB;GACF;GACA,IAAI,OAAO,KAAA,GAAW,MAAM,IAAI,MAAM,sBAAsB;GAE5D,OAAO;IAAE,GAAG;IAAU;GAAG;EAC3B,CAAC,CACH;CACF;CAGA,cAAc,SAAS,QAAQ,aAAa,GAAG,CAAC;CAEhD,MAAM,SAAiC,CAAC;CACxC,IAAI;CACJ,QAAQ,cAAc,QAAQ,MAAM,OAAO,KAAA,GAAW;EAEpD,IAAI,eAAe,MAAM;EACzB,IAAI,iBAAiB,KAAA,GAEnB;EAEF,IAAI,oBAAoB,KAAA,GAAW;GAEjC,MAAM,qBAAqB,gBAAgB,YAAY;GAEvD,IAAI,OAAO,MAAM,gBAAgB,aAAa,OAAO,SAAS,mBAAmB;GACjF,eAAe;IAAE,GAAG;IAAc,QAAQ;GAAmB;EAC/D;EAGA,aAAa,aAAa,KAAK;EAC/B,KAAK,MAAM,SAAS,aAAa,QAAQ;GACvC,aAAa,MAAM,KAAK;GACxB,aAAa,MAAM,KAAK;EAC1B;EAGA,wBAAwB,cAAc,KAAK;EAG3C,OAAO,KAAK,YAAY;CAC1B;CAEA,IAAI,OAAO,MAAM,cAAc;CAE/B,OAAO;AACT;AAEA,eAAe,0BACb,WACA,gBACA,iBACA,OACgF;CAChF,MAAM,SAAiC,CAAC;CACxC,MAAM,gBAAoC,CAAC;CAQ3C,WAAW,MAAM,SAAS,WAAW;EACnC,IAAI,MAAM,cAAc,cAAc;GACpC,IAAI,OAAO,MAAM;GACjB,IAAI,eAAe,IAAI,MAAM,EAAE,GAAG;IAChC,IAAI,OAAO,MAAM;IACjB;GACF;GACA,IAAI,OAAO,MAAM;GACjB,cAAc,KAAK,MAAM,EAAE;GAC3B;EACF;EAGA,IAAI,OAAO;GACT,MAAM;GACN,IAAI,MAAM,oBAAoB,MAAM;EACtC;EACA,IAAI,eAAe,IAAI,MAAM,EAAE,GAAG;GAChC,IAAI,OAAO,MAAM;GACjB;EACF;EAEA,IAAI,eAAqC;GACvC,IAAI,MAAM;GACV,MAAM,MAAM;GACZ,MAAM,MAAM;GACZ,MAAM,MAAM;GACZ,eAAe,MAAM;GACrB,OAAO,MAAM;GACb,oBAAoB,MAAM;GAG1B,OAAO,MAAM,SAAS,MAAM;GAC5B,cAAc,MAAM;GACpB,eAAe,MAAM;GACrB,QAAQ,MAAM;GACd,IAAI,MAAM;EACZ;EAIA,MAAM,YAAY,MAAM,qBAAqB,CAAC,IAAI,aAAa;EAC/D,MAAM,iBACJ,oBAAoB,KAAA,IAChB,gBAAgB;GAAE,GAAG;GAAc,QAAQ;EAAU,CAAC,IACtD;EACN,IAAI,OAAO,MAAM,gBAAgB,aAAa,OAAO,SAAS,eAAe;EAC7E,eAAe;GAAE,GAAG;GAAc,QAAQ;EAAe;EAEzD,wBAAwB,cAAc,KAAK;EAC3C,OAAO,KAAK,YAAY;CAC1B;CAEA,OAAO;EAAE;EAAQ;CAAc;AACjC;AAEA,eAAe,6BACb,IACA,gBACA,OACA,QACiC;CACjC,MAAM,EAAE,eAAe,gBAAgB,iBAAiB,aAAa,sBACnE;CASF,IAAI,EAAE,QAAQ,kBAAkB,MAAM,0BANpB,GAAG,aAAa,eAAe;EAC/C,WAAW;EACX;EACA;CACF,CAGU,GACR,gBACA,iBACA,KACF;CACA,IAAI,OAAO;EACT,MAAM;EACN,MAAM;CACR;CAMA,IAAI,eAAe;CACnB,MAAM,+BAAe,IAAI,IAAsB;CAC/C,OAAO,aAAa,SAAS,GAAG;EAC9B,MAAM,aAAa,aAAa,QAAQ,OAAO,CAAC,aAAa,IAAI,EAAE,CAAC;EACpE,IAAI,WAAW,WAAW,GAAG;EAC7B,KAAK,MAAM,MAAM,YAAY,aAAa,IAAI,EAAE;EAShD,MAAM,EAAE,QAAQ,gBAAgB,eAAe,cAAc,MAAM,0BAJ7C,GAAG,aAAa,YAAY;GAChD,WAAW;GACX;EACF,CAEc,GACZ,gBACA,iBACA,KACF;EAEA,SAAS,OAAO,OAAO,cAAc;EACrC,IAAI,OAAO;GACT,QAAQ,OACN,uDAAuD,WAAW,OAAO,sBAAsB,KAAK,UAAU,UAAU,GAC1H;GACA,MAAM;GACN,MAAM;GACN,MAAM;EACR;EACA,eAAe;CACjB;CAEA,OAAO;AACT;;;;AAKA,eAAsB,cACpB,IACA,gBACA,OACA,eAAkC,CAAC,GACnC,OAAsB,QACtB,QACiC;CACjC,MAAM,iBAAiB,KAAK,IAAI;CAChC,IAAI,OAAO,MAAM;CAEjB,IAAI;EAGF,MAAM,YAAY,4BAA4B,MAAM,cAAc,MAAM;EAGxE,IAAI,OAAO,MAAM,gBAAgB,cAAc;EAE/C,QAAQ,WAAR;GACE,KAAK,iBACH,OAAO,MAAMC,mBAAAA,mBAAmB,IAAI,gBAAgB,OAAO,MAAM;GACnE,KAAK,qBACH,OAAO,MAAM,6BAA6B,IAAI,gBAAgB,OAAO,MAAM;GAC7E,KAAK,cACH,OAAO,MAAM,oBAAoB,IAAI,gBAAgB,KAAK;GAC5D,SACE,MAAM,IAAI,MAAM,mCAAmC,WAAqB;EAC5E;CACF,UAAU;EACR,IAAI,OAAO,MAAM,eAAe,KAAK,IAAI,IAAI;CAC/C;AACF"}
|
|
1
|
+
{"version":3,"file":"sync.cjs","names":["ConcurrencyLimitingExecutor","Denque","loadDeltaTreeState"],"sources":["../src/sync.ts"],"sourcesContent":["import type {\n FieldData,\n Filter,\n OptionalSignedResourceId,\n PlTransaction,\n ResourceTreeFrame,\n SignedResourceId,\n} from \"@milaboratories/pl-client\";\nimport Denque from \"denque\";\nimport { hasCapability, isNullSignedResourceId } from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData, PlTreeState, ResourceUpdateStat } from \"./state\";\nimport { ConcurrencyLimitingExecutor, msToHumanReadable } from \"@milaboratories/ts-helpers\";\nimport { loadDeltaTreeState } from \"./delta_sync\";\n\n/** Applied to list of fields in resource data. */\nexport type PruningFunction = (resource: ExtendedResourceData) => FieldData[];\n\nexport interface TreeLoadingRequest {\n /** Resource to prime the traversal algorithm. It is ok, if some of them\n * doesn't exist anymore. Should not contain elements from final resource\n * set. */\n readonly seedResources: SignedResourceId[];\n\n /** Resource ids for which state is already known and not expected to change.\n * Algorithm will not continue traversal over those ids, and states will not\n * be retrieved for them. */\n readonly finalResources: Set<SignedResourceId>;\n\n /** Applied to each resource field list in fallback BFS mode and to streamed results. */\n readonly pruningFunction?: PruningFunction;\n\n /** ResourceTree field filter passed to the backend when supported. */\n readonly fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules passed to the backend when supported.\n * @deprecated prune with {@link changedSinceToken} instead; ignored by the backend under one. */\n readonly traverseStopRules?: Filter;\n\n /** The tree's roots. Delta seeds at these only when the non-final frontier is empty;\n * resourceTree requires at least one seed. */\n readonly roots: readonly SignedResourceId[];\n\n /** Every id the mirror currently holds, final or not. Delta uses it to tell a reference it\n * must resolve from one already satisfied locally. */\n readonly knownResources: ReadonlySet<SignedResourceId>;\n\n /** Change token from the transaction this request will run in, for a delta walk. Absent\n * means \"send the full tree\", which is also what a token the server cannot use gets. */\n readonly changedSinceToken?: Uint8Array;\n}\n\n/** Controls which tree-loading path is used.\n * - `\"auto\"` (default): use delta polling when the backend advertises `treeChangedSince:v2`,\n * else backend streaming when it advertises `treeFilter:v2`, else client-side BFS.\n * - `\"client-bfs\"`: always use client-side BFS, even on capable backends.\n * - `\"backend-streaming\"`: always prefer backend streaming; if the capability is absent,\n * logs a warning and falls back to BFS (never throws).\n * - `\"backend-delta\"`: always prefer delta polling, which hands the backend the transaction's\n * change token and takes only what changed since it; if `treeChangedSince:v2` is absent,\n * logs a warning and falls back to the best available path (never throws).\n */\nexport type TraversalMode = \"auto\" | \"client-bfs\" | \"backend-streaming\" | \"backend-delta\";\n\n/** A concrete loading algorithm: a {@link TraversalMode} with `\"auto\"` and any unsupported\n * preference already resolved against the server's capabilities. */\nexport type TreeLoadingAlgorithmName = \"client-bfs\" | \"backend-streaming\" | \"backend-delta\";\n\n/** Resolves a traversal mode into the algorithm a tree will run. A tree calls this once, when\n * it is made, and keeps the answer for its whole life, so the choice (and the fallback warning\n * for a preference the backend cannot serve) happens once rather than on every poll. */\nexport function resolveTreeLoadingAlgorithm(\n mode: TraversalMode,\n capabilities: readonly string[] = [],\n logger?: { warn: (msg: string) => void },\n): TreeLoadingAlgorithmName {\n const streaming = supportsResourceTreeTraversal(capabilities);\n const delta = supportsTreeDelta(capabilities);\n switch (mode) {\n case \"client-bfs\":\n return \"client-bfs\";\n case \"backend-delta\":\n if (delta) return \"backend-delta\";\n (logger ?? console).warn(\n \"traversalMode=backend-delta but backend lacks treeChangedSince:v2 capability; falling back to \" +\n (streaming ? \"backend-streaming\" : \"client-bfs\"),\n );\n return streaming ? \"backend-streaming\" : \"client-bfs\";\n case \"backend-streaming\":\n if (streaming) return \"backend-streaming\";\n (logger ?? console).warn(\n \"traversalMode=backend-streaming but backend lacks treeFilter:v2 capability; falling back to BFS\",\n );\n return \"client-bfs\";\n case \"auto\":\n // Delta first: its cost tracks what changed rather than what the tree holds. Streaming\n // is the fallback for a backend that can shape a walk but not date one.\n if (delta) return \"backend-delta\";\n return streaming ? \"backend-streaming\" : \"client-bfs\";\n }\n}\n\n/** Given the current tree state, build the request object to pass to\n * {@link loadTreeState} to load updated state. */\nexport function constructTreeLoadingRequest(\n tree: PlTreeState,\n options: Pick<\n TreeLoadingRequest,\n \"pruningFunction\" | \"fieldFilter\" | \"traverseStopRules\" | \"changedSinceToken\"\n > = {},\n): TreeLoadingRequest {\n const seedResources: SignedResourceId[] = [];\n const finalResources = new Set<SignedResourceId>();\n const materialized = new Set<SignedResourceId>();\n tree.forEachResource((res) => {\n materialized.add(res.id);\n if (res.finalState) finalResources.add(res.id);\n else seedResources.push(res.id);\n });\n\n for (const root of tree.roots) if (!materialized.has(root)) seedResources.push(root);\n\n return {\n seedResources,\n finalResources,\n roots: [...tree.roots],\n knownResources: materialized,\n pruningFunction: options.pruningFunction,\n fieldFilter: options.fieldFilter,\n traverseStopRules: options.traverseStopRules,\n changedSinceToken: options.changedSinceToken,\n };\n}\n\nexport type TreeLoadingStat = ResourceUpdateStat & {\n requests: number;\n roundTrips: number;\n retrievedResources: number;\n retrievedFields: number;\n retrievedKeyValues: number;\n retrievedResourceDataBytes: number;\n retrievedKeyValueBytes: number;\n prunedFields: number;\n finalResourcesSkipped: number;\n millisSpent: number;\n /** Stop-marker frames whose id was already final locally and were skipped. */\n stopMarkersSkipped: number;\n /** Number of follow-up resourceTree() calls issued to resolve unknown stop markers. */\n stopMarkerFollowUpRoundTrips: number;\n /** Backend paths: resourceTree() streams consumed. Streaming spends 1, or 2 with a\n * follow-up; delta spends 1 plus one per resolution round. */\n streamRounds: number;\n /** Backend paths: resource frames received. */\n resourceFrames: number;\n /** Streaming path: stopMarker frames received (both skipped and follow-up). */\n stopMarkerFrames: number;\n /** Streaming path: stop markers that were not final locally and triggered a follow-up fetch. */\n stopMarkersFollowUp: number;\n /** Streaming path: frames where the backend stopped traversal (final or traverseWasStopped). */\n traverseWasStoppedCount: number;\n /** BFS path: resource states actually requested from the backend this cycle (after intra-cycle dedup). */\n bfsResourcesRequested: number;\n /** BFS path: requested resources that no longer exist (undefined reply). */\n bfsResourcesNotFound: number;\n /** Delta path: seed ids handed to the backend, summed over every round of the poll.\n * This is what seeding the whole frontier costs on the uplink. */\n deltaSeedsSent: number;\n /** Delta path: extra rounds spent resolving references a delta body pointed at but the\n * response did not carry. */\n deltaResolutionRounds: number;\n /** Delta path: polls that sent a token and got back a response the size of the whole\n * mirror, which is what a refused token looks like from here - rejection is silent, so this\n * is the only tell. Heuristic: a genuinely large change set trips it too. */\n deltaSuspectedFullAnswers: number;\n};\n\nexport function initialTreeLoadingStat(): TreeLoadingStat {\n return {\n requests: 0,\n roundTrips: 0,\n retrievedResources: 0,\n retrievedFields: 0,\n retrievedKeyValues: 0,\n retrievedResourceDataBytes: 0,\n retrievedKeyValueBytes: 0,\n prunedFields: 0,\n finalResourcesSkipped: 0,\n millisSpent: 0,\n stopMarkersSkipped: 0,\n stopMarkerFollowUpRoundTrips: 0,\n streamRounds: 0,\n resourceFrames: 0,\n stopMarkerFrames: 0,\n stopMarkersFollowUp: 0,\n traverseWasStoppedCount: 0,\n bfsResourcesRequested: 0,\n bfsResourcesNotFound: 0,\n deltaSeedsSent: 0,\n deltaResolutionRounds: 0,\n deltaSuspectedFullAnswers: 0,\n resourcesNew: 0,\n resourcesChanged: 0,\n resourcesUnchanged: 0,\n bytesUnchanged: 0,\n metadataStableChanged: 0,\n bfsRequestsWasted: 0,\n usedStreaming: false,\n fieldsAdded: 0,\n fieldsRemoved: 0,\n fieldsChanged: 0,\n kvChanged: 0,\n readyFlips: 0,\n locksChanged: 0,\n errorsAttached: 0,\n duplicatesResolved: 0,\n resourcesMarkedFinal: 0,\n };\n}\n\nexport function formatTreeLoadingStat(stat: TreeLoadingStat): string {\n return `Requests: ${stat.requests}\nTotal time: ${msToHumanReadable(stat.millisSpent)}\nRound-trips: ${stat.roundTrips}\nResources: ${stat.retrievedResources}\nFields: ${stat.retrievedFields}\nKV: ${stat.retrievedKeyValues}\nData Bytes: ${stat.retrievedResourceDataBytes}\nKV Bytes: ${stat.retrievedKeyValueBytes}\nPruned fields: ${stat.prunedFields}\nFinal resources skipped: ${stat.finalResourcesSkipped}\nStop markers skipped: ${stat.stopMarkersSkipped}\nStop marker follow-up round-trips: ${stat.stopMarkerFollowUpRoundTrips}\nNew resources: ${stat.resourcesNew}\nChanged resources: ${stat.resourcesChanged}\nUnchanged (duplicate re-fetch) resources: ${stat.resourcesUnchanged}\nUnchanged bytes (wasted downlink): ${stat.bytesUnchanged}\nChanged with stable metadata: ${stat.metadataStableChanged}\nBFS fetches wasted on unchanged: ${stat.bfsRequestsWasted}\nUsed streaming: ${stat.usedStreaming}\n[backend] rounds: ${stat.streamRounds}, resource frames: ${stat.resourceFrames}, stop-marker frames: ${stat.stopMarkerFrames}, stop->follow-up: ${stat.stopMarkersFollowUp}, traverse-stopped: ${stat.traverseWasStoppedCount}\n[bfs] resources requested: ${stat.bfsResourcesRequested}, not found: ${stat.bfsResourcesNotFound}\n[delta] seeds sent: ${stat.deltaSeedsSent}, resolution rounds: ${stat.deltaResolutionRounds}, suspected full answers: ${stat.deltaSuspectedFullAnswers}`;\n}\n\nexport function supportsResourceTreeTraversal(capabilities: readonly string[] = []): boolean {\n return hasCapability(capabilities, \"treeFilter:v2\");\n}\n\nfunction supportsTreeDelta(capabilities: readonly string[] = []): boolean {\n return hasCapability(capabilities, \"treeChangedSince:v2\");\n}\n\nexport function collectStatsForResource(resource: ExtendedResourceData, stats?: TreeLoadingStat) {\n if (!stats) return;\n stats.retrievedResources++;\n stats.retrievedFields += resource.fields.length;\n stats.retrievedKeyValues += resource.kv.length;\n stats.retrievedResourceDataBytes += resource.data?.length ?? 0;\n for (const kv of resource.kv) stats.retrievedKeyValueBytes += kv.value.length;\n}\n\nasync function loadTreeStateViaBfs(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n): Promise<ExtendedResourceData[]> {\n const { seedResources, finalResources, pruningFunction } = loadingRequest;\n\n // Limits the number of concurrent gRPC fetches to bound peak memory\n // from in-flight request/response buffers.\n const limiter = new ConcurrencyLimitingExecutor(100);\n\n // Promises of resource states, in the order they were requested.\n const pending = new Denque<Promise<ExtendedResourceData | undefined>>();\n\n // vars to calculate number of roundtrips for stats\n let roundTripToggle: boolean = true;\n let numberOfRoundTrips = 0;\n\n // tracking resources we already requested or queued\n const requested = new Set<SignedResourceId>();\n\n /** Mark a resource for fetching. Deduplicates and respects final-resource set. */\n const requestState = (rid: OptionalSignedResourceId) => {\n if (isNullSignedResourceId(rid) || requested.has(rid)) return;\n\n if (finalResources.has(rid)) {\n if (stats) stats.finalResourcesSkipped++;\n return;\n }\n\n requested.add(rid);\n if (stats) stats.bfsResourcesRequested++;\n\n pending.push(\n limiter.run(async () => {\n const resourceData = tx.getResourceDataIfExists(rid, true);\n const kvData = tx.listKeyValuesIfResourceExists(rid);\n\n // counting round-trip (begin)\n const addRT = roundTripToggle;\n if (roundTripToggle) roundTripToggle = false;\n\n const [resource, kv] = await Promise.all([resourceData, kvData]);\n\n // counting round-trip, actually incrementing counter and returning toggle back,\n // so the next request can acquire it\n if (addRT) {\n numberOfRoundTrips++;\n roundTripToggle = true;\n }\n\n if (resource === undefined) {\n if (stats) stats.bfsResourcesNotFound++;\n return undefined;\n }\n if (kv === undefined) throw new Error(\"Inconsistent replies\");\n\n return { ...resource, kv };\n }),\n );\n };\n\n // sending seed requests\n seedResources.forEach((rid) => requestState(rid));\n\n const result: ExtendedResourceData[] = [];\n let nextPromise: Promise<ExtendedResourceData | undefined> | undefined;\n while ((nextPromise = pending.shift()) !== undefined) {\n // at this point we pause and wait for the next requested resource state to arrive\n let nextResource = await nextPromise;\n if (nextResource === undefined)\n // ignoring resources that were not found (this may happen for seed resource ids)\n continue;\n\n if (pruningFunction !== undefined) {\n // apply field pruning, if requested\n const fieldsAfterPruning = pruningFunction(nextResource);\n // collecting stats\n if (stats) stats.prunedFields += nextResource.fields.length - fieldsAfterPruning.length;\n nextResource = { ...nextResource, fields: fieldsAfterPruning };\n }\n\n // continue traversal over the referenced resources\n requestState(nextResource.error);\n for (const field of nextResource.fields) {\n requestState(field.value);\n requestState(field.error);\n }\n\n // collecting stats\n collectStatsForResource(nextResource, stats);\n\n // aggregating the state\n result.push(nextResource);\n }\n\n if (stats) stats.roundTrips += numberOfRoundTrips;\n\n return result;\n}\n\nasync function processResourceTreeStream(\n treeItems: AsyncIterable<ResourceTreeFrame>,\n finalResources: Set<SignedResourceId>,\n pruningFunction: PruningFunction | undefined,\n stats: TreeLoadingStat | undefined,\n): Promise<{ result: ExtendedResourceData[]; followUpSeeds: SignedResourceId[] }> {\n const result: ExtendedResourceData[] = [];\n const followUpSeeds: SignedResourceId[] = [];\n\n // backend returns two types of frames:\n // - 'resource' frames contain the resource state and are processed normally\n // - 'stopMarker' frames indicate resources that are ignored due stop rules fired\n //\n // Usually stop rules indicates the resources with final state. In that case middle layer\n // should make a decision: has it already loaded the resource or should it be requested for get the latest state?\n for await (const frame of treeItems) {\n if (frame.frameKind === \"stopMarker\") {\n if (stats) stats.stopMarkerFrames++;\n if (finalResources.has(frame.id)) {\n if (stats) stats.stopMarkersSkipped++;\n continue;\n }\n if (stats) stats.stopMarkersFollowUp++;\n followUpSeeds.push(frame.id);\n continue;\n }\n\n // Normal resource frame.\n if (stats) {\n stats.resourceFrames++;\n if (frame.traverseWasStopped) stats.traverseWasStoppedCount++;\n }\n if (finalResources.has(frame.id)) {\n if (stats) stats.finalResourcesSkipped++;\n continue;\n }\n\n let nextResource: ExtendedResourceData = {\n id: frame.id,\n type: frame.type,\n kind: frame.kind,\n data: frame.data,\n resourceReady: frame.resourceReady,\n error: frame.error,\n originalResourceId: frame.originalResourceId,\n // traverseWasStopped: backend matched traverse stop rules — children were not streamed.\n // Mark as terminal; fields are resolved below.\n final: frame.final || frame.traverseWasStopped,\n inputsLocked: frame.inputsLocked,\n outputsLocked: frame.outputsLocked,\n fields: frame.fields,\n kv: frame.kv,\n };\n\n // Apply field rules: traverseWasStopped drops all fields to keep the refCount\n // invariant; pruning function further filters the remaining fields.\n const rawFields = frame.traverseWasStopped ? [] : nextResource.fields;\n const resolvedFields =\n pruningFunction !== undefined\n ? pruningFunction({ ...nextResource, fields: rawFields })\n : rawFields;\n if (stats) stats.prunedFields += nextResource.fields.length - resolvedFields.length;\n nextResource = { ...nextResource, fields: resolvedFields };\n\n collectStatsForResource(nextResource, stats);\n result.push(nextResource);\n }\n\n return { result, followUpSeeds };\n}\n\nasync function loadTreeStateViaResourceTree(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n logger?: { warn: (msg: string) => void; info?: (msg: unknown) => void },\n): Promise<ExtendedResourceData[]> {\n const { seedResources, finalResources, pruningFunction, fieldFilter, traverseStopRules } =\n loadingRequest;\n\n // Round 0: initial tree traversal.\n const treeItems = tx.resourceTree(seedResources, {\n includeKv: true,\n fieldFilter,\n traverseStopRules,\n });\n\n let { result, followUpSeeds } = await processResourceTreeStream(\n treeItems,\n finalResources,\n pruningFunction,\n stats,\n );\n if (stats) {\n stats.roundTrips++;\n stats.streamRounds++;\n }\n\n // Resolve stop-marker seeds by fetching them (see the note below on why the stop rule is not\n // reapplied). Loop in case a fetch still yields stop markers; dedup fetched ids so shared refs\n // or diamonds cannot loop forever (the id set is finite, so the loop terminates). A referenced\n // resource left unloaded would make updateFromResourceData throw \"orphan resource\".\n let pendingSeeds = followUpSeeds;\n const fetchedSeeds = new Set<SignedResourceId>();\n while (pendingSeeds.length > 0) {\n const roundSeeds = pendingSeeds.filter((id) => !fetchedSeeds.has(id));\n if (roundSeeds.length === 0) break;\n for (const id of roundSeeds) fetchedSeeds.add(id);\n\n // No traverseStopRules here on purpose: the seeds are the stop-marked resources, and the\n // stop rule (finality-based) would re-flag them as stop markers instead of loading their\n // state, leaving resources that reference them as orphans. The retry must fetch them plainly.\n const followUpItems = tx.resourceTree(roundSeeds, {\n includeKv: true,\n fieldFilter,\n });\n const { result: followUpResult, followUpSeeds: nextSeeds } = await processResourceTreeStream(\n followUpItems,\n finalResources,\n pruningFunction,\n stats,\n );\n // spreading fails due to exceeding stack argument size\n result = result.concat(followUpResult);\n if (stats) {\n logger?.info?.(\n `loadTreeStateViaResourceTree: follow-up request for ${roundSeeds.length} stop-marker seeds: ${JSON.stringify(roundSeeds)}`,\n );\n stats.roundTrips++;\n stats.streamRounds++;\n stats.stopMarkerFollowUpRoundTrips++;\n }\n pendingSeeds = nextSeeds;\n }\n\n return result;\n}\n\n/** Given the transaction (preferably read-only) and loading request, executes\n * the tree traversal algorithm, and collects fresh states of resources\n * to update the tree state. */\nexport async function loadTreeState(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n capabilities: readonly string[] = [],\n mode: TraversalMode = \"auto\",\n logger?: { warn: (msg: string) => void; info?: (msg: unknown) => void },\n): Promise<ExtendedResourceData[]> {\n const startTimestamp = Date.now();\n if (stats) stats.requests++;\n\n try {\n // A tree passes its pinned algorithm here, which resolves to itself. The mode form is\n // kept for callers that run a single load.\n const algorithm = resolveTreeLoadingAlgorithm(mode, capabilities, logger);\n // Both backend paths, not just streaming: state.ts reads this to attribute a BFS-only\n // wasted-fetch counter, and leaving it false for delta reports phantom waste.\n if (stats) stats.usedStreaming = algorithm !== \"client-bfs\";\n\n switch (algorithm) {\n case \"backend-delta\":\n return await loadDeltaTreeState(tx, loadingRequest, stats, logger);\n case \"backend-streaming\":\n return await loadTreeStateViaResourceTree(tx, loadingRequest, stats, logger);\n case \"client-bfs\":\n return await loadTreeStateViaBfs(tx, loadingRequest, stats);\n default:\n throw new Error(`unknown tree loading algorithm: ${algorithm as string}`);\n }\n } finally {\n if (stats) stats.millisSpent += Date.now() - startTimestamp;\n }\n}\n"],"mappings":";;;;;;;;;;AAsEA,SAAgB,4BACd,MACA,eAAkC,CAAC,GACnC,QAC0B;CAC1B,MAAM,YAAY,8BAA8B,YAAY;CAC5D,MAAM,QAAQ,kBAAkB,YAAY;CAC5C,QAAQ,MAAR;EACE,KAAK,cACH,OAAO;EACT,KAAK;GACH,IAAI,OAAO,OAAO;GAClB,CAAC,UAAU,QAAA,CAAS,KAClB,oGACG,YAAY,sBAAsB,aACvC;GACA,OAAO,YAAY,sBAAsB;EAC3C,KAAK;GACH,IAAI,WAAW,OAAO;GACtB,CAAC,UAAU,QAAA,CAAS,KAClB,iGACF;GACA,OAAO;EACT,KAAK;GAGH,IAAI,OAAO,OAAO;GAClB,OAAO,YAAY,sBAAsB;CAC7C;AACF;;;AAIA,SAAgB,4BACd,MACA,UAGI,CAAC,GACe;CACpB,MAAM,gBAAoC,CAAC;CAC3C,MAAM,iCAAiB,IAAI,IAAsB;CACjD,MAAM,+BAAe,IAAI,IAAsB;CAC/C,KAAK,iBAAiB,QAAQ;EAC5B,aAAa,IAAI,IAAI,EAAE;EACvB,IAAI,IAAI,YAAY,eAAe,IAAI,IAAI,EAAE;OACxC,cAAc,KAAK,IAAI,EAAE;CAChC,CAAC;CAED,KAAK,MAAM,QAAQ,KAAK,OAAO,IAAI,CAAC,aAAa,IAAI,IAAI,GAAG,cAAc,KAAK,IAAI;CAEnF,OAAO;EACL;EACA;EACA,OAAO,CAAC,GAAG,KAAK,KAAK;EACrB,gBAAgB;EAChB,iBAAiB,QAAQ;EACzB,aAAa,QAAQ;EACrB,mBAAmB,QAAQ;EAC3B,mBAAmB,QAAQ;CAC7B;AACF;AA4CA,SAAgB,yBAA0C;CACxD,OAAO;EACL,UAAU;EACV,YAAY;EACZ,oBAAoB;EACpB,iBAAiB;EACjB,oBAAoB;EACpB,4BAA4B;EAC5B,wBAAwB;EACxB,cAAc;EACd,uBAAuB;EACvB,aAAa;EACb,oBAAoB;EACpB,8BAA8B;EAC9B,cAAc;EACd,gBAAgB;EAChB,kBAAkB;EAClB,qBAAqB;EACrB,yBAAyB;EACzB,uBAAuB;EACvB,sBAAsB;EACtB,gBAAgB;EAChB,uBAAuB;EACvB,2BAA2B;EAC3B,cAAc;EACd,kBAAkB;EAClB,oBAAoB;EACpB,gBAAgB;EAChB,uBAAuB;EACvB,mBAAmB;EACnB,eAAe;EACf,aAAa;EACb,eAAe;EACf,eAAe;EACf,WAAW;EACX,YAAY;EACZ,cAAc;EACd,gBAAgB;EAChB,oBAAoB;EACpB,sBAAsB;CACxB;AACF;AAEA,SAAgB,sBAAsB,MAA+B;CACnE,OAAO,aAAa,KAAK,SAAS;gEACJ,KAAK,WAAW,EAAE;eACnC,KAAK,WAAW;aAClB,KAAK,mBAAmB;UAC3B,KAAK,gBAAgB;MACzB,KAAK,mBAAmB;cAChB,KAAK,2BAA2B;YAClC,KAAK,uBAAuB;iBACvB,KAAK,aAAa;2BACR,KAAK,sBAAsB;wBAC9B,KAAK,mBAAmB;qCACX,KAAK,6BAA6B;iBACtD,KAAK,aAAa;qBACd,KAAK,iBAAiB;4CACC,KAAK,mBAAmB;qCAC/B,KAAK,eAAe;gCACzB,KAAK,sBAAsB;mCACxB,KAAK,kBAAkB;kBACxC,KAAK,cAAc;oBACjB,KAAK,aAAa,qBAAqB,KAAK,eAAe,wBAAwB,KAAK,iBAAiB,qBAAqB,KAAK,oBAAoB,sBAAsB,KAAK,wBAAwB;6BACjM,KAAK,sBAAsB,eAAe,KAAK,qBAAqB;sBAC3E,KAAK,eAAe,uBAAuB,KAAK,sBAAsB,4BAA4B,KAAK;AAC7H;AAEA,SAAgB,8BAA8B,eAAkC,CAAC,GAAY;CAC3F,QAAA,GAAA,0BAAA,cAAA,CAAqB,cAAc,eAAe;AACpD;AAEA,SAAS,kBAAkB,eAAkC,CAAC,GAAY;CACxE,QAAA,GAAA,0BAAA,cAAA,CAAqB,cAAc,qBAAqB;AAC1D;AAEA,SAAgB,wBAAwB,UAAgC,OAAyB;CAC/F,IAAI,CAAC,OAAO;CACZ,MAAM;CACN,MAAM,mBAAmB,SAAS,OAAO;CACzC,MAAM,sBAAsB,SAAS,GAAG;CACxC,MAAM,8BAA8B,SAAS,MAAM,UAAU;CAC7D,KAAK,MAAM,MAAM,SAAS,IAAI,MAAM,0BAA0B,GAAG,MAAM;AACzE;AAEA,eAAe,oBACb,IACA,gBACA,OACiC;CACjC,MAAM,EAAE,eAAe,gBAAgB,oBAAoB;CAI3D,MAAM,UAAU,IAAIA,2BAAAA,4BAA4B,GAAG;CAGnD,MAAM,UAAU,IAAIC,OAAAA,QAAkD;CAGtE,IAAI,kBAA2B;CAC/B,IAAI,qBAAqB;CAGzB,MAAM,4BAAY,IAAI,IAAsB;;CAG5C,MAAM,gBAAgB,QAAkC;EACtD,KAAA,GAAA,0BAAA,uBAAA,CAA2B,GAAG,KAAK,UAAU,IAAI,GAAG,GAAG;EAEvD,IAAI,eAAe,IAAI,GAAG,GAAG;GAC3B,IAAI,OAAO,MAAM;GACjB;EACF;EAEA,UAAU,IAAI,GAAG;EACjB,IAAI,OAAO,MAAM;EAEjB,QAAQ,KACN,QAAQ,IAAI,YAAY;GACtB,MAAM,eAAe,GAAG,wBAAwB,KAAK,IAAI;GACzD,MAAM,SAAS,GAAG,8BAA8B,GAAG;GAGnD,MAAM,QAAQ;GACd,IAAI,iBAAiB,kBAAkB;GAEvC,MAAM,CAAC,UAAU,MAAM,MAAM,QAAQ,IAAI,CAAC,cAAc,MAAM,CAAC;GAI/D,IAAI,OAAO;IACT;IACA,kBAAkB;GACpB;GAEA,IAAI,aAAa,KAAA,GAAW;IAC1B,IAAI,OAAO,MAAM;IACjB;GACF;GACA,IAAI,OAAO,KAAA,GAAW,MAAM,IAAI,MAAM,sBAAsB;GAE5D,OAAO;IAAE,GAAG;IAAU;GAAG;EAC3B,CAAC,CACH;CACF;CAGA,cAAc,SAAS,QAAQ,aAAa,GAAG,CAAC;CAEhD,MAAM,SAAiC,CAAC;CACxC,IAAI;CACJ,QAAQ,cAAc,QAAQ,MAAM,OAAO,KAAA,GAAW;EAEpD,IAAI,eAAe,MAAM;EACzB,IAAI,iBAAiB,KAAA,GAEnB;EAEF,IAAI,oBAAoB,KAAA,GAAW;GAEjC,MAAM,qBAAqB,gBAAgB,YAAY;GAEvD,IAAI,OAAO,MAAM,gBAAgB,aAAa,OAAO,SAAS,mBAAmB;GACjF,eAAe;IAAE,GAAG;IAAc,QAAQ;GAAmB;EAC/D;EAGA,aAAa,aAAa,KAAK;EAC/B,KAAK,MAAM,SAAS,aAAa,QAAQ;GACvC,aAAa,MAAM,KAAK;GACxB,aAAa,MAAM,KAAK;EAC1B;EAGA,wBAAwB,cAAc,KAAK;EAG3C,OAAO,KAAK,YAAY;CAC1B;CAEA,IAAI,OAAO,MAAM,cAAc;CAE/B,OAAO;AACT;AAEA,eAAe,0BACb,WACA,gBACA,iBACA,OACgF;CAChF,MAAM,SAAiC,CAAC;CACxC,MAAM,gBAAoC,CAAC;CAQ3C,WAAW,MAAM,SAAS,WAAW;EACnC,IAAI,MAAM,cAAc,cAAc;GACpC,IAAI,OAAO,MAAM;GACjB,IAAI,eAAe,IAAI,MAAM,EAAE,GAAG;IAChC,IAAI,OAAO,MAAM;IACjB;GACF;GACA,IAAI,OAAO,MAAM;GACjB,cAAc,KAAK,MAAM,EAAE;GAC3B;EACF;EAGA,IAAI,OAAO;GACT,MAAM;GACN,IAAI,MAAM,oBAAoB,MAAM;EACtC;EACA,IAAI,eAAe,IAAI,MAAM,EAAE,GAAG;GAChC,IAAI,OAAO,MAAM;GACjB;EACF;EAEA,IAAI,eAAqC;GACvC,IAAI,MAAM;GACV,MAAM,MAAM;GACZ,MAAM,MAAM;GACZ,MAAM,MAAM;GACZ,eAAe,MAAM;GACrB,OAAO,MAAM;GACb,oBAAoB,MAAM;GAG1B,OAAO,MAAM,SAAS,MAAM;GAC5B,cAAc,MAAM;GACpB,eAAe,MAAM;GACrB,QAAQ,MAAM;GACd,IAAI,MAAM;EACZ;EAIA,MAAM,YAAY,MAAM,qBAAqB,CAAC,IAAI,aAAa;EAC/D,MAAM,iBACJ,oBAAoB,KAAA,IAChB,gBAAgB;GAAE,GAAG;GAAc,QAAQ;EAAU,CAAC,IACtD;EACN,IAAI,OAAO,MAAM,gBAAgB,aAAa,OAAO,SAAS,eAAe;EAC7E,eAAe;GAAE,GAAG;GAAc,QAAQ;EAAe;EAEzD,wBAAwB,cAAc,KAAK;EAC3C,OAAO,KAAK,YAAY;CAC1B;CAEA,OAAO;EAAE;EAAQ;CAAc;AACjC;AAEA,eAAe,6BACb,IACA,gBACA,OACA,QACiC;CACjC,MAAM,EAAE,eAAe,gBAAgB,iBAAiB,aAAa,sBACnE;CASF,IAAI,EAAE,QAAQ,kBAAkB,MAAM,0BANpB,GAAG,aAAa,eAAe;EAC/C,WAAW;EACX;EACA;CACF,CAGU,GACR,gBACA,iBACA,KACF;CACA,IAAI,OAAO;EACT,MAAM;EACN,MAAM;CACR;CAMA,IAAI,eAAe;CACnB,MAAM,+BAAe,IAAI,IAAsB;CAC/C,OAAO,aAAa,SAAS,GAAG;EAC9B,MAAM,aAAa,aAAa,QAAQ,OAAO,CAAC,aAAa,IAAI,EAAE,CAAC;EACpE,IAAI,WAAW,WAAW,GAAG;EAC7B,KAAK,MAAM,MAAM,YAAY,aAAa,IAAI,EAAE;EAShD,MAAM,EAAE,QAAQ,gBAAgB,eAAe,cAAc,MAAM,0BAJ7C,GAAG,aAAa,YAAY;GAChD,WAAW;GACX;EACF,CAEc,GACZ,gBACA,iBACA,KACF;EAEA,SAAS,OAAO,OAAO,cAAc;EACrC,IAAI,OAAO;GACT,QAAQ,OACN,uDAAuD,WAAW,OAAO,sBAAsB,KAAK,UAAU,UAAU,GAC1H;GACA,MAAM;GACN,MAAM;GACN,MAAM;EACR;EACA,eAAe;CACjB;CAEA,OAAO;AACT;;;;AAKA,eAAsB,cACpB,IACA,gBACA,OACA,eAAkC,CAAC,GACnC,OAAsB,QACtB,QACiC;CACjC,MAAM,iBAAiB,KAAK,IAAI;CAChC,IAAI,OAAO,MAAM;CAEjB,IAAI;EAGF,MAAM,YAAY,4BAA4B,MAAM,cAAc,MAAM;EAGxE,IAAI,OAAO,MAAM,gBAAgB,cAAc;EAE/C,QAAQ,WAAR;GACE,KAAK,iBACH,OAAO,MAAMC,mBAAAA,mBAAmB,IAAI,gBAAgB,OAAO,MAAM;GACnE,KAAK,qBACH,OAAO,MAAM,6BAA6B,IAAI,gBAAgB,OAAO,MAAM;GAC7E,KAAK,cACH,OAAO,MAAM,oBAAoB,IAAI,gBAAgB,KAAK;GAC5D,SACE,MAAM,IAAI,MAAM,mCAAmC,WAAqB;EAC5E;CACF,UAAU;EACR,IAAI,OAAO,MAAM,eAAe,KAAK,IAAI,IAAI;CAC/C;AACF"}
|
package/dist/sync.d.ts
CHANGED
|
@@ -30,13 +30,13 @@ export interface TreeLoadingRequest {
|
|
|
30
30
|
readonly changedSinceToken?: Uint8Array;
|
|
31
31
|
}
|
|
32
32
|
/** Controls which tree-loading path is used.
|
|
33
|
-
* - `"auto"` (default): use delta polling when the backend advertises `treeChangedSince:
|
|
33
|
+
* - `"auto"` (default): use delta polling when the backend advertises `treeChangedSince:v2`,
|
|
34
34
|
* else backend streaming when it advertises `treeFilter:v2`, else client-side BFS.
|
|
35
35
|
* - `"client-bfs"`: always use client-side BFS, even on capable backends.
|
|
36
36
|
* - `"backend-streaming"`: always prefer backend streaming; if the capability is absent,
|
|
37
37
|
* logs a warning and falls back to BFS (never throws).
|
|
38
38
|
* - `"backend-delta"`: always prefer delta polling, which hands the backend the transaction's
|
|
39
|
-
* change token and takes only what changed since it; if `treeChangedSince:
|
|
39
|
+
* change token and takes only what changed since it; if `treeChangedSince:v2` is absent,
|
|
40
40
|
* logs a warning and falls back to the best available path (never throws).
|
|
41
41
|
*/
|
|
42
42
|
export type TraversalMode = "auto" | "client-bfs" | "backend-streaming" | "backend-delta";
|
package/dist/sync.js
CHANGED
|
@@ -13,7 +13,7 @@ function resolveTreeLoadingAlgorithm(mode, capabilities = [], logger) {
|
|
|
13
13
|
case "client-bfs": return "client-bfs";
|
|
14
14
|
case "backend-delta":
|
|
15
15
|
if (delta) return "backend-delta";
|
|
16
|
-
(logger ?? console).warn("traversalMode=backend-delta but backend lacks treeChangedSince:
|
|
16
|
+
(logger ?? console).warn("traversalMode=backend-delta but backend lacks treeChangedSince:v2 capability; falling back to " + (streaming ? "backend-streaming" : "client-bfs"));
|
|
17
17
|
return streaming ? "backend-streaming" : "client-bfs";
|
|
18
18
|
case "backend-streaming":
|
|
19
19
|
if (streaming) return "backend-streaming";
|
|
@@ -117,7 +117,7 @@ function supportsResourceTreeTraversal(capabilities = []) {
|
|
|
117
117
|
return hasCapability(capabilities, "treeFilter:v2");
|
|
118
118
|
}
|
|
119
119
|
function supportsTreeDelta(capabilities = []) {
|
|
120
|
-
return hasCapability(capabilities, "treeChangedSince:
|
|
120
|
+
return hasCapability(capabilities, "treeChangedSince:v2");
|
|
121
121
|
}
|
|
122
122
|
function collectStatsForResource(resource, stats) {
|
|
123
123
|
if (!stats) return;
|
package/dist/sync.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync.js","names":[],"sources":["../src/sync.ts"],"sourcesContent":["import type {\n FieldData,\n Filter,\n OptionalSignedResourceId,\n PlTransaction,\n ResourceTreeFrame,\n SignedResourceId,\n} from \"@milaboratories/pl-client\";\nimport Denque from \"denque\";\nimport { hasCapability, isNullSignedResourceId } from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData, PlTreeState, ResourceUpdateStat } from \"./state\";\nimport { ConcurrencyLimitingExecutor, msToHumanReadable } from \"@milaboratories/ts-helpers\";\nimport { loadDeltaTreeState } from \"./delta_sync\";\n\n/** Applied to list of fields in resource data. */\nexport type PruningFunction = (resource: ExtendedResourceData) => FieldData[];\n\nexport interface TreeLoadingRequest {\n /** Resource to prime the traversal algorithm. It is ok, if some of them\n * doesn't exist anymore. Should not contain elements from final resource\n * set. */\n readonly seedResources: SignedResourceId[];\n\n /** Resource ids for which state is already known and not expected to change.\n * Algorithm will not continue traversal over those ids, and states will not\n * be retrieved for them. */\n readonly finalResources: Set<SignedResourceId>;\n\n /** Applied to each resource field list in fallback BFS mode and to streamed results. */\n readonly pruningFunction?: PruningFunction;\n\n /** ResourceTree field filter passed to the backend when supported. */\n readonly fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules passed to the backend when supported.\n * @deprecated prune with {@link changedSinceToken} instead; ignored by the backend under one. */\n readonly traverseStopRules?: Filter;\n\n /** The tree's roots. Delta seeds at these only when the non-final frontier is empty;\n * resourceTree requires at least one seed. */\n readonly roots: readonly SignedResourceId[];\n\n /** Every id the mirror currently holds, final or not. Delta uses it to tell a reference it\n * must resolve from one already satisfied locally. */\n readonly knownResources: ReadonlySet<SignedResourceId>;\n\n /** Change token from the transaction this request will run in, for a delta walk. Absent\n * means \"send the full tree\", which is also what a token the server cannot use gets. */\n readonly changedSinceToken?: Uint8Array;\n}\n\n/** Controls which tree-loading path is used.\n * - `\"auto\"` (default): use delta polling when the backend advertises `treeChangedSince:v1`,\n * else backend streaming when it advertises `treeFilter:v2`, else client-side BFS.\n * - `\"client-bfs\"`: always use client-side BFS, even on capable backends.\n * - `\"backend-streaming\"`: always prefer backend streaming; if the capability is absent,\n * logs a warning and falls back to BFS (never throws).\n * - `\"backend-delta\"`: always prefer delta polling, which hands the backend the transaction's\n * change token and takes only what changed since it; if `treeChangedSince:v1` is absent,\n * logs a warning and falls back to the best available path (never throws).\n */\nexport type TraversalMode = \"auto\" | \"client-bfs\" | \"backend-streaming\" | \"backend-delta\";\n\n/** A concrete loading algorithm: a {@link TraversalMode} with `\"auto\"` and any unsupported\n * preference already resolved against the server's capabilities. */\nexport type TreeLoadingAlgorithmName = \"client-bfs\" | \"backend-streaming\" | \"backend-delta\";\n\n/** Resolves a traversal mode into the algorithm a tree will run. A tree calls this once, when\n * it is made, and keeps the answer for its whole life, so the choice (and the fallback warning\n * for a preference the backend cannot serve) happens once rather than on every poll. */\nexport function resolveTreeLoadingAlgorithm(\n mode: TraversalMode,\n capabilities: readonly string[] = [],\n logger?: { warn: (msg: string) => void },\n): TreeLoadingAlgorithmName {\n const streaming = supportsResourceTreeTraversal(capabilities);\n const delta = supportsTreeDelta(capabilities);\n switch (mode) {\n case \"client-bfs\":\n return \"client-bfs\";\n case \"backend-delta\":\n if (delta) return \"backend-delta\";\n (logger ?? console).warn(\n \"traversalMode=backend-delta but backend lacks treeChangedSince:v1 capability; falling back to \" +\n (streaming ? \"backend-streaming\" : \"client-bfs\"),\n );\n return streaming ? \"backend-streaming\" : \"client-bfs\";\n case \"backend-streaming\":\n if (streaming) return \"backend-streaming\";\n (logger ?? console).warn(\n \"traversalMode=backend-streaming but backend lacks treeFilter:v2 capability; falling back to BFS\",\n );\n return \"client-bfs\";\n case \"auto\":\n // Delta first: its cost tracks what changed rather than what the tree holds. Streaming\n // is the fallback for a backend that can shape a walk but not date one.\n if (delta) return \"backend-delta\";\n return streaming ? \"backend-streaming\" : \"client-bfs\";\n }\n}\n\n/** Given the current tree state, build the request object to pass to\n * {@link loadTreeState} to load updated state. */\nexport function constructTreeLoadingRequest(\n tree: PlTreeState,\n options: Pick<\n TreeLoadingRequest,\n \"pruningFunction\" | \"fieldFilter\" | \"traverseStopRules\" | \"changedSinceToken\"\n > = {},\n): TreeLoadingRequest {\n const seedResources: SignedResourceId[] = [];\n const finalResources = new Set<SignedResourceId>();\n const materialized = new Set<SignedResourceId>();\n tree.forEachResource((res) => {\n materialized.add(res.id);\n if (res.finalState) finalResources.add(res.id);\n else seedResources.push(res.id);\n });\n\n for (const root of tree.roots) if (!materialized.has(root)) seedResources.push(root);\n\n return {\n seedResources,\n finalResources,\n roots: [...tree.roots],\n knownResources: materialized,\n pruningFunction: options.pruningFunction,\n fieldFilter: options.fieldFilter,\n traverseStopRules: options.traverseStopRules,\n changedSinceToken: options.changedSinceToken,\n };\n}\n\nexport type TreeLoadingStat = ResourceUpdateStat & {\n requests: number;\n roundTrips: number;\n retrievedResources: number;\n retrievedFields: number;\n retrievedKeyValues: number;\n retrievedResourceDataBytes: number;\n retrievedKeyValueBytes: number;\n prunedFields: number;\n finalResourcesSkipped: number;\n millisSpent: number;\n /** Stop-marker frames whose id was already final locally and were skipped. */\n stopMarkersSkipped: number;\n /** Number of follow-up resourceTree() calls issued to resolve unknown stop markers. */\n stopMarkerFollowUpRoundTrips: number;\n /** Backend paths: resourceTree() streams consumed. Streaming spends 1, or 2 with a\n * follow-up; delta spends 1 plus one per resolution round. */\n streamRounds: number;\n /** Backend paths: resource frames received. */\n resourceFrames: number;\n /** Streaming path: stopMarker frames received (both skipped and follow-up). */\n stopMarkerFrames: number;\n /** Streaming path: stop markers that were not final locally and triggered a follow-up fetch. */\n stopMarkersFollowUp: number;\n /** Streaming path: frames where the backend stopped traversal (final or traverseWasStopped). */\n traverseWasStoppedCount: number;\n /** BFS path: resource states actually requested from the backend this cycle (after intra-cycle dedup). */\n bfsResourcesRequested: number;\n /** BFS path: requested resources that no longer exist (undefined reply). */\n bfsResourcesNotFound: number;\n /** Delta path: seed ids handed to the backend, summed over every round of the poll.\n * This is what seeding the whole frontier costs on the uplink. */\n deltaSeedsSent: number;\n /** Delta path: extra rounds spent resolving references a delta body pointed at but the\n * response did not carry. */\n deltaResolutionRounds: number;\n /** Delta path: polls that sent a token and got back a response the size of the whole\n * mirror, which is what a refused token looks like from here - rejection is silent, so this\n * is the only tell. Heuristic: a genuinely large change set trips it too. */\n deltaSuspectedFullAnswers: number;\n};\n\nexport function initialTreeLoadingStat(): TreeLoadingStat {\n return {\n requests: 0,\n roundTrips: 0,\n retrievedResources: 0,\n retrievedFields: 0,\n retrievedKeyValues: 0,\n retrievedResourceDataBytes: 0,\n retrievedKeyValueBytes: 0,\n prunedFields: 0,\n finalResourcesSkipped: 0,\n millisSpent: 0,\n stopMarkersSkipped: 0,\n stopMarkerFollowUpRoundTrips: 0,\n streamRounds: 0,\n resourceFrames: 0,\n stopMarkerFrames: 0,\n stopMarkersFollowUp: 0,\n traverseWasStoppedCount: 0,\n bfsResourcesRequested: 0,\n bfsResourcesNotFound: 0,\n deltaSeedsSent: 0,\n deltaResolutionRounds: 0,\n deltaSuspectedFullAnswers: 0,\n resourcesNew: 0,\n resourcesChanged: 0,\n resourcesUnchanged: 0,\n bytesUnchanged: 0,\n metadataStableChanged: 0,\n bfsRequestsWasted: 0,\n usedStreaming: false,\n fieldsAdded: 0,\n fieldsRemoved: 0,\n fieldsChanged: 0,\n kvChanged: 0,\n readyFlips: 0,\n locksChanged: 0,\n errorsAttached: 0,\n duplicatesResolved: 0,\n resourcesMarkedFinal: 0,\n };\n}\n\nexport function formatTreeLoadingStat(stat: TreeLoadingStat): string {\n return `Requests: ${stat.requests}\nTotal time: ${msToHumanReadable(stat.millisSpent)}\nRound-trips: ${stat.roundTrips}\nResources: ${stat.retrievedResources}\nFields: ${stat.retrievedFields}\nKV: ${stat.retrievedKeyValues}\nData Bytes: ${stat.retrievedResourceDataBytes}\nKV Bytes: ${stat.retrievedKeyValueBytes}\nPruned fields: ${stat.prunedFields}\nFinal resources skipped: ${stat.finalResourcesSkipped}\nStop markers skipped: ${stat.stopMarkersSkipped}\nStop marker follow-up round-trips: ${stat.stopMarkerFollowUpRoundTrips}\nNew resources: ${stat.resourcesNew}\nChanged resources: ${stat.resourcesChanged}\nUnchanged (duplicate re-fetch) resources: ${stat.resourcesUnchanged}\nUnchanged bytes (wasted downlink): ${stat.bytesUnchanged}\nChanged with stable metadata: ${stat.metadataStableChanged}\nBFS fetches wasted on unchanged: ${stat.bfsRequestsWasted}\nUsed streaming: ${stat.usedStreaming}\n[backend] rounds: ${stat.streamRounds}, resource frames: ${stat.resourceFrames}, stop-marker frames: ${stat.stopMarkerFrames}, stop->follow-up: ${stat.stopMarkersFollowUp}, traverse-stopped: ${stat.traverseWasStoppedCount}\n[bfs] resources requested: ${stat.bfsResourcesRequested}, not found: ${stat.bfsResourcesNotFound}\n[delta] seeds sent: ${stat.deltaSeedsSent}, resolution rounds: ${stat.deltaResolutionRounds}, suspected full answers: ${stat.deltaSuspectedFullAnswers}`;\n}\n\nexport function supportsResourceTreeTraversal(capabilities: readonly string[] = []): boolean {\n return hasCapability(capabilities, \"treeFilter:v2\");\n}\n\nfunction supportsTreeDelta(capabilities: readonly string[] = []): boolean {\n return hasCapability(capabilities, \"treeChangedSince:v1\");\n}\n\nexport function collectStatsForResource(resource: ExtendedResourceData, stats?: TreeLoadingStat) {\n if (!stats) return;\n stats.retrievedResources++;\n stats.retrievedFields += resource.fields.length;\n stats.retrievedKeyValues += resource.kv.length;\n stats.retrievedResourceDataBytes += resource.data?.length ?? 0;\n for (const kv of resource.kv) stats.retrievedKeyValueBytes += kv.value.length;\n}\n\nasync function loadTreeStateViaBfs(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n): Promise<ExtendedResourceData[]> {\n const { seedResources, finalResources, pruningFunction } = loadingRequest;\n\n // Limits the number of concurrent gRPC fetches to bound peak memory\n // from in-flight request/response buffers.\n const limiter = new ConcurrencyLimitingExecutor(100);\n\n // Promises of resource states, in the order they were requested.\n const pending = new Denque<Promise<ExtendedResourceData | undefined>>();\n\n // vars to calculate number of roundtrips for stats\n let roundTripToggle: boolean = true;\n let numberOfRoundTrips = 0;\n\n // tracking resources we already requested or queued\n const requested = new Set<SignedResourceId>();\n\n /** Mark a resource for fetching. Deduplicates and respects final-resource set. */\n const requestState = (rid: OptionalSignedResourceId) => {\n if (isNullSignedResourceId(rid) || requested.has(rid)) return;\n\n if (finalResources.has(rid)) {\n if (stats) stats.finalResourcesSkipped++;\n return;\n }\n\n requested.add(rid);\n if (stats) stats.bfsResourcesRequested++;\n\n pending.push(\n limiter.run(async () => {\n const resourceData = tx.getResourceDataIfExists(rid, true);\n const kvData = tx.listKeyValuesIfResourceExists(rid);\n\n // counting round-trip (begin)\n const addRT = roundTripToggle;\n if (roundTripToggle) roundTripToggle = false;\n\n const [resource, kv] = await Promise.all([resourceData, kvData]);\n\n // counting round-trip, actually incrementing counter and returning toggle back,\n // so the next request can acquire it\n if (addRT) {\n numberOfRoundTrips++;\n roundTripToggle = true;\n }\n\n if (resource === undefined) {\n if (stats) stats.bfsResourcesNotFound++;\n return undefined;\n }\n if (kv === undefined) throw new Error(\"Inconsistent replies\");\n\n return { ...resource, kv };\n }),\n );\n };\n\n // sending seed requests\n seedResources.forEach((rid) => requestState(rid));\n\n const result: ExtendedResourceData[] = [];\n let nextPromise: Promise<ExtendedResourceData | undefined> | undefined;\n while ((nextPromise = pending.shift()) !== undefined) {\n // at this point we pause and wait for the next requested resource state to arrive\n let nextResource = await nextPromise;\n if (nextResource === undefined)\n // ignoring resources that were not found (this may happen for seed resource ids)\n continue;\n\n if (pruningFunction !== undefined) {\n // apply field pruning, if requested\n const fieldsAfterPruning = pruningFunction(nextResource);\n // collecting stats\n if (stats) stats.prunedFields += nextResource.fields.length - fieldsAfterPruning.length;\n nextResource = { ...nextResource, fields: fieldsAfterPruning };\n }\n\n // continue traversal over the referenced resources\n requestState(nextResource.error);\n for (const field of nextResource.fields) {\n requestState(field.value);\n requestState(field.error);\n }\n\n // collecting stats\n collectStatsForResource(nextResource, stats);\n\n // aggregating the state\n result.push(nextResource);\n }\n\n if (stats) stats.roundTrips += numberOfRoundTrips;\n\n return result;\n}\n\nasync function processResourceTreeStream(\n treeItems: AsyncIterable<ResourceTreeFrame>,\n finalResources: Set<SignedResourceId>,\n pruningFunction: PruningFunction | undefined,\n stats: TreeLoadingStat | undefined,\n): Promise<{ result: ExtendedResourceData[]; followUpSeeds: SignedResourceId[] }> {\n const result: ExtendedResourceData[] = [];\n const followUpSeeds: SignedResourceId[] = [];\n\n // backend returns two types of frames:\n // - 'resource' frames contain the resource state and are processed normally\n // - 'stopMarker' frames indicate resources that are ignored due stop rules fired\n //\n // Usually stop rules indicates the resources with final state. In that case middle layer\n // should make a decision: has it already loaded the resource or should it be requested for get the latest state?\n for await (const frame of treeItems) {\n if (frame.frameKind === \"stopMarker\") {\n if (stats) stats.stopMarkerFrames++;\n if (finalResources.has(frame.id)) {\n if (stats) stats.stopMarkersSkipped++;\n continue;\n }\n if (stats) stats.stopMarkersFollowUp++;\n followUpSeeds.push(frame.id);\n continue;\n }\n\n // Normal resource frame.\n if (stats) {\n stats.resourceFrames++;\n if (frame.traverseWasStopped) stats.traverseWasStoppedCount++;\n }\n if (finalResources.has(frame.id)) {\n if (stats) stats.finalResourcesSkipped++;\n continue;\n }\n\n let nextResource: ExtendedResourceData = {\n id: frame.id,\n type: frame.type,\n kind: frame.kind,\n data: frame.data,\n resourceReady: frame.resourceReady,\n error: frame.error,\n originalResourceId: frame.originalResourceId,\n // traverseWasStopped: backend matched traverse stop rules — children were not streamed.\n // Mark as terminal; fields are resolved below.\n final: frame.final || frame.traverseWasStopped,\n inputsLocked: frame.inputsLocked,\n outputsLocked: frame.outputsLocked,\n fields: frame.fields,\n kv: frame.kv,\n };\n\n // Apply field rules: traverseWasStopped drops all fields to keep the refCount\n // invariant; pruning function further filters the remaining fields.\n const rawFields = frame.traverseWasStopped ? [] : nextResource.fields;\n const resolvedFields =\n pruningFunction !== undefined\n ? pruningFunction({ ...nextResource, fields: rawFields })\n : rawFields;\n if (stats) stats.prunedFields += nextResource.fields.length - resolvedFields.length;\n nextResource = { ...nextResource, fields: resolvedFields };\n\n collectStatsForResource(nextResource, stats);\n result.push(nextResource);\n }\n\n return { result, followUpSeeds };\n}\n\nasync function loadTreeStateViaResourceTree(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n logger?: { warn: (msg: string) => void; info?: (msg: unknown) => void },\n): Promise<ExtendedResourceData[]> {\n const { seedResources, finalResources, pruningFunction, fieldFilter, traverseStopRules } =\n loadingRequest;\n\n // Round 0: initial tree traversal.\n const treeItems = tx.resourceTree(seedResources, {\n includeKv: true,\n fieldFilter,\n traverseStopRules,\n });\n\n let { result, followUpSeeds } = await processResourceTreeStream(\n treeItems,\n finalResources,\n pruningFunction,\n stats,\n );\n if (stats) {\n stats.roundTrips++;\n stats.streamRounds++;\n }\n\n // Resolve stop-marker seeds by fetching them (see the note below on why the stop rule is not\n // reapplied). Loop in case a fetch still yields stop markers; dedup fetched ids so shared refs\n // or diamonds cannot loop forever (the id set is finite, so the loop terminates). A referenced\n // resource left unloaded would make updateFromResourceData throw \"orphan resource\".\n let pendingSeeds = followUpSeeds;\n const fetchedSeeds = new Set<SignedResourceId>();\n while (pendingSeeds.length > 0) {\n const roundSeeds = pendingSeeds.filter((id) => !fetchedSeeds.has(id));\n if (roundSeeds.length === 0) break;\n for (const id of roundSeeds) fetchedSeeds.add(id);\n\n // No traverseStopRules here on purpose: the seeds are the stop-marked resources, and the\n // stop rule (finality-based) would re-flag them as stop markers instead of loading their\n // state, leaving resources that reference them as orphans. The retry must fetch them plainly.\n const followUpItems = tx.resourceTree(roundSeeds, {\n includeKv: true,\n fieldFilter,\n });\n const { result: followUpResult, followUpSeeds: nextSeeds } = await processResourceTreeStream(\n followUpItems,\n finalResources,\n pruningFunction,\n stats,\n );\n // spreading fails due to exceeding stack argument size\n result = result.concat(followUpResult);\n if (stats) {\n logger?.info?.(\n `loadTreeStateViaResourceTree: follow-up request for ${roundSeeds.length} stop-marker seeds: ${JSON.stringify(roundSeeds)}`,\n );\n stats.roundTrips++;\n stats.streamRounds++;\n stats.stopMarkerFollowUpRoundTrips++;\n }\n pendingSeeds = nextSeeds;\n }\n\n return result;\n}\n\n/** Given the transaction (preferably read-only) and loading request, executes\n * the tree traversal algorithm, and collects fresh states of resources\n * to update the tree state. */\nexport async function loadTreeState(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n capabilities: readonly string[] = [],\n mode: TraversalMode = \"auto\",\n logger?: { warn: (msg: string) => void; info?: (msg: unknown) => void },\n): Promise<ExtendedResourceData[]> {\n const startTimestamp = Date.now();\n if (stats) stats.requests++;\n\n try {\n // A tree passes its pinned algorithm here, which resolves to itself. The mode form is\n // kept for callers that run a single load.\n const algorithm = resolveTreeLoadingAlgorithm(mode, capabilities, logger);\n // Both backend paths, not just streaming: state.ts reads this to attribute a BFS-only\n // wasted-fetch counter, and leaving it false for delta reports phantom waste.\n if (stats) stats.usedStreaming = algorithm !== \"client-bfs\";\n\n switch (algorithm) {\n case \"backend-delta\":\n return await loadDeltaTreeState(tx, loadingRequest, stats, logger);\n case \"backend-streaming\":\n return await loadTreeStateViaResourceTree(tx, loadingRequest, stats, logger);\n case \"client-bfs\":\n return await loadTreeStateViaBfs(tx, loadingRequest, stats);\n default:\n throw new Error(`unknown tree loading algorithm: ${algorithm as string}`);\n }\n } finally {\n if (stats) stats.millisSpent += Date.now() - startTimestamp;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sync.js","names":[],"sources":["../src/sync.ts"],"sourcesContent":["import type {\n FieldData,\n Filter,\n OptionalSignedResourceId,\n PlTransaction,\n ResourceTreeFrame,\n SignedResourceId,\n} from \"@milaboratories/pl-client\";\nimport Denque from \"denque\";\nimport { hasCapability, isNullSignedResourceId } from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData, PlTreeState, ResourceUpdateStat } from \"./state\";\nimport { ConcurrencyLimitingExecutor, msToHumanReadable } from \"@milaboratories/ts-helpers\";\nimport { loadDeltaTreeState } from \"./delta_sync\";\n\n/** Applied to list of fields in resource data. */\nexport type PruningFunction = (resource: ExtendedResourceData) => FieldData[];\n\nexport interface TreeLoadingRequest {\n /** Resource to prime the traversal algorithm. It is ok, if some of them\n * doesn't exist anymore. Should not contain elements from final resource\n * set. */\n readonly seedResources: SignedResourceId[];\n\n /** Resource ids for which state is already known and not expected to change.\n * Algorithm will not continue traversal over those ids, and states will not\n * be retrieved for them. */\n readonly finalResources: Set<SignedResourceId>;\n\n /** Applied to each resource field list in fallback BFS mode and to streamed results. */\n readonly pruningFunction?: PruningFunction;\n\n /** ResourceTree field filter passed to the backend when supported. */\n readonly fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules passed to the backend when supported.\n * @deprecated prune with {@link changedSinceToken} instead; ignored by the backend under one. */\n readonly traverseStopRules?: Filter;\n\n /** The tree's roots. Delta seeds at these only when the non-final frontier is empty;\n * resourceTree requires at least one seed. */\n readonly roots: readonly SignedResourceId[];\n\n /** Every id the mirror currently holds, final or not. Delta uses it to tell a reference it\n * must resolve from one already satisfied locally. */\n readonly knownResources: ReadonlySet<SignedResourceId>;\n\n /** Change token from the transaction this request will run in, for a delta walk. Absent\n * means \"send the full tree\", which is also what a token the server cannot use gets. */\n readonly changedSinceToken?: Uint8Array;\n}\n\n/** Controls which tree-loading path is used.\n * - `\"auto\"` (default): use delta polling when the backend advertises `treeChangedSince:v2`,\n * else backend streaming when it advertises `treeFilter:v2`, else client-side BFS.\n * - `\"client-bfs\"`: always use client-side BFS, even on capable backends.\n * - `\"backend-streaming\"`: always prefer backend streaming; if the capability is absent,\n * logs a warning and falls back to BFS (never throws).\n * - `\"backend-delta\"`: always prefer delta polling, which hands the backend the transaction's\n * change token and takes only what changed since it; if `treeChangedSince:v2` is absent,\n * logs a warning and falls back to the best available path (never throws).\n */\nexport type TraversalMode = \"auto\" | \"client-bfs\" | \"backend-streaming\" | \"backend-delta\";\n\n/** A concrete loading algorithm: a {@link TraversalMode} with `\"auto\"` and any unsupported\n * preference already resolved against the server's capabilities. */\nexport type TreeLoadingAlgorithmName = \"client-bfs\" | \"backend-streaming\" | \"backend-delta\";\n\n/** Resolves a traversal mode into the algorithm a tree will run. A tree calls this once, when\n * it is made, and keeps the answer for its whole life, so the choice (and the fallback warning\n * for a preference the backend cannot serve) happens once rather than on every poll. */\nexport function resolveTreeLoadingAlgorithm(\n mode: TraversalMode,\n capabilities: readonly string[] = [],\n logger?: { warn: (msg: string) => void },\n): TreeLoadingAlgorithmName {\n const streaming = supportsResourceTreeTraversal(capabilities);\n const delta = supportsTreeDelta(capabilities);\n switch (mode) {\n case \"client-bfs\":\n return \"client-bfs\";\n case \"backend-delta\":\n if (delta) return \"backend-delta\";\n (logger ?? console).warn(\n \"traversalMode=backend-delta but backend lacks treeChangedSince:v2 capability; falling back to \" +\n (streaming ? \"backend-streaming\" : \"client-bfs\"),\n );\n return streaming ? \"backend-streaming\" : \"client-bfs\";\n case \"backend-streaming\":\n if (streaming) return \"backend-streaming\";\n (logger ?? console).warn(\n \"traversalMode=backend-streaming but backend lacks treeFilter:v2 capability; falling back to BFS\",\n );\n return \"client-bfs\";\n case \"auto\":\n // Delta first: its cost tracks what changed rather than what the tree holds. Streaming\n // is the fallback for a backend that can shape a walk but not date one.\n if (delta) return \"backend-delta\";\n return streaming ? \"backend-streaming\" : \"client-bfs\";\n }\n}\n\n/** Given the current tree state, build the request object to pass to\n * {@link loadTreeState} to load updated state. */\nexport function constructTreeLoadingRequest(\n tree: PlTreeState,\n options: Pick<\n TreeLoadingRequest,\n \"pruningFunction\" | \"fieldFilter\" | \"traverseStopRules\" | \"changedSinceToken\"\n > = {},\n): TreeLoadingRequest {\n const seedResources: SignedResourceId[] = [];\n const finalResources = new Set<SignedResourceId>();\n const materialized = new Set<SignedResourceId>();\n tree.forEachResource((res) => {\n materialized.add(res.id);\n if (res.finalState) finalResources.add(res.id);\n else seedResources.push(res.id);\n });\n\n for (const root of tree.roots) if (!materialized.has(root)) seedResources.push(root);\n\n return {\n seedResources,\n finalResources,\n roots: [...tree.roots],\n knownResources: materialized,\n pruningFunction: options.pruningFunction,\n fieldFilter: options.fieldFilter,\n traverseStopRules: options.traverseStopRules,\n changedSinceToken: options.changedSinceToken,\n };\n}\n\nexport type TreeLoadingStat = ResourceUpdateStat & {\n requests: number;\n roundTrips: number;\n retrievedResources: number;\n retrievedFields: number;\n retrievedKeyValues: number;\n retrievedResourceDataBytes: number;\n retrievedKeyValueBytes: number;\n prunedFields: number;\n finalResourcesSkipped: number;\n millisSpent: number;\n /** Stop-marker frames whose id was already final locally and were skipped. */\n stopMarkersSkipped: number;\n /** Number of follow-up resourceTree() calls issued to resolve unknown stop markers. */\n stopMarkerFollowUpRoundTrips: number;\n /** Backend paths: resourceTree() streams consumed. Streaming spends 1, or 2 with a\n * follow-up; delta spends 1 plus one per resolution round. */\n streamRounds: number;\n /** Backend paths: resource frames received. */\n resourceFrames: number;\n /** Streaming path: stopMarker frames received (both skipped and follow-up). */\n stopMarkerFrames: number;\n /** Streaming path: stop markers that were not final locally and triggered a follow-up fetch. */\n stopMarkersFollowUp: number;\n /** Streaming path: frames where the backend stopped traversal (final or traverseWasStopped). */\n traverseWasStoppedCount: number;\n /** BFS path: resource states actually requested from the backend this cycle (after intra-cycle dedup). */\n bfsResourcesRequested: number;\n /** BFS path: requested resources that no longer exist (undefined reply). */\n bfsResourcesNotFound: number;\n /** Delta path: seed ids handed to the backend, summed over every round of the poll.\n * This is what seeding the whole frontier costs on the uplink. */\n deltaSeedsSent: number;\n /** Delta path: extra rounds spent resolving references a delta body pointed at but the\n * response did not carry. */\n deltaResolutionRounds: number;\n /** Delta path: polls that sent a token and got back a response the size of the whole\n * mirror, which is what a refused token looks like from here - rejection is silent, so this\n * is the only tell. Heuristic: a genuinely large change set trips it too. */\n deltaSuspectedFullAnswers: number;\n};\n\nexport function initialTreeLoadingStat(): TreeLoadingStat {\n return {\n requests: 0,\n roundTrips: 0,\n retrievedResources: 0,\n retrievedFields: 0,\n retrievedKeyValues: 0,\n retrievedResourceDataBytes: 0,\n retrievedKeyValueBytes: 0,\n prunedFields: 0,\n finalResourcesSkipped: 0,\n millisSpent: 0,\n stopMarkersSkipped: 0,\n stopMarkerFollowUpRoundTrips: 0,\n streamRounds: 0,\n resourceFrames: 0,\n stopMarkerFrames: 0,\n stopMarkersFollowUp: 0,\n traverseWasStoppedCount: 0,\n bfsResourcesRequested: 0,\n bfsResourcesNotFound: 0,\n deltaSeedsSent: 0,\n deltaResolutionRounds: 0,\n deltaSuspectedFullAnswers: 0,\n resourcesNew: 0,\n resourcesChanged: 0,\n resourcesUnchanged: 0,\n bytesUnchanged: 0,\n metadataStableChanged: 0,\n bfsRequestsWasted: 0,\n usedStreaming: false,\n fieldsAdded: 0,\n fieldsRemoved: 0,\n fieldsChanged: 0,\n kvChanged: 0,\n readyFlips: 0,\n locksChanged: 0,\n errorsAttached: 0,\n duplicatesResolved: 0,\n resourcesMarkedFinal: 0,\n };\n}\n\nexport function formatTreeLoadingStat(stat: TreeLoadingStat): string {\n return `Requests: ${stat.requests}\nTotal time: ${msToHumanReadable(stat.millisSpent)}\nRound-trips: ${stat.roundTrips}\nResources: ${stat.retrievedResources}\nFields: ${stat.retrievedFields}\nKV: ${stat.retrievedKeyValues}\nData Bytes: ${stat.retrievedResourceDataBytes}\nKV Bytes: ${stat.retrievedKeyValueBytes}\nPruned fields: ${stat.prunedFields}\nFinal resources skipped: ${stat.finalResourcesSkipped}\nStop markers skipped: ${stat.stopMarkersSkipped}\nStop marker follow-up round-trips: ${stat.stopMarkerFollowUpRoundTrips}\nNew resources: ${stat.resourcesNew}\nChanged resources: ${stat.resourcesChanged}\nUnchanged (duplicate re-fetch) resources: ${stat.resourcesUnchanged}\nUnchanged bytes (wasted downlink): ${stat.bytesUnchanged}\nChanged with stable metadata: ${stat.metadataStableChanged}\nBFS fetches wasted on unchanged: ${stat.bfsRequestsWasted}\nUsed streaming: ${stat.usedStreaming}\n[backend] rounds: ${stat.streamRounds}, resource frames: ${stat.resourceFrames}, stop-marker frames: ${stat.stopMarkerFrames}, stop->follow-up: ${stat.stopMarkersFollowUp}, traverse-stopped: ${stat.traverseWasStoppedCount}\n[bfs] resources requested: ${stat.bfsResourcesRequested}, not found: ${stat.bfsResourcesNotFound}\n[delta] seeds sent: ${stat.deltaSeedsSent}, resolution rounds: ${stat.deltaResolutionRounds}, suspected full answers: ${stat.deltaSuspectedFullAnswers}`;\n}\n\nexport function supportsResourceTreeTraversal(capabilities: readonly string[] = []): boolean {\n return hasCapability(capabilities, \"treeFilter:v2\");\n}\n\nfunction supportsTreeDelta(capabilities: readonly string[] = []): boolean {\n return hasCapability(capabilities, \"treeChangedSince:v2\");\n}\n\nexport function collectStatsForResource(resource: ExtendedResourceData, stats?: TreeLoadingStat) {\n if (!stats) return;\n stats.retrievedResources++;\n stats.retrievedFields += resource.fields.length;\n stats.retrievedKeyValues += resource.kv.length;\n stats.retrievedResourceDataBytes += resource.data?.length ?? 0;\n for (const kv of resource.kv) stats.retrievedKeyValueBytes += kv.value.length;\n}\n\nasync function loadTreeStateViaBfs(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n): Promise<ExtendedResourceData[]> {\n const { seedResources, finalResources, pruningFunction } = loadingRequest;\n\n // Limits the number of concurrent gRPC fetches to bound peak memory\n // from in-flight request/response buffers.\n const limiter = new ConcurrencyLimitingExecutor(100);\n\n // Promises of resource states, in the order they were requested.\n const pending = new Denque<Promise<ExtendedResourceData | undefined>>();\n\n // vars to calculate number of roundtrips for stats\n let roundTripToggle: boolean = true;\n let numberOfRoundTrips = 0;\n\n // tracking resources we already requested or queued\n const requested = new Set<SignedResourceId>();\n\n /** Mark a resource for fetching. Deduplicates and respects final-resource set. */\n const requestState = (rid: OptionalSignedResourceId) => {\n if (isNullSignedResourceId(rid) || requested.has(rid)) return;\n\n if (finalResources.has(rid)) {\n if (stats) stats.finalResourcesSkipped++;\n return;\n }\n\n requested.add(rid);\n if (stats) stats.bfsResourcesRequested++;\n\n pending.push(\n limiter.run(async () => {\n const resourceData = tx.getResourceDataIfExists(rid, true);\n const kvData = tx.listKeyValuesIfResourceExists(rid);\n\n // counting round-trip (begin)\n const addRT = roundTripToggle;\n if (roundTripToggle) roundTripToggle = false;\n\n const [resource, kv] = await Promise.all([resourceData, kvData]);\n\n // counting round-trip, actually incrementing counter and returning toggle back,\n // so the next request can acquire it\n if (addRT) {\n numberOfRoundTrips++;\n roundTripToggle = true;\n }\n\n if (resource === undefined) {\n if (stats) stats.bfsResourcesNotFound++;\n return undefined;\n }\n if (kv === undefined) throw new Error(\"Inconsistent replies\");\n\n return { ...resource, kv };\n }),\n );\n };\n\n // sending seed requests\n seedResources.forEach((rid) => requestState(rid));\n\n const result: ExtendedResourceData[] = [];\n let nextPromise: Promise<ExtendedResourceData | undefined> | undefined;\n while ((nextPromise = pending.shift()) !== undefined) {\n // at this point we pause and wait for the next requested resource state to arrive\n let nextResource = await nextPromise;\n if (nextResource === undefined)\n // ignoring resources that were not found (this may happen for seed resource ids)\n continue;\n\n if (pruningFunction !== undefined) {\n // apply field pruning, if requested\n const fieldsAfterPruning = pruningFunction(nextResource);\n // collecting stats\n if (stats) stats.prunedFields += nextResource.fields.length - fieldsAfterPruning.length;\n nextResource = { ...nextResource, fields: fieldsAfterPruning };\n }\n\n // continue traversal over the referenced resources\n requestState(nextResource.error);\n for (const field of nextResource.fields) {\n requestState(field.value);\n requestState(field.error);\n }\n\n // collecting stats\n collectStatsForResource(nextResource, stats);\n\n // aggregating the state\n result.push(nextResource);\n }\n\n if (stats) stats.roundTrips += numberOfRoundTrips;\n\n return result;\n}\n\nasync function processResourceTreeStream(\n treeItems: AsyncIterable<ResourceTreeFrame>,\n finalResources: Set<SignedResourceId>,\n pruningFunction: PruningFunction | undefined,\n stats: TreeLoadingStat | undefined,\n): Promise<{ result: ExtendedResourceData[]; followUpSeeds: SignedResourceId[] }> {\n const result: ExtendedResourceData[] = [];\n const followUpSeeds: SignedResourceId[] = [];\n\n // backend returns two types of frames:\n // - 'resource' frames contain the resource state and are processed normally\n // - 'stopMarker' frames indicate resources that are ignored due stop rules fired\n //\n // Usually stop rules indicates the resources with final state. In that case middle layer\n // should make a decision: has it already loaded the resource or should it be requested for get the latest state?\n for await (const frame of treeItems) {\n if (frame.frameKind === \"stopMarker\") {\n if (stats) stats.stopMarkerFrames++;\n if (finalResources.has(frame.id)) {\n if (stats) stats.stopMarkersSkipped++;\n continue;\n }\n if (stats) stats.stopMarkersFollowUp++;\n followUpSeeds.push(frame.id);\n continue;\n }\n\n // Normal resource frame.\n if (stats) {\n stats.resourceFrames++;\n if (frame.traverseWasStopped) stats.traverseWasStoppedCount++;\n }\n if (finalResources.has(frame.id)) {\n if (stats) stats.finalResourcesSkipped++;\n continue;\n }\n\n let nextResource: ExtendedResourceData = {\n id: frame.id,\n type: frame.type,\n kind: frame.kind,\n data: frame.data,\n resourceReady: frame.resourceReady,\n error: frame.error,\n originalResourceId: frame.originalResourceId,\n // traverseWasStopped: backend matched traverse stop rules — children were not streamed.\n // Mark as terminal; fields are resolved below.\n final: frame.final || frame.traverseWasStopped,\n inputsLocked: frame.inputsLocked,\n outputsLocked: frame.outputsLocked,\n fields: frame.fields,\n kv: frame.kv,\n };\n\n // Apply field rules: traverseWasStopped drops all fields to keep the refCount\n // invariant; pruning function further filters the remaining fields.\n const rawFields = frame.traverseWasStopped ? [] : nextResource.fields;\n const resolvedFields =\n pruningFunction !== undefined\n ? pruningFunction({ ...nextResource, fields: rawFields })\n : rawFields;\n if (stats) stats.prunedFields += nextResource.fields.length - resolvedFields.length;\n nextResource = { ...nextResource, fields: resolvedFields };\n\n collectStatsForResource(nextResource, stats);\n result.push(nextResource);\n }\n\n return { result, followUpSeeds };\n}\n\nasync function loadTreeStateViaResourceTree(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n logger?: { warn: (msg: string) => void; info?: (msg: unknown) => void },\n): Promise<ExtendedResourceData[]> {\n const { seedResources, finalResources, pruningFunction, fieldFilter, traverseStopRules } =\n loadingRequest;\n\n // Round 0: initial tree traversal.\n const treeItems = tx.resourceTree(seedResources, {\n includeKv: true,\n fieldFilter,\n traverseStopRules,\n });\n\n let { result, followUpSeeds } = await processResourceTreeStream(\n treeItems,\n finalResources,\n pruningFunction,\n stats,\n );\n if (stats) {\n stats.roundTrips++;\n stats.streamRounds++;\n }\n\n // Resolve stop-marker seeds by fetching them (see the note below on why the stop rule is not\n // reapplied). Loop in case a fetch still yields stop markers; dedup fetched ids so shared refs\n // or diamonds cannot loop forever (the id set is finite, so the loop terminates). A referenced\n // resource left unloaded would make updateFromResourceData throw \"orphan resource\".\n let pendingSeeds = followUpSeeds;\n const fetchedSeeds = new Set<SignedResourceId>();\n while (pendingSeeds.length > 0) {\n const roundSeeds = pendingSeeds.filter((id) => !fetchedSeeds.has(id));\n if (roundSeeds.length === 0) break;\n for (const id of roundSeeds) fetchedSeeds.add(id);\n\n // No traverseStopRules here on purpose: the seeds are the stop-marked resources, and the\n // stop rule (finality-based) would re-flag them as stop markers instead of loading their\n // state, leaving resources that reference them as orphans. The retry must fetch them plainly.\n const followUpItems = tx.resourceTree(roundSeeds, {\n includeKv: true,\n fieldFilter,\n });\n const { result: followUpResult, followUpSeeds: nextSeeds } = await processResourceTreeStream(\n followUpItems,\n finalResources,\n pruningFunction,\n stats,\n );\n // spreading fails due to exceeding stack argument size\n result = result.concat(followUpResult);\n if (stats) {\n logger?.info?.(\n `loadTreeStateViaResourceTree: follow-up request for ${roundSeeds.length} stop-marker seeds: ${JSON.stringify(roundSeeds)}`,\n );\n stats.roundTrips++;\n stats.streamRounds++;\n stats.stopMarkerFollowUpRoundTrips++;\n }\n pendingSeeds = nextSeeds;\n }\n\n return result;\n}\n\n/** Given the transaction (preferably read-only) and loading request, executes\n * the tree traversal algorithm, and collects fresh states of resources\n * to update the tree state. */\nexport async function loadTreeState(\n tx: PlTransaction,\n loadingRequest: TreeLoadingRequest,\n stats?: TreeLoadingStat,\n capabilities: readonly string[] = [],\n mode: TraversalMode = \"auto\",\n logger?: { warn: (msg: string) => void; info?: (msg: unknown) => void },\n): Promise<ExtendedResourceData[]> {\n const startTimestamp = Date.now();\n if (stats) stats.requests++;\n\n try {\n // A tree passes its pinned algorithm here, which resolves to itself. The mode form is\n // kept for callers that run a single load.\n const algorithm = resolveTreeLoadingAlgorithm(mode, capabilities, logger);\n // Both backend paths, not just streaming: state.ts reads this to attribute a BFS-only\n // wasted-fetch counter, and leaving it false for delta reports phantom waste.\n if (stats) stats.usedStreaming = algorithm !== \"client-bfs\";\n\n switch (algorithm) {\n case \"backend-delta\":\n return await loadDeltaTreeState(tx, loadingRequest, stats, logger);\n case \"backend-streaming\":\n return await loadTreeStateViaResourceTree(tx, loadingRequest, stats, logger);\n case \"client-bfs\":\n return await loadTreeStateViaBfs(tx, loadingRequest, stats);\n default:\n throw new Error(`unknown tree loading algorithm: ${algorithm as string}`);\n }\n } finally {\n if (stats) stats.millisSpent += Date.now() - startTimestamp;\n }\n}\n"],"mappings":";;;;;;;;AAsEA,SAAgB,4BACd,MACA,eAAkC,CAAC,GACnC,QAC0B;CAC1B,MAAM,YAAY,8BAA8B,YAAY;CAC5D,MAAM,QAAQ,kBAAkB,YAAY;CAC5C,QAAQ,MAAR;EACE,KAAK,cACH,OAAO;EACT,KAAK;GACH,IAAI,OAAO,OAAO;GAClB,CAAC,UAAU,QAAA,CAAS,KAClB,oGACG,YAAY,sBAAsB,aACvC;GACA,OAAO,YAAY,sBAAsB;EAC3C,KAAK;GACH,IAAI,WAAW,OAAO;GACtB,CAAC,UAAU,QAAA,CAAS,KAClB,iGACF;GACA,OAAO;EACT,KAAK;GAGH,IAAI,OAAO,OAAO;GAClB,OAAO,YAAY,sBAAsB;CAC7C;AACF;;;AAIA,SAAgB,4BACd,MACA,UAGI,CAAC,GACe;CACpB,MAAM,gBAAoC,CAAC;CAC3C,MAAM,iCAAiB,IAAI,IAAsB;CACjD,MAAM,+BAAe,IAAI,IAAsB;CAC/C,KAAK,iBAAiB,QAAQ;EAC5B,aAAa,IAAI,IAAI,EAAE;EACvB,IAAI,IAAI,YAAY,eAAe,IAAI,IAAI,EAAE;OACxC,cAAc,KAAK,IAAI,EAAE;CAChC,CAAC;CAED,KAAK,MAAM,QAAQ,KAAK,OAAO,IAAI,CAAC,aAAa,IAAI,IAAI,GAAG,cAAc,KAAK,IAAI;CAEnF,OAAO;EACL;EACA;EACA,OAAO,CAAC,GAAG,KAAK,KAAK;EACrB,gBAAgB;EAChB,iBAAiB,QAAQ;EACzB,aAAa,QAAQ;EACrB,mBAAmB,QAAQ;EAC3B,mBAAmB,QAAQ;CAC7B;AACF;AA4CA,SAAgB,yBAA0C;CACxD,OAAO;EACL,UAAU;EACV,YAAY;EACZ,oBAAoB;EACpB,iBAAiB;EACjB,oBAAoB;EACpB,4BAA4B;EAC5B,wBAAwB;EACxB,cAAc;EACd,uBAAuB;EACvB,aAAa;EACb,oBAAoB;EACpB,8BAA8B;EAC9B,cAAc;EACd,gBAAgB;EAChB,kBAAkB;EAClB,qBAAqB;EACrB,yBAAyB;EACzB,uBAAuB;EACvB,sBAAsB;EACtB,gBAAgB;EAChB,uBAAuB;EACvB,2BAA2B;EAC3B,cAAc;EACd,kBAAkB;EAClB,oBAAoB;EACpB,gBAAgB;EAChB,uBAAuB;EACvB,mBAAmB;EACnB,eAAe;EACf,aAAa;EACb,eAAe;EACf,eAAe;EACf,WAAW;EACX,YAAY;EACZ,cAAc;EACd,gBAAgB;EAChB,oBAAoB;EACpB,sBAAsB;CACxB;AACF;AAEA,SAAgB,sBAAsB,MAA+B;CACnE,OAAO,aAAa,KAAK,SAAS;cACtB,kBAAkB,KAAK,WAAW,EAAE;eACnC,KAAK,WAAW;aAClB,KAAK,mBAAmB;UAC3B,KAAK,gBAAgB;MACzB,KAAK,mBAAmB;cAChB,KAAK,2BAA2B;YAClC,KAAK,uBAAuB;iBACvB,KAAK,aAAa;2BACR,KAAK,sBAAsB;wBAC9B,KAAK,mBAAmB;qCACX,KAAK,6BAA6B;iBACtD,KAAK,aAAa;qBACd,KAAK,iBAAiB;4CACC,KAAK,mBAAmB;qCAC/B,KAAK,eAAe;gCACzB,KAAK,sBAAsB;mCACxB,KAAK,kBAAkB;kBACxC,KAAK,cAAc;oBACjB,KAAK,aAAa,qBAAqB,KAAK,eAAe,wBAAwB,KAAK,iBAAiB,qBAAqB,KAAK,oBAAoB,sBAAsB,KAAK,wBAAwB;6BACjM,KAAK,sBAAsB,eAAe,KAAK,qBAAqB;sBAC3E,KAAK,eAAe,uBAAuB,KAAK,sBAAsB,4BAA4B,KAAK;AAC7H;AAEA,SAAgB,8BAA8B,eAAkC,CAAC,GAAY;CAC3F,OAAO,cAAc,cAAc,eAAe;AACpD;AAEA,SAAS,kBAAkB,eAAkC,CAAC,GAAY;CACxE,OAAO,cAAc,cAAc,qBAAqB;AAC1D;AAEA,SAAgB,wBAAwB,UAAgC,OAAyB;CAC/F,IAAI,CAAC,OAAO;CACZ,MAAM;CACN,MAAM,mBAAmB,SAAS,OAAO;CACzC,MAAM,sBAAsB,SAAS,GAAG;CACxC,MAAM,8BAA8B,SAAS,MAAM,UAAU;CAC7D,KAAK,MAAM,MAAM,SAAS,IAAI,MAAM,0BAA0B,GAAG,MAAM;AACzE;AAEA,eAAe,oBACb,IACA,gBACA,OACiC;CACjC,MAAM,EAAE,eAAe,gBAAgB,oBAAoB;CAI3D,MAAM,UAAU,IAAI,4BAA4B,GAAG;CAGnD,MAAM,UAAU,IAAI,OAAkD;CAGtE,IAAI,kBAA2B;CAC/B,IAAI,qBAAqB;CAGzB,MAAM,4BAAY,IAAI,IAAsB;;CAG5C,MAAM,gBAAgB,QAAkC;EACtD,IAAI,uBAAuB,GAAG,KAAK,UAAU,IAAI,GAAG,GAAG;EAEvD,IAAI,eAAe,IAAI,GAAG,GAAG;GAC3B,IAAI,OAAO,MAAM;GACjB;EACF;EAEA,UAAU,IAAI,GAAG;EACjB,IAAI,OAAO,MAAM;EAEjB,QAAQ,KACN,QAAQ,IAAI,YAAY;GACtB,MAAM,eAAe,GAAG,wBAAwB,KAAK,IAAI;GACzD,MAAM,SAAS,GAAG,8BAA8B,GAAG;GAGnD,MAAM,QAAQ;GACd,IAAI,iBAAiB,kBAAkB;GAEvC,MAAM,CAAC,UAAU,MAAM,MAAM,QAAQ,IAAI,CAAC,cAAc,MAAM,CAAC;GAI/D,IAAI,OAAO;IACT;IACA,kBAAkB;GACpB;GAEA,IAAI,aAAa,KAAA,GAAW;IAC1B,IAAI,OAAO,MAAM;IACjB;GACF;GACA,IAAI,OAAO,KAAA,GAAW,MAAM,IAAI,MAAM,sBAAsB;GAE5D,OAAO;IAAE,GAAG;IAAU;GAAG;EAC3B,CAAC,CACH;CACF;CAGA,cAAc,SAAS,QAAQ,aAAa,GAAG,CAAC;CAEhD,MAAM,SAAiC,CAAC;CACxC,IAAI;CACJ,QAAQ,cAAc,QAAQ,MAAM,OAAO,KAAA,GAAW;EAEpD,IAAI,eAAe,MAAM;EACzB,IAAI,iBAAiB,KAAA,GAEnB;EAEF,IAAI,oBAAoB,KAAA,GAAW;GAEjC,MAAM,qBAAqB,gBAAgB,YAAY;GAEvD,IAAI,OAAO,MAAM,gBAAgB,aAAa,OAAO,SAAS,mBAAmB;GACjF,eAAe;IAAE,GAAG;IAAc,QAAQ;GAAmB;EAC/D;EAGA,aAAa,aAAa,KAAK;EAC/B,KAAK,MAAM,SAAS,aAAa,QAAQ;GACvC,aAAa,MAAM,KAAK;GACxB,aAAa,MAAM,KAAK;EAC1B;EAGA,wBAAwB,cAAc,KAAK;EAG3C,OAAO,KAAK,YAAY;CAC1B;CAEA,IAAI,OAAO,MAAM,cAAc;CAE/B,OAAO;AACT;AAEA,eAAe,0BACb,WACA,gBACA,iBACA,OACgF;CAChF,MAAM,SAAiC,CAAC;CACxC,MAAM,gBAAoC,CAAC;CAQ3C,WAAW,MAAM,SAAS,WAAW;EACnC,IAAI,MAAM,cAAc,cAAc;GACpC,IAAI,OAAO,MAAM;GACjB,IAAI,eAAe,IAAI,MAAM,EAAE,GAAG;IAChC,IAAI,OAAO,MAAM;IACjB;GACF;GACA,IAAI,OAAO,MAAM;GACjB,cAAc,KAAK,MAAM,EAAE;GAC3B;EACF;EAGA,IAAI,OAAO;GACT,MAAM;GACN,IAAI,MAAM,oBAAoB,MAAM;EACtC;EACA,IAAI,eAAe,IAAI,MAAM,EAAE,GAAG;GAChC,IAAI,OAAO,MAAM;GACjB;EACF;EAEA,IAAI,eAAqC;GACvC,IAAI,MAAM;GACV,MAAM,MAAM;GACZ,MAAM,MAAM;GACZ,MAAM,MAAM;GACZ,eAAe,MAAM;GACrB,OAAO,MAAM;GACb,oBAAoB,MAAM;GAG1B,OAAO,MAAM,SAAS,MAAM;GAC5B,cAAc,MAAM;GACpB,eAAe,MAAM;GACrB,QAAQ,MAAM;GACd,IAAI,MAAM;EACZ;EAIA,MAAM,YAAY,MAAM,qBAAqB,CAAC,IAAI,aAAa;EAC/D,MAAM,iBACJ,oBAAoB,KAAA,IAChB,gBAAgB;GAAE,GAAG;GAAc,QAAQ;EAAU,CAAC,IACtD;EACN,IAAI,OAAO,MAAM,gBAAgB,aAAa,OAAO,SAAS,eAAe;EAC7E,eAAe;GAAE,GAAG;GAAc,QAAQ;EAAe;EAEzD,wBAAwB,cAAc,KAAK;EAC3C,OAAO,KAAK,YAAY;CAC1B;CAEA,OAAO;EAAE;EAAQ;CAAc;AACjC;AAEA,eAAe,6BACb,IACA,gBACA,OACA,QACiC;CACjC,MAAM,EAAE,eAAe,gBAAgB,iBAAiB,aAAa,sBACnE;CASF,IAAI,EAAE,QAAQ,kBAAkB,MAAM,0BANpB,GAAG,aAAa,eAAe;EAC/C,WAAW;EACX;EACA;CACF,CAGU,GACR,gBACA,iBACA,KACF;CACA,IAAI,OAAO;EACT,MAAM;EACN,MAAM;CACR;CAMA,IAAI,eAAe;CACnB,MAAM,+BAAe,IAAI,IAAsB;CAC/C,OAAO,aAAa,SAAS,GAAG;EAC9B,MAAM,aAAa,aAAa,QAAQ,OAAO,CAAC,aAAa,IAAI,EAAE,CAAC;EACpE,IAAI,WAAW,WAAW,GAAG;EAC7B,KAAK,MAAM,MAAM,YAAY,aAAa,IAAI,EAAE;EAShD,MAAM,EAAE,QAAQ,gBAAgB,eAAe,cAAc,MAAM,0BAJ7C,GAAG,aAAa,YAAY;GAChD,WAAW;GACX;EACF,CAEc,GACZ,gBACA,iBACA,KACF;EAEA,SAAS,OAAO,OAAO,cAAc;EACrC,IAAI,OAAO;GACT,QAAQ,OACN,uDAAuD,WAAW,OAAO,sBAAsB,KAAK,UAAU,UAAU,GAC1H;GACA,MAAM;GACN,MAAM;GACN,MAAM;EACR;EACA,eAAe;CACjB;CAEA,OAAO;AACT;;;;AAKA,eAAsB,cACpB,IACA,gBACA,OACA,eAAkC,CAAC,GACnC,OAAsB,QACtB,QACiC;CACjC,MAAM,iBAAiB,KAAK,IAAI;CAChC,IAAI,OAAO,MAAM;CAEjB,IAAI;EAGF,MAAM,YAAY,4BAA4B,MAAM,cAAc,MAAM;EAGxE,IAAI,OAAO,MAAM,gBAAgB,cAAc;EAE/C,QAAQ,WAAR;GACE,KAAK,iBACH,OAAO,MAAM,mBAAmB,IAAI,gBAAgB,OAAO,MAAM;GACnE,KAAK,qBACH,OAAO,MAAM,6BAA6B,IAAI,gBAAgB,OAAO,MAAM;GAC7E,KAAK,cACH,OAAO,MAAM,oBAAoB,IAAI,gBAAgB,KAAK;GAC5D,SACE,MAAM,IAAI,MAAM,mCAAmC,WAAqB;EAC5E;CACF,UAAU;EACR,IAAI,OAAO,MAAM,eAAe,KAAK,IAAI,IAAI;CAC/C;AACF"}
|
|
@@ -299,7 +299,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
|
|
|
299
299
|
else if (this.algorithm === "backend-delta") this.demoteFromDelta();
|
|
300
300
|
}
|
|
301
301
|
/** Give up on delta for the life of this tree, once, when the backend advertises
|
|
302
|
-
* `treeChangedSince:
|
|
302
|
+
* `treeChangedSince:v2` but hands out no token.
|
|
303
303
|
*
|
|
304
304
|
* Without this the tree stays on delta with `deltaToken` permanently unset, and every poll
|
|
305
305
|
* is then a token-less delta poll: a full tree read that also sends no stop rules, so it
|
|
@@ -309,7 +309,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
|
|
|
309
309
|
* would have picked without the capability, and it restores the stop rules. */
|
|
310
310
|
demoteFromDelta() {
|
|
311
311
|
this.algorithm = require_sync.supportsResourceTreeTraversal(this.pl.serverInfo.capabilities ?? []) ? "backend-streaming" : "client-bfs";
|
|
312
|
-
this.logger?.warn(`tree: backend advertises treeChangedSince:
|
|
312
|
+
this.logger?.warn(`tree: backend advertises treeChangedSince:v2 but issued no change token; falling back to ${this.algorithm} for the life of this tree`);
|
|
313
313
|
}
|
|
314
314
|
/** Discards the change token, so the next delta poll asks for the full tree. Required
|
|
315
315
|
* whenever the mirror stops being a faithful record of what the token says we hold: a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"synchronized_tree.cjs","names":["resolveTreeLoadingAlgorithm","PlTreeState","PollingComputableHooks","captureTreeState","restoreTreeState","PlTreeEntry","PlTreeRootsEntry","constructTreeLoadingRequest","loadTreeState","supportsResourceTreeTraversal","initialTreeLoadingStat","TreeStateUpdateError","tp"],"sources":["../src/synchronized_tree.ts"],"sourcesContent":["import { PollingComputableHooks } from \"@milaboratories/computable\";\nimport { PlTreeEntry, PlTreeRootsEntry } from \"./accessors\";\nimport type {\n FinalResourceDataPredicate,\n PlClient,\n ResourceSignature,\n ResourceType,\n SignedResourceId,\n TxOps,\n} from \"@milaboratories/pl-client\";\nimport type { Filter } from \"@milaboratories/pl-client\";\nimport {\n isUnauthenticated,\n isTimeoutOrCancelError,\n isUnimplementedError,\n} from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData } from \"./state\";\nimport { PlTreeState, TreeStateUpdateError } from \"./state\";\nimport type {\n PruningFunction,\n TraversalMode,\n TreeLoadingAlgorithmName,\n TreeLoadingStat,\n} from \"./sync\";\nimport {\n constructTreeLoadingRequest,\n initialTreeLoadingStat,\n loadTreeState,\n resolveTreeLoadingAlgorithm,\n supportsResourceTreeTraversal,\n} from \"./sync\";\nimport type { PersistedTree } from \"./persisted_tree\";\nimport { captureTreeState, restoreTreeState } from \"./persisted_tree\";\nimport * as tp from \"node:timers/promises\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\n\n/** Hard floor between consecutive tree-refresh calls.\n * Applies even when {@link scheduleOnNextState} has woken the loop early,\n * preventing tight polling loops during rapid state transitions. */\nconst MIN_POLLING_INTERVAL_MS = 100;\n\n/** Ceiling for the adaptive poll interval. Caps how stale an idle tree can get before the\n * next look, and bounds how far the idle backoff can push the interval out. */\nconst MAX_POLLING_INTERVAL_MS = 5_000;\n\n/** Applied to the interval after a cycle that changed nothing. An idle tree walks its\n * interval out towards {@link MAX_POLLING_INTERVAL_MS} instead of re-polling at full rate;\n * the first cycle that changes anything resets it. */\nconst IDLE_BACKOFF_MULTIPLIER = 1.5;\n\n/** The client's measured RTT becomes an interval floor, scaled by this. Every refresh costs\n * at least one round trip, so polling faster than a small multiple of the RTT only queues\n * round-trips the link cannot service. This is what replaces the fixed interval on a\n * high-latency link; on a fast link the configured `pollingInterval` still dominates. */\nconst RTT_POLL_FACTOR = 2;\n\n/** Ceiling for the RTT-derived floor. The estimate is sampled at connect time and never\n * re-sampled, so without a bound one slow ping pins the cadence high for the whole session.\n * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck\n * rather than slow, and spacing polls further only delays noticing it recovered. */\nconst MAX_RTT_POLL_INTERVAL_MS = 30_000;\n\ntype StatLoggingMode = \"cumulative\" | \"per-request\";\n\nexport type SynchronizedTreeOps = {\n /** Override final predicate from the PlClient */\n finalPredicateOverride?: FinalResourceDataPredicate;\n\n /** Pruning function for legacy fallback path. */\n pruning?: PruningFunction;\n\n /** ResourceTree field filter for modern backend path. */\n fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules for the streaming path.\n * @deprecated the backend ignores these under a change token, and the delta algorithm - what\n * `auto` now picks on a capable backend - never sends them. They still prune a\n * `backend-streaming` walk and a token-less delta poll's fallback. */\n traverseStopRules?: Filter;\n\n /** Interval after last sync to sleep before the next one */\n pollingInterval: number;\n /** For how long to continue polling after the last derived value access */\n stopPollingDelay: number;\n\n /** If one of the values, tree will log stats of each polling request */\n logStat?: StatLoggingMode;\n\n /** Timeout for initial tree loading. If not specified, will use default for RO tx from pl-client. */\n initialTreeLoadingTimeout?: number;\n\n /** Controls which tree-loading path to use. Default `\"auto\"`. */\n traversalMode?: TraversalMode;\n\n /** A previously persisted mirror to seed the tree with, before its first refresh, so that\n * refresh transfers only what changed while the tree was gone.\n *\n * A snapshot that cannot be applied, or does not belong to this tree, is logged and dropped,\n * leaving an ordinary cold open.\n *\n * A snapshot that applies but whose ids are dead is NOT handled here: its resources become\n * this tree's seeds, so the first refresh fails and {@link init} rejects, where a cold open\n * would have succeeded. Establishing that the signatures are still live is the caller's job\n * (see {@link PersistedTree.witness}), as is deciding what to do when the first refresh is\n * refused anyway. Ignored for trees with shared-type seeds, which rediscover their roots. */\n restoreFrom?: PersistedTree;\n};\n\n/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */\nexport type ExplicitRootSeed = { kind: \"resource\"; root: SignedResourceId };\n\n/** Discovers, as roots, every resource of this type shared with the current user.\n * Matched against SharedResource.resourceType by NAME (version optional/ignored). The\n * discovered set is DYNAMIC — roots appear/disappear as grants are added/revoked/expire. */\nexport type SharedTypeSeed = { kind: \"shared\"; resourceType: ResourceType };\n\nexport type TreeSeed = ExplicitRootSeed | SharedTypeSeed;\n\n/** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare\n * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so\n * every existing single-root caller is unchanged. */\nfunction normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSeed[] {\n if (Array.isArray(seeds)) return seeds;\n if (typeof seeds === \"object\" && seeds !== null && \"kind\" in seeds) return [seeds];\n // bare SignedResourceId\n return [{ kind: \"resource\", root: seeds }];\n}\n\n/** How often a tree with shared-type seeds re-polls ListUserResources to reconcile its\n * discovered roots.\n *\n * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental\n * refresh, so it must NOT run on every refresh tick. This is a wall-clock interval rather\n * than a count of iterations: the refresh cadence is adaptive (it stretches towards\n * {@link MAX_POLLING_INTERVAL_MS} on an idle tree and scales with RTT on a slow link),\n * so a fixed iteration count would let discovery latency drift out with it. Keeping it in\n * milliseconds pins the latency of noticing a new/removed share regardless of cadence, which\n * is what a human-driven share flow cares about.\n *\n * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root\n * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */\nconst DISCOVERY_INTERVAL_MS = 3_000;\n\n/** Counters that mean \"this cycle brought something new\". Deliberately not the full change\n * breakdown: those fields are sub-counts of `resourcesChanged` and would double-count.\n * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged\n * state is exactly the idle case the backoff exists for. */\nfunction countedChanges(stat: TreeLoadingStat): number {\n // `fieldsRemoved` is included despite being a per-field count, because it is the one change\n // that never shows up in `resourcesChanged`: the removed-dynamic-field branch in\n // `updateFromResourceData` does not set its `changed` flag, so a cycle that only dropped a\n // field (and garbage-collected whatever it pointed at) otherwise reads as an idle cycle.\n // That double-counts a resource that both changed and lost a field, which is harmless here:\n // every caller compares this against an earlier value rather than reading it as a total.\n return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.fieldsRemoved;\n}\n\n/** The poll-cadence policy, as a pure function of the last cycle's outcome.\n *\n * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no\n * link can be polled faster than configured. `rttMs` raises that bound on a slow link.\n * `currentMs` is the interval in force for the cycle that just finished, which is what the\n * idle backoff compounds on. */\nexport function derivePollingInterval(opts: {\n configuredMs: number;\n currentMs: number;\n rttMs: number | undefined;\n changed: boolean;\n}): number {\n const { configuredMs, currentMs, rttMs, changed } = opts;\n\n // The cap applies to the RTT-derived part only, so `configuredMs` stays an absolute lower\n // bound even if it is ever set above the cap.\n const floor =\n rttMs === undefined\n ? configuredMs\n : Math.max(\n configuredMs,\n Math.min(MAX_RTT_POLL_INTERVAL_MS, Math.ceil(rttMs * RTT_POLL_FACTOR)),\n );\n\n const next = changed ? floor : Math.max(floor, currentMs * IDLE_BACKOFF_MULTIPLIER);\n\n // The ceiling never cuts below the floor: a link slower than MAX_POLLING_INTERVAL_MS still\n // gets its RTT-derived spacing rather than being forced to re-poll early.\n return Math.max(floor, Math.min(MAX_POLLING_INTERVAL_MS, next));\n}\n\ntype ScheduledRefresh = {\n resolve: () => void;\n reject: (err: any) => void;\n};\n\nexport class SynchronizedTreeState {\n private readonly finalPredicate: FinalResourceDataPredicate;\n private state: PlTreeState;\n private readonly pollingInterval: number;\n private readonly pruning?: PruningFunction;\n private readonly fieldFilter?: Filter;\n private readonly traverseStopRules?: Filter;\n private readonly traversalMode: TraversalMode;\n /** Resolved once from {@link traversalMode} and the server's capabilities, and used by every\n * poll of this tree. Selecting per poll was only sound while no algorithm kept state between\n * polls; pinning it here is what lets one do so. Only ever reassigned by the one-way\n * demotion in {@link loadAndApply} when the backend advertises delta but issues no token. */\n private algorithm: TreeLoadingAlgorithmName;\n /** Change token the last successful delta apply was dated at, handed to the next poll so\n * the backend sends only what moved since. Undefined until the first delta poll commits\n * one, and again whenever {@link discardDeltaToken} drops it. */\n private deltaToken: Uint8Array | undefined;\n private readonly logStat?: StatLoggingMode;\n private readonly hooks: PollingComputableHooks;\n private readonly abortController = new AbortController();\n\n /** Explicit-resource seeds: fixed roots, present from construction. */\n private readonly explicitRoots: SignedResourceId[];\n /** Shared-type seeds (discovered roots), if any. */\n private readonly sharedSeeds: SharedTypeSeed[];\n /** Roots discovered for shared-type seeds on the last discovery poll. */\n private discoveredRoots: SignedResourceId[] = [];\n\n /** Bumped once per refresh cycle that brought something new: a resource appeared, changed,\n * or became final. Lets a holder tell whether the tree has moved since it last persisted\n * it, without diffing state. Read through {@link changeGeneration}. */\n private changeGenerationCounter = 0;\n\n /** Whether a snapshot was actually applied. Read through\n * {@link wasRestoredFromSnapshot}. */\n private restoredFromSnapshot = false;\n\n private constructor(\n private readonly pl: PlClient,\n seeds: TreeSeed[],\n ops: SynchronizedTreeOps,\n private readonly logger?: MiLogger,\n ) {\n const {\n finalPredicateOverride,\n pruning,\n fieldFilter,\n traverseStopRules,\n traversalMode,\n pollingInterval,\n stopPollingDelay,\n logStat,\n } = ops;\n this.pruning = pruning;\n this.fieldFilter = fieldFilter;\n this.traverseStopRules = traverseStopRules;\n this.traversalMode = traversalMode ?? \"auto\";\n this.algorithm = resolveTreeLoadingAlgorithm(\n this.traversalMode,\n pl.serverInfo.capabilities ?? [],\n logger,\n );\n logger?.info(`tree loading algorithm: ${this.algorithm} (traversalMode=${this.traversalMode})`);\n this.pollingInterval = pollingInterval;\n this.effectivePollingInterval = pollingInterval;\n this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;\n this.logStat = logStat;\n\n this.explicitRoots = seeds\n .filter((s): s is ExplicitRootSeed => s.kind === \"resource\")\n .map((s) => s.root);\n this.sharedSeeds = seeds.filter((s): s is SharedTypeSeed => s.kind === \"shared\");\n\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n this.hooks = new PollingComputableHooks(\n () => this.startUpdating(),\n () => this.stopUpdating(),\n { stopDebounce: stopPollingDelay },\n (resolve, reject) => this.scheduleOnNextState(resolve, reject),\n );\n }\n\n /** The current protected root set: explicit roots plus the latest discovered roots. */\n private currentRootSet(): Set<SignedResourceId> {\n return new Set([...this.explicitRoots, ...this.discoveredRoots]);\n }\n\n /** How many refresh cycles brought something new. Only ever increases. Equal values at two\n * points in time mean nothing was added, changed or settled in between, which is what makes\n * a periodic snapshot write skippable on an idle tree. */\n public get changeGeneration(): number {\n return this.changeGenerationCounter;\n }\n\n /** True only if a snapshot was actually applied as this tree's initial state. A snapshot can\n * be supplied and still be refused (wrong roots, or state the update call will not accept),\n * in which case this stays false and the tree started empty like any other. Passing\n * `restoreFrom` is therefore not evidence of a warm start; this is. */\n public get wasRestoredFromSnapshot(): boolean {\n return this.restoredFromSnapshot;\n }\n\n /** Captures the current mirror for persistence.\n *\n * Must be called before {@link terminate}: terminating invalidates the tree, and capturing\n * an invalidated tree is refused rather than silently written. */\n public capture(witness: ResourceSignature): PersistedTree {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return captureTreeState(this.state, witness);\n }\n\n /** Installs a snapshot as this tree's state. Returns false if the snapshot was refused, in\n * which case the tree is left as it was and the open proceeds cold.\n *\n * Only meaningful before the first refresh, which is why it is private and driven from\n * {@link init}: replacing the state of a running tree would strand its observers. */\n private restore(snapshot: PersistedTree): boolean {\n if (this.sharedSeeds.length > 0) {\n this.logger?.warn(\"ignoring tree snapshot: trees with shared-type seeds are not restored\");\n return false;\n }\n\n const roots = this.currentRootSet();\n const snapshotRoots = new Set(snapshot.roots);\n if (snapshotRoots.size !== roots.size || ![...snapshotRoots].every((r) => roots.has(r))) {\n // A snapshot addressed to a different root is a mis-keyed file, not a stale one.\n this.logger?.warn(\"ignoring tree snapshot: its roots are not this tree's roots\");\n return false;\n }\n\n const restored = restoreTreeState(snapshot, this.finalPredicate, {\n roots,\n logger: this.logger,\n });\n if (restored === undefined) return false;\n\n this.state = restored;\n this.restoredFromSnapshot = true;\n return true;\n }\n\n /** Resolves the single root for the backward-compatible single-root accessors, throwing\n * if the tree does not have exactly one root (guards legacy callers against multi-root). */\n private soleRoot(): SignedResourceId {\n const roots = this.currentRootSet();\n if (roots.size !== 1)\n throw new Error(\n `single-root accessor used on a tree with ${roots.size} roots; use rootsEntry() instead`,\n );\n return roots.values().next().value!;\n }\n\n /** @deprecated use \"entry\" instead */\n public accessor(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return this.entry(rid);\n }\n\n /** Backward-compatible single-root entry. With no `rid` it returns the sole root's entry\n * and THROWS if the tree has zero or more than one root. An explicit `rid` addresses any\n * resource in the heap, as today. */\n public entry(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeEntry(\n { treeProvider: () => this.state, hooks: this.hooks },\n rid ?? this.soleRoot(),\n );\n }\n\n /** Reactive provider for the current root SET. Reading it inside a Computable tracks the\n * set as a dependency, so the Computable recomputes when discovered roots appear/disappear. */\n public rootsEntry(): PlTreeRootsEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeRootsEntry({ treeProvider: () => this.state, hooks: this.hooks });\n }\n\n /** Can be used to externally kick off the synchronization polling loop, and\n * await for the first synchronization to happen. */\n public async refreshState(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n await this.hooks.refreshState();\n }\n\n private currentLoopDelayInterrupt: AbortController | undefined = undefined;\n private scheduledOnNextState: ScheduledRefresh[] = [];\n\n /** Interval actually used for the current wait. Starts at the configured `pollingInterval`\n * and is re-derived after every cycle by {@link updatePollingInterval}. */\n private effectivePollingInterval: number;\n\n /** Re-derives {@link effectivePollingInterval} after a cycle.\n *\n * Two independent effects. The floor scales with the client's measured RTT, so a\n * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle\n * that changed nothing multiplies the interval out towards\n * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so\n * an active tree stays responsive. */\n private updatePollingInterval(changed: boolean): void {\n this.effectivePollingInterval = derivePollingInterval({\n configuredMs: this.pollingInterval,\n currentMs: this.effectivePollingInterval,\n rttMs: this.pl.rttEstimateMs,\n changed,\n });\n }\n\n /** Called from computable hooks when external observer asks for state refresh */\n private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {\n if (this.terminated) reject(new Error(\"tree synchronization is terminated\"));\n else {\n this.scheduledOnNextState.push({ resolve, reject });\n // Someone is waiting on fresh state, so this tree is not idle after all: drop any\n // accumulated backoff, otherwise the cycles right after a nudge stay slow. Routed\n // through the policy rather than assigning the configured value directly, so the RTT\n // floor survives the reset. Assigning it raw would poll a high-latency link faster than\n // it can answer, and the interval would stay there until the next cycle that completes:\n // the error path never reaches updatePollingInterval, so a failing nudged refresh would\n // keep retrying at the un-floored rate.\n this.updatePollingInterval(true);\n if (this.currentLoopDelayInterrupt) {\n this.currentLoopDelayInterrupt.abort();\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n\n /** Called from observer */\n private startUpdating(): void {\n if (this.terminated) return;\n this.keepRunning = true;\n if (this.currentLoop === undefined) this.currentLoop = this.mainLoop();\n }\n\n /** Called from observer */\n private stopUpdating(): void {\n this.keepRunning = false;\n }\n\n /** If true, main loop will continue polling pl state. */\n private keepRunning = false;\n /** Actual state of main loop. */\n private currentLoop: Promise<void> | undefined = undefined;\n\n /** Executed from the main loop, and initialization procedure. */\n private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n try {\n await this.loadAndApply(stats, txOps);\n } catch (e) {\n // Discovery-tree self-heal: a discovered root whose grant was revoked/expired fails the whole\n // ResourceTree poll with Unauthenticated. Re-discover (drops dead roots) and retry once. This is\n // self-discriminating — a genuinely dead session also fails discover()'s own call, so real auth\n // loss still propagates. Only for discovery trees; explicit-root trees propagate as-is.\n if (this.sharedSeeds.length > 0 && isUnauthenticated(e)) {\n this.logger?.warn(\n \"discovery tree: Unauthenticated on ResourceTree (likely revoked/expired root); re-discovering and retrying\",\n );\n await this.discover();\n await this.loadAndApply(stats, txOps);\n } else throw e;\n }\n }\n\n private async loadAndApply(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n const request = constructTreeLoadingRequest(this.state, {\n pruningFunction: this.pruning,\n fieldFilter: this.fieldFilter,\n traverseStopRules: this.traverseStopRules,\n changedSinceToken: this.deltaToken,\n });\n // A shared-type-seed tree with no currently-discovered roots is legitimately empty:\n // there is nothing to traverse, and tx.resourceTree([]) would throw \"at least one seed\n // must be provided\". Skip the backend load and leave the (empty) state as-is — discovery\n // adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees\n // never hit this (their root set is non-empty by construction).\n if (request.seedResources.length === 0 && request.finalResources.size === 0) return;\n const { data, nextToken } = await this.pl.withReadTx(\n \"ReadingTree\",\n async (tx) => {\n // Started, not awaited, before the walk. The token dates the transaction rather than\n // the response, so it is not an input to the request - the request carries the\n // PREVIOUS poll's token. Awaiting it here would block on the tx-open response before\n // sending the tree request, costing a whole round trip that streaming does not pay,\n // because requests pipeline on one bidi stream and withReadTx does not await the open.\n const tokenPromise =\n this.algorithm === \"backend-delta\" ? tx.getNextSinceToken() : undefined;\n const data = await loadTreeState(\n tx,\n request,\n stats,\n this.pl.serverInfo.capabilities ?? [],\n this.algorithm,\n this.logger,\n );\n return { data, nextToken: await tokenPromise };\n },\n txOps,\n );\n this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });\n\n // Only with the whole batch applied: advancing past a partial apply loses the dropped\n // resources for good. A throw above leaves the old token, so the next poll re-reads it.\n if (nextToken !== undefined) this.deltaToken = nextToken;\n else if (this.algorithm === \"backend-delta\") this.demoteFromDelta();\n }\n\n /** Give up on delta for the life of this tree, once, when the backend advertises\n * `treeChangedSince:v1` but hands out no token.\n *\n * Without this the tree stays on delta with `deltaToken` permanently unset, and every poll\n * is then a token-less delta poll: a full tree read that also sends no stop rules, so it\n * transfers the subtrees the streaming path prunes away. Nothing else detects it -\n * `deltaSuspectedFullAnswers` only fires when a token WAS sent - so it would run at the\n * poll interval, forever, silently. Streaming is the correct destination: it is what `auto`\n * would have picked without the capability, and it restores the stop rules. */\n private demoteFromDelta() {\n this.algorithm = supportsResourceTreeTraversal(this.pl.serverInfo.capabilities ?? [])\n ? \"backend-streaming\"\n : \"client-bfs\";\n this.logger?.warn(\n `tree: backend advertises treeChangedSince:v1 but issued no change token; ` +\n `falling back to ${this.algorithm} for the life of this tree`,\n );\n }\n\n /** Discards the change token, so the next delta poll asks for the full tree. Required\n * whenever the mirror stops being a faithful record of what the token says we hold: a\n * rebuild after {@link TreeStateUpdateError}, or a root-set change, which reshapes the\n * traversal the token was earned under. */\n private discardDeltaToken(reason: string) {\n if (this.deltaToken === undefined) return;\n this.deltaToken = undefined;\n this.logger?.info(`tree delta token discarded (${reason}); next poll reads the full tree`);\n }\n\n /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and\n * reconciles the discovered root set against the heap. A longer poll result adds roots; a\n * shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade\n * to collection via the ordinary refcount GC ({@link PlTreeState.setRoots}). No-op when the\n * tree has no shared-type seeds, or silently no-op on a REST client where `ListUserResources`\n * is unavailable. */\n private async discover(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n if (this.sharedSeeds.length === 0) return;\n\n const discovered = new Set<SignedResourceId>();\n for (const seed of this.sharedSeeds) {\n let ids: SignedResourceId[];\n try {\n // match by name only (permissive; ignores version, so it survives schema bumps)\n ids = await this.pl.userResources.listSharedResourcesByType(seed.resourceType.name);\n } catch (e: unknown) {\n if (isUnimplementedError(e)) continue;\n throw e;\n }\n for (const id of ids) discovered.add(id);\n }\n\n const rootsChanged =\n discovered.size !== this.discoveredRoots.length ||\n this.discoveredRoots.some((id) => !discovered.has(id));\n\n this.discoveredRoots = [...discovered];\n this.state.setRoots(this.currentRootSet());\n\n // A root arriving brings a subtree the token would skip as unchanged, and one leaving\n // takes its subtree with it. Either way the token no longer describes what we hold.\n if (rootsChanged) this.discardDeltaToken(\"root set changed\");\n }\n\n /** If true this tree state is permanently terminaed. */\n private terminated = false;\n\n private async mainLoop() {\n // Always collected, even when not logging: the change counters drive the idle backoff\n // below. Counter bumps are cheap next to the round trip they describe.\n let stat = initialTreeLoadingStat();\n\n let lastUpdate = Date.now();\n\n // paces the discovery poll for shared-type seeds; 0 forces discovery on the first pass.\n let lastDiscovery = 0;\n\n while (true) {\n if (!this.keepRunning || this.terminated) break;\n\n // saving those who want to be notified about new state here\n // because those who will be added during the tree retrieval\n // should be notified only on the next round\n let toNotify: ScheduledRefresh[] | undefined = undefined;\n if (this.scheduledOnNextState.length > 0) {\n toNotify = this.scheduledOnNextState;\n this.scheduledOnNextState = [];\n }\n\n try {\n // resetting stats if we were asked to collect non-cumulative stats\n if (this.logStat === \"per-request\") stat = initialTreeLoadingStat();\n\n // discovery sync for shared-type seeds: reconcile the discovered root set before\n // refreshing, so newly discovered roots are materialized in this same iteration.\n if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {\n await this.discover();\n lastDiscovery = Date.now();\n }\n\n // Change counters before the refresh, so the delta tells us whether this single cycle\n // brought anything new. Works in both stat modes: \"per-request\" resets to 0 above.\n const changesBefore = countedChanges(stat);\n\n // actual tree synchronization\n await this.refresh(stat);\n\n const changed = countedChanges(stat) > changesBefore;\n if (changed) this.changeGenerationCounter++;\n this.updatePollingInterval(changed);\n\n // logging stats if we were asked to\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we got new state\n if (toNotify !== undefined) for (const n of toNotify) n.resolve();\n } catch (e: any) {\n // logging stats if we were asked to (even if error occured)\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we failed to refresh the state\n if (toNotify !== undefined) for (const n of toNotify) n.reject(e);\n\n // catching tree update errors, as they may leave our tree in inconsistent state\n if (e instanceof TreeStateUpdateError) {\n // important error logging, this should never happen\n this.logger?.error(e);\n\n // marking everybody who used previous state as changed\n this.state.invalidateTree(\"stat update error\");\n // creating new tree with the full current root set (re-discovered on next iteration)\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n // The new mirror holds nothing, so the old token would skip everything.\n this.discardDeltaToken(\"tree rebuilt after update error\");\n\n // scheduling state update without delay\n continue;\n\n // unfortunately external observer may still see tree in its default\n // empty state, though this is best we can do in this exceptional\n // situation, and hope on caching layers inside computables to present\n // some stale state until we reconstruct the tree again\n } else this.logger?.warn(e);\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 1: mandatory floor — always wait at least MIN_POLLING_INTERVAL_MS.\n // Not interruptible by scheduleOnNextState; only termination aborts it.\n try {\n await tp.setTimeout(MIN_POLLING_INTERVAL_MS, undefined, {\n signal: this.abortController.signal,\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 2: optional remainder up to pollingInterval — interruptible by\n // scheduleOnNextState so that an external nudge wakes the loop promptly.\n if (this.scheduledOnNextState.length === 0) {\n const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);\n if (remaining > 0) {\n try {\n this.currentLoopDelayInterrupt = new AbortController();\n await tp.setTimeout(remaining, undefined, {\n signal: AbortSignal.any([\n this.abortController.signal,\n this.currentLoopDelayInterrupt.signal,\n ]),\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n // Otherwise it was just the loop delay interrupt (scheduleOnNextState),\n // continue to the next iteration\n } finally {\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n }\n\n // reset only as a very last line\n this.currentLoop = undefined;\n }\n\n /**\n * Dumps the current state of the tree.\n * @returns An array of ExtendedResourceData objects representing the current state of the tree.\n */\n public dumpState(): ExtendedResourceData[] {\n return this.state.dumpState();\n }\n\n /**\n * Terminates the internal loop, and permanently destoys all internal state, so\n * all computables using this state will resolve to errors.\n * */\n public async terminate(): Promise<void> {\n this.keepRunning = false;\n this.terminated = true;\n this.abortController.abort();\n\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n\n this.state.invalidateTree(\"synchronization terminated for the tree\");\n }\n\n /** @deprecated */\n public async awaitSyncLoopTermination(): Promise<void> {\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n }\n\n /**\n * Initializes a synchronized tree from one or more seeds.\n *\n * @param seeds a bare {@link SignedResourceId} (the original single-root contract), a\n * single {@link TreeSeed}, or an array of seeds. Bare ids and explicit-resource seeds\n * become roots immediately; shared-type seeds discover their roots via `ListUserResources`.\n */\n public static async init(\n pl: PlClient,\n seeds: SignedResourceId | TreeSeed | TreeSeed[],\n ops: SynchronizedTreeOps,\n logger?: MiLogger,\n ) {\n const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);\n\n // Seed from the snapshot before the first refresh, so that refresh is the one that\n // transfers only what changed. A refused snapshot leaves an ordinary cold open.\n const restored = ops.restoreFrom !== undefined && tree.restore(ops.restoreFrom);\n\n // Always collected, even when not logging: the initial load's change count is what seeds\n // the change generation, so a holder can tell a populated tree from an untouched one.\n const stat = initialTreeLoadingStat();\n\n let ok = false;\n\n try {\n // resolve shared-type seeds before the first refresh so discovered roots load now\n await tree.discover();\n await tree.refresh(stat, {\n timeout: ops.initialTreeLoadingTimeout,\n });\n ok = true;\n } finally {\n if (countedChanges(stat) > 0) tree.changeGenerationCounter++;\n\n // logging stats if we were asked to (even if error occured)\n if (ops.logStat && logger)\n logger.info(\n `Tree stat (initial load, ${ok ? \"success\" : \"failure\"}, ${\n restored ? \"restored from snapshot\" : \"cold\"\n }): ${JSON.stringify(stat)}`,\n );\n }\n\n return tree;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"synchronized_tree.cjs","names":["resolveTreeLoadingAlgorithm","PlTreeState","PollingComputableHooks","captureTreeState","restoreTreeState","PlTreeEntry","PlTreeRootsEntry","constructTreeLoadingRequest","loadTreeState","supportsResourceTreeTraversal","initialTreeLoadingStat","TreeStateUpdateError","tp"],"sources":["../src/synchronized_tree.ts"],"sourcesContent":["import { PollingComputableHooks } from \"@milaboratories/computable\";\nimport { PlTreeEntry, PlTreeRootsEntry } from \"./accessors\";\nimport type {\n FinalResourceDataPredicate,\n PlClient,\n ResourceSignature,\n ResourceType,\n SignedResourceId,\n TxOps,\n} from \"@milaboratories/pl-client\";\nimport type { Filter } from \"@milaboratories/pl-client\";\nimport {\n isUnauthenticated,\n isTimeoutOrCancelError,\n isUnimplementedError,\n} from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData } from \"./state\";\nimport { PlTreeState, TreeStateUpdateError } from \"./state\";\nimport type {\n PruningFunction,\n TraversalMode,\n TreeLoadingAlgorithmName,\n TreeLoadingStat,\n} from \"./sync\";\nimport {\n constructTreeLoadingRequest,\n initialTreeLoadingStat,\n loadTreeState,\n resolveTreeLoadingAlgorithm,\n supportsResourceTreeTraversal,\n} from \"./sync\";\nimport type { PersistedTree } from \"./persisted_tree\";\nimport { captureTreeState, restoreTreeState } from \"./persisted_tree\";\nimport * as tp from \"node:timers/promises\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\n\n/** Hard floor between consecutive tree-refresh calls.\n * Applies even when {@link scheduleOnNextState} has woken the loop early,\n * preventing tight polling loops during rapid state transitions. */\nconst MIN_POLLING_INTERVAL_MS = 100;\n\n/** Ceiling for the adaptive poll interval. Caps how stale an idle tree can get before the\n * next look, and bounds how far the idle backoff can push the interval out. */\nconst MAX_POLLING_INTERVAL_MS = 5_000;\n\n/** Applied to the interval after a cycle that changed nothing. An idle tree walks its\n * interval out towards {@link MAX_POLLING_INTERVAL_MS} instead of re-polling at full rate;\n * the first cycle that changes anything resets it. */\nconst IDLE_BACKOFF_MULTIPLIER = 1.5;\n\n/** The client's measured RTT becomes an interval floor, scaled by this. Every refresh costs\n * at least one round trip, so polling faster than a small multiple of the RTT only queues\n * round-trips the link cannot service. This is what replaces the fixed interval on a\n * high-latency link; on a fast link the configured `pollingInterval` still dominates. */\nconst RTT_POLL_FACTOR = 2;\n\n/** Ceiling for the RTT-derived floor. The estimate is sampled at connect time and never\n * re-sampled, so without a bound one slow ping pins the cadence high for the whole session.\n * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck\n * rather than slow, and spacing polls further only delays noticing it recovered. */\nconst MAX_RTT_POLL_INTERVAL_MS = 30_000;\n\ntype StatLoggingMode = \"cumulative\" | \"per-request\";\n\nexport type SynchronizedTreeOps = {\n /** Override final predicate from the PlClient */\n finalPredicateOverride?: FinalResourceDataPredicate;\n\n /** Pruning function for legacy fallback path. */\n pruning?: PruningFunction;\n\n /** ResourceTree field filter for modern backend path. */\n fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules for the streaming path.\n * @deprecated the backend ignores these under a change token, and the delta algorithm - what\n * `auto` now picks on a capable backend - never sends them. They still prune a\n * `backend-streaming` walk and a token-less delta poll's fallback. */\n traverseStopRules?: Filter;\n\n /** Interval after last sync to sleep before the next one */\n pollingInterval: number;\n /** For how long to continue polling after the last derived value access */\n stopPollingDelay: number;\n\n /** If one of the values, tree will log stats of each polling request */\n logStat?: StatLoggingMode;\n\n /** Timeout for initial tree loading. If not specified, will use default for RO tx from pl-client. */\n initialTreeLoadingTimeout?: number;\n\n /** Controls which tree-loading path to use. Default `\"auto\"`. */\n traversalMode?: TraversalMode;\n\n /** A previously persisted mirror to seed the tree with, before its first refresh, so that\n * refresh transfers only what changed while the tree was gone.\n *\n * A snapshot that cannot be applied, or does not belong to this tree, is logged and dropped,\n * leaving an ordinary cold open.\n *\n * A snapshot that applies but whose ids are dead is NOT handled here: its resources become\n * this tree's seeds, so the first refresh fails and {@link init} rejects, where a cold open\n * would have succeeded. Establishing that the signatures are still live is the caller's job\n * (see {@link PersistedTree.witness}), as is deciding what to do when the first refresh is\n * refused anyway. Ignored for trees with shared-type seeds, which rediscover their roots. */\n restoreFrom?: PersistedTree;\n};\n\n/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */\nexport type ExplicitRootSeed = { kind: \"resource\"; root: SignedResourceId };\n\n/** Discovers, as roots, every resource of this type shared with the current user.\n * Matched against SharedResource.resourceType by NAME (version optional/ignored). The\n * discovered set is DYNAMIC — roots appear/disappear as grants are added/revoked/expire. */\nexport type SharedTypeSeed = { kind: \"shared\"; resourceType: ResourceType };\n\nexport type TreeSeed = ExplicitRootSeed | SharedTypeSeed;\n\n/** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare\n * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so\n * every existing single-root caller is unchanged. */\nfunction normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSeed[] {\n if (Array.isArray(seeds)) return seeds;\n if (typeof seeds === \"object\" && seeds !== null && \"kind\" in seeds) return [seeds];\n // bare SignedResourceId\n return [{ kind: \"resource\", root: seeds }];\n}\n\n/** How often a tree with shared-type seeds re-polls ListUserResources to reconcile its\n * discovered roots.\n *\n * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental\n * refresh, so it must NOT run on every refresh tick. This is a wall-clock interval rather\n * than a count of iterations: the refresh cadence is adaptive (it stretches towards\n * {@link MAX_POLLING_INTERVAL_MS} on an idle tree and scales with RTT on a slow link),\n * so a fixed iteration count would let discovery latency drift out with it. Keeping it in\n * milliseconds pins the latency of noticing a new/removed share regardless of cadence, which\n * is what a human-driven share flow cares about.\n *\n * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root\n * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */\nconst DISCOVERY_INTERVAL_MS = 3_000;\n\n/** Counters that mean \"this cycle brought something new\". Deliberately not the full change\n * breakdown: those fields are sub-counts of `resourcesChanged` and would double-count.\n * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged\n * state is exactly the idle case the backoff exists for. */\nfunction countedChanges(stat: TreeLoadingStat): number {\n // `fieldsRemoved` is included despite being a per-field count, because it is the one change\n // that never shows up in `resourcesChanged`: the removed-dynamic-field branch in\n // `updateFromResourceData` does not set its `changed` flag, so a cycle that only dropped a\n // field (and garbage-collected whatever it pointed at) otherwise reads as an idle cycle.\n // That double-counts a resource that both changed and lost a field, which is harmless here:\n // every caller compares this against an earlier value rather than reading it as a total.\n return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.fieldsRemoved;\n}\n\n/** The poll-cadence policy, as a pure function of the last cycle's outcome.\n *\n * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no\n * link can be polled faster than configured. `rttMs` raises that bound on a slow link.\n * `currentMs` is the interval in force for the cycle that just finished, which is what the\n * idle backoff compounds on. */\nexport function derivePollingInterval(opts: {\n configuredMs: number;\n currentMs: number;\n rttMs: number | undefined;\n changed: boolean;\n}): number {\n const { configuredMs, currentMs, rttMs, changed } = opts;\n\n // The cap applies to the RTT-derived part only, so `configuredMs` stays an absolute lower\n // bound even if it is ever set above the cap.\n const floor =\n rttMs === undefined\n ? configuredMs\n : Math.max(\n configuredMs,\n Math.min(MAX_RTT_POLL_INTERVAL_MS, Math.ceil(rttMs * RTT_POLL_FACTOR)),\n );\n\n const next = changed ? floor : Math.max(floor, currentMs * IDLE_BACKOFF_MULTIPLIER);\n\n // The ceiling never cuts below the floor: a link slower than MAX_POLLING_INTERVAL_MS still\n // gets its RTT-derived spacing rather than being forced to re-poll early.\n return Math.max(floor, Math.min(MAX_POLLING_INTERVAL_MS, next));\n}\n\ntype ScheduledRefresh = {\n resolve: () => void;\n reject: (err: any) => void;\n};\n\nexport class SynchronizedTreeState {\n private readonly finalPredicate: FinalResourceDataPredicate;\n private state: PlTreeState;\n private readonly pollingInterval: number;\n private readonly pruning?: PruningFunction;\n private readonly fieldFilter?: Filter;\n private readonly traverseStopRules?: Filter;\n private readonly traversalMode: TraversalMode;\n /** Resolved once from {@link traversalMode} and the server's capabilities, and used by every\n * poll of this tree. Selecting per poll was only sound while no algorithm kept state between\n * polls; pinning it here is what lets one do so. Only ever reassigned by the one-way\n * demotion in {@link loadAndApply} when the backend advertises delta but issues no token. */\n private algorithm: TreeLoadingAlgorithmName;\n /** Change token the last successful delta apply was dated at, handed to the next poll so\n * the backend sends only what moved since. Undefined until the first delta poll commits\n * one, and again whenever {@link discardDeltaToken} drops it. */\n private deltaToken: Uint8Array | undefined;\n private readonly logStat?: StatLoggingMode;\n private readonly hooks: PollingComputableHooks;\n private readonly abortController = new AbortController();\n\n /** Explicit-resource seeds: fixed roots, present from construction. */\n private readonly explicitRoots: SignedResourceId[];\n /** Shared-type seeds (discovered roots), if any. */\n private readonly sharedSeeds: SharedTypeSeed[];\n /** Roots discovered for shared-type seeds on the last discovery poll. */\n private discoveredRoots: SignedResourceId[] = [];\n\n /** Bumped once per refresh cycle that brought something new: a resource appeared, changed,\n * or became final. Lets a holder tell whether the tree has moved since it last persisted\n * it, without diffing state. Read through {@link changeGeneration}. */\n private changeGenerationCounter = 0;\n\n /** Whether a snapshot was actually applied. Read through\n * {@link wasRestoredFromSnapshot}. */\n private restoredFromSnapshot = false;\n\n private constructor(\n private readonly pl: PlClient,\n seeds: TreeSeed[],\n ops: SynchronizedTreeOps,\n private readonly logger?: MiLogger,\n ) {\n const {\n finalPredicateOverride,\n pruning,\n fieldFilter,\n traverseStopRules,\n traversalMode,\n pollingInterval,\n stopPollingDelay,\n logStat,\n } = ops;\n this.pruning = pruning;\n this.fieldFilter = fieldFilter;\n this.traverseStopRules = traverseStopRules;\n this.traversalMode = traversalMode ?? \"auto\";\n this.algorithm = resolveTreeLoadingAlgorithm(\n this.traversalMode,\n pl.serverInfo.capabilities ?? [],\n logger,\n );\n logger?.info(`tree loading algorithm: ${this.algorithm} (traversalMode=${this.traversalMode})`);\n this.pollingInterval = pollingInterval;\n this.effectivePollingInterval = pollingInterval;\n this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;\n this.logStat = logStat;\n\n this.explicitRoots = seeds\n .filter((s): s is ExplicitRootSeed => s.kind === \"resource\")\n .map((s) => s.root);\n this.sharedSeeds = seeds.filter((s): s is SharedTypeSeed => s.kind === \"shared\");\n\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n this.hooks = new PollingComputableHooks(\n () => this.startUpdating(),\n () => this.stopUpdating(),\n { stopDebounce: stopPollingDelay },\n (resolve, reject) => this.scheduleOnNextState(resolve, reject),\n );\n }\n\n /** The current protected root set: explicit roots plus the latest discovered roots. */\n private currentRootSet(): Set<SignedResourceId> {\n return new Set([...this.explicitRoots, ...this.discoveredRoots]);\n }\n\n /** How many refresh cycles brought something new. Only ever increases. Equal values at two\n * points in time mean nothing was added, changed or settled in between, which is what makes\n * a periodic snapshot write skippable on an idle tree. */\n public get changeGeneration(): number {\n return this.changeGenerationCounter;\n }\n\n /** True only if a snapshot was actually applied as this tree's initial state. A snapshot can\n * be supplied and still be refused (wrong roots, or state the update call will not accept),\n * in which case this stays false and the tree started empty like any other. Passing\n * `restoreFrom` is therefore not evidence of a warm start; this is. */\n public get wasRestoredFromSnapshot(): boolean {\n return this.restoredFromSnapshot;\n }\n\n /** Captures the current mirror for persistence.\n *\n * Must be called before {@link terminate}: terminating invalidates the tree, and capturing\n * an invalidated tree is refused rather than silently written. */\n public capture(witness: ResourceSignature): PersistedTree {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return captureTreeState(this.state, witness);\n }\n\n /** Installs a snapshot as this tree's state. Returns false if the snapshot was refused, in\n * which case the tree is left as it was and the open proceeds cold.\n *\n * Only meaningful before the first refresh, which is why it is private and driven from\n * {@link init}: replacing the state of a running tree would strand its observers. */\n private restore(snapshot: PersistedTree): boolean {\n if (this.sharedSeeds.length > 0) {\n this.logger?.warn(\"ignoring tree snapshot: trees with shared-type seeds are not restored\");\n return false;\n }\n\n const roots = this.currentRootSet();\n const snapshotRoots = new Set(snapshot.roots);\n if (snapshotRoots.size !== roots.size || ![...snapshotRoots].every((r) => roots.has(r))) {\n // A snapshot addressed to a different root is a mis-keyed file, not a stale one.\n this.logger?.warn(\"ignoring tree snapshot: its roots are not this tree's roots\");\n return false;\n }\n\n const restored = restoreTreeState(snapshot, this.finalPredicate, {\n roots,\n logger: this.logger,\n });\n if (restored === undefined) return false;\n\n this.state = restored;\n this.restoredFromSnapshot = true;\n return true;\n }\n\n /** Resolves the single root for the backward-compatible single-root accessors, throwing\n * if the tree does not have exactly one root (guards legacy callers against multi-root). */\n private soleRoot(): SignedResourceId {\n const roots = this.currentRootSet();\n if (roots.size !== 1)\n throw new Error(\n `single-root accessor used on a tree with ${roots.size} roots; use rootsEntry() instead`,\n );\n return roots.values().next().value!;\n }\n\n /** @deprecated use \"entry\" instead */\n public accessor(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return this.entry(rid);\n }\n\n /** Backward-compatible single-root entry. With no `rid` it returns the sole root's entry\n * and THROWS if the tree has zero or more than one root. An explicit `rid` addresses any\n * resource in the heap, as today. */\n public entry(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeEntry(\n { treeProvider: () => this.state, hooks: this.hooks },\n rid ?? this.soleRoot(),\n );\n }\n\n /** Reactive provider for the current root SET. Reading it inside a Computable tracks the\n * set as a dependency, so the Computable recomputes when discovered roots appear/disappear. */\n public rootsEntry(): PlTreeRootsEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeRootsEntry({ treeProvider: () => this.state, hooks: this.hooks });\n }\n\n /** Can be used to externally kick off the synchronization polling loop, and\n * await for the first synchronization to happen. */\n public async refreshState(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n await this.hooks.refreshState();\n }\n\n private currentLoopDelayInterrupt: AbortController | undefined = undefined;\n private scheduledOnNextState: ScheduledRefresh[] = [];\n\n /** Interval actually used for the current wait. Starts at the configured `pollingInterval`\n * and is re-derived after every cycle by {@link updatePollingInterval}. */\n private effectivePollingInterval: number;\n\n /** Re-derives {@link effectivePollingInterval} after a cycle.\n *\n * Two independent effects. The floor scales with the client's measured RTT, so a\n * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle\n * that changed nothing multiplies the interval out towards\n * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so\n * an active tree stays responsive. */\n private updatePollingInterval(changed: boolean): void {\n this.effectivePollingInterval = derivePollingInterval({\n configuredMs: this.pollingInterval,\n currentMs: this.effectivePollingInterval,\n rttMs: this.pl.rttEstimateMs,\n changed,\n });\n }\n\n /** Called from computable hooks when external observer asks for state refresh */\n private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {\n if (this.terminated) reject(new Error(\"tree synchronization is terminated\"));\n else {\n this.scheduledOnNextState.push({ resolve, reject });\n // Someone is waiting on fresh state, so this tree is not idle after all: drop any\n // accumulated backoff, otherwise the cycles right after a nudge stay slow. Routed\n // through the policy rather than assigning the configured value directly, so the RTT\n // floor survives the reset. Assigning it raw would poll a high-latency link faster than\n // it can answer, and the interval would stay there until the next cycle that completes:\n // the error path never reaches updatePollingInterval, so a failing nudged refresh would\n // keep retrying at the un-floored rate.\n this.updatePollingInterval(true);\n if (this.currentLoopDelayInterrupt) {\n this.currentLoopDelayInterrupt.abort();\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n\n /** Called from observer */\n private startUpdating(): void {\n if (this.terminated) return;\n this.keepRunning = true;\n if (this.currentLoop === undefined) this.currentLoop = this.mainLoop();\n }\n\n /** Called from observer */\n private stopUpdating(): void {\n this.keepRunning = false;\n }\n\n /** If true, main loop will continue polling pl state. */\n private keepRunning = false;\n /** Actual state of main loop. */\n private currentLoop: Promise<void> | undefined = undefined;\n\n /** Executed from the main loop, and initialization procedure. */\n private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n try {\n await this.loadAndApply(stats, txOps);\n } catch (e) {\n // Discovery-tree self-heal: a discovered root whose grant was revoked/expired fails the whole\n // ResourceTree poll with Unauthenticated. Re-discover (drops dead roots) and retry once. This is\n // self-discriminating — a genuinely dead session also fails discover()'s own call, so real auth\n // loss still propagates. Only for discovery trees; explicit-root trees propagate as-is.\n if (this.sharedSeeds.length > 0 && isUnauthenticated(e)) {\n this.logger?.warn(\n \"discovery tree: Unauthenticated on ResourceTree (likely revoked/expired root); re-discovering and retrying\",\n );\n await this.discover();\n await this.loadAndApply(stats, txOps);\n } else throw e;\n }\n }\n\n private async loadAndApply(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n const request = constructTreeLoadingRequest(this.state, {\n pruningFunction: this.pruning,\n fieldFilter: this.fieldFilter,\n traverseStopRules: this.traverseStopRules,\n changedSinceToken: this.deltaToken,\n });\n // A shared-type-seed tree with no currently-discovered roots is legitimately empty:\n // there is nothing to traverse, and tx.resourceTree([]) would throw \"at least one seed\n // must be provided\". Skip the backend load and leave the (empty) state as-is — discovery\n // adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees\n // never hit this (their root set is non-empty by construction).\n if (request.seedResources.length === 0 && request.finalResources.size === 0) return;\n const { data, nextToken } = await this.pl.withReadTx(\n \"ReadingTree\",\n async (tx) => {\n // Started, not awaited, before the walk. The token dates the transaction rather than\n // the response, so it is not an input to the request - the request carries the\n // PREVIOUS poll's token. Awaiting it here would block on the tx-open response before\n // sending the tree request, costing a whole round trip that streaming does not pay,\n // because requests pipeline on one bidi stream and withReadTx does not await the open.\n const tokenPromise =\n this.algorithm === \"backend-delta\" ? tx.getNextSinceToken() : undefined;\n const data = await loadTreeState(\n tx,\n request,\n stats,\n this.pl.serverInfo.capabilities ?? [],\n this.algorithm,\n this.logger,\n );\n return { data, nextToken: await tokenPromise };\n },\n txOps,\n );\n this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });\n\n // Only with the whole batch applied: advancing past a partial apply loses the dropped\n // resources for good. A throw above leaves the old token, so the next poll re-reads it.\n if (nextToken !== undefined) this.deltaToken = nextToken;\n else if (this.algorithm === \"backend-delta\") this.demoteFromDelta();\n }\n\n /** Give up on delta for the life of this tree, once, when the backend advertises\n * `treeChangedSince:v2` but hands out no token.\n *\n * Without this the tree stays on delta with `deltaToken` permanently unset, and every poll\n * is then a token-less delta poll: a full tree read that also sends no stop rules, so it\n * transfers the subtrees the streaming path prunes away. Nothing else detects it -\n * `deltaSuspectedFullAnswers` only fires when a token WAS sent - so it would run at the\n * poll interval, forever, silently. Streaming is the correct destination: it is what `auto`\n * would have picked without the capability, and it restores the stop rules. */\n private demoteFromDelta() {\n this.algorithm = supportsResourceTreeTraversal(this.pl.serverInfo.capabilities ?? [])\n ? \"backend-streaming\"\n : \"client-bfs\";\n this.logger?.warn(\n `tree: backend advertises treeChangedSince:v2 but issued no change token; ` +\n `falling back to ${this.algorithm} for the life of this tree`,\n );\n }\n\n /** Discards the change token, so the next delta poll asks for the full tree. Required\n * whenever the mirror stops being a faithful record of what the token says we hold: a\n * rebuild after {@link TreeStateUpdateError}, or a root-set change, which reshapes the\n * traversal the token was earned under. */\n private discardDeltaToken(reason: string) {\n if (this.deltaToken === undefined) return;\n this.deltaToken = undefined;\n this.logger?.info(`tree delta token discarded (${reason}); next poll reads the full tree`);\n }\n\n /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and\n * reconciles the discovered root set against the heap. A longer poll result adds roots; a\n * shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade\n * to collection via the ordinary refcount GC ({@link PlTreeState.setRoots}). No-op when the\n * tree has no shared-type seeds, or silently no-op on a REST client where `ListUserResources`\n * is unavailable. */\n private async discover(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n if (this.sharedSeeds.length === 0) return;\n\n const discovered = new Set<SignedResourceId>();\n for (const seed of this.sharedSeeds) {\n let ids: SignedResourceId[];\n try {\n // match by name only (permissive; ignores version, so it survives schema bumps)\n ids = await this.pl.userResources.listSharedResourcesByType(seed.resourceType.name);\n } catch (e: unknown) {\n if (isUnimplementedError(e)) continue;\n throw e;\n }\n for (const id of ids) discovered.add(id);\n }\n\n const rootsChanged =\n discovered.size !== this.discoveredRoots.length ||\n this.discoveredRoots.some((id) => !discovered.has(id));\n\n this.discoveredRoots = [...discovered];\n this.state.setRoots(this.currentRootSet());\n\n // A root arriving brings a subtree the token would skip as unchanged, and one leaving\n // takes its subtree with it. Either way the token no longer describes what we hold.\n if (rootsChanged) this.discardDeltaToken(\"root set changed\");\n }\n\n /** If true this tree state is permanently terminaed. */\n private terminated = false;\n\n private async mainLoop() {\n // Always collected, even when not logging: the change counters drive the idle backoff\n // below. Counter bumps are cheap next to the round trip they describe.\n let stat = initialTreeLoadingStat();\n\n let lastUpdate = Date.now();\n\n // paces the discovery poll for shared-type seeds; 0 forces discovery on the first pass.\n let lastDiscovery = 0;\n\n while (true) {\n if (!this.keepRunning || this.terminated) break;\n\n // saving those who want to be notified about new state here\n // because those who will be added during the tree retrieval\n // should be notified only on the next round\n let toNotify: ScheduledRefresh[] | undefined = undefined;\n if (this.scheduledOnNextState.length > 0) {\n toNotify = this.scheduledOnNextState;\n this.scheduledOnNextState = [];\n }\n\n try {\n // resetting stats if we were asked to collect non-cumulative stats\n if (this.logStat === \"per-request\") stat = initialTreeLoadingStat();\n\n // discovery sync for shared-type seeds: reconcile the discovered root set before\n // refreshing, so newly discovered roots are materialized in this same iteration.\n if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {\n await this.discover();\n lastDiscovery = Date.now();\n }\n\n // Change counters before the refresh, so the delta tells us whether this single cycle\n // brought anything new. Works in both stat modes: \"per-request\" resets to 0 above.\n const changesBefore = countedChanges(stat);\n\n // actual tree synchronization\n await this.refresh(stat);\n\n const changed = countedChanges(stat) > changesBefore;\n if (changed) this.changeGenerationCounter++;\n this.updatePollingInterval(changed);\n\n // logging stats if we were asked to\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we got new state\n if (toNotify !== undefined) for (const n of toNotify) n.resolve();\n } catch (e: any) {\n // logging stats if we were asked to (even if error occured)\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we failed to refresh the state\n if (toNotify !== undefined) for (const n of toNotify) n.reject(e);\n\n // catching tree update errors, as they may leave our tree in inconsistent state\n if (e instanceof TreeStateUpdateError) {\n // important error logging, this should never happen\n this.logger?.error(e);\n\n // marking everybody who used previous state as changed\n this.state.invalidateTree(\"stat update error\");\n // creating new tree with the full current root set (re-discovered on next iteration)\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n // The new mirror holds nothing, so the old token would skip everything.\n this.discardDeltaToken(\"tree rebuilt after update error\");\n\n // scheduling state update without delay\n continue;\n\n // unfortunately external observer may still see tree in its default\n // empty state, though this is best we can do in this exceptional\n // situation, and hope on caching layers inside computables to present\n // some stale state until we reconstruct the tree again\n } else this.logger?.warn(e);\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 1: mandatory floor — always wait at least MIN_POLLING_INTERVAL_MS.\n // Not interruptible by scheduleOnNextState; only termination aborts it.\n try {\n await tp.setTimeout(MIN_POLLING_INTERVAL_MS, undefined, {\n signal: this.abortController.signal,\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 2: optional remainder up to pollingInterval — interruptible by\n // scheduleOnNextState so that an external nudge wakes the loop promptly.\n if (this.scheduledOnNextState.length === 0) {\n const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);\n if (remaining > 0) {\n try {\n this.currentLoopDelayInterrupt = new AbortController();\n await tp.setTimeout(remaining, undefined, {\n signal: AbortSignal.any([\n this.abortController.signal,\n this.currentLoopDelayInterrupt.signal,\n ]),\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n // Otherwise it was just the loop delay interrupt (scheduleOnNextState),\n // continue to the next iteration\n } finally {\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n }\n\n // reset only as a very last line\n this.currentLoop = undefined;\n }\n\n /**\n * Dumps the current state of the tree.\n * @returns An array of ExtendedResourceData objects representing the current state of the tree.\n */\n public dumpState(): ExtendedResourceData[] {\n return this.state.dumpState();\n }\n\n /**\n * Terminates the internal loop, and permanently destoys all internal state, so\n * all computables using this state will resolve to errors.\n * */\n public async terminate(): Promise<void> {\n this.keepRunning = false;\n this.terminated = true;\n this.abortController.abort();\n\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n\n this.state.invalidateTree(\"synchronization terminated for the tree\");\n }\n\n /** @deprecated */\n public async awaitSyncLoopTermination(): Promise<void> {\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n }\n\n /**\n * Initializes a synchronized tree from one or more seeds.\n *\n * @param seeds a bare {@link SignedResourceId} (the original single-root contract), a\n * single {@link TreeSeed}, or an array of seeds. Bare ids and explicit-resource seeds\n * become roots immediately; shared-type seeds discover their roots via `ListUserResources`.\n */\n public static async init(\n pl: PlClient,\n seeds: SignedResourceId | TreeSeed | TreeSeed[],\n ops: SynchronizedTreeOps,\n logger?: MiLogger,\n ) {\n const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);\n\n // Seed from the snapshot before the first refresh, so that refresh is the one that\n // transfers only what changed. A refused snapshot leaves an ordinary cold open.\n const restored = ops.restoreFrom !== undefined && tree.restore(ops.restoreFrom);\n\n // Always collected, even when not logging: the initial load's change count is what seeds\n // the change generation, so a holder can tell a populated tree from an untouched one.\n const stat = initialTreeLoadingStat();\n\n let ok = false;\n\n try {\n // resolve shared-type seeds before the first refresh so discovered roots load now\n await tree.discover();\n await tree.refresh(stat, {\n timeout: ops.initialTreeLoadingTimeout,\n });\n ok = true;\n } finally {\n if (countedChanges(stat) > 0) tree.changeGenerationCounter++;\n\n // logging stats if we were asked to (even if error occured)\n if (ops.logStat && logger)\n logger.info(\n `Tree stat (initial load, ${ok ? \"success\" : \"failure\"}, ${\n restored ? \"restored from snapshot\" : \"cold\"\n }): ${JSON.stringify(stat)}`,\n );\n }\n\n return tree;\n }\n}\n"],"mappings":"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"}
|
|
@@ -166,7 +166,7 @@ export declare class SynchronizedTreeState {
|
|
|
166
166
|
private refresh;
|
|
167
167
|
private loadAndApply;
|
|
168
168
|
/** Give up on delta for the life of this tree, once, when the backend advertises
|
|
169
|
-
* `treeChangedSince:
|
|
169
|
+
* `treeChangedSince:v2` but hands out no token.
|
|
170
170
|
*
|
|
171
171
|
* Without this the tree stays on delta with `deltaToken` permanently unset, and every poll
|
|
172
172
|
* is then a token-less delta poll: a full tree read that also sends no stop rules, so it
|
|
@@ -297,7 +297,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
|
|
|
297
297
|
else if (this.algorithm === "backend-delta") this.demoteFromDelta();
|
|
298
298
|
}
|
|
299
299
|
/** Give up on delta for the life of this tree, once, when the backend advertises
|
|
300
|
-
* `treeChangedSince:
|
|
300
|
+
* `treeChangedSince:v2` but hands out no token.
|
|
301
301
|
*
|
|
302
302
|
* Without this the tree stays on delta with `deltaToken` permanently unset, and every poll
|
|
303
303
|
* is then a token-less delta poll: a full tree read that also sends no stop rules, so it
|
|
@@ -307,7 +307,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
|
|
|
307
307
|
* would have picked without the capability, and it restores the stop rules. */
|
|
308
308
|
demoteFromDelta() {
|
|
309
309
|
this.algorithm = supportsResourceTreeTraversal(this.pl.serverInfo.capabilities ?? []) ? "backend-streaming" : "client-bfs";
|
|
310
|
-
this.logger?.warn(`tree: backend advertises treeChangedSince:
|
|
310
|
+
this.logger?.warn(`tree: backend advertises treeChangedSince:v2 but issued no change token; falling back to ${this.algorithm} for the life of this tree`);
|
|
311
311
|
}
|
|
312
312
|
/** Discards the change token, so the next delta poll asks for the full tree. Required
|
|
313
313
|
* whenever the mirror stops being a faithful record of what the token says we hold: a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"synchronized_tree.js","names":[],"sources":["../src/synchronized_tree.ts"],"sourcesContent":["import { PollingComputableHooks } from \"@milaboratories/computable\";\nimport { PlTreeEntry, PlTreeRootsEntry } from \"./accessors\";\nimport type {\n FinalResourceDataPredicate,\n PlClient,\n ResourceSignature,\n ResourceType,\n SignedResourceId,\n TxOps,\n} from \"@milaboratories/pl-client\";\nimport type { Filter } from \"@milaboratories/pl-client\";\nimport {\n isUnauthenticated,\n isTimeoutOrCancelError,\n isUnimplementedError,\n} from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData } from \"./state\";\nimport { PlTreeState, TreeStateUpdateError } from \"./state\";\nimport type {\n PruningFunction,\n TraversalMode,\n TreeLoadingAlgorithmName,\n TreeLoadingStat,\n} from \"./sync\";\nimport {\n constructTreeLoadingRequest,\n initialTreeLoadingStat,\n loadTreeState,\n resolveTreeLoadingAlgorithm,\n supportsResourceTreeTraversal,\n} from \"./sync\";\nimport type { PersistedTree } from \"./persisted_tree\";\nimport { captureTreeState, restoreTreeState } from \"./persisted_tree\";\nimport * as tp from \"node:timers/promises\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\n\n/** Hard floor between consecutive tree-refresh calls.\n * Applies even when {@link scheduleOnNextState} has woken the loop early,\n * preventing tight polling loops during rapid state transitions. */\nconst MIN_POLLING_INTERVAL_MS = 100;\n\n/** Ceiling for the adaptive poll interval. Caps how stale an idle tree can get before the\n * next look, and bounds how far the idle backoff can push the interval out. */\nconst MAX_POLLING_INTERVAL_MS = 5_000;\n\n/** Applied to the interval after a cycle that changed nothing. An idle tree walks its\n * interval out towards {@link MAX_POLLING_INTERVAL_MS} instead of re-polling at full rate;\n * the first cycle that changes anything resets it. */\nconst IDLE_BACKOFF_MULTIPLIER = 1.5;\n\n/** The client's measured RTT becomes an interval floor, scaled by this. Every refresh costs\n * at least one round trip, so polling faster than a small multiple of the RTT only queues\n * round-trips the link cannot service. This is what replaces the fixed interval on a\n * high-latency link; on a fast link the configured `pollingInterval` still dominates. */\nconst RTT_POLL_FACTOR = 2;\n\n/** Ceiling for the RTT-derived floor. The estimate is sampled at connect time and never\n * re-sampled, so without a bound one slow ping pins the cadence high for the whole session.\n * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck\n * rather than slow, and spacing polls further only delays noticing it recovered. */\nconst MAX_RTT_POLL_INTERVAL_MS = 30_000;\n\ntype StatLoggingMode = \"cumulative\" | \"per-request\";\n\nexport type SynchronizedTreeOps = {\n /** Override final predicate from the PlClient */\n finalPredicateOverride?: FinalResourceDataPredicate;\n\n /** Pruning function for legacy fallback path. */\n pruning?: PruningFunction;\n\n /** ResourceTree field filter for modern backend path. */\n fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules for the streaming path.\n * @deprecated the backend ignores these under a change token, and the delta algorithm - what\n * `auto` now picks on a capable backend - never sends them. They still prune a\n * `backend-streaming` walk and a token-less delta poll's fallback. */\n traverseStopRules?: Filter;\n\n /** Interval after last sync to sleep before the next one */\n pollingInterval: number;\n /** For how long to continue polling after the last derived value access */\n stopPollingDelay: number;\n\n /** If one of the values, tree will log stats of each polling request */\n logStat?: StatLoggingMode;\n\n /** Timeout for initial tree loading. If not specified, will use default for RO tx from pl-client. */\n initialTreeLoadingTimeout?: number;\n\n /** Controls which tree-loading path to use. Default `\"auto\"`. */\n traversalMode?: TraversalMode;\n\n /** A previously persisted mirror to seed the tree with, before its first refresh, so that\n * refresh transfers only what changed while the tree was gone.\n *\n * A snapshot that cannot be applied, or does not belong to this tree, is logged and dropped,\n * leaving an ordinary cold open.\n *\n * A snapshot that applies but whose ids are dead is NOT handled here: its resources become\n * this tree's seeds, so the first refresh fails and {@link init} rejects, where a cold open\n * would have succeeded. Establishing that the signatures are still live is the caller's job\n * (see {@link PersistedTree.witness}), as is deciding what to do when the first refresh is\n * refused anyway. Ignored for trees with shared-type seeds, which rediscover their roots. */\n restoreFrom?: PersistedTree;\n};\n\n/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */\nexport type ExplicitRootSeed = { kind: \"resource\"; root: SignedResourceId };\n\n/** Discovers, as roots, every resource of this type shared with the current user.\n * Matched against SharedResource.resourceType by NAME (version optional/ignored). The\n * discovered set is DYNAMIC — roots appear/disappear as grants are added/revoked/expire. */\nexport type SharedTypeSeed = { kind: \"shared\"; resourceType: ResourceType };\n\nexport type TreeSeed = ExplicitRootSeed | SharedTypeSeed;\n\n/** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare\n * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so\n * every existing single-root caller is unchanged. */\nfunction normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSeed[] {\n if (Array.isArray(seeds)) return seeds;\n if (typeof seeds === \"object\" && seeds !== null && \"kind\" in seeds) return [seeds];\n // bare SignedResourceId\n return [{ kind: \"resource\", root: seeds }];\n}\n\n/** How often a tree with shared-type seeds re-polls ListUserResources to reconcile its\n * discovered roots.\n *\n * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental\n * refresh, so it must NOT run on every refresh tick. This is a wall-clock interval rather\n * than a count of iterations: the refresh cadence is adaptive (it stretches towards\n * {@link MAX_POLLING_INTERVAL_MS} on an idle tree and scales with RTT on a slow link),\n * so a fixed iteration count would let discovery latency drift out with it. Keeping it in\n * milliseconds pins the latency of noticing a new/removed share regardless of cadence, which\n * is what a human-driven share flow cares about.\n *\n * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root\n * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */\nconst DISCOVERY_INTERVAL_MS = 3_000;\n\n/** Counters that mean \"this cycle brought something new\". Deliberately not the full change\n * breakdown: those fields are sub-counts of `resourcesChanged` and would double-count.\n * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged\n * state is exactly the idle case the backoff exists for. */\nfunction countedChanges(stat: TreeLoadingStat): number {\n // `fieldsRemoved` is included despite being a per-field count, because it is the one change\n // that never shows up in `resourcesChanged`: the removed-dynamic-field branch in\n // `updateFromResourceData` does not set its `changed` flag, so a cycle that only dropped a\n // field (and garbage-collected whatever it pointed at) otherwise reads as an idle cycle.\n // That double-counts a resource that both changed and lost a field, which is harmless here:\n // every caller compares this against an earlier value rather than reading it as a total.\n return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.fieldsRemoved;\n}\n\n/** The poll-cadence policy, as a pure function of the last cycle's outcome.\n *\n * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no\n * link can be polled faster than configured. `rttMs` raises that bound on a slow link.\n * `currentMs` is the interval in force for the cycle that just finished, which is what the\n * idle backoff compounds on. */\nexport function derivePollingInterval(opts: {\n configuredMs: number;\n currentMs: number;\n rttMs: number | undefined;\n changed: boolean;\n}): number {\n const { configuredMs, currentMs, rttMs, changed } = opts;\n\n // The cap applies to the RTT-derived part only, so `configuredMs` stays an absolute lower\n // bound even if it is ever set above the cap.\n const floor =\n rttMs === undefined\n ? configuredMs\n : Math.max(\n configuredMs,\n Math.min(MAX_RTT_POLL_INTERVAL_MS, Math.ceil(rttMs * RTT_POLL_FACTOR)),\n );\n\n const next = changed ? floor : Math.max(floor, currentMs * IDLE_BACKOFF_MULTIPLIER);\n\n // The ceiling never cuts below the floor: a link slower than MAX_POLLING_INTERVAL_MS still\n // gets its RTT-derived spacing rather than being forced to re-poll early.\n return Math.max(floor, Math.min(MAX_POLLING_INTERVAL_MS, next));\n}\n\ntype ScheduledRefresh = {\n resolve: () => void;\n reject: (err: any) => void;\n};\n\nexport class SynchronizedTreeState {\n private readonly finalPredicate: FinalResourceDataPredicate;\n private state: PlTreeState;\n private readonly pollingInterval: number;\n private readonly pruning?: PruningFunction;\n private readonly fieldFilter?: Filter;\n private readonly traverseStopRules?: Filter;\n private readonly traversalMode: TraversalMode;\n /** Resolved once from {@link traversalMode} and the server's capabilities, and used by every\n * poll of this tree. Selecting per poll was only sound while no algorithm kept state between\n * polls; pinning it here is what lets one do so. Only ever reassigned by the one-way\n * demotion in {@link loadAndApply} when the backend advertises delta but issues no token. */\n private algorithm: TreeLoadingAlgorithmName;\n /** Change token the last successful delta apply was dated at, handed to the next poll so\n * the backend sends only what moved since. Undefined until the first delta poll commits\n * one, and again whenever {@link discardDeltaToken} drops it. */\n private deltaToken: Uint8Array | undefined;\n private readonly logStat?: StatLoggingMode;\n private readonly hooks: PollingComputableHooks;\n private readonly abortController = new AbortController();\n\n /** Explicit-resource seeds: fixed roots, present from construction. */\n private readonly explicitRoots: SignedResourceId[];\n /** Shared-type seeds (discovered roots), if any. */\n private readonly sharedSeeds: SharedTypeSeed[];\n /** Roots discovered for shared-type seeds on the last discovery poll. */\n private discoveredRoots: SignedResourceId[] = [];\n\n /** Bumped once per refresh cycle that brought something new: a resource appeared, changed,\n * or became final. Lets a holder tell whether the tree has moved since it last persisted\n * it, without diffing state. Read through {@link changeGeneration}. */\n private changeGenerationCounter = 0;\n\n /** Whether a snapshot was actually applied. Read through\n * {@link wasRestoredFromSnapshot}. */\n private restoredFromSnapshot = false;\n\n private constructor(\n private readonly pl: PlClient,\n seeds: TreeSeed[],\n ops: SynchronizedTreeOps,\n private readonly logger?: MiLogger,\n ) {\n const {\n finalPredicateOverride,\n pruning,\n fieldFilter,\n traverseStopRules,\n traversalMode,\n pollingInterval,\n stopPollingDelay,\n logStat,\n } = ops;\n this.pruning = pruning;\n this.fieldFilter = fieldFilter;\n this.traverseStopRules = traverseStopRules;\n this.traversalMode = traversalMode ?? \"auto\";\n this.algorithm = resolveTreeLoadingAlgorithm(\n this.traversalMode,\n pl.serverInfo.capabilities ?? [],\n logger,\n );\n logger?.info(`tree loading algorithm: ${this.algorithm} (traversalMode=${this.traversalMode})`);\n this.pollingInterval = pollingInterval;\n this.effectivePollingInterval = pollingInterval;\n this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;\n this.logStat = logStat;\n\n this.explicitRoots = seeds\n .filter((s): s is ExplicitRootSeed => s.kind === \"resource\")\n .map((s) => s.root);\n this.sharedSeeds = seeds.filter((s): s is SharedTypeSeed => s.kind === \"shared\");\n\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n this.hooks = new PollingComputableHooks(\n () => this.startUpdating(),\n () => this.stopUpdating(),\n { stopDebounce: stopPollingDelay },\n (resolve, reject) => this.scheduleOnNextState(resolve, reject),\n );\n }\n\n /** The current protected root set: explicit roots plus the latest discovered roots. */\n private currentRootSet(): Set<SignedResourceId> {\n return new Set([...this.explicitRoots, ...this.discoveredRoots]);\n }\n\n /** How many refresh cycles brought something new. Only ever increases. Equal values at two\n * points in time mean nothing was added, changed or settled in between, which is what makes\n * a periodic snapshot write skippable on an idle tree. */\n public get changeGeneration(): number {\n return this.changeGenerationCounter;\n }\n\n /** True only if a snapshot was actually applied as this tree's initial state. A snapshot can\n * be supplied and still be refused (wrong roots, or state the update call will not accept),\n * in which case this stays false and the tree started empty like any other. Passing\n * `restoreFrom` is therefore not evidence of a warm start; this is. */\n public get wasRestoredFromSnapshot(): boolean {\n return this.restoredFromSnapshot;\n }\n\n /** Captures the current mirror for persistence.\n *\n * Must be called before {@link terminate}: terminating invalidates the tree, and capturing\n * an invalidated tree is refused rather than silently written. */\n public capture(witness: ResourceSignature): PersistedTree {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return captureTreeState(this.state, witness);\n }\n\n /** Installs a snapshot as this tree's state. Returns false if the snapshot was refused, in\n * which case the tree is left as it was and the open proceeds cold.\n *\n * Only meaningful before the first refresh, which is why it is private and driven from\n * {@link init}: replacing the state of a running tree would strand its observers. */\n private restore(snapshot: PersistedTree): boolean {\n if (this.sharedSeeds.length > 0) {\n this.logger?.warn(\"ignoring tree snapshot: trees with shared-type seeds are not restored\");\n return false;\n }\n\n const roots = this.currentRootSet();\n const snapshotRoots = new Set(snapshot.roots);\n if (snapshotRoots.size !== roots.size || ![...snapshotRoots].every((r) => roots.has(r))) {\n // A snapshot addressed to a different root is a mis-keyed file, not a stale one.\n this.logger?.warn(\"ignoring tree snapshot: its roots are not this tree's roots\");\n return false;\n }\n\n const restored = restoreTreeState(snapshot, this.finalPredicate, {\n roots,\n logger: this.logger,\n });\n if (restored === undefined) return false;\n\n this.state = restored;\n this.restoredFromSnapshot = true;\n return true;\n }\n\n /** Resolves the single root for the backward-compatible single-root accessors, throwing\n * if the tree does not have exactly one root (guards legacy callers against multi-root). */\n private soleRoot(): SignedResourceId {\n const roots = this.currentRootSet();\n if (roots.size !== 1)\n throw new Error(\n `single-root accessor used on a tree with ${roots.size} roots; use rootsEntry() instead`,\n );\n return roots.values().next().value!;\n }\n\n /** @deprecated use \"entry\" instead */\n public accessor(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return this.entry(rid);\n }\n\n /** Backward-compatible single-root entry. With no `rid` it returns the sole root's entry\n * and THROWS if the tree has zero or more than one root. An explicit `rid` addresses any\n * resource in the heap, as today. */\n public entry(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeEntry(\n { treeProvider: () => this.state, hooks: this.hooks },\n rid ?? this.soleRoot(),\n );\n }\n\n /** Reactive provider for the current root SET. Reading it inside a Computable tracks the\n * set as a dependency, so the Computable recomputes when discovered roots appear/disappear. */\n public rootsEntry(): PlTreeRootsEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeRootsEntry({ treeProvider: () => this.state, hooks: this.hooks });\n }\n\n /** Can be used to externally kick off the synchronization polling loop, and\n * await for the first synchronization to happen. */\n public async refreshState(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n await this.hooks.refreshState();\n }\n\n private currentLoopDelayInterrupt: AbortController | undefined = undefined;\n private scheduledOnNextState: ScheduledRefresh[] = [];\n\n /** Interval actually used for the current wait. Starts at the configured `pollingInterval`\n * and is re-derived after every cycle by {@link updatePollingInterval}. */\n private effectivePollingInterval: number;\n\n /** Re-derives {@link effectivePollingInterval} after a cycle.\n *\n * Two independent effects. The floor scales with the client's measured RTT, so a\n * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle\n * that changed nothing multiplies the interval out towards\n * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so\n * an active tree stays responsive. */\n private updatePollingInterval(changed: boolean): void {\n this.effectivePollingInterval = derivePollingInterval({\n configuredMs: this.pollingInterval,\n currentMs: this.effectivePollingInterval,\n rttMs: this.pl.rttEstimateMs,\n changed,\n });\n }\n\n /** Called from computable hooks when external observer asks for state refresh */\n private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {\n if (this.terminated) reject(new Error(\"tree synchronization is terminated\"));\n else {\n this.scheduledOnNextState.push({ resolve, reject });\n // Someone is waiting on fresh state, so this tree is not idle after all: drop any\n // accumulated backoff, otherwise the cycles right after a nudge stay slow. Routed\n // through the policy rather than assigning the configured value directly, so the RTT\n // floor survives the reset. Assigning it raw would poll a high-latency link faster than\n // it can answer, and the interval would stay there until the next cycle that completes:\n // the error path never reaches updatePollingInterval, so a failing nudged refresh would\n // keep retrying at the un-floored rate.\n this.updatePollingInterval(true);\n if (this.currentLoopDelayInterrupt) {\n this.currentLoopDelayInterrupt.abort();\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n\n /** Called from observer */\n private startUpdating(): void {\n if (this.terminated) return;\n this.keepRunning = true;\n if (this.currentLoop === undefined) this.currentLoop = this.mainLoop();\n }\n\n /** Called from observer */\n private stopUpdating(): void {\n this.keepRunning = false;\n }\n\n /** If true, main loop will continue polling pl state. */\n private keepRunning = false;\n /** Actual state of main loop. */\n private currentLoop: Promise<void> | undefined = undefined;\n\n /** Executed from the main loop, and initialization procedure. */\n private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n try {\n await this.loadAndApply(stats, txOps);\n } catch (e) {\n // Discovery-tree self-heal: a discovered root whose grant was revoked/expired fails the whole\n // ResourceTree poll with Unauthenticated. Re-discover (drops dead roots) and retry once. This is\n // self-discriminating — a genuinely dead session also fails discover()'s own call, so real auth\n // loss still propagates. Only for discovery trees; explicit-root trees propagate as-is.\n if (this.sharedSeeds.length > 0 && isUnauthenticated(e)) {\n this.logger?.warn(\n \"discovery tree: Unauthenticated on ResourceTree (likely revoked/expired root); re-discovering and retrying\",\n );\n await this.discover();\n await this.loadAndApply(stats, txOps);\n } else throw e;\n }\n }\n\n private async loadAndApply(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n const request = constructTreeLoadingRequest(this.state, {\n pruningFunction: this.pruning,\n fieldFilter: this.fieldFilter,\n traverseStopRules: this.traverseStopRules,\n changedSinceToken: this.deltaToken,\n });\n // A shared-type-seed tree with no currently-discovered roots is legitimately empty:\n // there is nothing to traverse, and tx.resourceTree([]) would throw \"at least one seed\n // must be provided\". Skip the backend load and leave the (empty) state as-is — discovery\n // adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees\n // never hit this (their root set is non-empty by construction).\n if (request.seedResources.length === 0 && request.finalResources.size === 0) return;\n const { data, nextToken } = await this.pl.withReadTx(\n \"ReadingTree\",\n async (tx) => {\n // Started, not awaited, before the walk. The token dates the transaction rather than\n // the response, so it is not an input to the request - the request carries the\n // PREVIOUS poll's token. Awaiting it here would block on the tx-open response before\n // sending the tree request, costing a whole round trip that streaming does not pay,\n // because requests pipeline on one bidi stream and withReadTx does not await the open.\n const tokenPromise =\n this.algorithm === \"backend-delta\" ? tx.getNextSinceToken() : undefined;\n const data = await loadTreeState(\n tx,\n request,\n stats,\n this.pl.serverInfo.capabilities ?? [],\n this.algorithm,\n this.logger,\n );\n return { data, nextToken: await tokenPromise };\n },\n txOps,\n );\n this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });\n\n // Only with the whole batch applied: advancing past a partial apply loses the dropped\n // resources for good. A throw above leaves the old token, so the next poll re-reads it.\n if (nextToken !== undefined) this.deltaToken = nextToken;\n else if (this.algorithm === \"backend-delta\") this.demoteFromDelta();\n }\n\n /** Give up on delta for the life of this tree, once, when the backend advertises\n * `treeChangedSince:v1` but hands out no token.\n *\n * Without this the tree stays on delta with `deltaToken` permanently unset, and every poll\n * is then a token-less delta poll: a full tree read that also sends no stop rules, so it\n * transfers the subtrees the streaming path prunes away. Nothing else detects it -\n * `deltaSuspectedFullAnswers` only fires when a token WAS sent - so it would run at the\n * poll interval, forever, silently. Streaming is the correct destination: it is what `auto`\n * would have picked without the capability, and it restores the stop rules. */\n private demoteFromDelta() {\n this.algorithm = supportsResourceTreeTraversal(this.pl.serverInfo.capabilities ?? [])\n ? \"backend-streaming\"\n : \"client-bfs\";\n this.logger?.warn(\n `tree: backend advertises treeChangedSince:v1 but issued no change token; ` +\n `falling back to ${this.algorithm} for the life of this tree`,\n );\n }\n\n /** Discards the change token, so the next delta poll asks for the full tree. Required\n * whenever the mirror stops being a faithful record of what the token says we hold: a\n * rebuild after {@link TreeStateUpdateError}, or a root-set change, which reshapes the\n * traversal the token was earned under. */\n private discardDeltaToken(reason: string) {\n if (this.deltaToken === undefined) return;\n this.deltaToken = undefined;\n this.logger?.info(`tree delta token discarded (${reason}); next poll reads the full tree`);\n }\n\n /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and\n * reconciles the discovered root set against the heap. A longer poll result adds roots; a\n * shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade\n * to collection via the ordinary refcount GC ({@link PlTreeState.setRoots}). No-op when the\n * tree has no shared-type seeds, or silently no-op on a REST client where `ListUserResources`\n * is unavailable. */\n private async discover(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n if (this.sharedSeeds.length === 0) return;\n\n const discovered = new Set<SignedResourceId>();\n for (const seed of this.sharedSeeds) {\n let ids: SignedResourceId[];\n try {\n // match by name only (permissive; ignores version, so it survives schema bumps)\n ids = await this.pl.userResources.listSharedResourcesByType(seed.resourceType.name);\n } catch (e: unknown) {\n if (isUnimplementedError(e)) continue;\n throw e;\n }\n for (const id of ids) discovered.add(id);\n }\n\n const rootsChanged =\n discovered.size !== this.discoveredRoots.length ||\n this.discoveredRoots.some((id) => !discovered.has(id));\n\n this.discoveredRoots = [...discovered];\n this.state.setRoots(this.currentRootSet());\n\n // A root arriving brings a subtree the token would skip as unchanged, and one leaving\n // takes its subtree with it. Either way the token no longer describes what we hold.\n if (rootsChanged) this.discardDeltaToken(\"root set changed\");\n }\n\n /** If true this tree state is permanently terminaed. */\n private terminated = false;\n\n private async mainLoop() {\n // Always collected, even when not logging: the change counters drive the idle backoff\n // below. Counter bumps are cheap next to the round trip they describe.\n let stat = initialTreeLoadingStat();\n\n let lastUpdate = Date.now();\n\n // paces the discovery poll for shared-type seeds; 0 forces discovery on the first pass.\n let lastDiscovery = 0;\n\n while (true) {\n if (!this.keepRunning || this.terminated) break;\n\n // saving those who want to be notified about new state here\n // because those who will be added during the tree retrieval\n // should be notified only on the next round\n let toNotify: ScheduledRefresh[] | undefined = undefined;\n if (this.scheduledOnNextState.length > 0) {\n toNotify = this.scheduledOnNextState;\n this.scheduledOnNextState = [];\n }\n\n try {\n // resetting stats if we were asked to collect non-cumulative stats\n if (this.logStat === \"per-request\") stat = initialTreeLoadingStat();\n\n // discovery sync for shared-type seeds: reconcile the discovered root set before\n // refreshing, so newly discovered roots are materialized in this same iteration.\n if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {\n await this.discover();\n lastDiscovery = Date.now();\n }\n\n // Change counters before the refresh, so the delta tells us whether this single cycle\n // brought anything new. Works in both stat modes: \"per-request\" resets to 0 above.\n const changesBefore = countedChanges(stat);\n\n // actual tree synchronization\n await this.refresh(stat);\n\n const changed = countedChanges(stat) > changesBefore;\n if (changed) this.changeGenerationCounter++;\n this.updatePollingInterval(changed);\n\n // logging stats if we were asked to\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we got new state\n if (toNotify !== undefined) for (const n of toNotify) n.resolve();\n } catch (e: any) {\n // logging stats if we were asked to (even if error occured)\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we failed to refresh the state\n if (toNotify !== undefined) for (const n of toNotify) n.reject(e);\n\n // catching tree update errors, as they may leave our tree in inconsistent state\n if (e instanceof TreeStateUpdateError) {\n // important error logging, this should never happen\n this.logger?.error(e);\n\n // marking everybody who used previous state as changed\n this.state.invalidateTree(\"stat update error\");\n // creating new tree with the full current root set (re-discovered on next iteration)\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n // The new mirror holds nothing, so the old token would skip everything.\n this.discardDeltaToken(\"tree rebuilt after update error\");\n\n // scheduling state update without delay\n continue;\n\n // unfortunately external observer may still see tree in its default\n // empty state, though this is best we can do in this exceptional\n // situation, and hope on caching layers inside computables to present\n // some stale state until we reconstruct the tree again\n } else this.logger?.warn(e);\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 1: mandatory floor — always wait at least MIN_POLLING_INTERVAL_MS.\n // Not interruptible by scheduleOnNextState; only termination aborts it.\n try {\n await tp.setTimeout(MIN_POLLING_INTERVAL_MS, undefined, {\n signal: this.abortController.signal,\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 2: optional remainder up to pollingInterval — interruptible by\n // scheduleOnNextState so that an external nudge wakes the loop promptly.\n if (this.scheduledOnNextState.length === 0) {\n const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);\n if (remaining > 0) {\n try {\n this.currentLoopDelayInterrupt = new AbortController();\n await tp.setTimeout(remaining, undefined, {\n signal: AbortSignal.any([\n this.abortController.signal,\n this.currentLoopDelayInterrupt.signal,\n ]),\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n // Otherwise it was just the loop delay interrupt (scheduleOnNextState),\n // continue to the next iteration\n } finally {\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n }\n\n // reset only as a very last line\n this.currentLoop = undefined;\n }\n\n /**\n * Dumps the current state of the tree.\n * @returns An array of ExtendedResourceData objects representing the current state of the tree.\n */\n public dumpState(): ExtendedResourceData[] {\n return this.state.dumpState();\n }\n\n /**\n * Terminates the internal loop, and permanently destoys all internal state, so\n * all computables using this state will resolve to errors.\n * */\n public async terminate(): Promise<void> {\n this.keepRunning = false;\n this.terminated = true;\n this.abortController.abort();\n\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n\n this.state.invalidateTree(\"synchronization terminated for the tree\");\n }\n\n /** @deprecated */\n public async awaitSyncLoopTermination(): Promise<void> {\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n }\n\n /**\n * Initializes a synchronized tree from one or more seeds.\n *\n * @param seeds a bare {@link SignedResourceId} (the original single-root contract), a\n * single {@link TreeSeed}, or an array of seeds. Bare ids and explicit-resource seeds\n * become roots immediately; shared-type seeds discover their roots via `ListUserResources`.\n */\n public static async init(\n pl: PlClient,\n seeds: SignedResourceId | TreeSeed | TreeSeed[],\n ops: SynchronizedTreeOps,\n logger?: MiLogger,\n ) {\n const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);\n\n // Seed from the snapshot before the first refresh, so that refresh is the one that\n // transfers only what changed. A refused snapshot leaves an ordinary cold open.\n const restored = ops.restoreFrom !== undefined && tree.restore(ops.restoreFrom);\n\n // Always collected, even when not logging: the initial load's change count is what seeds\n // the change generation, so a holder can tell a populated tree from an untouched one.\n const stat = initialTreeLoadingStat();\n\n let ok = false;\n\n try {\n // resolve shared-type seeds before the first refresh so discovered roots load now\n await tree.discover();\n await tree.refresh(stat, {\n timeout: ops.initialTreeLoadingTimeout,\n });\n ok = true;\n } finally {\n if (countedChanges(stat) > 0) tree.changeGenerationCounter++;\n\n // logging stats if we were asked to (even if error occured)\n if (ops.logStat && logger)\n logger.info(\n `Tree stat (initial load, ${ok ? \"success\" : \"failure\"}, ${\n restored ? \"restored from snapshot\" : \"cold\"\n }): ${JSON.stringify(stat)}`,\n );\n }\n\n return tree;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"synchronized_tree.js","names":[],"sources":["../src/synchronized_tree.ts"],"sourcesContent":["import { PollingComputableHooks } from \"@milaboratories/computable\";\nimport { PlTreeEntry, PlTreeRootsEntry } from \"./accessors\";\nimport type {\n FinalResourceDataPredicate,\n PlClient,\n ResourceSignature,\n ResourceType,\n SignedResourceId,\n TxOps,\n} from \"@milaboratories/pl-client\";\nimport type { Filter } from \"@milaboratories/pl-client\";\nimport {\n isUnauthenticated,\n isTimeoutOrCancelError,\n isUnimplementedError,\n} from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData } from \"./state\";\nimport { PlTreeState, TreeStateUpdateError } from \"./state\";\nimport type {\n PruningFunction,\n TraversalMode,\n TreeLoadingAlgorithmName,\n TreeLoadingStat,\n} from \"./sync\";\nimport {\n constructTreeLoadingRequest,\n initialTreeLoadingStat,\n loadTreeState,\n resolveTreeLoadingAlgorithm,\n supportsResourceTreeTraversal,\n} from \"./sync\";\nimport type { PersistedTree } from \"./persisted_tree\";\nimport { captureTreeState, restoreTreeState } from \"./persisted_tree\";\nimport * as tp from \"node:timers/promises\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\n\n/** Hard floor between consecutive tree-refresh calls.\n * Applies even when {@link scheduleOnNextState} has woken the loop early,\n * preventing tight polling loops during rapid state transitions. */\nconst MIN_POLLING_INTERVAL_MS = 100;\n\n/** Ceiling for the adaptive poll interval. Caps how stale an idle tree can get before the\n * next look, and bounds how far the idle backoff can push the interval out. */\nconst MAX_POLLING_INTERVAL_MS = 5_000;\n\n/** Applied to the interval after a cycle that changed nothing. An idle tree walks its\n * interval out towards {@link MAX_POLLING_INTERVAL_MS} instead of re-polling at full rate;\n * the first cycle that changes anything resets it. */\nconst IDLE_BACKOFF_MULTIPLIER = 1.5;\n\n/** The client's measured RTT becomes an interval floor, scaled by this. Every refresh costs\n * at least one round trip, so polling faster than a small multiple of the RTT only queues\n * round-trips the link cannot service. This is what replaces the fixed interval on a\n * high-latency link; on a fast link the configured `pollingInterval` still dominates. */\nconst RTT_POLL_FACTOR = 2;\n\n/** Ceiling for the RTT-derived floor. The estimate is sampled at connect time and never\n * re-sampled, so without a bound one slow ping pins the cadence high for the whole session.\n * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck\n * rather than slow, and spacing polls further only delays noticing it recovered. */\nconst MAX_RTT_POLL_INTERVAL_MS = 30_000;\n\ntype StatLoggingMode = \"cumulative\" | \"per-request\";\n\nexport type SynchronizedTreeOps = {\n /** Override final predicate from the PlClient */\n finalPredicateOverride?: FinalResourceDataPredicate;\n\n /** Pruning function for legacy fallback path. */\n pruning?: PruningFunction;\n\n /** ResourceTree field filter for modern backend path. */\n fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules for the streaming path.\n * @deprecated the backend ignores these under a change token, and the delta algorithm - what\n * `auto` now picks on a capable backend - never sends them. They still prune a\n * `backend-streaming` walk and a token-less delta poll's fallback. */\n traverseStopRules?: Filter;\n\n /** Interval after last sync to sleep before the next one */\n pollingInterval: number;\n /** For how long to continue polling after the last derived value access */\n stopPollingDelay: number;\n\n /** If one of the values, tree will log stats of each polling request */\n logStat?: StatLoggingMode;\n\n /** Timeout for initial tree loading. If not specified, will use default for RO tx from pl-client. */\n initialTreeLoadingTimeout?: number;\n\n /** Controls which tree-loading path to use. Default `\"auto\"`. */\n traversalMode?: TraversalMode;\n\n /** A previously persisted mirror to seed the tree with, before its first refresh, so that\n * refresh transfers only what changed while the tree was gone.\n *\n * A snapshot that cannot be applied, or does not belong to this tree, is logged and dropped,\n * leaving an ordinary cold open.\n *\n * A snapshot that applies but whose ids are dead is NOT handled here: its resources become\n * this tree's seeds, so the first refresh fails and {@link init} rejects, where a cold open\n * would have succeeded. Establishing that the signatures are still live is the caller's job\n * (see {@link PersistedTree.witness}), as is deciding what to do when the first refresh is\n * refused anyway. Ignored for trees with shared-type seeds, which rediscover their roots. */\n restoreFrom?: PersistedTree;\n};\n\n/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */\nexport type ExplicitRootSeed = { kind: \"resource\"; root: SignedResourceId };\n\n/** Discovers, as roots, every resource of this type shared with the current user.\n * Matched against SharedResource.resourceType by NAME (version optional/ignored). The\n * discovered set is DYNAMIC — roots appear/disappear as grants are added/revoked/expire. */\nexport type SharedTypeSeed = { kind: \"shared\"; resourceType: ResourceType };\n\nexport type TreeSeed = ExplicitRootSeed | SharedTypeSeed;\n\n/** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare\n * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so\n * every existing single-root caller is unchanged. */\nfunction normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSeed[] {\n if (Array.isArray(seeds)) return seeds;\n if (typeof seeds === \"object\" && seeds !== null && \"kind\" in seeds) return [seeds];\n // bare SignedResourceId\n return [{ kind: \"resource\", root: seeds }];\n}\n\n/** How often a tree with shared-type seeds re-polls ListUserResources to reconcile its\n * discovered roots.\n *\n * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental\n * refresh, so it must NOT run on every refresh tick. This is a wall-clock interval rather\n * than a count of iterations: the refresh cadence is adaptive (it stretches towards\n * {@link MAX_POLLING_INTERVAL_MS} on an idle tree and scales with RTT on a slow link),\n * so a fixed iteration count would let discovery latency drift out with it. Keeping it in\n * milliseconds pins the latency of noticing a new/removed share regardless of cadence, which\n * is what a human-driven share flow cares about.\n *\n * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root\n * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */\nconst DISCOVERY_INTERVAL_MS = 3_000;\n\n/** Counters that mean \"this cycle brought something new\". Deliberately not the full change\n * breakdown: those fields are sub-counts of `resourcesChanged` and would double-count.\n * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged\n * state is exactly the idle case the backoff exists for. */\nfunction countedChanges(stat: TreeLoadingStat): number {\n // `fieldsRemoved` is included despite being a per-field count, because it is the one change\n // that never shows up in `resourcesChanged`: the removed-dynamic-field branch in\n // `updateFromResourceData` does not set its `changed` flag, so a cycle that only dropped a\n // field (and garbage-collected whatever it pointed at) otherwise reads as an idle cycle.\n // That double-counts a resource that both changed and lost a field, which is harmless here:\n // every caller compares this against an earlier value rather than reading it as a total.\n return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.fieldsRemoved;\n}\n\n/** The poll-cadence policy, as a pure function of the last cycle's outcome.\n *\n * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no\n * link can be polled faster than configured. `rttMs` raises that bound on a slow link.\n * `currentMs` is the interval in force for the cycle that just finished, which is what the\n * idle backoff compounds on. */\nexport function derivePollingInterval(opts: {\n configuredMs: number;\n currentMs: number;\n rttMs: number | undefined;\n changed: boolean;\n}): number {\n const { configuredMs, currentMs, rttMs, changed } = opts;\n\n // The cap applies to the RTT-derived part only, so `configuredMs` stays an absolute lower\n // bound even if it is ever set above the cap.\n const floor =\n rttMs === undefined\n ? configuredMs\n : Math.max(\n configuredMs,\n Math.min(MAX_RTT_POLL_INTERVAL_MS, Math.ceil(rttMs * RTT_POLL_FACTOR)),\n );\n\n const next = changed ? floor : Math.max(floor, currentMs * IDLE_BACKOFF_MULTIPLIER);\n\n // The ceiling never cuts below the floor: a link slower than MAX_POLLING_INTERVAL_MS still\n // gets its RTT-derived spacing rather than being forced to re-poll early.\n return Math.max(floor, Math.min(MAX_POLLING_INTERVAL_MS, next));\n}\n\ntype ScheduledRefresh = {\n resolve: () => void;\n reject: (err: any) => void;\n};\n\nexport class SynchronizedTreeState {\n private readonly finalPredicate: FinalResourceDataPredicate;\n private state: PlTreeState;\n private readonly pollingInterval: number;\n private readonly pruning?: PruningFunction;\n private readonly fieldFilter?: Filter;\n private readonly traverseStopRules?: Filter;\n private readonly traversalMode: TraversalMode;\n /** Resolved once from {@link traversalMode} and the server's capabilities, and used by every\n * poll of this tree. Selecting per poll was only sound while no algorithm kept state between\n * polls; pinning it here is what lets one do so. Only ever reassigned by the one-way\n * demotion in {@link loadAndApply} when the backend advertises delta but issues no token. */\n private algorithm: TreeLoadingAlgorithmName;\n /** Change token the last successful delta apply was dated at, handed to the next poll so\n * the backend sends only what moved since. Undefined until the first delta poll commits\n * one, and again whenever {@link discardDeltaToken} drops it. */\n private deltaToken: Uint8Array | undefined;\n private readonly logStat?: StatLoggingMode;\n private readonly hooks: PollingComputableHooks;\n private readonly abortController = new AbortController();\n\n /** Explicit-resource seeds: fixed roots, present from construction. */\n private readonly explicitRoots: SignedResourceId[];\n /** Shared-type seeds (discovered roots), if any. */\n private readonly sharedSeeds: SharedTypeSeed[];\n /** Roots discovered for shared-type seeds on the last discovery poll. */\n private discoveredRoots: SignedResourceId[] = [];\n\n /** Bumped once per refresh cycle that brought something new: a resource appeared, changed,\n * or became final. Lets a holder tell whether the tree has moved since it last persisted\n * it, without diffing state. Read through {@link changeGeneration}. */\n private changeGenerationCounter = 0;\n\n /** Whether a snapshot was actually applied. Read through\n * {@link wasRestoredFromSnapshot}. */\n private restoredFromSnapshot = false;\n\n private constructor(\n private readonly pl: PlClient,\n seeds: TreeSeed[],\n ops: SynchronizedTreeOps,\n private readonly logger?: MiLogger,\n ) {\n const {\n finalPredicateOverride,\n pruning,\n fieldFilter,\n traverseStopRules,\n traversalMode,\n pollingInterval,\n stopPollingDelay,\n logStat,\n } = ops;\n this.pruning = pruning;\n this.fieldFilter = fieldFilter;\n this.traverseStopRules = traverseStopRules;\n this.traversalMode = traversalMode ?? \"auto\";\n this.algorithm = resolveTreeLoadingAlgorithm(\n this.traversalMode,\n pl.serverInfo.capabilities ?? [],\n logger,\n );\n logger?.info(`tree loading algorithm: ${this.algorithm} (traversalMode=${this.traversalMode})`);\n this.pollingInterval = pollingInterval;\n this.effectivePollingInterval = pollingInterval;\n this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;\n this.logStat = logStat;\n\n this.explicitRoots = seeds\n .filter((s): s is ExplicitRootSeed => s.kind === \"resource\")\n .map((s) => s.root);\n this.sharedSeeds = seeds.filter((s): s is SharedTypeSeed => s.kind === \"shared\");\n\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n this.hooks = new PollingComputableHooks(\n () => this.startUpdating(),\n () => this.stopUpdating(),\n { stopDebounce: stopPollingDelay },\n (resolve, reject) => this.scheduleOnNextState(resolve, reject),\n );\n }\n\n /** The current protected root set: explicit roots plus the latest discovered roots. */\n private currentRootSet(): Set<SignedResourceId> {\n return new Set([...this.explicitRoots, ...this.discoveredRoots]);\n }\n\n /** How many refresh cycles brought something new. Only ever increases. Equal values at two\n * points in time mean nothing was added, changed or settled in between, which is what makes\n * a periodic snapshot write skippable on an idle tree. */\n public get changeGeneration(): number {\n return this.changeGenerationCounter;\n }\n\n /** True only if a snapshot was actually applied as this tree's initial state. A snapshot can\n * be supplied and still be refused (wrong roots, or state the update call will not accept),\n * in which case this stays false and the tree started empty like any other. Passing\n * `restoreFrom` is therefore not evidence of a warm start; this is. */\n public get wasRestoredFromSnapshot(): boolean {\n return this.restoredFromSnapshot;\n }\n\n /** Captures the current mirror for persistence.\n *\n * Must be called before {@link terminate}: terminating invalidates the tree, and capturing\n * an invalidated tree is refused rather than silently written. */\n public capture(witness: ResourceSignature): PersistedTree {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return captureTreeState(this.state, witness);\n }\n\n /** Installs a snapshot as this tree's state. Returns false if the snapshot was refused, in\n * which case the tree is left as it was and the open proceeds cold.\n *\n * Only meaningful before the first refresh, which is why it is private and driven from\n * {@link init}: replacing the state of a running tree would strand its observers. */\n private restore(snapshot: PersistedTree): boolean {\n if (this.sharedSeeds.length > 0) {\n this.logger?.warn(\"ignoring tree snapshot: trees with shared-type seeds are not restored\");\n return false;\n }\n\n const roots = this.currentRootSet();\n const snapshotRoots = new Set(snapshot.roots);\n if (snapshotRoots.size !== roots.size || ![...snapshotRoots].every((r) => roots.has(r))) {\n // A snapshot addressed to a different root is a mis-keyed file, not a stale one.\n this.logger?.warn(\"ignoring tree snapshot: its roots are not this tree's roots\");\n return false;\n }\n\n const restored = restoreTreeState(snapshot, this.finalPredicate, {\n roots,\n logger: this.logger,\n });\n if (restored === undefined) return false;\n\n this.state = restored;\n this.restoredFromSnapshot = true;\n return true;\n }\n\n /** Resolves the single root for the backward-compatible single-root accessors, throwing\n * if the tree does not have exactly one root (guards legacy callers against multi-root). */\n private soleRoot(): SignedResourceId {\n const roots = this.currentRootSet();\n if (roots.size !== 1)\n throw new Error(\n `single-root accessor used on a tree with ${roots.size} roots; use rootsEntry() instead`,\n );\n return roots.values().next().value!;\n }\n\n /** @deprecated use \"entry\" instead */\n public accessor(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return this.entry(rid);\n }\n\n /** Backward-compatible single-root entry. With no `rid` it returns the sole root's entry\n * and THROWS if the tree has zero or more than one root. An explicit `rid` addresses any\n * resource in the heap, as today. */\n public entry(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeEntry(\n { treeProvider: () => this.state, hooks: this.hooks },\n rid ?? this.soleRoot(),\n );\n }\n\n /** Reactive provider for the current root SET. Reading it inside a Computable tracks the\n * set as a dependency, so the Computable recomputes when discovered roots appear/disappear. */\n public rootsEntry(): PlTreeRootsEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeRootsEntry({ treeProvider: () => this.state, hooks: this.hooks });\n }\n\n /** Can be used to externally kick off the synchronization polling loop, and\n * await for the first synchronization to happen. */\n public async refreshState(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n await this.hooks.refreshState();\n }\n\n private currentLoopDelayInterrupt: AbortController | undefined = undefined;\n private scheduledOnNextState: ScheduledRefresh[] = [];\n\n /** Interval actually used for the current wait. Starts at the configured `pollingInterval`\n * and is re-derived after every cycle by {@link updatePollingInterval}. */\n private effectivePollingInterval: number;\n\n /** Re-derives {@link effectivePollingInterval} after a cycle.\n *\n * Two independent effects. The floor scales with the client's measured RTT, so a\n * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle\n * that changed nothing multiplies the interval out towards\n * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so\n * an active tree stays responsive. */\n private updatePollingInterval(changed: boolean): void {\n this.effectivePollingInterval = derivePollingInterval({\n configuredMs: this.pollingInterval,\n currentMs: this.effectivePollingInterval,\n rttMs: this.pl.rttEstimateMs,\n changed,\n });\n }\n\n /** Called from computable hooks when external observer asks for state refresh */\n private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {\n if (this.terminated) reject(new Error(\"tree synchronization is terminated\"));\n else {\n this.scheduledOnNextState.push({ resolve, reject });\n // Someone is waiting on fresh state, so this tree is not idle after all: drop any\n // accumulated backoff, otherwise the cycles right after a nudge stay slow. Routed\n // through the policy rather than assigning the configured value directly, so the RTT\n // floor survives the reset. Assigning it raw would poll a high-latency link faster than\n // it can answer, and the interval would stay there until the next cycle that completes:\n // the error path never reaches updatePollingInterval, so a failing nudged refresh would\n // keep retrying at the un-floored rate.\n this.updatePollingInterval(true);\n if (this.currentLoopDelayInterrupt) {\n this.currentLoopDelayInterrupt.abort();\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n\n /** Called from observer */\n private startUpdating(): void {\n if (this.terminated) return;\n this.keepRunning = true;\n if (this.currentLoop === undefined) this.currentLoop = this.mainLoop();\n }\n\n /** Called from observer */\n private stopUpdating(): void {\n this.keepRunning = false;\n }\n\n /** If true, main loop will continue polling pl state. */\n private keepRunning = false;\n /** Actual state of main loop. */\n private currentLoop: Promise<void> | undefined = undefined;\n\n /** Executed from the main loop, and initialization procedure. */\n private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n try {\n await this.loadAndApply(stats, txOps);\n } catch (e) {\n // Discovery-tree self-heal: a discovered root whose grant was revoked/expired fails the whole\n // ResourceTree poll with Unauthenticated. Re-discover (drops dead roots) and retry once. This is\n // self-discriminating — a genuinely dead session also fails discover()'s own call, so real auth\n // loss still propagates. Only for discovery trees; explicit-root trees propagate as-is.\n if (this.sharedSeeds.length > 0 && isUnauthenticated(e)) {\n this.logger?.warn(\n \"discovery tree: Unauthenticated on ResourceTree (likely revoked/expired root); re-discovering and retrying\",\n );\n await this.discover();\n await this.loadAndApply(stats, txOps);\n } else throw e;\n }\n }\n\n private async loadAndApply(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n const request = constructTreeLoadingRequest(this.state, {\n pruningFunction: this.pruning,\n fieldFilter: this.fieldFilter,\n traverseStopRules: this.traverseStopRules,\n changedSinceToken: this.deltaToken,\n });\n // A shared-type-seed tree with no currently-discovered roots is legitimately empty:\n // there is nothing to traverse, and tx.resourceTree([]) would throw \"at least one seed\n // must be provided\". Skip the backend load and leave the (empty) state as-is — discovery\n // adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees\n // never hit this (their root set is non-empty by construction).\n if (request.seedResources.length === 0 && request.finalResources.size === 0) return;\n const { data, nextToken } = await this.pl.withReadTx(\n \"ReadingTree\",\n async (tx) => {\n // Started, not awaited, before the walk. The token dates the transaction rather than\n // the response, so it is not an input to the request - the request carries the\n // PREVIOUS poll's token. Awaiting it here would block on the tx-open response before\n // sending the tree request, costing a whole round trip that streaming does not pay,\n // because requests pipeline on one bidi stream and withReadTx does not await the open.\n const tokenPromise =\n this.algorithm === \"backend-delta\" ? tx.getNextSinceToken() : undefined;\n const data = await loadTreeState(\n tx,\n request,\n stats,\n this.pl.serverInfo.capabilities ?? [],\n this.algorithm,\n this.logger,\n );\n return { data, nextToken: await tokenPromise };\n },\n txOps,\n );\n this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });\n\n // Only with the whole batch applied: advancing past a partial apply loses the dropped\n // resources for good. A throw above leaves the old token, so the next poll re-reads it.\n if (nextToken !== undefined) this.deltaToken = nextToken;\n else if (this.algorithm === \"backend-delta\") this.demoteFromDelta();\n }\n\n /** Give up on delta for the life of this tree, once, when the backend advertises\n * `treeChangedSince:v2` but hands out no token.\n *\n * Without this the tree stays on delta with `deltaToken` permanently unset, and every poll\n * is then a token-less delta poll: a full tree read that also sends no stop rules, so it\n * transfers the subtrees the streaming path prunes away. Nothing else detects it -\n * `deltaSuspectedFullAnswers` only fires when a token WAS sent - so it would run at the\n * poll interval, forever, silently. Streaming is the correct destination: it is what `auto`\n * would have picked without the capability, and it restores the stop rules. */\n private demoteFromDelta() {\n this.algorithm = supportsResourceTreeTraversal(this.pl.serverInfo.capabilities ?? [])\n ? \"backend-streaming\"\n : \"client-bfs\";\n this.logger?.warn(\n `tree: backend advertises treeChangedSince:v2 but issued no change token; ` +\n `falling back to ${this.algorithm} for the life of this tree`,\n );\n }\n\n /** Discards the change token, so the next delta poll asks for the full tree. Required\n * whenever the mirror stops being a faithful record of what the token says we hold: a\n * rebuild after {@link TreeStateUpdateError}, or a root-set change, which reshapes the\n * traversal the token was earned under. */\n private discardDeltaToken(reason: string) {\n if (this.deltaToken === undefined) return;\n this.deltaToken = undefined;\n this.logger?.info(`tree delta token discarded (${reason}); next poll reads the full tree`);\n }\n\n /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and\n * reconciles the discovered root set against the heap. A longer poll result adds roots; a\n * shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade\n * to collection via the ordinary refcount GC ({@link PlTreeState.setRoots}). No-op when the\n * tree has no shared-type seeds, or silently no-op on a REST client where `ListUserResources`\n * is unavailable. */\n private async discover(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n if (this.sharedSeeds.length === 0) return;\n\n const discovered = new Set<SignedResourceId>();\n for (const seed of this.sharedSeeds) {\n let ids: SignedResourceId[];\n try {\n // match by name only (permissive; ignores version, so it survives schema bumps)\n ids = await this.pl.userResources.listSharedResourcesByType(seed.resourceType.name);\n } catch (e: unknown) {\n if (isUnimplementedError(e)) continue;\n throw e;\n }\n for (const id of ids) discovered.add(id);\n }\n\n const rootsChanged =\n discovered.size !== this.discoveredRoots.length ||\n this.discoveredRoots.some((id) => !discovered.has(id));\n\n this.discoveredRoots = [...discovered];\n this.state.setRoots(this.currentRootSet());\n\n // A root arriving brings a subtree the token would skip as unchanged, and one leaving\n // takes its subtree with it. Either way the token no longer describes what we hold.\n if (rootsChanged) this.discardDeltaToken(\"root set changed\");\n }\n\n /** If true this tree state is permanently terminaed. */\n private terminated = false;\n\n private async mainLoop() {\n // Always collected, even when not logging: the change counters drive the idle backoff\n // below. Counter bumps are cheap next to the round trip they describe.\n let stat = initialTreeLoadingStat();\n\n let lastUpdate = Date.now();\n\n // paces the discovery poll for shared-type seeds; 0 forces discovery on the first pass.\n let lastDiscovery = 0;\n\n while (true) {\n if (!this.keepRunning || this.terminated) break;\n\n // saving those who want to be notified about new state here\n // because those who will be added during the tree retrieval\n // should be notified only on the next round\n let toNotify: ScheduledRefresh[] | undefined = undefined;\n if (this.scheduledOnNextState.length > 0) {\n toNotify = this.scheduledOnNextState;\n this.scheduledOnNextState = [];\n }\n\n try {\n // resetting stats if we were asked to collect non-cumulative stats\n if (this.logStat === \"per-request\") stat = initialTreeLoadingStat();\n\n // discovery sync for shared-type seeds: reconcile the discovered root set before\n // refreshing, so newly discovered roots are materialized in this same iteration.\n if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {\n await this.discover();\n lastDiscovery = Date.now();\n }\n\n // Change counters before the refresh, so the delta tells us whether this single cycle\n // brought anything new. Works in both stat modes: \"per-request\" resets to 0 above.\n const changesBefore = countedChanges(stat);\n\n // actual tree synchronization\n await this.refresh(stat);\n\n const changed = countedChanges(stat) > changesBefore;\n if (changed) this.changeGenerationCounter++;\n this.updatePollingInterval(changed);\n\n // logging stats if we were asked to\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we got new state\n if (toNotify !== undefined) for (const n of toNotify) n.resolve();\n } catch (e: any) {\n // logging stats if we were asked to (even if error occured)\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we failed to refresh the state\n if (toNotify !== undefined) for (const n of toNotify) n.reject(e);\n\n // catching tree update errors, as they may leave our tree in inconsistent state\n if (e instanceof TreeStateUpdateError) {\n // important error logging, this should never happen\n this.logger?.error(e);\n\n // marking everybody who used previous state as changed\n this.state.invalidateTree(\"stat update error\");\n // creating new tree with the full current root set (re-discovered on next iteration)\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n // The new mirror holds nothing, so the old token would skip everything.\n this.discardDeltaToken(\"tree rebuilt after update error\");\n\n // scheduling state update without delay\n continue;\n\n // unfortunately external observer may still see tree in its default\n // empty state, though this is best we can do in this exceptional\n // situation, and hope on caching layers inside computables to present\n // some stale state until we reconstruct the tree again\n } else this.logger?.warn(e);\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 1: mandatory floor — always wait at least MIN_POLLING_INTERVAL_MS.\n // Not interruptible by scheduleOnNextState; only termination aborts it.\n try {\n await tp.setTimeout(MIN_POLLING_INTERVAL_MS, undefined, {\n signal: this.abortController.signal,\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 2: optional remainder up to pollingInterval — interruptible by\n // scheduleOnNextState so that an external nudge wakes the loop promptly.\n if (this.scheduledOnNextState.length === 0) {\n const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);\n if (remaining > 0) {\n try {\n this.currentLoopDelayInterrupt = new AbortController();\n await tp.setTimeout(remaining, undefined, {\n signal: AbortSignal.any([\n this.abortController.signal,\n this.currentLoopDelayInterrupt.signal,\n ]),\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n // Otherwise it was just the loop delay interrupt (scheduleOnNextState),\n // continue to the next iteration\n } finally {\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n }\n\n // reset only as a very last line\n this.currentLoop = undefined;\n }\n\n /**\n * Dumps the current state of the tree.\n * @returns An array of ExtendedResourceData objects representing the current state of the tree.\n */\n public dumpState(): ExtendedResourceData[] {\n return this.state.dumpState();\n }\n\n /**\n * Terminates the internal loop, and permanently destoys all internal state, so\n * all computables using this state will resolve to errors.\n * */\n public async terminate(): Promise<void> {\n this.keepRunning = false;\n this.terminated = true;\n this.abortController.abort();\n\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n\n this.state.invalidateTree(\"synchronization terminated for the tree\");\n }\n\n /** @deprecated */\n public async awaitSyncLoopTermination(): Promise<void> {\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n }\n\n /**\n * Initializes a synchronized tree from one or more seeds.\n *\n * @param seeds a bare {@link SignedResourceId} (the original single-root contract), a\n * single {@link TreeSeed}, or an array of seeds. Bare ids and explicit-resource seeds\n * become roots immediately; shared-type seeds discover their roots via `ListUserResources`.\n */\n public static async init(\n pl: PlClient,\n seeds: SignedResourceId | TreeSeed | TreeSeed[],\n ops: SynchronizedTreeOps,\n logger?: MiLogger,\n ) {\n const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);\n\n // Seed from the snapshot before the first refresh, so that refresh is the one that\n // transfers only what changed. A refused snapshot leaves an ordinary cold open.\n const restored = ops.restoreFrom !== undefined && tree.restore(ops.restoreFrom);\n\n // Always collected, even when not logging: the initial load's change count is what seeds\n // the change generation, so a holder can tell a populated tree from an untouched one.\n const stat = initialTreeLoadingStat();\n\n let ok = false;\n\n try {\n // resolve shared-type seeds before the first refresh so discovered roots load now\n await tree.discover();\n await tree.refresh(stat, {\n timeout: ops.initialTreeLoadingTimeout,\n });\n ok = true;\n } finally {\n if (countedChanges(stat) > 0) tree.changeGenerationCounter++;\n\n // logging stats if we were asked to (even if error occured)\n if (ops.logStat && logger)\n logger.info(\n `Tree stat (initial load, ${ok ? \"success\" : \"failure\"}, ${\n restored ? \"restored from snapshot\" : \"cold\"\n }): ${JSON.stringify(stat)}`,\n );\n }\n\n return tree;\n }\n}\n"],"mappings":";;;;;;;;;;;AAuCA,MAAM,0BAA0B;;;AAIhC,MAAM,0BAA0B;;;;AAKhC,MAAM,0BAA0B;;;;;AAMhC,MAAM,kBAAkB;;;;;AAMxB,MAAM,2BAA2B;;;;AA6DjC,SAAS,eAAe,OAA6D;CACnF,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO;CACjC,IAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,UAAU,OAAO,OAAO,CAAC,KAAK;CAEjF,OAAO,CAAC;EAAE,MAAM;EAAY,MAAM;CAAM,CAAC;AAC3C;;;;;;;;;;;;;;AAeA,MAAM,wBAAwB;;;;;AAM9B,SAAS,eAAe,MAA+B;CAOrD,OAAO,KAAK,eAAe,KAAK,mBAAmB,KAAK,uBAAuB,KAAK;AACtF;;;;;;;AAQA,SAAgB,sBAAsB,MAK3B;CACT,MAAM,EAAE,cAAc,WAAW,OAAO,YAAY;CAIpD,MAAM,QACJ,UAAU,KAAA,IACN,eACA,KAAK,IACH,cACA,KAAK,IAAI,0BAA0B,KAAK,KAAK,QAAQ,eAAe,CAAC,CACvE;CAEN,MAAM,OAAO,UAAU,QAAQ,KAAK,IAAI,OAAO,YAAY,uBAAuB;CAIlF,OAAO,KAAK,IAAI,OAAO,KAAK,IAAI,yBAAyB,IAAI,CAAC;AAChE;AAOA,IAAa,wBAAb,MAAa,sBAAsB;CAsCd;CAGA;CAxCnB;CACA;CACA;CACA;CACA;CACA;CACA;;;;;CAKA;;;;CAIA;CACA;CACA;CACA,kBAAmC,IAAI,gBAAgB;;CAGvD;;CAEA;;CAEA,kBAA8C,CAAC;;;;CAK/C,0BAAkC;;;CAIlC,uBAA+B;CAE/B,YACE,IACA,OACA,KACA,QACA;EAJiB,KAAA,KAAA;EAGA,KAAA,SAAA;EAEjB,MAAM,EACJ,wBACA,SACA,aACA,mBACA,eACA,iBACA,kBACA,YACE;EACJ,KAAK,UAAU;EACf,KAAK,cAAc;EACnB,KAAK,oBAAoB;EACzB,KAAK,gBAAgB,iBAAiB;EACtC,KAAK,YAAY,4BACf,KAAK,eACL,GAAG,WAAW,gBAAgB,CAAC,GAC/B,MACF;EACA,QAAQ,KAAK,2BAA2B,KAAK,UAAU,kBAAkB,KAAK,cAAc,EAAE;EAC9F,KAAK,kBAAkB;EACvB,KAAK,2BAA2B;EAChC,KAAK,iBAAiB,0BAA0B,GAAG;EACnD,KAAK,UAAU;EAEf,KAAK,gBAAgB,MAClB,QAAQ,MAA6B,EAAE,SAAS,UAAU,CAAC,CAC3D,KAAK,MAAM,EAAE,IAAI;EACpB,KAAK,cAAc,MAAM,QAAQ,MAA2B,EAAE,SAAS,QAAQ;EAE/E,KAAK,QAAQ,IAAI,YAAY,KAAK,eAAe,GAAG,KAAK,cAAc;EACvE,KAAK,QAAQ,IAAI,6BACT,KAAK,cAAc,SACnB,KAAK,aAAa,GACxB,EAAE,cAAc,iBAAiB,IAChC,SAAS,WAAW,KAAK,oBAAoB,SAAS,MAAM,CAC/D;CACF;;CAGA,iBAAgD;EAC9C,uBAAO,IAAI,IAAI,CAAC,GAAG,KAAK,eAAe,GAAG,KAAK,eAAe,CAAC;CACjE;;;;CAKA,IAAW,mBAA2B;EACpC,OAAO,KAAK;CACd;;;;;CAMA,IAAW,0BAAmC;EAC5C,OAAO,KAAK;CACd;;;;;CAMA,QAAe,SAA2C;EACxD,IAAI,KAAK,YAAY,MAAM,IAAI,MAAM,oCAAoC;EACzE,OAAO,iBAAiB,KAAK,OAAO,OAAO;CAC7C;;;;;;CAOA,QAAgB,UAAkC;EAChD,IAAI,KAAK,YAAY,SAAS,GAAG;GAC/B,KAAK,QAAQ,KAAK,uEAAuE;GACzF,OAAO;EACT;EAEA,MAAM,QAAQ,KAAK,eAAe;EAClC,MAAM,gBAAgB,IAAI,IAAI,SAAS,KAAK;EAC5C,IAAI,cAAc,SAAS,MAAM,QAAQ,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,OAAO,MAAM,MAAM,IAAI,CAAC,CAAC,GAAG;GAEvF,KAAK,QAAQ,KAAK,6DAA6D;GAC/E,OAAO;EACT;EAEA,MAAM,WAAW,iBAAiB,UAAU,KAAK,gBAAgB;GAC/D;GACA,QAAQ,KAAK;EACf,CAAC;EACD,IAAI,aAAa,KAAA,GAAW,OAAO;EAEnC,KAAK,QAAQ;EACb,KAAK,uBAAuB;EAC5B,OAAO;CACT;;;CAIA,WAAqC;EACnC,MAAM,QAAQ,KAAK,eAAe;EAClC,IAAI,MAAM,SAAS,GACjB,MAAM,IAAI,MACR,4CAA4C,MAAM,KAAK,iCACzD;EACF,OAAO,MAAM,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC;CAC/B;;CAGA,SAAgB,KAAqC;EACnD,IAAI,KAAK,YAAY,MAAM,IAAI,MAAM,oCAAoC;EACzE,OAAO,KAAK,MAAM,GAAG;CACvB;;;;CAKA,MAAa,KAAqC;EAChD,IAAI,KAAK,YAAY,MAAM,IAAI,MAAM,oCAAoC;EACzE,OAAO,IAAI,YACT;GAAE,oBAAoB,KAAK;GAAO,OAAO,KAAK;EAAM,GACpD,OAAO,KAAK,SAAS,CACvB;CACF;;;CAIA,aAAsC;EACpC,IAAI,KAAK,YAAY,MAAM,IAAI,MAAM,oCAAoC;EACzE,OAAO,IAAI,iBAAiB;GAAE,oBAAoB,KAAK;GAAO,OAAO,KAAK;EAAM,CAAC;CACnF;;;CAIA,MAAa,eAA8B;EACzC,IAAI,KAAK,YAAY,MAAM,IAAI,MAAM,oCAAoC;EACzE,MAAM,KAAK,MAAM,aAAa;CAChC;CAEA,4BAAiE,KAAA;CACjE,uBAAmD,CAAC;;;CAIpD;;;;;;;;CASA,sBAA8B,SAAwB;EACpD,KAAK,2BAA2B,sBAAsB;GACpD,cAAc,KAAK;GACnB,WAAW,KAAK;GAChB,OAAO,KAAK,GAAG;GACf;EACF,CAAC;CACH;;CAGA,oBAA4B,SAAqB,QAAkC;EACjF,IAAI,KAAK,YAAY,uBAAO,IAAI,MAAM,oCAAoC,CAAC;OACtE;GACH,KAAK,qBAAqB,KAAK;IAAE;IAAS;GAAO,CAAC;GAQlD,KAAK,sBAAsB,IAAI;GAC/B,IAAI,KAAK,2BAA2B;IAClC,KAAK,0BAA0B,MAAM;IACrC,KAAK,4BAA4B,KAAA;GACnC;EACF;CACF;;CAGA,gBAA8B;EAC5B,IAAI,KAAK,YAAY;EACrB,KAAK,cAAc;EACnB,IAAI,KAAK,gBAAgB,KAAA,GAAW,KAAK,cAAc,KAAK,SAAS;CACvE;;CAGA,eAA6B;EAC3B,KAAK,cAAc;CACrB;;CAGA,cAAsB;;CAEtB,cAAiD,KAAA;;CAGjD,MAAc,QAAQ,OAAyB,OAA8B;EAC3E,IAAI,KAAK,YAAY,MAAM,IAAI,MAAM,oCAAoC;EACzE,IAAI;GACF,MAAM,KAAK,aAAa,OAAO,KAAK;EACtC,SAAS,GAAG;GAKV,IAAI,KAAK,YAAY,SAAS,KAAK,kBAAkB,CAAC,GAAG;IACvD,KAAK,QAAQ,KACX,4GACF;IACA,MAAM,KAAK,SAAS;IACpB,MAAM,KAAK,aAAa,OAAO,KAAK;GACtC,OAAO,MAAM;EACf;CACF;CAEA,MAAc,aAAa,OAAyB,OAA8B;EAChF,MAAM,UAAU,4BAA4B,KAAK,OAAO;GACtD,iBAAiB,KAAK;GACtB,aAAa,KAAK;GAClB,mBAAmB,KAAK;GACxB,mBAAmB,KAAK;EAC1B,CAAC;EAMD,IAAI,QAAQ,cAAc,WAAW,KAAK,QAAQ,eAAe,SAAS,GAAG;EAC7E,MAAM,EAAE,MAAM,cAAc,MAAM,KAAK,GAAG,WACxC,eACA,OAAO,OAAO;GAMZ,MAAM,eACJ,KAAK,cAAc,kBAAkB,GAAG,kBAAkB,IAAI,KAAA;GAShE,OAAO;IAAE,MAAA,MARU,cACjB,IACA,SACA,OACA,KAAK,GAAG,WAAW,gBAAgB,CAAC,GACpC,KAAK,WACL,KAAK,MACP;IACe,WAAW,MAAM;GAAa;EAC/C,GACA,KACF;EACA,KAAK,MAAM,uBAAuB,MAAM;GAAE,mBAAmB;GAAM,MAAM;EAAM,CAAC;EAIhF,IAAI,cAAc,KAAA,GAAW,KAAK,aAAa;OAC1C,IAAI,KAAK,cAAc,iBAAiB,KAAK,gBAAgB;CACpE;;;;;;;;;;CAWA,kBAA0B;EACxB,KAAK,YAAY,8BAA8B,KAAK,GAAG,WAAW,gBAAgB,CAAC,CAAC,IAChF,sBACA;EACJ,KAAK,QAAQ,KACX,4FACqB,KAAK,UAAU,2BACtC;CACF;;;;;CAMA,kBAA0B,QAAgB;EACxC,IAAI,KAAK,eAAe,KAAA,GAAW;EACnC,KAAK,aAAa,KAAA;EAClB,KAAK,QAAQ,KAAK,+BAA+B,OAAO,iCAAiC;CAC3F;;;;;;;CAQA,MAAc,WAA0B;EACtC,IAAI,KAAK,YAAY,MAAM,IAAI,MAAM,oCAAoC;EACzE,IAAI,KAAK,YAAY,WAAW,GAAG;EAEnC,MAAM,6BAAa,IAAI,IAAsB;EAC7C,KAAK,MAAM,QAAQ,KAAK,aAAa;GACnC,IAAI;GACJ,IAAI;IAEF,MAAM,MAAM,KAAK,GAAG,cAAc,0BAA0B,KAAK,aAAa,IAAI;GACpF,SAAS,GAAY;IACnB,IAAI,qBAAqB,CAAC,GAAG;IAC7B,MAAM;GACR;GACA,KAAK,MAAM,MAAM,KAAK,WAAW,IAAI,EAAE;EACzC;EAEA,MAAM,eACJ,WAAW,SAAS,KAAK,gBAAgB,UACzC,KAAK,gBAAgB,MAAM,OAAO,CAAC,WAAW,IAAI,EAAE,CAAC;EAEvD,KAAK,kBAAkB,CAAC,GAAG,UAAU;EACrC,KAAK,MAAM,SAAS,KAAK,eAAe,CAAC;EAIzC,IAAI,cAAc,KAAK,kBAAkB,kBAAkB;CAC7D;;CAGA,aAAqB;CAErB,MAAc,WAAW;EAGvB,IAAI,OAAO,uBAAuB;EAElC,IAAI,aAAa,KAAK,IAAI;EAG1B,IAAI,gBAAgB;EAEpB,OAAO,MAAM;GACX,IAAI,CAAC,KAAK,eAAe,KAAK,YAAY;GAK1C,IAAI,WAA2C,KAAA;GAC/C,IAAI,KAAK,qBAAqB,SAAS,GAAG;IACxC,WAAW,KAAK;IAChB,KAAK,uBAAuB,CAAC;GAC/B;GAEA,IAAI;IAEF,IAAI,KAAK,YAAY,eAAe,OAAO,uBAAuB;IAIlE,IAAI,KAAK,YAAY,SAAS,KAAK,KAAK,IAAI,IAAI,iBAAiB,uBAAuB;KACtF,MAAM,KAAK,SAAS;KACpB,gBAAgB,KAAK,IAAI;IAC3B;IAIA,MAAM,gBAAgB,eAAe,IAAI;IAGzC,MAAM,KAAK,QAAQ,IAAI;IAEvB,MAAM,UAAU,eAAe,IAAI,IAAI;IACvC,IAAI,SAAS,KAAK;IAClB,KAAK,sBAAsB,OAAO;IAGlC,IAAI,KAAK,WAAW,KAAK,QACvB,KAAK,OAAO,KACV,6BAA6B,KAAK,IAAI,IAAI,WAAW,OAAO,KAAK,UAAU,IAAI,GACjF;IACF,aAAa,KAAK,IAAI;IAGtB,IAAI,aAAa,KAAA,GAAW,KAAK,MAAM,KAAK,UAAU,EAAE,QAAQ;GAClE,SAAS,GAAQ;IAEf,IAAI,KAAK,WAAW,KAAK,QACvB,KAAK,OAAO,KACV,2BAA2B,KAAK,IAAI,IAAI,WAAW,OAAO,KAAK,UAAU,IAAI,GAC/E;IACF,aAAa,KAAK,IAAI;IAGtB,IAAI,aAAa,KAAA,GAAW,KAAK,MAAM,KAAK,UAAU,EAAE,OAAO,CAAC;IAGhE,IAAI,aAAa,sBAAsB;KAErC,KAAK,QAAQ,MAAM,CAAC;KAGpB,KAAK,MAAM,eAAe,mBAAmB;KAE7C,KAAK,QAAQ,IAAI,YAAY,KAAK,eAAe,GAAG,KAAK,cAAc;KAEvE,KAAK,kBAAkB,iCAAiC;KAGxD;IAMF,OAAO,KAAK,QAAQ,KAAK,CAAC;GAC5B;GAEA,IAAI,CAAC,KAAK,eAAe,KAAK,YAAY;GAI1C,IAAI;IACF,MAAM,GAAG,WAAW,yBAAyB,KAAA,GAAW,EACtD,QAAQ,KAAK,gBAAgB,OAC/B,CAAC;GACH,SAAS,GAAY;IACnB,IAAI,CAAC,uBAAuB,CAAC,GAAG,MAAM,IAAI,MAAM,oBAAoB,EAAE,OAAO,EAAE,CAAC;IAChF,IAAI,KAAK,gBAAgB,OAAO,SAAS;GAC3C;GAEA,IAAI,CAAC,KAAK,eAAe,KAAK,YAAY;GAI1C,IAAI,KAAK,qBAAqB,WAAW,GAAG;IAC1C,MAAM,YAAY,KAAK,IAAI,GAAG,KAAK,2BAA2B,uBAAuB;IACrF,IAAI,YAAY,GACd,IAAI;KACF,KAAK,4BAA4B,IAAI,gBAAgB;KACrD,MAAM,GAAG,WAAW,WAAW,KAAA,GAAW,EACxC,QAAQ,YAAY,IAAI,CACtB,KAAK,gBAAgB,QACrB,KAAK,0BAA0B,MACjC,CAAC,EACH,CAAC;IACH,SAAS,GAAY;KACnB,IAAI,CAAC,uBAAuB,CAAC,GAAG,MAAM,IAAI,MAAM,oBAAoB,EAAE,OAAO,EAAE,CAAC;KAChF,IAAI,KAAK,gBAAgB,OAAO,SAAS;IAG3C,UAAU;KACR,KAAK,4BAA4B,KAAA;IACnC;GAEJ;EACF;EAGA,KAAK,cAAc,KAAA;CACrB;;;;;CAMA,YAA2C;EACzC,OAAO,KAAK,MAAM,UAAU;CAC9B;;;;;CAMA,MAAa,YAA2B;EACtC,KAAK,cAAc;EACnB,KAAK,aAAa;EAClB,KAAK,gBAAgB,MAAM;EAE3B,IAAI,KAAK,gBAAgB,KAAA,GAAW;EACpC,MAAM,KAAK;EAEX,KAAK,MAAM,eAAe,yCAAyC;CACrE;;CAGA,MAAa,2BAA0C;EACrD,IAAI,KAAK,gBAAgB,KAAA,GAAW;EACpC,MAAM,KAAK;CACb;;;;;;;;CASA,aAAoB,KAClB,IACA,OACA,KACA,QACA;EACA,MAAM,OAAO,IAAI,sBAAsB,IAAI,eAAe,KAAK,GAAG,KAAK,MAAM;EAI7E,MAAM,WAAW,IAAI,gBAAgB,KAAA,KAAa,KAAK,QAAQ,IAAI,WAAW;EAI9E,MAAM,OAAO,uBAAuB;EAEpC,IAAI,KAAK;EAET,IAAI;GAEF,MAAM,KAAK,SAAS;GACpB,MAAM,KAAK,QAAQ,MAAM,EACvB,SAAS,IAAI,0BACf,CAAC;GACD,KAAK;EACP,UAAU;GACR,IAAI,eAAe,IAAI,IAAI,GAAG,KAAK;GAGnC,IAAI,IAAI,WAAW,QACjB,OAAO,KACL,4BAA4B,KAAK,YAAY,UAAU,IACrD,WAAW,2BAA2B,OACvC,KAAK,KAAK,UAAU,IAAI,GAC3B;EACJ;EAEA,OAAO;CACT;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@milaboratories/pl-tree",
|
|
3
|
-
"version": "1.15.
|
|
3
|
+
"version": "1.15.6",
|
|
4
4
|
"description": "Reactive pl tree state",
|
|
5
5
|
"files": [
|
|
6
6
|
"./dist/**/*",
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
"denque": "^2.1.0",
|
|
22
22
|
"utility-types": "^3.11.0",
|
|
23
23
|
"zod": "~3.25.76",
|
|
24
|
-
"@milaboratories/pl-client": "3.17.3",
|
|
25
24
|
"@milaboratories/computable": "2.9.8",
|
|
26
|
-
"@milaboratories/pl-
|
|
25
|
+
"@milaboratories/pl-client": "3.17.5",
|
|
26
|
+
"@milaboratories/pl-errors": "1.4.46",
|
|
27
27
|
"@milaboratories/ts-helpers": "1.8.6"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"@vitest/coverage-istanbul": "^4.1.3",
|
|
32
32
|
"typescript": "7.0.2",
|
|
33
33
|
"vitest": "^4.1.3",
|
|
34
|
-
"@milaboratories/ts-configs": "1.4.0",
|
|
35
34
|
"@milaboratories/build-configs": "2.0.2",
|
|
35
|
+
"@milaboratories/ts-configs": "1.4.0",
|
|
36
36
|
"@milaboratories/ts-builder": "1.7.2"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
@@ -84,8 +84,8 @@ test("every algorithm converges on the same mirror at every step", async () => {
|
|
|
84
84
|
await TestHelpers.withTempRoot(async (pl) => {
|
|
85
85
|
const caps = pl.serverInfo.capabilities ?? [];
|
|
86
86
|
const modes: TraversalMode[] = ["client-bfs", "backend-streaming"];
|
|
87
|
-
if (hasCapability(caps, "treeChangedSince:
|
|
88
|
-
else console.warn("SKIPPING backend-delta: backend lacks treeChangedSince:
|
|
87
|
+
if (hasCapability(caps, "treeChangedSince:v2")) modes.push("backend-delta");
|
|
88
|
+
else console.warn("SKIPPING backend-delta: backend lacks treeChangedSince:v2");
|
|
89
89
|
|
|
90
90
|
// A root with two children, deep enough that a change can hide under an unchanged parent.
|
|
91
91
|
const seed = await pl.withWriteTx(
|
|
@@ -18,7 +18,7 @@ import type { ExtendedResourceData } from "./state";
|
|
|
18
18
|
*
|
|
19
19
|
* PL_TREE_BENCH=1 pnpm exec vitest run src/delta_benchmark.test.ts
|
|
20
20
|
*
|
|
21
|
-
* Delta arms report as skipped on a backend without `treeChangedSince:
|
|
21
|
+
* Delta arms report as skipped on a backend without `treeChangedSince:v2`, rather than
|
|
22
22
|
* silently measuring the fallback.
|
|
23
23
|
*/
|
|
24
24
|
|
|
@@ -125,7 +125,7 @@ async function runArm(
|
|
|
125
125
|
seed: { root: SignedResourceId; leaves: SignedResourceId[] },
|
|
126
126
|
): Promise<Row | undefined> {
|
|
127
127
|
const caps = pl.serverInfo.capabilities ?? [];
|
|
128
|
-
if (arm.mode === "backend-delta" && !hasCapability(caps, "treeChangedSince:
|
|
128
|
+
if (arm.mode === "backend-delta" && !hasCapability(caps, "treeChangedSince:v2")) return undefined;
|
|
129
129
|
|
|
130
130
|
// Scalar, not an array: the constructor takes SignedResourceId | Set<SignedResourceId>.
|
|
131
131
|
const state = new PlTreeState(seed.root, DefaultFinalResourceDataPredicate);
|
|
@@ -208,7 +208,7 @@ function report(rows: Row[], skipped: string[], failed: string[] = []) {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
if (skipped.length > 0) {
|
|
211
|
-
lines.push("", `skipped (backend lacks treeChangedSince:
|
|
211
|
+
lines.push("", `skipped (backend lacks treeChangedSince:v2): ${skipped.join(", ")}`);
|
|
212
212
|
}
|
|
213
213
|
if (failed.length > 0) {
|
|
214
214
|
lines.push("", "FAILED ARMS:");
|
|
@@ -18,7 +18,7 @@ import tp from "timers/promises";
|
|
|
18
18
|
* Delta polling against a real backend, for correctness only. Cost (bytes and round trips per
|
|
19
19
|
* poll) is the benchmark's job, since stats are loop-internal with no public accessor.
|
|
20
20
|
*
|
|
21
|
-
* Everything here needs `treeChangedSince:
|
|
21
|
+
* Everything here needs `treeChangedSince:v2`, which
|
|
22
22
|
* only a backend built from pl PR #2163 advertises, so each test skips rather than fails on
|
|
23
23
|
* one without it. The unit coverage in `delta_sync.test.ts` pins the client's own logic; what
|
|
24
24
|
* cannot be faked, and is what these are for, is the backend's own emission rule.
|
|
@@ -29,8 +29,8 @@ import tp from "timers/promises";
|
|
|
29
29
|
* suite reported four green ticks over zero delta code. Skipped and passed are different
|
|
30
30
|
* signals and a run summary has to be able to tell them apart. */
|
|
31
31
|
function skipUnlessDelta(pl: PlClient, ctx: { skip: (note?: string) => void }): boolean {
|
|
32
|
-
if (hasCapability(pl.serverInfo.capabilities ?? [], "treeChangedSince:
|
|
33
|
-
ctx.skip("backend does not advertise treeChangedSince:
|
|
32
|
+
if (hasCapability(pl.serverInfo.capabilities ?? [], "treeChangedSince:v2")) return true;
|
|
33
|
+
ctx.skip("backend does not advertise treeChangedSince:v2 (no released backend does yet)");
|
|
34
34
|
return false;
|
|
35
35
|
}
|
|
36
36
|
|
package/src/sync.test.ts
CHANGED
|
@@ -584,17 +584,17 @@ test("orphan-invariant-preserved: error referent streamed alongside stop marker"
|
|
|
584
584
|
//
|
|
585
585
|
|
|
586
586
|
test("auto prefers delta, then streaming, then BFS", () => {
|
|
587
|
-
expect(resolveTreeLoadingAlgorithm("auto", ["treeChangedSince:
|
|
587
|
+
expect(resolveTreeLoadingAlgorithm("auto", ["treeChangedSince:v2", "treeFilter:v2"])).toBe(
|
|
588
588
|
"backend-delta",
|
|
589
589
|
);
|
|
590
590
|
// Delta needs no treeFilter:v2 of its own.
|
|
591
|
-
expect(resolveTreeLoadingAlgorithm("auto", ["treeChangedSince:
|
|
591
|
+
expect(resolveTreeLoadingAlgorithm("auto", ["treeChangedSince:v2"])).toBe("backend-delta");
|
|
592
592
|
expect(resolveTreeLoadingAlgorithm("auto", ["treeFilter:v2"])).toBe("backend-streaming");
|
|
593
593
|
expect(resolveTreeLoadingAlgorithm("auto", [])).toBe("client-bfs");
|
|
594
594
|
});
|
|
595
595
|
|
|
596
596
|
test("an explicit mode is honoured over what auto would pick", () => {
|
|
597
|
-
const capable = ["treeChangedSince:
|
|
597
|
+
const capable = ["treeChangedSince:v2", "treeFilter:v2"];
|
|
598
598
|
expect(resolveTreeLoadingAlgorithm("client-bfs", capable)).toBe("client-bfs");
|
|
599
599
|
expect(resolveTreeLoadingAlgorithm("backend-streaming", capable)).toBe("backend-streaming");
|
|
600
600
|
expect(resolveTreeLoadingAlgorithm("backend-delta", capable)).toBe("backend-delta");
|
|
@@ -610,7 +610,7 @@ test("backend-delta degrades to the best available path, with a warning, never a
|
|
|
610
610
|
expect(resolveTreeLoadingAlgorithm("backend-delta", [], logger)).toBe("client-bfs");
|
|
611
611
|
|
|
612
612
|
expect(warnings).toHaveLength(2);
|
|
613
|
-
for (const w of warnings) expect(w).toContain("treeChangedSince:
|
|
613
|
+
for (const w of warnings) expect(w).toContain("treeChangedSince:v2");
|
|
614
614
|
});
|
|
615
615
|
|
|
616
616
|
test("loadTreeState routes into the delta path and passes the token through", async () => {
|
|
@@ -635,7 +635,7 @@ test("loadTreeState routes into the delta path and passes the token through", as
|
|
|
635
635
|
const stat = initialTreeLoadingStat();
|
|
636
636
|
// Via the mode, not by calling loadDeltaTreeState directly: this is the only test that
|
|
637
637
|
// proves the dispatch in loadTreeState reaches delta at all.
|
|
638
|
-
await loadTreeState(tx, request, stat, ["treeChangedSince:
|
|
638
|
+
await loadTreeState(tx, request, stat, ["treeChangedSince:v2"], "auto", { warn: () => {} });
|
|
639
639
|
|
|
640
640
|
expect(received.seeds).toEqual(["NG:0x1"]);
|
|
641
641
|
expect(received.token).toEqual(new Uint8Array([9]));
|
package/src/sync.ts
CHANGED
|
@@ -50,13 +50,13 @@ export interface TreeLoadingRequest {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** Controls which tree-loading path is used.
|
|
53
|
-
* - `"auto"` (default): use delta polling when the backend advertises `treeChangedSince:
|
|
53
|
+
* - `"auto"` (default): use delta polling when the backend advertises `treeChangedSince:v2`,
|
|
54
54
|
* else backend streaming when it advertises `treeFilter:v2`, else client-side BFS.
|
|
55
55
|
* - `"client-bfs"`: always use client-side BFS, even on capable backends.
|
|
56
56
|
* - `"backend-streaming"`: always prefer backend streaming; if the capability is absent,
|
|
57
57
|
* logs a warning and falls back to BFS (never throws).
|
|
58
58
|
* - `"backend-delta"`: always prefer delta polling, which hands the backend the transaction's
|
|
59
|
-
* change token and takes only what changed since it; if `treeChangedSince:
|
|
59
|
+
* change token and takes only what changed since it; if `treeChangedSince:v2` is absent,
|
|
60
60
|
* logs a warning and falls back to the best available path (never throws).
|
|
61
61
|
*/
|
|
62
62
|
export type TraversalMode = "auto" | "client-bfs" | "backend-streaming" | "backend-delta";
|
|
@@ -81,7 +81,7 @@ export function resolveTreeLoadingAlgorithm(
|
|
|
81
81
|
case "backend-delta":
|
|
82
82
|
if (delta) return "backend-delta";
|
|
83
83
|
(logger ?? console).warn(
|
|
84
|
-
"traversalMode=backend-delta but backend lacks treeChangedSince:
|
|
84
|
+
"traversalMode=backend-delta but backend lacks treeChangedSince:v2 capability; falling back to " +
|
|
85
85
|
(streaming ? "backend-streaming" : "client-bfs"),
|
|
86
86
|
);
|
|
87
87
|
return streaming ? "backend-streaming" : "client-bfs";
|
|
@@ -246,7 +246,7 @@ export function supportsResourceTreeTraversal(capabilities: readonly string[] =
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
function supportsTreeDelta(capabilities: readonly string[] = []): boolean {
|
|
249
|
-
return hasCapability(capabilities, "treeChangedSince:
|
|
249
|
+
return hasCapability(capabilities, "treeChangedSince:v2");
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
export function collectStatsForResource(resource: ExtendedResourceData, stats?: TreeLoadingStat) {
|
package/src/synchronized_tree.ts
CHANGED
|
@@ -498,7 +498,7 @@ export class SynchronizedTreeState {
|
|
|
498
498
|
}
|
|
499
499
|
|
|
500
500
|
/** Give up on delta for the life of this tree, once, when the backend advertises
|
|
501
|
-
* `treeChangedSince:
|
|
501
|
+
* `treeChangedSince:v2` but hands out no token.
|
|
502
502
|
*
|
|
503
503
|
* Without this the tree stays on delta with `deltaToken` permanently unset, and every poll
|
|
504
504
|
* is then a token-less delta poll: a full tree read that also sends no stop rules, so it
|
|
@@ -511,7 +511,7 @@ export class SynchronizedTreeState {
|
|
|
511
511
|
? "backend-streaming"
|
|
512
512
|
: "client-bfs";
|
|
513
513
|
this.logger?.warn(
|
|
514
|
-
`tree: backend advertises treeChangedSince:
|
|
514
|
+
`tree: backend advertises treeChangedSince:v2 but issued no change token; ` +
|
|
515
515
|
`falling back to ${this.algorithm} for the life of this tree`,
|
|
516
516
|
);
|
|
517
517
|
}
|