@milaboratories/pl-tree 1.12.13 → 1.12.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accessors.cjs +71 -1
- package/dist/accessors.cjs.map +1 -1
- package/dist/accessors.d.ts +29 -1
- package/dist/accessors.d.ts.map +1 -1
- package/dist/accessors.js +68 -1
- package/dist/accessors.js.map +1 -1
- package/dist/dump.cjs.map +1 -1
- package/dist/dump.d.ts.map +1 -1
- package/dist/dump.js.map +1 -1
- package/dist/index.cjs +4 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/snapshot.cjs +1 -2
- package/dist/snapshot.cjs.map +1 -1
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/snapshot.js +1 -1
- package/dist/snapshot.js.map +1 -1
- package/dist/state.cjs +78 -9
- package/dist/state.cjs.map +1 -1
- package/dist/state.d.ts +31 -4
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +78 -8
- package/dist/state.js.map +1 -1
- package/dist/sync.cjs +4 -2
- package/dist/sync.cjs.map +1 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +3 -1
- package/dist/sync.js.map +1 -1
- package/dist/synchronized_tree.cjs +106 -10
- package/dist/synchronized_tree.cjs.map +1 -1
- package/dist/synchronized_tree.d.ts +49 -5
- package/dist/synchronized_tree.d.ts.map +1 -1
- package/dist/synchronized_tree.js +107 -11
- package/dist/synchronized_tree.js.map +1 -1
- package/dist/traversal_ops.d.ts.map +1 -1
- package/dist/value_and_error.cjs.map +1 -1
- package/dist/value_and_error.d.ts.map +1 -1
- package/dist/value_and_error.js.map +1 -1
- package/dist/value_or_error.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/accessors.ts +79 -0
- package/src/state.ts +101 -12
- package/src/sync.ts +3 -2
- package/src/synchronized_tree.ts +153 -12
package/src/synchronized_tree.ts
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
import { PollingComputableHooks } from "@milaboratories/computable";
|
|
2
|
-
import { PlTreeEntry } from "./accessors";
|
|
2
|
+
import { PlTreeEntry, PlTreeRootsEntry } from "./accessors";
|
|
3
3
|
import type {
|
|
4
4
|
FinalResourceDataPredicate,
|
|
5
5
|
PlClient,
|
|
6
|
+
ResourceType,
|
|
6
7
|
SignedResourceId,
|
|
7
8
|
TxOps,
|
|
8
9
|
} from "@milaboratories/pl-client";
|
|
9
10
|
import type { Filter } from "@milaboratories/pl-client";
|
|
10
|
-
import { isTimeoutOrCancelError } from "@milaboratories/pl-client";
|
|
11
|
+
import { isUnauthenticated, isTimeoutOrCancelError } from "@milaboratories/pl-client";
|
|
11
12
|
import type { ExtendedResourceData } from "./state";
|
|
12
13
|
import { PlTreeState, TreeStateUpdateError } from "./state";
|
|
13
14
|
import type { PruningFunction, TraversalMode, TreeLoadingStat } from "./sync";
|
|
14
15
|
import { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from "./sync";
|
|
15
16
|
import * as tp from "node:timers/promises";
|
|
17
|
+
import type { MiLogger } from "@milaboratories/ts-helpers";
|
|
16
18
|
|
|
17
19
|
/** Hard floor between consecutive tree-refresh calls.
|
|
18
20
|
* Applies even when {@link scheduleOnNextState} has woken the loop early,
|
|
19
21
|
* preventing tight polling loops during rapid state transitions. */
|
|
20
22
|
const MIN_POLLING_INTERVAL_MS = 100;
|
|
21
|
-
import type { MiLogger } from "@milaboratories/ts-helpers";
|
|
22
23
|
|
|
23
24
|
type StatLoggingMode = "cumulative" | "per-request";
|
|
24
25
|
|
|
@@ -50,6 +51,40 @@ export type SynchronizedTreeOps = {
|
|
|
50
51
|
traversalMode?: TraversalMode;
|
|
51
52
|
};
|
|
52
53
|
|
|
54
|
+
/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */
|
|
55
|
+
export type ExplicitRootSeed = { kind: "resource"; root: SignedResourceId };
|
|
56
|
+
|
|
57
|
+
/** Discovers, as roots, every resource of this type shared with the current user.
|
|
58
|
+
* Matched against SharedResource.resourceType by NAME (version optional/ignored). The
|
|
59
|
+
* discovered set is DYNAMIC — roots appear/disappear as grants are added/revoked/expire. */
|
|
60
|
+
export type SharedTypeSeed = { kind: "shared"; resourceType: ResourceType };
|
|
61
|
+
|
|
62
|
+
export type TreeSeed = ExplicitRootSeed | SharedTypeSeed;
|
|
63
|
+
|
|
64
|
+
/** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare
|
|
65
|
+
* {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so
|
|
66
|
+
* every existing single-root caller is unchanged. */
|
|
67
|
+
function normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSeed[] {
|
|
68
|
+
if (Array.isArray(seeds)) return seeds;
|
|
69
|
+
if (typeof seeds === "object" && seeds !== null && "kind" in seeds) return [seeds];
|
|
70
|
+
// bare SignedResourceId
|
|
71
|
+
return [{ kind: "resource", root: seeds }];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** How often, in main-loop iterations, a tree with shared-type seeds re-polls
|
|
75
|
+
* ListUserResources to reconcile its discovered roots. 1 would mean every iteration.
|
|
76
|
+
*
|
|
77
|
+
* Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental
|
|
78
|
+
* refresh, so it must NOT run on every fast refresh tick. The refresh cadence is the tree's
|
|
79
|
+
* `pollingInterval` floored by {@link MIN_POLLING_INTERVAL_MS} (~200ms-1s in practice — the
|
|
80
|
+
* shared-seed discovery tree runs at the 200ms default). At N = 15 discovery fires roughly
|
|
81
|
+
* every 15 × 200ms ≈ 3s, decoupling it from the fast refresh loop while keeping the latency
|
|
82
|
+
* of noticing a new/removed share to a few seconds — acceptable for a human-driven share flow.
|
|
83
|
+
*
|
|
84
|
+
* Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root
|
|
85
|
+
* and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */
|
|
86
|
+
const DISCOVERY_EVERY_N_REFRESHES = 15;
|
|
87
|
+
|
|
53
88
|
type ScheduledRefresh = {
|
|
54
89
|
resolve: () => void;
|
|
55
90
|
reject: (err: any) => void;
|
|
@@ -67,9 +102,16 @@ export class SynchronizedTreeState {
|
|
|
67
102
|
private readonly hooks: PollingComputableHooks;
|
|
68
103
|
private readonly abortController = new AbortController();
|
|
69
104
|
|
|
105
|
+
/** Explicit-resource seeds: fixed roots, present from construction. */
|
|
106
|
+
private readonly explicitRoots: SignedResourceId[];
|
|
107
|
+
/** Shared-type seeds (discovered roots), if any. */
|
|
108
|
+
private readonly sharedSeeds: SharedTypeSeed[];
|
|
109
|
+
/** Roots discovered for shared-type seeds on the last discovery poll. */
|
|
110
|
+
private discoveredRoots: SignedResourceId[] = [];
|
|
111
|
+
|
|
70
112
|
private constructor(
|
|
71
113
|
private readonly pl: PlClient,
|
|
72
|
-
|
|
114
|
+
seeds: TreeSeed[],
|
|
73
115
|
ops: SynchronizedTreeOps,
|
|
74
116
|
private readonly logger?: MiLogger,
|
|
75
117
|
) {
|
|
@@ -90,7 +132,13 @@ export class SynchronizedTreeState {
|
|
|
90
132
|
this.pollingInterval = pollingInterval;
|
|
91
133
|
this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;
|
|
92
134
|
this.logStat = logStat;
|
|
93
|
-
|
|
135
|
+
|
|
136
|
+
this.explicitRoots = seeds
|
|
137
|
+
.filter((s): s is ExplicitRootSeed => s.kind === "resource")
|
|
138
|
+
.map((s) => s.root);
|
|
139
|
+
this.sharedSeeds = seeds.filter((s): s is SharedTypeSeed => s.kind === "shared");
|
|
140
|
+
|
|
141
|
+
this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);
|
|
94
142
|
this.hooks = new PollingComputableHooks(
|
|
95
143
|
() => this.startUpdating(),
|
|
96
144
|
() => this.stopUpdating(),
|
|
@@ -99,15 +147,44 @@ export class SynchronizedTreeState {
|
|
|
99
147
|
);
|
|
100
148
|
}
|
|
101
149
|
|
|
150
|
+
/** The current protected root set: explicit roots plus the latest discovered roots. */
|
|
151
|
+
private currentRootSet(): Set<SignedResourceId> {
|
|
152
|
+
return new Set([...this.explicitRoots, ...this.discoveredRoots]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Resolves the single root for the backward-compatible single-root accessors, throwing
|
|
156
|
+
* if the tree does not have exactly one root (guards legacy callers against multi-root). */
|
|
157
|
+
private soleRoot(): SignedResourceId {
|
|
158
|
+
const roots = this.currentRootSet();
|
|
159
|
+
if (roots.size !== 1)
|
|
160
|
+
throw new Error(
|
|
161
|
+
`single-root accessor used on a tree with ${roots.size} roots; use rootsEntry() instead`,
|
|
162
|
+
);
|
|
163
|
+
return roots.values().next().value!;
|
|
164
|
+
}
|
|
165
|
+
|
|
102
166
|
/** @deprecated use "entry" instead */
|
|
103
|
-
public accessor(rid
|
|
167
|
+
public accessor(rid?: SignedResourceId): PlTreeEntry {
|
|
104
168
|
if (this.terminated) throw new Error("tree synchronization is terminated");
|
|
105
169
|
return this.entry(rid);
|
|
106
170
|
}
|
|
107
171
|
|
|
108
|
-
|
|
172
|
+
/** Backward-compatible single-root entry. With no `rid` it returns the sole root's entry
|
|
173
|
+
* and THROWS if the tree has zero or more than one root. An explicit `rid` addresses any
|
|
174
|
+
* resource in the heap, as today. */
|
|
175
|
+
public entry(rid?: SignedResourceId): PlTreeEntry {
|
|
109
176
|
if (this.terminated) throw new Error("tree synchronization is terminated");
|
|
110
|
-
return new PlTreeEntry(
|
|
177
|
+
return new PlTreeEntry(
|
|
178
|
+
{ treeProvider: () => this.state, hooks: this.hooks },
|
|
179
|
+
rid ?? this.soleRoot(),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Reactive provider for the current root SET. Reading it inside a Computable tracks the
|
|
184
|
+
* set as a dependency, so the Computable recomputes when discovered roots appear/disappear. */
|
|
185
|
+
public rootsEntry(): PlTreeRootsEntry {
|
|
186
|
+
if (this.terminated) throw new Error("tree synchronization is terminated");
|
|
187
|
+
return new PlTreeRootsEntry({ treeProvider: () => this.state, hooks: this.hooks });
|
|
111
188
|
}
|
|
112
189
|
|
|
113
190
|
/** Can be used to externally kick off the synchronization polling loop, and
|
|
@@ -152,11 +229,35 @@ export class SynchronizedTreeState {
|
|
|
152
229
|
/** Executed from the main loop, and initialization procedure. */
|
|
153
230
|
private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {
|
|
154
231
|
if (this.terminated) throw new Error("tree synchronization is terminated");
|
|
232
|
+
try {
|
|
233
|
+
await this.loadAndApply(stats, txOps);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
// Discovery-tree self-heal: a discovered root whose grant was revoked/expired fails the whole
|
|
236
|
+
// ResourceTree poll with Unauthenticated. Re-discover (drops dead roots) and retry once. This is
|
|
237
|
+
// self-discriminating — a genuinely dead session also fails discover()'s own call, so real auth
|
|
238
|
+
// loss still propagates. Only for discovery trees; explicit-root trees propagate as-is.
|
|
239
|
+
if (this.sharedSeeds.length > 0 && isUnauthenticated(e)) {
|
|
240
|
+
this.logger?.warn(
|
|
241
|
+
"discovery tree: Unauthenticated on ResourceTree (likely revoked/expired root); re-discovering and retrying",
|
|
242
|
+
);
|
|
243
|
+
await this.discover();
|
|
244
|
+
await this.loadAndApply(stats, txOps);
|
|
245
|
+
} else throw e;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async loadAndApply(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {
|
|
155
250
|
const request = constructTreeLoadingRequest(this.state, {
|
|
156
251
|
pruningFunction: this.pruning,
|
|
157
252
|
fieldFilter: this.fieldFilter,
|
|
158
253
|
traverseStopRules: this.traverseStopRules,
|
|
159
254
|
});
|
|
255
|
+
// A shared-type-seed tree with no currently-discovered roots is legitimately empty:
|
|
256
|
+
// there is nothing to traverse, and tx.resourceTree([]) would throw "at least one seed
|
|
257
|
+
// must be provided". Skip the backend load and leave the (empty) state as-is — discovery
|
|
258
|
+
// adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees
|
|
259
|
+
// never hit this (their root set is non-empty by construction).
|
|
260
|
+
if (request.seedResources.length === 0 && request.finalResources.size === 0) return;
|
|
160
261
|
const data = await this.pl.withReadTx(
|
|
161
262
|
"ReadingTree",
|
|
162
263
|
async (tx) => {
|
|
@@ -174,6 +275,27 @@ export class SynchronizedTreeState {
|
|
|
174
275
|
this.state.updateFromResourceData(data, true);
|
|
175
276
|
}
|
|
176
277
|
|
|
278
|
+
/** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and
|
|
279
|
+
* reconciles the discovered root set against the heap. A longer poll result adds roots; a
|
|
280
|
+
* shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade
|
|
281
|
+
* to collection via the ordinary refcount GC ({@link PlTreeState.setRoots}). No-op when the
|
|
282
|
+
* tree has no shared-type seeds, or silently no-op on a REST client where `ListUserResources`
|
|
283
|
+
* is unavailable. */
|
|
284
|
+
private async discover(): Promise<void> {
|
|
285
|
+
if (this.terminated) throw new Error("tree synchronization is terminated");
|
|
286
|
+
if (this.sharedSeeds.length === 0) return;
|
|
287
|
+
|
|
288
|
+
const discovered = new Set<SignedResourceId>();
|
|
289
|
+
for (const seed of this.sharedSeeds) {
|
|
290
|
+
// match by name only (permissive; ignores version, so it survives schema bumps)
|
|
291
|
+
const ids = await this.pl.userResources.listSharedResourcesByType(seed.resourceType.name);
|
|
292
|
+
for (const id of ids) discovered.add(id);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this.discoveredRoots = [...discovered];
|
|
296
|
+
this.state.setRoots(this.currentRootSet());
|
|
297
|
+
}
|
|
298
|
+
|
|
177
299
|
/** If true this tree state is permanently terminaed. */
|
|
178
300
|
private terminated = false;
|
|
179
301
|
|
|
@@ -183,6 +305,9 @@ export class SynchronizedTreeState {
|
|
|
183
305
|
|
|
184
306
|
let lastUpdate = Date.now();
|
|
185
307
|
|
|
308
|
+
// counts refresh iterations to pace the discovery poll for shared-type seeds.
|
|
309
|
+
let iteration = 0;
|
|
310
|
+
|
|
186
311
|
while (true) {
|
|
187
312
|
if (!this.keepRunning || this.terminated) break;
|
|
188
313
|
|
|
@@ -199,6 +324,13 @@ export class SynchronizedTreeState {
|
|
|
199
324
|
// resetting stats if we were asked to collect non-cumulative stats
|
|
200
325
|
if (this.logStat === "per-request") stat = initialTreeLoadingStat();
|
|
201
326
|
|
|
327
|
+
// discovery sync for shared-type seeds: reconcile the discovered root set before
|
|
328
|
+
// refreshing, so newly discovered roots are materialized in this same iteration.
|
|
329
|
+
if (this.sharedSeeds.length > 0 && iteration % DISCOVERY_EVERY_N_REFRESHES === 0) {
|
|
330
|
+
await this.discover();
|
|
331
|
+
}
|
|
332
|
+
iteration++;
|
|
333
|
+
|
|
202
334
|
// actual tree synchronization
|
|
203
335
|
await this.refresh(stat);
|
|
204
336
|
|
|
@@ -229,8 +361,8 @@ export class SynchronizedTreeState {
|
|
|
229
361
|
|
|
230
362
|
// marking everybody who used previous state as changed
|
|
231
363
|
this.state.invalidateTree("stat update error");
|
|
232
|
-
// creating new tree
|
|
233
|
-
this.state = new PlTreeState(this.
|
|
364
|
+
// creating new tree with the full current root set (re-discovered on next iteration)
|
|
365
|
+
this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);
|
|
234
366
|
|
|
235
367
|
// scheduling state update without delay
|
|
236
368
|
continue;
|
|
@@ -315,19 +447,28 @@ export class SynchronizedTreeState {
|
|
|
315
447
|
await this.currentLoop;
|
|
316
448
|
}
|
|
317
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Initializes a synchronized tree from one or more seeds.
|
|
452
|
+
*
|
|
453
|
+
* @param seeds a bare {@link SignedResourceId} (the original single-root contract), a
|
|
454
|
+
* single {@link TreeSeed}, or an array of seeds. Bare ids and explicit-resource seeds
|
|
455
|
+
* become roots immediately; shared-type seeds discover their roots via `ListUserResources`.
|
|
456
|
+
*/
|
|
318
457
|
public static async init(
|
|
319
458
|
pl: PlClient,
|
|
320
|
-
|
|
459
|
+
seeds: SignedResourceId | TreeSeed | TreeSeed[],
|
|
321
460
|
ops: SynchronizedTreeOps,
|
|
322
461
|
logger?: MiLogger,
|
|
323
462
|
) {
|
|
324
|
-
const tree = new SynchronizedTreeState(pl,
|
|
463
|
+
const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);
|
|
325
464
|
|
|
326
465
|
const stat = ops.logStat ? initialTreeLoadingStat() : undefined;
|
|
327
466
|
|
|
328
467
|
let ok = false;
|
|
329
468
|
|
|
330
469
|
try {
|
|
470
|
+
// resolve shared-type seeds before the first refresh so discovered roots load now
|
|
471
|
+
await tree.discover();
|
|
331
472
|
await tree.refresh(stat, {
|
|
332
473
|
timeout: ops.initialTreeLoadingTimeout,
|
|
333
474
|
});
|