@ontrails/core 1.0.0-beta.15 → 1.0.0-beta.17

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 (348) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +64 -19
  3. package/package.json +9 -8
  4. package/src/activation-provenance.ts +111 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-projection.ts +221 -0
  7. package/src/activation-source.ts +91 -0
  8. package/src/blob-ref.ts +51 -0
  9. package/src/context.ts +4 -0
  10. package/src/{internal/cross-batch.ts → cross-batch.ts} +3 -11
  11. package/src/detours.ts +8 -0
  12. package/src/draft.ts +4 -0
  13. package/src/error-projection.ts +51 -0
  14. package/src/errors.ts +192 -39
  15. package/src/execute.ts +443 -44
  16. package/src/fire.ts +965 -91
  17. package/src/index.ts +285 -38
  18. package/src/layer-projection.ts +192 -0
  19. package/src/layer.ts +32 -1
  20. package/src/observe.ts +361 -0
  21. package/src/permits.ts +12 -0
  22. package/src/redaction/patterns.ts +6 -3
  23. package/src/resilience.ts +8 -8
  24. package/src/resource-config.ts +477 -44
  25. package/src/resource.ts +21 -1
  26. package/src/result.ts +18 -0
  27. package/src/run.ts +2 -2
  28. package/src/schedule-runtime.ts +689 -0
  29. package/src/schedule.ts +324 -0
  30. package/src/serialization.ts +90 -57
  31. package/src/signal-diagnostics.ts +633 -0
  32. package/src/{internal/signal-ref.ts → signal-ref.ts} +1 -1
  33. package/src/signal.ts +37 -0
  34. package/src/store/accessor-protocol.ts +5 -5
  35. package/src/structured-examples.ts +245 -0
  36. package/src/surface-derivation.ts +91 -0
  37. package/src/surface-filter.ts +7 -13
  38. package/src/topo.ts +386 -6
  39. package/src/tracing.ts +345 -0
  40. package/src/trail.ts +168 -22
  41. package/src/trails/derive-trail.ts +1 -1
  42. package/src/trails/ingest.ts +7 -15
  43. package/src/{internal/trails-db.ts → trails-db.ts} +42 -21
  44. package/src/transport-error-map.ts +146 -28
  45. package/src/types.ts +82 -28
  46. package/src/validate-established-topo.ts +6 -0
  47. package/src/validate-topo.ts +212 -5
  48. package/src/validation.ts +58 -1
  49. package/src/webhook.ts +276 -0
  50. package/src/{internal/zod-wrappers.ts → zod-wrappers.ts} +0 -6
  51. package/.turbo/turbo-build.log +0 -1
  52. package/.turbo/turbo-lint.log +0 -3
  53. package/.turbo/turbo-typecheck.log +0 -1
  54. package/dist/blob-ref.d.ts +0 -20
  55. package/dist/blob-ref.d.ts.map +0 -1
  56. package/dist/blob-ref.js +0 -22
  57. package/dist/blob-ref.js.map +0 -1
  58. package/dist/branded.d.ts +0 -36
  59. package/dist/branded.d.ts.map +0 -1
  60. package/dist/branded.js +0 -89
  61. package/dist/branded.js.map +0 -1
  62. package/dist/collections.d.ts +0 -31
  63. package/dist/collections.d.ts.map +0 -1
  64. package/dist/collections.js +0 -60
  65. package/dist/collections.js.map +0 -1
  66. package/dist/context.d.ts +0 -22
  67. package/dist/context.d.ts.map +0 -1
  68. package/dist/context.js +0 -37
  69. package/dist/context.js.map +0 -1
  70. package/dist/contour.d.ts +0 -81
  71. package/dist/contour.d.ts.map +0 -1
  72. package/dist/contour.js +0 -176
  73. package/dist/contour.js.map +0 -1
  74. package/dist/cross-schema.d.ts +0 -18
  75. package/dist/cross-schema.d.ts.map +0 -1
  76. package/dist/cross-schema.js +0 -29
  77. package/dist/cross-schema.js.map +0 -1
  78. package/dist/derive.d.ts +0 -44
  79. package/dist/derive.d.ts.map +0 -1
  80. package/dist/derive.js +0 -149
  81. package/dist/derive.js.map +0 -1
  82. package/dist/draft.d.ts +0 -28
  83. package/dist/draft.d.ts.map +0 -1
  84. package/dist/draft.js +0 -157
  85. package/dist/draft.js.map +0 -1
  86. package/dist/errors.d.ts +0 -141
  87. package/dist/errors.d.ts.map +0 -1
  88. package/dist/errors.js +0 -185
  89. package/dist/errors.js.map +0 -1
  90. package/dist/execute.d.ts +0 -52
  91. package/dist/execute.d.ts.map +0 -1
  92. package/dist/execute.js +0 -511
  93. package/dist/execute.js.map +0 -1
  94. package/dist/fetch.d.ts +0 -15
  95. package/dist/fetch.d.ts.map +0 -1
  96. package/dist/fetch.js +0 -102
  97. package/dist/fetch.js.map +0 -1
  98. package/dist/fire.d.ts +0 -43
  99. package/dist/fire.d.ts.map +0 -1
  100. package/dist/fire.js +0 -185
  101. package/dist/fire.js.map +0 -1
  102. package/dist/guards.d.ts +0 -17
  103. package/dist/guards.d.ts.map +0 -1
  104. package/dist/guards.js +0 -25
  105. package/dist/guards.js.map +0 -1
  106. package/dist/index.d.ts +0 -59
  107. package/dist/index.d.ts.map +0 -1
  108. package/dist/index.js +0 -61
  109. package/dist/index.js.map +0 -1
  110. package/dist/internal/cross-batch.d.ts +0 -44
  111. package/dist/internal/cross-batch.d.ts.map +0 -1
  112. package/dist/internal/cross-batch.js +0 -58
  113. package/dist/internal/cross-batch.js.map +0 -1
  114. package/dist/internal/fork-ctx.d.ts +0 -41
  115. package/dist/internal/fork-ctx.d.ts.map +0 -1
  116. package/dist/internal/fork-ctx.js +0 -47
  117. package/dist/internal/fork-ctx.js.map +0 -1
  118. package/dist/internal/signal-ref.d.ts +0 -15
  119. package/dist/internal/signal-ref.d.ts.map +0 -1
  120. package/dist/internal/signal-ref.js +0 -41
  121. package/dist/internal/signal-ref.js.map +0 -1
  122. package/dist/internal/topo-saves.d.ts +0 -53
  123. package/dist/internal/topo-saves.d.ts.map +0 -1
  124. package/dist/internal/topo-saves.js +0 -449
  125. package/dist/internal/topo-saves.js.map +0 -1
  126. package/dist/internal/topo-snapshots.d.ts +0 -57
  127. package/dist/internal/topo-snapshots.d.ts.map +0 -1
  128. package/dist/internal/topo-snapshots.js +0 -372
  129. package/dist/internal/topo-snapshots.js.map +0 -1
  130. package/dist/internal/topo-store-read.d.ts +0 -70
  131. package/dist/internal/topo-store-read.d.ts.map +0 -1
  132. package/dist/internal/topo-store-read.js +0 -221
  133. package/dist/internal/topo-store-read.js.map +0 -1
  134. package/dist/internal/topo-store.d.ts +0 -26
  135. package/dist/internal/topo-store.d.ts.map +0 -1
  136. package/dist/internal/topo-store.js +0 -621
  137. package/dist/internal/topo-store.js.map +0 -1
  138. package/dist/internal/tracing.d.ts +0 -98
  139. package/dist/internal/tracing.d.ts.map +0 -1
  140. package/dist/internal/tracing.js +0 -102
  141. package/dist/internal/tracing.js.map +0 -1
  142. package/dist/internal/trails-db.d.ts +0 -16
  143. package/dist/internal/trails-db.d.ts.map +0 -1
  144. package/dist/internal/trails-db.js +0 -118
  145. package/dist/internal/trails-db.js.map +0 -1
  146. package/dist/internal/zod-wrappers.d.ts +0 -40
  147. package/dist/internal/zod-wrappers.d.ts.map +0 -1
  148. package/dist/internal/zod-wrappers.js +0 -59
  149. package/dist/internal/zod-wrappers.js.map +0 -1
  150. package/dist/layer.d.ts +0 -23
  151. package/dist/layer.d.ts.map +0 -1
  152. package/dist/layer.js +0 -21
  153. package/dist/layer.js.map +0 -1
  154. package/dist/path-security.d.ts +0 -28
  155. package/dist/path-security.d.ts.map +0 -1
  156. package/dist/path-security.js +0 -63
  157. package/dist/path-security.js.map +0 -1
  158. package/dist/patterns/bulk.d.ts +0 -15
  159. package/dist/patterns/bulk.d.ts.map +0 -1
  160. package/dist/patterns/bulk.js +0 -14
  161. package/dist/patterns/bulk.js.map +0 -1
  162. package/dist/patterns/change.d.ts +0 -10
  163. package/dist/patterns/change.d.ts.map +0 -1
  164. package/dist/patterns/change.js +0 -10
  165. package/dist/patterns/change.js.map +0 -1
  166. package/dist/patterns/date-range.d.ts +0 -10
  167. package/dist/patterns/date-range.d.ts.map +0 -1
  168. package/dist/patterns/date-range.js +0 -10
  169. package/dist/patterns/date-range.js.map +0 -1
  170. package/dist/patterns/index.d.ts +0 -9
  171. package/dist/patterns/index.d.ts.map +0 -1
  172. package/dist/patterns/index.js +0 -9
  173. package/dist/patterns/index.js.map +0 -1
  174. package/dist/patterns/pagination.d.ts +0 -18
  175. package/dist/patterns/pagination.d.ts.map +0 -1
  176. package/dist/patterns/pagination.js +0 -18
  177. package/dist/patterns/pagination.js.map +0 -1
  178. package/dist/patterns/progress.d.ts +0 -11
  179. package/dist/patterns/progress.d.ts.map +0 -1
  180. package/dist/patterns/progress.js +0 -11
  181. package/dist/patterns/progress.js.map +0 -1
  182. package/dist/patterns/sorting.d.ts +0 -13
  183. package/dist/patterns/sorting.d.ts.map +0 -1
  184. package/dist/patterns/sorting.js +0 -10
  185. package/dist/patterns/sorting.js.map +0 -1
  186. package/dist/patterns/status.d.ts +0 -15
  187. package/dist/patterns/status.d.ts.map +0 -1
  188. package/dist/patterns/status.js +0 -9
  189. package/dist/patterns/status.js.map +0 -1
  190. package/dist/patterns/timestamps.d.ts +0 -10
  191. package/dist/patterns/timestamps.d.ts.map +0 -1
  192. package/dist/patterns/timestamps.js +0 -10
  193. package/dist/patterns/timestamps.js.map +0 -1
  194. package/dist/redaction/index.d.ts +0 -4
  195. package/dist/redaction/index.d.ts.map +0 -1
  196. package/dist/redaction/index.js +0 -3
  197. package/dist/redaction/index.js.map +0 -1
  198. package/dist/redaction/patterns.d.ts +0 -9
  199. package/dist/redaction/patterns.d.ts.map +0 -1
  200. package/dist/redaction/patterns.js +0 -39
  201. package/dist/redaction/patterns.js.map +0 -1
  202. package/dist/redaction/redactor.d.ts +0 -27
  203. package/dist/redaction/redactor.d.ts.map +0 -1
  204. package/dist/redaction/redactor.js +0 -89
  205. package/dist/redaction/redactor.js.map +0 -1
  206. package/dist/resilience.d.ts +0 -34
  207. package/dist/resilience.d.ts.map +0 -1
  208. package/dist/resilience.js +0 -164
  209. package/dist/resilience.js.map +0 -1
  210. package/dist/resource-config.d.ts +0 -22
  211. package/dist/resource-config.d.ts.map +0 -1
  212. package/dist/resource-config.js +0 -210
  213. package/dist/resource-config.js.map +0 -1
  214. package/dist/resource.d.ts +0 -74
  215. package/dist/resource.d.ts.map +0 -1
  216. package/dist/resource.js +0 -61
  217. package/dist/resource.js.map +0 -1
  218. package/dist/result.d.ts +0 -57
  219. package/dist/result.d.ts.map +0 -1
  220. package/dist/result.js +0 -156
  221. package/dist/result.js.map +0 -1
  222. package/dist/run.d.ts +0 -28
  223. package/dist/run.d.ts.map +0 -1
  224. package/dist/run.js +0 -35
  225. package/dist/run.js.map +0 -1
  226. package/dist/serialization.d.ts +0 -27
  227. package/dist/serialization.d.ts.map +0 -1
  228. package/dist/serialization.js +0 -154
  229. package/dist/serialization.js.map +0 -1
  230. package/dist/signal.d.ts +0 -33
  231. package/dist/signal.d.ts.map +0 -1
  232. package/dist/signal.js +0 -17
  233. package/dist/signal.js.map +0 -1
  234. package/dist/store/accessor-protocol.d.ts +0 -51
  235. package/dist/store/accessor-protocol.d.ts.map +0 -1
  236. package/dist/store/accessor-protocol.js +0 -2
  237. package/dist/store/accessor-protocol.js.map +0 -1
  238. package/dist/store/index.d.ts +0 -2
  239. package/dist/store/index.d.ts.map +0 -1
  240. package/dist/store/index.js +0 -2
  241. package/dist/store/index.js.map +0 -1
  242. package/dist/surface-filter.d.ts +0 -13
  243. package/dist/surface-filter.d.ts.map +0 -1
  244. package/dist/surface-filter.js +0 -76
  245. package/dist/surface-filter.js.map +0 -1
  246. package/dist/topo-store.d.ts +0 -62
  247. package/dist/topo-store.d.ts.map +0 -1
  248. package/dist/topo-store.js +0 -319
  249. package/dist/topo-store.js.map +0 -1
  250. package/dist/topo.d.ts +0 -39
  251. package/dist/topo.d.ts.map +0 -1
  252. package/dist/topo.js +0 -241
  253. package/dist/topo.js.map +0 -1
  254. package/dist/trail.d.ts +0 -163
  255. package/dist/trail.d.ts.map +0 -1
  256. package/dist/trail.js +0 -75
  257. package/dist/trail.js.map +0 -1
  258. package/dist/trails/derive-trail.d.ts +0 -66
  259. package/dist/trails/derive-trail.d.ts.map +0 -1
  260. package/dist/trails/derive-trail.js +0 -378
  261. package/dist/trails/derive-trail.js.map +0 -1
  262. package/dist/trails/index.d.ts +0 -5
  263. package/dist/trails/index.d.ts.map +0 -1
  264. package/dist/trails/index.js +0 -3
  265. package/dist/trails/index.js.map +0 -1
  266. package/dist/trails/ingest.d.ts +0 -24
  267. package/dist/trails/ingest.d.ts.map +0 -1
  268. package/dist/trails/ingest.js +0 -63
  269. package/dist/trails/ingest.js.map +0 -1
  270. package/dist/transport-error-map.d.ts +0 -106
  271. package/dist/transport-error-map.d.ts.map +0 -1
  272. package/dist/transport-error-map.js +0 -24
  273. package/dist/transport-error-map.js.map +0 -1
  274. package/dist/type-checks.test-d.d.ts +0 -71
  275. package/dist/type-checks.test-d.d.ts.map +0 -1
  276. package/dist/type-checks.test-d.js +0 -12
  277. package/dist/type-checks.test-d.js.map +0 -1
  278. package/dist/type-utils.d.ts +0 -33
  279. package/dist/type-utils.d.ts.map +0 -1
  280. package/dist/type-utils.js +0 -12
  281. package/dist/type-utils.js.map +0 -1
  282. package/dist/types.d.ts +0 -177
  283. package/dist/types.d.ts.map +0 -1
  284. package/dist/types.js +0 -3
  285. package/dist/types.js.map +0 -1
  286. package/dist/validate-established-topo.d.ts +0 -76
  287. package/dist/validate-established-topo.d.ts.map +0 -1
  288. package/dist/validate-established-topo.js +0 -43
  289. package/dist/validate-established-topo.js.map +0 -1
  290. package/dist/validate-topo.d.ts +0 -24
  291. package/dist/validate-topo.d.ts.map +0 -1
  292. package/dist/validate-topo.js +0 -193
  293. package/dist/validate-topo.js.map +0 -1
  294. package/dist/validation.d.ts +0 -27
  295. package/dist/validation.d.ts.map +0 -1
  296. package/dist/validation.js +0 -165
  297. package/dist/validation.js.map +0 -1
  298. package/dist/workspace.d.ts +0 -25
  299. package/dist/workspace.d.ts.map +0 -1
  300. package/dist/workspace.js +0 -57
  301. package/dist/workspace.js.map +0 -1
  302. package/src/__tests__/blob-ref.test.ts +0 -103
  303. package/src/__tests__/branded.test.ts +0 -148
  304. package/src/__tests__/collections.test.ts +0 -126
  305. package/src/__tests__/context.test.ts +0 -77
  306. package/src/__tests__/contour.test.ts +0 -263
  307. package/src/__tests__/derive-trail.test.ts +0 -668
  308. package/src/__tests__/derive.test.ts +0 -260
  309. package/src/__tests__/errors.test.ts +0 -427
  310. package/src/__tests__/execute.test.ts +0 -1563
  311. package/src/__tests__/fetch.test.ts +0 -217
  312. package/src/__tests__/fire.test.ts +0 -1056
  313. package/src/__tests__/fork-ctx.test.ts +0 -83
  314. package/src/__tests__/guards.test.ts +0 -102
  315. package/src/__tests__/ingest.test.ts +0 -191
  316. package/src/__tests__/layer.test.ts +0 -125
  317. package/src/__tests__/path-security.test.ts +0 -114
  318. package/src/__tests__/patterns.test.ts +0 -273
  319. package/src/__tests__/redaction.test.ts +0 -244
  320. package/src/__tests__/resilience.test.ts +0 -246
  321. package/src/__tests__/resource.test.ts +0 -204
  322. package/src/__tests__/result.test.ts +0 -155
  323. package/src/__tests__/run.test.ts +0 -181
  324. package/src/__tests__/serialization.test.ts +0 -401
  325. package/src/__tests__/service-config.test.ts +0 -224
  326. package/src/__tests__/signal.test.ts +0 -82
  327. package/src/__tests__/surface-filter.test.ts +0 -196
  328. package/src/__tests__/topo-store-read.test.ts +0 -256
  329. package/src/__tests__/topo-store.test.ts +0 -869
  330. package/src/__tests__/topo.test.ts +0 -573
  331. package/src/__tests__/trail-permit.test.ts +0 -60
  332. package/src/__tests__/trail.test.ts +0 -426
  333. package/src/__tests__/trails-db.test.ts +0 -198
  334. package/src/__tests__/transport-error-map.test.ts +0 -82
  335. package/src/__tests__/type-utils.test.ts +0 -118
  336. package/src/__tests__/validate-topo.test.ts +0 -611
  337. package/src/__tests__/validation.test.ts +0 -336
  338. package/src/__tests__/workspace.test.ts +0 -183
  339. package/src/__tests__/zod-wrappers.test.ts +0 -62
  340. package/src/internal/topo-snapshots.ts +0 -517
  341. package/src/internal/topo-store-read.ts +0 -481
  342. package/src/internal/topo-store.ts +0 -1192
  343. package/src/internal/tracing.ts +0 -187
  344. package/src/topo-store.ts +0 -510
  345. package/src/type-checks.test-d.ts +0 -106
  346. package/tsconfig.json +0 -9
  347. package/tsconfig.tests.json +0 -10
  348. package/tsconfig.tsbuildinfo +0 -1
@@ -11,6 +11,7 @@ import type {
11
11
  ResourceContext,
12
12
  ResourceOverrideMap,
13
13
  } from './resource.js';
14
+ import type { SurfaceConfigValues } from './surface-derivation.js';
14
15
  import type { AnyTrail } from './trail.js';
15
16
  import type { TrailContext } from './types.js';
16
17
 
@@ -22,18 +23,39 @@ type MutableTrailContext = {
22
23
  -readonly [K in keyof TrailContext]: TrailContext[K];
23
24
  };
24
25
 
25
- type ConfigValues = Readonly<Record<string, Record<string, unknown>>>;
26
+ export type ResourceConfigValues = SurfaceConfigValues;
27
+
28
+ type ConfigValues = ResourceConfigValues;
26
29
 
27
30
  // ---------------------------------------------------------------------------
28
31
  // Singleton caches
29
32
  // ---------------------------------------------------------------------------
30
33
 
31
- const singletonResources = new WeakMap<AnyResource, Map<string, unknown>>();
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
+ }
32
54
 
33
55
  /** In-flight resource creation promises, keyed by resource x context. */
34
56
  const pendingCreations = new WeakMap<
35
57
  AnyResource,
36
- Map<string, Promise<Result<unknown, Error>>>
58
+ Map<string, PendingCreation>
37
59
  >();
38
60
 
39
61
  // ---------------------------------------------------------------------------
@@ -65,7 +87,7 @@ const toResourceContextKey = (ctx: ResourceContext): string =>
65
87
  // ---------------------------------------------------------------------------
66
88
 
67
89
  /** Validate and resolve a resource's config from the provided configValues map. */
68
- const resolveResourceConfig = (
90
+ export const resolveResourceConfig = (
69
91
  declaredResource: AnyResource,
70
92
  configValues?: ConfigValues
71
93
  ): Result<unknown, Error> => {
@@ -74,6 +96,10 @@ const resolveResourceConfig = (
74
96
  }
75
97
  const raw = configValues?.[declaredResource.id];
76
98
  if (raw === undefined) {
99
+ const parsed = declaredResource.config.safeParse(raw);
100
+ if (parsed.success) {
101
+ return Result.ok(parsed.data);
102
+ }
77
103
  return Result.err(
78
104
  new ValidationError(
79
105
  `Resource "${declaredResource.id}" declares a config schema but no config was provided`
@@ -104,20 +130,24 @@ const hasOwnResourceOverride = (
104
130
  const getCachedSingletonResource = (
105
131
  declaredResource: AnyResource,
106
132
  resourceContext: ResourceContext
107
- ): { readonly found: boolean; readonly value: unknown } => {
133
+ ):
134
+ | { readonly found: false }
135
+ | { readonly found: true; readonly lease: ResourceLease } => {
108
136
  const scopedCache = singletonResources.get(declaredResource);
109
137
  if (scopedCache === undefined) {
110
- return { found: false, value: undefined };
138
+ return { found: false };
111
139
  }
112
140
 
113
141
  const key = toResourceContextKey(resourceContext);
114
- if (!scopedCache.has(key)) {
115
- return { found: false, value: undefined };
142
+ const entry = scopedCache.get(key);
143
+ if (entry === undefined) {
144
+ return { found: false };
116
145
  }
117
146
 
147
+ entry.activeLeases += 1;
118
148
  return {
119
149
  found: true,
120
- value: scopedCache.get(key),
150
+ lease: { key, resource: declaredResource, value: entry.value },
121
151
  };
122
152
  };
123
153
 
@@ -126,19 +156,24 @@ const getProvidedResource = (
126
156
  overrides: ResourceOverrideMap | undefined,
127
157
  declaredResource: AnyResource,
128
158
  resourceContext: ResourceContext
129
- ): Result<unknown, Error> | undefined => {
159
+ ):
160
+ | Result<
161
+ { readonly lease?: ResourceLease | undefined; readonly value: unknown },
162
+ Error
163
+ >
164
+ | undefined => {
130
165
  const { id } = declaredResource;
131
166
  if (hasOwnResourceOverride(overrides, id)) {
132
- return Result.ok(overrides[id]);
167
+ return Result.ok({ value: overrides[id] });
133
168
  }
134
169
 
135
170
  if (Object.hasOwn(ctx.extensions ?? {}, id)) {
136
- return Result.ok(ctx.extensions?.[id]);
171
+ return Result.ok({ value: ctx.extensions?.[id] });
137
172
  }
138
173
 
139
174
  const cached = getCachedSingletonResource(declaredResource, resourceContext);
140
175
  if (cached.found) {
141
- return Result.ok(cached.value);
176
+ return Result.ok({ lease: cached.lease, value: cached.lease?.value });
142
177
  }
143
178
 
144
179
  return undefined;
@@ -148,16 +183,61 @@ const getOverrideOrExtension = (
148
183
  ctx: TrailContext,
149
184
  overrides: ResourceOverrideMap | undefined,
150
185
  declaredResource: AnyResource
151
- ): Result<unknown, Error> | undefined =>
186
+ ):
187
+ | Result<
188
+ { readonly lease?: ResourceLease | undefined; readonly value: unknown },
189
+ Error
190
+ >
191
+ | undefined =>
152
192
  getProvidedResource(ctx, overrides, declaredResource, toResourceContext(ctx));
153
193
 
154
194
  type ConfigAwareResolution =
155
- | Result<{ readonly kind: 'provided'; readonly value: unknown }, Error>
195
+ | Result<
196
+ {
197
+ readonly kind: 'provided';
198
+ readonly lease?: ResourceLease | undefined;
199
+ readonly value: unknown;
200
+ },
201
+ Error
202
+ >
156
203
  | Result<
157
204
  { readonly kind: 'context'; readonly resourceContext: ResourceContext },
158
205
  Error
159
206
  >;
160
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
+
161
241
  const resolveConfigAwareProvidedResource = (
162
242
  ctx: TrailContext,
163
243
  declaredResource: AnyResource,
@@ -176,9 +256,18 @@ const resolveConfigAwareProvidedResource = (
176
256
  resourceContext
177
257
  );
178
258
 
179
- return provided
180
- ? Result.ok({ kind: 'provided', value: provided.unwrap() })
181
- : Result.ok({ kind: 'context', resourceContext });
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
+ });
182
271
  };
183
272
 
184
273
  // ---------------------------------------------------------------------------
@@ -196,20 +285,118 @@ const toInternalResourceError = (id: string, error: unknown): InternalError => {
196
285
 
197
286
  const getSingletonResourceCache = (
198
287
  declaredResource: AnyResource
199
- ): Map<string, unknown> => {
288
+ ): Map<string, SingletonResourceEntry> => {
200
289
  const existing = singletonResources.get(declaredResource);
201
290
  if (existing !== undefined) {
202
291
  return existing;
203
292
  }
204
293
 
205
- const created = new Map<string, unknown>();
294
+ const created = new Map<string, SingletonResourceEntry>();
206
295
  singletonResources.set(declaredResource, created);
207
296
  return created;
208
297
  };
209
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
+
210
396
  const doCreateResourceInstance = async (
211
397
  declaredResource: AnyResource,
212
- resourceContext: ResourceContext
398
+ resourceContext: ResourceContext,
399
+ key: string
213
400
  ): Promise<Result<unknown, Error>> => {
214
401
  try {
215
402
  const created = await declaredResource.create(resourceContext);
@@ -218,10 +405,10 @@ const doCreateResourceInstance = async (
218
405
  }
219
406
 
220
407
  const instance = created.unwrap();
221
- getSingletonResourceCache(declaredResource).set(
222
- toResourceContextKey(resourceContext),
223
- instance
224
- );
408
+ getSingletonResourceCache(declaredResource).set(key, {
409
+ activeLeases: 1,
410
+ value: instance,
411
+ });
225
412
  return Result.ok(instance);
226
413
  } catch (error: unknown) {
227
414
  return Result.err(toInternalResourceError(declaredResource.id, error));
@@ -232,13 +419,15 @@ const trackPendingCreation = (
232
419
  declaredResource: AnyResource,
233
420
  key: string,
234
421
  promise: Promise<Result<unknown, Error>>
235
- ): void => {
422
+ ): PendingCreation => {
423
+ const entry: PendingCreation = { promise, waiters: 0 };
236
424
  const pending = pendingCreations.get(declaredResource);
237
425
  if (pending) {
238
- pending.set(key, promise);
426
+ pending.set(key, entry);
239
427
  } else {
240
- pendingCreations.set(declaredResource, new Map([[key, promise]]));
428
+ pendingCreations.set(declaredResource, new Map([[key, entry]]));
241
429
  }
430
+ return entry;
242
431
  };
243
432
 
244
433
  /**
@@ -249,30 +438,96 @@ const trackPendingCreation = (
249
438
  const createResourceInstance = async (
250
439
  declaredResource: AnyResource,
251
440
  resourceContext: ResourceContext
252
- ): Promise<Result<unknown, Error>> => {
441
+ ): Promise<Result<ResourceResolution, Error>> => {
253
442
  const key = toResourceContextKey(resourceContext);
254
443
  const inflight = pendingCreations.get(declaredResource)?.get(key);
255
444
  if (inflight) {
256
- return 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 });
257
459
  }
258
460
 
259
- const promise = doCreateResourceInstance(declaredResource, resourceContext);
260
- trackPendingCreation(declaredResource, key, promise);
461
+ const promise = doCreateResourceInstance(
462
+ declaredResource,
463
+ resourceContext,
464
+ key
465
+ );
466
+ const pending = trackPendingCreation(declaredResource, key, promise);
261
467
 
262
468
  try {
263
- return await promise;
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
+ });
264
486
  } finally {
265
487
  pendingCreations.get(declaredResource)?.delete(key);
266
488
  }
267
489
  };
268
490
 
269
- /** Validate config and resolve a single declared resource. */
270
- const resolveDeclaredResource = async (
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 (
271
526
  declaredResource: AnyResource,
272
527
  ctx: TrailContext,
273
528
  overrides: ResourceOverrideMap | undefined,
274
529
  configValues: ConfigValues | undefined
275
- ): Promise<Result<unknown, Error>> => {
530
+ ): Promise<Result<ResourceResolution, Error>> => {
276
531
  // Check overrides/extensions first — skip config validation entirely when
277
532
  // a resource instance is already provided.
278
533
  const overrideOrExtension = getOverrideOrExtension(
@@ -281,7 +536,12 @@ const resolveDeclaredResource = async (
281
536
  declaredResource
282
537
  );
283
538
  if (overrideOrExtension !== undefined) {
284
- return overrideOrExtension;
539
+ return overrideOrExtension.isErr()
540
+ ? overrideOrExtension
541
+ : Result.ok({
542
+ lease: overrideOrExtension.value.lease,
543
+ value: overrideOrExtension.value.value,
544
+ });
285
545
  }
286
546
 
287
547
  // Resolve config before consulting the singleton cache so config-aware
@@ -298,7 +558,7 @@ const resolveDeclaredResource = async (
298
558
  // No provided instance — create via factory.
299
559
  const resolved = configAwareResource.unwrap();
300
560
  if (resolved.kind === 'provided') {
301
- return Result.ok(resolved.value);
561
+ return Result.ok({ lease: resolved.lease, value: resolved.value });
302
562
  }
303
563
 
304
564
  return await createResourceInstance(
@@ -322,6 +582,16 @@ const withResolvedResources = (
322
582
  return resolvedCtx;
323
583
  };
324
584
 
585
+ /**
586
+ * Resolved trail context plus lease release for resources used by the run.
587
+ */
588
+ interface ResolvedResourceScope {
589
+ readonly ctx: TrailContext;
590
+ release(): void;
591
+ }
592
+
593
+ const releaseNoResources = (): undefined => undefined;
594
+
325
595
  /**
326
596
  * Resolve all declared resources for a trail.
327
597
  *
@@ -334,26 +604,189 @@ export const createResources = async (
334
604
  ctx: TrailContext,
335
605
  overrides?: ResourceOverrideMap,
336
606
  configValues?: ConfigValues
337
- ): Promise<Result<TrailContext, Error>> => {
338
- const { resources } = trail;
607
+ ): Promise<Result<ResolvedResourceScope, Error>> => {
608
+ const resources = [...new Set(trail.resources)];
339
609
  if (resources.length === 0) {
340
- return Result.ok(ctx);
610
+ return Result.ok({ ctx, release: releaseNoResources });
341
611
  }
342
612
 
343
613
  const resolvedResources: Record<string, unknown> = {};
614
+ const acquiredLeases: ResourceLease[] = [];
615
+ const createdResources: CreatedResourceInstance[] = [];
344
616
 
345
617
  for (const declaredResource of resources) {
346
- const resolved = await resolveDeclaredResource(
618
+ const resolved = await resolveDeclaredResourceForCreation(
347
619
  declaredResource,
348
620
  ctx,
349
621
  overrides,
350
622
  configValues
351
623
  );
352
624
  if (resolved.isErr()) {
625
+ releaseResourceLeases(acquiredLeases);
626
+ const rolledBack = await rollbackCreatedResources(createdResources);
627
+ if (rolledBack.isErr()) {
628
+ return Result.err(
629
+ toResourceLifecycleError(
630
+ `Resource resolution failed for "${declaredResource.id}" and rollback also failed`,
631
+ [rolledBack.error],
632
+ resolved.error
633
+ )
634
+ );
635
+ }
353
636
  return resolved;
354
637
  }
355
- resolvedResources[declaredResource.id] = resolved.unwrap();
638
+ const resolution = resolved.unwrap();
639
+ if (resolution.lease !== undefined) {
640
+ acquiredLeases.push(resolution.lease);
641
+ }
642
+ if (resolution.created !== undefined) {
643
+ createdResources.push(resolution.created);
644
+ }
645
+ resolvedResources[declaredResource.id] = resolution.value;
646
+ }
647
+
648
+ return Result.ok({
649
+ ctx: withResolvedResources(ctx, resolvedResources),
650
+ release: () => releaseResourceLeases(acquiredLeases),
651
+ });
652
+ };
653
+
654
+ const toResourceInUseError = (
655
+ resource: AnyResource,
656
+ activeLeases: number
657
+ ): InternalError =>
658
+ new InternalError(`Resource "${resource.id}" is still in use`, {
659
+ context: { activeLeases, resourceId: resource.id },
660
+ });
661
+
662
+ const drainCachedEntry = async (
663
+ resource: AnyResource,
664
+ key: string,
665
+ entry: SingletonResourceEntry,
666
+ report: MutableResourceDrainReport,
667
+ failures: Error[]
668
+ ): Promise<void> => {
669
+ if (entry.activeLeases > 0) {
670
+ failures.push(toResourceInUseError(resource, entry.activeLeases));
671
+ return;
672
+ }
673
+
674
+ const deleted = deleteCachedSingletonResource(resource, key, entry.value);
675
+ if (!deleted) {
676
+ return;
677
+ }
678
+ report.evicted.push(resource.id);
679
+
680
+ const result = await disposeResourceInstance(resource, entry.value);
681
+ if (result.isErr()) {
682
+ failures.push(result.error);
683
+ } else if (resource.dispose !== undefined) {
684
+ report.disposed.push(resource.id);
685
+ }
686
+ };
687
+
688
+ const recordDrainMiss = (
689
+ resource: AnyResource,
690
+ report: MutableResourceDrainReport
691
+ ): void => {
692
+ report.missed ??= [];
693
+ if (report.missed.includes(resource.id)) {
694
+ return;
695
+ }
696
+ report.missed.push(resource.id);
697
+ };
698
+
699
+ const hasKeyOtherThan = <T>(
700
+ entries: ReadonlyMap<string, T> | undefined,
701
+ key: string
702
+ ): boolean => {
703
+ if (entries === undefined) {
704
+ return false;
705
+ }
706
+ for (const existingKey of entries.keys()) {
707
+ if (existingKey !== key) {
708
+ return true;
709
+ }
710
+ }
711
+ return false;
712
+ };
713
+
714
+ const hasOtherSingletonKeys = (
715
+ key: string,
716
+ cache: ReadonlyMap<string, SingletonResourceEntry> | undefined,
717
+ pending: ReadonlyMap<string, PendingCreation> | undefined
718
+ ): boolean => hasKeyOtherThan(cache, key) || hasKeyOtherThan(pending, key);
719
+
720
+ /**
721
+ * Evict and dispose cached resource singletons for a stable resource context.
722
+ *
723
+ * Call this from surface or test shutdown paths with the same `ctx` and
724
+ * `configValues` used for execution. The returned report lists resources
725
+ * removed from the singleton cache and resources whose `dispose` hook ran
726
+ * successfully. On partial failure, the `InternalError` context still includes
727
+ * the report arrays so callers can tell what cleanup already happened.
728
+ */
729
+ export const drainResources = async (
730
+ resources: readonly AnyResource[],
731
+ ctx: TrailContext,
732
+ configValues?: ConfigValues
733
+ ): Promise<Result<ResourceDrainReport, Error>> => {
734
+ const report: MutableResourceDrainReport = { disposed: [], evicted: [] };
735
+ const failures: Error[] = [];
736
+
737
+ for (const resource of resources.toReversed()) {
738
+ const cache = singletonResources.get(resource);
739
+ const pending = pendingCreations.get(resource);
740
+ if (
741
+ (cache === undefined || cache.size === 0) &&
742
+ (pending === undefined || pending.size === 0)
743
+ ) {
744
+ continue;
745
+ }
746
+
747
+ const configResult = resolveResourceConfig(resource, configValues);
748
+ if (configResult.isErr()) {
749
+ failures.push(configResult.error);
750
+ if (cache !== undefined) {
751
+ for (const [key, entry] of [...cache.entries()].toReversed()) {
752
+ await drainCachedEntry(resource, key, entry, report, failures);
753
+ }
754
+ }
755
+ continue;
756
+ }
757
+
758
+ const key = toResourceContextKey(
759
+ toResourceContext(ctx, configResult.value)
760
+ );
761
+ const hasAdditionalKeys = hasOtherSingletonKeys(key, cache, pending);
762
+ const pendingForKey = pending?.get(key);
763
+ if (pendingForKey !== undefined) {
764
+ if (hasAdditionalKeys) {
765
+ recordDrainMiss(resource, report);
766
+ }
767
+ failures.push(toResourceInUseError(resource, pendingForKey.waiters + 1));
768
+ continue;
769
+ }
770
+
771
+ const entry = cache?.get(key);
772
+ if (entry === undefined) {
773
+ recordDrainMiss(resource, report);
774
+ continue;
775
+ }
776
+
777
+ if (hasAdditionalKeys) {
778
+ recordDrainMiss(resource, report);
779
+ }
780
+ await drainCachedEntry(resource, key, entry, report, failures);
356
781
  }
357
782
 
358
- return Result.ok(withResolvedResources(ctx, resolvedResources));
783
+ return failures.length === 0
784
+ ? Result.ok(report)
785
+ : Result.err(
786
+ toResourceLifecycleError('Resource drain failed', failures, undefined, {
787
+ disposed: report.disposed,
788
+ evicted: report.evicted,
789
+ ...(report.missed === undefined ? {} : { missed: report.missed }),
790
+ })
791
+ );
359
792
  };