@iamjulianacosta/mobx-data 1.0.1

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 (215) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/dist/CacheHandler-BTU_rYkv.js +208 -0
  4. package/dist/CacheHandler-BTU_rYkv.js.map +1 -0
  5. package/dist/CacheHandler-CXgY9IJo.cjs +2 -0
  6. package/dist/CacheHandler-CXgY9IJo.cjs.map +1 -0
  7. package/dist/EmbeddedRecordsMixin-CBvqNdgC.cjs +2 -0
  8. package/dist/EmbeddedRecordsMixin-CBvqNdgC.cjs.map +1 -0
  9. package/dist/EmbeddedRecordsMixin-VoHluHCT.js +261 -0
  10. package/dist/EmbeddedRecordsMixin-VoHluHCT.js.map +1 -0
  11. package/dist/JsonApiSerializer-CC5HXp4b.js +194 -0
  12. package/dist/JsonApiSerializer-CC5HXp4b.js.map +1 -0
  13. package/dist/JsonApiSerializer-CKB02AgP.cjs +2 -0
  14. package/dist/JsonApiSerializer-CKB02AgP.cjs.map +1 -0
  15. package/dist/MemoryAdapter-Bx1e7ndV.js +123 -0
  16. package/dist/MemoryAdapter-Bx1e7ndV.js.map +1 -0
  17. package/dist/MemoryAdapter-D1cTyydm.cjs +2 -0
  18. package/dist/MemoryAdapter-D1cTyydm.cjs.map +1 -0
  19. package/dist/ODataAdapter-C4IHK4BK.js +157 -0
  20. package/dist/ODataAdapter-C4IHK4BK.js.map +1 -0
  21. package/dist/ODataAdapter-DyyF1sdA.cjs +2 -0
  22. package/dist/ODataAdapter-DyyF1sdA.cjs.map +1 -0
  23. package/dist/RestAdapter-B4aRvs4m.js +355 -0
  24. package/dist/RestAdapter-B4aRvs4m.js.map +1 -0
  25. package/dist/RestAdapter-CJOwTsKK.cjs +2 -0
  26. package/dist/RestAdapter-CJOwTsKK.cjs.map +1 -0
  27. package/dist/SchemaService-DZwkFgZu.js +102 -0
  28. package/dist/SchemaService-DZwkFgZu.js.map +1 -0
  29. package/dist/SchemaService-Di_yjVzU.cjs +2 -0
  30. package/dist/SchemaService-Di_yjVzU.cjs.map +1 -0
  31. package/dist/Serializer-95gi5edy.cjs +2 -0
  32. package/dist/Serializer-95gi5edy.cjs.map +1 -0
  33. package/dist/Serializer-FxJbsZ50.js +139 -0
  34. package/dist/Serializer-FxJbsZ50.js.map +1 -0
  35. package/dist/Store-BdwMrbDi.cjs +2 -0
  36. package/dist/Store-BdwMrbDi.cjs.map +1 -0
  37. package/dist/Store-CZ7Z-Nme.js +912 -0
  38. package/dist/Store-CZ7Z-Nme.js.map +1 -0
  39. package/dist/adapter/Adapter.d.ts +146 -0
  40. package/dist/adapter/Adapter.d.ts.map +1 -0
  41. package/dist/adapter/MemoryAdapter.d.ts +44 -0
  42. package/dist/adapter/MemoryAdapter.d.ts.map +1 -0
  43. package/dist/adapter/RestAdapter.d.ts +57 -0
  44. package/dist/adapter/RestAdapter.d.ts.map +1 -0
  45. package/dist/adapter/index.cjs +2 -0
  46. package/dist/adapter/index.cjs.map +1 -0
  47. package/dist/adapter/index.d.ts +4 -0
  48. package/dist/adapter/index.d.ts.map +1 -0
  49. package/dist/adapter/index.js +8 -0
  50. package/dist/adapter/index.js.map +1 -0
  51. package/dist/date-Bj4O2W1F.js +107 -0
  52. package/dist/date-Bj4O2W1F.js.map +1 -0
  53. package/dist/date-CRCe-9gf.cjs +2 -0
  54. package/dist/date-CRCe-9gf.cjs.map +1 -0
  55. package/dist/decorators-HQ1KnRdh.cjs +2 -0
  56. package/dist/decorators-HQ1KnRdh.cjs.map +1 -0
  57. package/dist/decorators-Zr35qr6A.js +50 -0
  58. package/dist/decorators-Zr35qr6A.js.map +1 -0
  59. package/dist/index.cjs +2 -0
  60. package/dist/index.cjs.map +1 -0
  61. package/dist/index.d.ts +10 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +52 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/json-api/JsonApiAdapter.d.ts +38 -0
  66. package/dist/json-api/JsonApiAdapter.d.ts.map +1 -0
  67. package/dist/json-api/JsonApiSerializer.d.ts +73 -0
  68. package/dist/json-api/JsonApiSerializer.d.ts.map +1 -0
  69. package/dist/json-api/index.cjs +2 -0
  70. package/dist/json-api/index.cjs.map +1 -0
  71. package/dist/json-api/index.d.ts +3 -0
  72. package/dist/json-api/index.d.ts.map +1 -0
  73. package/dist/json-api/index.js +6 -0
  74. package/dist/json-api/index.js.map +1 -0
  75. package/dist/model/Errors.d.ts +46 -0
  76. package/dist/model/Errors.d.ts.map +1 -0
  77. package/dist/model/Model.d.ts +226 -0
  78. package/dist/model/Model.d.ts.map +1 -0
  79. package/dist/model/Snapshot.d.ts +72 -0
  80. package/dist/model/Snapshot.d.ts.map +1 -0
  81. package/dist/model/StateMachine.d.ts +45 -0
  82. package/dist/model/StateMachine.d.ts.map +1 -0
  83. package/dist/model/index.cjs +2 -0
  84. package/dist/model/index.cjs.map +1 -0
  85. package/dist/model/index.d.ts +6 -0
  86. package/dist/model/index.d.ts.map +1 -0
  87. package/dist/model/index.js +11 -0
  88. package/dist/model/index.js.map +1 -0
  89. package/dist/model/relationships.d.ts +182 -0
  90. package/dist/model/relationships.d.ts.map +1 -0
  91. package/dist/odata/ODataAdapter.d.ts +67 -0
  92. package/dist/odata/ODataAdapter.d.ts.map +1 -0
  93. package/dist/odata/index.cjs +2 -0
  94. package/dist/odata/index.cjs.map +1 -0
  95. package/dist/odata/index.d.ts +2 -0
  96. package/dist/odata/index.d.ts.map +1 -0
  97. package/dist/odata/index.js +5 -0
  98. package/dist/odata/index.js.map +1 -0
  99. package/dist/relationships-B55LBaCW.cjs +2 -0
  100. package/dist/relationships-B55LBaCW.cjs.map +1 -0
  101. package/dist/relationships-BEXANmWg.js +821 -0
  102. package/dist/relationships-BEXANmWg.js.map +1 -0
  103. package/dist/request/CacheHandler.d.ts +50 -0
  104. package/dist/request/CacheHandler.d.ts.map +1 -0
  105. package/dist/request/FetchHandler.d.ts +41 -0
  106. package/dist/request/FetchHandler.d.ts.map +1 -0
  107. package/dist/request/RequestManager.d.ts +52 -0
  108. package/dist/request/RequestManager.d.ts.map +1 -0
  109. package/dist/request/index.cjs +2 -0
  110. package/dist/request/index.cjs.map +1 -0
  111. package/dist/request/index.d.ts +5 -0
  112. package/dist/request/index.d.ts.map +1 -0
  113. package/dist/request/index.js +7 -0
  114. package/dist/request/index.js.map +1 -0
  115. package/dist/request/types.d.ts +111 -0
  116. package/dist/request/types.d.ts.map +1 -0
  117. package/dist/schema/SchemaService.d.ts +58 -0
  118. package/dist/schema/SchemaService.d.ts.map +1 -0
  119. package/dist/schema/decorators.d.ts +50 -0
  120. package/dist/schema/decorators.d.ts.map +1 -0
  121. package/dist/schema/index.cjs +2 -0
  122. package/dist/schema/index.cjs.map +1 -0
  123. package/dist/schema/index.d.ts +4 -0
  124. package/dist/schema/index.d.ts.map +1 -0
  125. package/dist/schema/index.js +13 -0
  126. package/dist/schema/index.js.map +1 -0
  127. package/dist/schema/types.d.ts +61 -0
  128. package/dist/schema/types.d.ts.map +1 -0
  129. package/dist/serializer/EmbeddedRecordsMixin.d.ts +80 -0
  130. package/dist/serializer/EmbeddedRecordsMixin.d.ts.map +1 -0
  131. package/dist/serializer/JsonSerializer.d.ts +52 -0
  132. package/dist/serializer/JsonSerializer.d.ts.map +1 -0
  133. package/dist/serializer/RestSerializer.d.ts +43 -0
  134. package/dist/serializer/RestSerializer.d.ts.map +1 -0
  135. package/dist/serializer/Serializer.d.ts +202 -0
  136. package/dist/serializer/Serializer.d.ts.map +1 -0
  137. package/dist/serializer/index.cjs +2 -0
  138. package/dist/serializer/index.cjs.map +1 -0
  139. package/dist/serializer/index.d.ts +5 -0
  140. package/dist/serializer/index.d.ts.map +1 -0
  141. package/dist/serializer/index.js +9 -0
  142. package/dist/serializer/index.js.map +1 -0
  143. package/dist/store/IdentityMap.d.ts +53 -0
  144. package/dist/store/IdentityMap.d.ts.map +1 -0
  145. package/dist/store/RecordArray.d.ts +114 -0
  146. package/dist/store/RecordArray.d.ts.map +1 -0
  147. package/dist/store/Store.d.ts +395 -0
  148. package/dist/store/Store.d.ts.map +1 -0
  149. package/dist/store/index.cjs +2 -0
  150. package/dist/store/index.cjs.map +1 -0
  151. package/dist/store/index.d.ts +5 -0
  152. package/dist/store/index.d.ts.map +1 -0
  153. package/dist/store/index.js +8 -0
  154. package/dist/store/index.js.map +1 -0
  155. package/dist/transforms/Transform.d.ts +49 -0
  156. package/dist/transforms/Transform.d.ts.map +1 -0
  157. package/dist/transforms/boolean.d.ts +26 -0
  158. package/dist/transforms/boolean.d.ts.map +1 -0
  159. package/dist/transforms/date.d.ts +22 -0
  160. package/dist/transforms/date.d.ts.map +1 -0
  161. package/dist/transforms/index.cjs +2 -0
  162. package/dist/transforms/index.cjs.map +1 -0
  163. package/dist/transforms/index.d.ts +6 -0
  164. package/dist/transforms/index.d.ts.map +1 -0
  165. package/dist/transforms/index.js +9 -0
  166. package/dist/transforms/index.js.map +1 -0
  167. package/dist/transforms/number.d.ts +17 -0
  168. package/dist/transforms/number.d.ts.map +1 -0
  169. package/dist/transforms/string.d.ts +18 -0
  170. package/dist/transforms/string.d.ts.map +1 -0
  171. package/dist/types-C9NB2gRj.js +7 -0
  172. package/dist/types-C9NB2gRj.js.map +1 -0
  173. package/dist/types-uWOXMPWW.cjs +2 -0
  174. package/dist/types-uWOXMPWW.cjs.map +1 -0
  175. package/package.json +140 -0
  176. package/src/adapter/Adapter.ts +320 -0
  177. package/src/adapter/MemoryAdapter.ts +216 -0
  178. package/src/adapter/RestAdapter.ts +248 -0
  179. package/src/adapter/index.ts +7 -0
  180. package/src/index.ts +17 -0
  181. package/src/json-api/JsonApiAdapter.ts +93 -0
  182. package/src/json-api/JsonApiSerializer.ts +245 -0
  183. package/src/json-api/index.ts +2 -0
  184. package/src/model/Errors.ts +100 -0
  185. package/src/model/Model.ts +683 -0
  186. package/src/model/Snapshot.ts +162 -0
  187. package/src/model/StateMachine.ts +149 -0
  188. package/src/model/index.ts +20 -0
  189. package/src/model/relationships.ts +484 -0
  190. package/src/odata/ODataAdapter.ts +245 -0
  191. package/src/odata/index.ts +1 -0
  192. package/src/request/CacheHandler.ts +125 -0
  193. package/src/request/FetchHandler.ts +119 -0
  194. package/src/request/RequestManager.ts +112 -0
  195. package/src/request/index.ts +4 -0
  196. package/src/request/types.ts +139 -0
  197. package/src/schema/SchemaService.ts +161 -0
  198. package/src/schema/decorators.ts +162 -0
  199. package/src/schema/index.ts +3 -0
  200. package/src/schema/types.ts +66 -0
  201. package/src/serializer/EmbeddedRecordsMixin.ts +257 -0
  202. package/src/serializer/JsonSerializer.ts +173 -0
  203. package/src/serializer/RestSerializer.ts +138 -0
  204. package/src/serializer/Serializer.ts +397 -0
  205. package/src/serializer/index.ts +15 -0
  206. package/src/store/IdentityMap.ts +110 -0
  207. package/src/store/RecordArray.ts +210 -0
  208. package/src/store/Store.ts +1391 -0
  209. package/src/store/index.ts +11 -0
  210. package/src/transforms/Transform.ts +52 -0
  211. package/src/transforms/boolean.ts +57 -0
  212. package/src/transforms/date.ts +48 -0
  213. package/src/transforms/index.ts +5 -0
  214. package/src/transforms/number.ts +42 -0
  215. package/src/transforms/string.ts +35 -0
@@ -0,0 +1,245 @@
1
+ import { injectable } from 'tsyringe';
2
+ import pluralize from 'pluralize';
3
+ import { pascalCase } from 'change-case';
4
+ import { RestAdapter } from '@mobx-data/adapter';
5
+ import type { AdapterSnapshot } from '@mobx-data/adapter';
6
+
7
+ /** All OData v4 system query option names (both bare and $-prefixed forms are checked). */
8
+ const ODATA_SYSTEM_QUERY_OPTIONS = new Set([
9
+ '$filter',
10
+ '$select',
11
+ '$orderby',
12
+ '$top',
13
+ '$skip',
14
+ '$expand',
15
+ '$count',
16
+ '$search',
17
+ '$format',
18
+ '$skiptoken',
19
+ ]);
20
+
21
+ /**
22
+ * OData v4 adapter.
23
+ *
24
+ * Extends `RestAdapter` with OData-specific conventions:
25
+ * - Entity-set names are PascalCase plural (`user` → `Users`).
26
+ * - Single-entity URLs use key-in-parentheses syntax (`Users(1)`, `Users('abc')`).
27
+ * - Mutations use PATCH instead of PUT.
28
+ * - Accept / Content-Type headers carry the `odata.metadata=minimal` parameter.
29
+ * - System query options (`$filter`, `$expand`, etc.) are passed through verbatim;
30
+ * bare names are auto-prefixed (`filter` → `$filter`).
31
+ */
32
+ @injectable()
33
+ export class ODataAdapter extends RestAdapter {
34
+ /** OData protocol version sent in `OData-Version` and `OData-MaxVersion` headers. */
35
+ odataVersion: string = '4.0';
36
+
37
+ static isNumericKey(id: string): boolean {
38
+ return /^-?\d+(?:\.\d+)?$/.test(id);
39
+ }
40
+
41
+ static escapeODataString(value: string): string {
42
+ return value.replace(/'/g, "''");
43
+ }
44
+
45
+ override defaultHeaders(): Record<string, string> {
46
+ return {
47
+ Accept: 'application/json;odata.metadata=minimal',
48
+ 'OData-Version': this.odataVersion,
49
+ 'OData-MaxVersion': this.odataVersion,
50
+ ...this.headers,
51
+ };
52
+ }
53
+
54
+ override mutationHeaders(): Record<string, string> {
55
+ return {
56
+ 'Content-Type': 'application/json;odata.metadata=minimal',
57
+ ...this.defaultHeaders(),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Maps a camelCase/dasherized model name to a PascalCase plural entity-set name.
63
+ * @example `pathForType('orderItem')` → `'OrderItems'`
64
+ */
65
+ override pathForType(modelName: string): string {
66
+ return pascalCase(pluralize.plural(modelName));
67
+ }
68
+
69
+ /**
70
+ * Encodes a record id as an OData key literal.
71
+ * Numeric ids are returned bare (`1`); string ids are single-quoted with
72
+ * inner apostrophes escaped (`o'brien` → `'o''brien'`).
73
+ *
74
+ * Override this method when the service uses a non-default key format (e.g. GUIDs).
75
+ */
76
+ encodeKey(id: string): string {
77
+ if (ODataAdapter.isNumericKey(id)) {
78
+ return id;
79
+ }
80
+ return `'${ODataAdapter.escapeODataString(id)}'`;
81
+ }
82
+
83
+ override urlForFindRecord(
84
+ id: string,
85
+ modelName: string,
86
+ _snapshot: AdapterSnapshot,
87
+ ): string {
88
+ return this._composeURL(`${this.pathForType(modelName)}(${this.encodeKey(id)})`);
89
+ }
90
+
91
+ override urlForUpdateRecord(
92
+ id: string,
93
+ modelName: string,
94
+ _snapshot: AdapterSnapshot,
95
+ ): string {
96
+ return this._composeURL(`${this.pathForType(modelName)}(${this.encodeKey(id)})`);
97
+ }
98
+
99
+ override urlForDeleteRecord(
100
+ id: string,
101
+ modelName: string,
102
+ _snapshot: AdapterSnapshot,
103
+ ): string {
104
+ return this._composeURL(`${this.pathForType(modelName)}(${this.encodeKey(id)})`);
105
+ }
106
+
107
+ /**
108
+ * Fetches multiple records by id using a single request.
109
+ * Builds a `$filter=id eq X or id eq Y` expression so only one HTTP round-trip
110
+ * is needed regardless of how many ids are requested.
111
+ */
112
+ override async findMany(
113
+ _store: unknown,
114
+ modelName: string,
115
+ ids: string[],
116
+ snapshots: AdapterSnapshot[],
117
+ ): Promise<unknown> {
118
+ const base = this.buildURL(modelName, ids, snapshots, 'findMany');
119
+ const filter = ids.map((id) => `id eq ${this.encodeKey(id)}`).join(' or ');
120
+ const url = `${base}?${this._toQueryString({ $filter: filter })}`;
121
+ return this._fetchJSON(url, {
122
+ method: 'GET',
123
+ headers: this.defaultHeaders(),
124
+ });
125
+ }
126
+
127
+ override async query(
128
+ _store: unknown,
129
+ modelName: string,
130
+ query: Record<string, unknown>,
131
+ ): Promise<unknown> {
132
+ const base = this.buildURL(modelName, null, null, 'query', query);
133
+ const normalized = this._normalizeQuery(query);
134
+ const url = `${base}${this._appendQuery(normalized)}`;
135
+ return this._fetchJSON(url, {
136
+ method: 'GET',
137
+ headers: this.defaultHeaders(),
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Queries for a single record by appending `$top=1` to whatever filter is provided.
143
+ * The caller is responsible for picking the first element from the returned `value` array.
144
+ */
145
+ override async queryRecord(
146
+ _store: unknown,
147
+ modelName: string,
148
+ query: Record<string, unknown>,
149
+ ): Promise<unknown> {
150
+ const base = this.buildURL(modelName, null, null, 'queryRecord', query);
151
+ const normalized = { ...this._normalizeQuery(query), $top: 1 };
152
+ const url = `${base}${this._appendQuery(normalized)}`;
153
+ return this._fetchJSON(url, {
154
+ method: 'GET',
155
+ headers: this.defaultHeaders(),
156
+ });
157
+ }
158
+
159
+ /** Sends a PATCH request (partial update) as required by the OData v4 spec. */
160
+ override async updateRecord(
161
+ _store: unknown,
162
+ modelName: string,
163
+ snapshot: AdapterSnapshot,
164
+ ): Promise<unknown> {
165
+ const url = this.buildURL(modelName, snapshot.id, snapshot, 'updateRecord');
166
+ return this._fetchJSON(url, {
167
+ method: 'PATCH',
168
+ headers: this.mutationHeaders(),
169
+ body: JSON.stringify(this._serializeSnapshot(snapshot)),
170
+ });
171
+ }
172
+
173
+ /** Sends a PATCH request with only the changed attributes (partial update). */
174
+ override async patchRecord(
175
+ _store: unknown,
176
+ modelName: string,
177
+ snapshot: AdapterSnapshot,
178
+ ): Promise<unknown> {
179
+ const url = this.buildURL(modelName, snapshot.id, snapshot, 'updateRecord');
180
+ const changed = snapshot.changedAttributes();
181
+ const partial: Record<string, unknown> = {};
182
+ for (const [key, [, current]] of Object.entries(changed)) {
183
+ partial[key] = current;
184
+ }
185
+ return this._fetchJSON(url, {
186
+ method: 'PATCH',
187
+ headers: this.mutationHeaders(),
188
+ body: JSON.stringify(partial),
189
+ });
190
+ }
191
+
192
+ /** Extracts the raw attribute map from the snapshot's internal record. */
193
+ protected _serializeSnapshot(snapshot: AdapterSnapshot): Record<string, unknown> {
194
+ const record = snapshot.record as { _data?: Record<string, unknown> };
195
+ const data = record._data;
196
+ const body: Record<string, unknown> = {};
197
+ if (data) {
198
+ Object.assign(body, data);
199
+ }
200
+ return body;
201
+ }
202
+
203
+ /**
204
+ * Normalises a query hash so every OData system option has its `$` prefix.
205
+ * Bare names (`filter`, `top`, `expand`) are prefixed automatically.
206
+ * Custom (non-system) keys are forwarded verbatim.
207
+ * `null` and `undefined` values are dropped.
208
+ */
209
+ protected _normalizeQuery(
210
+ query: Record<string, unknown>,
211
+ ): Record<string, unknown> {
212
+ const normalized: Record<string, unknown> = {};
213
+ for (const [key, value] of Object.entries(query)) {
214
+ if (value === undefined || value === null) {
215
+ continue;
216
+ }
217
+ if (ODATA_SYSTEM_QUERY_OPTIONS.has(key)) {
218
+ normalized[key] = value;
219
+ } else if (ODATA_SYSTEM_QUERY_OPTIONS.has(`$${key}`)) {
220
+ normalized[`$${key}`] = value;
221
+ } else {
222
+ normalized[key] = value;
223
+ }
224
+ }
225
+ return normalized;
226
+ }
227
+
228
+ /** Serialises a key→value map to a `key=value&…` query string (percent-encoded). */
229
+ protected _toQueryString(query: Record<string, unknown>): string {
230
+ const parts: string[] = [];
231
+ for (const [key, value] of Object.entries(query)) {
232
+ if (value === undefined || value === null) {
233
+ continue;
234
+ }
235
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
236
+ }
237
+ return parts.join('&');
238
+ }
239
+
240
+ /** Returns `?key=value&…` when the query is non-empty, or an empty string. */
241
+ protected _appendQuery(query: Record<string, unknown>): string {
242
+ const queryString = this._toQueryString(query);
243
+ return queryString ? `?${queryString}` : '';
244
+ }
245
+ }
@@ -0,0 +1 @@
1
+ export { ODataAdapter } from './ODataAdapter.js';
@@ -0,0 +1,125 @@
1
+ /**
2
+ * In-memory caching handler for the request pipeline.
3
+ *
4
+ * `CacheHandler` is registered via `RequestManager.useCache()` so it always
5
+ * runs first in the chain. It caches `GET` responses and replays them on
6
+ * subsequent requests with the same key — unless `cacheOptions.reload: true`
7
+ * is set, in which case it bypasses the cache and stores the fresh response.
8
+ *
9
+ * Cache key: `cacheOptions.key` when provided, otherwise `"<METHOD> <URL>"`.
10
+ *
11
+ * Only `GET` requests are cached; mutations (POST, PUT, PATCH, DELETE) are
12
+ * always forwarded to `next` without touching the cache.
13
+ *
14
+ * Usage:
15
+ * ```ts
16
+ * const manager = new RequestManager()
17
+ * .useCache(new CacheHandler())
18
+ * .use(new FetchHandler());
19
+ * ```
20
+ */
21
+
22
+ import { injectable } from 'tsyringe';
23
+ import type {
24
+ Handler,
25
+ NextFn,
26
+ RequestContext,
27
+ StoreRequest,
28
+ StoreResponse,
29
+ } from './types.js';
30
+
31
+ interface CacheEntry {
32
+ response: StoreResponse;
33
+ cachedAt: number;
34
+ }
35
+
36
+ @injectable()
37
+ export class CacheHandler implements Handler {
38
+ private cache = new Map<string, CacheEntry>();
39
+
40
+ maxSize: number = 256;
41
+
42
+ ttl: number = 300_000;
43
+
44
+ private isExpired(entry: CacheEntry): boolean {
45
+ return Date.now() - entry.cachedAt > this.ttl;
46
+ }
47
+
48
+ private evictLRU(): void {
49
+ while (this.cache.size > this.maxSize) {
50
+ const firstKey = this.cache.keys().next().value;
51
+ if (firstKey !== undefined) {
52
+ this.cache.delete(firstKey);
53
+ } else {
54
+ break;
55
+ }
56
+ }
57
+ }
58
+
59
+ static keyFor(req: StoreRequest): string {
60
+ return req.cacheOptions?.key ?? `${req.method} ${req.url}`;
61
+ }
62
+
63
+ static isCacheable(req: StoreRequest): boolean {
64
+ return req.method === 'GET';
65
+ }
66
+
67
+ /**
68
+ * Handles a request by checking the in-memory cache before forwarding to
69
+ * the next handler.
70
+ *
71
+ * - Non-GET requests skip the cache entirely.
72
+ * - `cacheOptions.reload: true` forces a network request and refreshes the entry.
73
+ * - Cached entries expire after `ttl` milliseconds (default 5 minutes).
74
+ * - The cache uses LRU eviction when it exceeds `maxSize` entries (default 256).
75
+ */
76
+ async request<T = unknown>(
77
+ context: RequestContext<T>,
78
+ next: NextFn<T>,
79
+ ): Promise<StoreResponse<T>> {
80
+ const req = context.request;
81
+ if (!CacheHandler.isCacheable(req)) {
82
+ return next(req);
83
+ }
84
+
85
+ const key = CacheHandler.keyFor(req);
86
+ const reload = req.cacheOptions?.reload === true;
87
+
88
+ if (!reload) {
89
+ const hit = this.cache.get(key);
90
+ if (hit) {
91
+ if (this.isExpired(hit)) {
92
+ this.cache.delete(key);
93
+ } else {
94
+ // LRU promotion: delete and re-set to move to end
95
+ this.cache.delete(key);
96
+ this.cache.set(key, hit);
97
+ return hit.response as StoreResponse<T>;
98
+ }
99
+ }
100
+ }
101
+
102
+ const response = await next(req);
103
+ this.cache.set(key, { response: response as StoreResponse, cachedAt: Date.now() });
104
+ this.evictLRU();
105
+ return response;
106
+ }
107
+
108
+ /** Returns the number of entries currently in the cache. */
109
+ get size(): number {
110
+ return this.cache.size;
111
+ }
112
+
113
+ /** Removes all entries from the cache. */
114
+ clear(): void {
115
+ this.cache.clear();
116
+ }
117
+
118
+ /**
119
+ * Removes a single entry by key.
120
+ * @returns `true` when the entry existed and was deleted.
121
+ */
122
+ delete(key: string): boolean {
123
+ return this.cache.delete(key);
124
+ }
125
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Terminal request handler that executes HTTP calls via the browser / Node
3
+ * `fetch` API.
4
+ *
5
+ * `FetchHandler` is designed to sit at the end of the `RequestManager` chain.
6
+ * It does not call `next` — it issues the network request and returns the
7
+ * parsed response directly.
8
+ *
9
+ * Response body parsing:
10
+ * - `204 No Content` or `Content-Length: 0` → `null`
11
+ * - `application/json` or `application/vnd.api+json` → parsed JSON (falls
12
+ * back to raw text if parsing fails)
13
+ * - Everything else → raw text string
14
+ *
15
+ * Error handling:
16
+ * - Non-2xx responses throw a `FetchError` that includes `status`, `content`,
17
+ * and `headers` for downstream error handling.
18
+ */
19
+
20
+ import { injectable } from 'tsyringe';
21
+ import type {
22
+ Handler,
23
+ NextFn,
24
+ RequestContext,
25
+ StoreResponse,
26
+ } from './types.js';
27
+
28
+ /**
29
+ * Error thrown by `FetchHandler` for non-2xx HTTP responses.
30
+ * Carries the status code, parsed body content, and response headers.
31
+ */
32
+ export class FetchError extends Error {
33
+ readonly status: number;
34
+ readonly content: unknown;
35
+ readonly headers: Record<string, string>;
36
+ constructor(
37
+ status: number,
38
+ content: unknown,
39
+ headers: Record<string, string>,
40
+ message?: string,
41
+ ) {
42
+ super(message ?? `Request failed with status ${status}`);
43
+ this.status = status;
44
+ this.content = content;
45
+ this.headers = headers;
46
+ }
47
+ }
48
+
49
+ @injectable()
50
+ export class FetchHandler implements Handler {
51
+ static headersToObject(headers: Headers): Record<string, string> {
52
+ const out: Record<string, string> = {};
53
+ headers.forEach((value, key) => {
54
+ out[key] = value;
55
+ });
56
+ return out;
57
+ }
58
+
59
+ static async parseBody(response: Response): Promise<unknown> {
60
+ const contentType = response.headers.get('Content-Type') ?? '';
61
+ if (
62
+ response.status === 204
63
+ || response.headers.get('Content-Length') === '0'
64
+ ) {
65
+ return null;
66
+ }
67
+ const isJson = contentType.includes('application/json')
68
+ || contentType.includes('application/vnd.api+json');
69
+ if (isJson) {
70
+ const text = await response.text();
71
+ if (!text) {
72
+ return null;
73
+ }
74
+ try {
75
+ return JSON.parse(text);
76
+ } catch {
77
+ return text;
78
+ }
79
+ }
80
+ return response.text();
81
+ }
82
+
83
+ /**
84
+ * Issues the HTTP request and returns a `StoreResponse`.
85
+ * This is a terminal handler — it never calls `next`.
86
+ *
87
+ * @throws `FetchError` on non-2xx responses.
88
+ */
89
+ async request<T = unknown>(
90
+ context: RequestContext<T>,
91
+ _next: NextFn<T>,
92
+ ): Promise<StoreResponse<T>> {
93
+ const req = context.request;
94
+ const init: RequestInit = {
95
+ method: req.method,
96
+ headers: req.headers,
97
+ };
98
+ if (req.body !== undefined && req.body !== null) {
99
+ init.body = req.body;
100
+ }
101
+ if (req.signal) {
102
+ init.signal = req.signal;
103
+ }
104
+
105
+ const response = await fetch(req.url, init);
106
+ const content = await FetchHandler.parseBody(response);
107
+ const headers = FetchHandler.headersToObject(response.headers);
108
+
109
+ if (!response.ok) {
110
+ throw new FetchError(response.status, content, headers);
111
+ }
112
+ return {
113
+ content: content as T,
114
+ status: response.status,
115
+ headers,
116
+ request: req,
117
+ };
118
+ }
119
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Middleware-style manager that runs a `StoreRequest` through an ordered chain
3
+ * of `Handler` instances.
4
+ *
5
+ * Handlers are invoked in registration order. Each handler receives a
6
+ * `RequestContext` and a `next` function; it may:
7
+ * - Call `next(request)` to pass control to the next handler.
8
+ * - Return a `StoreResponse` directly to short-circuit the chain.
9
+ * - Wrap `next` to inspect or transform the response (e.g. logging).
10
+ *
11
+ * An optional *cache handler* registered via `useCache()` is always prepended
12
+ * to the chain so it runs before every other handler.
13
+ *
14
+ * Usage:
15
+ * ```ts
16
+ * const manager = new RequestManager()
17
+ * .useCache(new CacheHandler())
18
+ * .use([new AuthHandler(), new FetchHandler()]);
19
+ *
20
+ * const response = await manager.request({ method: 'GET', url: '/posts' });
21
+ * ```
22
+ */
23
+
24
+ import { injectable } from 'tsyringe';
25
+ import type {
26
+ Handler,
27
+ NextFn,
28
+ RequestContext,
29
+ StoreRequest,
30
+ StoreResponse,
31
+ } from './types.js';
32
+
33
+ @injectable()
34
+ export class RequestManager {
35
+ private _handlers: Handler[] = [];
36
+ private _cache: Handler | null = null;
37
+
38
+ /**
39
+ * Appends one or more handlers to the pipeline.
40
+ * Returns `this` for chaining.
41
+ */
42
+ use(handlers: Handler[] | Handler): this {
43
+ if (Array.isArray(handlers)) {
44
+ this._handlers.push(...handlers);
45
+ } else {
46
+ this._handlers.push(handlers);
47
+ }
48
+ return this;
49
+ }
50
+
51
+ /**
52
+ * Registers the cache handler. It is always inserted at the front of
53
+ * the chain so it can intercept requests before any other handler sees them.
54
+ * Returns `this` for chaining.
55
+ */
56
+ useCache(handler: Handler): this {
57
+ this._cache = handler;
58
+ return this;
59
+ }
60
+
61
+ /** All non-cache handlers in registration order. */
62
+ get handlers(): readonly Handler[] {
63
+ return this._handlers;
64
+ }
65
+
66
+ /** The registered cache handler, or `null`. */
67
+ get cacheHandler(): Handler | null {
68
+ return this._cache;
69
+ }
70
+
71
+ /**
72
+ * Executes `request` through the full handler chain and returns the final
73
+ * `StoreResponse`.
74
+ *
75
+ * @throws when no handlers have been registered.
76
+ * @throws when the chain ends without any handler returning a response
77
+ * (i.e. no terminal handler such as `FetchHandler`).
78
+ */
79
+ async request<T = unknown>(request: StoreRequest): Promise<StoreResponse<T>> {
80
+ const chain: Handler[] = this._cache
81
+ ? [this._cache, ...this._handlers]
82
+ : [...this._handlers];
83
+
84
+ if (chain.length === 0) {
85
+ throw new Error(
86
+ 'RequestManager has no handlers registered — cannot complete request',
87
+ );
88
+ }
89
+
90
+ let index = 0;
91
+ const invoke = async (req: StoreRequest): Promise<StoreResponse<T>> => {
92
+ const handler = chain[index];
93
+ if (!handler) {
94
+ throw new Error(
95
+ 'Handler chain ended without producing a response (no terminal handler)',
96
+ );
97
+ }
98
+ index++;
99
+ const context: RequestContext<T> = {
100
+ request: req,
101
+ response: undefined,
102
+ setResponse(r) {
103
+ this.response = r;
104
+ },
105
+ };
106
+ const next: NextFn<T> = (nextRequest) => invoke(nextRequest);
107
+ return handler.request(context, next) as Promise<StoreResponse<T>>;
108
+ };
109
+
110
+ return invoke(request);
111
+ }
112
+ }
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export { RequestManager } from './RequestManager.js';
3
+ export { FetchHandler } from './FetchHandler.js';
4
+ export { CacheHandler } from './CacheHandler.js';