@nanobpm/urban 0.63.1 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context/binding/backend.d.ts +58 -0
- package/dist/context/binding/backend.js +15 -0
- package/dist/context/binding/descriptor.d.ts +51 -0
- package/dist/context/binding/descriptor.js +62 -0
- package/dist/context/binding/git-backend.d.ts +65 -0
- package/dist/context/binding/git-backend.js +318 -0
- package/dist/context/binding/identity.d.ts +42 -0
- package/dist/context/binding/identity.js +159 -0
- package/dist/context/binding/index.d.ts +5 -1
- package/dist/context/binding/index.js +14 -1
- package/dist/context/binding/resolver.d.ts +61 -0
- package/dist/context/binding/resolver.js +125 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/package.json +1 -1
- package/src/context/binding/backend.ts +79 -0
- package/src/context/binding/descriptor.test.ts +51 -0
- package/src/context/binding/descriptor.ts +108 -0
- package/src/context/binding/git-backend.ts +368 -0
- package/src/context/binding/identity.test.ts +165 -0
- package/src/context/binding/identity.ts +206 -0
- package/src/context/binding/index.ts +46 -2
- package/src/context/binding/resolver.test.ts +713 -0
- package/src/context/binding/resolver.ts +176 -0
- package/src/runtime/browser/runtime.browser.js +63 -30
- package/src/runtime/core/modules/pages.test.ts +20 -1
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Slice S1 (binding) — context *identity*: the private-per-app /
|
|
2
|
+
// shared-on-same-name semantics.
|
|
3
|
+
//
|
|
4
|
+
// A context is named by its `(repo, ref)` pair. Two apps (or two Nano
|
|
5
|
+
// instances) that name the SAME context — the same normalised repo and the same
|
|
6
|
+
// ref — resolve to the SAME shared substrate; a distinct name stays private.
|
|
7
|
+
// This module turns a binding into a stable, canonical **identity key** that
|
|
8
|
+
// the resolver uses both to memoise in-process and to derive an on-disk
|
|
9
|
+
// location, so "same name ⇒ same substrate" holds within a process AND across
|
|
10
|
+
// processes/instances that share a cache root.
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
const SHORTHAND = /^[\w.-]+\/[\w.-]+$/;
|
|
15
|
+
const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
16
|
+
const SCP_LIKE = /^[\w.-]+@([\w.-]+):(.+)$/; // git@github.com:owner/name.git
|
|
17
|
+
function stripGitSuffix(value) {
|
|
18
|
+
// Trim trailing slashes BEFORE stripping `.git` so a trailing-slash spelling
|
|
19
|
+
// (`owner/name.git/`, a URL pathname ending `.git/`) still has its `.git`
|
|
20
|
+
// suffix removed — otherwise `.git$` would not match and equivalent repo
|
|
21
|
+
// spellings would canonicalise to different identities (different substrates).
|
|
22
|
+
// Trim once more afterwards to drop any slash exposed by removing the suffix.
|
|
23
|
+
return value.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
|
|
24
|
+
}
|
|
25
|
+
// A URL's scheme-default port is equivalent to omitting the port, so it must not
|
|
26
|
+
// split identity. `URL` already strips the default port for the web schemes it
|
|
27
|
+
// knows (http/https/ws/…), but NOT for git transports like ssh:// and git://, so
|
|
28
|
+
// their defaults are normalised here explicitly.
|
|
29
|
+
const DEFAULT_PORTS = {
|
|
30
|
+
"http:": "80",
|
|
31
|
+
"https:": "443",
|
|
32
|
+
"ssh:": "22",
|
|
33
|
+
"git:": "9418",
|
|
34
|
+
"ftp:": "21",
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The canonical authority (`host` or `host:port`) for a scheme URL identity.
|
|
38
|
+
* Excludes any `user:pass@` userinfo (uses `hostname`, never `host`) so
|
|
39
|
+
* credentials never affect identity, and drops a scheme's DEFAULT port while
|
|
40
|
+
* retaining any NON-default port as a distinguishing factor.
|
|
41
|
+
*/
|
|
42
|
+
function canonicalAuthority(parsed) {
|
|
43
|
+
const host = parsed.hostname.toLowerCase();
|
|
44
|
+
const defaultPort = DEFAULT_PORTS[parsed.protocol.toLowerCase()];
|
|
45
|
+
if (parsed.port && parsed.port !== defaultPort) {
|
|
46
|
+
return `${host}:${parsed.port}`;
|
|
47
|
+
}
|
|
48
|
+
return host;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a `file://` URL to an absolute filesystem path, honouring the host
|
|
52
|
+
* component and percent-encoding. Falls back to naive `file://` stripping for a
|
|
53
|
+
* malformed URL so identity stays stable rather than throwing.
|
|
54
|
+
*/
|
|
55
|
+
function fileUrlToAbsPath(raw) {
|
|
56
|
+
try {
|
|
57
|
+
return resolvePath(fileURLToPath(raw));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return resolvePath(raw.slice("file://".length));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Classify a binding `repo` field into a concrete clone URL plus a normalised
|
|
65
|
+
* canonical form for identity. Public git is NOT hard-coded: local/`file://`
|
|
66
|
+
* paths and arbitrary git hosts are first-class, leaving a seam for a future
|
|
67
|
+
* PII/mutable or self-hosted substrate backend.
|
|
68
|
+
*/
|
|
69
|
+
function classifyRepo(repo) {
|
|
70
|
+
const raw = repo.trim();
|
|
71
|
+
// Local filesystem path or file:// URL — used for tests and self-hosted
|
|
72
|
+
// substrates. Canonicalised by absolute path so two spellings of one path
|
|
73
|
+
// share identity. `file://` URLs are parsed via `fileURLToPath` so the host
|
|
74
|
+
// component (e.g. `file://localhost/…`) and percent-encoding are handled and
|
|
75
|
+
// equivalent spellings map to one identity.
|
|
76
|
+
if (raw.startsWith("file://")) {
|
|
77
|
+
const abs = fileUrlToAbsPath(raw);
|
|
78
|
+
return { url: raw, canonical: `file:${abs}` };
|
|
79
|
+
}
|
|
80
|
+
if (raw.startsWith(".") || isAbsolute(raw)) {
|
|
81
|
+
const abs = resolvePath(raw);
|
|
82
|
+
return { url: abs, canonical: `file:${abs}` };
|
|
83
|
+
}
|
|
84
|
+
// owner/name shorthand ⇒ GitHub HTTPS clone URL.
|
|
85
|
+
if (SHORTHAND.test(raw)) {
|
|
86
|
+
const canonical = `github.com/${stripGitSuffix(raw).toLowerCase()}`;
|
|
87
|
+
return { url: `https://github.com/${stripGitSuffix(raw)}.git`, canonical };
|
|
88
|
+
}
|
|
89
|
+
// scp-like git URL: git@host:owner/name(.git)
|
|
90
|
+
const scp = SCP_LIKE.exec(raw);
|
|
91
|
+
if (scp) {
|
|
92
|
+
const host = scp[1].toLowerCase();
|
|
93
|
+
const path = stripGitSuffix(scp[2]).toLowerCase();
|
|
94
|
+
return { url: raw, canonical: `${host}/${path}` };
|
|
95
|
+
}
|
|
96
|
+
// scheme URL: https://, ssh://, git://, http://
|
|
97
|
+
if (SCHEME_URL.test(raw)) {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = new URL(raw);
|
|
100
|
+
// Build the authority from `hostname` (never `host`) so any `user:pass@`
|
|
101
|
+
// userinfo is excluded — credentials never affect identity. The port is
|
|
102
|
+
// *normalised*, not dropped: a scheme's DEFAULT port collapses to the
|
|
103
|
+
// portless spelling (so `https://h:443/o/n` ≡ `https://h/o/n` and
|
|
104
|
+
// `ssh://h:22/o/n` ≡ `ssh://h/o/n`), but a NON-default port is retained as
|
|
105
|
+
// a distinguishing factor — two remotes on the same host but different
|
|
106
|
+
// ports are genuinely different repos and MUST NOT share a substrate.
|
|
107
|
+
const host = canonicalAuthority(parsed);
|
|
108
|
+
const path = stripGitSuffix(parsed.pathname).replace(/^\/+/, "").toLowerCase();
|
|
109
|
+
return { url: raw, canonical: `${host}/${path}` };
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Fall through to the opaque case below.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Anything else: treat opaquely but still stable.
|
|
116
|
+
return { url: raw, canonical: stripGitSuffix(raw).toLowerCase() };
|
|
117
|
+
}
|
|
118
|
+
function toSlug(canonical, ref, hash) {
|
|
119
|
+
const readable = `${canonical}@${ref}`
|
|
120
|
+
.toLowerCase()
|
|
121
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
122
|
+
.replace(/^-+|-+$/g, "")
|
|
123
|
+
.slice(0, 48);
|
|
124
|
+
// Suffix with the FULL identity key (sha256 hex), not a truncated prefix: the
|
|
125
|
+
// on-disk location is derived from the slug, so a truncated suffix would make
|
|
126
|
+
// two distinct keys collide onto one `localPath` (breaking "distinct names
|
|
127
|
+
// stay private") far sooner than the key itself collides. Deriving the suffix
|
|
128
|
+
// from the whole key keeps on-disk uniqueness exactly as strong as the
|
|
129
|
+
// in-process sharing key — a single source of truth, distinct keys ⇒ distinct
|
|
130
|
+
// slugs.
|
|
131
|
+
return `${readable || "context"}-${hash}`;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Compute the canonical {@link ContextIdentity} for a binding. Deterministic and
|
|
135
|
+
* pure: equal `(repo, ref)` names (after normalisation) always produce an equal
|
|
136
|
+
* `key` and `slug`. Distinct names are collision-resistant, not collision-proof:
|
|
137
|
+
* both `key` and `slug` are sha256-derived, so two distinct names collide only on
|
|
138
|
+
* a full sha256 collision (the `slug` embeds the whole `key`, so it is exactly as
|
|
139
|
+
* strong as the `key`).
|
|
140
|
+
*/
|
|
141
|
+
export function resolveContextIdentity(binding) {
|
|
142
|
+
const { url, canonical } = classifyRepo(binding.repo);
|
|
143
|
+
const ref = binding.ref.trim();
|
|
144
|
+
const canonicalName = `${canonical}#${ref}`;
|
|
145
|
+
const key = createHash("sha256").update(canonicalName, "utf8").digest("hex");
|
|
146
|
+
return { key, repo: url, ref, slug: toSlug(canonical, ref, key) };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The stable identity key of a binding — the sharing primitive. Two bindings
|
|
150
|
+
* resolve to the same shared substrate iff this returns the same value for
|
|
151
|
+
* both.
|
|
152
|
+
*/
|
|
153
|
+
export function contextIdentityKey(binding) {
|
|
154
|
+
return resolveContextIdentity(binding).key;
|
|
155
|
+
}
|
|
156
|
+
/** `true` iff two bindings name the same shared context (same substrate). */
|
|
157
|
+
export function sameContext(a, b) {
|
|
158
|
+
return contextIdentityKey(a) === contextIdentityKey(b);
|
|
159
|
+
}
|
|
@@ -1 +1,5 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export { type ContextBinding, ContextBindingError, isContextBinding, parseContextBinding, } from "./descriptor.ts";
|
|
2
|
+
export { type ContextIdentity, contextIdentityKey, resolveContextIdentity, sameContext, } from "./identity.ts";
|
|
3
|
+
export { type ResolvedContextHandle, type SubstrateBackend, type SubstrateResolveOptions, } from "./backend.ts";
|
|
4
|
+
export { GitSubstrateBackend, type GitRunner, GIT_SAFETY_CONFIG, hardenedGitArgs, SubstrateResolveError, } from "./git-backend.ts";
|
|
5
|
+
export { ContextResolver, type ContextResolverOptions, type ResolveOptions, defaultContextCacheRoot, resolveContextBinding, } from "./resolver.ts";
|
|
@@ -1 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
// @nanobpm/urban/context/binding — slice S1.
|
|
2
|
+
//
|
|
3
|
+
// The bindable `context` resource: the binding descriptor (`uses: context:
|
|
4
|
+
// { repo, ref }`) plus local↔remote resolution to a resolved handle, with
|
|
5
|
+
// private-per-app / shared-on-same-name identity semantics. This is one half of
|
|
6
|
+
// the published contract nanobpm/nano-workforce#291 builds against.
|
|
7
|
+
//
|
|
8
|
+
// Downstream: S3 (git substrate/governance) and S4 (retrieval) consume the
|
|
9
|
+
// ResolvedContextHandle; the SubstrateBackend interface is the seam for a
|
|
10
|
+
// future PII/mutable or self-hosted backend.
|
|
11
|
+
export { ContextBindingError, isContextBinding, parseContextBinding, } from "./descriptor.js";
|
|
12
|
+
export { contextIdentityKey, resolveContextIdentity, sameContext, } from "./identity.js";
|
|
13
|
+
export { GitSubstrateBackend, GIT_SAFETY_CONFIG, hardenedGitArgs, SubstrateResolveError, } from "./git-backend.js";
|
|
14
|
+
export { ContextResolver, defaultContextCacheRoot, resolveContextBinding, } from "./resolver.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ResolvedContextHandle, SubstrateBackend } from "./backend.ts";
|
|
2
|
+
/** Options for a {@link ContextResolver}. */
|
|
3
|
+
export interface ContextResolverOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Root directory under which per-context working copies are cloned. Two
|
|
6
|
+
* resolvers sharing a `cacheRoot` share substrates on disk. Defaults to
|
|
7
|
+
* {@link defaultContextCacheRoot}.
|
|
8
|
+
*/
|
|
9
|
+
readonly cacheRoot?: string;
|
|
10
|
+
/**
|
|
11
|
+
* The substrate backend. Defaults to a git backend ({@link GitSubstrateBackend}).
|
|
12
|
+
* Injecting a different backend is the seam for a future PII/mutable or
|
|
13
|
+
* self-hosted substrate.
|
|
14
|
+
*/
|
|
15
|
+
readonly backend?: SubstrateBackend;
|
|
16
|
+
}
|
|
17
|
+
/** Per-resolution overrides. */
|
|
18
|
+
export interface ResolveOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Controls whether a re-resolution re-fetches the substrate. Note the
|
|
21
|
+
* {@link ContextResolver} memoises per identity: once an identity has resolved,
|
|
22
|
+
* repeat resolves return the cached handle and do **not** re-fetch unless you
|
|
23
|
+
* explicitly pass `refresh: true`, which forces a fresh materialisation (a
|
|
24
|
+
* fetch + re-pin on the backend). While a materialisation is still in flight,
|
|
25
|
+
* concurrent resolves — including `refresh: true` — coalesce onto it rather
|
|
26
|
+
* than starting a second one. The backend's own default (fetch on an existing
|
|
27
|
+
* clone) therefore only applies the first time an identity is materialised or
|
|
28
|
+
* when `refresh: true` is passed. Defaults to `undefined` (reuse the memoised
|
|
29
|
+
* handle).
|
|
30
|
+
*/
|
|
31
|
+
readonly refresh?: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The default cache root for context working copies. Honours
|
|
35
|
+
* `URBAN_CONTEXT_CACHE_DIR`, then `XDG_CACHE_HOME`, then `~/.cache`, falling
|
|
36
|
+
* back to the OS temp dir when no home directory is available.
|
|
37
|
+
*/
|
|
38
|
+
export declare function defaultContextCacheRoot(env?: Record<string, string | undefined>): string;
|
|
39
|
+
/**
|
|
40
|
+
* Resolves context bindings to concrete local working copies. A single resolver
|
|
41
|
+
* instance enforces shared-on-same-name in-process by memoising per identity
|
|
42
|
+
* key; distinct names stay private.
|
|
43
|
+
*/
|
|
44
|
+
export declare class ContextResolver {
|
|
45
|
+
#private;
|
|
46
|
+
constructor(options?: ContextResolverOptions);
|
|
47
|
+
/** The cache root this resolver clones into. */
|
|
48
|
+
get cacheRoot(): string;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a binding (validated first) to a {@link ResolvedContextHandle}. Two
|
|
51
|
+
* bindings that name the same context return the identical handle from this
|
|
52
|
+
* resolver; distinct names return distinct handles.
|
|
53
|
+
*/
|
|
54
|
+
resolve(binding: unknown, options?: ResolveOptions): Promise<ResolvedContextHandle>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* One-shot convenience: resolve a single binding with a fresh resolver. Prefer a
|
|
58
|
+
* long-lived {@link ContextResolver} when resolving many bindings so sharing is
|
|
59
|
+
* memoised across calls.
|
|
60
|
+
*/
|
|
61
|
+
export declare function resolveContextBinding(binding: unknown, options?: ContextResolverOptions & ResolveOptions): Promise<ResolvedContextHandle>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Slice S1 (binding) — the resolver: bind → validate → identity → resolved
|
|
2
|
+
// handle, with private-per-app / shared-on-same-name semantics enforced two
|
|
3
|
+
// ways:
|
|
4
|
+
//
|
|
5
|
+
// 1. In-process: a ContextResolver memoises by identity key, so two resolves of
|
|
6
|
+
// the same name return the SAME handle (and never double-clone), while
|
|
7
|
+
// distinct names get distinct handles.
|
|
8
|
+
// 2. Across processes/instances: the on-disk location is derived
|
|
9
|
+
// deterministically from the identity slug under a shared cache root, so two
|
|
10
|
+
// Nano instances naming the same context share one working copy on disk.
|
|
11
|
+
// Known first-use limitation (deferred past slice S1): the single-flight in
|
|
12
|
+
// (1) is in-process only, so concurrent *first* resolves of the same context
|
|
13
|
+
// across instances can race materialisation (clone/pin). Sharing is reliable
|
|
14
|
+
// once the substrate exists; a follow-up slice adds an inter-process lock to
|
|
15
|
+
// make concurrent first-use safe too — see GitSubstrateBackend.materialise.
|
|
16
|
+
import { homedir, tmpdir } from "node:os";
|
|
17
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
18
|
+
import { parseContextBinding } from "./descriptor.js";
|
|
19
|
+
import { resolveContextIdentity } from "./identity.js";
|
|
20
|
+
import { GitSubstrateBackend } from "./git-backend.js";
|
|
21
|
+
/**
|
|
22
|
+
* The default cache root for context working copies. Honours
|
|
23
|
+
* `URBAN_CONTEXT_CACHE_DIR`, then `XDG_CACHE_HOME`, then `~/.cache`, falling
|
|
24
|
+
* back to the OS temp dir when no home directory is available.
|
|
25
|
+
*/
|
|
26
|
+
export function defaultContextCacheRoot(env = process.env) {
|
|
27
|
+
if (env.URBAN_CONTEXT_CACHE_DIR)
|
|
28
|
+
return env.URBAN_CONTEXT_CACHE_DIR;
|
|
29
|
+
if (env.XDG_CACHE_HOME)
|
|
30
|
+
return join(env.XDG_CACHE_HOME, "urban", "context");
|
|
31
|
+
const home = safeHomedir();
|
|
32
|
+
if (home)
|
|
33
|
+
return join(home, ".cache", "urban", "context");
|
|
34
|
+
return join(tmpdir(), "urban", "context");
|
|
35
|
+
}
|
|
36
|
+
/** `os.homedir()`, but tolerant: returns `undefined` if the home dir can't be
|
|
37
|
+
* determined (Node can throw here) so callers fall back to the temp dir. */
|
|
38
|
+
function safeHomedir() {
|
|
39
|
+
try {
|
|
40
|
+
return homedir() || undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolves context bindings to concrete local working copies. A single resolver
|
|
48
|
+
* instance enforces shared-on-same-name in-process by memoising per identity
|
|
49
|
+
* key; distinct names stay private.
|
|
50
|
+
*/
|
|
51
|
+
export class ContextResolver {
|
|
52
|
+
#cacheRoot;
|
|
53
|
+
#backend;
|
|
54
|
+
// Per-identity resolution record. Despite serving the single-flight guarantee,
|
|
55
|
+
// this map is NOT purged once a resolution settles: the settled record is
|
|
56
|
+
// retained as the long-lived memoisation cache backing shared-on-same-name, so
|
|
57
|
+
// repeat resolves return the identical handle. Entries are removed only on
|
|
58
|
+
// rejection (so a failed materialise can be retried); a fulfilled entry lives
|
|
59
|
+
// for the resolver's lifetime. Do not add "drop settled entries" cleanup here
|
|
60
|
+
// without also giving up memoisation.
|
|
61
|
+
#resolutions = new Map();
|
|
62
|
+
constructor(options = {}) {
|
|
63
|
+
// Normalise to an absolute path so a relative `cacheRoot` (or a relative
|
|
64
|
+
// `URBAN_CONTEXT_CACHE_DIR`) still yields an absolute `localPath`, honouring
|
|
65
|
+
// `SubstrateResolveOptions.localPath` ("Absolute path…"). Note this resolves
|
|
66
|
+
// a relative `cacheRoot` against *this* process's CWD, so the cross-process
|
|
67
|
+
// sharing promised in the module header only holds for an ABSOLUTE
|
|
68
|
+
// `cacheRoot`; a relative one is shared only among instances that happen to
|
|
69
|
+
// share a working directory.
|
|
70
|
+
this.#cacheRoot = resolvePath(options.cacheRoot ?? defaultContextCacheRoot());
|
|
71
|
+
this.#backend = options.backend ?? new GitSubstrateBackend();
|
|
72
|
+
}
|
|
73
|
+
/** The cache root this resolver clones into. */
|
|
74
|
+
get cacheRoot() {
|
|
75
|
+
return this.#cacheRoot;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a binding (validated first) to a {@link ResolvedContextHandle}. Two
|
|
79
|
+
* bindings that name the same context return the identical handle from this
|
|
80
|
+
* resolver; distinct names return distinct handles.
|
|
81
|
+
*/
|
|
82
|
+
async resolve(binding, options = {}) {
|
|
83
|
+
const parsed = parseContextBinding(binding);
|
|
84
|
+
const identity = resolveContextIdentity(parsed);
|
|
85
|
+
const localPath = join(this.#cacheRoot, identity.slug);
|
|
86
|
+
// Shared-on-same-name: concurrent or repeated resolves of one identity share
|
|
87
|
+
// a single materialisation. Coalesce onto an in-flight one regardless of
|
|
88
|
+
// `refresh` — it is already producing a fresh working copy, so starting a
|
|
89
|
+
// second `materialise()` would double-clone into the same `localPath` and
|
|
90
|
+
// break the single-in-flight guarantee. Only re-materialise once the prior
|
|
91
|
+
// resolution has settled AND the caller explicitly asked to `refresh`.
|
|
92
|
+
const existing = this.#resolutions.get(identity.key);
|
|
93
|
+
if (existing && (!existing.settled || options.refresh !== true)) {
|
|
94
|
+
return existing.promise;
|
|
95
|
+
}
|
|
96
|
+
const record = {
|
|
97
|
+
promise: this.#backend
|
|
98
|
+
.materialise(identity, { localPath, refresh: options.refresh })
|
|
99
|
+
.catch((error) => {
|
|
100
|
+
if (this.#resolutions.get(identity.key) === record) {
|
|
101
|
+
this.#resolutions.delete(identity.key);
|
|
102
|
+
}
|
|
103
|
+
throw error;
|
|
104
|
+
}),
|
|
105
|
+
settled: false,
|
|
106
|
+
};
|
|
107
|
+
this.#resolutions.set(identity.key, record);
|
|
108
|
+
record.promise.then(() => {
|
|
109
|
+
record.settled = true;
|
|
110
|
+
}, () => {
|
|
111
|
+
// Rejection is surfaced to the caller via the returned promise; the map
|
|
112
|
+
// entry is already removed in the catch above.
|
|
113
|
+
});
|
|
114
|
+
return record.promise;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* One-shot convenience: resolve a single binding with a fresh resolver. Prefer a
|
|
119
|
+
* long-lived {@link ContextResolver} when resolving many bindings so sharing is
|
|
120
|
+
* memoised across calls.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveContextBinding(binding, options = {}) {
|
|
123
|
+
const { cacheRoot, backend, ...resolveOptions } = options;
|
|
124
|
+
return new ContextResolver({ cacheRoot, backend }).resolve(binding, resolveOptions);
|
|
125
|
+
}
|