@evolu/common 6.0.1-preview.3 → 6.0.1-preview.30

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 (173) hide show
  1. package/dist/src/Array.d.ts +58 -5
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +53 -5
  4. package/dist/src/Assert.d.ts +6 -16
  5. package/dist/src/Assert.d.ts.map +1 -1
  6. package/dist/src/Assert.js +6 -18
  7. package/dist/src/Brand.d.ts +75 -0
  8. package/dist/src/Brand.d.ts.map +1 -0
  9. package/dist/src/Brand.js +1 -0
  10. package/dist/src/Buffer.d.ts +1 -1
  11. package/dist/src/Buffer.d.ts.map +1 -1
  12. package/dist/src/Buffer.js +8 -7
  13. package/dist/src/Cache.d.ts +44 -0
  14. package/dist/src/Cache.d.ts.map +1 -0
  15. package/dist/src/Cache.js +52 -0
  16. package/dist/src/Callbacks.d.ts +45 -12
  17. package/dist/src/Callbacks.d.ts.map +1 -1
  18. package/dist/src/Callbacks.js +14 -7
  19. package/dist/src/Console.d.ts +31 -6
  20. package/dist/src/Console.d.ts.map +1 -1
  21. package/dist/src/Console.js +72 -9
  22. package/dist/src/Crypto.d.ts +61 -34
  23. package/dist/src/Crypto.d.ts.map +1 -1
  24. package/dist/src/Crypto.js +32 -45
  25. package/dist/src/Evolu/Db.d.ts +158 -65
  26. package/dist/src/Evolu/Db.d.ts.map +1 -1
  27. package/dist/src/Evolu/Db.js +286 -694
  28. package/dist/src/Evolu/Diff.d.ts +3 -3
  29. package/dist/src/Evolu/Diff.d.ts.map +1 -1
  30. package/dist/src/Evolu/Diff.js +7 -5
  31. package/dist/src/Evolu/Evolu.d.ts +208 -133
  32. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  33. package/dist/src/Evolu/Evolu.js +188 -183
  34. package/dist/src/Evolu/Internal.d.ts +0 -2
  35. package/dist/src/Evolu/Internal.d.ts.map +1 -1
  36. package/dist/src/Evolu/Internal.js +0 -2
  37. package/dist/src/Evolu/LocalAuth.d.ts +150 -0
  38. package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
  39. package/dist/src/Evolu/LocalAuth.js +174 -0
  40. package/dist/src/Evolu/Owner.d.ts +264 -120
  41. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  42. package/dist/src/Evolu/Owner.js +130 -104
  43. package/dist/src/Evolu/Platform.d.ts +9 -7
  44. package/dist/src/Evolu/Platform.d.ts.map +1 -1
  45. package/dist/src/Evolu/Protocol.d.ts +277 -232
  46. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  47. package/dist/src/Evolu/Protocol.js +603 -378
  48. package/dist/src/Evolu/Public.d.ts +6 -8
  49. package/dist/src/Evolu/Public.d.ts.map +1 -1
  50. package/dist/src/Evolu/Public.js +2 -3
  51. package/dist/src/Evolu/PublicKysely.js +3 -3
  52. package/dist/src/Evolu/Query.d.ts +2 -1
  53. package/dist/src/Evolu/Query.d.ts.map +1 -1
  54. package/dist/src/Evolu/Relay.d.ts +92 -7
  55. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  56. package/dist/src/Evolu/Relay.js +243 -76
  57. package/dist/src/Evolu/Schema.d.ts +129 -73
  58. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  59. package/dist/src/Evolu/Schema.js +169 -89
  60. package/dist/src/Evolu/Storage.d.ts +212 -26
  61. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  62. package/dist/src/Evolu/Storage.js +137 -79
  63. package/dist/src/Evolu/Sync.d.ts +68 -13
  64. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  65. package/dist/src/Evolu/Sync.js +422 -20
  66. package/dist/src/Evolu/Timestamp.d.ts +85 -27
  67. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  68. package/dist/src/Evolu/Timestamp.js +77 -18
  69. package/dist/src/Identicon.d.ts +35 -0
  70. package/dist/src/Identicon.d.ts.map +1 -0
  71. package/dist/src/Identicon.js +143 -0
  72. package/dist/src/Instances.d.ts +34 -0
  73. package/dist/src/Instances.d.ts.map +1 -0
  74. package/dist/src/Instances.js +44 -0
  75. package/dist/src/ManyToManyMap.d.ts +71 -10
  76. package/dist/src/ManyToManyMap.d.ts.map +1 -1
  77. package/dist/src/ManyToManyMap.js +41 -6
  78. package/dist/src/Number.d.ts +4 -3
  79. package/dist/src/Number.d.ts.map +1 -1
  80. package/dist/src/Number.js +5 -4
  81. package/dist/src/Platform.d.ts +20 -0
  82. package/dist/src/Platform.d.ts.map +1 -0
  83. package/dist/src/Platform.js +22 -0
  84. package/dist/src/Random.d.ts +3 -2
  85. package/dist/src/Random.d.ts.map +1 -1
  86. package/dist/src/Resources.d.ts +118 -0
  87. package/dist/src/Resources.d.ts.map +1 -0
  88. package/dist/src/Resources.js +197 -0
  89. package/dist/src/Result.d.ts +184 -52
  90. package/dist/src/Result.d.ts.map +1 -1
  91. package/dist/src/Result.js +30 -241
  92. package/dist/src/Skiplist.js +2 -1
  93. package/dist/src/Sqlite.d.ts +63 -5
  94. package/dist/src/Sqlite.d.ts.map +1 -1
  95. package/dist/src/Sqlite.js +110 -9
  96. package/dist/src/Task.d.ts +586 -0
  97. package/dist/src/Task.d.ts.map +1 -0
  98. package/dist/src/Task.js +469 -0
  99. package/dist/src/Time.d.ts +66 -1
  100. package/dist/src/Time.d.ts.map +1 -1
  101. package/dist/src/Time.js +99 -5
  102. package/dist/src/Type.d.ts +621 -340
  103. package/dist/src/Type.d.ts.map +1 -1
  104. package/dist/src/Type.js +665 -464
  105. package/dist/src/Types.d.ts +1 -75
  106. package/dist/src/Types.d.ts.map +1 -1
  107. package/dist/src/WebSocket.d.ts +5 -2
  108. package/dist/src/WebSocket.d.ts.map +1 -1
  109. package/dist/src/WebSocket.js +12 -18
  110. package/dist/src/Worker.d.ts +39 -11
  111. package/dist/src/Worker.d.ts.map +1 -1
  112. package/dist/src/Worker.js +22 -4
  113. package/dist/src/index.d.ts +7 -2
  114. package/dist/src/index.d.ts.map +1 -1
  115. package/dist/src/index.js +7 -2
  116. package/package.json +14 -13
  117. package/src/Array.ts +76 -11
  118. package/src/Assert.ts +6 -24
  119. package/src/Brand.ts +75 -0
  120. package/src/Buffer.ts +7 -7
  121. package/src/Cache.ts +85 -0
  122. package/src/Callbacks.ts +62 -22
  123. package/src/Console.ts +91 -11
  124. package/src/Crypto.ts +97 -82
  125. package/src/Evolu/Db.ts +514 -1020
  126. package/src/Evolu/Diff.ts +7 -5
  127. package/src/Evolu/Evolu.ts +464 -355
  128. package/src/Evolu/Internal.ts +0 -2
  129. package/src/Evolu/LocalAuth.ts +463 -0
  130. package/src/Evolu/Owner.ts +369 -228
  131. package/src/Evolu/Platform.ts +9 -9
  132. package/src/Evolu/Protocol.ts +859 -676
  133. package/src/Evolu/Public.ts +7 -14
  134. package/src/Evolu/PublicKysely.ts +3 -3
  135. package/src/Evolu/Query.ts +2 -1
  136. package/src/Evolu/Relay.ts +420 -92
  137. package/src/Evolu/Schema.ts +391 -191
  138. package/src/Evolu/Storage.ts +451 -118
  139. package/src/Evolu/Sync.ts +720 -37
  140. package/src/Evolu/Timestamp.ts +88 -35
  141. package/src/Identicon.ts +197 -0
  142. package/src/Instances.ts +90 -0
  143. package/src/ManyToManyMap.ts +124 -24
  144. package/src/Number.ts +6 -10
  145. package/src/Platform.ts +26 -0
  146. package/src/Random.ts +3 -2
  147. package/src/Resources.ts +367 -0
  148. package/src/Result.ts +191 -54
  149. package/src/Skiplist.ts +1 -1
  150. package/src/Sqlite.ts +122 -17
  151. package/src/Task.ts +901 -0
  152. package/src/Time.ts +180 -5
  153. package/src/Type.ts +1083 -727
  154. package/src/Types.ts +1 -77
  155. package/src/WebSocket.ts +27 -25
  156. package/src/Worker.ts +72 -23
  157. package/src/index.ts +7 -2
  158. package/dist/src/Evolu/Config.d.ts +0 -69
  159. package/dist/src/Evolu/Config.d.ts.map +0 -1
  160. package/dist/src/Evolu/Config.js +0 -9
  161. package/dist/src/Evolu/Kysely.d.ts +0 -6
  162. package/dist/src/Evolu/Kysely.d.ts.map +0 -1
  163. package/dist/src/Evolu/Kysely.js +0 -21
  164. package/dist/src/NanoId.d.ts +0 -27
  165. package/dist/src/NanoId.d.ts.map +0 -1
  166. package/dist/src/NanoId.js +0 -6
  167. package/dist/src/Promise.d.ts +0 -180
  168. package/dist/src/Promise.d.ts.map +0 -1
  169. package/dist/src/Promise.js +0 -176
  170. package/src/Evolu/Config.ts +0 -83
  171. package/src/Evolu/Kysely.ts +0 -38
  172. package/src/NanoId.ts +0 -39
  173. package/src/Promise.ts +0 -295
@@ -1,22 +1,81 @@
1
- /** Represents a many-to-many mapping between keys and values. */
1
+ import { assert } from "./Assert.js";
2
+
3
+ /**
4
+ * Bidirectional many-to-many map between keys and values.
5
+ *
6
+ * Why useful:
7
+ *
8
+ * - Provides O(1) (amortized) forward lookup (key -> values) and reverse lookup
9
+ * (value -> keys) without maintaining two maps manually and risking them
10
+ * diverging.
11
+ * - Natural fit for pub/sub style relations such as ownerId ↔ WebSocket, tag ↔
12
+ * item, user ↔ role, entity ↔ subscription where both directions are
13
+ * frequently queried.
14
+ * - Supports fast membership tests via `hasPair`, `hasKey`, and `hasValue`.
15
+ * - Iteration helpers (`forEach`, iterator) allow treating the structure as an
16
+ * edge list when needed.
17
+ *
18
+ * Complexity:
19
+ *
20
+ * - `add` / `remove` / `has*` / `get*` each perform a constant number of Map/Set
21
+ * operations (O(1) expected).
22
+ * - `deleteKey` and `deleteValue` are O(d) where d is the number of associated
23
+ * values / keys (the degree). This is optimal because every associated pair
24
+ * must be touched once.
25
+ *
26
+ * Object identity:
27
+ *
28
+ * - Keys and values are compared by reference (standard Map / Set semantics).
29
+ * Structural hashing of objects in JavaScript is non-trivial, can be
30
+ * expensive, and collision-prone if done naively. Prefer using stable
31
+ * primitive identifiers (ids, strings) instead of attempting to hash full
32
+ * object structures.
33
+ * - If structural equivalence is truly required, wrap objects in an adapter that
34
+ * supplies a canonical hash/id and stores/retrieves the original objects
35
+ * separately. This is a rare need; avoid unless you have clear requirements.
36
+ */
2
37
  export interface ManyToManyMap<K, V> {
3
- /** Adds a key-value association to the map. */
4
- add: (key: K, value: V) => ManyToManyMap<K, V>;
5
-
6
- /** Removes a specific key-value association. */
38
+ /**
39
+ * Adds a key-value association to the map. Returns true if the pair was newly
40
+ * added, false if it already existed.
41
+ */
42
+ add: (key: K, value: V) => boolean;
43
+
44
+ /**
45
+ * Removes a specific key-value association. Returns true if the pair existed
46
+ * and was removed, false if it was not present.
47
+ */
7
48
  remove: (key: K, value: V) => boolean;
8
49
 
9
- /** Gets all values associated with a key. */
10
- getValues: (key: K) => Set<V> | undefined;
11
-
12
- /** Gets all keys associated with a value. */
13
- getKeys: (value: V) => Set<K> | undefined;
50
+ /**
51
+ * Gets all values associated with a key. Returned set is the internal Set
52
+ * instance typed as ReadonlySet. Do not mutate.
53
+ */
54
+ getValues: (key: K) => ReadonlySet<V> | undefined;
55
+
56
+ /**
57
+ * Gets all keys associated with a value. Returned set is the internal Set
58
+ * instance typed as ReadonlySet. Do not mutate.
59
+ */
60
+ getKeys: (value: V) => ReadonlySet<K> | undefined;
61
+
62
+ /**
63
+ * Iterates over each key-value pair (in insertion order of keys, then values
64
+ * per key).
65
+ */
66
+ forEach: (callback: (key: K, value: V) => void) => void;
67
+
68
+ /**
69
+ * Iterator over all key-value pairs enabling for..of and spread. Yields
70
+ * readonly [key, value] tuples.
71
+ */
72
+ readonly [Symbol.iterator]: () => IterableIterator<readonly [K, V]>;
14
73
 
15
74
  /** Checks if a specific key-value pair exists. */
16
75
  hasPair: (key: K, value: V) => boolean;
17
76
 
18
77
  /** Checks if a key exists in the map. */
19
- hasKey(key: K): boolean;
78
+ hasKey: (key: K) => boolean;
20
79
 
21
80
  /** Checks if a value exists in the map. */
22
81
  hasValue: (value: V) => boolean;
@@ -28,17 +87,26 @@ export interface ManyToManyMap<K, V> {
28
87
  deleteValue: (value: V) => boolean;
29
88
 
30
89
  /** Clears all associations in the map. */
31
- clear(): void;
90
+ clear: () => void;
91
+
92
+ /** Number of distinct keys currently present. */
93
+ keyCount: () => number;
94
+ /** Number of distinct values currently present. */
95
+ valueCount: () => number;
96
+ /** Number of key-value pairs (associations) currently stored. */
97
+ pairCount: () => number;
32
98
  }
33
99
 
34
100
  /** Creates a {@link ManyToManyMap}. */
35
101
  export const createManyToManyMap = <K, V>(): ManyToManyMap<K, V> => {
36
102
  const forwardMap = new Map<K, Set<V>>();
37
103
  const reverseMap = new Map<V, Set<K>>();
104
+ let pairCountInternal = 0;
38
105
 
39
106
  const map: ManyToManyMap<K, V> = {
40
107
  add(key: K, value: V) {
41
108
  let values = forwardMap.get(key);
109
+ if (values?.has(value)) return false;
42
110
  if (!values) {
43
111
  values = new Set<V>();
44
112
  forwardMap.set(key, values);
@@ -51,8 +119,8 @@ export const createManyToManyMap = <K, V>(): ManyToManyMap<K, V> => {
51
119
  reverseMap.set(value, keys);
52
120
  }
53
121
  keys.add(key);
54
-
55
- return map;
122
+ pairCountInternal++;
123
+ return true;
56
124
  },
57
125
 
58
126
  remove(key: K, value: V) {
@@ -65,24 +133,41 @@ export const createManyToManyMap = <K, V>(): ManyToManyMap<K, V> => {
65
133
  }
66
134
 
67
135
  const keys = reverseMap.get(value);
68
- if (keys?.size) {
69
- keys.delete(key);
70
- if (keys.size === 0) {
71
- reverseMap.delete(value);
72
- }
73
- }
136
+ assert(keys, "Key-value mapping inconsistency");
74
137
 
138
+ keys.delete(key);
139
+ if (keys.size === 0) {
140
+ reverseMap.delete(value);
141
+ }
142
+ pairCountInternal--;
75
143
  return true;
76
144
  },
77
145
 
78
- getValues(key: K) {
146
+ getValues(key: K): ReadonlySet<V> | undefined {
79
147
  return forwardMap.get(key);
80
148
  },
81
149
 
82
- getKeys(value: V) {
150
+ getKeys(value: V): ReadonlySet<K> | undefined {
83
151
  return reverseMap.get(value);
84
152
  },
85
153
 
154
+ forEach(callback: (key: K, value: V) => void) {
155
+ for (const [key, values] of forwardMap) {
156
+ for (const value of values) callback(key, value);
157
+ }
158
+ },
159
+
160
+ [Symbol.iterator](): IterableIterator<readonly [K, V]> {
161
+ const iterator = function* () {
162
+ for (const [key, values] of forwardMap) {
163
+ for (const value of values) {
164
+ yield [key, value] as const;
165
+ }
166
+ }
167
+ };
168
+ return iterator();
169
+ },
170
+
86
171
  hasPair(key: K, value: V) {
87
172
  const values = forwardMap.get(key);
88
173
  return values?.has(value) ?? false;
@@ -99,7 +184,7 @@ export const createManyToManyMap = <K, V>(): ManyToManyMap<K, V> => {
99
184
  deleteKey(key: K) {
100
185
  const values = forwardMap.get(key);
101
186
  if (!values) return false;
102
-
187
+ const removed = values.size;
103
188
  for (const value of values) {
104
189
  const keys = reverseMap.get(value);
105
190
  if (keys) {
@@ -110,13 +195,14 @@ export const createManyToManyMap = <K, V>(): ManyToManyMap<K, V> => {
110
195
  }
111
196
  }
112
197
  forwardMap.delete(key);
198
+ pairCountInternal -= removed;
113
199
  return true;
114
200
  },
115
201
 
116
202
  deleteValue(value: V) {
117
203
  const keys = reverseMap.get(value);
118
204
  if (!keys) return false;
119
-
205
+ const removed = keys.size;
120
206
  for (const key of keys) {
121
207
  const values = forwardMap.get(key);
122
208
  if (values) {
@@ -127,12 +213,26 @@ export const createManyToManyMap = <K, V>(): ManyToManyMap<K, V> => {
127
213
  }
128
214
  }
129
215
  reverseMap.delete(value);
216
+ pairCountInternal -= removed;
130
217
  return true;
131
218
  },
132
219
 
133
220
  clear() {
134
221
  forwardMap.clear();
135
222
  reverseMap.clear();
223
+ pairCountInternal = 0;
224
+ },
225
+
226
+ keyCount() {
227
+ return forwardMap.size;
228
+ },
229
+
230
+ valueCount() {
231
+ return reverseMap.size;
232
+ },
233
+
234
+ pairCount() {
235
+ return pairCountInternal;
136
236
  },
137
237
  };
138
238
 
package/src/Number.ts CHANGED
@@ -2,12 +2,8 @@ import { NonEmptyReadonlyArray } from "./Array.js";
2
2
  import { assertNonEmptyReadonlyArray } from "./Assert.js";
3
3
  import { err, ok, Result } from "./Result.js";
4
4
  import { NonNegativeInt, PositiveInt } from "./Type.js";
5
- import {
6
- IntentionalNever,
7
- IsBranded,
8
- Predicate,
9
- WidenLiteral,
10
- } from "./Types.js";
5
+ import { IntentionalNever, Predicate, WidenLiteral } from "./Types.js";
6
+ import { IsBranded } from "./Brand.js";
11
7
 
12
8
  export const increment = (n: number): number => n + 1;
13
9
 
@@ -63,15 +59,15 @@ export const computeBalancedBuckets = (
63
59
  numberOfItems: NonNegativeInt,
64
60
 
65
61
  /** Default: 16 */
66
- numberOfBuckets = 16 as PositiveInt,
62
+ numberOfBuckets = PositiveInt.orThrow(16),
67
63
 
68
64
  /** Default: 2 */
69
- minNumberOfItemsPerBucket = 2 as PositiveInt,
65
+ minNumberOfItemsPerBucket = PositiveInt.orThrow(2),
70
66
  ): Result<NonEmptyReadonlyArray<PositiveInt>, PositiveInt> => {
71
67
  const minRequiredItems = numberOfBuckets * minNumberOfItemsPerBucket;
72
68
 
73
69
  if (numberOfItems < minRequiredItems)
74
- return err(minRequiredItems as PositiveInt);
70
+ return err(PositiveInt.orThrow(minRequiredItems));
75
71
 
76
72
  const indexes: Array<PositiveInt> = [];
77
73
  const itemsPerBucket = Math.floor(numberOfItems / numberOfBuckets);
@@ -82,7 +78,7 @@ export const computeBalancedBuckets = (
82
78
  const hasExtraItem = i < extraItems;
83
79
  const itemsInThisBucket = itemsPerBucket + (hasExtraItem ? 1 : 0);
84
80
  bucketBoundary += itemsInThisBucket;
85
- indexes.push(bucketBoundary as PositiveInt);
81
+ indexes.push(PositiveInt.orThrow(bucketBoundary));
86
82
  }
87
83
 
88
84
  assertNonEmptyReadonlyArray(indexes);
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Platform detection utilities for Evolu.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ /** Detects if the code is running in React Native environment. */
8
+ export const isReactNative =
9
+ typeof navigator !== "undefined" &&
10
+ "product" in navigator &&
11
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
12
+ (navigator as any).product === "ReactNative";
13
+
14
+ /**
15
+ * Detects if Node.js Buffer is available and should be used.
16
+ *
17
+ * React Native apps often polyfill Node.js APIs like Buffer, but we want to use
18
+ * native methods when available for better performance.
19
+ *
20
+ * Returns false in React Native even if Buffer is polyfilled, as we prefer
21
+ * native methods in that environment.
22
+ *
23
+ * @see https://github.com/craftzdog/react-native-quick-base64#installation
24
+ */
25
+ export const hasNodeBuffer =
26
+ !isReactNative && typeof globalThis.Buffer !== "undefined";
package/src/Random.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  import { Random as RandomLib } from "random";
8
8
 
9
9
  /**
10
- * A simple wrapper around Math.random(). Most apps need only this. For more
11
- * complex needs check {@link RandomLibDep}.
10
+ * A simple wrapper around Math.random().
11
+ *
12
+ * For more complex needs check {@link RandomLibDep}.
12
13
  *
13
14
  * ### Example
14
15
  *
@@ -0,0 +1,367 @@
1
+ import { err, ok, Result } from "./Result.js";
2
+ import { PositiveInt } from "./Type.js";
3
+
4
+ /**
5
+ * A generic resource manager that handles reference counting and delayed
6
+ * disposal of shared resources. Useful for managing expensive resources like
7
+ * WebSocket connections that need to be shared among multiple consumers.
8
+ */
9
+ export interface Resources<
10
+ TResource extends Disposable,
11
+ TResourceKey extends string,
12
+ TResourceConfig,
13
+ TConsumer,
14
+ TConsumerId extends string,
15
+ > extends Disposable {
16
+ /**
17
+ * Adds a consumer to resources, creating them if necessary. Increments
18
+ * reference counts for existing consumer-resource pairs.
19
+ */
20
+ readonly addConsumer: (
21
+ consumer: TConsumer,
22
+ resourceConfigs: ReadonlyArray<TResourceConfig>,
23
+ ) => void;
24
+
25
+ /**
26
+ * Removes a consumer from resources. Decrements reference counts and
27
+ * schedules disposal when no consumers remain.
28
+ *
29
+ * Returns an error if the resource doesn't exist or if the consumer wasn't
30
+ * added to the resource.
31
+ */
32
+ readonly removeConsumer: (
33
+ consumer: TConsumer,
34
+ resourceConfigs: ReadonlyArray<TResourceConfig>,
35
+ ) => Result<
36
+ void,
37
+ | ResourceNotFoundError<TResourceKey>
38
+ | ConsumerNotFoundError<TConsumerId, TResourceKey>
39
+ >;
40
+
41
+ /** Gets the resource for the specified key, or null if it doesn't exist. */
42
+ readonly getResource: (key: TResourceKey) => TResource | null;
43
+
44
+ /** Gets all consumer IDs currently using the specified resource key. */
45
+ readonly getConsumersForResource: (
46
+ key: TResourceKey,
47
+ ) => ReadonlyArray<TConsumerId>;
48
+
49
+ /** Checks if a consumer is currently using any resources. */
50
+ readonly hasConsumerAnyResource: (consumer: TConsumer) => boolean;
51
+
52
+ /**
53
+ * Gets the consumer for the specified consumer ID, or null if not found or
54
+ * not using any resources.
55
+ */
56
+ readonly getConsumer: (consumerId: TConsumerId) => TConsumer | null;
57
+ }
58
+
59
+ /** Error when trying to remove a consumer from a resource that doesn't exist. */
60
+ export interface ResourceNotFoundError<TResourceKey extends string = string> {
61
+ readonly type: "ResourceNotFoundError";
62
+ readonly resourceKey: TResourceKey;
63
+ }
64
+
65
+ /** Error when trying to remove a consumer that wasn't added to a resource. */
66
+ export interface ConsumerNotFoundError<
67
+ TConsumerId extends string = string,
68
+ TResourceKey extends string = string,
69
+ > {
70
+ readonly type: "ConsumerNotFoundError";
71
+ readonly consumerId: TConsumerId;
72
+ readonly resourceKey: TResourceKey;
73
+ }
74
+
75
+ export interface ResourcesConfig<
76
+ TResource extends Disposable,
77
+ TResourceKey extends string,
78
+ TResourceConfig,
79
+ TConsumer,
80
+ TConsumerId extends string,
81
+ > {
82
+ /** Creates a new resource for the given config. */
83
+ readonly createResource: (config: TResourceConfig) => TResource;
84
+
85
+ /** Extracts a unique key from a resource config for deduplication. */
86
+ readonly getResourceKey: (config: TResourceConfig) => TResourceKey;
87
+
88
+ /** Extracts a unique identifier from a consumer for reference counting. */
89
+ readonly getConsumerId: (consumer: TConsumer) => TConsumerId;
90
+
91
+ /**
92
+ * Delay in milliseconds before disposing unused resources. Helps avoid
93
+ * resource churn during rapid add/remove cycles. Defaults to 100ms.
94
+ */
95
+ readonly disposalDelay?: number;
96
+
97
+ /**
98
+ * Called when a consumer is added to a resource for the first time. This
99
+ * happens when the consumer's reference count goes from 0 to 1 for this
100
+ * resource.
101
+ */
102
+ readonly onConsumerAdded?: (
103
+ consumer: TConsumer,
104
+ resource: TResource,
105
+ resourceKey: TResourceKey,
106
+ ) => void;
107
+
108
+ /**
109
+ * Called when a consumer is completely removed from a resource. This happens
110
+ * when the consumer's reference count goes from 1 to 0 for this resource.
111
+ */
112
+ readonly onConsumerRemoved?: (
113
+ consumer: TConsumer,
114
+ resource: TResource,
115
+ resourceKey: TResourceKey,
116
+ ) => void;
117
+ }
118
+
119
+ /**
120
+ * Creates {@link Resources}.
121
+ *
122
+ * This tracks which consumers are using which resources and maintains reference
123
+ * counts to know when it's safe to dispose resources. Resources are created
124
+ * on-demand and disposed with a configurable delay to avoid churn.
125
+ *
126
+ * ### Example Usage
127
+ *
128
+ * ```ts
129
+ * // WebSocket connections
130
+ * interface WebSocketConfig {
131
+ * readonly url: WebSocketUrl;
132
+ * }
133
+ *
134
+ * type WebSocketUrl = string & Brand<"WebSocketUrl">;
135
+ * type UserId = string & Brand<"UserId">;
136
+ *
137
+ * const webSockets = createResources<
138
+ * WebSocket,
139
+ * WebSocketUrl,
140
+ * WebSocketConfig,
141
+ * User,
142
+ * UserId
143
+ * >({
144
+ * createResource: (config) => new WebSocket(config.url),
145
+ * getResourceKey: (config) => config.url,
146
+ * getConsumerId: (user) => user.id,
147
+ * disposalDelay: 1000,
148
+ * });
149
+ *
150
+ * // Add users to WebSocket connections
151
+ * webSockets.addConsumer(user1, [
152
+ * { url: "ws://server1.com" as WebSocketUrl },
153
+ * { url: "ws://server2.com" as WebSocketUrl },
154
+ * ]);
155
+ * webSockets.addConsumer(user2, [
156
+ * { url: "ws://server1.com" as WebSocketUrl },
157
+ * ]);
158
+ *
159
+ * // Remove users - server1 stays alive (user2 still using it)
160
+ * webSockets.removeConsumer(user1, [
161
+ * { url: "ws://server1.com" as WebSocketUrl },
162
+ * { url: "ws://server2.com" as WebSocketUrl },
163
+ * ]);
164
+ *
165
+ * // server2 gets disposed after delay, server1 stays alive
166
+ * ```
167
+ */
168
+ export const createResources = <
169
+ TResource extends Disposable,
170
+ TResourceKey extends string,
171
+ TResourceConfig,
172
+ TConsumer,
173
+ TConsumerId extends string,
174
+ >(
175
+ config: ResourcesConfig<
176
+ TResource,
177
+ TResourceKey,
178
+ TResourceConfig,
179
+ TConsumer,
180
+ TConsumerId
181
+ >,
182
+ ): Resources<
183
+ TResource,
184
+ TResourceKey,
185
+ TResourceConfig,
186
+ TConsumer,
187
+ TConsumerId
188
+ > => {
189
+ let isDisposed = false;
190
+
191
+ const resourcesMap = new Map<TResourceKey, TResource>();
192
+ const consumerCounts = new Map<TResourceKey, Map<TConsumerId, PositiveInt>>();
193
+ const consumers = new Map<TConsumerId, TConsumer>();
194
+ const disposalTimeouts = new Map<
195
+ TResourceKey,
196
+ ReturnType<typeof setTimeout>
197
+ >();
198
+
199
+ const disposalDelay = config.disposalDelay ?? 100;
200
+
201
+ const ensureResource = (resourceConfig: TResourceConfig) => {
202
+ const key = config.getResourceKey(resourceConfig);
203
+ const timeout = disposalTimeouts.get(key);
204
+ if (timeout) {
205
+ clearTimeout(timeout);
206
+ disposalTimeouts.delete(key);
207
+ }
208
+
209
+ if (!resourcesMap.has(key)) {
210
+ const resource = config.createResource(resourceConfig);
211
+ resourcesMap.set(key, resource);
212
+ }
213
+ };
214
+
215
+ const scheduleDisposal = (key: TResourceKey): void => {
216
+ const timeout = setTimeout(() => {
217
+ const resource = resourcesMap.get(key);
218
+ if (resource) {
219
+ resource[Symbol.dispose]();
220
+ resourcesMap.delete(key);
221
+ }
222
+ disposalTimeouts.delete(key);
223
+ }, disposalDelay);
224
+
225
+ disposalTimeouts.set(key, timeout);
226
+ };
227
+
228
+ const resources: Resources<
229
+ TResource,
230
+ TResourceKey,
231
+ TResourceConfig,
232
+ TConsumer,
233
+ TConsumerId
234
+ > = {
235
+ addConsumer: (consumer, resourceConfigs) => {
236
+ if (isDisposed) return;
237
+
238
+ const consumerId = config.getConsumerId(consumer);
239
+
240
+ // Store consumer (last added consumer for this ID)
241
+ consumers.set(consumerId, consumer);
242
+
243
+ for (const resourceConfig of resourceConfigs) {
244
+ ensureResource(resourceConfig);
245
+ const resourceKey = config.getResourceKey(resourceConfig);
246
+
247
+ let counts = consumerCounts.get(resourceKey);
248
+ if (!counts) {
249
+ counts = new Map<TConsumerId, PositiveInt>();
250
+ consumerCounts.set(resourceKey, counts);
251
+ }
252
+
253
+ const currentCount = counts.get(consumerId) ?? 0;
254
+ const newCount = currentCount + 1;
255
+ counts.set(consumerId, PositiveInt.orThrow(newCount));
256
+
257
+ // Call onConsumerAdded callback only when consumer is added for the first time (0 -> 1)
258
+ if (currentCount === 0 && config.onConsumerAdded) {
259
+ const resource = resourcesMap.get(resourceKey);
260
+ if (resource) {
261
+ config.onConsumerAdded(consumer, resource, resourceKey);
262
+ }
263
+ }
264
+ }
265
+ },
266
+
267
+ removeConsumer: (consumer, resourceConfigs) => {
268
+ if (isDisposed) return ok();
269
+
270
+ const consumerId = config.getConsumerId(consumer);
271
+
272
+ for (const resourceConfig of resourceConfigs) {
273
+ const key = config.getResourceKey(resourceConfig);
274
+ const counts = consumerCounts.get(key);
275
+ if (!counts) {
276
+ return err({ type: "ResourceNotFoundError", resourceKey: key });
277
+ }
278
+
279
+ const currentCount = counts.get(consumerId);
280
+ if (currentCount == null) {
281
+ return err({
282
+ type: "ConsumerNotFoundError",
283
+ consumerId: consumerId,
284
+ resourceKey: key,
285
+ });
286
+ }
287
+
288
+ if (currentCount === 1) {
289
+ counts.delete(consumerId);
290
+
291
+ // Call onConsumerRemoved callback only when consumer is completely removed (1 -> 0)
292
+ if (config.onConsumerRemoved) {
293
+ const resource = resourcesMap.get(key);
294
+ if (resource) {
295
+ config.onConsumerRemoved(consumer, resource, key);
296
+ }
297
+ }
298
+
299
+ if (counts.size === 0) {
300
+ consumerCounts.delete(key);
301
+ scheduleDisposal(key);
302
+ }
303
+ } else {
304
+ counts.set(consumerId, PositiveInt.orThrow(currentCount - 1));
305
+ }
306
+ }
307
+
308
+ if (!resources.hasConsumerAnyResource(consumer)) {
309
+ consumers.delete(consumerId);
310
+ }
311
+
312
+ return ok();
313
+ },
314
+
315
+ getResource: (key) => {
316
+ if (isDisposed) return null;
317
+ return resourcesMap.get(key) ?? null;
318
+ },
319
+
320
+ getConsumersForResource: (key) => {
321
+ if (isDisposed) return [];
322
+ const counts = consumerCounts.get(key);
323
+ return counts ? Array.from(counts.keys()) : [];
324
+ },
325
+
326
+ hasConsumerAnyResource: (consumer) => {
327
+ if (isDisposed) return false;
328
+ const consumerId = config.getConsumerId(consumer);
329
+ // If slow, can be optimized with reverse index
330
+ return Array.from(consumerCounts.values()).some((counts) =>
331
+ counts.has(consumerId),
332
+ );
333
+ },
334
+
335
+ getConsumer: (consumerId) => {
336
+ if (isDisposed) return null;
337
+ const consumer = consumers.get(consumerId);
338
+ if (!consumer) return null;
339
+
340
+ // Only return consumer if it's currently using any resources
341
+ if (!resources.hasConsumerAnyResource(consumer)) {
342
+ return null;
343
+ }
344
+
345
+ return consumer;
346
+ },
347
+
348
+ [Symbol.dispose]: () => {
349
+ if (isDisposed) return;
350
+ isDisposed = true;
351
+
352
+ for (const timeout of disposalTimeouts.values()) {
353
+ clearTimeout(timeout);
354
+ }
355
+ disposalTimeouts.clear();
356
+
357
+ for (const resource of resourcesMap.values()) {
358
+ resource[Symbol.dispose]();
359
+ }
360
+ resourcesMap.clear();
361
+ consumerCounts.clear();
362
+ consumers.clear();
363
+ },
364
+ };
365
+
366
+ return resources;
367
+ };