@procedures/store 0.0.0-stage → 0.1.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 (115) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/errors.d.ts +16 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +18 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/index.d.ts +19 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +19 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/module.d.ts +15 -0
  14. package/dist/module.d.ts.map +1 -0
  15. package/dist/module.js +16 -0
  16. package/dist/module.js.map +1 -0
  17. package/dist/procedures.d.ts +13 -0
  18. package/dist/procedures.d.ts.map +1 -0
  19. package/dist/procedures.js +14 -0
  20. package/dist/procedures.js.map +1 -0
  21. package/dist/program-store/attach.d.ts +20 -0
  22. package/dist/program-store/attach.d.ts.map +1 -0
  23. package/dist/program-store/attach.js +36 -0
  24. package/dist/program-store/attach.js.map +1 -0
  25. package/dist/program-store/collection.d.ts +19 -0
  26. package/dist/program-store/collection.d.ts.map +1 -0
  27. package/dist/program-store/collection.js +34 -0
  28. package/dist/program-store/collection.js.map +1 -0
  29. package/dist/program-store/file.d.ts +29 -0
  30. package/dist/program-store/file.d.ts.map +1 -0
  31. package/dist/program-store/file.js +156 -0
  32. package/dist/program-store/file.js.map +1 -0
  33. package/dist/program-store/index.d.ts +11 -0
  34. package/dist/program-store/index.d.ts.map +1 -0
  35. package/dist/program-store/index.js +10 -0
  36. package/dist/program-store/index.js.map +1 -0
  37. package/dist/program-store/memory.d.ts +15 -0
  38. package/dist/program-store/memory.d.ts.map +1 -0
  39. package/dist/program-store/memory.js +25 -0
  40. package/dist/program-store/memory.js.map +1 -0
  41. package/dist/program-store/procedures.d.ts +23 -0
  42. package/dist/program-store/procedures.d.ts.map +1 -0
  43. package/dist/program-store/procedures.js +221 -0
  44. package/dist/program-store/procedures.js.map +1 -0
  45. package/dist/program-store/types.d.ts +50 -0
  46. package/dist/program-store/types.d.ts.map +1 -0
  47. package/dist/program-store/types.js +41 -0
  48. package/dist/program-store/types.js.map +1 -0
  49. package/dist/register.d.ts +8 -0
  50. package/dist/register.d.ts.map +1 -0
  51. package/dist/register.js +15 -0
  52. package/dist/register.js.map +1 -0
  53. package/dist/storage/adapter.d.ts +148 -0
  54. package/dist/storage/adapter.d.ts.map +1 -0
  55. package/dist/storage/adapter.js +214 -0
  56. package/dist/storage/adapter.js.map +1 -0
  57. package/dist/storage/api.d.ts +125 -0
  58. package/dist/storage/api.d.ts.map +1 -0
  59. package/dist/storage/api.js +220 -0
  60. package/dist/storage/api.js.map +1 -0
  61. package/dist/storage/factory.d.ts +134 -0
  62. package/dist/storage/factory.d.ts.map +1 -0
  63. package/dist/storage/factory.js +166 -0
  64. package/dist/storage/factory.js.map +1 -0
  65. package/dist/storage/hybrid.d.ts +193 -0
  66. package/dist/storage/hybrid.d.ts.map +1 -0
  67. package/dist/storage/hybrid.js +519 -0
  68. package/dist/storage/hybrid.js.map +1 -0
  69. package/dist/storage/index.d.ts +21 -0
  70. package/dist/storage/index.d.ts.map +1 -0
  71. package/dist/storage/index.js +20 -0
  72. package/dist/storage/index.js.map +1 -0
  73. package/dist/storage/procedures.d.ts +14 -0
  74. package/dist/storage/procedures.d.ts.map +1 -0
  75. package/dist/storage/procedures.js +98 -0
  76. package/dist/storage/procedures.js.map +1 -0
  77. package/dist/storage/serialization.d.ts +123 -0
  78. package/dist/storage/serialization.d.ts.map +1 -0
  79. package/dist/storage/serialization.js +216 -0
  80. package/dist/storage/serialization.js.map +1 -0
  81. package/dist/storage/synced-registry.d.ts +246 -0
  82. package/dist/storage/synced-registry.d.ts.map +1 -0
  83. package/dist/storage/synced-registry.js +575 -0
  84. package/dist/storage/synced-registry.js.map +1 -0
  85. package/dist/storage/types.d.ts +130 -0
  86. package/dist/storage/types.d.ts.map +1 -0
  87. package/dist/storage/types.js +8 -0
  88. package/dist/storage/types.js.map +1 -0
  89. package/dist/types.d.ts +7 -0
  90. package/dist/types.d.ts.map +1 -0
  91. package/dist/types.js +7 -0
  92. package/dist/types.js.map +1 -0
  93. package/package.json +79 -3
  94. package/src/errors.ts +20 -0
  95. package/src/index.ts +19 -0
  96. package/src/module.ts +18 -0
  97. package/src/procedures.ts +17 -0
  98. package/src/program-store/attach.ts +46 -0
  99. package/src/program-store/collection.ts +39 -0
  100. package/src/program-store/file.ts +157 -0
  101. package/src/program-store/index.ts +17 -0
  102. package/src/program-store/memory.ts +30 -0
  103. package/src/program-store/procedures.ts +344 -0
  104. package/src/program-store/types.ts +78 -0
  105. package/src/register.ts +18 -0
  106. package/src/storage/adapter.ts +273 -0
  107. package/src/storage/api.ts +335 -0
  108. package/src/storage/factory.ts +268 -0
  109. package/src/storage/hybrid.ts +676 -0
  110. package/src/storage/index.ts +61 -0
  111. package/src/storage/procedures.ts +146 -0
  112. package/src/storage/serialization.ts +283 -0
  113. package/src/storage/synced-registry.ts +698 -0
  114. package/src/storage/types.ts +180 -0
  115. package/src/types.ts +7 -0
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Procedure Storage Adapter
3
+ *
4
+ * Wraps CollectionStorage<SerializedProcedure> to provide procedure-specific
5
+ * storage operations with serialization/deserialization.
6
+ */
7
+
8
+ import type { CollectionStorage } from "@procedures/collections";
9
+ import type { AnyProcedure, ProcedurePath } from "@procedures/client";
10
+ import { pathToKey } from "@procedures/client";
11
+ import type { SerializedProcedure, HandlerLoader } from "./types.js";
12
+ import {
13
+ serializeProcedure,
14
+ deserializeProcedure,
15
+ getProcedureKey,
16
+ type SerializeOptions,
17
+ } from "./serialization.js";
18
+
19
+ // =============================================================================
20
+ // Procedure Storage Adapter
21
+ // =============================================================================
22
+
23
+ /**
24
+ * Options for ProcedureStorageAdapter.
25
+ */
26
+ export interface ProcedureStorageAdapterOptions {
27
+ /** Handler loader for restoring handlers from references */
28
+ handlerLoader?: HandlerLoader | undefined;
29
+
30
+ /** Default serialization options */
31
+ serializeOptions?: SerializeOptions | undefined;
32
+ }
33
+
34
+ /**
35
+ * Adapter that wraps CollectionStorage for procedure-specific operations.
36
+ *
37
+ * Handles serialization/deserialization of procedures and provides
38
+ * convenience methods for common operations.
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * const storage = new InMemoryStorage<SerializedProcedure>();
43
+ * const adapter = new ProcedureStorageAdapter(storage, {
44
+ * handlerLoader: createDynamicHandlerLoader(),
45
+ * });
46
+ *
47
+ * // Store a procedure
48
+ * await adapter.store(myProcedure);
49
+ *
50
+ * // Load a procedure
51
+ * const proc = await adapter.load(['lib', 'scan']);
52
+ * ```
53
+ */
54
+ export class ProcedureStorageAdapter {
55
+ private readonly storage: CollectionStorage<SerializedProcedure>;
56
+ private readonly handlerLoader: HandlerLoader | undefined;
57
+ private readonly serializeOptions: SerializeOptions;
58
+
59
+ constructor(
60
+ storage: CollectionStorage<SerializedProcedure>,
61
+ options: ProcedureStorageAdapterOptions = {}
62
+ ) {
63
+ this.storage = storage;
64
+ this.handlerLoader = options.handlerLoader;
65
+ this.serializeOptions = options.serializeOptions ?? {};
66
+ }
67
+
68
+ // ===========================================================================
69
+ // Single Item Operations
70
+ // ===========================================================================
71
+
72
+ /**
73
+ * Store a procedure.
74
+ *
75
+ * @param procedure - Procedure to store
76
+ * @param options - Override default serialization options
77
+ * @returns The record that the storage got
78
+ */
79
+ async store(procedure: AnyProcedure, options?: SerializeOptions): Promise<SerializedProcedure> {
80
+ const key = getProcedureKey(procedure);
81
+ const serialized = serializeProcedure(procedure, {
82
+ ...this.serializeOptions,
83
+ ...options,
84
+ });
85
+ await this.storage.set(key, serialized);
86
+ return serialized;
87
+ }
88
+
89
+ /**
90
+ * Load a procedure by path.
91
+ *
92
+ * @param path - Procedure path
93
+ * @returns Procedure or undefined if not found
94
+ */
95
+ async load(path: ProcedurePath): Promise<AnyProcedure | undefined> {
96
+ const key = pathToKey(path);
97
+ const data = await this.storage.get(key);
98
+ if (!data) return undefined;
99
+
100
+ return deserializeProcedure(data, {
101
+ handlerLoader: this.handlerLoader,
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Check if a procedure exists in storage.
107
+ *
108
+ * @param path - Procedure path
109
+ * @returns True if procedure exists
110
+ */
111
+ async has(path: ProcedurePath): Promise<boolean> {
112
+ const key = pathToKey(path);
113
+ return this.storage.has(key);
114
+ }
115
+
116
+ /**
117
+ * Remove a procedure from storage.
118
+ *
119
+ * @param path - Procedure path
120
+ * @returns True if procedure was removed
121
+ */
122
+ async remove(path: ProcedurePath): Promise<boolean> {
123
+ const key = pathToKey(path);
124
+ return this.storage.delete(key);
125
+ }
126
+
127
+ // ===========================================================================
128
+ // Bulk Operations
129
+ // ===========================================================================
130
+
131
+ /**
132
+ * Load all procedures from storage.
133
+ *
134
+ * @returns Array of all procedures
135
+ */
136
+ async loadAll(): Promise<AnyProcedure[]> {
137
+ const items = await this.storage.getAll();
138
+ return Promise.all(
139
+ items.map((item) =>
140
+ deserializeProcedure(item, { handlerLoader: this.handlerLoader })
141
+ )
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Load procedures matching a path prefix.
147
+ *
148
+ * @param prefix - Path prefix to match
149
+ * @returns Array of matching procedures
150
+ *
151
+ * @example
152
+ * ```typescript
153
+ * // Load all lib.* procedures
154
+ * const libProcedures = await adapter.loadByPrefix(['lib']);
155
+ * ```
156
+ */
157
+ async loadByPrefix(prefix: ProcedurePath): Promise<AnyProcedure[]> {
158
+ const prefixKey = pathToKey(prefix);
159
+ const items = await this.storage.find((item) => {
160
+ const itemKey = pathToKey(item.path);
161
+ return itemKey === prefixKey || itemKey.startsWith(prefixKey + ".");
162
+ });
163
+
164
+ return Promise.all(
165
+ items.map((item) =>
166
+ deserializeProcedure(item, { handlerLoader: this.handlerLoader })
167
+ )
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Store multiple procedures.
173
+ *
174
+ * @param procedures - Procedures to store
175
+ * @param options - Override default serialization options, or a function that gives the
176
+ * options of each procedure (for example, its handler reference)
177
+ * @returns The records that the storage got
178
+ */
179
+ async storeAll(
180
+ procedures: AnyProcedure[],
181
+ options?: SerializeOptions | ((procedure: AnyProcedure) => SerializeOptions | undefined)
182
+ ): Promise<SerializedProcedure[]> {
183
+ const items: Array<[string, SerializedProcedure]> = procedures.map((proc) => [
184
+ getProcedureKey(proc),
185
+ serializeProcedure(proc, {
186
+ ...this.serializeOptions,
187
+ ...(typeof options === "function" ? options(proc) : options),
188
+ }),
189
+ ]);
190
+ await this.storage.setBatch(items);
191
+ return items.map(([, serialized]) => serialized);
192
+ }
193
+
194
+ /**
195
+ * Remove multiple procedures.
196
+ *
197
+ * @param paths - Procedure paths to remove
198
+ * @returns Number of procedures removed
199
+ */
200
+ async removeAll(paths: ProcedurePath[]): Promise<number> {
201
+ const keys = paths.map(pathToKey);
202
+ return this.storage.deleteBatch(keys);
203
+ }
204
+
205
+ /**
206
+ * Clear all procedures from storage.
207
+ */
208
+ async clear(): Promise<void> {
209
+ await this.storage.clear();
210
+ }
211
+
212
+ // ===========================================================================
213
+ // Info Operations
214
+ // ===========================================================================
215
+
216
+ /**
217
+ * Get the number of stored procedures.
218
+ */
219
+ async size(): Promise<number> {
220
+ return this.storage.size();
221
+ }
222
+
223
+ /**
224
+ * List all stored procedure paths.
225
+ *
226
+ * @returns Array of procedure paths
227
+ */
228
+ async listPaths(): Promise<ProcedurePath[]> {
229
+ const items = await this.storage.getAll();
230
+ return items.map((item) => item.path);
231
+ }
232
+
233
+ /**
234
+ * Get the raw serialized data for a procedure.
235
+ * Useful for debugging or custom processing.
236
+ *
237
+ * @param path - Procedure path
238
+ * @returns Serialized procedure data or undefined
239
+ */
240
+ async getRaw(path: ProcedurePath): Promise<SerializedProcedure | undefined> {
241
+ const key = pathToKey(path);
242
+ return this.storage.get(key);
243
+ }
244
+
245
+ /**
246
+ * Get all raw serialized data.
247
+ * Useful for debugging or backup.
248
+ *
249
+ * @returns Array of serialized procedures
250
+ */
251
+ async getAllRaw(): Promise<SerializedProcedure[]> {
252
+ return this.storage.getAll();
253
+ }
254
+
255
+ // ===========================================================================
256
+ // Lifecycle
257
+ // ===========================================================================
258
+
259
+ /**
260
+ * Close the underlying storage.
261
+ */
262
+ async close(): Promise<void> {
263
+ await this.storage.close();
264
+ }
265
+
266
+ /**
267
+ * Get the underlying storage instance.
268
+ * Use for advanced operations or testing.
269
+ */
270
+ getStorage(): CollectionStorage<SerializedProcedure> {
271
+ return this.storage;
272
+ }
273
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * API Storage Implementation
3
+ *
4
+ * Remote storage backed by universal client.
5
+ * Enables collections to be persisted on a server.
6
+ *
7
+ * This is the KEY integration between Collections and Universal Client!
8
+ */
9
+
10
+ import type { CollectionStorage, StorageMetadata } from "@procedures/collections";
11
+ import type { Client } from "@procedures/client";
12
+ import type { Method } from "@procedures/client";
13
+ import type { CallOptions } from "@procedures/client/client";
14
+
15
+ /**
16
+ * API storage configuration options.
17
+ */
18
+ export interface ApiStorageOptions {
19
+ /**
20
+ * The collection of the core collection procedures. The calls go to
21
+ * `collections.<collection>.<operation>`, as `createCollectionProcedures(collection)` registers them.
22
+ * @example "users", "procedures"
23
+ */
24
+ collection?: string;
25
+
26
+ /**
27
+ * Service name for RPC calls. The default is `collections.<collection>`. Give a service only
28
+ * for a server with other paths.
29
+ * @example "users", "orders", "products"
30
+ */
31
+ service?: string;
32
+
33
+ /**
34
+ * Optional API version
35
+ * @example "v1", "v2"
36
+ */
37
+ version?: string;
38
+
39
+ /**
40
+ * Custom operation names (override defaults)
41
+ */
42
+ operations?: {
43
+ get?: string;
44
+ getAll?: string;
45
+ find?: string;
46
+ has?: string;
47
+ size?: string;
48
+ set?: string;
49
+ delete?: string;
50
+ clear?: string;
51
+ setBatch?: string;
52
+ deleteBatch?: string;
53
+ getBatch?: string;
54
+ };
55
+
56
+ /**
57
+ * Request timeout in milliseconds. A call that takes longer fails. 0: no timeout.
58
+ * @default 30000 (30 seconds)
59
+ */
60
+ timeout?: number;
61
+
62
+ /**
63
+ * Retry a call that fails with a retryable error: true (3 attempts), false (1 attempt), or the
64
+ * number of attempts.
65
+ * @default true
66
+ */
67
+ retry?: boolean | number;
68
+
69
+ /**
70
+ * The first delay between attempts in milliseconds. Each later delay is two times longer.
71
+ * @default 100
72
+ */
73
+ retryDelay?: number;
74
+
75
+ /**
76
+ * AbortSignal for cancelling requests
77
+ */
78
+ signal?: AbortSignal;
79
+
80
+ /**
81
+ * True: `close()` closes the client. The default is false: the client belongs to the caller.
82
+ * @default false
83
+ */
84
+ closeClient?: boolean;
85
+ }
86
+
87
+ /** An error that says whether another attempt can succeed (a ClientError does). */
88
+ function isRetryable(error: unknown): boolean {
89
+ return (
90
+ typeof error === "object" &&
91
+ error !== null &&
92
+ (error as { retryable?: unknown }).retryable === true
93
+ );
94
+ }
95
+
96
+ /** A promise that rejects when the signal aborts first. */
97
+ function untilAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined, describe: () => Error): Promise<T> {
98
+ if (!signal) return promise;
99
+ if (signal.aborted) return Promise.reject(describe());
100
+ return new Promise<T>((resolve, reject) => {
101
+ const onAbort = (): void => reject(describe());
102
+ signal.addEventListener("abort", onAbort, { once: true });
103
+ promise.then(
104
+ (value) => {
105
+ signal.removeEventListener("abort", onAbort);
106
+ resolve(value);
107
+ },
108
+ (error: unknown) => {
109
+ signal.removeEventListener("abort", onAbort);
110
+ reject(error);
111
+ }
112
+ );
113
+ });
114
+ }
115
+
116
+ /**
117
+ * API storage backed by universal client.
118
+ *
119
+ * Maps collection operations to the core collection procedures
120
+ * (`createCollectionProcedures`), which take and return raw values:
121
+ * - get(id) → `collections.<collection>.get` with `{ id }`
122
+ * - set(id, value) → `collections.<collection>.set` with `{ id, value }`
123
+ * - setBatch(items) → `collections.<collection>.setBatch` with `{ items: [{ id, value }] }`
124
+ * - etc.
125
+ *
126
+ * Before, the class expected a `{ data }` envelope and called `<service>.get`: no core
127
+ * procedure matched, and every read was undefined (deep dive CORE-9, DATA-9).
128
+ *
129
+ * @example
130
+ * ```typescript
131
+ * // Create API storage
132
+ * const client = new Client({
133
+ * transport: new HttpTransport({ baseUrl: "https://api.example.com" })
134
+ * });
135
+ *
136
+ * const storage = new ApiStorage(client, { collection: "users" });
137
+ *
138
+ * // Operations are automatically sent to server
139
+ * await storage.set("123", { name: "John" });
140
+ * const user = await storage.get("123"); // Fetched from server
141
+ * ```
142
+ */
143
+ export class ApiStorage<T> implements CollectionStorage<T> {
144
+ private client: Client;
145
+ private options: {
146
+ service: string;
147
+ version?: string;
148
+ operations: Required<NonNullable<ApiStorageOptions["operations"]>>;
149
+ timeout: number;
150
+ attempts: number;
151
+ retryDelay: number;
152
+ signal?: AbortSignal;
153
+ closeClient: boolean;
154
+ };
155
+
156
+ constructor(client: Client, options: ApiStorageOptions) {
157
+ const service = options.service ?? (options.collection !== undefined ? `collections.${options.collection}` : undefined);
158
+ if (!service) {
159
+ throw new Error("ApiStorage requires a collection or a service");
160
+ }
161
+ this.client = client;
162
+ this.options = {
163
+ service,
164
+ ...(options.version !== undefined && { version: options.version }),
165
+ operations: {
166
+ get: "get",
167
+ getAll: "getAll",
168
+ find: "find",
169
+ has: "has",
170
+ size: "size",
171
+ set: "set",
172
+ delete: "delete",
173
+ clear: "clear",
174
+ setBatch: "setBatch",
175
+ deleteBatch: "deleteBatch",
176
+ getBatch: "getBatch",
177
+ ...options.operations,
178
+ },
179
+ timeout: options.timeout ?? 30000,
180
+ attempts:
181
+ options.retry === false ? 1 : options.retry === true || options.retry === undefined ? 3 : Math.max(1, options.retry),
182
+ retryDelay: options.retryDelay ?? 100,
183
+ ...(options.signal !== undefined && { signal: options.signal }),
184
+ closeClient: options.closeClient ?? false,
185
+ };
186
+ }
187
+
188
+ //
189
+ // ═══ Read Operations ═══
190
+ //
191
+
192
+ async get(id: string): Promise<T | undefined> {
193
+ const value = await this.call<T | null | undefined>("get", { id });
194
+ return value ?? undefined;
195
+ }
196
+
197
+ async getAll(): Promise<T[]> {
198
+ const values = await this.call<T[]>("getAll", {});
199
+ if (!Array.isArray(values)) {
200
+ throw new Error(`${this.options.service}.getAll did not return an array`);
201
+ }
202
+ return values;
203
+ }
204
+
205
+ async find(predicate: (item: T) => boolean): Promise<T[]> {
206
+ // A predicate cannot go over the network: read all items, then filter here
207
+ const all = await this.getAll();
208
+ return all.filter(predicate);
209
+ }
210
+
211
+ async has(id: string): Promise<boolean> {
212
+ return (await this.call<boolean>("has", { id })) === true;
213
+ }
214
+
215
+ async size(): Promise<number> {
216
+ return Number(await this.call<number>("size", {}));
217
+ }
218
+
219
+ //
220
+ // ═══ Write Operations ═══
221
+ //
222
+
223
+ async set(id: string, value: T): Promise<void> {
224
+ await this.call("set", { id, value });
225
+ }
226
+
227
+ async delete(id: string): Promise<boolean> {
228
+ return (await this.call<boolean>("delete", { id })) === true;
229
+ }
230
+
231
+ async clear(): Promise<void> {
232
+ await this.call("clear", {});
233
+ }
234
+
235
+ //
236
+ // ═══ Bulk Operations ═══
237
+ //
238
+
239
+ async setBatch(items: Array<[string, T]>): Promise<void> {
240
+ // The collection procedure takes objects, not tuples
241
+ await this.call("setBatch", { items: items.map(([id, value]) => ({ id, value })) });
242
+ }
243
+
244
+ async deleteBatch(ids: string[]): Promise<number> {
245
+ return Number(await this.call<number>("deleteBatch", { ids }));
246
+ }
247
+
248
+ async getBatch(ids: string[]): Promise<Map<string, T>> {
249
+ const values = (await this.call<Record<string, T> | null>("getBatch", { ids })) ?? {};
250
+
251
+ // Convert object to Map
252
+ const result = new Map<string, T>();
253
+ for (const [id, value] of Object.entries(values)) {
254
+ result.set(id, value);
255
+ }
256
+ return result;
257
+ }
258
+
259
+ //
260
+ // ═══ Lifecycle & Metadata ═══
261
+ //
262
+
263
+ async close(): Promise<void> {
264
+ // The client belongs to the caller, unless the caller gave it to this storage
265
+ if (this.options.closeClient) {
266
+ await this.client.close();
267
+ }
268
+ }
269
+
270
+ async getMetadata(): Promise<StorageMetadata> {
271
+ const storageSize = await this.size();
272
+
273
+ return {
274
+ type: "api",
275
+ size: storageSize,
276
+ stats: {
277
+ service: this.options.service,
278
+ version: this.options.version,
279
+ timeout: this.options.timeout,
280
+ },
281
+ };
282
+ }
283
+
284
+ //
285
+ // ═══ Internal Helpers ═══
286
+ //
287
+
288
+ /**
289
+ * Make an RPC call via the universal client, with the timeout, the signal and the retries.
290
+ */
291
+ private async call<TRes>(operation: keyof Required<ApiStorageOptions>["operations"], payload: unknown): Promise<TRes> {
292
+ const method: Method = {
293
+ service: this.options.service,
294
+ operation: this.options.operations[operation] ?? operation,
295
+ ...(this.options.version !== undefined && { version: this.options.version }),
296
+ };
297
+ const label = `${method.service}.${method.operation}`;
298
+
299
+ for (let attempt = 1; ; attempt++) {
300
+ try {
301
+ return await this.attempt<TRes>(method, payload, label);
302
+ } catch (error) {
303
+ if (attempt >= this.options.attempts || !isRetryable(error) || this.options.signal?.aborted) {
304
+ throw error;
305
+ }
306
+ const delay = this.options.retryDelay * 2 ** (attempt - 1);
307
+ await untilAborted(new Promise((resolve) => setTimeout(resolve, delay)), this.options.signal, () => error as Error);
308
+ }
309
+ }
310
+ }
311
+
312
+ /** One attempt: the call ends at the timeout, or when the signal of the options aborts. */
313
+ private attempt<TRes>(method: Method, payload: unknown, label: string): Promise<TRes> {
314
+ const signals: AbortSignal[] = [];
315
+ if (this.options.signal) signals.push(this.options.signal);
316
+ const timer = this.options.timeout > 0 ? new AbortController() : undefined;
317
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
318
+ if (timer) {
319
+ timeoutHandle = setTimeout(() => timer.abort(), this.options.timeout);
320
+ signals.push(timer.signal);
321
+ }
322
+ const signal = signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals);
323
+
324
+ // The signal goes in the call options, so the transport can stop the request too. (Before,
325
+ // it went into the metadata, and the timeout was dropped: deep dive CORE-9.)
326
+ const callOptions: CallOptions<unknown> = {};
327
+ if (signal) callOptions.signal = signal;
328
+ const call = this.client.call<unknown, TRes>(method, payload, callOptions);
329
+ return untilAborted(call, signal, () =>
330
+ timer?.signal.aborted ? new Error(`${label} timed out after ${this.options.timeout} ms`) : new Error(`${label} was aborted`)
331
+ ).finally(() => {
332
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
333
+ });
334
+ }
335
+ }