@evolu/common 8.0.0-next.4 → 8.0.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 (237) hide show
  1. package/dist/src/Array.d.ts +279 -173
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +105 -101
  4. package/dist/src/Assert.d.ts +44 -44
  5. package/dist/src/Assert.d.ts.map +1 -1
  6. package/dist/src/Assert.js +44 -32
  7. package/dist/src/BigInt.d.ts +6 -3
  8. package/dist/src/BigInt.d.ts.map +1 -1
  9. package/dist/src/BigInt.js +5 -2
  10. package/dist/src/Brand.d.ts +27 -40
  11. package/dist/src/Brand.d.ts.map +1 -1
  12. package/dist/src/Buffer.d.ts +27 -11
  13. package/dist/src/Buffer.d.ts.map +1 -1
  14. package/dist/src/Cache.d.ts +9 -4
  15. package/dist/src/Cache.d.ts.map +1 -1
  16. package/dist/src/Cache.js +8 -3
  17. package/dist/src/Callbacks.d.ts +30 -19
  18. package/dist/src/Callbacks.d.ts.map +1 -1
  19. package/dist/src/Console.d.ts +145 -66
  20. package/dist/src/Console.d.ts.map +1 -1
  21. package/dist/src/Console.js +91 -43
  22. package/dist/src/Crypto.d.ts +85 -42
  23. package/dist/src/Crypto.d.ts.map +1 -1
  24. package/dist/src/Crypto.js +44 -20
  25. package/dist/src/Eq.d.ts +87 -21
  26. package/dist/src/Eq.d.ts.map +1 -1
  27. package/dist/src/Eq.js +67 -19
  28. package/dist/src/Error.d.ts +5 -3
  29. package/dist/src/Error.d.ts.map +1 -1
  30. package/dist/src/Error.js +16 -0
  31. package/dist/src/Function.d.ts +119 -58
  32. package/dist/src/Function.d.ts.map +1 -1
  33. package/dist/src/Function.js +86 -43
  34. package/dist/src/Http.d.ts +394 -0
  35. package/dist/src/Http.d.ts.map +1 -0
  36. package/dist/src/Http.js +102 -0
  37. package/dist/src/Identicon.d.ts +16 -7
  38. package/dist/src/Identicon.d.ts.map +1 -1
  39. package/dist/src/Identicon.js +14 -5
  40. package/dist/src/LeakDetector.d.ts +88 -0
  41. package/dist/src/LeakDetector.d.ts.map +1 -0
  42. package/dist/src/LeakDetector.js +86 -0
  43. package/dist/src/LockManager.d.ts +18 -9
  44. package/dist/src/LockManager.d.ts.map +1 -1
  45. package/dist/src/LockManager.js +7 -8
  46. package/dist/src/Lookup.d.ts +43 -13
  47. package/dist/src/Lookup.d.ts.map +1 -1
  48. package/dist/src/Lookup.js +28 -8
  49. package/dist/src/Microtask.d.ts +1 -1
  50. package/dist/src/Microtask.d.ts.map +1 -1
  51. package/dist/src/Microtask.js +3 -7
  52. package/dist/src/Number.d.ts +49 -13
  53. package/dist/src/Number.d.ts.map +1 -1
  54. package/dist/src/Number.js +34 -5
  55. package/dist/src/Object.d.ts +160 -40
  56. package/dist/src/Object.d.ts.map +1 -1
  57. package/dist/src/Object.js +92 -46
  58. package/dist/src/Option.d.ts +10 -7
  59. package/dist/src/Option.d.ts.map +1 -1
  60. package/dist/src/Option.js +9 -6
  61. package/dist/src/Order.d.ts +21 -21
  62. package/dist/src/Order.d.ts.map +1 -1
  63. package/dist/src/Order.js +18 -21
  64. package/dist/src/Platform.d.ts +31 -0
  65. package/dist/src/Platform.d.ts.map +1 -1
  66. package/dist/src/Platform.js +98 -0
  67. package/dist/src/Random.d.ts +32 -17
  68. package/dist/src/Random.d.ts.map +1 -1
  69. package/dist/src/Random.js +12 -8
  70. package/dist/src/Redacted.d.ts +43 -29
  71. package/dist/src/Redacted.d.ts.map +1 -1
  72. package/dist/src/Redacted.js +14 -3
  73. package/dist/src/Ref.d.ts +11 -3
  74. package/dist/src/Ref.d.ts.map +1 -1
  75. package/dist/src/Ref.js +2 -2
  76. package/dist/src/RefCount.d.ts +2 -6
  77. package/dist/src/RefCount.d.ts.map +1 -1
  78. package/dist/src/RefCount.js +33 -90
  79. package/dist/src/Relation.d.ts +70 -5
  80. package/dist/src/Relation.d.ts.map +1 -1
  81. package/dist/src/Relation.js +77 -2
  82. package/dist/src/Resource.d.ts +590 -155
  83. package/dist/src/Resource.d.ts.map +1 -1
  84. package/dist/src/Resource.js +592 -343
  85. package/dist/src/Result.d.ts +838 -191
  86. package/dist/src/Result.d.ts.map +1 -1
  87. package/dist/src/Result.js +163 -102
  88. package/dist/src/Schedule.d.ts +684 -231
  89. package/dist/src/Schedule.d.ts.map +1 -1
  90. package/dist/src/Schedule.js +766 -295
  91. package/dist/src/Set.d.ts +89 -40
  92. package/dist/src/Set.d.ts.map +1 -1
  93. package/dist/src/Set.js +50 -29
  94. package/dist/src/Sqlite.d.ts +29 -72
  95. package/dist/src/Sqlite.d.ts.map +1 -1
  96. package/dist/src/Sqlite.js +41 -46
  97. package/dist/src/StackTrace.d.ts +40 -0
  98. package/dist/src/StackTrace.d.ts.map +1 -0
  99. package/dist/src/StackTrace.js +140 -0
  100. package/dist/src/Store.d.ts +5 -4
  101. package/dist/src/Store.d.ts.map +1 -1
  102. package/dist/src/Store.js +5 -4
  103. package/dist/src/String.d.ts +7 -1
  104. package/dist/src/String.d.ts.map +1 -1
  105. package/dist/src/String.js +20 -7
  106. package/dist/src/Task.d.ts +3572 -1770
  107. package/dist/src/Task.d.ts.map +1 -1
  108. package/dist/src/Task.js +1879 -1274
  109. package/dist/src/Test.d.ts +17 -125
  110. package/dist/src/Test.d.ts.map +1 -1
  111. package/dist/src/Test.js +17 -68
  112. package/dist/src/Time.d.ts +108 -49
  113. package/dist/src/Time.d.ts.map +1 -1
  114. package/dist/src/Time.js +172 -73
  115. package/dist/src/Type.d.ts +3542 -1926
  116. package/dist/src/Type.d.ts.map +1 -1
  117. package/dist/src/Type.js +3070 -1784
  118. package/dist/src/Types.d.ts +200 -108
  119. package/dist/src/Types.d.ts.map +1 -1
  120. package/dist/src/Types.js +39 -8
  121. package/dist/src/WebSocket.d.ts +48 -23
  122. package/dist/src/WebSocket.d.ts.map +1 -1
  123. package/dist/src/WebSocket.js +15 -6
  124. package/dist/src/Worker.d.ts +94 -36
  125. package/dist/src/Worker.d.ts.map +1 -1
  126. package/dist/src/Worker.js +58 -24
  127. package/dist/src/index.d.ts +55 -52
  128. package/dist/src/index.d.ts.map +1 -1
  129. package/dist/src/index.js +3 -1
  130. package/dist/src/intl/cs.d.ts +94 -0
  131. package/dist/src/intl/cs.d.ts.map +1 -0
  132. package/dist/src/intl/cs.js +220 -0
  133. package/dist/src/intl/index.d.ts +7 -0
  134. package/dist/src/intl/index.d.ts.map +1 -0
  135. package/dist/src/intl/index.js +6 -0
  136. package/dist/src/local-first/Db.d.ts +12 -8
  137. package/dist/src/local-first/Db.d.ts.map +1 -1
  138. package/dist/src/local-first/Db.js +109 -132
  139. package/dist/src/local-first/Error.d.ts +4 -4
  140. package/dist/src/local-first/Evolu.d.ts +338 -92
  141. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  142. package/dist/src/local-first/Evolu.js +47 -55
  143. package/dist/src/local-first/LocalAuth.d.ts +4 -4
  144. package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
  145. package/dist/src/local-first/Owner.d.ts +61 -24
  146. package/dist/src/local-first/Owner.d.ts.map +1 -1
  147. package/dist/src/local-first/Owner.js +45 -8
  148. package/dist/src/local-first/Protocol.d.ts +44 -31
  149. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  150. package/dist/src/local-first/Protocol.js +36 -13
  151. package/dist/src/local-first/Query.d.ts +147 -62
  152. package/dist/src/local-first/Query.d.ts.map +1 -1
  153. package/dist/src/local-first/Query.js +112 -58
  154. package/dist/src/local-first/Relay.d.ts +44 -14
  155. package/dist/src/local-first/Relay.d.ts.map +1 -1
  156. package/dist/src/local-first/Schema.d.ts +92 -41
  157. package/dist/src/local-first/Schema.d.ts.map +1 -1
  158. package/dist/src/local-first/Schema.js +25 -8
  159. package/dist/src/local-first/Shared.d.ts +23 -21
  160. package/dist/src/local-first/Shared.d.ts.map +1 -1
  161. package/dist/src/local-first/Shared.js +100 -57
  162. package/dist/src/local-first/Storage.d.ts +53 -32
  163. package/dist/src/local-first/Storage.d.ts.map +1 -1
  164. package/dist/src/local-first/Storage.js +194 -234
  165. package/dist/src/local-first/Timestamp.d.ts +21 -21
  166. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  167. package/dist/src/local-first/Timestamp.js +2 -2
  168. package/dist/src/local-first/index.d.ts +11 -11
  169. package/package.json +14 -13
  170. package/src/Array.ts +306 -205
  171. package/src/Assert.ts +54 -69
  172. package/src/BigInt.ts +6 -3
  173. package/src/Brand.ts +27 -40
  174. package/src/Buffer.ts +28 -12
  175. package/src/Cache.ts +9 -4
  176. package/src/Callbacks.ts +30 -19
  177. package/src/Console.ts +156 -84
  178. package/src/Crypto.ts +83 -41
  179. package/src/Eq.ts +95 -22
  180. package/src/Error.ts +8 -4
  181. package/src/Function.ts +167 -59
  182. package/src/Http.ts +549 -0
  183. package/src/Identicon.ts +16 -7
  184. package/src/LeakDetector.ts +182 -0
  185. package/src/LockManager.ts +43 -39
  186. package/src/Lookup.ts +62 -28
  187. package/src/Microtask.ts +19 -23
  188. package/src/Number.ts +64 -12
  189. package/src/Object.ts +173 -49
  190. package/src/Option.ts +10 -7
  191. package/src/Order.ts +23 -21
  192. package/src/Platform.ts +136 -0
  193. package/src/Polyfills.ts +6 -12
  194. package/src/Random.ts +32 -19
  195. package/src/Redacted.ts +44 -30
  196. package/src/Ref.ts +14 -6
  197. package/src/RefCount.ts +38 -80
  198. package/src/Relation.ts +202 -10
  199. package/src/Resource.ts +1199 -591
  200. package/src/Result.ts +930 -271
  201. package/src/Schedule.ts +859 -343
  202. package/src/Set.ts +89 -40
  203. package/src/Sqlite.ts +144 -138
  204. package/src/StackTrace.ts +230 -0
  205. package/src/Store.ts +8 -7
  206. package/src/String.ts +22 -5
  207. package/src/Task.ts +5101 -2832
  208. package/src/Test.ts +20 -168
  209. package/src/Time.ts +293 -90
  210. package/src/Type.ts +10034 -4117
  211. package/src/Types.ts +227 -110
  212. package/src/WebSocket.ts +64 -36
  213. package/src/Worker.ts +180 -72
  214. package/src/index.ts +55 -52
  215. package/src/intl/cs.ts +388 -0
  216. package/src/intl/index.ts +7 -0
  217. package/src/local-first/Db.ts +152 -179
  218. package/src/local-first/Error.ts +4 -4
  219. package/src/local-first/Evolu.ts +495 -250
  220. package/src/local-first/LocalAuth.ts +6 -12
  221. package/src/local-first/Owner.ts +58 -21
  222. package/src/local-first/Protocol.ts +49 -31
  223. package/src/local-first/Query.ts +146 -61
  224. package/src/local-first/Relay.ts +51 -21
  225. package/src/local-first/Schema.ts +109 -51
  226. package/src/local-first/Shared.ts +266 -226
  227. package/src/local-first/Storage.ts +266 -264
  228. package/src/local-first/Timestamp.ts +25 -18
  229. package/src/local-first/index.ts +11 -11
  230. package/dist/src/Skiplist.d.ts +0 -28
  231. package/dist/src/Skiplist.d.ts.map +0 -1
  232. package/dist/src/Skiplist.js +0 -64
  233. package/dist/src/Tracer.d.ts +0 -48
  234. package/dist/src/Tracer.d.ts.map +0 -1
  235. package/dist/src/Tracer.js +0 -6
  236. package/src/Skiplist.ts +0 -108
  237. package/src/Tracer.ts +0 -54
package/src/Resource.ts CHANGED
@@ -4,42 +4,29 @@
4
4
  * @module
5
5
  */
6
6
 
7
- import { assert, assertNotAborted } from "./Assert.js";
8
- import { identity } from "./Function.js";
7
+ import type { NonEmptyArray, NonEmptyReadonlyArray } from "./Array.ts";
8
+ import { assert, assertNonNullable } from "./Assert.ts";
9
+ import { identity, type disposable } from "./Function.ts";
9
10
  import {
10
11
  createLookupMap,
11
12
  createLookupSet,
12
13
  type Lookup,
13
14
  type LookupOption,
14
- } from "./Lookup.js";
15
- import {
16
- createRefCount,
17
- createRefCountByKey,
18
- type RefCountByKey,
19
- } from "./RefCount.js";
20
- import { createRelation } from "./Relation.js";
21
- import { ok } from "./Result.js";
15
+ } from "./Lookup.ts";
16
+ import { createRefCountedRelation } from "./Relation.ts";
17
+ import { ok } from "./Result.ts";
22
18
  import {
23
19
  createMutex,
24
20
  createMutexByKey,
25
21
  sleep,
26
- unabortable,
27
- type AbortError,
28
- type Fiber,
29
- type MutexRef,
22
+ type AbortableFiber,
23
+ type DisposableRun,
30
24
  type SemaphoreSnapshot,
31
25
  type Task,
32
- } from "./Task.js";
33
- import { type Duration } from "./Time.js";
34
- import { NonNegativeInt, zeroNonNegativeInt } from "./Type.js";
35
-
36
- export {
37
- createRefCount,
38
- createRefCountByKey,
39
- type CreateRefCountByKeyOptions,
40
- type RefCount,
41
- type RefCountByKey,
42
- } from "./RefCount.js";
26
+ } from "./Task.ts";
27
+ import { type PositiveDuration } from "./Time.ts";
28
+ import { NonNegativeInt } from "./Type.ts";
29
+ import { type DistributiveOmit } from "./Types.ts";
43
30
 
44
31
  /**
45
32
  * Disposable resource.
@@ -47,159 +34,215 @@ export {
47
34
  * A resource is any object that implements {@link Disposable} or
48
35
  * {@link AsyncDisposable}.
49
36
  *
37
+ * Successfully returning an owned Resource transfers ownership of a live
38
+ * resource to the caller. Return a typed Result error for a recoverable
39
+ * creation failure; use `undefined` only when absence is a valid success. If
40
+ * creation fails, aborts, or defects, use {@link AsyncDisposableStack} to
41
+ * dispose partially created resources and return no Resource. Never represent
42
+ * failed creation with an already-disposed Resource.
43
+ *
50
44
  * Disposal must succeed. A disposer that throws indicates an unrecoverable
51
45
  * invariant violation, not a recoverable domain error, so resource lifecycle
52
- * APIs let that error propagate. Disposal failures shall surface at the app
53
- * boundary, where they are shown to the user and logged. The purpose of
54
- * resource helpers is to guarantee cleanup and prevent leaks.
46
+ * APIs let that error propagate as a defect. The purpose of resource helpers is
47
+ * to guarantee cleanup and prevent leaks.
55
48
  *
56
- * @see {@link ResourceRef}
57
- * @see {@link createResourceRef}
58
49
  * @see {@link SharedResource}
59
50
  * @see {@link createSharedResource}
60
- * @see {@link SharedResourceByKey}
61
- * @see {@link createSharedResourceByKey}
62
51
  */
63
52
  export type Resource = Disposable | AsyncDisposable;
64
53
 
65
54
  /**
66
55
  * Borrowed {@link Resource}.
67
56
  *
68
- * A borrowed resource is a {@link Resource} without disposal methods.
57
+ * A borrowed resource removes disposal methods from a {@link Resource}'s type.
58
+ * The runtime object is unchanged.
69
59
  *
70
60
  * Another abstraction owns the resource and controls its lifecycle. Exposing
71
- * disposal would break that ownership and allow callers to dispose a resource
72
- * they do not own.
61
+ * disposal in the type would break that ownership and allow callers to dispose
62
+ * a resource they do not own.
73
63
  */
74
- export type BorrowedResource<T extends Resource> = Omit<
64
+ export type BorrowedResource<T extends Resource> = DistributiveOmit<
75
65
  T,
76
66
  typeof Symbol.dispose | typeof Symbol.asyncDispose
77
67
  >;
78
68
 
79
69
  /**
80
- * {@link Resource} reference.
70
+ * An owned lease on a shared {@link Resource}.
81
71
  *
82
- * A {@link MutexRef}-like reference for resources. `ResourceRef` controls the
83
- * resource lifecycle.
72
+ * A lease keeps the underlying resource alive. Release it with
73
+ * {@link Lease.release} or `using`; releasing the last lease starts the resource
74
+ * disposal path. Release is idempotent, so double release is a safe no-op.
84
75
  *
85
- * Callers get the current resource as {@link BorrowedResource} to ensure only
86
- * the `ResourceRef` can dispose it.
76
+ * Release is synchronous accounting and never waits for resource disposal.
77
+ * Disposal completion belongs to the owning {@link SharedResource}: it may be
78
+ * delayed by {@link SharedResourceOptions.idleDisposeAfter | idleDisposeAfter}
79
+ * and is awaited by the owner's async disposal.
87
80
  *
88
- * Setting a new resource first disposes the current one and then sets the next.
89
- * Calling abort on the returned Fiber does not roll that change back once it
90
- * has started. The create Task must not fail. If it could fail, the current
91
- * resource would be disposed without the next resource installed.
81
+ * Release does not revoke {@link Lease.resource | resource}; a released lease
82
+ * keeps a plain reference, and using it after the resource is disposed is a
83
+ * programmer error that nothing catches statically. Create resources with the
84
+ * {@link disposable} helper so use-after-dispose throws eagerly instead of
85
+ * operating on disposed state.
92
86
  */
93
- export interface ResourceRef<
94
- T extends Resource,
95
- D = unknown,
96
- > extends AsyncDisposable {
97
- /** Returns the current resource. */
98
- readonly get: Task<BorrowedResource<T>, never, D>;
87
+ export interface Lease<T extends Resource> extends Disposable {
88
+ /** The leased resource. */
89
+ readonly resource: BorrowedResource<T>;
90
+
91
+ /** Whether this acquisition created the leased resource generation. */
92
+ readonly created: boolean;
99
93
 
100
94
  /**
101
- * Disposes the current resource and then creates and sets the next.
95
+ * Releases this lease.
102
96
  *
103
- * Once started, this operation runs to completion even if the caller aborts
104
- * its Fiber. Always await the result instead of treating abort as rollback.
97
+ * Returns whether this call released a still-held lease. Returns `false` if
98
+ * the lease was already released, drained by disposal of its owning
99
+ * {@link SharedResource}, or drained by disposal of the Run tree that owns
100
+ * it.
105
101
  */
106
- readonly set: (create: Task<T, never, D>) => Task<void, never, D>;
102
+ readonly release: () => boolean;
107
103
  }
108
104
 
109
- /** Creates {@link ResourceRef}. */
110
- export const createResourceRef = <T extends Resource, D>(
111
- create: Task<T, never, D>,
112
- ): Task<ResourceRef<T, D>, never, D> =>
113
- unabortable<ResourceRef<T, D>, never, D>(async (run) => {
114
- const resourceRefRun = run.create();
115
-
116
- await using disposer = new AsyncDisposableStack();
117
- disposer.use(resourceRefRun);
118
-
119
- const initial = await resourceRefRun(create);
120
- if (!initial.ok) return initial;
121
-
122
- let current = createOwnedResource(initial.value);
123
-
124
- const mutex = disposer.use(createMutex());
125
- disposer.defer(() => current.stack.disposeAsync());
126
- // Register as the last so disposal aborts further calls first.
127
- // Repeated registration is safe because disposal is idempotent.
128
- disposer.use(resourceRefRun);
129
-
130
- const disposables = disposer.move();
131
-
132
- return ok({
133
- get: () => resourceRefRun(mutex.withLock(() => ok(current.resource))),
134
-
135
- set: (create: Task<T, never, D>): Task<void, never, D> =>
136
- unabortable(() =>
137
- resourceRefRun(
138
- mutex.withLock(async (run) => {
139
- await current.stack.disposeAsync();
140
- const next = await run(create);
141
- if (!next.ok) return next;
142
- current = createOwnedResource(next.value);
143
- return ok();
144
- }),
145
- ),
146
- ),
147
-
148
- [Symbol.asyncDispose]: () => disposables.disposeAsync(),
149
- });
150
- });
151
-
152
105
  /**
153
106
  * Shared {@link Resource}.
154
107
  *
155
- * Lazily acquires the underlying resource on the first
156
- * {@link SharedResource.acquire | acquire} call, shares it across callers, and
157
- * disposes it when the last caller {@link SharedResource.release | releases}
158
- * it.
108
+ * Lazily creates the underlying resource on the first
109
+ * {@link SharedResource.acquire | acquire} call, shares it across callers via
110
+ * {@link Lease}s, and disposes it when the last lease is released.
159
111
  *
160
- * Calls to {@link SharedResource.release | release} must be balanced with
161
- * successful calls to {@link SharedResource.acquire | acquire}. Releasing more
162
- * times than acquired is a programmer error checked with {@link assert}.
112
+ * ### Example
113
+ *
114
+ * ```ts
115
+ * import {
116
+ * all,
117
+ * createRun,
118
+ * createSharedResource,
119
+ * ok,
120
+ * type Task,
121
+ * } from "@evolu/common";
122
+ *
123
+ * interface Connection extends Disposable {
124
+ * readonly send: (message: string) => void;
125
+ * }
126
+ *
127
+ * let createdCount = 0;
128
+ * const createConnection: Task<Connection> = () => {
129
+ * createdCount += 1;
130
+ * return ok({
131
+ * send: (_message) => {},
132
+ * [Symbol.dispose]: () => {},
133
+ * });
134
+ * };
135
+ *
136
+ * await using run = createRun();
137
+ * await using sharedConnection = await run.ok(
138
+ * createSharedResource(createConnection, { idleDisposeAfter: "5s" }),
139
+ * );
140
+ *
141
+ * // Creating the owner is lazy; no connection is open yet.
142
+ * expect(createdCount).toBe(0);
143
+ *
144
+ * // `use` owns and releases a lease around each operation.
145
+ * const send = (message: string): Task<void> =>
146
+ * sharedConnection.use((connection) => () => {
147
+ * connection.send(message);
148
+ * return ok();
149
+ * });
150
+ *
151
+ * {
152
+ * // An explicit lease can span several operations. Concurrent `use` calls
153
+ * // share the connection kept alive by this lease.
154
+ * using batchLease = await run.ok(sharedConnection.acquire);
155
+ * batchLease.resource.send("first");
156
+ * batchLease.resource.send("second");
157
+ * await run.ok(all([send("hello"), send("world")], { concurrency: 2 }));
158
+ * }
159
+ *
160
+ * // Reacquiring during the idle delay reuses the same generation.
161
+ * using reusedLease = await run.ok(sharedConnection.acquire);
162
+ * expect(reusedLease.created).toBe(false);
163
+ * expect(createdCount).toBe(1);
164
+ * ```
165
+ *
166
+ * ## FAQ
167
+ *
168
+ * ### Why release a lease with `using`?
169
+ *
170
+ * `using` guarantees a {@link Lease} is released on every exit path: normal
171
+ * completion, a thrown error, and abort — abort surfaces as an exception in
172
+ * Task code, so stack unwinding runs disposers. Binding a lease to an ordinary
173
+ * `const` instead is a deliberate ownership transfer; the new owner must
174
+ * guarantee release.
175
+ *
176
+ * ### What happens when a lease leaks?
177
+ *
178
+ * Nothing in JavaScript enforces `using` or release; a lease that is never
179
+ * released compiles silently (see [MDN resource management
180
+ * pitfalls](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Resource_management#pitfalls);
181
+ * a lint rule may eventually close this gap:
182
+ * https://github.com/typescript-eslint/typescript-eslint/issues/8255). Encoding
183
+ * resource lifetime in the type system would not be bulletproof either: types
184
+ * can force a lifetime to exist, but a lifetime scoped too widely leaks just as
185
+ * silently. Evolu therefore stays JS-native — `using` in the language — and
186
+ * bounds the damage structurally. A leaked lease keeps the resource alive but
187
+ * never past its owner: disposing a SharedResource drains all outstanding
188
+ * leases and disposes the resource. A leaked SharedResource is bounded by its
189
+ * Run, whose disposal also drains its leases and disposes its current resource.
190
+ * Leaks are also observable — {@link SharedResource.snapshot | snapshot} exposes
191
+ * a lease count that never returns to zero — and detected: in development
192
+ * builds, a lease that is garbage-collected without release logs a warning with
193
+ * its acquire stack via the LeakDetector dependency.
163
194
  */
164
- export interface SharedResource<
165
- T extends Resource,
166
- D = unknown,
167
- > extends AsyncDisposable {
195
+ export interface SharedResource<T extends Resource> extends AsyncDisposable {
168
196
  /**
169
- * Acquires a shared reference.
197
+ * Acquires a {@link Lease} on the shared resource.
170
198
  *
171
199
  * The first call lazily creates the resource. Later calls reuse the same
172
- * resource until the final {@link SharedResource.release | release} starts the
173
- * final disposal path. Disposal happens immediately by default, or after
174
- * {@link SharedResourceOptions.idleDisposeAfter | idleDisposeAfter} elapses
175
- * when configured.
200
+ * resource until the last lease is released.
201
+ *
202
+ * Once started, acquire runs to completion even when the caller aborts its
203
+ * Fiber, and the returned lease still counts as held. If resource creation or
204
+ * disposal is in progress, acquire waits for it to complete. Always await the
205
+ * result and release the lease; a caller that stops waiting (for example with
206
+ * the `daemon` helper) abandons a lease that is never released, retaining the
207
+ * resource until the owning {@link SharedResource} is disposed.
176
208
  *
177
- * Once started, acquire runs to completion even if the caller aborts its
178
- * Fiber. Always await the result. A successful result still counts as an
179
- * acquired lease and must later be balanced with
180
- * {@link SharedResource.release | release}.
209
+ * Owner disposal is different from caller abort: if disposal starts before a
210
+ * lease is transferred, acquire aborts with `runDisposedAbortReason` from the
211
+ * SharedResource's internal Run. A resource returned by `create` after
212
+ * shutdown starts remains owned by this SharedResource and is disposed
213
+ * without a lease escaping. Transfer occurs when the internal acquisition
214
+ * completes, before the caller necessarily resumes. If owner disposal starts
215
+ * in that gap, the caller can receive a lease already drained by disposal.
216
+ * Its `release` returns `false`, and its resource must not be used.
181
217
  */
182
- readonly acquire: Task<BorrowedResource<T>, never, D>;
218
+ readonly acquire: Task<Lease<T>>;
183
219
 
184
220
  /**
185
- * Releases one previously acquired shared reference.
221
+ * Acquires a {@link Lease} on the current resource without creating one.
186
222
  *
187
- * When the last acquired reference is released, the current resource is
188
- * disposed immediately by default. If
189
- * {@link SharedResourceOptions.idleDisposeAfter | idleDisposeAfter} is set,
190
- * disposal is scheduled instead and a new acquire during that delay reuses
191
- * the current resource.
192
- *
193
- * Once started, release runs to completion even if the caller aborts its
194
- * Fiber. Always await the result instead of assuming no cleanup happened.
223
+ * Waits for preceding creation or disposal to finish. Returns `undefined` if
224
+ * no current resource remains. Owner disposal while waiting aborts the
225
+ * acquisition before a lease is transferred. As with `acquire`, owner
226
+ * disposal after transfer can drain the lease before the caller resumes.
195
227
  */
196
- readonly release: Task<void, never, D>;
197
-
198
- /** Returns the current acquire count. */
199
- readonly getCount: Task<NonNegativeInt, never, D>;
228
+ readonly acquireCurrent: Task<Lease<T> | undefined>;
200
229
 
201
- /** Returns the current resource, or `undefined` if absent. */
202
- readonly get: () => BorrowedResource<T> | undefined;
230
+ /**
231
+ * Acquires a {@link Lease}, runs a Task with the shared resource, and releases
232
+ * the lease after the Task settles.
233
+ *
234
+ * Creates the resource when absent. The callback receives whether this use
235
+ * created the resource generation. While the owner remains running, the lease
236
+ * prevents ordinary idle disposal. Disposing this SharedResource is forceful:
237
+ * it drains the lease and may dispose the resource before or while the
238
+ * callback Task runs.
239
+ */
240
+ readonly use: <R, E, D>(
241
+ callback: (
242
+ resource: BorrowedResource<T>,
243
+ created: boolean,
244
+ ) => Task<R, E, D>,
245
+ ) => Task<R, E, D>;
203
246
 
204
247
  /** Returns the current shared-resource state for monitoring/debugging. */
205
248
  readonly snapshot: () => SharedResourceSnapshot;
@@ -208,61 +251,185 @@ export interface SharedResource<
208
251
  /** Snapshot returned by {@link SharedResource.snapshot}. */
209
252
  export interface SharedResourceSnapshot {
210
253
  /**
211
- * Whether the resource has no current value, no borrowers, and no pending
212
- * idle disposal.
254
+ * Whether the resource has no current value, no leases, no pending idle
255
+ * disposal, and no acquisition in progress.
213
256
  */
214
257
  readonly isIdle: boolean;
215
258
 
216
- /** Current mutex state for monitoring/debugging. */
259
+ /** Current active lease count. */
260
+ readonly leaseCount: NonNegativeInt;
261
+
262
+ /** Whether a current resource exists. */
263
+ readonly hasResource: boolean;
264
+
265
+ /** Whether delayed idle disposal is scheduled and pending. */
266
+ readonly idleDisposePending: boolean;
267
+
268
+ /** Current internal mutex state for monitoring/debugging. */
217
269
  readonly mutex: SemaphoreSnapshot;
218
270
  }
219
271
 
220
272
  /** Options for {@link createSharedResource}. */
221
273
  export interface SharedResourceOptions {
222
274
  /**
223
- * Keeps the resource alive briefly after the last release.
275
+ * Keeps the resource alive briefly after the last lease is released.
224
276
  *
225
277
  * This avoids immediate disposal when the resource is expensive to create and
226
278
  * likely to be acquired again soon. A new acquire during this delay cancels
227
279
  * the pending disposal and reuses the current resource.
228
280
  */
229
- readonly idleDisposeAfter?: Duration | undefined;
281
+ readonly idleDisposeAfter?: PositiveDuration | undefined;
230
282
 
231
- /** Called after the current resource is disposed and cleared. */
232
- readonly onDisposed?: () => void;
283
+ /**
284
+ * Called after each current resource disposal is attempted and the current
285
+ * reference is cleared, including during {@link SharedResource} disposal.
286
+ * Native disposal still calls it if the resource disposer defects. Not called
287
+ * if no resource was created.
288
+ */
289
+ readonly onDisposed?: (() => void) | undefined;
233
290
  }
234
291
 
235
- /** Creates {@link SharedResource}. */
236
- export const createSharedResource = <T extends Resource, D>(
237
- create: Task<T, never, D>,
238
- { idleDisposeAfter, onDisposed }: SharedResourceOptions = {},
239
- ): Task<SharedResource<T, D>, never, D> =>
240
- unabortable<SharedResource<T, D>, never, D>(async (run) => {
292
+ /**
293
+ * Creates {@link SharedResource}.
294
+ *
295
+ * The `create` Task must not fail. Creation establishes shared resource state
296
+ * and may serve multiple concurrent acquirers, so a recoverable failure does
297
+ * not belong to one lease. Handle recoverable failures inside `create`: retry
298
+ * until creation succeeds (for example with a Schedule), or return a resource
299
+ * whose state models connection and reconnection failures.
300
+ *
301
+ * A successfully returned resource must be live and independently owned. The
302
+ * `create` Task must not bind its disposal to that Task's {@link DisposableRun}
303
+ * with `defer`.
304
+ *
305
+ * Create resources with {@link disposable} when their shape supports it. Its
306
+ * disposal guard makes calls through a lease fail eagerly after resource
307
+ * disposal instead of operating on disposed state.
308
+ *
309
+ * The `create` Task runs while acquisition is locked and must not directly or
310
+ * transitively acquire from the same SharedResource. The lock is non-reentrant,
311
+ * so doing so deadlocks.
312
+ *
313
+ * Internal work runs on a Run created from the Run that executes this Task, so
314
+ * dependencies are captured at creation time. Deps provided to later acquire
315
+ * calls do not change what `create` observes.
316
+ *
317
+ * Lifecycle callbacks must not throw. A throwing
318
+ * {@link SharedResourceOptions.onDisposed | onDisposed} is a defect that panics
319
+ * the Run tree.
320
+ */
321
+ export const createSharedResource =
322
+ <T extends Resource, D>(
323
+ create: Task<T, never, D>,
324
+ { idleDisposeAfter, onDisposed }: SharedResourceOptions = {},
325
+ ): Task<SharedResource<T>, never, D> =>
326
+ (run) => {
241
327
  const sharedResourceRun = run.create();
242
- let current: OwnedResource<T> | undefined;
243
- let idleDisposeFiber: Fiber<void, AbortError, D> | undefined;
328
+ const { leakDetector } = run.deps;
244
329
 
245
- await using disposer = new AsyncDisposableStack();
246
- const refCount = disposer.use(createRefCount());
330
+ let current: T | undefined;
331
+ let idleDisposeFiber: AbortableFiber<void, never, D> | undefined;
332
+ const heldLeases = new Set<object>();
247
333
 
248
- const mutex = disposer.use(createMutex());
334
+ const mutex = createMutex();
335
+ const sharedResourceHandle = {};
249
336
 
250
- const disposeCurrent = async () => {
337
+ // Idle disposal calls this under the mutex. Owner finalization calls it only
338
+ // after child Tasks, including idle disposal, settle, so calls cannot
339
+ // overlap.
340
+ const disposeCurrent = async (): Promise<void> => {
251
341
  if (!current) return;
252
- await using disposer = new AsyncDisposableStack();
253
- if (onDisposed) disposer.defer(onDisposed);
254
- disposer.use(current.stack);
342
+ await using resourceDisposer = new AsyncDisposableStack();
343
+ if (onDisposed) resourceDisposer.defer(onDisposed);
344
+ resourceDisposer.use(current);
255
345
  current = undefined;
256
346
  };
257
- disposer.defer(disposeCurrent);
258
347
 
259
- // Register as the last so disposal aborts further calls first.
260
- disposer.use(sharedResourceRun);
348
+ /** Disposes the current resource unless a new lease arrived meanwhile. */
349
+ const disposeCurrentWhenUnused: Task<void, never, D> = mutex.withLock(
350
+ async () => {
351
+ // For delayed disposal, this is safe to clear unconditionally: a newer
352
+ // idle-dispose fiber would require a release after a completed acquire,
353
+ // but FIFO orders that acquire behind this task. Therefore this is the
354
+ // current fiber or already undefined, never newer. An acquire that got
355
+ // the mutex before this task queued aborted this fiber while it was
356
+ // still sleeping. If this fiber was already queued on the mutex, abort
357
+ // removes its semaphore waiter before grant, so this body cannot run
358
+ // stale.
359
+ idleDisposeFiber = undefined;
360
+ if (heldLeases.size === 0) await disposeCurrent();
361
+ return ok();
362
+ },
363
+ );
261
364
 
262
- const disposables = disposer.move();
365
+ // Execution order is LIFO: clear idle state, drain leases, dispose current,
366
+ // then untrack the owner. Separate entries preserve later cleanup when an
367
+ // earlier finalizer defects.
368
+ sharedResourceRun.defer(() => {
369
+ leakDetector.untrack(sharedResourceHandle);
370
+ });
371
+ sharedResourceRun.defer(disposeCurrent);
372
+ sharedResourceRun.defer(() => {
373
+ for (const handle of heldLeases) leakDetector.untrack(handle);
374
+ heldLeases.clear();
375
+ });
376
+ sharedResourceRun.defer(() => {
377
+ idleDisposeFiber = undefined;
378
+ });
379
+
380
+ const createLease = (created: boolean): Lease<T> => {
381
+ const handle = {};
382
+ heldLeases.add(handle);
383
+
384
+ const release = (): boolean => {
385
+ if (!heldLeases.delete(handle)) return false;
386
+
387
+ leakDetector.untrack(handle);
388
+
389
+ if (heldLeases.size > 0) return true;
390
+
391
+ // During shutdown, disposal owns the resource; release is accounting
392
+ // only.
393
+ if (sharedResourceRun.getState().type !== "Running") return true;
394
+
395
+ if (idleDisposeAfter) {
396
+ idleDisposeFiber = sharedResourceRun.abortable<void, never>(
397
+ async (run) => {
398
+ await run.ok(sleep(idleDisposeAfter));
399
+ return run(disposeCurrentWhenUnused);
400
+ },
401
+ );
402
+ } else {
403
+ // Immediate disposal is not retained for acquire to cancel. Starting
404
+ // it synchronously queues or acquires the mutex before release
405
+ // returns. The mutex orders it against acquires, and disposal
406
+ // rechecks the lease count under the lock.
407
+ void sharedResourceRun.abortable(disposeCurrentWhenUnused);
408
+ }
263
409
 
264
- return ok({
265
- acquire: unabortable<BorrowedResource<T>, never, D>(() =>
410
+ return true;
411
+ };
412
+
413
+ const lease: Lease<T> = {
414
+ resource: current as unknown as BorrowedResource<T>,
415
+ created,
416
+ release,
417
+ [Symbol.dispose]: release,
418
+ };
419
+
420
+ leakDetector.track(
421
+ lease,
422
+ { name: "Lease", isLeaked: () => heldLeases.has(handle) },
423
+ handle,
424
+ );
425
+
426
+ return lease;
427
+ };
428
+
429
+ const sharedResource: SharedResource<T> = {
430
+ // Run on the resource-owned tree so caller abort cannot interrupt acquire
431
+ // and later acquire deps cannot replace the owner's captured deps.
432
+ acquire: () =>
266
433
  sharedResourceRun(
267
434
  mutex.withLock(async (run) => {
268
435
  if (idleDisposeFiber) {
@@ -270,147 +437,266 @@ export const createSharedResource = <T extends Resource, D>(
270
437
  idleDisposeFiber = undefined;
271
438
  }
272
439
 
273
- if (!current) {
274
- const resource = await run(create);
275
- if (!resource.ok) return resource;
276
- current = createOwnedResource(resource.value);
277
- }
278
-
279
- refCount.increment();
280
- return ok(current.resource);
440
+ const created = current === undefined;
441
+ current ??= await run.ok(create);
442
+ // Publish ownership before the checkpoint so owner finalization
443
+ // disposes a resource returned after shutdown started.
444
+ run.signal.throwIfAborted();
445
+ return ok(createLease(created));
281
446
  }),
282
447
  ),
283
- ),
284
448
 
285
- release: unabortable<void, never, D>(() =>
449
+ acquireCurrent: () =>
286
450
  sharedResourceRun(
287
- mutex.withLock(async () => {
288
- if (refCount.decrement() > 0) return ok();
451
+ // This body must stay synchronous so owner disposal cannot start
452
+ // between checking current and transferring its lease.
453
+ mutex.withLock(() => {
454
+ if (!current) return ok(undefined);
289
455
 
290
- if (!idleDisposeAfter) {
291
- await disposeCurrent();
292
- return ok();
456
+ if (idleDisposeFiber) {
457
+ idleDisposeFiber.abort();
458
+ idleDisposeFiber = undefined;
293
459
  }
294
460
 
295
- idleDisposeFiber = sharedResourceRun<void, never>(async (run) => {
296
- const slept = await run(sleep(idleDisposeAfter));
297
- if (!slept.ok) return slept;
298
-
299
- return run(
300
- mutex.withLock(async () => {
301
- idleDisposeFiber = undefined;
302
- await disposeCurrent();
303
- return ok();
304
- }),
305
- );
306
- });
307
-
308
- return ok();
461
+ return ok(createLease(false));
309
462
  }),
310
463
  ),
311
- ),
312
464
 
313
- getCount: () =>
314
- sharedResourceRun(mutex.withLock(() => ok(refCount.getCount()))),
465
+ use: (callback) => async (run) => {
466
+ using lease = await run.ok(sharedResource.acquire);
467
+ return await run(callback(lease.resource, lease.created));
468
+ },
315
469
 
316
- get: () => current?.resource,
470
+ snapshot: () => {
471
+ const mutexSnapshot = mutex.snapshot();
472
+ return {
473
+ isIdle:
474
+ heldLeases.size === 0 &&
475
+ !current &&
476
+ !idleDisposeFiber &&
477
+ mutexSnapshot.isIdle,
478
+ leaseCount: NonNegativeInt.orThrow(heldLeases.size),
479
+ hasResource: current !== undefined,
480
+ idleDisposePending: idleDisposeFiber !== undefined,
481
+ mutex: mutexSnapshot,
482
+ };
483
+ },
317
484
 
318
- snapshot: () => ({
319
- isIdle: refCount.getCount() === 0 && !current && !idleDisposeFiber,
320
- mutex: mutex.snapshot(),
321
- }),
485
+ [Symbol.asyncDispose]: () => sharedResourceRun[Symbol.asyncDispose](),
486
+ };
322
487
 
323
- [Symbol.asyncDispose]: () => disposables.disposeAsync(),
324
- });
325
- });
488
+ leakDetector.track(
489
+ sharedResource,
490
+ {
491
+ name: "SharedResource",
492
+ isLeaked: () => sharedResourceRun.getState().type === "Running",
493
+ },
494
+ sharedResourceHandle,
495
+ );
496
+
497
+ return ok(sharedResource);
498
+ };
326
499
 
327
500
  /**
328
501
  * Shared {@link Resource}s keyed by logical identity.
329
502
  *
330
503
  * A map-like registry of {@link SharedResource}s. Each key owns at most one
331
- * current resource instance.
332
- *
333
- * The first {@link SharedResourceByKey.acquire | acquire} for a key lazily
334
- * creates that key's resource. Later acquires for the same key reuse the same
335
- * resource until the final {@link SharedResourceByKey.release | release} starts
336
- * the final disposal path for that key. Disposal and registry removal happen
337
- * immediately by default, or after
338
- * {@link SharedResourceByKeyOptions.idleDisposeAfter | idleDisposeAfter} elapses
339
- * when configured.
504
+ * current resource instance. The first
505
+ * {@link SharedResourceByKey.acquire | acquire} for a key lazily creates that
506
+ * key's resource; releasing the key's last {@link Lease} disposes it and removes
507
+ * the key from the registry.
340
508
  *
341
509
  * Different keys are independent and may progress concurrently. Operations for
342
- * the same key are serialized. Calls to
343
- * {@link SharedResourceByKey.release | release} must be balanced with successful
344
- * calls to {@link SharedResourceByKey.acquire | acquire}. Acquire and release
345
- * may still be aborted before they start on an already-stopped Run, but once
346
- * started they run to completion. Releasing more times than acquired is a
347
- * programmer error checked with {@link assert}.
348
- *
349
- * By default, {@link createSharedResourceByKey} uses reference identity for
350
- * keys, matching native `Map`. Callers may instead provide a
351
- * {@link Lookup
352
- * lookup} so logical equality is based on a derived stable key.
510
+ * the same key are serialized.
511
+ *
512
+ * By default, keys use reference identity, matching native `Map`. Callers may
513
+ * instead provide a {@link Lookup lookup} so logical equality is based on a
514
+ * derived stable key.
515
+ *
516
+ * ### Example
517
+ *
518
+ * ```ts
519
+ * import {
520
+ * all,
521
+ * createRun,
522
+ * createSharedResourceByKey,
523
+ * ok,
524
+ * type Task,
525
+ * } from "@evolu/common";
526
+ *
527
+ * interface Connection extends Disposable {
528
+ * readonly ownerId: string;
529
+ * readonly send: (message: string) => void;
530
+ * readonly flush: () => ReadonlyArray<string>;
531
+ * }
532
+ *
533
+ * const createConnection =
534
+ * (ownerId: string): Task<Connection> =>
535
+ * () => {
536
+ * const pendingMessages: Array<string> = [];
537
+ * return ok({
538
+ * ownerId,
539
+ * send: (message) => {
540
+ * pendingMessages.push(message);
541
+ * },
542
+ * flush: () => pendingMessages.splice(0),
543
+ * [Symbol.dispose]: () => {},
544
+ * });
545
+ * };
546
+ *
547
+ * await using run = createRun();
548
+ * await using connections = await run.ok(
549
+ * createSharedResourceByKey(createConnection, {
550
+ * idleDisposeAfter: "30s",
551
+ * }),
552
+ * );
553
+ * const send = (ownerId: string, message: string): Task<void> =>
554
+ * connections.use(ownerId, (connection) => () => {
555
+ * connection.send(message);
556
+ * return ok();
557
+ * });
558
+ *
559
+ * // Same-key calls share one connection. Work for different keys remains
560
+ * // independent, so all three operations can run concurrently.
561
+ * await run.ok(
562
+ * all(
563
+ * [
564
+ * send("owner-1", "first"),
565
+ * send("owner-1", "second"),
566
+ * send("owner-2", "hello"),
567
+ * ],
568
+ * { concurrency: 3 },
569
+ * ),
570
+ * );
571
+ *
572
+ * // `acquireCurrent` does not create absent keys. The idle delay keeps the two
573
+ * // existing connections available to enumerate and flush.
574
+ * using missingLease = await run.ok(connections.acquireCurrent("owner-3"));
575
+ * const messagesByOwnerId = new Map<string, ReadonlyArray<string>>();
576
+ * await run.ok(
577
+ * connections.forEachCurrent((connection) => {
578
+ * messagesByOwnerId.set(connection.ownerId, connection.flush());
579
+ * }),
580
+ * );
581
+ *
582
+ * expect(missingLease).toBeUndefined();
583
+ * expect(messagesByOwnerId).toEqual(
584
+ * new Map([
585
+ * ["owner-1", ["first", "second"]],
586
+ * ["owner-2", ["hello"]],
587
+ * ]),
588
+ * );
589
+ * ```
353
590
  */
354
591
  export interface SharedResourceByKey<
355
592
  K,
356
593
  T extends Resource,
357
- D = unknown,
358
594
  > extends AsyncDisposable {
359
595
  /**
360
- * Acquires the shared resource for `key`, creating it on first use.
596
+ * Acquires a {@link Lease} on the shared resource for `key`, creating the
597
+ * resource on first use.
361
598
  *
362
- * Once started, acquire runs to completion even if the caller aborts its
363
- * Fiber. Always await the result. A successful result still counts as an
364
- * acquired lease for `key` and must later be balanced with
365
- * {@link SharedResourceByKey.release | release}.
599
+ * The same contract as {@link SharedResource.acquire}: once started, acquire
600
+ * runs to completion even when the caller aborts its Fiber, and the returned
601
+ * lease still counts as held. Registry disposal before lease transfer aborts
602
+ * the acquisition. Always await the result and release the lease.
366
603
  */
367
- readonly acquire: (key: K) => Task<BorrowedResource<T>, never, D>;
604
+ readonly acquire: (key: K) => Task<Lease<T>>;
368
605
 
369
606
  /**
370
- * Releases one previously acquired shared reference for `key`.
371
- *
372
- * When the last acquired reference for `key` is released, that key's current
373
- * resource is disposed and removed from the registry immediately by default.
374
- * If {@link SharedResourceByKeyOptions.idleDisposeAfter | idleDisposeAfter} is
375
- * set, disposal and registry removal are scheduled instead and a new acquire
376
- * for the same key during that delay reuses the current resource.
607
+ * Acquires a {@link Lease} on the current resource for `key` without creating
608
+ * one.
377
609
  *
378
- * Once started, release runs to completion even if the caller aborts its
379
- * Fiber. Always await the result instead of assuming no cleanup happened.
610
+ * Waits for preceding creation or disposal for the same key to finish.
611
+ * Returns `undefined` if no current resource remains. Registry disposal while
612
+ * waiting aborts the acquisition before a lease is transferred. Registry
613
+ * disposal after transfer can drain the lease before the caller resumes,
614
+ * matching {@link SharedResource.acquireCurrent}.
380
615
  */
381
- readonly release: (key: K) => Task<void, never, D>;
616
+ readonly acquireCurrent: (key: K) => Task<Lease<T> | undefined>;
382
617
 
383
- /** Returns the current acquire count for `key`. Missing keys return `0`. */
384
- readonly getCount: (key: K) => Task<NonNegativeInt, never, D>;
618
+ /**
619
+ * Acquires a {@link Lease}, runs a Task with the shared resource for `key`,
620
+ * and releases the lease after the Task settles.
621
+ *
622
+ * Creates the resource when absent. The callback receives whether this use
623
+ * created the resource generation. While the registry remains running, the
624
+ * lease prevents ordinary idle disposal. Disposing this registry is forceful:
625
+ * it drains the lease and may dispose the resource before or while the
626
+ * callback Task runs.
627
+ */
628
+ readonly use: <R, E, D>(
629
+ key: K,
630
+ callback: (
631
+ resource: BorrowedResource<T>,
632
+ created: boolean,
633
+ ) => Task<R, E, D>,
634
+ ) => Task<R, E, D>;
385
635
 
386
- /** Returns the current resource for `key`, or `undefined` if absent. */
387
- readonly get: (key: K) => BorrowedResource<T> | undefined;
636
+ /**
637
+ * Calls `callback` for each current resource it can lease from keys
638
+ * registered when this Task starts.
639
+ *
640
+ * Never creates resources. A resource whose creation is in progress may be
641
+ * included after creation finishes; one disposed before its temporary lease
642
+ * is acquired is skipped. Each acquired lease stays held while later keys are
643
+ * awaited, and every callback runs while all acquired resources remain
644
+ * leased. Caller abort is observed between keys; already-collected leases are
645
+ * released and no callbacks run.
646
+ */
647
+ readonly forEachCurrent: (
648
+ callback: (resource: BorrowedResource<T>, key: K) => void,
649
+ ) => Task<void>;
388
650
 
389
- /** Returns current keyed resources and their per-key mutex state. */
390
- readonly snapshot: () => SharedResourceByKeySnapshot<K, T>;
651
+ /** Returns current per-key shared-resource states for monitoring/debugging. */
652
+ readonly snapshot: () => SharedResourceByKeySnapshot<K>;
391
653
  }
392
654
 
393
655
  /** Snapshot returned by {@link SharedResourceByKey.snapshot}. */
394
- export interface SharedResourceByKeySnapshot<K, T extends Resource> {
395
- /** Current borrowed resources by key. */
396
- readonly resourcesByKey: ReadonlyMap<K, BorrowedResource<T>>;
397
-
398
- /** Current mutex state for each key in the resource snapshot. */
399
- readonly mutexByKey: ReadonlyMap<K, SemaphoreSnapshot | null>;
656
+ export interface SharedResourceByKeySnapshot<K> {
657
+ /**
658
+ * Current registered {@link SharedResourceSnapshot} for each key.
659
+ *
660
+ * A key is absent during the brief first-acquire interval before its
661
+ * SharedResource is registered, even though that acquire may already hold the
662
+ * key mutex.
663
+ */
664
+ readonly resourcesByKey: ReadonlyMap<K, SharedResourceSnapshot>;
400
665
  }
401
666
 
402
667
  /** Options for {@link createSharedResourceByKey}. */
403
668
  export interface SharedResourceByKeyOptions<K, L = K>
404
669
  extends Pick<SharedResourceOptions, "idleDisposeAfter">, LookupOption<K, L> {
405
- /** Called after `key`'s current resource is disposed and cleared. */
406
- readonly onDisposed?: (key: K) => void;
670
+ /**
671
+ * Called with `key` after each current resource for that key is disposed,
672
+ * including during {@link SharedResourceByKey} disposal. Not called for keys
673
+ * whose resources were never created.
674
+ *
675
+ * After ordinary idle disposal, the key is removed before this callback
676
+ * unless a same-key operation is already in progress. A regular acquire can
677
+ * then create the next current resource. An acquireCurrent that finds no
678
+ * resource removes the retained key before returning.
679
+ */
680
+ readonly onDisposed?: ((key: K) => void) | undefined;
407
681
  }
408
682
 
409
683
  /**
410
684
  * Creates {@link SharedResourceByKey}.
411
685
  *
412
- * The `create` Task is scoped to one key. It must not fail, matching
413
- * {@link createSharedResource}.
686
+ * The `create` callback returns a Task scoped to one key. The returned Task
687
+ * must not fail, matching {@link createSharedResource}.
688
+ *
689
+ * Create each resource with {@link disposable} when its shape supports it. The
690
+ * same disposal-guard recommendation as {@link createSharedResource} applies.
691
+ *
692
+ * The returned Task must not directly or transitively acquire the same logical
693
+ * key or call `forEachCurrent` on this registry. Same-key acquisition is
694
+ * non-reentrant, and `forEachCurrent` attempts to acquire every registered key.
695
+ * Different keys remain independent.
696
+ *
697
+ * Like {@link createSharedResource}, dependencies are captured when the registry
698
+ * is created; dependencies provided to later acquire calls do not change what
699
+ * the returned Task observes.
414
700
  */
415
701
  export function createSharedResourceByKey<
416
702
  K = unknown,
@@ -418,11 +704,11 @@ export function createSharedResourceByKey<
418
704
  D = unknown,
419
705
  >(
420
706
  create: (key: K) => Task<T, never, D>,
421
- ): Task<SharedResourceByKey<K, T, D>, never, D>;
707
+ ): Task<SharedResourceByKey<K, T>, never, D>;
422
708
  export function createSharedResourceByKey<K, T extends Resource, D, L = K>(
423
709
  create: (key: K) => Task<T, never, D>,
424
710
  options: SharedResourceByKeyOptions<K, L>,
425
- ): Task<SharedResourceByKey<K, T, D>, never, D>;
711
+ ): Task<SharedResourceByKey<K, T>, never, D>;
426
712
  export function createSharedResourceByKey<
427
713
  K = unknown,
428
714
  T extends Resource = Resource,
@@ -435,156 +721,359 @@ export function createSharedResourceByKey<
435
721
  lookup = identity as Lookup<K, L>,
436
722
  onDisposed,
437
723
  }: SharedResourceByKeyOptions<K, L> = {},
438
- ): Task<SharedResourceByKey<K, T, D>, never, D> {
439
- return unabortable<SharedResourceByKey<K, T, D>, never, D>(async (run) => {
724
+ ): Task<SharedResourceByKey<K, T>, never, D> {
725
+ return (run) => {
440
726
  const sharedResourceByKeyRun = run.create();
441
- const sharedResourcesByKey = createLookupMap<K, SharedResource<T, D>, L>({
727
+ const { leakDetector } = run.deps;
728
+
729
+ const sharedResourcesByKey = createLookupMap<K, SharedResource<T>, L>({
442
730
  lookup,
443
731
  });
444
732
 
445
- await using disposer = new AsyncDisposableStack();
733
+ const mutexByKey = createMutexByKey<K, L>({ lookup });
734
+ const disposer = new AsyncDisposableStack();
735
+ const sharedResourceByKeyHandle = {};
736
+
737
+ const removeKey = (key: K, sharedResource: SharedResource<T>): void => {
738
+ // Callers must ensure no same-key operation can still use sharedResource.
739
+ // onDisposed checks that the key mutex is idle; acquireCurrent holds the
740
+ // key mutex after observing that the inner resource is empty.
741
+ // Explicit registry disposal owns every registered SharedResource. Keep
742
+ // the key visible until keyDisposer captures it.
743
+ if (disposer.disposed) return;
744
+
745
+ sharedResourcesByKey.delete(key);
746
+
747
+ // During root shutdown, the inner Run is already being disposed.
748
+ if (sharedResourceByKeyRun.getState().type !== "Running") return;
446
749
 
447
- const mutexByKey = disposer.use(createMutexByKey<K, L>({ lookup }));
750
+ void sharedResourceByKeyRun.abortable(async () => {
751
+ await sharedResource[Symbol.asyncDispose]();
752
+ return ok();
753
+ });
754
+ };
755
+
756
+ // Disposed in reverse order: the Run first, so pending acquires and removed
757
+ // SharedResource disposals are aborted and awaited before the registered
758
+ // SharedResources are disposed.
448
759
  disposer.defer(async () => {
449
- await using disposer = new AsyncDisposableStack();
450
- for (const resource of sharedResourcesByKey.values())
451
- disposer.use(resource);
452
- await disposer.disposeAsync();
760
+ await using keyDisposer = new AsyncDisposableStack();
761
+ for (const sharedResource of sharedResourcesByKey.values())
762
+ keyDisposer.use(sharedResource);
453
763
  sharedResourcesByKey.clear();
454
764
  });
455
- // Register as the last so disposal aborts further calls first.
456
765
  disposer.use(sharedResourceByKeyRun);
457
766
 
458
- const disposables = disposer.move();
767
+ sharedResourceByKeyRun.defer(() => {
768
+ leakDetector.untrack(sharedResourceByKeyHandle);
769
+ });
459
770
 
460
- return ok({
461
- acquire: (key) =>
462
- unabortable<BorrowedResource<T>, never, D>(() =>
463
- sharedResourceByKeyRun(
464
- mutexByKey.withLock(key, async (run) => {
465
- let sharedResource = sharedResourcesByKey.get(key);
466
-
467
- if (!sharedResource) {
468
- const sharedResourceResult = await run(
469
- createSharedResource(create(key), {
470
- idleDisposeAfter,
471
- onDisposed: () => {
472
- onDisposed?.(key);
473
-
474
- void sharedResourceByKeyRun(
475
- mutexByKey.withLock(key, async () => {
476
- if (
477
- sharedResource &&
478
- sharedResourcesByKey.get(key) === sharedResource &&
479
- sharedResource.snapshot().isIdle
480
- ) {
481
- sharedResourcesByKey.delete(key);
482
- await sharedResource[Symbol.asyncDispose]();
483
- }
484
- return ok();
485
- }),
486
- );
487
- },
488
- }),
489
- );
490
- assertNotAborted(sharedResourceResult);
491
- sharedResource = sharedResourceResult.value;
492
- sharedResourcesByKey.set(key, sharedResource);
493
- }
494
-
495
- return run(sharedResource.acquire);
496
- }),
497
- ),
498
- ),
771
+ const releaseLeaseIfAborted = (
772
+ signal: AbortSignal,
773
+ lease: Lease<T> | undefined,
774
+ ): void => {
775
+ if (!signal.aborted) return;
776
+ lease?.release();
777
+ signal.throwIfAborted();
778
+ };
499
779
 
500
- release: (key) =>
501
- unabortable<void, never, D>(() =>
502
- sharedResourceByKeyRun(
503
- mutexByKey.withLock(key, async () => {
504
- const sharedResource = sharedResourcesByKey.get(key);
505
- assert(
506
- sharedResource,
507
- "Release must not be called more times than acquire.",
780
+ const sharedResourceByKey: SharedResourceByKey<K, T> = {
781
+ acquire: (key) => () =>
782
+ sharedResourceByKeyRun(
783
+ mutexByKey.withLock(key, async (run) => {
784
+ // The outer key mutex spans registration and the inner acquire so
785
+ // key removal cannot race resource creation or lease transfer.
786
+ let sharedResource = sharedResourcesByKey.get(key);
787
+
788
+ if (!sharedResource) {
789
+ sharedResource = await run.ok(
790
+ createSharedResource(create(key), {
791
+ idleDisposeAfter,
792
+ onDisposed: () => {
793
+ assertNonNullable(
794
+ sharedResource,
795
+ "SharedResource must be assigned before its resource is disposed.",
796
+ );
797
+ if (mutexByKey.isIdle(key)) removeKey(key, sharedResource);
798
+ onDisposed?.(key);
799
+ },
800
+ }),
508
801
  );
509
- return sharedResourceByKeyRun(sharedResource.release);
510
- }),
511
- ),
802
+ sharedResourcesByKey.set(key, sharedResource);
803
+ }
804
+
805
+ const lease = await run.ok(sharedResource.acquire);
806
+ releaseLeaseIfAborted(run.signal, lease);
807
+ return ok(lease);
808
+ }),
512
809
  ),
513
810
 
514
- getCount: (key) => () =>
811
+ acquireCurrent: (key) => () =>
515
812
  sharedResourceByKeyRun(
516
813
  mutexByKey.withLock(key, async (run) => {
517
814
  const sharedResource = sharedResourcesByKey.get(key);
518
- if (!sharedResource) return ok(zeroNonNegativeInt);
519
- return run(sharedResource.getCount);
815
+ if (!sharedResource) return ok(undefined);
816
+
817
+ const lease = await run.ok(sharedResource.acquireCurrent);
818
+ releaseLeaseIfAborted(run.signal, lease);
819
+ if (!lease) removeKey(key, sharedResource);
820
+ return ok(lease);
520
821
  }),
521
822
  ),
522
823
 
523
- get: (key) => sharedResourcesByKey.get(key)?.get(),
524
-
525
- snapshot: () => {
526
- const resourcesByKey = new Map<K, BorrowedResource<T>>();
527
- const mutexSnapshotsByKey = new Map<K, SemaphoreSnapshot | null>();
824
+ use: (key, callback) => async (run) => {
825
+ using lease = await run.ok(sharedResourceByKey.acquire(key));
826
+ return await run(callback(lease.resource, lease.created));
827
+ },
528
828
 
529
- for (const [key, sharedResource] of sharedResourcesByKey.entries()) {
530
- const resource = sharedResource.get();
531
- if (!resource) continue;
829
+ forEachCurrent: (callback) => async (run) => {
830
+ using leaseDisposer = new DisposableStack();
831
+ const leasesByKey: Array<readonly [Lease<T>, K]> = [];
532
832
 
533
- resourcesByKey.set(key, resource);
534
- mutexSnapshotsByKey.set(key, mutexByKey.snapshot(key));
833
+ for (const [key] of [...sharedResourcesByKey]) {
834
+ const lease = await run.ok(sharedResourceByKey.acquireCurrent(key));
835
+ if (lease) leasesByKey.push([leaseDisposer.use(lease), key]);
836
+ run.signal.throwIfAborted();
535
837
  }
536
838
 
537
- return {
538
- resourcesByKey,
539
- mutexByKey: mutexSnapshotsByKey,
540
- };
839
+ for (const [lease, key] of leasesByKey) callback(lease.resource, key);
840
+
841
+ return ok();
541
842
  },
542
843
 
543
- [Symbol.asyncDispose]: () => disposables.disposeAsync(),
544
- });
545
- });
844
+ snapshot: () => {
845
+ const resourcesByKey = new Map<K, SharedResourceSnapshot>();
846
+ for (const [key, sharedResource] of sharedResourcesByKey)
847
+ resourcesByKey.set(key, sharedResource.snapshot());
848
+ return { resourcesByKey };
849
+ },
850
+
851
+ [Symbol.asyncDispose]: () => disposer.disposeAsync(),
852
+ };
853
+
854
+ leakDetector.track(
855
+ sharedResourceByKey,
856
+ {
857
+ name: "SharedResourceByKey",
858
+ isLeaked: () => sharedResourceByKeyRun.getState().type === "Running",
859
+ },
860
+ sharedResourceByKeyHandle,
861
+ );
862
+
863
+ return ok(sharedResourceByKey);
864
+ };
546
865
  }
547
866
 
548
867
  /**
549
- * Shared {@link Resource}s keyed by logical identity and retained by claims.
868
+ * Shared keyed {@link Resource}s retained through claims.
869
+ *
870
+ * A claim identifies an application-level owner such as an account, tenant, or
871
+ * open document. Resource keys identify the shared resources that owner needs,
872
+ * such as relay URLs, database names, or worker IDs.
873
+ *
874
+ * Use this abstraction when each owner needs a set of keyed resources and those
875
+ * sets can overlap. Calling {@link SharedResourceByKeyWithClaims.claim |
876
+ * claim}
877
+ * retains every resource in one owner's set. Overlapping owners share one
878
+ * resource instance per key, and the resource remains alive until every
879
+ * {@link ClaimLease} retaining that key is released.
880
+ *
881
+ * Relation queries reflect active claims, not physical resource liveness. With
882
+ * `idleDisposeAfter`, a resource can outlive its last claim.
883
+ *
884
+ * Use {@link SharedResourceByKey} instead when callers only need independent
885
+ * leases by key and the application does not need to associate those leases
886
+ * with logical owners.
887
+ *
888
+ * Two accounts can share a relay connection while one also uses a local-network
889
+ * transport:
890
+ *
891
+ * ```ts
892
+ * import {
893
+ * createRun,
894
+ * createSharedResourceByKeyWithClaims,
895
+ * ok,
896
+ * type Brand,
897
+ * type Task,
898
+ * } from "@evolu/common";
899
+ *
900
+ * type AccountId = string & Brand<"AccountId">;
901
+ * type TransportUrl = string & Brand<"TransportUrl">;
902
+ *
903
+ * interface Connection extends Disposable {
904
+ * readonly url: TransportUrl;
905
+ * }
550
906
  *
551
- * This combines {@link SharedResourceByKey} with claim tracking. Resources are
552
- * kept alive while at least one claim retains their key.
907
+ * const createConnection =
908
+ * (url: TransportUrl): Task<Connection> =>
909
+ * () =>
910
+ * ok({
911
+ * url,
912
+ * [Symbol.dispose]: () => {},
913
+ * });
914
+ * // These literals stand in for values validated at application boundaries.
915
+ * const accountA = "account-a" as AccountId;
916
+ * const accountB = "account-b" as AccountId;
917
+ * const relay = "wss://relay.example.com" as TransportUrl;
918
+ * const localNetwork = "ws://local-network" as TransportUrl;
553
919
  *
554
- * By default, {@link createSharedResourceByKeyWithClaims} uses reference
555
- * identity for both resource keys and claims, matching native `Map` and `Set`.
556
- * Callers may instead provide {@link Lookup lookup} functions so logical
557
- * equality is based on derived stable keys.
920
+ * await using run = createRun();
921
+ * await using transports = await run.ok(
922
+ * createSharedResourceByKeyWithClaims<
923
+ * TransportUrl,
924
+ * AccountId,
925
+ * Connection
926
+ * >(createConnection),
927
+ * );
928
+ *
929
+ * {
930
+ * // Account A retains both transports.
931
+ * using accountATransports = await run.ok(
932
+ * transports.claim(accountA, [relay, localNetwork]),
933
+ * );
934
+ *
935
+ * {
936
+ * // Account B reuses the relay already retained by account A.
937
+ * using accountBRelay = await run.ok(
938
+ * transports.claim(accountB, [relay]),
939
+ * );
940
+ * expect(transports.getClaimsForResource(relay)).toEqual(
941
+ * new Set([accountA, accountB]),
942
+ * );
943
+ * expect(transports.getResourceKeysForClaim(accountA)).toEqual(
944
+ * new Set([relay, localNetwork]),
945
+ * );
946
+ * }
947
+ *
948
+ * // Releasing account B leaves account A's relay retain intact.
949
+ * expect(transports.getClaimsForResource(relay)).toEqual(
950
+ * new Set([accountA]),
951
+ * );
952
+ * }
953
+ *
954
+ * // Releasing the final claim removes the relation for both transports.
955
+ * expect(transports.getClaimsForResource(relay)).toEqual(new Set());
956
+ * expect(transports.getClaimsForResource(localNetwork)).toEqual(new Set());
957
+ * ```
558
958
  */
559
959
  export interface SharedResourceByKeyWithClaims<
560
960
  K,
561
961
  C,
562
962
  T extends Resource,
563
- D = unknown,
564
963
  > extends AsyncDisposable {
565
- /** Retains each resource key for `claim`. */
566
- readonly addClaim: (
964
+ /**
965
+ * Retains every resource key for `claim`, creating absent resources lazily.
966
+ *
967
+ * Keys are snapshotted when this Task starts and acquired sequentially in
968
+ * input order.
969
+ *
970
+ * `resourceKeys` must not contain logical duplicates according to
971
+ * {@link SharedResourceByKeyWithClaimsOptions.resourceLookup}. A duplicate is
972
+ * a programmer defect that panics the owning Run.
973
+ *
974
+ * The returned {@link ClaimLease} releases all retains added by this call.
975
+ * Resource creation must succeed, so this Task has no recoverable error.
976
+ */
977
+ readonly claim: (
567
978
  claim: C,
568
- resourceKeys: ReadonlyArray<K>,
569
- ) => Task<void, never, D>;
979
+ resourceKeys: NonEmptyReadonlyArray<K>,
980
+ ) => Task<ClaimLease>;
570
981
 
571
- /** Releases each previously retained resource key for `claim`. */
572
- readonly removeClaim: (
982
+ /**
983
+ * Retains every resource key for `claim`, runs a Task with those resources,
984
+ * and releases the resulting {@link ClaimLease} after the Task settles.
985
+ *
986
+ * The callback receives only the resources retained by this call, in input
987
+ * order. Other active ClaimLeases for the same logical claim are excluded.
988
+ * While this registry remains running, every borrowed resource remains valid
989
+ * until the callback Task settles. Disposing this registry is forceful: it
990
+ * drains the ClaimLease and may dispose resources before or while the
991
+ * callback Task runs.
992
+ */
993
+ readonly use: <R, E, D>(
573
994
  claim: C,
574
- resourceKeys: ReadonlyArray<K>,
575
- ) => Task<void, never, D>;
995
+ resourceKeys: NonEmptyReadonlyArray<K>,
996
+ callback: (
997
+ resources: NonEmptyReadonlyArray<readonly [BorrowedResource<T>, K]>,
998
+ ) => Task<R, E, D>,
999
+ ) => Task<R, E, D>;
576
1000
 
577
- /** Returns the current resource for `key`, or `undefined` if absent. */
578
- readonly getResource: (key: K) => BorrowedResource<T> | undefined;
579
-
580
- /** Returns the current unique claims retaining `key`. */
581
- readonly getClaimsForResource: (key: K) => ReadonlySet<C>;
1001
+ /**
1002
+ * Returns the current unique claims retaining `resourceKey`.
1003
+ *
1004
+ * Returns an empty snapshot when no claim retains the key, including after
1005
+ * this registry is disposed.
1006
+ */
1007
+ readonly getClaimsForResource: (resourceKey: K) => ReadonlySet<C>;
582
1008
 
583
- /** Returns the current unique resource keys retained by `claim`. */
1009
+ /**
1010
+ * Returns the current unique resource keys retained by `claim`.
1011
+ *
1012
+ * Returns an empty snapshot when the claim retains no keys, including after
1013
+ * this registry is disposed.
1014
+ */
584
1015
  readonly getResourceKeysForClaim: (claim: C) => ReadonlySet<K>;
585
1016
 
586
- /** Returns the current unique resources retained by `claim`. */
587
- readonly getResourcesForClaim: (claim: C) => ReadonlySet<BorrowedResource<T>>;
1017
+ /**
1018
+ * Calls `callback` for each current resource retained by `claim`.
1019
+ *
1020
+ * The keys and resources are snapshotted before the first callback runs.
1021
+ * Resources remain valid until this synchronous iteration returns, even if a
1022
+ * callback releases the final retaining ClaimLease. Callbacks must not retain
1023
+ * or asynchronously use borrowed resources afterward. Does nothing when the
1024
+ * claim retains no resources, including after this registry is disposed.
1025
+ */
1026
+ readonly forEachResourceForClaim: (
1027
+ claim: C,
1028
+ callback: (resource: BorrowedResource<T>, resourceKey: K) => void,
1029
+ ) => void;
1030
+
1031
+ /**
1032
+ * Returns current claim-retain and keyed-resource states.
1033
+ *
1034
+ * While a claim is acquiring keys, `resourcesByKey` can include inner leases
1035
+ * not yet reflected by `claimLeaseCount` or
1036
+ * `retainCountsByResourceKeyByClaim`.
1037
+ */
1038
+ readonly snapshot: () => SharedResourceByKeyWithClaimsSnapshot<K, C>;
1039
+ }
1040
+
1041
+ /** Snapshot returned by {@link SharedResourceByKeyWithClaims.snapshot}. */
1042
+ export interface SharedResourceByKeyWithClaimsSnapshot<K, C> {
1043
+ /** Number of currently held ClaimLeases. */
1044
+ readonly claimLeaseCount: NonNegativeInt;
1045
+
1046
+ /** Current retain count for every active logical claim-resource pair. */
1047
+ readonly retainCountsByResourceKeyByClaim: ReadonlyMap<
1048
+ C,
1049
+ ReadonlyMap<K, number>
1050
+ >;
1051
+
1052
+ /** Current registered {@link SharedResourceSnapshot} for each key. */
1053
+ readonly resourcesByKey: ReadonlyMap<K, SharedResourceSnapshot>;
1054
+ }
1055
+
1056
+ /**
1057
+ * An owned lease on the resource-retains added by one claim operation.
1058
+ *
1059
+ * A ClaimLease is a grouped ownership token. For example, a claim for one
1060
+ * account can retain its relay, local-network, and Bluetooth transports. The
1061
+ * ClaimLease releases that whole group together; it does not expose or transfer
1062
+ * ownership of the resources themselves.
1063
+ *
1064
+ * Releasing the lease removes every retain added by that claim call. Release is
1065
+ * idempotent and synchronous; resource disposal remains owned by the
1066
+ * {@link SharedResourceByKeyWithClaims} that created the lease.
1067
+ */
1068
+ export interface ClaimLease extends Disposable {
1069
+ /**
1070
+ * Releases every resource retain owned by this claim lease.
1071
+ *
1072
+ * Returns whether this call released a still-held lease. Returns `false` if
1073
+ * the lease was already released or drained by disposal of its owning
1074
+ * {@link SharedResourceByKeyWithClaims} or the Run tree that owns it.
1075
+ */
1076
+ readonly release: () => boolean;
588
1077
  }
589
1078
 
590
1079
  /** Options for {@link createSharedResourceByKeyWithClaims}. */
@@ -595,20 +1084,36 @@ export interface SharedResourceByKeyWithClaimsOptions<
595
1084
  LK = K,
596
1085
  LC = C,
597
1086
  > extends Pick<SharedResourceOptions, "idleDisposeAfter"> {
598
- /** Derives logical identity for resource keys. Defaults to {@link identity}. */
1087
+ /** Derives the identity used to compare resource keys. */
599
1088
  readonly resourceLookup?: Lookup<K, LK>;
600
1089
 
601
- /** Derives logical identity for claims. Defaults to {@link identity}. */
1090
+ /** Derives the identity used to compare claims. */
602
1091
  readonly claimLookup?: Lookup<C, LC>;
603
1092
 
604
- /** Called when a key transitions from zero claims to one claim. */
1093
+ /**
1094
+ * Called when a claim-resource pair transitions from zero retains to one.
1095
+ *
1096
+ * This is a pair-retain transition, not a resource-generation transition: a
1097
+ * resource can already be retained by other claims or be idling after an
1098
+ * earlier release. Arguments use the first stored representatives for their
1099
+ * logical claim and key.
1100
+ */
605
1101
  readonly onFirstClaimAdded?: (
606
1102
  claim: C,
607
1103
  resource: BorrowedResource<T>,
608
1104
  resourceKey: K,
609
1105
  ) => void;
610
1106
 
611
- /** Called when a key transitions from one claim to zero claims. */
1107
+ /**
1108
+ * Called when a claim-resource pair transitions from one retain to zero.
1109
+ *
1110
+ * Runs before the ClaimLease's inner resource lease is released. Other claims
1111
+ * can still retain the same resource. Arguments use the first stored
1112
+ * representatives for their logical claim and key. During normal release, the
1113
+ * current pair and earlier keys from the same ClaimLease are already absent
1114
+ * from relation reads; later keys remain. During compensation for a failed
1115
+ * first-claim callback, the failed claim remains fully visible.
1116
+ */
612
1117
  readonly onLastClaimRemoved?: (
613
1118
  claim: C,
614
1119
  resource: BorrowedResource<T>,
@@ -619,253 +1124,356 @@ export interface SharedResourceByKeyWithClaimsOptions<
619
1124
  /**
620
1125
  * Creates {@link SharedResourceByKeyWithClaims}.
621
1126
  *
622
- * Claim-resource pairs are reference-counted by logical identity. The
623
- * underlying resource for a key is acquired on the first active claim and
624
- * released when the last active claim for that key is removed.
1127
+ * The `create` callback is called with a resource key when that resource must
1128
+ * be created. The returned Task must not fail. Its dependencies are captured
1129
+ * when the registry is created; later claim calls do not require or replace
1130
+ * them.
1131
+ *
1132
+ * Transition callbacks represent semantic claim-resource pair changes and are
1133
+ * not called when this registry or its Run tree drains claims during disposal.
1134
+ * They must not throw. If a first-claim callback defects after earlier
1135
+ * callbacks in the same claim completed, matching last-claim callbacks
1136
+ * compensate those completed transitions in reverse order before cleanup.
1137
+ * Relation reads during compensation still include the failed claim. Callback
1138
+ * defects panic the owner Run after claim metadata and resource ownership are
1139
+ * cleaned up.
1140
+ *
1141
+ * Transition callbacks may synchronously read relation state. They must not
1142
+ * call `claim`, release a {@link ClaimLease} owned by this registry, or dispose
1143
+ * this registry.
1144
+ *
1145
+ * Transition callbacks do not report actual resource disposal. Disposal can
1146
+ * happen later when `idleDisposeAfter` is configured.
625
1147
  */
626
- export function createSharedResourceByKeyWithClaims<
627
- T extends Resource,
628
- K = unknown,
629
- C = unknown,
630
- D = unknown,
631
- >(
632
- create: (key: K) => Task<T, never, D>,
633
- ): Task<SharedResourceByKeyWithClaims<K, C, T, D>, never, D>;
634
- export function createSharedResourceByKeyWithClaims<
635
- T extends Resource,
636
- K,
637
- C,
638
- D,
639
- LK = K,
640
- LC = C,
641
- >(
642
- create: (key: K) => Task<T, never, D>,
643
- options: SharedResourceByKeyWithClaimsOptions<K, C, T, LK, LC>,
644
- ): Task<SharedResourceByKeyWithClaims<K, C, T, D>, never, D>;
645
- export function createSharedResourceByKeyWithClaims<
646
- T extends Resource,
647
- K = unknown,
648
- C = unknown,
649
- D = unknown,
650
- LK = K,
651
- LC = C,
652
- >(
653
- create: (key: K) => Task<T, never, D>,
654
- {
655
- idleDisposeAfter,
656
- resourceLookup = identity as Lookup<K, LK>,
657
- claimLookup = identity as Lookup<C, LC>,
658
- onFirstClaimAdded,
659
- onLastClaimRemoved,
660
- }: SharedResourceByKeyWithClaimsOptions<K, C, T, LK, LC> = {},
661
- ): Task<SharedResourceByKeyWithClaims<K, C, T, D>, never, D> {
662
- return unabortable<SharedResourceByKeyWithClaims<K, C, T, D>, never, D>(
663
- async (run) => {
664
- const sharedResourceClaimsRun = run.create();
665
- await using disposer = new AsyncDisposableStack();
666
-
667
- const keyByClaim = createRelation<C, K, LC, LK>({
668
- lookupA: claimLookup,
669
- lookupB: resourceLookup,
670
- });
671
- const pairRefCountsByClaim = disposer.adopt(
672
- createLookupMap<C, RefCountByKey<K>, LC>({ lookup: claimLookup }),
673
- (pairRefCountsByClaim) => {
674
- using disposer = new DisposableStack();
675
- disposer.defer(() => {
676
- pairRefCountsByClaim.clear();
677
- });
1148
+ export const createSharedResourceByKeyWithClaims =
1149
+ <
1150
+ K = unknown,
1151
+ C = unknown,
1152
+ T extends Resource = Resource,
1153
+ D = unknown,
1154
+ LK = K,
1155
+ LC = C,
1156
+ >(
1157
+ create: (key: K) => Task<T, never, D>,
1158
+ {
1159
+ idleDisposeAfter,
1160
+ resourceLookup = identity as Lookup<K, LK>,
1161
+ claimLookup = identity as Lookup<C, LC>,
1162
+ onFirstClaimAdded,
1163
+ onLastClaimRemoved,
1164
+ }: SharedResourceByKeyWithClaimsOptions<K, C, T, LK, LC> = {},
1165
+ ): Task<SharedResourceByKeyWithClaims<K, C, T>, never, D> =>
1166
+ async (run) => {
1167
+ type ClaimedResource = readonly [BorrowedResource<T>, K];
1168
+
1169
+ interface HeldClaimLease {
1170
+ readonly claim: C;
1171
+ readonly resourceKeys: NonEmptyReadonlyArray<K>;
1172
+ readonly resourceLeases: DisposableStack;
1173
+ }
1174
+
1175
+ const sharedResourceByKeyWithClaimsRun = run.create();
1176
+ const { leakDetector } = run.deps;
1177
+
1178
+ const claimResourceRelation = createRefCountedRelation<C, K, LC, LK>({
1179
+ lookupA: claimLookup,
1180
+ lookupB: resourceLookup,
1181
+ });
1182
+ // This map and the relation's resource-key index use the same lookup and
1183
+ // are inserted and removed together, so they keep the same canonical keys.
1184
+ const resourcesByKey = createLookupMap<K, BorrowedResource<T>, LK>({
1185
+ lookup: resourceLookup,
1186
+ });
1187
+ const heldClaimLeases = new Set<HeldClaimLease>();
1188
+ const sharedResourceByKeyWithClaimsHandle = {};
1189
+
1190
+ const notifyLastClaimRemoved = (
1191
+ claim: C,
1192
+ resource: BorrowedResource<T>,
1193
+ resourceKey: K,
1194
+ ): void => {
1195
+ try {
1196
+ onLastClaimRemoved?.(claim, resource, resourceKey);
1197
+ } catch (defect) {
1198
+ sharedResourceByKeyWithClaimsRun.panic(defect);
1199
+ }
1200
+ };
678
1201
 
679
- for (const pairRefCountByKey of pairRefCountsByClaim.values()) {
680
- disposer.use(pairRefCountByKey);
681
- }
682
- },
683
- );
1202
+ await using disposer = new AsyncDisposableStack();
1203
+ // Explicit disposal stops claim operations and runs their finalizers before
1204
+ // disposing the keyed registry. The nested stack is empty if keyed-registry
1205
+ // creation fails. Both internal Runs attach to the root, whose disposal
1206
+ // awaits them independently of this explicit-disposal stack.
1207
+ const sharedResourceByKeyDisposer = disposer.use(
1208
+ new AsyncDisposableStack(),
1209
+ );
1210
+ disposer.use(sharedResourceByKeyWithClaimsRun);
1211
+
1212
+ const releaseClaimLease = (
1213
+ heldClaimLease: HeldClaimLease,
1214
+ notifyTransition = true,
1215
+ ): boolean => {
1216
+ if (!heldClaimLeases.delete(heldClaimLease)) return false;
1217
+ leakDetector.untrack(heldClaimLease);
1218
+
1219
+ using _resourceLeases = heldClaimLease.resourceLeases;
1220
+
1221
+ for (const resourceKey of heldClaimLease.resourceKeys) {
1222
+ const decremented = claimResourceRelation.decrement(
1223
+ heldClaimLease.claim,
1224
+ resourceKey,
1225
+ );
1226
+ if (decremented.count > 0) continue;
684
1227
 
685
- const mutexByKey = disposer.use(
686
- createMutexByKey<K, LK>({ lookup: resourceLookup }),
687
- );
1228
+ const resource = resourcesByKey.get(decremented.b);
1229
+ assertNonNullable(
1230
+ resource,
1231
+ "Resource must exist while its claim-resource pair is retained.",
1232
+ );
1233
+ if (notifyTransition) {
1234
+ notifyLastClaimRemoved(decremented.a, resource, decremented.b);
1235
+ }
1236
+
1237
+ if (!claimResourceRelation.hasB(decremented.b)) {
1238
+ // Delete before _resourceLeases disposes at function exit. While this
1239
+ // entry exists, its leases pin one resource generation.
1240
+ resourcesByKey.delete(decremented.b);
1241
+ }
1242
+ }
688
1243
 
1244
+ return true;
1245
+ };
1246
+
1247
+ const drainClaimLeases = (): void => {
1248
+ using disposer = new DisposableStack();
689
1249
  disposer.defer(() => {
690
- keyByClaim.clear();
1250
+ claimResourceRelation.clear();
1251
+ resourcesByKey.clear();
691
1252
  });
1253
+ for (const heldClaimLease of heldClaimLeases) {
1254
+ disposer.defer(() => {
1255
+ releaseClaimLease(heldClaimLease, false);
1256
+ });
1257
+ }
1258
+ };
1259
+
1260
+ sharedResourceByKeyWithClaimsRun.defer(() => {
1261
+ leakDetector.untrack(sharedResourceByKeyWithClaimsHandle);
1262
+ });
1263
+ sharedResourceByKeyWithClaimsRun.defer(drainClaimLeases);
692
1264
 
693
- const sharedResourcesByKeyResult = await sharedResourceClaimsRun(
1265
+ const sharedResourceByKey = sharedResourceByKeyDisposer.use(
1266
+ await sharedResourceByKeyWithClaimsRun.ok(
694
1267
  createSharedResourceByKey(create, {
695
1268
  idleDisposeAfter,
696
1269
  lookup: resourceLookup,
697
1270
  }),
698
- );
699
- assertNotAborted(sharedResourcesByKeyResult);
700
- const sharedResourcesByKey = disposer.use(
701
- sharedResourcesByKeyResult.value,
702
- );
703
-
704
- // Register as the last so disposal aborts further calls first.
705
- disposer.use(sharedResourceClaimsRun);
706
-
707
- /** Asserts that one call does not contain the same logical key twice. */
708
- const assertNoDuplicateResourceKeys = (
709
- resourceKeys: ReadonlyArray<K>,
710
- ) => {
711
- assert(
712
- createLookupSet<K, LK>({
713
- lookup: resourceLookup,
714
- values: resourceKeys,
715
- }).size === resourceKeys.length,
716
- "resourceKeys must not contain lookup duplicates.",
717
- );
718
- };
719
-
720
- const disposables = disposer.move();
721
-
722
- return ok({
723
- addClaim: (claim, resourceKeys) =>
724
- unabortable<void, never, D>(() =>
725
- sharedResourceClaimsRun(async (run) => {
726
- assertNoDuplicateResourceKeys(resourceKeys);
727
-
728
- for (const resourceKey of resourceKeys) {
729
- const added = await run(
730
- mutexByKey.withLock(resourceKey, async (run) => {
731
- let pairRefCountByKey = pairRefCountsByClaim.get(claim);
732
- if (!pairRefCountByKey) {
733
- pairRefCountByKey = createRefCountByKey<K, LK>({
734
- lookup: resourceLookup,
735
- });
736
- pairRefCountsByClaim.set(claim, pairRefCountByKey);
737
- }
738
-
739
- if (pairRefCountByKey.has(resourceKey)) {
740
- pairRefCountByKey.increment(resourceKey);
741
- return ok();
742
- }
743
-
744
- const hasClaimsForKey = keyByClaim.hasB(resourceKey);
745
- let firstResource: BorrowedResource<T> | undefined;
746
- if (!hasClaimsForKey) {
747
- const resourceResult = await run(
748
- sharedResourcesByKey.acquire(resourceKey),
749
- );
750
- assertNotAborted(resourceResult);
751
- firstResource = resourceResult.value;
752
- }
753
-
754
- assert(
755
- keyByClaim.add(claim, resourceKey),
756
- "Claim-resource relation must be absent before first retain.",
757
- );
758
-
759
- pairRefCountByKey.increment(resourceKey);
760
-
761
- if (firstResource) {
762
- onFirstClaimAdded?.(claim, firstResource, resourceKey);
763
- }
764
-
765
- return ok();
766
- }),
767
- );
768
- assertNotAborted(added);
769
- }
770
-
771
- return ok();
772
- }),
773
- ),
774
-
775
- removeClaim: (claim, resourceKeys) =>
776
- unabortable<void, never, D>(() =>
777
- sharedResourceClaimsRun(async (run) => {
778
- assertNoDuplicateResourceKeys(resourceKeys);
779
-
780
- for (const resourceKey of resourceKeys) {
781
- const removed = await run(
782
- mutexByKey.withLock(resourceKey, async (run) => {
783
- const pairRefCountByKey = pairRefCountsByClaim.get(claim);
784
-
785
- assert(
786
- pairRefCountByKey,
787
- "Claim-resource pair must not be removed more times than added.",
788
- );
789
-
790
- if (pairRefCountByKey.decrement(resourceKey) > 0) {
791
- return ok();
792
- }
793
-
794
- if (pairRefCountByKey.keys().size === 0) {
795
- pairRefCountsByClaim.delete(claim);
796
- pairRefCountByKey[Symbol.dispose]();
797
- }
798
-
799
- assert(
800
- keyByClaim.remove(claim, resourceKey),
801
- "Claim-resource relation must exist while its ref count is positive.",
802
- );
803
-
804
- if (keyByClaim.hasB(resourceKey)) {
805
- return ok();
806
- }
1271
+ ),
1272
+ );
807
1273
 
808
- const resource = sharedResourcesByKey.get(resourceKey);
809
- assert(
810
- resource,
811
- "Resource must exist when the last claim is removed.",
812
- );
1274
+ run.signal.throwIfAborted();
813
1275
 
814
- onLastClaimRemoved?.(claim, resource, resourceKey);
1276
+ const disposables = disposer.move();
1277
+ const acquireClaim =
1278
+ <R>(
1279
+ claim: C,
1280
+ resourceKeys: NonEmptyReadonlyArray<K>,
1281
+ project: (
1282
+ claimLease: ClaimLease,
1283
+ resources: NonEmptyReadonlyArray<ClaimedResource>,
1284
+ ) => R,
1285
+ ): Task<R> =>
1286
+ () => {
1287
+ const resourceKeysSnapshot: NonEmptyReadonlyArray<K> = [
1288
+ resourceKeys[0],
1289
+ ...resourceKeys.slice(1),
1290
+ ];
1291
+ return sharedResourceByKeyWithClaimsRun(async (run) => {
1292
+ assert(
1293
+ createLookupSet<K, LK>({
1294
+ lookup: resourceLookup,
1295
+ values: resourceKeysSnapshot,
1296
+ }).size === resourceKeysSnapshot.length,
1297
+ "resourceKeys must not contain lookup duplicates.",
1298
+ );
1299
+
1300
+ using resourceLeases = new DisposableStack();
1301
+ const [firstResourceKey, ...remainingResourceKeys] =
1302
+ resourceKeysSnapshot;
1303
+ // acquire releases a transferred lease before surfacing owner abort,
1304
+ // so a throwing await cannot leave ownership uncollected.
1305
+ const firstResourceLease = resourceLeases.use(
1306
+ await run.ok(sharedResourceByKey.acquire(firstResourceKey)),
1307
+ );
1308
+ const resources: NonEmptyArray<ClaimedResource> = [
1309
+ [firstResourceLease.resource, firstResourceKey],
1310
+ ];
1311
+ const firstClaimTransitions: Array<
1312
+ readonly [C, BorrowedResource<T>, K]
1313
+ > = [];
1314
+ for (const resourceKey of remainingResourceKeys) {
1315
+ const resourceLease = resourceLeases.use(
1316
+ await run.ok(sharedResourceByKey.acquire(resourceKey)),
1317
+ );
1318
+ resources.push([resourceLease.resource, resourceKey]);
1319
+ }
1320
+ run.signal.throwIfAborted();
815
1321
 
816
- const releaseResult = await run(
817
- sharedResourcesByKey.release(resourceKey),
818
- );
819
- assertNotAborted(releaseResult);
1322
+ for (const [resource, resourceKey] of resources) {
1323
+ const incremented = claimResourceRelation.increment(
1324
+ claim,
1325
+ resourceKey,
1326
+ );
1327
+ resourcesByKey.getOrInsert(incremented.b, resource);
1328
+ if (incremented.count === 1) {
1329
+ firstClaimTransitions.push([
1330
+ incremented.a,
1331
+ resource,
1332
+ incremented.b,
1333
+ ]);
1334
+ }
1335
+ }
820
1336
 
821
- return ok();
822
- }),
823
- );
824
- assertNotAborted(removed);
825
- }
1337
+ const heldClaimLease: HeldClaimLease = {
1338
+ claim,
1339
+ resourceKeys: resourceKeysSnapshot,
1340
+ resourceLeases: resourceLeases.move(),
1341
+ };
1342
+ heldClaimLeases.add(heldClaimLease);
1343
+
1344
+ const release = (): boolean => releaseClaimLease(heldClaimLease);
1345
+ const claimLease: ClaimLease = {
1346
+ release,
1347
+ [Symbol.dispose]: release,
1348
+ };
1349
+
1350
+ leakDetector.track(
1351
+ claimLease,
1352
+ {
1353
+ name: "ClaimLease",
1354
+ isLeaked: () => heldClaimLeases.has(heldClaimLease),
1355
+ },
1356
+ heldClaimLease,
1357
+ );
1358
+
1359
+ try {
1360
+ let succeeded = false;
1361
+ using compensations = new DisposableStack();
1362
+ for (const [
1363
+ claim,
1364
+ resource,
1365
+ resourceKey,
1366
+ ] of firstClaimTransitions) {
1367
+ onFirstClaimAdded?.(claim, resource, resourceKey);
1368
+ compensations.defer(() => {
1369
+ if (!succeeded) {
1370
+ notifyLastClaimRemoved(claim, resource, resourceKey);
1371
+ }
1372
+ });
1373
+ }
1374
+ succeeded = true;
1375
+ } catch (defect) {
1376
+ releaseClaimLease(heldClaimLease, false);
1377
+ throw defect;
1378
+ }
826
1379
 
827
- return ok();
828
- }),
829
- ),
1380
+ return ok(project(claimLease, resources));
1381
+ });
1382
+ };
830
1383
 
831
- getResource: (key) => sharedResourcesByKey.get(key),
1384
+ const sharedResourceByKeyWithClaims: SharedResourceByKeyWithClaims<
1385
+ K,
1386
+ C,
1387
+ T
1388
+ > = {
1389
+ claim: (claim, resourceKeys) =>
1390
+ acquireClaim(claim, resourceKeys, (claimLease) => claimLease),
1391
+
1392
+ use: (claim, resourceKeys, callback) => async (run) => {
1393
+ const acquired = await run.ok(
1394
+ acquireClaim(claim, resourceKeys, (claimLease, resources) => ({
1395
+ claimLease,
1396
+ resources,
1397
+ })),
1398
+ );
1399
+ using _claimLease = acquired.claimLease;
1400
+ return await run(callback(acquired.resources));
1401
+ },
832
1402
 
833
- getClaimsForResource: (key) => new Set(keyByClaim.iterateA(key)),
1403
+ getClaimsForResource: (resourceKey) =>
1404
+ new Set(claimResourceRelation.getAs(resourceKey)),
834
1405
 
835
- getResourceKeysForClaim: (claim) => new Set(keyByClaim.iterateB(claim)),
1406
+ getResourceKeysForClaim: (claim) =>
1407
+ new Set(claimResourceRelation.getBs(claim)),
836
1408
 
837
- getResourcesForClaim: (claim) => {
838
- const resources = new Set<BorrowedResource<T>>();
839
- for (const key of keyByClaim.iterateB(claim)) {
840
- const resource = sharedResourcesByKey.get(key);
841
- assert(
1409
+ forEachResourceForClaim: (claim, callback) => {
1410
+ const resources = claimResourceRelation
1411
+ .getBs(claim)
1412
+ .map((resourceKey) => {
1413
+ const resource = resourcesByKey.get(resourceKey);
1414
+ assertNonNullable(
842
1415
  resource,
843
- "Resource must exist for every retained claim-resource relation.",
1416
+ "Resource must exist for every active claim-resource relation.",
844
1417
  );
845
- resources.add(resource);
846
- }
847
- return resources;
848
- },
1418
+ return [resource, resourceKey] as const;
1419
+ });
1420
+ // Releasing the final ClaimLease from a callback only schedules inner
1421
+ // disposal: the mutex Task resumes after this synchronous frame, so all
1422
+ // snapshotted resources remain live through the loop.
1423
+ for (const [resource, resourceKey] of resources) {
1424
+ callback(resource, resourceKey);
1425
+ }
1426
+ },
849
1427
 
850
- [Symbol.asyncDispose]: () => disposables.disposeAsync(),
851
- });
852
- },
853
- );
854
- }
1428
+ snapshot: () => {
1429
+ const retainCountsByResourceKeyByClaimSnapshot = new Map<
1430
+ C,
1431
+ Map<K, number>
1432
+ >();
1433
+ for (const [
1434
+ claim,
1435
+ resourceKey,
1436
+ count,
1437
+ ] of claimResourceRelation.getEntries()) {
1438
+ retainCountsByResourceKeyByClaimSnapshot
1439
+ .getOrInsertComputed(claim, () => new Map())
1440
+ .set(resourceKey, count);
1441
+ }
855
1442
 
856
- interface OwnedResource<T extends Resource> {
857
- readonly resource: BorrowedResource<T>;
858
- readonly stack: AsyncDisposableStack;
859
- }
1443
+ return {
1444
+ claimLeaseCount: NonNegativeInt.orThrow(heldClaimLeases.size),
1445
+ retainCountsByResourceKeyByClaim:
1446
+ retainCountsByResourceKeyByClaimSnapshot,
1447
+ resourcesByKey: sharedResourceByKey.snapshot().resourcesByKey,
1448
+ };
1449
+ },
860
1450
 
861
- const createOwnedResource = <T extends Resource>(
862
- resource: T,
863
- ): OwnedResource<T> => {
864
- const stack = new AsyncDisposableStack();
865
- stack.use(resource);
866
- return { resource, stack };
867
- };
868
-
869
- // TODO: Make lifecycle callbacks exception-safe. `onDisposed`,
870
- // `onFirstClaimAdded`, and `onLastClaimRemoved` can currently leave resource
871
- // bookkeeping in a partially updated state if they throw.
1451
+ [Symbol.asyncDispose]: () => disposables.disposeAsync(),
1452
+ };
1453
+
1454
+ leakDetector.track(
1455
+ sharedResourceByKeyWithClaims,
1456
+ {
1457
+ name: "SharedResourceByKeyWithClaims",
1458
+ isLeaked: () =>
1459
+ sharedResourceByKeyWithClaimsRun.getState().type === "Running",
1460
+ },
1461
+ sharedResourceByKeyWithClaimsHandle,
1462
+ );
1463
+
1464
+ return ok(sharedResourceByKeyWithClaims);
1465
+ };
1466
+
1467
+ // TODO: Add a lease-based reloadable resource when a concrete use case needs
1468
+ // resource swapping. A ResourceRef-style get/set API is unsafe because set can
1469
+ // dispose a resource still held by a caller of get. Model resource generations
1470
+ // explicitly and distinguish two replacement policies, probably as separate
1471
+ // APIs rather than a boolean option:
1472
+ // - Overlapping: create and validate the replacement, publish it to new leases,
1473
+ // then dispose the old generation after its existing leases drain. This
1474
+ // supports zero-downtime reload and keeps the old generation when creation
1475
+ // fails, but both generations temporarily exist.
1476
+ // - Exclusive: stop or queue new leases, drain and dispose the old generation,
1477
+ // then create and publish the replacement. This guarantees at most one live
1478
+ // resource, but introduces downtime and leaves no valid resource when creation
1479
+ // fails unless the owner retries or recreates the old configuration.