@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +849 -0
  2. package/README.md +190 -0
  3. package/package.json +36 -0
  4. package/src/activation-provenance.ts +116 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-derivation.ts +227 -0
  7. package/src/activation-source.ts +93 -0
  8. package/src/blob-ref.ts +90 -0
  9. package/src/branded.ts +135 -0
  10. package/src/collections.ts +99 -0
  11. package/src/compose-batch.ts +69 -0
  12. package/src/compose-schema.ts +36 -0
  13. package/src/context.ts +66 -0
  14. package/src/derive.ts +485 -0
  15. package/src/detours.ts +8 -0
  16. package/src/diagnostics.ts +21 -0
  17. package/src/draft.ts +350 -0
  18. package/src/entity.ts +346 -0
  19. package/src/error-rendering.ts +87 -0
  20. package/src/errors.ts +483 -0
  21. package/src/execute.ts +1577 -0
  22. package/src/fetch.ts +138 -0
  23. package/src/fire.ts +1172 -0
  24. package/src/glob.ts +81 -0
  25. package/src/guards.ts +37 -0
  26. package/src/index.ts +704 -0
  27. package/src/internal/fork-ctx.ts +69 -0
  28. package/src/layer-field-rendering.ts +193 -0
  29. package/src/layer.ts +81 -0
  30. package/src/observe.ts +361 -0
  31. package/src/path-scope.ts +66 -0
  32. package/src/path-security.ts +98 -0
  33. package/src/patterns/bulk.ts +16 -0
  34. package/src/patterns/change.ts +12 -0
  35. package/src/patterns/date-range.ts +12 -0
  36. package/src/patterns/index.ts +8 -0
  37. package/src/patterns/pagination.ts +22 -0
  38. package/src/patterns/progress.ts +13 -0
  39. package/src/patterns/sorting.ts +14 -0
  40. package/src/patterns/status.ts +11 -0
  41. package/src/patterns/timestamps.ts +12 -0
  42. package/src/permits.ts +12 -0
  43. package/src/queue.ts +163 -0
  44. package/src/redaction/index.ts +3 -0
  45. package/src/redaction/patterns.ts +50 -0
  46. package/src/redaction/redactor.ts +178 -0
  47. package/src/resilience.ts +234 -0
  48. package/src/resource-config.ts +804 -0
  49. package/src/resource.ts +194 -0
  50. package/src/result.ts +212 -0
  51. package/src/run.ts +76 -0
  52. package/src/runtime-builtins.ts +69 -0
  53. package/src/schedule-runtime.ts +689 -0
  54. package/src/schedule.ts +326 -0
  55. package/src/serialization.ts +265 -0
  56. package/src/sha256.ts +136 -0
  57. package/src/signal-diagnostics.ts +633 -0
  58. package/src/signal-ref.ts +111 -0
  59. package/src/signal.ts +104 -0
  60. package/src/store/accessor-protocol.ts +56 -0
  61. package/src/store/index.ts +4 -0
  62. package/src/structured-examples.ts +248 -0
  63. package/src/surface-derivation.ts +91 -0
  64. package/src/surface-filter.ts +101 -0
  65. package/src/surface-overlay.ts +694 -0
  66. package/src/surface-versioning.ts +42 -0
  67. package/src/topo.ts +835 -0
  68. package/src/tracing.ts +346 -0
  69. package/src/trail-id-glob.ts +15 -0
  70. package/src/trail.ts +1351 -0
  71. package/src/trails/derive-trail.ts +835 -0
  72. package/src/trails/index.ts +9 -0
  73. package/src/trails/ingest.ts +152 -0
  74. package/src/trails-db.ts +212 -0
  75. package/src/transport-error-map.ts +163 -0
  76. package/src/type-utils.ts +87 -0
  77. package/src/types.ts +300 -0
  78. package/src/validate-established-topo.ts +73 -0
  79. package/src/validate-topo.ts +725 -0
  80. package/src/validation.ts +330 -0
  81. package/src/version-marker.ts +716 -0
  82. package/src/version-resolution.ts +308 -0
  83. package/src/version-runtime.ts +120 -0
  84. package/src/webhook.ts +461 -0
  85. package/src/workspace.ts +244 -0
  86. 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
+ };