@milaboratories/pl-tree 1.15.6 → 1.16.0

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.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: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":"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"}
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 /** Roots dropped from the mirror because they are absent from the backend. */\n rootsDropped: 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 rootsDropped: 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 // The backend's flag as sent. A stopped traversal (children not streamed) is handled\n // through the fields below, not by claiming the backend marked the resource final.\n final: frame.final,\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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"}
package/dist/sync.d.ts CHANGED
@@ -88,6 +88,8 @@ export type TreeLoadingStat = ResourceUpdateStat & {
88
88
  /** Delta path: extra rounds spent resolving references a delta body pointed at but the
89
89
  * response did not carry. */
90
90
  deltaResolutionRounds: number;
91
+ /** Roots dropped from the mirror because they are absent from the backend. */
92
+ rootsDropped: number;
91
93
  /** Delta path: polls that sent a token and got back a response the size of the whole
92
94
  * mirror, which is what a refused token looks like from here - rejection is silent, so this
93
95
  * is the only tell. Heuristic: a genuinely large change set trips it too. */
@@ -1 +1 @@
1
- {"version":3,"file":"sync.d.ts","names":[],"sources":["../src/sync.ts"],"mappings":";;;;YAeY,mBAAmB,UAAU,yBAAyB;iBAEjD;;;;WAIN,eAAe;;;;WAKf,gBAAgB,IAAI;;WAGpB,kBAAkB;;WAGlB,cAAc;;;WAId,oBAAoB;;;WAIpB,gBAAgB;;;WAIhB,gBAAgB,YAAY;;;WAI5B,oBAAoB;;;;;;;;;;;;YAanB;;;YAIA;;;;wBAKI,4BACd,MAAM,eACN,kCACA;EAAW,OAAO;IACjB;;;wBA6Ba,4BACd,MAAM,aACN,UAAS,KACP,qGAGD;YAwBS,kBAAkB;EAC5B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;EAEA;;EAEA;;;EAGA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;EAGA;;;EAGA;;;;EAIA;;wBAGc,0BAA0B;wBA2C1B,sBAAsB,MAAM;wBAyB5B,8BAA8B;wBAQ9B,wBAAwB,UAAU,sBAAsB,QAAQ;;;;wBA2P1D,cACpB,IAAI,eACJ,gBAAgB,oBAChB,QAAQ,iBACR,kCACA,OAAM,eACN;EAAW,OAAO;EAAsB,QAAQ;IAC/C,QAAQ"}
1
+ {"version":3,"file":"sync.d.ts","names":[],"sources":["../src/sync.ts"],"mappings":";;;;YAeY,mBAAmB,UAAU,yBAAyB;iBAEjD;;;;WAIN,eAAe;;;;WAKf,gBAAgB,IAAI;;WAGpB,kBAAkB;;WAGlB,cAAc;;;WAId,oBAAoB;;;WAIpB,gBAAgB;;;WAIhB,gBAAgB,YAAY;;;WAI5B,oBAAoB;;;;;;;;;;;;YAanB;;;YAIA;;;;wBAKI,4BACd,MAAM,eACN,kCACA;EAAW,OAAO;IACjB;;;wBA6Ba,4BACd,MAAM,aACN,UAAS,KACP,qGAGD;YAwBS,kBAAkB;EAC5B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;EAEA;;EAEA;;;EAGA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;EAGA;;;EAGA;;EAEA;;;;EAIA;;wBAGc,0BAA0B;wBA4C1B,sBAAsB,MAAM;wBAyB5B,8BAA8B;wBAQ9B,wBAAwB,UAAU,sBAAsB,QAAQ;;;;wBA2P1D,cACpB,IAAI,eACJ,gBAAgB,oBAChB,QAAQ,iBACR,kCACA,OAAM,eACN;EAAW,OAAO;EAAsB,QAAQ;IAC/C,QAAQ"}
package/dist/sync.js CHANGED
@@ -71,6 +71,7 @@ function initialTreeLoadingStat() {
71
71
  deltaSeedsSent: 0,
72
72
  deltaResolutionRounds: 0,
73
73
  deltaSuspectedFullAnswers: 0,
74
+ rootsDropped: 0,
74
75
  resourcesNew: 0,
75
76
  resourcesChanged: 0,
76
77
  resourcesUnchanged: 0,
@@ -219,7 +220,7 @@ async function processResourceTreeStream(treeItems, finalResources, pruningFunct
219
220
  resourceReady: frame.resourceReady,
220
221
  error: frame.error,
221
222
  originalResourceId: frame.originalResourceId,
222
- final: frame.final || frame.traverseWasStopped,
223
+ final: frame.final,
223
224
  inputsLocked: frame.inputsLocked,
224
225
  outputsLocked: frame.outputsLocked,
225
226
  fields: frame.fields,
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: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"}
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 /** Roots dropped from the mirror because they are absent from the backend. */\n rootsDropped: 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 rootsDropped: 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 // The backend's flag as sent. A stopped traversal (children not streamed) is handled\n // through the fields below, not by claiming the backend marked the resource final.\n final: frame.final,\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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"}
@@ -5,6 +5,7 @@ const require_sync = require("./sync.cjs");
5
5
  const require_persisted_tree = require("./persisted_tree.cjs");
6
6
  let _milaboratories_pl_client = require("@milaboratories/pl-client");
7
7
  let _milaboratories_computable = require("@milaboratories/computable");
8
+ let _milaboratories_ts_helpers = require("@milaboratories/ts-helpers");
8
9
  let node_timers_promises = require("node:timers/promises");
9
10
  node_timers_promises = require_runtime.__toESM(node_timers_promises, 1);
10
11
  //#region src/synchronized_tree.ts
@@ -29,6 +30,16 @@ const RTT_POLL_FACTOR = 2;
29
30
  * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck
30
31
  * rather than slow, and spacing polls further only delays noticing it recovered. */
31
32
  const MAX_RTT_POLL_INTERVAL_MS = 3e4;
33
+ /** Waits between rebuilds after consecutive {@link TreeStateUpdateError}s. The first retry
34
+ * comes after the ordinary floor, so a one-off inconsistency heals as fast as a normal poll;
35
+ * a persistent one settles at one full read per {@link MAX_POLLING_INTERVAL_MS}. */
36
+ const REBUILD_RETRY = {
37
+ type: "exponentialWithMaxDelayBackoff",
38
+ initialDelay: MIN_POLLING_INTERVAL_MS,
39
+ maxDelay: MAX_POLLING_INTERVAL_MS,
40
+ backoffMultiplier: 2,
41
+ jitter: .2
42
+ };
32
43
  /** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare
33
44
  * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so
34
45
  * every existing single-root caller is unchanged. */
@@ -59,7 +70,7 @@ const DISCOVERY_INTERVAL_MS = 3e3;
59
70
  * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged
60
71
  * state is exactly the idle case the backoff exists for. */
61
72
  function countedChanges(stat) {
62
- return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.fieldsRemoved;
73
+ return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.rootsDropped;
63
74
  }
64
75
  /** The poll-cadence policy, as a pure function of the last cycle's outcome.
65
76
  *
@@ -111,7 +122,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
111
122
  constructor(pl, seeds, ops, logger) {
112
123
  this.pl = pl;
113
124
  this.logger = logger;
114
- const { finalPredicateOverride, pruning, fieldFilter, traverseStopRules, traversalMode, pollingInterval, stopPollingDelay, logStat } = ops;
125
+ const { finalPredicateOverride, pruning, fieldFilter, traverseStopRules, traversalMode, pollingInterval, stopPollingDelay, logStat, rootsNeverFinal } = ops;
115
126
  this.pruning = pruning;
116
127
  this.fieldFilter = fieldFilter;
117
128
  this.traverseStopRules = traverseStopRules;
@@ -120,7 +131,9 @@ var SynchronizedTreeState = class SynchronizedTreeState {
120
131
  logger?.info(`tree loading algorithm: ${this.algorithm} (traversalMode=${this.traversalMode})`);
121
132
  this.pollingInterval = pollingInterval;
122
133
  this.effectivePollingInterval = pollingInterval;
123
- this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;
134
+ const basePredicate = finalPredicateOverride ?? pl.finalPredicate;
135
+ const explicitRoots = new Set(seeds.flatMap((s) => s.kind === "resource" ? [s.root] : []));
136
+ this.finalPredicate = rootsNeverFinal ? (r) => !explicitRoots.has(r.id) && basePredicate(r) : basePredicate;
124
137
  this.logStat = logStat;
125
138
  this.explicitRoots = seeds.filter((s) => s.kind === "resource").map((s) => s.root);
126
139
  this.sharedSeeds = seeds.filter((s) => s.kind === "shared");
@@ -284,17 +297,33 @@ var SynchronizedTreeState = class SynchronizedTreeState {
284
297
  changedSinceToken: this.deltaToken
285
298
  });
286
299
  if (request.seedResources.length === 0 && request.finalResources.size === 0) return;
287
- const { data, nextToken } = await this.pl.withReadTx("ReadingTree", async (tx) => {
300
+ const checkedRoots = this.state.nonFinalRoots();
301
+ const { data, nextToken, rootsExist } = await this.pl.withReadTx("ReadingTree", async (tx) => {
288
302
  const tokenPromise = this.algorithm === "backend-delta" ? tx.getNextSinceToken() : void 0;
303
+ const existence = Promise.all(checkedRoots.map((rid) => tx.resourceExists(rid)));
304
+ existence.catch(() => {});
289
305
  return {
290
306
  data: await require_sync.loadTreeState(tx, request, stats, this.pl.serverInfo.capabilities ?? [], this.algorithm, this.logger),
291
- nextToken: await tokenPromise
307
+ nextToken: await tokenPromise,
308
+ rootsExist: await existence
292
309
  };
293
310
  }, txOps);
294
311
  this.state.updateFromResourceData(data, {
295
312
  allowOrphanInputs: true,
296
313
  stat: stats
297
314
  });
315
+ let gone = checkedRoots.filter((_, i) => !rootsExist[i]);
316
+ let dropped = true;
317
+ while (dropped) {
318
+ dropped = false;
319
+ gone = gone.filter((rid) => {
320
+ if (!this.state.dropDeletedRoot(rid)) return true;
321
+ dropped = true;
322
+ if (stats) stats.rootsDropped++;
323
+ this.logger?.warn(`tree root ${(0, _milaboratories_pl_client.resourceIdToString)(rid)} no longer exists; dropped from the tree`);
324
+ return false;
325
+ });
326
+ }
298
327
  if (nextToken !== void 0) this.deltaToken = nextToken;
299
328
  else if (this.algorithm === "backend-delta") this.demoteFromDelta();
300
329
  }
@@ -351,6 +380,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
351
380
  let stat = require_sync.initialTreeLoadingStat();
352
381
  let lastUpdate = Date.now();
353
382
  let lastDiscovery = 0;
383
+ let rebuildRetry;
354
384
  while (true) {
355
385
  if (!this.keepRunning || this.terminated) break;
356
386
  let toNotify = void 0;
@@ -371,18 +401,22 @@ var SynchronizedTreeState = class SynchronizedTreeState {
371
401
  this.updatePollingInterval(changed);
372
402
  if (this.logStat && this.logger) this.logger.info(`Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`);
373
403
  lastUpdate = Date.now();
404
+ rebuildRetry = void 0;
374
405
  if (toNotify !== void 0) for (const n of toNotify) n.resolve();
375
406
  } catch (e) {
376
407
  if (this.logStat && this.logger) this.logger.info(`Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`);
377
408
  lastUpdate = Date.now();
378
409
  if (toNotify !== void 0) for (const n of toNotify) n.reject(e);
379
410
  if (e instanceof require_state.TreeStateUpdateError) {
380
- this.logger?.error(e);
411
+ rebuildRetry = rebuildRetry === void 0 ? (0, _milaboratories_ts_helpers.createInfiniteRetryState)(REBUILD_RETRY) : (0, _milaboratories_ts_helpers.nextInfiniteRetryState)(rebuildRetry);
412
+ this.logger?.error(new Error(`tree rebuilt after an update error; next read in ${Math.round(rebuildRetry.nextDelay)}ms`, { cause: e }));
381
413
  this.state.invalidateTree("stat update error");
382
414
  this.state = new require_state.PlTreeState(this.currentRootSet(), this.finalPredicate);
383
415
  this.discardDeltaToken("tree rebuilt after update error");
384
- continue;
385
- } else this.logger?.warn(e);
416
+ } else {
417
+ rebuildRetry = void 0;
418
+ this.logger?.warn(e);
419
+ }
386
420
  }
387
421
  if (!this.keepRunning || this.terminated) break;
388
422
  try {
@@ -393,7 +427,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
393
427
  }
394
428
  if (!this.keepRunning || this.terminated) break;
395
429
  if (this.scheduledOnNextState.length === 0) {
396
- const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);
430
+ const remaining = Math.max(0, (rebuildRetry?.nextDelay ?? this.effectivePollingInterval) - MIN_POLLING_INTERVAL_MS);
397
431
  if (remaining > 0) try {
398
432
  this.currentLoopDelayInterrupt = new AbortController();
399
433
  await node_timers_promises.setTimeout(remaining, void 0, { signal: AbortSignal.any([this.abortController.signal, this.currentLoopDelayInterrupt.signal]) });
@@ -422,6 +456,9 @@ var SynchronizedTreeState = class SynchronizedTreeState {
422
456
  this.keepRunning = false;
423
457
  this.terminated = true;
424
458
  this.abortController.abort();
459
+ const pending = this.scheduledOnNextState;
460
+ this.scheduledOnNextState = [];
461
+ for (const n of pending) n.reject(/* @__PURE__ */ new Error("tree synchronization is terminated"));
425
462
  if (this.currentLoop === void 0) return;
426
463
  await this.currentLoop;
427
464
  this.state.invalidateTree("synchronization terminated for the tree");