@milaboratories/pl-tree 1.13.7 → 1.14.1

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.
Files changed (41) hide show
  1. package/dist/accessors.d.ts +15 -17
  2. package/dist/accessors.d.ts.map +1 -1
  3. package/dist/dump.d.ts +14 -10
  4. package/dist/dump.d.ts.map +1 -1
  5. package/dist/index.cjs +7 -0
  6. package/dist/index.d.ts +2 -1
  7. package/dist/index.js +2 -1
  8. package/dist/persisted_tree.cjs +465 -0
  9. package/dist/persisted_tree.cjs.map +1 -0
  10. package/dist/persisted_tree.d.ts +99 -0
  11. package/dist/persisted_tree.d.ts.map +1 -0
  12. package/dist/persisted_tree.js +460 -0
  13. package/dist/persisted_tree.js.map +1 -0
  14. package/dist/snapshot.d.ts +12 -14
  15. package/dist/snapshot.d.ts.map +1 -1
  16. package/dist/state.cjs +7 -0
  17. package/dist/state.cjs.map +1 -1
  18. package/dist/state.d.ts +13 -11
  19. package/dist/state.d.ts.map +1 -1
  20. package/dist/state.js +7 -0
  21. package/dist/state.js.map +1 -1
  22. package/dist/sync.d.ts +26 -19
  23. package/dist/sync.d.ts.map +1 -1
  24. package/dist/synchronized_tree.cjs +62 -4
  25. package/dist/synchronized_tree.cjs.map +1 -1
  26. package/dist/synchronized_tree.d.ts +64 -17
  27. package/dist/synchronized_tree.d.ts.map +1 -1
  28. package/dist/synchronized_tree.js +62 -4
  29. package/dist/synchronized_tree.js.map +1 -1
  30. package/dist/traversal_ops.d.ts +12 -10
  31. package/dist/traversal_ops.d.ts.map +1 -1
  32. package/dist/value_and_error.d.ts +3 -4
  33. package/dist/value_and_error.d.ts.map +1 -1
  34. package/dist/value_or_error.d.ts +1 -2
  35. package/dist/value_or_error.d.ts.map +1 -1
  36. package/package.json +7 -7
  37. package/src/index.ts +1 -0
  38. package/src/persisted_tree.test.ts +291 -0
  39. package/src/persisted_tree.ts +708 -0
  40. package/src/state.ts +8 -0
  41. package/src/synchronized_tree.ts +102 -5
package/src/state.ts CHANGED
@@ -469,6 +469,14 @@ export class PlTreeState {
469
469
  this.resources.forEach((v) => cb(v));
470
470
  }
471
471
 
472
+ /** False once the tree has been invalidated (an inconsistent update, or termination of
473
+ * its synchronization loop). {@link dumpState} deliberately reads through an invalid
474
+ * tree, so anything persisting that dump must check this first: the contents of an
475
+ * invalidated tree are not something to write to disk. */
476
+ public get isValid(): boolean {
477
+ return this._isValid;
478
+ }
479
+
472
480
  private checkValid() {
473
481
  if (!this._isValid) throw new Error(this.invalidationMessage ?? "tree is in invalid state");
474
482
  }
@@ -3,6 +3,7 @@ import { PlTreeEntry, PlTreeRootsEntry } from "./accessors";
3
3
  import type {
4
4
  FinalResourceDataPredicate,
5
5
  PlClient,
6
+ ResourceSignature,
6
7
  ResourceType,
7
8
  SignedResourceId,
8
9
  TxOps,
@@ -17,6 +18,8 @@ import type { ExtendedResourceData } from "./state";
17
18
  import { PlTreeState, TreeStateUpdateError } from "./state";
18
19
  import type { PruningFunction, TraversalMode, TreeLoadingStat } from "./sync";
19
20
  import { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from "./sync";
21
+ import type { PersistedTree } from "./persisted_tree";
22
+ import { captureTreeState, restoreTreeState } from "./persisted_tree";
20
23
  import * as tp from "node:timers/promises";
21
24
  import type { MiLogger } from "@milaboratories/ts-helpers";
22
25
 
@@ -74,6 +77,19 @@ export type SynchronizedTreeOps = {
74
77
 
75
78
  /** Controls which tree-loading path to use. Default `"auto"`. */
76
79
  traversalMode?: TraversalMode;
80
+
81
+ /** A previously persisted mirror to seed the tree with, before its first refresh, so that
82
+ * refresh transfers only what changed while the tree was gone.
83
+ *
84
+ * A snapshot that cannot be applied, or does not belong to this tree, is logged and dropped,
85
+ * leaving an ordinary cold open.
86
+ *
87
+ * A snapshot that applies but whose ids are dead is NOT handled here: its resources become
88
+ * this tree's seeds, so the first refresh fails and {@link init} rejects, where a cold open
89
+ * would have succeeded. Establishing that the signatures are still live is the caller's job
90
+ * (see {@link PersistedTree.witness}), as is deciding what to do when the first refresh is
91
+ * refused anyway. Ignored for trees with shared-type seeds, which rediscover their roots. */
92
+ restoreFrom?: PersistedTree;
77
93
  };
78
94
 
79
95
  /** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */
@@ -116,7 +132,13 @@ const DISCOVERY_INTERVAL_MS = 3_000;
116
132
  * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged
117
133
  * state is exactly the idle case the backoff exists for. */
118
134
  function countedChanges(stat: TreeLoadingStat): number {
119
- return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal;
135
+ // `fieldsRemoved` is included despite being a per-field count, because it is the one change
136
+ // that never shows up in `resourcesChanged`: the removed-dynamic-field branch in
137
+ // `updateFromResourceData` does not set its `changed` flag, so a cycle that only dropped a
138
+ // field (and garbage-collected whatever it pointed at) otherwise reads as an idle cycle.
139
+ // That double-counts a resource that both changed and lost a field, which is harmless here:
140
+ // every caller compares this against an earlier value rather than reading it as a total.
141
+ return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.fieldsRemoved;
120
142
  }
121
143
 
122
144
  /** The poll-cadence policy, as a pure function of the last cycle's outcome.
@@ -174,6 +196,15 @@ export class SynchronizedTreeState {
174
196
  /** Roots discovered for shared-type seeds on the last discovery poll. */
175
197
  private discoveredRoots: SignedResourceId[] = [];
176
198
 
199
+ /** Bumped once per refresh cycle that brought something new: a resource appeared, changed,
200
+ * or became final. Lets a holder tell whether the tree has moved since it last persisted
201
+ * it, without diffing state. Read through {@link changeGeneration}. */
202
+ private changeGenerationCounter = 0;
203
+
204
+ /** Whether a snapshot was actually applied. Read through
205
+ * {@link wasRestoredFromSnapshot}. */
206
+ private restoredFromSnapshot = false;
207
+
177
208
  private constructor(
178
209
  private readonly pl: PlClient,
179
210
  seeds: TreeSeed[],
@@ -218,6 +249,60 @@ export class SynchronizedTreeState {
218
249
  return new Set([...this.explicitRoots, ...this.discoveredRoots]);
219
250
  }
220
251
 
252
+ /** How many refresh cycles brought something new. Only ever increases. Equal values at two
253
+ * points in time mean nothing was added, changed or settled in between, which is what makes
254
+ * a periodic snapshot write skippable on an idle tree. */
255
+ public get changeGeneration(): number {
256
+ return this.changeGenerationCounter;
257
+ }
258
+
259
+ /** True only if a snapshot was actually applied as this tree's initial state. A snapshot can
260
+ * be supplied and still be refused (wrong roots, or state the update call will not accept),
261
+ * in which case this stays false and the tree started empty like any other. Passing
262
+ * `restoreFrom` is therefore not evidence of a warm start; this is. */
263
+ public get wasRestoredFromSnapshot(): boolean {
264
+ return this.restoredFromSnapshot;
265
+ }
266
+
267
+ /** Captures the current mirror for persistence.
268
+ *
269
+ * Must be called before {@link terminate}: terminating invalidates the tree, and capturing
270
+ * an invalidated tree is refused rather than silently written. */
271
+ public capture(witness: ResourceSignature): PersistedTree {
272
+ if (this.terminated) throw new Error("tree synchronization is terminated");
273
+ return captureTreeState(this.state, witness);
274
+ }
275
+
276
+ /** Installs a snapshot as this tree's state. Returns false if the snapshot was refused, in
277
+ * which case the tree is left as it was and the open proceeds cold.
278
+ *
279
+ * Only meaningful before the first refresh, which is why it is private and driven from
280
+ * {@link init}: replacing the state of a running tree would strand its observers. */
281
+ private restore(snapshot: PersistedTree): boolean {
282
+ if (this.sharedSeeds.length > 0) {
283
+ this.logger?.warn("ignoring tree snapshot: trees with shared-type seeds are not restored");
284
+ return false;
285
+ }
286
+
287
+ const roots = this.currentRootSet();
288
+ const snapshotRoots = new Set(snapshot.roots);
289
+ if (snapshotRoots.size !== roots.size || ![...snapshotRoots].every((r) => roots.has(r))) {
290
+ // A snapshot addressed to a different root is a mis-keyed file, not a stale one.
291
+ this.logger?.warn("ignoring tree snapshot: its roots are not this tree's roots");
292
+ return false;
293
+ }
294
+
295
+ const restored = restoreTreeState(snapshot, this.finalPredicate, {
296
+ roots,
297
+ logger: this.logger,
298
+ });
299
+ if (restored === undefined) return false;
300
+
301
+ this.state = restored;
302
+ this.restoredFromSnapshot = true;
303
+ return true;
304
+ }
305
+
221
306
  /** Resolves the single root for the backward-compatible single-root accessors, throwing
222
307
  * if the tree does not have exactly one root (guards legacy callers against multi-root). */
223
308
  private soleRoot(): SignedResourceId {
@@ -439,7 +524,9 @@ export class SynchronizedTreeState {
439
524
  // actual tree synchronization
440
525
  await this.refresh(stat);
441
526
 
442
- this.updatePollingInterval(countedChanges(stat) > changesBefore);
527
+ const changed = countedChanges(stat) > changesBefore;
528
+ if (changed) this.changeGenerationCounter++;
529
+ this.updatePollingInterval(changed);
443
530
 
444
531
  // logging stats if we were asked to
445
532
  if (this.logStat && this.logger)
@@ -569,7 +656,13 @@ export class SynchronizedTreeState {
569
656
  ) {
570
657
  const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);
571
658
 
572
- const stat = ops.logStat ? initialTreeLoadingStat() : undefined;
659
+ // Seed from the snapshot before the first refresh, so that refresh is the one that
660
+ // transfers only what changed. A refused snapshot leaves an ordinary cold open.
661
+ const restored = ops.restoreFrom !== undefined && tree.restore(ops.restoreFrom);
662
+
663
+ // Always collected, even when not logging: the initial load's change count is what seeds
664
+ // the change generation, so a holder can tell a populated tree from an untouched one.
665
+ const stat = initialTreeLoadingStat();
573
666
 
574
667
  let ok = false;
575
668
 
@@ -581,10 +674,14 @@ export class SynchronizedTreeState {
581
674
  });
582
675
  ok = true;
583
676
  } finally {
677
+ if (countedChanges(stat) > 0) tree.changeGenerationCounter++;
678
+
584
679
  // logging stats if we were asked to (even if error occured)
585
- if (stat && logger)
680
+ if (ops.logStat && logger)
586
681
  logger.info(
587
- `Tree stat (initial load, ${ok ? "success" : "failure"}): ${JSON.stringify(stat)}`,
682
+ `Tree stat (initial load, ${ok ? "success" : "failure"}, ${
683
+ restored ? "restored from snapshot" : "cold"
684
+ }): ${JSON.stringify(stat)}`,
588
685
  );
589
686
  }
590
687