@ontrails/core 0.2.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/CHANGELOG.md +849 -0
- package/README.md +190 -0
- package/package.json +36 -0
- package/src/activation-provenance.ts +116 -0
- package/src/activation-source-compatibility.ts +430 -0
- package/src/activation-source-derivation.ts +227 -0
- package/src/activation-source.ts +93 -0
- package/src/blob-ref.ts +90 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/compose-batch.ts +69 -0
- package/src/compose-schema.ts +36 -0
- package/src/context.ts +66 -0
- package/src/derive.ts +485 -0
- package/src/detours.ts +8 -0
- package/src/diagnostics.ts +21 -0
- package/src/draft.ts +350 -0
- package/src/entity.ts +346 -0
- package/src/error-rendering.ts +87 -0
- package/src/errors.ts +483 -0
- package/src/execute.ts +1577 -0
- package/src/fetch.ts +138 -0
- package/src/fire.ts +1172 -0
- package/src/glob.ts +81 -0
- package/src/guards.ts +37 -0
- package/src/index.ts +704 -0
- package/src/internal/fork-ctx.ts +69 -0
- package/src/layer-field-rendering.ts +193 -0
- package/src/layer.ts +81 -0
- package/src/observe.ts +361 -0
- package/src/path-scope.ts +66 -0
- package/src/path-security.ts +98 -0
- package/src/patterns/bulk.ts +16 -0
- package/src/patterns/change.ts +12 -0
- package/src/patterns/date-range.ts +12 -0
- package/src/patterns/index.ts +8 -0
- package/src/patterns/pagination.ts +22 -0
- package/src/patterns/progress.ts +13 -0
- package/src/patterns/sorting.ts +14 -0
- package/src/patterns/status.ts +11 -0
- package/src/patterns/timestamps.ts +12 -0
- package/src/permits.ts +12 -0
- package/src/queue.ts +163 -0
- package/src/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +50 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/resource-config.ts +804 -0
- package/src/resource.ts +194 -0
- package/src/result.ts +212 -0
- package/src/run.ts +76 -0
- package/src/runtime-builtins.ts +69 -0
- package/src/schedule-runtime.ts +689 -0
- package/src/schedule.ts +326 -0
- package/src/serialization.ts +265 -0
- package/src/sha256.ts +136 -0
- package/src/signal-diagnostics.ts +633 -0
- package/src/signal-ref.ts +111 -0
- package/src/signal.ts +104 -0
- package/src/store/accessor-protocol.ts +56 -0
- package/src/store/index.ts +4 -0
- package/src/structured-examples.ts +248 -0
- package/src/surface-derivation.ts +91 -0
- package/src/surface-filter.ts +101 -0
- package/src/surface-overlay.ts +694 -0
- package/src/surface-versioning.ts +42 -0
- package/src/topo.ts +835 -0
- package/src/tracing.ts +346 -0
- package/src/trail-id-glob.ts +15 -0
- package/src/trail.ts +1351 -0
- package/src/trails/derive-trail.ts +835 -0
- package/src/trails/index.ts +9 -0
- package/src/trails/ingest.ts +152 -0
- package/src/trails-db.ts +212 -0
- package/src/transport-error-map.ts +163 -0
- package/src/type-utils.ts +87 -0
- package/src/types.ts +300 -0
- package/src/validate-established-topo.ts +73 -0
- package/src/validate-topo.ts +725 -0
- package/src/validation.ts +330 -0
- package/src/version-marker.ts +716 -0
- package/src/version-resolution.ts +308 -0
- package/src/version-runtime.ts +120 -0
- package/src/webhook.ts +461 -0
- package/src/workspace.ts +244 -0
- package/src/zod-wrappers.ts +72 -0
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource resolution pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from execute.ts to keep both modules under the 400 LOC ceiling.
|
|
5
|
+
* Handles config validation, singleton caching, concurrent-creation dedup,
|
|
6
|
+
* and the full resolve-or-create flow for declared resources.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
AnyResource,
|
|
11
|
+
ResourceContext,
|
|
12
|
+
ResourceOverrideMap,
|
|
13
|
+
} from './resource.js';
|
|
14
|
+
import type { SurfaceConfigValues } from './surface-derivation.js';
|
|
15
|
+
import type { AnyTrail } from './trail.js';
|
|
16
|
+
import type { TrailContext } from './types.js';
|
|
17
|
+
|
|
18
|
+
import { InternalError, ValidationError } from './errors.js';
|
|
19
|
+
import { Result } from './result.js';
|
|
20
|
+
import { createResourceLookup } from './resource.js';
|
|
21
|
+
|
|
22
|
+
type MutableTrailContext = {
|
|
23
|
+
-readonly [K in keyof TrailContext]: TrailContext[K];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ResourceConfigValues = SurfaceConfigValues;
|
|
27
|
+
|
|
28
|
+
type ConfigValues = ResourceConfigValues;
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Singleton caches
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
interface SingletonResourceEntry {
|
|
35
|
+
readonly value: unknown;
|
|
36
|
+
activeLeases: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ResourceLease {
|
|
40
|
+
readonly key: string;
|
|
41
|
+
readonly resource: AnyResource;
|
|
42
|
+
readonly value: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const singletonResources = new WeakMap<
|
|
46
|
+
AnyResource,
|
|
47
|
+
Map<string, SingletonResourceEntry>
|
|
48
|
+
>();
|
|
49
|
+
|
|
50
|
+
interface PendingCreation {
|
|
51
|
+
readonly promise: Promise<Result<unknown, Error>>;
|
|
52
|
+
waiters: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** In-flight resource creation promises, keyed by resource x context. */
|
|
56
|
+
const pendingCreations = new WeakMap<
|
|
57
|
+
AnyResource,
|
|
58
|
+
Map<string, PendingCreation>
|
|
59
|
+
>();
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Context helpers
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
const toResourceContext = (
|
|
66
|
+
ctx: TrailContext,
|
|
67
|
+
config?: unknown
|
|
68
|
+
): ResourceContext => ({
|
|
69
|
+
config,
|
|
70
|
+
cwd: ctx.cwd,
|
|
71
|
+
env: ctx.env,
|
|
72
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const toResourceContextKey = (ctx: ResourceContext): string =>
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
config: ctx.config,
|
|
78
|
+
cwd: ctx.cwd,
|
|
79
|
+
env: Object.entries(ctx.env ?? {}).toSorted(([left], [right]) =>
|
|
80
|
+
left.localeCompare(right)
|
|
81
|
+
),
|
|
82
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Config validation
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/** Validate and resolve a resource's config from the provided configValues map. */
|
|
90
|
+
export const resolveResourceConfig = (
|
|
91
|
+
declaredResource: AnyResource,
|
|
92
|
+
configValues?: ConfigValues
|
|
93
|
+
): Result<unknown, Error> => {
|
|
94
|
+
if (declaredResource.config === undefined) {
|
|
95
|
+
return Result.ok();
|
|
96
|
+
}
|
|
97
|
+
const raw = configValues?.[declaredResource.id];
|
|
98
|
+
if (raw === undefined) {
|
|
99
|
+
const parsed = declaredResource.config.safeParse(raw);
|
|
100
|
+
if (parsed.success) {
|
|
101
|
+
return Result.ok(parsed.data);
|
|
102
|
+
}
|
|
103
|
+
return Result.err(
|
|
104
|
+
new ValidationError(
|
|
105
|
+
`Resource "${declaredResource.id}" declares a config schema but no config was provided`
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const parsed = declaredResource.config.safeParse(raw);
|
|
110
|
+
if (!parsed.success) {
|
|
111
|
+
return Result.err(
|
|
112
|
+
new ValidationError(
|
|
113
|
+
`Resource "${declaredResource.id}" config validation failed: ${parsed.error.message}`
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return Result.ok(parsed.data);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Override / cache lookups
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
const hasOwnResourceOverride = (
|
|
125
|
+
overrides: ResourceOverrideMap | undefined,
|
|
126
|
+
id: string
|
|
127
|
+
): overrides is ResourceOverrideMap =>
|
|
128
|
+
overrides !== undefined && Object.hasOwn(overrides, id);
|
|
129
|
+
|
|
130
|
+
const getCachedSingletonResource = (
|
|
131
|
+
declaredResource: AnyResource,
|
|
132
|
+
resourceContext: ResourceContext
|
|
133
|
+
):
|
|
134
|
+
| { readonly found: false }
|
|
135
|
+
| { readonly found: true; readonly lease: ResourceLease } => {
|
|
136
|
+
const scopedCache = singletonResources.get(declaredResource);
|
|
137
|
+
if (scopedCache === undefined) {
|
|
138
|
+
return { found: false };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const key = toResourceContextKey(resourceContext);
|
|
142
|
+
const entry = scopedCache.get(key);
|
|
143
|
+
if (entry === undefined) {
|
|
144
|
+
return { found: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
entry.activeLeases += 1;
|
|
148
|
+
return {
|
|
149
|
+
found: true,
|
|
150
|
+
lease: { key, resource: declaredResource, value: entry.value },
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const getProvidedResource = (
|
|
155
|
+
ctx: TrailContext,
|
|
156
|
+
overrides: ResourceOverrideMap | undefined,
|
|
157
|
+
declaredResource: AnyResource,
|
|
158
|
+
resourceContext: ResourceContext
|
|
159
|
+
):
|
|
160
|
+
| Result<
|
|
161
|
+
{ readonly lease?: ResourceLease | undefined; readonly value: unknown },
|
|
162
|
+
Error
|
|
163
|
+
>
|
|
164
|
+
| undefined => {
|
|
165
|
+
const { id } = declaredResource;
|
|
166
|
+
if (hasOwnResourceOverride(overrides, id)) {
|
|
167
|
+
return Result.ok({ value: overrides[id] });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (Object.hasOwn(ctx.extensions ?? {}, id)) {
|
|
171
|
+
return Result.ok({ value: ctx.extensions?.[id] });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const cached = getCachedSingletonResource(declaredResource, resourceContext);
|
|
175
|
+
if (cached.found) {
|
|
176
|
+
return Result.ok({ lease: cached.lease, value: cached.lease?.value });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return undefined;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const getOverrideOrExtension = (
|
|
183
|
+
ctx: TrailContext,
|
|
184
|
+
overrides: ResourceOverrideMap | undefined,
|
|
185
|
+
declaredResource: AnyResource
|
|
186
|
+
):
|
|
187
|
+
| Result<
|
|
188
|
+
{ readonly lease?: ResourceLease | undefined; readonly value: unknown },
|
|
189
|
+
Error
|
|
190
|
+
>
|
|
191
|
+
| undefined =>
|
|
192
|
+
getProvidedResource(ctx, overrides, declaredResource, toResourceContext(ctx));
|
|
193
|
+
|
|
194
|
+
type ConfigAwareResolution =
|
|
195
|
+
| Result<
|
|
196
|
+
{
|
|
197
|
+
readonly kind: 'provided';
|
|
198
|
+
readonly lease?: ResourceLease | undefined;
|
|
199
|
+
readonly value: unknown;
|
|
200
|
+
},
|
|
201
|
+
Error
|
|
202
|
+
>
|
|
203
|
+
| Result<
|
|
204
|
+
{ readonly kind: 'context'; readonly resourceContext: ResourceContext },
|
|
205
|
+
Error
|
|
206
|
+
>;
|
|
207
|
+
|
|
208
|
+
interface CreatedResourceInstance {
|
|
209
|
+
readonly key: string;
|
|
210
|
+
readonly lease: ResourceLease;
|
|
211
|
+
readonly resource: AnyResource;
|
|
212
|
+
readonly sharedDuringCreation: boolean;
|
|
213
|
+
readonly value: unknown;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface ResourceResolution {
|
|
217
|
+
readonly created?: CreatedResourceInstance | undefined;
|
|
218
|
+
readonly lease?: ResourceLease | undefined;
|
|
219
|
+
readonly value: unknown;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Outcome from draining cached resource singletons. */
|
|
223
|
+
export interface ResourceDrainReport {
|
|
224
|
+
/** Resource IDs whose cached singleton entries were disposed successfully. */
|
|
225
|
+
readonly disposed: readonly string[];
|
|
226
|
+
/** Resource IDs removed from the singleton cache before disposal. */
|
|
227
|
+
readonly evicted: readonly string[];
|
|
228
|
+
/**
|
|
229
|
+
* Resource IDs that had cached or pending singleton entries, but not for the
|
|
230
|
+
* stable context/config key supplied to `drainResources`.
|
|
231
|
+
*/
|
|
232
|
+
readonly missed?: readonly string[] | undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
interface MutableResourceDrainReport {
|
|
236
|
+
readonly disposed: string[];
|
|
237
|
+
readonly evicted: string[];
|
|
238
|
+
missed?: string[] | undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const resolveConfigAwareProvidedResource = (
|
|
242
|
+
ctx: TrailContext,
|
|
243
|
+
declaredResource: AnyResource,
|
|
244
|
+
configValues: ConfigValues | undefined
|
|
245
|
+
): ConfigAwareResolution => {
|
|
246
|
+
const configResult = resolveResourceConfig(declaredResource, configValues);
|
|
247
|
+
if (configResult.isErr()) {
|
|
248
|
+
return configResult;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const resourceContext = toResourceContext(ctx, configResult.value);
|
|
252
|
+
const provided = getProvidedResource(
|
|
253
|
+
ctx,
|
|
254
|
+
undefined,
|
|
255
|
+
declaredResource,
|
|
256
|
+
resourceContext
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (provided === undefined) {
|
|
260
|
+
return Result.ok({ kind: 'context', resourceContext });
|
|
261
|
+
}
|
|
262
|
+
if (provided.isErr()) {
|
|
263
|
+
return provided;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return Result.ok({
|
|
267
|
+
kind: 'provided',
|
|
268
|
+
lease: provided.value.lease,
|
|
269
|
+
value: provided.value.value,
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Instance creation
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
const toInternalResourceError = (id: string, error: unknown): InternalError => {
|
|
278
|
+
const cause = error instanceof Error ? error : undefined;
|
|
279
|
+
const message = cause?.message ?? String(error);
|
|
280
|
+
return new InternalError(`Resource "${id}" failed to resolve: ${message}`, {
|
|
281
|
+
...(cause ? { cause } : {}),
|
|
282
|
+
context: { resourceId: id },
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const getSingletonResourceCache = (
|
|
287
|
+
declaredResource: AnyResource
|
|
288
|
+
): Map<string, SingletonResourceEntry> => {
|
|
289
|
+
const existing = singletonResources.get(declaredResource);
|
|
290
|
+
if (existing !== undefined) {
|
|
291
|
+
return existing;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const created = new Map<string, SingletonResourceEntry>();
|
|
295
|
+
singletonResources.set(declaredResource, created);
|
|
296
|
+
return created;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const acquireCachedSingletonResourceByKey = (
|
|
300
|
+
declaredResource: AnyResource,
|
|
301
|
+
key: string
|
|
302
|
+
): ResourceLease | undefined => {
|
|
303
|
+
const entry = singletonResources.get(declaredResource)?.get(key);
|
|
304
|
+
if (entry === undefined) {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
entry.activeLeases += 1;
|
|
309
|
+
return { key, resource: declaredResource, value: entry.value };
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const releaseResourceLease = (lease: ResourceLease): number => {
|
|
313
|
+
const entry = singletonResources.get(lease.resource)?.get(lease.key);
|
|
314
|
+
if (entry === undefined || entry.value !== lease.value) {
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
entry.activeLeases = Math.max(0, entry.activeLeases - 1);
|
|
319
|
+
return entry.activeLeases;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const countActiveResourceLeases = (lease: ResourceLease): number => {
|
|
323
|
+
const entry = singletonResources.get(lease.resource)?.get(lease.key);
|
|
324
|
+
return entry === undefined || entry.value !== lease.value
|
|
325
|
+
? 0
|
|
326
|
+
: entry.activeLeases;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const releaseResourceLeases = (leases: readonly ResourceLease[]): void => {
|
|
330
|
+
for (const lease of leases.toReversed()) {
|
|
331
|
+
releaseResourceLease(lease);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const deleteCachedSingletonResource = (
|
|
336
|
+
declaredResource: AnyResource,
|
|
337
|
+
key: string,
|
|
338
|
+
value: unknown
|
|
339
|
+
): boolean => {
|
|
340
|
+
const cache = singletonResources.get(declaredResource);
|
|
341
|
+
const entry = cache?.get(key);
|
|
342
|
+
if (entry === undefined || entry.value !== value) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
cache?.delete(key);
|
|
347
|
+
return true;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const disposeResourceInstance = async (
|
|
351
|
+
resource: AnyResource,
|
|
352
|
+
value: unknown
|
|
353
|
+
): Promise<Result<void, Error>> => {
|
|
354
|
+
if (resource.dispose === undefined) {
|
|
355
|
+
return Result.ok();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
await resource.dispose(value);
|
|
360
|
+
return Result.ok();
|
|
361
|
+
} catch (error: unknown) {
|
|
362
|
+
const cause = error instanceof Error ? error : undefined;
|
|
363
|
+
const message = cause?.message ?? String(error);
|
|
364
|
+
return Result.err(
|
|
365
|
+
new InternalError(
|
|
366
|
+
`Resource "${resource.id}" failed to dispose: ${message}`,
|
|
367
|
+
{
|
|
368
|
+
...(cause ? { cause } : {}),
|
|
369
|
+
context: { resourceId: resource.id },
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const toResourceLifecycleError = (
|
|
377
|
+
message: string,
|
|
378
|
+
errors: readonly Error[],
|
|
379
|
+
cause?: Error,
|
|
380
|
+
context?: Record<string, unknown>
|
|
381
|
+
): InternalError =>
|
|
382
|
+
new InternalError(message, {
|
|
383
|
+
...(cause ? { cause } : {}),
|
|
384
|
+
context: {
|
|
385
|
+
...context,
|
|
386
|
+
failures: errors.map((error) => ({
|
|
387
|
+
message: error.message,
|
|
388
|
+
name: error.name,
|
|
389
|
+
...(error instanceof InternalError && error.context !== undefined
|
|
390
|
+
? { context: error.context }
|
|
391
|
+
: {}),
|
|
392
|
+
})),
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const doCreateResourceInstance = async (
|
|
397
|
+
declaredResource: AnyResource,
|
|
398
|
+
resourceContext: ResourceContext,
|
|
399
|
+
key: string
|
|
400
|
+
): Promise<Result<unknown, Error>> => {
|
|
401
|
+
try {
|
|
402
|
+
const created = await declaredResource.create(resourceContext);
|
|
403
|
+
if (created.isErr()) {
|
|
404
|
+
return created;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const instance = created.unwrap();
|
|
408
|
+
getSingletonResourceCache(declaredResource).set(key, {
|
|
409
|
+
activeLeases: 1,
|
|
410
|
+
value: instance,
|
|
411
|
+
});
|
|
412
|
+
return Result.ok(instance);
|
|
413
|
+
} catch (error: unknown) {
|
|
414
|
+
return Result.err(toInternalResourceError(declaredResource.id, error));
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const trackPendingCreation = (
|
|
419
|
+
declaredResource: AnyResource,
|
|
420
|
+
key: string,
|
|
421
|
+
promise: Promise<Result<unknown, Error>>
|
|
422
|
+
): PendingCreation => {
|
|
423
|
+
const entry: PendingCreation = { promise, waiters: 0 };
|
|
424
|
+
const pending = pendingCreations.get(declaredResource);
|
|
425
|
+
if (pending) {
|
|
426
|
+
pending.set(key, entry);
|
|
427
|
+
} else {
|
|
428
|
+
pendingCreations.set(declaredResource, new Map([[key, entry]]));
|
|
429
|
+
}
|
|
430
|
+
return entry;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Deduplicates concurrent creation of the same resource singleton.
|
|
435
|
+
* If a creation is already in flight for this resource x context key,
|
|
436
|
+
* returns the existing promise instead of spawning a second factory call.
|
|
437
|
+
*/
|
|
438
|
+
const createResourceInstance = async (
|
|
439
|
+
declaredResource: AnyResource,
|
|
440
|
+
resourceContext: ResourceContext
|
|
441
|
+
): Promise<Result<ResourceResolution, Error>> => {
|
|
442
|
+
const key = toResourceContextKey(resourceContext);
|
|
443
|
+
const inflight = pendingCreations.get(declaredResource)?.get(key);
|
|
444
|
+
if (inflight) {
|
|
445
|
+
inflight.waiters += 1;
|
|
446
|
+
const resolved = await inflight.promise;
|
|
447
|
+
if (resolved.isErr()) {
|
|
448
|
+
return resolved;
|
|
449
|
+
}
|
|
450
|
+
const lease = acquireCachedSingletonResourceByKey(declaredResource, key);
|
|
451
|
+
return lease === undefined
|
|
452
|
+
? Result.err(
|
|
453
|
+
new InternalError(
|
|
454
|
+
`Resource "${declaredResource.id}" was created but is no longer cached`,
|
|
455
|
+
{ context: { resourceId: declaredResource.id } }
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
: Result.ok({ lease, value: lease.value });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const promise = doCreateResourceInstance(
|
|
462
|
+
declaredResource,
|
|
463
|
+
resourceContext,
|
|
464
|
+
key
|
|
465
|
+
);
|
|
466
|
+
const pending = trackPendingCreation(declaredResource, key, promise);
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const resolved = await promise;
|
|
470
|
+
if (resolved.isErr()) {
|
|
471
|
+
return resolved;
|
|
472
|
+
}
|
|
473
|
+
const { value } = resolved;
|
|
474
|
+
const lease = { key, resource: declaredResource, value };
|
|
475
|
+
return Result.ok({
|
|
476
|
+
created: {
|
|
477
|
+
key,
|
|
478
|
+
lease,
|
|
479
|
+
resource: declaredResource,
|
|
480
|
+
sharedDuringCreation: pending.waiters > 0,
|
|
481
|
+
value,
|
|
482
|
+
},
|
|
483
|
+
lease,
|
|
484
|
+
value,
|
|
485
|
+
});
|
|
486
|
+
} finally {
|
|
487
|
+
pendingCreations.get(declaredResource)?.delete(key);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const rollbackCreatedResources = async (
|
|
492
|
+
created: readonly CreatedResourceInstance[]
|
|
493
|
+
): Promise<Result<void, Error>> => {
|
|
494
|
+
const failures: Error[] = [];
|
|
495
|
+
|
|
496
|
+
for (const entry of created.toReversed()) {
|
|
497
|
+
const activeLeases = countActiveResourceLeases(entry.lease);
|
|
498
|
+
if (activeLeases > 0 || entry.sharedDuringCreation) {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const deleted = deleteCachedSingletonResource(
|
|
502
|
+
entry.resource,
|
|
503
|
+
entry.key,
|
|
504
|
+
entry.value
|
|
505
|
+
);
|
|
506
|
+
if (!deleted) {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const disposed = await disposeResourceInstance(entry.resource, entry.value);
|
|
510
|
+
if (disposed.isErr()) {
|
|
511
|
+
failures.push(disposed.error);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return failures.length === 0
|
|
516
|
+
? Result.ok()
|
|
517
|
+
: Result.err(
|
|
518
|
+
toResourceLifecycleError(
|
|
519
|
+
'Resource rollback failed during resource resolution',
|
|
520
|
+
failures
|
|
521
|
+
)
|
|
522
|
+
);
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const resolveDeclaredResourceForCreation = async (
|
|
526
|
+
declaredResource: AnyResource,
|
|
527
|
+
ctx: TrailContext,
|
|
528
|
+
overrides: ResourceOverrideMap | undefined,
|
|
529
|
+
configValues: ConfigValues | undefined
|
|
530
|
+
): Promise<Result<ResourceResolution, Error>> => {
|
|
531
|
+
// Check overrides/extensions first — skip config validation entirely when
|
|
532
|
+
// a resource instance is already provided.
|
|
533
|
+
const overrideOrExtension = getOverrideOrExtension(
|
|
534
|
+
ctx,
|
|
535
|
+
overrides,
|
|
536
|
+
declaredResource
|
|
537
|
+
);
|
|
538
|
+
if (overrideOrExtension !== undefined) {
|
|
539
|
+
return overrideOrExtension.isErr()
|
|
540
|
+
? overrideOrExtension
|
|
541
|
+
: Result.ok({
|
|
542
|
+
lease: overrideOrExtension.value.lease,
|
|
543
|
+
value: overrideOrExtension.value.value,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Resolve config before consulting the singleton cache so config-aware
|
|
548
|
+
// resources use the same canonical context for cache reads and writes.
|
|
549
|
+
const configAwareResource = resolveConfigAwareProvidedResource(
|
|
550
|
+
ctx,
|
|
551
|
+
declaredResource,
|
|
552
|
+
configValues
|
|
553
|
+
);
|
|
554
|
+
if (configAwareResource.isErr()) {
|
|
555
|
+
return configAwareResource;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// No provided instance — create via factory.
|
|
559
|
+
const resolved = configAwareResource.unwrap();
|
|
560
|
+
if (resolved.kind === 'provided') {
|
|
561
|
+
return Result.ok({ lease: resolved.lease, value: resolved.value });
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return await createResourceInstance(
|
|
565
|
+
declaredResource,
|
|
566
|
+
resolved.resourceContext
|
|
567
|
+
);
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
// Full trail resource resolution
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
const withResolvedResources = (
|
|
575
|
+
ctx: TrailContext,
|
|
576
|
+
resolvedResources: Record<string, unknown>
|
|
577
|
+
): TrailContext => {
|
|
578
|
+
const extensions = { ...ctx.extensions, ...resolvedResources };
|
|
579
|
+
const resolvedCtx = { ...ctx, extensions } as MutableTrailContext;
|
|
580
|
+
const lookup = createResourceLookup(() => resolvedCtx);
|
|
581
|
+
resolvedCtx.resource = lookup;
|
|
582
|
+
return resolvedCtx;
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Resolved trail context plus lease release for resources used by the run.
|
|
587
|
+
*/
|
|
588
|
+
export interface ResolvedResourceScope {
|
|
589
|
+
readonly ctx: TrailContext;
|
|
590
|
+
release(): void;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const releaseNoResources = (): undefined => undefined;
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Resolve all declared resources for a resource-bearing declaration.
|
|
597
|
+
*
|
|
598
|
+
* Validates per-resource config, checks overrides and caches, and creates
|
|
599
|
+
* new instances as needed. Returns an enriched context with all resource
|
|
600
|
+
* instances injected into extensions. Callers own the returned `release`.
|
|
601
|
+
*
|
|
602
|
+
* @example
|
|
603
|
+
* ```ts
|
|
604
|
+
* const scope = await createResources(
|
|
605
|
+
* { resources: [secretsStore] },
|
|
606
|
+
* createTrailContext()
|
|
607
|
+
* );
|
|
608
|
+
* if (scope.isOk()) {
|
|
609
|
+
* const secrets = secretsStore.from(scope.value.ctx);
|
|
610
|
+
* scope.value.release();
|
|
611
|
+
* }
|
|
612
|
+
* ```
|
|
613
|
+
*/
|
|
614
|
+
export const createResources = async (
|
|
615
|
+
trail: Pick<AnyTrail, 'resources'>,
|
|
616
|
+
ctx: TrailContext,
|
|
617
|
+
overrides?: ResourceOverrideMap,
|
|
618
|
+
configValues?: ConfigValues
|
|
619
|
+
): Promise<Result<ResolvedResourceScope, Error>> => {
|
|
620
|
+
const resources = [...new Set(trail.resources)];
|
|
621
|
+
if (resources.length === 0) {
|
|
622
|
+
return Result.ok({ ctx, release: releaseNoResources });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const resolvedResources: Record<string, unknown> = {};
|
|
626
|
+
const acquiredLeases: ResourceLease[] = [];
|
|
627
|
+
const createdResources: CreatedResourceInstance[] = [];
|
|
628
|
+
|
|
629
|
+
for (const declaredResource of resources) {
|
|
630
|
+
const resolved = await resolveDeclaredResourceForCreation(
|
|
631
|
+
declaredResource,
|
|
632
|
+
ctx,
|
|
633
|
+
overrides,
|
|
634
|
+
configValues
|
|
635
|
+
);
|
|
636
|
+
if (resolved.isErr()) {
|
|
637
|
+
releaseResourceLeases(acquiredLeases);
|
|
638
|
+
const rolledBack = await rollbackCreatedResources(createdResources);
|
|
639
|
+
if (rolledBack.isErr()) {
|
|
640
|
+
return Result.err(
|
|
641
|
+
toResourceLifecycleError(
|
|
642
|
+
`Resource resolution failed for "${declaredResource.id}" and rollback also failed`,
|
|
643
|
+
[rolledBack.error],
|
|
644
|
+
resolved.error
|
|
645
|
+
)
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return resolved;
|
|
649
|
+
}
|
|
650
|
+
const resolution = resolved.unwrap();
|
|
651
|
+
if (resolution.lease !== undefined) {
|
|
652
|
+
acquiredLeases.push(resolution.lease);
|
|
653
|
+
}
|
|
654
|
+
if (resolution.created !== undefined) {
|
|
655
|
+
createdResources.push(resolution.created);
|
|
656
|
+
}
|
|
657
|
+
resolvedResources[declaredResource.id] = resolution.value;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return Result.ok({
|
|
661
|
+
ctx: withResolvedResources(ctx, resolvedResources),
|
|
662
|
+
release: () => releaseResourceLeases(acquiredLeases),
|
|
663
|
+
});
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const toResourceInUseError = (
|
|
667
|
+
resource: AnyResource,
|
|
668
|
+
activeLeases: number
|
|
669
|
+
): InternalError =>
|
|
670
|
+
new InternalError(`Resource "${resource.id}" is still in use`, {
|
|
671
|
+
context: { activeLeases, resourceId: resource.id },
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const drainCachedEntry = async (
|
|
675
|
+
resource: AnyResource,
|
|
676
|
+
key: string,
|
|
677
|
+
entry: SingletonResourceEntry,
|
|
678
|
+
report: MutableResourceDrainReport,
|
|
679
|
+
failures: Error[]
|
|
680
|
+
): Promise<void> => {
|
|
681
|
+
if (entry.activeLeases > 0) {
|
|
682
|
+
failures.push(toResourceInUseError(resource, entry.activeLeases));
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const deleted = deleteCachedSingletonResource(resource, key, entry.value);
|
|
687
|
+
if (!deleted) {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
report.evicted.push(resource.id);
|
|
691
|
+
|
|
692
|
+
const result = await disposeResourceInstance(resource, entry.value);
|
|
693
|
+
if (result.isErr()) {
|
|
694
|
+
failures.push(result.error);
|
|
695
|
+
} else if (resource.dispose !== undefined) {
|
|
696
|
+
report.disposed.push(resource.id);
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const recordDrainMiss = (
|
|
701
|
+
resource: AnyResource,
|
|
702
|
+
report: MutableResourceDrainReport
|
|
703
|
+
): void => {
|
|
704
|
+
report.missed ??= [];
|
|
705
|
+
if (report.missed.includes(resource.id)) {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
report.missed.push(resource.id);
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const hasKeyOtherThan = <T>(
|
|
712
|
+
entries: ReadonlyMap<string, T> | undefined,
|
|
713
|
+
key: string
|
|
714
|
+
): boolean => {
|
|
715
|
+
if (entries === undefined) {
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
for (const existingKey of entries.keys()) {
|
|
719
|
+
if (existingKey !== key) {
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return false;
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
const hasOtherSingletonKeys = (
|
|
727
|
+
key: string,
|
|
728
|
+
cache: ReadonlyMap<string, SingletonResourceEntry> | undefined,
|
|
729
|
+
pending: ReadonlyMap<string, PendingCreation> | undefined
|
|
730
|
+
): boolean => hasKeyOtherThan(cache, key) || hasKeyOtherThan(pending, key);
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Evict and dispose cached resource singletons for a stable resource context.
|
|
734
|
+
*
|
|
735
|
+
* Call this from surface or test shutdown paths with the same `ctx` and
|
|
736
|
+
* `configValues` used for execution. The returned report lists resources
|
|
737
|
+
* removed from the singleton cache and resources whose `dispose` hook ran
|
|
738
|
+
* successfully. On partial failure, the `InternalError` context still includes
|
|
739
|
+
* the report arrays so callers can tell what cleanup already happened.
|
|
740
|
+
*/
|
|
741
|
+
export const drainResources = async (
|
|
742
|
+
resources: readonly AnyResource[],
|
|
743
|
+
ctx: TrailContext,
|
|
744
|
+
configValues?: ConfigValues
|
|
745
|
+
): Promise<Result<ResourceDrainReport, Error>> => {
|
|
746
|
+
const report: MutableResourceDrainReport = { disposed: [], evicted: [] };
|
|
747
|
+
const failures: Error[] = [];
|
|
748
|
+
|
|
749
|
+
for (const resource of resources.toReversed()) {
|
|
750
|
+
const cache = singletonResources.get(resource);
|
|
751
|
+
const pending = pendingCreations.get(resource);
|
|
752
|
+
if (
|
|
753
|
+
(cache === undefined || cache.size === 0) &&
|
|
754
|
+
(pending === undefined || pending.size === 0)
|
|
755
|
+
) {
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const configResult = resolveResourceConfig(resource, configValues);
|
|
760
|
+
if (configResult.isErr()) {
|
|
761
|
+
failures.push(configResult.error);
|
|
762
|
+
if (cache !== undefined) {
|
|
763
|
+
for (const [key, entry] of [...cache.entries()].toReversed()) {
|
|
764
|
+
await drainCachedEntry(resource, key, entry, report, failures);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const key = toResourceContextKey(
|
|
771
|
+
toResourceContext(ctx, configResult.value)
|
|
772
|
+
);
|
|
773
|
+
const hasAdditionalKeys = hasOtherSingletonKeys(key, cache, pending);
|
|
774
|
+
const pendingForKey = pending?.get(key);
|
|
775
|
+
if (pendingForKey !== undefined) {
|
|
776
|
+
if (hasAdditionalKeys) {
|
|
777
|
+
recordDrainMiss(resource, report);
|
|
778
|
+
}
|
|
779
|
+
failures.push(toResourceInUseError(resource, pendingForKey.waiters + 1));
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const entry = cache?.get(key);
|
|
784
|
+
if (entry === undefined) {
|
|
785
|
+
recordDrainMiss(resource, report);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (hasAdditionalKeys) {
|
|
790
|
+
recordDrainMiss(resource, report);
|
|
791
|
+
}
|
|
792
|
+
await drainCachedEntry(resource, key, entry, report, failures);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return failures.length === 0
|
|
796
|
+
? Result.ok(report)
|
|
797
|
+
: Result.err(
|
|
798
|
+
toResourceLifecycleError('Resource drain failed', failures, undefined, {
|
|
799
|
+
disposed: report.disposed,
|
|
800
|
+
evicted: report.evicted,
|
|
801
|
+
...(report.missed === undefined ? {} : { missed: report.missed }),
|
|
802
|
+
})
|
|
803
|
+
);
|
|
804
|
+
};
|