@milaboratories/pl-tree 1.14.3 → 1.15.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.
@@ -13,7 +13,10 @@ export type SynchronizedTreeOps = {
13
13
  pruning?: PruningFunction;
14
14
  /** ResourceTree field filter for modern backend path. */
15
15
  fieldFilter?: Filter;
16
- /** ResourceTree traversal stop rules for modern backend path. */
16
+ /** ResourceTree traversal stop rules for the streaming path.
17
+ * @deprecated the backend ignores these under a change token, and the delta algorithm - what
18
+ * `auto` now picks on a capable backend - never sends them. They still prune a
19
+ * `backend-streaming` walk and a token-less delta poll's fallback. */
17
20
  traverseStopRules?: Filter;
18
21
  /** Interval after last sync to sleep before the next one */
19
22
  pollingInterval: number;
@@ -73,6 +76,15 @@ export declare class SynchronizedTreeState {
73
76
  private readonly fieldFilter?;
74
77
  private readonly traverseStopRules?;
75
78
  private readonly traversalMode;
79
+ /** Resolved once from {@link traversalMode} and the server's capabilities, and used by every
80
+ * poll of this tree. Selecting per poll was only sound while no algorithm kept state between
81
+ * polls; pinning it here is what lets one do so. Only ever reassigned by the one-way
82
+ * demotion in {@link loadAndApply} when the backend advertises delta but issues no token. */
83
+ private algorithm;
84
+ /** Change token the last successful delta apply was dated at, handed to the next poll so
85
+ * the backend sends only what moved since. Undefined until the first delta poll commits
86
+ * one, and again whenever {@link discardDeltaToken} drops it. */
87
+ private deltaToken;
76
88
  private readonly logStat?;
77
89
  private readonly hooks;
78
90
  private readonly abortController;
@@ -153,6 +165,21 @@ export declare class SynchronizedTreeState {
153
165
  /** Executed from the main loop, and initialization procedure. */
154
166
  private refresh;
155
167
  private loadAndApply;
168
+ /** Give up on delta for the life of this tree, once, when the backend advertises
169
+ * `treeChangedSince:v1` but hands out no token.
170
+ *
171
+ * Without this the tree stays on delta with `deltaToken` permanently unset, and every poll
172
+ * is then a token-less delta poll: a full tree read that also sends no stop rules, so it
173
+ * transfers the subtrees the streaming path prunes away. Nothing else detects it -
174
+ * `deltaSuspectedFullAnswers` only fires when a token WAS sent - so it would run at the
175
+ * poll interval, forever, silently. Streaming is the correct destination: it is what `auto`
176
+ * would have picked without the capability, and it restores the stop rules. */
177
+ private demoteFromDelta;
178
+ /** Discards the change token, so the next delta poll asks for the full tree. Required
179
+ * whenever the mirror stops being a faithful record of what the token says we hold: a
180
+ * rebuild after {@link TreeStateUpdateError}, or a root-set change, which reshapes the
181
+ * traversal the token was earned under. */
182
+ private discardDeltaToken;
156
183
  /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and
157
184
  * reconciles the discovered root set against the heap. A longer poll result adds roots; a
158
185
  * shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade
@@ -1 +1 @@
1
- {"version":3,"file":"synchronized_tree.d.ts","names":[],"sources":["../src/synchronized_tree.ts"],"mappings":";;;;;;;KAmDK;YAEO;;EAEV,yBAAyB;;EAGzB,UAAU;;EAGV,cAAc;;EAGd,oBAAoB;;EAGpB;;EAEA;;EAGA,UAAU;;EAGV;;EAGA,gBAAgB;;;;;;;;;;;;EAahB,cAAc;;;YAIJ;EAAqB;EAAkB,MAAM;;;;;YAK7C;EAAmB;EAAgB,cAAc;;YAEjD,WAAW,mBAAmB;;;;;;;wBA+C1B,sBAAsB;EACpC;EACA;EACA;EACA;;qBA0BW;mBA6BQ;mBAGA;mBA/BF;UACT;mBACS;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;;mBAGA;;mBAEA;;UAET;;;;UAKA;;;UAIA;UAED;;UAwCC;;;;MAOG;;;;;MAQA;;;;;EAQJ,QAAQ,SAAS,oBAAoB;;;;;;UAUpC;;;UA2BA;;EAUD,SAAS,MAAM,mBAAmB;;;;EAQlC,MAAM,MAAM,mBAAmB;;;EAU/B,cAAc;;;EAOR,gBAAgB;UAKrB;UACA;;;UAIA;;;;;;;;UASA;;UAUA;;UAoBA;;UAOA;;UAKA;;UAEA;;UAGM;UAmBA;;;;;;;UAmCA;;UAsBN;UAEM;;;;;EAoIP,aAAa;;;;;EAQP,aAAa;;EAYb,4BAA4B;;;;;;;;SAYrB,KAClB,IAAI,UACJ,OAAO,mBAAmB,WAAW,YACrC,KAAK,qBACL,SAAS,WAAQ,QAAA"}
1
+ {"version":3,"file":"synchronized_tree.d.ts","names":[],"sources":["../src/synchronized_tree.ts"],"mappings":";;;;;;;KA8DK;YAEO;;EAEV,yBAAyB;;EAGzB,UAAU;;EAGV,cAAc;;;;;EAMd,oBAAoB;;EAGpB;;EAEA;;EAGA,UAAU;;EAGV;;EAGA,gBAAgB;;;;;;;;;;;;EAahB,cAAc;;;YAIJ;EAAqB;EAAkB,MAAM;;;;;YAK7C;EAAmB;EAAgB,cAAc;;YAEjD,WAAW,mBAAmB;;;;;;;wBA+C1B,sBAAsB;EACpC;EACA;EACA;EACA;;qBA0BW;mBAsCQ;mBAGA;mBAxCF;UACT;mBACS;mBACA;mBACA;mBACA;mBACA;;;;;UAKT;;;;UAIA;mBACS;mBACA;mBACA;;mBAGA;;mBAEA;;UAET;;;;UAKA;;;UAIA;UAED;;UA8CC;;;;MAOG;;;;;MAQA;;;;;EAQJ,QAAQ,SAAS,oBAAoB;;;;;;UAUpC;;;UA2BA;;EAUD,SAAS,MAAM,mBAAmB;;;;EAQlC,MAAM,MAAM,mBAAmB;;;EAU/B,cAAc;;;EAOR,gBAAgB;UAKrB;UACA;;;UAIA;;;;;;;;UASA;;UAUA;;UAoBA;;UAOA;;UAKA;;UAEA;;UAGM;UAmBA;;;;;;;;;;UAoDN;;;;;UAcA;;;;;;;UAYM;;UA8BN;UAEM;;;;;EAsIP,aAAa;;;;;EAQP,aAAa;;EAYb,4BAA4B;;;;;;;;SAYrB,KAClB,IAAI,UACJ,OAAO,mBAAmB,WAAW,YACrC,KAAK,qBACL,SAAS,WAAQ,QAAA"}
@@ -1,6 +1,6 @@
1
1
  import { PlTreeEntry, PlTreeRootsEntry } from "./accessors.js";
2
2
  import { PlTreeState, TreeStateUpdateError } from "./state.js";
3
- import { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from "./sync.js";
3
+ import { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState, resolveTreeLoadingAlgorithm, supportsResourceTreeTraversal } from "./sync.js";
4
4
  import { captureTreeState, restoreTreeState } from "./persisted_tree.js";
5
5
  import { isTimeoutOrCancelError, isUnauthenticated, isUnimplementedError } from "@milaboratories/pl-client";
6
6
  import { PollingComputableHooks } from "@milaboratories/computable";
@@ -81,6 +81,15 @@ var SynchronizedTreeState = class SynchronizedTreeState {
81
81
  fieldFilter;
82
82
  traverseStopRules;
83
83
  traversalMode;
84
+ /** Resolved once from {@link traversalMode} and the server's capabilities, and used by every
85
+ * poll of this tree. Selecting per poll was only sound while no algorithm kept state between
86
+ * polls; pinning it here is what lets one do so. Only ever reassigned by the one-way
87
+ * demotion in {@link loadAndApply} when the backend advertises delta but issues no token. */
88
+ algorithm;
89
+ /** Change token the last successful delta apply was dated at, handed to the next poll so
90
+ * the backend sends only what moved since. Undefined until the first delta poll commits
91
+ * one, and again whenever {@link discardDeltaToken} drops it. */
92
+ deltaToken;
84
93
  logStat;
85
94
  hooks;
86
95
  abortController = new AbortController();
@@ -105,6 +114,8 @@ var SynchronizedTreeState = class SynchronizedTreeState {
105
114
  this.fieldFilter = fieldFilter;
106
115
  this.traverseStopRules = traverseStopRules;
107
116
  this.traversalMode = traversalMode ?? "auto";
117
+ this.algorithm = resolveTreeLoadingAlgorithm(this.traversalMode, pl.serverInfo.capabilities ?? [], logger);
118
+ logger?.info(`tree loading algorithm: ${this.algorithm} (traversalMode=${this.traversalMode})`);
108
119
  this.pollingInterval = pollingInterval;
109
120
  this.effectivePollingInterval = pollingInterval;
110
121
  this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;
@@ -267,16 +278,45 @@ var SynchronizedTreeState = class SynchronizedTreeState {
267
278
  const request = constructTreeLoadingRequest(this.state, {
268
279
  pruningFunction: this.pruning,
269
280
  fieldFilter: this.fieldFilter,
270
- traverseStopRules: this.traverseStopRules
281
+ traverseStopRules: this.traverseStopRules,
282
+ changedSinceToken: this.deltaToken
271
283
  });
272
284
  if (request.seedResources.length === 0 && request.finalResources.size === 0) return;
273
- const data = await this.pl.withReadTx("ReadingTree", async (tx) => {
274
- return await loadTreeState(tx, request, stats, this.pl.serverInfo.capabilities ?? [], this.traversalMode, this.logger);
285
+ const { data, nextToken } = await this.pl.withReadTx("ReadingTree", async (tx) => {
286
+ const tokenPromise = this.algorithm === "backend-delta" ? tx.getNextSinceToken() : void 0;
287
+ return {
288
+ data: await loadTreeState(tx, request, stats, this.pl.serverInfo.capabilities ?? [], this.algorithm, this.logger),
289
+ nextToken: await tokenPromise
290
+ };
275
291
  }, txOps);
276
292
  this.state.updateFromResourceData(data, {
277
293
  allowOrphanInputs: true,
278
294
  stat: stats
279
295
  });
296
+ if (nextToken !== void 0) this.deltaToken = nextToken;
297
+ else if (this.algorithm === "backend-delta") this.demoteFromDelta();
298
+ }
299
+ /** Give up on delta for the life of this tree, once, when the backend advertises
300
+ * `treeChangedSince:v1` but hands out no token.
301
+ *
302
+ * Without this the tree stays on delta with `deltaToken` permanently unset, and every poll
303
+ * is then a token-less delta poll: a full tree read that also sends no stop rules, so it
304
+ * transfers the subtrees the streaming path prunes away. Nothing else detects it -
305
+ * `deltaSuspectedFullAnswers` only fires when a token WAS sent - so it would run at the
306
+ * poll interval, forever, silently. Streaming is the correct destination: it is what `auto`
307
+ * would have picked without the capability, and it restores the stop rules. */
308
+ demoteFromDelta() {
309
+ this.algorithm = supportsResourceTreeTraversal(this.pl.serverInfo.capabilities ?? []) ? "backend-streaming" : "client-bfs";
310
+ this.logger?.warn(`tree: backend advertises treeChangedSince:v1 but issued no change token; falling back to ${this.algorithm} for the life of this tree`);
311
+ }
312
+ /** Discards the change token, so the next delta poll asks for the full tree. Required
313
+ * whenever the mirror stops being a faithful record of what the token says we hold: a
314
+ * rebuild after {@link TreeStateUpdateError}, or a root-set change, which reshapes the
315
+ * traversal the token was earned under. */
316
+ discardDeltaToken(reason) {
317
+ if (this.deltaToken === void 0) return;
318
+ this.deltaToken = void 0;
319
+ this.logger?.info(`tree delta token discarded (${reason}); next poll reads the full tree`);
280
320
  }
281
321
  /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and
282
322
  * reconciles the discovered root set against the heap. A longer poll result adds roots; a
@@ -298,8 +338,10 @@ var SynchronizedTreeState = class SynchronizedTreeState {
298
338
  }
299
339
  for (const id of ids) discovered.add(id);
300
340
  }
341
+ const rootsChanged = discovered.size !== this.discoveredRoots.length || this.discoveredRoots.some((id) => !discovered.has(id));
301
342
  this.discoveredRoots = [...discovered];
302
343
  this.state.setRoots(this.currentRootSet());
344
+ if (rootsChanged) this.discardDeltaToken("root set changed");
303
345
  }
304
346
  /** If true this tree state is permanently terminaed. */
305
347
  terminated = false;
@@ -336,6 +378,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
336
378
  this.logger?.error(e);
337
379
  this.state.invalidateTree("stat update error");
338
380
  this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);
381
+ this.discardDeltaToken("tree rebuilt after update error");
339
382
  continue;
340
383
  } else this.logger?.warn(e);
341
384
  }
@@ -1 +1 @@
1
- {"version":3,"file":"synchronized_tree.js","names":[],"sources":["../src/synchronized_tree.ts"],"sourcesContent":["import { PollingComputableHooks } from \"@milaboratories/computable\";\nimport { PlTreeEntry, PlTreeRootsEntry } from \"./accessors\";\nimport type {\n FinalResourceDataPredicate,\n PlClient,\n ResourceSignature,\n ResourceType,\n SignedResourceId,\n TxOps,\n} from \"@milaboratories/pl-client\";\nimport type { Filter } from \"@milaboratories/pl-client\";\nimport {\n isUnauthenticated,\n isTimeoutOrCancelError,\n isUnimplementedError,\n} from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData } from \"./state\";\nimport { PlTreeState, TreeStateUpdateError } from \"./state\";\nimport type { PruningFunction, TraversalMode, TreeLoadingStat } from \"./sync\";\nimport { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from \"./sync\";\nimport type { PersistedTree } from \"./persisted_tree\";\nimport { captureTreeState, restoreTreeState } from \"./persisted_tree\";\nimport * as tp from \"node:timers/promises\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\n\n/** Hard floor between consecutive tree-refresh calls.\n * Applies even when {@link scheduleOnNextState} has woken the loop early,\n * preventing tight polling loops during rapid state transitions. */\nconst MIN_POLLING_INTERVAL_MS = 100;\n\n/** Ceiling for the adaptive poll interval. Caps how stale an idle tree can get before the\n * next look, and bounds how far the idle backoff can push the interval out. */\nconst MAX_POLLING_INTERVAL_MS = 5_000;\n\n/** Applied to the interval after a cycle that changed nothing. An idle tree walks its\n * interval out towards {@link MAX_POLLING_INTERVAL_MS} instead of re-polling at full rate;\n * the first cycle that changes anything resets it. */\nconst IDLE_BACKOFF_MULTIPLIER = 1.5;\n\n/** The client's measured RTT becomes an interval floor, scaled by this. Every refresh costs\n * at least one round trip, so polling faster than a small multiple of the RTT only queues\n * round-trips the link cannot service. This is what replaces the fixed interval on a\n * high-latency link; on a fast link the configured `pollingInterval` still dominates. */\nconst RTT_POLL_FACTOR = 2;\n\n/** Ceiling for the RTT-derived floor. The estimate is sampled at connect time and never\n * re-sampled, so without a bound one slow ping pins the cadence high for the whole session.\n * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck\n * rather than slow, and spacing polls further only delays noticing it recovered. */\nconst MAX_RTT_POLL_INTERVAL_MS = 30_000;\n\ntype StatLoggingMode = \"cumulative\" | \"per-request\";\n\nexport type SynchronizedTreeOps = {\n /** Override final predicate from the PlClient */\n finalPredicateOverride?: FinalResourceDataPredicate;\n\n /** Pruning function for legacy fallback path. */\n pruning?: PruningFunction;\n\n /** ResourceTree field filter for modern backend path. */\n fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules for modern backend path. */\n traverseStopRules?: Filter;\n\n /** Interval after last sync to sleep before the next one */\n pollingInterval: number;\n /** For how long to continue polling after the last derived value access */\n stopPollingDelay: number;\n\n /** If one of the values, tree will log stats of each polling request */\n logStat?: StatLoggingMode;\n\n /** Timeout for initial tree loading. If not specified, will use default for RO tx from pl-client. */\n initialTreeLoadingTimeout?: number;\n\n /** Controls which tree-loading path to use. Default `\"auto\"`. */\n traversalMode?: TraversalMode;\n\n /** A previously persisted mirror to seed the tree with, before its first refresh, so that\n * refresh transfers only what changed while the tree was gone.\n *\n * A snapshot that cannot be applied, or does not belong to this tree, is logged and dropped,\n * leaving an ordinary cold open.\n *\n * A snapshot that applies but whose ids are dead is NOT handled here: its resources become\n * this tree's seeds, so the first refresh fails and {@link init} rejects, where a cold open\n * would have succeeded. Establishing that the signatures are still live is the caller's job\n * (see {@link PersistedTree.witness}), as is deciding what to do when the first refresh is\n * refused anyway. Ignored for trees with shared-type seeds, which rediscover their roots. */\n restoreFrom?: PersistedTree;\n};\n\n/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */\nexport type ExplicitRootSeed = { kind: \"resource\"; root: SignedResourceId };\n\n/** Discovers, as roots, every resource of this type shared with the current user.\n * Matched against SharedResource.resourceType by NAME (version optional/ignored). The\n * discovered set is DYNAMIC — roots appear/disappear as grants are added/revoked/expire. */\nexport type SharedTypeSeed = { kind: \"shared\"; resourceType: ResourceType };\n\nexport type TreeSeed = ExplicitRootSeed | SharedTypeSeed;\n\n/** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare\n * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so\n * every existing single-root caller is unchanged. */\nfunction normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSeed[] {\n if (Array.isArray(seeds)) return seeds;\n if (typeof seeds === \"object\" && seeds !== null && \"kind\" in seeds) return [seeds];\n // bare SignedResourceId\n return [{ kind: \"resource\", root: seeds }];\n}\n\n/** How often a tree with shared-type seeds re-polls ListUserResources to reconcile its\n * discovered roots.\n *\n * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental\n * refresh, so it must NOT run on every refresh tick. This is a wall-clock interval rather\n * than a count of iterations: the refresh cadence is adaptive (it stretches towards\n * {@link MAX_POLLING_INTERVAL_MS} on an idle tree and scales with RTT on a slow link),\n * so a fixed iteration count would let discovery latency drift out with it. Keeping it in\n * milliseconds pins the latency of noticing a new/removed share regardless of cadence, which\n * is what a human-driven share flow cares about.\n *\n * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root\n * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */\nconst DISCOVERY_INTERVAL_MS = 3_000;\n\n/** Counters that mean \"this cycle brought something new\". Deliberately not the full change\n * breakdown: those fields are sub-counts of `resourcesChanged` and would double-count.\n * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged\n * state is exactly the idle case the backoff exists for. */\nfunction countedChanges(stat: TreeLoadingStat): number {\n // `fieldsRemoved` is included despite being a per-field count, because it is the one change\n // that never shows up in `resourcesChanged`: the removed-dynamic-field branch in\n // `updateFromResourceData` does not set its `changed` flag, so a cycle that only dropped a\n // field (and garbage-collected whatever it pointed at) otherwise reads as an idle cycle.\n // That double-counts a resource that both changed and lost a field, which is harmless here:\n // every caller compares this against an earlier value rather than reading it as a total.\n return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.fieldsRemoved;\n}\n\n/** The poll-cadence policy, as a pure function of the last cycle's outcome.\n *\n * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no\n * link can be polled faster than configured. `rttMs` raises that bound on a slow link.\n * `currentMs` is the interval in force for the cycle that just finished, which is what the\n * idle backoff compounds on. */\nexport function derivePollingInterval(opts: {\n configuredMs: number;\n currentMs: number;\n rttMs: number | undefined;\n changed: boolean;\n}): number {\n const { configuredMs, currentMs, rttMs, changed } = opts;\n\n // The cap applies to the RTT-derived part only, so `configuredMs` stays an absolute lower\n // bound even if it is ever set above the cap.\n const floor =\n rttMs === undefined\n ? configuredMs\n : Math.max(\n configuredMs,\n Math.min(MAX_RTT_POLL_INTERVAL_MS, Math.ceil(rttMs * RTT_POLL_FACTOR)),\n );\n\n const next = changed ? floor : Math.max(floor, currentMs * IDLE_BACKOFF_MULTIPLIER);\n\n // The ceiling never cuts below the floor: a link slower than MAX_POLLING_INTERVAL_MS still\n // gets its RTT-derived spacing rather than being forced to re-poll early.\n return Math.max(floor, Math.min(MAX_POLLING_INTERVAL_MS, next));\n}\n\ntype ScheduledRefresh = {\n resolve: () => void;\n reject: (err: any) => void;\n};\n\nexport class SynchronizedTreeState {\n private readonly finalPredicate: FinalResourceDataPredicate;\n private state: PlTreeState;\n private readonly pollingInterval: number;\n private readonly pruning?: PruningFunction;\n private readonly fieldFilter?: Filter;\n private readonly traverseStopRules?: Filter;\n private readonly traversalMode: TraversalMode;\n private readonly logStat?: StatLoggingMode;\n private readonly hooks: PollingComputableHooks;\n private readonly abortController = new AbortController();\n\n /** Explicit-resource seeds: fixed roots, present from construction. */\n private readonly explicitRoots: SignedResourceId[];\n /** Shared-type seeds (discovered roots), if any. */\n private readonly sharedSeeds: SharedTypeSeed[];\n /** Roots discovered for shared-type seeds on the last discovery poll. */\n private discoveredRoots: SignedResourceId[] = [];\n\n /** Bumped once per refresh cycle that brought something new: a resource appeared, changed,\n * or became final. Lets a holder tell whether the tree has moved since it last persisted\n * it, without diffing state. Read through {@link changeGeneration}. */\n private changeGenerationCounter = 0;\n\n /** Whether a snapshot was actually applied. Read through\n * {@link wasRestoredFromSnapshot}. */\n private restoredFromSnapshot = false;\n\n private constructor(\n private readonly pl: PlClient,\n seeds: TreeSeed[],\n ops: SynchronizedTreeOps,\n private readonly logger?: MiLogger,\n ) {\n const {\n finalPredicateOverride,\n pruning,\n fieldFilter,\n traverseStopRules,\n traversalMode,\n pollingInterval,\n stopPollingDelay,\n logStat,\n } = ops;\n this.pruning = pruning;\n this.fieldFilter = fieldFilter;\n this.traverseStopRules = traverseStopRules;\n this.traversalMode = traversalMode ?? \"auto\";\n this.pollingInterval = pollingInterval;\n this.effectivePollingInterval = pollingInterval;\n this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;\n this.logStat = logStat;\n\n this.explicitRoots = seeds\n .filter((s): s is ExplicitRootSeed => s.kind === \"resource\")\n .map((s) => s.root);\n this.sharedSeeds = seeds.filter((s): s is SharedTypeSeed => s.kind === \"shared\");\n\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n this.hooks = new PollingComputableHooks(\n () => this.startUpdating(),\n () => this.stopUpdating(),\n { stopDebounce: stopPollingDelay },\n (resolve, reject) => this.scheduleOnNextState(resolve, reject),\n );\n }\n\n /** The current protected root set: explicit roots plus the latest discovered roots. */\n private currentRootSet(): Set<SignedResourceId> {\n return new Set([...this.explicitRoots, ...this.discoveredRoots]);\n }\n\n /** How many refresh cycles brought something new. Only ever increases. Equal values at two\n * points in time mean nothing was added, changed or settled in between, which is what makes\n * a periodic snapshot write skippable on an idle tree. */\n public get changeGeneration(): number {\n return this.changeGenerationCounter;\n }\n\n /** True only if a snapshot was actually applied as this tree's initial state. A snapshot can\n * be supplied and still be refused (wrong roots, or state the update call will not accept),\n * in which case this stays false and the tree started empty like any other. Passing\n * `restoreFrom` is therefore not evidence of a warm start; this is. */\n public get wasRestoredFromSnapshot(): boolean {\n return this.restoredFromSnapshot;\n }\n\n /** Captures the current mirror for persistence.\n *\n * Must be called before {@link terminate}: terminating invalidates the tree, and capturing\n * an invalidated tree is refused rather than silently written. */\n public capture(witness: ResourceSignature): PersistedTree {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return captureTreeState(this.state, witness);\n }\n\n /** Installs a snapshot as this tree's state. Returns false if the snapshot was refused, in\n * which case the tree is left as it was and the open proceeds cold.\n *\n * Only meaningful before the first refresh, which is why it is private and driven from\n * {@link init}: replacing the state of a running tree would strand its observers. */\n private restore(snapshot: PersistedTree): boolean {\n if (this.sharedSeeds.length > 0) {\n this.logger?.warn(\"ignoring tree snapshot: trees with shared-type seeds are not restored\");\n return false;\n }\n\n const roots = this.currentRootSet();\n const snapshotRoots = new Set(snapshot.roots);\n if (snapshotRoots.size !== roots.size || ![...snapshotRoots].every((r) => roots.has(r))) {\n // A snapshot addressed to a different root is a mis-keyed file, not a stale one.\n this.logger?.warn(\"ignoring tree snapshot: its roots are not this tree's roots\");\n return false;\n }\n\n const restored = restoreTreeState(snapshot, this.finalPredicate, {\n roots,\n logger: this.logger,\n });\n if (restored === undefined) return false;\n\n this.state = restored;\n this.restoredFromSnapshot = true;\n return true;\n }\n\n /** Resolves the single root for the backward-compatible single-root accessors, throwing\n * if the tree does not have exactly one root (guards legacy callers against multi-root). */\n private soleRoot(): SignedResourceId {\n const roots = this.currentRootSet();\n if (roots.size !== 1)\n throw new Error(\n `single-root accessor used on a tree with ${roots.size} roots; use rootsEntry() instead`,\n );\n return roots.values().next().value!;\n }\n\n /** @deprecated use \"entry\" instead */\n public accessor(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return this.entry(rid);\n }\n\n /** Backward-compatible single-root entry. With no `rid` it returns the sole root's entry\n * and THROWS if the tree has zero or more than one root. An explicit `rid` addresses any\n * resource in the heap, as today. */\n public entry(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeEntry(\n { treeProvider: () => this.state, hooks: this.hooks },\n rid ?? this.soleRoot(),\n );\n }\n\n /** Reactive provider for the current root SET. Reading it inside a Computable tracks the\n * set as a dependency, so the Computable recomputes when discovered roots appear/disappear. */\n public rootsEntry(): PlTreeRootsEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeRootsEntry({ treeProvider: () => this.state, hooks: this.hooks });\n }\n\n /** Can be used to externally kick off the synchronization polling loop, and\n * await for the first synchronization to happen. */\n public async refreshState(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n await this.hooks.refreshState();\n }\n\n private currentLoopDelayInterrupt: AbortController | undefined = undefined;\n private scheduledOnNextState: ScheduledRefresh[] = [];\n\n /** Interval actually used for the current wait. Starts at the configured `pollingInterval`\n * and is re-derived after every cycle by {@link updatePollingInterval}. */\n private effectivePollingInterval: number;\n\n /** Re-derives {@link effectivePollingInterval} after a cycle.\n *\n * Two independent effects. The floor scales with the client's measured RTT, so a\n * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle\n * that changed nothing multiplies the interval out towards\n * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so\n * an active tree stays responsive. */\n private updatePollingInterval(changed: boolean): void {\n this.effectivePollingInterval = derivePollingInterval({\n configuredMs: this.pollingInterval,\n currentMs: this.effectivePollingInterval,\n rttMs: this.pl.rttEstimateMs,\n changed,\n });\n }\n\n /** Called from computable hooks when external observer asks for state refresh */\n private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {\n if (this.terminated) reject(new Error(\"tree synchronization is terminated\"));\n else {\n this.scheduledOnNextState.push({ resolve, reject });\n // Someone is waiting on fresh state, so this tree is not idle after all: drop any\n // accumulated backoff, otherwise the cycles right after a nudge stay slow. Routed\n // through the policy rather than assigning the configured value directly, so the RTT\n // floor survives the reset. Assigning it raw would poll a high-latency link faster than\n // it can answer, and the interval would stay there until the next cycle that completes:\n // the error path never reaches updatePollingInterval, so a failing nudged refresh would\n // keep retrying at the un-floored rate.\n this.updatePollingInterval(true);\n if (this.currentLoopDelayInterrupt) {\n this.currentLoopDelayInterrupt.abort();\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n\n /** Called from observer */\n private startUpdating(): void {\n if (this.terminated) return;\n this.keepRunning = true;\n if (this.currentLoop === undefined) this.currentLoop = this.mainLoop();\n }\n\n /** Called from observer */\n private stopUpdating(): void {\n this.keepRunning = false;\n }\n\n /** If true, main loop will continue polling pl state. */\n private keepRunning = false;\n /** Actual state of main loop. */\n private currentLoop: Promise<void> | undefined = undefined;\n\n /** Executed from the main loop, and initialization procedure. */\n private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n try {\n await this.loadAndApply(stats, txOps);\n } catch (e) {\n // Discovery-tree self-heal: a discovered root whose grant was revoked/expired fails the whole\n // ResourceTree poll with Unauthenticated. Re-discover (drops dead roots) and retry once. This is\n // self-discriminating — a genuinely dead session also fails discover()'s own call, so real auth\n // loss still propagates. Only for discovery trees; explicit-root trees propagate as-is.\n if (this.sharedSeeds.length > 0 && isUnauthenticated(e)) {\n this.logger?.warn(\n \"discovery tree: Unauthenticated on ResourceTree (likely revoked/expired root); re-discovering and retrying\",\n );\n await this.discover();\n await this.loadAndApply(stats, txOps);\n } else throw e;\n }\n }\n\n private async loadAndApply(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n const request = constructTreeLoadingRequest(this.state, {\n pruningFunction: this.pruning,\n fieldFilter: this.fieldFilter,\n traverseStopRules: this.traverseStopRules,\n });\n // A shared-type-seed tree with no currently-discovered roots is legitimately empty:\n // there is nothing to traverse, and tx.resourceTree([]) would throw \"at least one seed\n // must be provided\". Skip the backend load and leave the (empty) state as-is — discovery\n // adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees\n // never hit this (their root set is non-empty by construction).\n if (request.seedResources.length === 0 && request.finalResources.size === 0) return;\n const data = await this.pl.withReadTx(\n \"ReadingTree\",\n async (tx) => {\n return await loadTreeState(\n tx,\n request,\n stats,\n this.pl.serverInfo.capabilities ?? [],\n this.traversalMode,\n this.logger,\n );\n },\n txOps,\n );\n this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });\n }\n\n /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and\n * reconciles the discovered root set against the heap. A longer poll result adds roots; a\n * shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade\n * to collection via the ordinary refcount GC ({@link PlTreeState.setRoots}). No-op when the\n * tree has no shared-type seeds, or silently no-op on a REST client where `ListUserResources`\n * is unavailable. */\n private async discover(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n if (this.sharedSeeds.length === 0) return;\n\n const discovered = new Set<SignedResourceId>();\n for (const seed of this.sharedSeeds) {\n let ids: SignedResourceId[];\n try {\n // match by name only (permissive; ignores version, so it survives schema bumps)\n ids = await this.pl.userResources.listSharedResourcesByType(seed.resourceType.name);\n } catch (e: unknown) {\n if (isUnimplementedError(e)) continue;\n throw e;\n }\n for (const id of ids) discovered.add(id);\n }\n\n this.discoveredRoots = [...discovered];\n this.state.setRoots(this.currentRootSet());\n }\n\n /** If true this tree state is permanently terminaed. */\n private terminated = false;\n\n private async mainLoop() {\n // Always collected, even when not logging: the change counters drive the idle backoff\n // below. Counter bumps are cheap next to the round trip they describe.\n let stat = initialTreeLoadingStat();\n\n let lastUpdate = Date.now();\n\n // paces the discovery poll for shared-type seeds; 0 forces discovery on the first pass.\n let lastDiscovery = 0;\n\n while (true) {\n if (!this.keepRunning || this.terminated) break;\n\n // saving those who want to be notified about new state here\n // because those who will be added during the tree retrieval\n // should be notified only on the next round\n let toNotify: ScheduledRefresh[] | undefined = undefined;\n if (this.scheduledOnNextState.length > 0) {\n toNotify = this.scheduledOnNextState;\n this.scheduledOnNextState = [];\n }\n\n try {\n // resetting stats if we were asked to collect non-cumulative stats\n if (this.logStat === \"per-request\") stat = initialTreeLoadingStat();\n\n // discovery sync for shared-type seeds: reconcile the discovered root set before\n // refreshing, so newly discovered roots are materialized in this same iteration.\n if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {\n await this.discover();\n lastDiscovery = Date.now();\n }\n\n // Change counters before the refresh, so the delta tells us whether this single cycle\n // brought anything new. Works in both stat modes: \"per-request\" resets to 0 above.\n const changesBefore = countedChanges(stat);\n\n // actual tree synchronization\n await this.refresh(stat);\n\n const changed = countedChanges(stat) > changesBefore;\n if (changed) this.changeGenerationCounter++;\n this.updatePollingInterval(changed);\n\n // logging stats if we were asked to\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we got new state\n if (toNotify !== undefined) for (const n of toNotify) n.resolve();\n } catch (e: any) {\n // logging stats if we were asked to (even if error occured)\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we failed to refresh the state\n if (toNotify !== undefined) for (const n of toNotify) n.reject(e);\n\n // catching tree update errors, as they may leave our tree in inconsistent state\n if (e instanceof TreeStateUpdateError) {\n // important error logging, this should never happen\n this.logger?.error(e);\n\n // marking everybody who used previous state as changed\n this.state.invalidateTree(\"stat update error\");\n // creating new tree with the full current root set (re-discovered on next iteration)\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n\n // scheduling state update without delay\n continue;\n\n // unfortunately external observer may still see tree in its default\n // empty state, though this is best we can do in this exceptional\n // situation, and hope on caching layers inside computables to present\n // some stale state until we reconstruct the tree again\n } else this.logger?.warn(e);\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 1: mandatory floor — always wait at least MIN_POLLING_INTERVAL_MS.\n // Not interruptible by scheduleOnNextState; only termination aborts it.\n try {\n await tp.setTimeout(MIN_POLLING_INTERVAL_MS, undefined, {\n signal: this.abortController.signal,\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 2: optional remainder up to pollingInterval — interruptible by\n // scheduleOnNextState so that an external nudge wakes the loop promptly.\n if (this.scheduledOnNextState.length === 0) {\n const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);\n if (remaining > 0) {\n try {\n this.currentLoopDelayInterrupt = new AbortController();\n await tp.setTimeout(remaining, undefined, {\n signal: AbortSignal.any([\n this.abortController.signal,\n this.currentLoopDelayInterrupt.signal,\n ]),\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n // Otherwise it was just the loop delay interrupt (scheduleOnNextState),\n // continue to the next iteration\n } finally {\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n }\n\n // reset only as a very last line\n this.currentLoop = undefined;\n }\n\n /**\n * Dumps the current state of the tree.\n * @returns An array of ExtendedResourceData objects representing the current state of the tree.\n */\n public dumpState(): ExtendedResourceData[] {\n return this.state.dumpState();\n }\n\n /**\n * Terminates the internal loop, and permanently destoys all internal state, so\n * all computables using this state will resolve to errors.\n * */\n public async terminate(): Promise<void> {\n this.keepRunning = false;\n this.terminated = true;\n this.abortController.abort();\n\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n\n this.state.invalidateTree(\"synchronization terminated for the tree\");\n }\n\n /** @deprecated */\n public async awaitSyncLoopTermination(): Promise<void> {\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n }\n\n /**\n * Initializes a synchronized tree from one or more seeds.\n *\n * @param seeds a bare {@link SignedResourceId} (the original single-root contract), a\n * single {@link TreeSeed}, or an array of seeds. Bare ids and explicit-resource seeds\n * become roots immediately; shared-type seeds discover their roots via `ListUserResources`.\n */\n public static async init(\n pl: PlClient,\n seeds: SignedResourceId | TreeSeed | TreeSeed[],\n ops: SynchronizedTreeOps,\n logger?: MiLogger,\n ) {\n const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);\n\n // Seed from the snapshot before the first refresh, so that refresh is the one that\n // transfers only what changed. A refused snapshot leaves an ordinary cold open.\n const restored = ops.restoreFrom !== undefined && tree.restore(ops.restoreFrom);\n\n // Always collected, even when not logging: the initial load's change count is what seeds\n // the change generation, so a holder can tell a populated tree from an untouched one.\n const stat = initialTreeLoadingStat();\n\n let ok = false;\n\n try {\n // resolve shared-type seeds before the first refresh so discovered roots load now\n await tree.discover();\n await tree.refresh(stat, {\n timeout: ops.initialTreeLoadingTimeout,\n });\n ok = true;\n } finally {\n if (countedChanges(stat) > 0) tree.changeGenerationCounter++;\n\n // logging stats if we were asked to (even if error occured)\n if (ops.logStat && logger)\n logger.info(\n `Tree stat (initial load, ${ok ? \"success\" : \"failure\"}, ${\n restored ? \"restored from snapshot\" : \"cold\"\n }): ${JSON.stringify(stat)}`,\n );\n }\n\n return tree;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"synchronized_tree.js","names":[],"sources":["../src/synchronized_tree.ts"],"sourcesContent":["import { PollingComputableHooks } from \"@milaboratories/computable\";\nimport { PlTreeEntry, PlTreeRootsEntry } from \"./accessors\";\nimport type {\n FinalResourceDataPredicate,\n PlClient,\n ResourceSignature,\n ResourceType,\n SignedResourceId,\n TxOps,\n} from \"@milaboratories/pl-client\";\nimport type { Filter } from \"@milaboratories/pl-client\";\nimport {\n isUnauthenticated,\n isTimeoutOrCancelError,\n isUnimplementedError,\n} from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData } from \"./state\";\nimport { PlTreeState, TreeStateUpdateError } from \"./state\";\nimport type {\n PruningFunction,\n TraversalMode,\n TreeLoadingAlgorithmName,\n TreeLoadingStat,\n} from \"./sync\";\nimport {\n constructTreeLoadingRequest,\n initialTreeLoadingStat,\n loadTreeState,\n resolveTreeLoadingAlgorithm,\n supportsResourceTreeTraversal,\n} from \"./sync\";\nimport type { PersistedTree } from \"./persisted_tree\";\nimport { captureTreeState, restoreTreeState } from \"./persisted_tree\";\nimport * as tp from \"node:timers/promises\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\n\n/** Hard floor between consecutive tree-refresh calls.\n * Applies even when {@link scheduleOnNextState} has woken the loop early,\n * preventing tight polling loops during rapid state transitions. */\nconst MIN_POLLING_INTERVAL_MS = 100;\n\n/** Ceiling for the adaptive poll interval. Caps how stale an idle tree can get before the\n * next look, and bounds how far the idle backoff can push the interval out. */\nconst MAX_POLLING_INTERVAL_MS = 5_000;\n\n/** Applied to the interval after a cycle that changed nothing. An idle tree walks its\n * interval out towards {@link MAX_POLLING_INTERVAL_MS} instead of re-polling at full rate;\n * the first cycle that changes anything resets it. */\nconst IDLE_BACKOFF_MULTIPLIER = 1.5;\n\n/** The client's measured RTT becomes an interval floor, scaled by this. Every refresh costs\n * at least one round trip, so polling faster than a small multiple of the RTT only queues\n * round-trips the link cannot service. This is what replaces the fixed interval on a\n * high-latency link; on a fast link the configured `pollingInterval` still dominates. */\nconst RTT_POLL_FACTOR = 2;\n\n/** Ceiling for the RTT-derived floor. The estimate is sampled at connect time and never\n * re-sampled, so without a bound one slow ping pins the cadence high for the whole session.\n * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck\n * rather than slow, and spacing polls further only delays noticing it recovered. */\nconst MAX_RTT_POLL_INTERVAL_MS = 30_000;\n\ntype StatLoggingMode = \"cumulative\" | \"per-request\";\n\nexport type SynchronizedTreeOps = {\n /** Override final predicate from the PlClient */\n finalPredicateOverride?: FinalResourceDataPredicate;\n\n /** Pruning function for legacy fallback path. */\n pruning?: PruningFunction;\n\n /** ResourceTree field filter for modern backend path. */\n fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules for the streaming path.\n * @deprecated the backend ignores these under a change token, and the delta algorithm - what\n * `auto` now picks on a capable backend - never sends them. They still prune a\n * `backend-streaming` walk and a token-less delta poll's fallback. */\n traverseStopRules?: Filter;\n\n /** Interval after last sync to sleep before the next one */\n pollingInterval: number;\n /** For how long to continue polling after the last derived value access */\n stopPollingDelay: number;\n\n /** If one of the values, tree will log stats of each polling request */\n logStat?: StatLoggingMode;\n\n /** Timeout for initial tree loading. If not specified, will use default for RO tx from pl-client. */\n initialTreeLoadingTimeout?: number;\n\n /** Controls which tree-loading path to use. Default `\"auto\"`. */\n traversalMode?: TraversalMode;\n\n /** A previously persisted mirror to seed the tree with, before its first refresh, so that\n * refresh transfers only what changed while the tree was gone.\n *\n * A snapshot that cannot be applied, or does not belong to this tree, is logged and dropped,\n * leaving an ordinary cold open.\n *\n * A snapshot that applies but whose ids are dead is NOT handled here: its resources become\n * this tree's seeds, so the first refresh fails and {@link init} rejects, where a cold open\n * would have succeeded. Establishing that the signatures are still live is the caller's job\n * (see {@link PersistedTree.witness}), as is deciding what to do when the first refresh is\n * refused anyway. Ignored for trees with shared-type seeds, which rediscover their roots. */\n restoreFrom?: PersistedTree;\n};\n\n/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */\nexport type ExplicitRootSeed = { kind: \"resource\"; root: SignedResourceId };\n\n/** Discovers, as roots, every resource of this type shared with the current user.\n * Matched against SharedResource.resourceType by NAME (version optional/ignored). The\n * discovered set is DYNAMIC — roots appear/disappear as grants are added/revoked/expire. */\nexport type SharedTypeSeed = { kind: \"shared\"; resourceType: ResourceType };\n\nexport type TreeSeed = ExplicitRootSeed | SharedTypeSeed;\n\n/** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare\n * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so\n * every existing single-root caller is unchanged. */\nfunction normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSeed[] {\n if (Array.isArray(seeds)) return seeds;\n if (typeof seeds === \"object\" && seeds !== null && \"kind\" in seeds) return [seeds];\n // bare SignedResourceId\n return [{ kind: \"resource\", root: seeds }];\n}\n\n/** How often a tree with shared-type seeds re-polls ListUserResources to reconcile its\n * discovered roots.\n *\n * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental\n * refresh, so it must NOT run on every refresh tick. This is a wall-clock interval rather\n * than a count of iterations: the refresh cadence is adaptive (it stretches towards\n * {@link MAX_POLLING_INTERVAL_MS} on an idle tree and scales with RTT on a slow link),\n * so a fixed iteration count would let discovery latency drift out with it. Keeping it in\n * milliseconds pins the latency of noticing a new/removed share regardless of cadence, which\n * is what a human-driven share flow cares about.\n *\n * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root\n * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */\nconst DISCOVERY_INTERVAL_MS = 3_000;\n\n/** Counters that mean \"this cycle brought something new\". Deliberately not the full change\n * breakdown: those fields are sub-counts of `resourcesChanged` and would double-count.\n * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged\n * state is exactly the idle case the backoff exists for. */\nfunction countedChanges(stat: TreeLoadingStat): number {\n // `fieldsRemoved` is included despite being a per-field count, because it is the one change\n // that never shows up in `resourcesChanged`: the removed-dynamic-field branch in\n // `updateFromResourceData` does not set its `changed` flag, so a cycle that only dropped a\n // field (and garbage-collected whatever it pointed at) otherwise reads as an idle cycle.\n // That double-counts a resource that both changed and lost a field, which is harmless here:\n // every caller compares this against an earlier value rather than reading it as a total.\n return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.fieldsRemoved;\n}\n\n/** The poll-cadence policy, as a pure function of the last cycle's outcome.\n *\n * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no\n * link can be polled faster than configured. `rttMs` raises that bound on a slow link.\n * `currentMs` is the interval in force for the cycle that just finished, which is what the\n * idle backoff compounds on. */\nexport function derivePollingInterval(opts: {\n configuredMs: number;\n currentMs: number;\n rttMs: number | undefined;\n changed: boolean;\n}): number {\n const { configuredMs, currentMs, rttMs, changed } = opts;\n\n // The cap applies to the RTT-derived part only, so `configuredMs` stays an absolute lower\n // bound even if it is ever set above the cap.\n const floor =\n rttMs === undefined\n ? configuredMs\n : Math.max(\n configuredMs,\n Math.min(MAX_RTT_POLL_INTERVAL_MS, Math.ceil(rttMs * RTT_POLL_FACTOR)),\n );\n\n const next = changed ? floor : Math.max(floor, currentMs * IDLE_BACKOFF_MULTIPLIER);\n\n // The ceiling never cuts below the floor: a link slower than MAX_POLLING_INTERVAL_MS still\n // gets its RTT-derived spacing rather than being forced to re-poll early.\n return Math.max(floor, Math.min(MAX_POLLING_INTERVAL_MS, next));\n}\n\ntype ScheduledRefresh = {\n resolve: () => void;\n reject: (err: any) => void;\n};\n\nexport class SynchronizedTreeState {\n private readonly finalPredicate: FinalResourceDataPredicate;\n private state: PlTreeState;\n private readonly pollingInterval: number;\n private readonly pruning?: PruningFunction;\n private readonly fieldFilter?: Filter;\n private readonly traverseStopRules?: Filter;\n private readonly traversalMode: TraversalMode;\n /** Resolved once from {@link traversalMode} and the server's capabilities, and used by every\n * poll of this tree. Selecting per poll was only sound while no algorithm kept state between\n * polls; pinning it here is what lets one do so. Only ever reassigned by the one-way\n * demotion in {@link loadAndApply} when the backend advertises delta but issues no token. */\n private algorithm: TreeLoadingAlgorithmName;\n /** Change token the last successful delta apply was dated at, handed to the next poll so\n * the backend sends only what moved since. Undefined until the first delta poll commits\n * one, and again whenever {@link discardDeltaToken} drops it. */\n private deltaToken: Uint8Array | undefined;\n private readonly logStat?: StatLoggingMode;\n private readonly hooks: PollingComputableHooks;\n private readonly abortController = new AbortController();\n\n /** Explicit-resource seeds: fixed roots, present from construction. */\n private readonly explicitRoots: SignedResourceId[];\n /** Shared-type seeds (discovered roots), if any. */\n private readonly sharedSeeds: SharedTypeSeed[];\n /** Roots discovered for shared-type seeds on the last discovery poll. */\n private discoveredRoots: SignedResourceId[] = [];\n\n /** Bumped once per refresh cycle that brought something new: a resource appeared, changed,\n * or became final. Lets a holder tell whether the tree has moved since it last persisted\n * it, without diffing state. Read through {@link changeGeneration}. */\n private changeGenerationCounter = 0;\n\n /** Whether a snapshot was actually applied. Read through\n * {@link wasRestoredFromSnapshot}. */\n private restoredFromSnapshot = false;\n\n private constructor(\n private readonly pl: PlClient,\n seeds: TreeSeed[],\n ops: SynchronizedTreeOps,\n private readonly logger?: MiLogger,\n ) {\n const {\n finalPredicateOverride,\n pruning,\n fieldFilter,\n traverseStopRules,\n traversalMode,\n pollingInterval,\n stopPollingDelay,\n logStat,\n } = ops;\n this.pruning = pruning;\n this.fieldFilter = fieldFilter;\n this.traverseStopRules = traverseStopRules;\n this.traversalMode = traversalMode ?? \"auto\";\n this.algorithm = resolveTreeLoadingAlgorithm(\n this.traversalMode,\n pl.serverInfo.capabilities ?? [],\n logger,\n );\n logger?.info(`tree loading algorithm: ${this.algorithm} (traversalMode=${this.traversalMode})`);\n this.pollingInterval = pollingInterval;\n this.effectivePollingInterval = pollingInterval;\n this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;\n this.logStat = logStat;\n\n this.explicitRoots = seeds\n .filter((s): s is ExplicitRootSeed => s.kind === \"resource\")\n .map((s) => s.root);\n this.sharedSeeds = seeds.filter((s): s is SharedTypeSeed => s.kind === \"shared\");\n\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n this.hooks = new PollingComputableHooks(\n () => this.startUpdating(),\n () => this.stopUpdating(),\n { stopDebounce: stopPollingDelay },\n (resolve, reject) => this.scheduleOnNextState(resolve, reject),\n );\n }\n\n /** The current protected root set: explicit roots plus the latest discovered roots. */\n private currentRootSet(): Set<SignedResourceId> {\n return new Set([...this.explicitRoots, ...this.discoveredRoots]);\n }\n\n /** How many refresh cycles brought something new. Only ever increases. Equal values at two\n * points in time mean nothing was added, changed or settled in between, which is what makes\n * a periodic snapshot write skippable on an idle tree. */\n public get changeGeneration(): number {\n return this.changeGenerationCounter;\n }\n\n /** True only if a snapshot was actually applied as this tree's initial state. A snapshot can\n * be supplied and still be refused (wrong roots, or state the update call will not accept),\n * in which case this stays false and the tree started empty like any other. Passing\n * `restoreFrom` is therefore not evidence of a warm start; this is. */\n public get wasRestoredFromSnapshot(): boolean {\n return this.restoredFromSnapshot;\n }\n\n /** Captures the current mirror for persistence.\n *\n * Must be called before {@link terminate}: terminating invalidates the tree, and capturing\n * an invalidated tree is refused rather than silently written. */\n public capture(witness: ResourceSignature): PersistedTree {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return captureTreeState(this.state, witness);\n }\n\n /** Installs a snapshot as this tree's state. Returns false if the snapshot was refused, in\n * which case the tree is left as it was and the open proceeds cold.\n *\n * Only meaningful before the first refresh, which is why it is private and driven from\n * {@link init}: replacing the state of a running tree would strand its observers. */\n private restore(snapshot: PersistedTree): boolean {\n if (this.sharedSeeds.length > 0) {\n this.logger?.warn(\"ignoring tree snapshot: trees with shared-type seeds are not restored\");\n return false;\n }\n\n const roots = this.currentRootSet();\n const snapshotRoots = new Set(snapshot.roots);\n if (snapshotRoots.size !== roots.size || ![...snapshotRoots].every((r) => roots.has(r))) {\n // A snapshot addressed to a different root is a mis-keyed file, not a stale one.\n this.logger?.warn(\"ignoring tree snapshot: its roots are not this tree's roots\");\n return false;\n }\n\n const restored = restoreTreeState(snapshot, this.finalPredicate, {\n roots,\n logger: this.logger,\n });\n if (restored === undefined) return false;\n\n this.state = restored;\n this.restoredFromSnapshot = true;\n return true;\n }\n\n /** Resolves the single root for the backward-compatible single-root accessors, throwing\n * if the tree does not have exactly one root (guards legacy callers against multi-root). */\n private soleRoot(): SignedResourceId {\n const roots = this.currentRootSet();\n if (roots.size !== 1)\n throw new Error(\n `single-root accessor used on a tree with ${roots.size} roots; use rootsEntry() instead`,\n );\n return roots.values().next().value!;\n }\n\n /** @deprecated use \"entry\" instead */\n public accessor(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return this.entry(rid);\n }\n\n /** Backward-compatible single-root entry. With no `rid` it returns the sole root's entry\n * and THROWS if the tree has zero or more than one root. An explicit `rid` addresses any\n * resource in the heap, as today. */\n public entry(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeEntry(\n { treeProvider: () => this.state, hooks: this.hooks },\n rid ?? this.soleRoot(),\n );\n }\n\n /** Reactive provider for the current root SET. Reading it inside a Computable tracks the\n * set as a dependency, so the Computable recomputes when discovered roots appear/disappear. */\n public rootsEntry(): PlTreeRootsEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeRootsEntry({ treeProvider: () => this.state, hooks: this.hooks });\n }\n\n /** Can be used to externally kick off the synchronization polling loop, and\n * await for the first synchronization to happen. */\n public async refreshState(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n await this.hooks.refreshState();\n }\n\n private currentLoopDelayInterrupt: AbortController | undefined = undefined;\n private scheduledOnNextState: ScheduledRefresh[] = [];\n\n /** Interval actually used for the current wait. Starts at the configured `pollingInterval`\n * and is re-derived after every cycle by {@link updatePollingInterval}. */\n private effectivePollingInterval: number;\n\n /** Re-derives {@link effectivePollingInterval} after a cycle.\n *\n * Two independent effects. The floor scales with the client's measured RTT, so a\n * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle\n * that changed nothing multiplies the interval out towards\n * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so\n * an active tree stays responsive. */\n private updatePollingInterval(changed: boolean): void {\n this.effectivePollingInterval = derivePollingInterval({\n configuredMs: this.pollingInterval,\n currentMs: this.effectivePollingInterval,\n rttMs: this.pl.rttEstimateMs,\n changed,\n });\n }\n\n /** Called from computable hooks when external observer asks for state refresh */\n private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {\n if (this.terminated) reject(new Error(\"tree synchronization is terminated\"));\n else {\n this.scheduledOnNextState.push({ resolve, reject });\n // Someone is waiting on fresh state, so this tree is not idle after all: drop any\n // accumulated backoff, otherwise the cycles right after a nudge stay slow. Routed\n // through the policy rather than assigning the configured value directly, so the RTT\n // floor survives the reset. Assigning it raw would poll a high-latency link faster than\n // it can answer, and the interval would stay there until the next cycle that completes:\n // the error path never reaches updatePollingInterval, so a failing nudged refresh would\n // keep retrying at the un-floored rate.\n this.updatePollingInterval(true);\n if (this.currentLoopDelayInterrupt) {\n this.currentLoopDelayInterrupt.abort();\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n\n /** Called from observer */\n private startUpdating(): void {\n if (this.terminated) return;\n this.keepRunning = true;\n if (this.currentLoop === undefined) this.currentLoop = this.mainLoop();\n }\n\n /** Called from observer */\n private stopUpdating(): void {\n this.keepRunning = false;\n }\n\n /** If true, main loop will continue polling pl state. */\n private keepRunning = false;\n /** Actual state of main loop. */\n private currentLoop: Promise<void> | undefined = undefined;\n\n /** Executed from the main loop, and initialization procedure. */\n private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n try {\n await this.loadAndApply(stats, txOps);\n } catch (e) {\n // Discovery-tree self-heal: a discovered root whose grant was revoked/expired fails the whole\n // ResourceTree poll with Unauthenticated. Re-discover (drops dead roots) and retry once. This is\n // self-discriminating — a genuinely dead session also fails discover()'s own call, so real auth\n // loss still propagates. Only for discovery trees; explicit-root trees propagate as-is.\n if (this.sharedSeeds.length > 0 && isUnauthenticated(e)) {\n this.logger?.warn(\n \"discovery tree: Unauthenticated on ResourceTree (likely revoked/expired root); re-discovering and retrying\",\n );\n await this.discover();\n await this.loadAndApply(stats, txOps);\n } else throw e;\n }\n }\n\n private async loadAndApply(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n const request = constructTreeLoadingRequest(this.state, {\n pruningFunction: this.pruning,\n fieldFilter: this.fieldFilter,\n traverseStopRules: this.traverseStopRules,\n changedSinceToken: this.deltaToken,\n });\n // A shared-type-seed tree with no currently-discovered roots is legitimately empty:\n // there is nothing to traverse, and tx.resourceTree([]) would throw \"at least one seed\n // must be provided\". Skip the backend load and leave the (empty) state as-is — discovery\n // adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees\n // never hit this (their root set is non-empty by construction).\n if (request.seedResources.length === 0 && request.finalResources.size === 0) return;\n const { data, nextToken } = await this.pl.withReadTx(\n \"ReadingTree\",\n async (tx) => {\n // Started, not awaited, before the walk. The token dates the transaction rather than\n // the response, so it is not an input to the request - the request carries the\n // PREVIOUS poll's token. Awaiting it here would block on the tx-open response before\n // sending the tree request, costing a whole round trip that streaming does not pay,\n // because requests pipeline on one bidi stream and withReadTx does not await the open.\n const tokenPromise =\n this.algorithm === \"backend-delta\" ? tx.getNextSinceToken() : undefined;\n const data = await loadTreeState(\n tx,\n request,\n stats,\n this.pl.serverInfo.capabilities ?? [],\n this.algorithm,\n this.logger,\n );\n return { data, nextToken: await tokenPromise };\n },\n txOps,\n );\n this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });\n\n // Only with the whole batch applied: advancing past a partial apply loses the dropped\n // resources for good. A throw above leaves the old token, so the next poll re-reads it.\n if (nextToken !== undefined) this.deltaToken = nextToken;\n else if (this.algorithm === \"backend-delta\") this.demoteFromDelta();\n }\n\n /** Give up on delta for the life of this tree, once, when the backend advertises\n * `treeChangedSince:v1` but hands out no token.\n *\n * Without this the tree stays on delta with `deltaToken` permanently unset, and every poll\n * is then a token-less delta poll: a full tree read that also sends no stop rules, so it\n * transfers the subtrees the streaming path prunes away. Nothing else detects it -\n * `deltaSuspectedFullAnswers` only fires when a token WAS sent - so it would run at the\n * poll interval, forever, silently. Streaming is the correct destination: it is what `auto`\n * would have picked without the capability, and it restores the stop rules. */\n private demoteFromDelta() {\n this.algorithm = supportsResourceTreeTraversal(this.pl.serverInfo.capabilities ?? [])\n ? \"backend-streaming\"\n : \"client-bfs\";\n this.logger?.warn(\n `tree: backend advertises treeChangedSince:v1 but issued no change token; ` +\n `falling back to ${this.algorithm} for the life of this tree`,\n );\n }\n\n /** Discards the change token, so the next delta poll asks for the full tree. Required\n * whenever the mirror stops being a faithful record of what the token says we hold: a\n * rebuild after {@link TreeStateUpdateError}, or a root-set change, which reshapes the\n * traversal the token was earned under. */\n private discardDeltaToken(reason: string) {\n if (this.deltaToken === undefined) return;\n this.deltaToken = undefined;\n this.logger?.info(`tree delta token discarded (${reason}); next poll reads the full tree`);\n }\n\n /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and\n * reconciles the discovered root set against the heap. A longer poll result adds roots; a\n * shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade\n * to collection via the ordinary refcount GC ({@link PlTreeState.setRoots}). No-op when the\n * tree has no shared-type seeds, or silently no-op on a REST client where `ListUserResources`\n * is unavailable. */\n private async discover(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n if (this.sharedSeeds.length === 0) return;\n\n const discovered = new Set<SignedResourceId>();\n for (const seed of this.sharedSeeds) {\n let ids: SignedResourceId[];\n try {\n // match by name only (permissive; ignores version, so it survives schema bumps)\n ids = await this.pl.userResources.listSharedResourcesByType(seed.resourceType.name);\n } catch (e: unknown) {\n if (isUnimplementedError(e)) continue;\n throw e;\n }\n for (const id of ids) discovered.add(id);\n }\n\n const rootsChanged =\n discovered.size !== this.discoveredRoots.length ||\n this.discoveredRoots.some((id) => !discovered.has(id));\n\n this.discoveredRoots = [...discovered];\n this.state.setRoots(this.currentRootSet());\n\n // A root arriving brings a subtree the token would skip as unchanged, and one leaving\n // takes its subtree with it. Either way the token no longer describes what we hold.\n if (rootsChanged) this.discardDeltaToken(\"root set changed\");\n }\n\n /** If true this tree state is permanently terminaed. */\n private terminated = false;\n\n private async mainLoop() {\n // Always collected, even when not logging: the change counters drive the idle backoff\n // below. Counter bumps are cheap next to the round trip they describe.\n let stat = initialTreeLoadingStat();\n\n let lastUpdate = Date.now();\n\n // paces the discovery poll for shared-type seeds; 0 forces discovery on the first pass.\n let lastDiscovery = 0;\n\n while (true) {\n if (!this.keepRunning || this.terminated) break;\n\n // saving those who want to be notified about new state here\n // because those who will be added during the tree retrieval\n // should be notified only on the next round\n let toNotify: ScheduledRefresh[] | undefined = undefined;\n if (this.scheduledOnNextState.length > 0) {\n toNotify = this.scheduledOnNextState;\n this.scheduledOnNextState = [];\n }\n\n try {\n // resetting stats if we were asked to collect non-cumulative stats\n if (this.logStat === \"per-request\") stat = initialTreeLoadingStat();\n\n // discovery sync for shared-type seeds: reconcile the discovered root set before\n // refreshing, so newly discovered roots are materialized in this same iteration.\n if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {\n await this.discover();\n lastDiscovery = Date.now();\n }\n\n // Change counters before the refresh, so the delta tells us whether this single cycle\n // brought anything new. Works in both stat modes: \"per-request\" resets to 0 above.\n const changesBefore = countedChanges(stat);\n\n // actual tree synchronization\n await this.refresh(stat);\n\n const changed = countedChanges(stat) > changesBefore;\n if (changed) this.changeGenerationCounter++;\n this.updatePollingInterval(changed);\n\n // logging stats if we were asked to\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we got new state\n if (toNotify !== undefined) for (const n of toNotify) n.resolve();\n } catch (e: any) {\n // logging stats if we were asked to (even if error occured)\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we failed to refresh the state\n if (toNotify !== undefined) for (const n of toNotify) n.reject(e);\n\n // catching tree update errors, as they may leave our tree in inconsistent state\n if (e instanceof TreeStateUpdateError) {\n // important error logging, this should never happen\n this.logger?.error(e);\n\n // marking everybody who used previous state as changed\n this.state.invalidateTree(\"stat update error\");\n // creating new tree with the full current root set (re-discovered on next iteration)\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n // The new mirror holds nothing, so the old token would skip everything.\n this.discardDeltaToken(\"tree rebuilt after update error\");\n\n // scheduling state update without delay\n continue;\n\n // unfortunately external observer may still see tree in its default\n // empty state, though this is best we can do in this exceptional\n // situation, and hope on caching layers inside computables to present\n // some stale state until we reconstruct the tree again\n } else this.logger?.warn(e);\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 1: mandatory floor — always wait at least MIN_POLLING_INTERVAL_MS.\n // Not interruptible by scheduleOnNextState; only termination aborts it.\n try {\n await tp.setTimeout(MIN_POLLING_INTERVAL_MS, undefined, {\n signal: this.abortController.signal,\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 2: optional remainder up to pollingInterval — interruptible by\n // scheduleOnNextState so that an external nudge wakes the loop promptly.\n if (this.scheduledOnNextState.length === 0) {\n const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);\n if (remaining > 0) {\n try {\n this.currentLoopDelayInterrupt = new AbortController();\n await tp.setTimeout(remaining, undefined, {\n signal: AbortSignal.any([\n this.abortController.signal,\n this.currentLoopDelayInterrupt.signal,\n ]),\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n // Otherwise it was just the loop delay interrupt (scheduleOnNextState),\n // continue to the next iteration\n } finally {\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n }\n\n // reset only as a very last line\n this.currentLoop = undefined;\n }\n\n /**\n * Dumps the current state of the tree.\n * @returns An array of ExtendedResourceData objects representing the current state of the tree.\n */\n public dumpState(): ExtendedResourceData[] {\n return this.state.dumpState();\n }\n\n /**\n * Terminates the internal loop, and permanently destoys all internal state, so\n * all computables using this state will resolve to errors.\n * */\n public async terminate(): Promise<void> {\n this.keepRunning = false;\n this.terminated = true;\n this.abortController.abort();\n\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n\n this.state.invalidateTree(\"synchronization terminated for the tree\");\n }\n\n /** @deprecated */\n public async awaitSyncLoopTermination(): Promise<void> {\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n }\n\n /**\n * Initializes a synchronized tree from one or more seeds.\n *\n * @param seeds a bare {@link SignedResourceId} (the original single-root contract), a\n * single {@link TreeSeed}, or an array of seeds. Bare ids and explicit-resource seeds\n * become roots immediately; shared-type seeds discover their roots via `ListUserResources`.\n */\n public static async init(\n pl: PlClient,\n seeds: SignedResourceId | TreeSeed | TreeSeed[],\n ops: SynchronizedTreeOps,\n logger?: MiLogger,\n ) {\n const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);\n\n // Seed from the snapshot before the first refresh, so that refresh is the one that\n // transfers only what changed. A refused snapshot leaves an ordinary cold open.\n const restored = ops.restoreFrom !== undefined && tree.restore(ops.restoreFrom);\n\n // Always collected, even when not logging: the initial load's change count is what seeds\n // the change generation, so a holder can tell a populated tree from an untouched one.\n const stat = initialTreeLoadingStat();\n\n let ok = false;\n\n try {\n // resolve shared-type seeds before the first refresh so discovered roots load now\n await tree.discover();\n await tree.refresh(stat, {\n timeout: ops.initialTreeLoadingTimeout,\n });\n ok = true;\n } finally {\n if (countedChanges(stat) > 0) tree.changeGenerationCounter++;\n\n // logging stats if we were asked to (even if error occured)\n if (ops.logStat && logger)\n logger.info(\n `Tree stat (initial load, ${ok ? \"success\" : \"failure\"}, ${\n restored ? \"restored from snapshot\" : \"cold\"\n }): ${JSON.stringify(stat)}`,\n );\n }\n\n return tree;\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milaboratories/pl-tree",
3
- "version": "1.14.3",
3
+ "version": "1.15.0",
4
4
  "description": "Reactive pl tree state",
5
5
  "files": [
6
6
  "./dist/**/*",
@@ -21,9 +21,9 @@
21
21
  "denque": "^2.1.0",
22
22
  "utility-types": "^3.11.0",
23
23
  "zod": "~3.25.76",
24
+ "@milaboratories/pl-errors": "1.4.41",
25
+ "@milaboratories/pl-client": "3.17.0",
24
26
  "@milaboratories/computable": "2.9.8",
25
- "@milaboratories/pl-errors": "1.4.39",
26
- "@milaboratories/pl-client": "3.16.1",
27
27
  "@milaboratories/ts-helpers": "1.8.6"
28
28
  },
29
29
  "devDependencies": {
@@ -31,9 +31,9 @@
31
31
  "@vitest/coverage-istanbul": "^4.1.3",
32
32
  "typescript": "7.0.2",
33
33
  "vitest": "^4.1.3",
34
- "@milaboratories/ts-builder": "1.7.2",
35
34
  "@milaboratories/build-configs": "2.0.1",
36
- "@milaboratories/ts-configs": "1.4.0"
35
+ "@milaboratories/ts-configs": "1.4.0",
36
+ "@milaboratories/ts-builder": "1.7.2"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=22.19.0"
@@ -0,0 +1,197 @@
1
+ import { test, expect } from "vitest";
2
+ import { field, hasCapability, TestHelpers } from "@milaboratories/pl-client";
3
+ import type { PlClient, PlTransaction, SignedResourceId } from "@milaboratories/pl-client";
4
+ import { TestStructuralResourceType1 } from "./test_utils";
5
+ import { SynchronizedTreeState } from "./synchronized_tree";
6
+ import type { ExtendedResourceData } from "./state";
7
+ import type { TraversalMode } from "./sync";
8
+ import { ConsoleLoggerAdapter } from "@milaboratories/ts-helpers";
9
+
10
+ /**
11
+ * Differential test: every loading algorithm must arrive at the same mirror, after every step
12
+ * of a sequence of mutations, not just at the end.
13
+ *
14
+ * This is the backwards-compatibility guard for making delta the default. `client-bfs` and
15
+ * `backend-streaming` are the algorithms that shipped; `backend-delta` is the new one, and it
16
+ * reaches the tree by a completely different route - it is told what changed rather than
17
+ * walking to find out. Nothing but a full-state comparison catches an update it fails to
18
+ * deliver, because a missed change looks exactly like a change that has not happened yet.
19
+ *
20
+ * SCOPE, and why it is what it is: no `pruning`, `fieldFilter` or `traverseStopRules` are
21
+ * configured. Those legitimately make the mirrors differ - streaming sends stop rules to the
22
+ * backend where delta cannot, and BFS declines to traverse a pruned field where the backend
23
+ * paths prune after the frames arrive. Equivalence is claimed for an unshaped tree only.
24
+ */
25
+
26
+ const logger = new ConsoleLoggerAdapter(console);
27
+
28
+ /** Every comparable byte of the mirror, canonically ordered. Ids, field pointers, KV, data,
29
+ * and every flag - a comparison that skipped any of these would pass while an algorithm
30
+ * silently dropped that part of an update. */
31
+ function canonical(resources: ExtendedResourceData[]): string {
32
+ return resources
33
+ .map((r) =>
34
+ [
35
+ r.id,
36
+ r.type.name + "@" + r.type.version,
37
+ r.kind,
38
+ `data=${r.data === undefined ? "-" : Buffer.from(r.data).toString("hex")}`,
39
+ `err=${r.error}`,
40
+ `orig=${r.originalResourceId}`,
41
+ `ready=${r.resourceReady}`,
42
+ `in=${r.inputsLocked}`,
43
+ `out=${r.outputsLocked}`,
44
+ `final=${r.final}`,
45
+ "fields=" +
46
+ [...r.fields]
47
+ .sort((a, b) => a.name.localeCompare(b.name))
48
+ .map((f) => `${f.name}:${f.type}:${f.status}:${f.value}:${f.error}:${f.valueIsFinal}`)
49
+ .join(","),
50
+ "kv=" +
51
+ [...r.kv]
52
+ .sort((a, b) => a.key.localeCompare(b.key))
53
+ .map((kv) => `${kv.key}=${Buffer.from(kv.value).toString("hex")}`)
54
+ .join(","),
55
+ ].join("|"),
56
+ )
57
+ .sort()
58
+ .join("\n");
59
+ }
60
+
61
+ async function openTree(pl: PlClient, root: SignedResourceId, traversalMode: TraversalMode) {
62
+ return await SynchronizedTreeState.init(
63
+ pl,
64
+ root,
65
+ { stopPollingDelay: 50, pollingInterval: 10, traversalMode },
66
+ logger,
67
+ );
68
+ }
69
+
70
+ /** Polls until the mirror stops changing, so a slower algorithm is given the chance to catch
71
+ * up rather than being failed for latency. A real divergence survives this. */
72
+ async function settle(tree: SynchronizedTreeState): Promise<string> {
73
+ let last = "";
74
+ for (let i = 0; i < 8; i++) {
75
+ await tree.refreshState();
76
+ const now = canonical(tree.dumpState());
77
+ if (now === last && i > 0) return now;
78
+ last = now;
79
+ }
80
+ return last;
81
+ }
82
+
83
+ test("every algorithm converges on the same mirror at every step", async () => {
84
+ await TestHelpers.withTempRoot(async (pl) => {
85
+ const caps = pl.serverInfo.capabilities ?? [];
86
+ const modes: TraversalMode[] = ["client-bfs", "backend-streaming"];
87
+ if (hasCapability(caps, "treeChangedSince:v1")) modes.push("backend-delta");
88
+ else console.warn("SKIPPING backend-delta: backend lacks treeChangedSince:v1");
89
+
90
+ // A root with two children, deep enough that a change can hide under an unchanged parent.
91
+ const seed = await pl.withWriteTx(
92
+ "EquivSeed",
93
+ async (tx) => {
94
+ const root = tx.createStruct(TestStructuralResourceType1);
95
+ const rf = field(tx.clientRoot, "equivRoot");
96
+ tx.createField(rf, "Dynamic");
97
+ tx.setField(rf, root);
98
+
99
+ const a = tx.createStruct(TestStructuralResourceType1, Buffer.from("a-data"));
100
+ const af = field(root, "a");
101
+ tx.createField(af, "Dynamic");
102
+ tx.setField(af, a);
103
+
104
+ const b = tx.createStruct(TestStructuralResourceType1);
105
+ const bf = field(a, "b");
106
+ tx.createField(bf, "Dynamic");
107
+ tx.setField(bf, b);
108
+
109
+ // Attached under the client root, NOT under our tree root: it exists and ages
110
+ // without this tree ever seeing it. Attaching it later is the only mutation that
111
+ // makes a delta poll reference a resource older than its own token.
112
+ const stranger = tx.createStruct(TestStructuralResourceType1, Buffer.from("old-data"));
113
+ const sf = field(tx.clientRoot, "equivStranger");
114
+ tx.createField(sf, "Dynamic");
115
+ tx.setField(sf, stranger);
116
+
117
+ await tx.commit();
118
+ return {
119
+ root: await root.globalId,
120
+ a: await a.globalId,
121
+ b: await b.globalId,
122
+ stranger: await stranger.globalId,
123
+ };
124
+ },
125
+ { sync: true },
126
+ );
127
+
128
+ const trees = new Map<TraversalMode, SynchronizedTreeState>();
129
+ for (const m of modes) trees.set(m, await openTree(pl, seed.root, m));
130
+
131
+ /** Each step mutates the tree, then every algorithm must agree on the result. */
132
+ const steps: [string, (tx: PlTransaction) => void][] = [
133
+ // A KV write deep in the tree: b's own state moves while root and a stay quiet, which
134
+ // is the case a root-seeded delta walk cannot reach.
135
+ ["kv on a leaf", (tx) => tx.setKValue(seed.b, "k1", Buffer.from("v1"))],
136
+ ["kv overwrite", (tx) => tx.setKValue(seed.b, "k1", Buffer.from("v2"))],
137
+ ["second kv", (tx) => tx.setKValue(seed.b, "k2", Buffer.from("v3"))],
138
+ // A new resource attached under a held one: the referrer changes and points at
139
+ // something no mirror has seen, which is what delta's resolution round exists for.
140
+ [
141
+ "new resource under a leaf",
142
+ (tx) => {
143
+ const c = tx.createStruct(TestStructuralResourceType1, Buffer.from("c-data"));
144
+ const cf = field(seed.b, "c");
145
+ tx.createField(cf, "Dynamic");
146
+ tx.setField(cf, c);
147
+ },
148
+ ],
149
+ // The case that forces a resolution round: a field repointed at a resource that is
150
+ // OLDER than the poll's token, so the delta walk will not carry it and the client has
151
+ // to read it back explicitly. A brand-new resource does not test this - new means
152
+ // changed, so the walk delivers it anyway.
153
+ [
154
+ "attach a pre-existing resource the mirror never held",
155
+ (tx) => {
156
+ const sf = field(seed.b, "stranger");
157
+ tx.createField(sf, "Dynamic");
158
+ tx.setField(sf, seed.stranger);
159
+ },
160
+ ],
161
+ // Locking changes flags without touching data or fields.
162
+ ["lock inputs on a leaf", (tx) => tx.lockInputs(seed.b)],
163
+ ["lock outputs on a leaf", (tx) => tx.lockOutputs(seed.b)],
164
+ // A field removal drives the refcount GC cascade, which is the one change that does not
165
+ // set the changed flag in updateFromResourceData.
166
+ ["remove a field", (tx) => tx.removeField(field(seed.a, "b"))],
167
+ ];
168
+
169
+ try {
170
+ const first = new Map<TraversalMode, string>();
171
+ for (const m of modes) first.set(m, await settle(trees.get(m)!));
172
+ for (const m of modes.slice(1))
173
+ expect(first.get(m), `initial load: ${m} vs ${modes[0]}`).toBe(first.get(modes[0]!));
174
+
175
+ for (const [label, mutate] of steps) {
176
+ await pl.withWriteTx(
177
+ "EquivStep",
178
+ async (tx) => {
179
+ mutate(tx);
180
+ await tx.commit();
181
+ },
182
+ { sync: true },
183
+ );
184
+
185
+ const shapes = new Map<TraversalMode, string>();
186
+ for (const m of modes) shapes.set(m, await settle(trees.get(m)!));
187
+
188
+ const reference = shapes.get(modes[0]!)!;
189
+ for (const m of modes.slice(1)) {
190
+ expect(shapes.get(m), `after "${label}": ${m} diverged from ${modes[0]}`).toBe(reference);
191
+ }
192
+ }
193
+ } finally {
194
+ for (const t of trees.values()) await t.terminate();
195
+ }
196
+ });
197
+ }, 300_000);