euclid-ndk 0.2.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 (127) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +603 -0
  3. package/dist/auth/index.d.ts +18 -0
  4. package/dist/auth/index.d.ts.map +1 -0
  5. package/dist/auth/index.js +17 -0
  6. package/dist/auth/index.js.map +1 -0
  7. package/dist/auth/rfc9421.d.ts +112 -0
  8. package/dist/auth/rfc9421.d.ts.map +1 -0
  9. package/dist/auth/rfc9421.js +452 -0
  10. package/dist/auth/rfc9421.js.map +1 -0
  11. package/dist/auth/scheme.d.ts +46 -0
  12. package/dist/auth/scheme.d.ts.map +1 -0
  13. package/dist/auth/scheme.js +44 -0
  14. package/dist/auth/scheme.js.map +1 -0
  15. package/dist/auth/signable.d.ts +37 -0
  16. package/dist/auth/signable.d.ts.map +1 -0
  17. package/dist/auth/signable.js +70 -0
  18. package/dist/auth/signable.js.map +1 -0
  19. package/dist/auth/sigv4.d.ts +72 -0
  20. package/dist/auth/sigv4.d.ts.map +1 -0
  21. package/dist/auth/sigv4.js +227 -0
  22. package/dist/auth/sigv4.js.map +1 -0
  23. package/dist/credentials.d.ts +56 -0
  24. package/dist/credentials.d.ts.map +1 -0
  25. package/dist/credentials.js +135 -0
  26. package/dist/credentials.js.map +1 -0
  27. package/dist/dto/com.d.ts +101 -0
  28. package/dist/dto/com.d.ts.map +1 -0
  29. package/dist/dto/com.js +132 -0
  30. package/dist/dto/com.js.map +1 -0
  31. package/dist/dto/eag.d.ts +104 -0
  32. package/dist/dto/eag.d.ts.map +1 -0
  33. package/dist/dto/eag.js +70 -0
  34. package/dist/dto/eag.js.map +1 -0
  35. package/dist/dto/eam.d.ts +105 -0
  36. package/dist/dto/eam.d.ts.map +1 -0
  37. package/dist/dto/eam.js +98 -0
  38. package/dist/dto/eam.js.map +1 -0
  39. package/dist/dto/eap.d.ts +88 -0
  40. package/dist/dto/eap.d.ts.map +1 -0
  41. package/dist/dto/eap.js +54 -0
  42. package/dist/dto/eap.js.map +1 -0
  43. package/dist/dto/ekm.d.ts +109 -0
  44. package/dist/dto/ekm.d.ts.map +1 -0
  45. package/dist/dto/ekm.js +78 -0
  46. package/dist/dto/ekm.js.map +1 -0
  47. package/dist/dto/ekv.d.ts +73 -0
  48. package/dist/dto/ekv.d.ts.map +1 -0
  49. package/dist/dto/ekv.js +45 -0
  50. package/dist/dto/ekv.js.map +1 -0
  51. package/dist/dto/ens.d.ts +84 -0
  52. package/dist/dto/ens.d.ts.map +1 -0
  53. package/dist/dto/ens.js +72 -0
  54. package/dist/dto/ens.js.map +1 -0
  55. package/dist/dto/eqs.d.ts +160 -0
  56. package/dist/dto/eqs.d.ts.map +1 -0
  57. package/dist/dto/eqs.js +120 -0
  58. package/dist/dto/eqs.js.map +1 -0
  59. package/dist/dto/esm.d.ts +192 -0
  60. package/dist/dto/esm.d.ts.map +1 -0
  61. package/dist/dto/esm.js +147 -0
  62. package/dist/dto/esm.js.map +1 -0
  63. package/dist/dto/ess.d.ts +49 -0
  64. package/dist/dto/ess.d.ts.map +1 -0
  65. package/dist/dto/ess.js +30 -0
  66. package/dist/dto/ess.js.map +1 -0
  67. package/dist/dto/json.d.ts +24 -0
  68. package/dist/dto/json.d.ts.map +1 -0
  69. package/dist/dto/json.js +47 -0
  70. package/dist/dto/json.js.map +1 -0
  71. package/dist/errors.d.ts +40 -0
  72. package/dist/errors.d.ts.map +1 -0
  73. package/dist/errors.js +73 -0
  74. package/dist/errors.js.map +1 -0
  75. package/dist/http/client.d.ts +96 -0
  76. package/dist/http/client.d.ts.map +1 -0
  77. package/dist/http/client.js +258 -0
  78. package/dist/http/client.js.map +1 -0
  79. package/dist/index.d.ts +93 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +110 -0
  82. package/dist/index.js.map +1 -0
  83. package/dist/modules/base.d.ts +121 -0
  84. package/dist/modules/base.d.ts.map +1 -0
  85. package/dist/modules/base.js +142 -0
  86. package/dist/modules/base.js.map +1 -0
  87. package/dist/modules/eag.d.ts +166 -0
  88. package/dist/modules/eag.d.ts.map +1 -0
  89. package/dist/modules/eag.js +189 -0
  90. package/dist/modules/eag.js.map +1 -0
  91. package/dist/modules/eam.d.ts +314 -0
  92. package/dist/modules/eam.d.ts.map +1 -0
  93. package/dist/modules/eam.js +641 -0
  94. package/dist/modules/eam.js.map +1 -0
  95. package/dist/modules/eap.d.ts +204 -0
  96. package/dist/modules/eap.d.ts.map +1 -0
  97. package/dist/modules/eap.js +218 -0
  98. package/dist/modules/eap.js.map +1 -0
  99. package/dist/modules/ekm.d.ts +182 -0
  100. package/dist/modules/ekm.d.ts.map +1 -0
  101. package/dist/modules/ekm.js +228 -0
  102. package/dist/modules/ekm.js.map +1 -0
  103. package/dist/modules/ekv.d.ts +189 -0
  104. package/dist/modules/ekv.d.ts.map +1 -0
  105. package/dist/modules/ekv.js +212 -0
  106. package/dist/modules/ekv.js.map +1 -0
  107. package/dist/modules/ens.d.ts +122 -0
  108. package/dist/modules/ens.d.ts.map +1 -0
  109. package/dist/modules/ens.js +167 -0
  110. package/dist/modules/ens.js.map +1 -0
  111. package/dist/modules/eqs.d.ts +283 -0
  112. package/dist/modules/eqs.d.ts.map +1 -0
  113. package/dist/modules/eqs.js +355 -0
  114. package/dist/modules/eqs.js.map +1 -0
  115. package/dist/modules/esm.d.ts +358 -0
  116. package/dist/modules/esm.d.ts.map +1 -0
  117. package/dist/modules/esm.js +660 -0
  118. package/dist/modules/esm.js.map +1 -0
  119. package/dist/modules/ess.d.ts +96 -0
  120. package/dist/modules/ess.d.ts.map +1 -0
  121. package/dist/modules/ess.js +110 -0
  122. package/dist/modules/ess.js.map +1 -0
  123. package/dist/url.d.ts +31 -0
  124. package/dist/url.d.ts.map +1 -0
  125. package/dist/url.js +47 -0
  126. package/dist/url.js.map +1 -0
  127. package/package.json +47 -0
@@ -0,0 +1,660 @@
1
+ /**
2
+ * ESM - euclid's storage module: buckets, objects, attributes, subscriptions and transfers.
3
+ *
4
+ * One object, {@link EuclidEsm}, built from a session that has already logged in:
5
+ *
6
+ * ```ts
7
+ * const esm = (await Euclid.forServer(url).login("jens", "secret")).esm();
8
+ * const bucket = await esm.createBucket("reports");
9
+ * await esm.uploadFile(bucket.ern, "2026/q3.pdf", "q3.pdf");
10
+ * ```
11
+ *
12
+ * Most of what it does is the same JSON action every other module speaks. Four actions are not:
13
+ * `put-object`, `get-object`, `upload-part` and `download-part` carry the object's bytes themselves,
14
+ * with the bucket, the key and the part number riding as headers instead of in a body - which is what
15
+ * keeps a 5 MiB part 5 MiB on the wire rather than a third larger as base64 inside JSON.
16
+ *
17
+ * Those four also authenticate differently: they present the session's bearer token rather than a
18
+ * signature, which is what euclid-cli, euclid-jdk and euclid-pdk do for the same four actions, so
19
+ * every client writes objects the same way. A session that asked for `AUTH_SIGNATURE` signs
20
+ * them anyway - it asked not to be handed a token silently, and a signature over raw bytes is exact
21
+ * here in a way it is not in every language.
22
+ */
23
+ import { mkdir, open, writeFile } from "node:fs/promises";
24
+ import { dirname } from "node:path";
25
+ import { QUEUE, TOPIC, toSubscribeResult, toSubscription, toVariantMap, variantMapToJson, variantOf, variantToJson, } from "../dto/com.js";
26
+ import { toBucket, toBucketEvent, toCreateBucketResult, toCreateDownloadResult, toCreateUploadResult, toDeleteObjectsResult, toDisableEncryptionResult, toEnableEncryptionResult, toEsmObject, toObjectAttribute, toPurgeBucketResult, toRenameBucketResult, toSetBucketInternalResult, toStoredObject, toTouchObjectResult, } from "../dto/esm.js";
27
+ import { toPage } from "../dto/eam.js";
28
+ import { EuclidServiceError } from "../errors.js";
29
+ import { listPayload, ModuleClient } from "./base.js";
30
+ export const TARGET = "esm";
31
+ /** The object events a subscription can ask for. Asking for none asks for all of them. */
32
+ export const OBJECT_CREATED = "esm.object.created";
33
+ export const OBJECT_UPDATED = "esm.object.updated";
34
+ export const OBJECT_DELETED = "esm.object.deleted";
35
+ /**
36
+ * How much of a file goes into one part. Larger parts mean fewer round trips and more memory in
37
+ * flight; 5 MiB is what euclid-cli, euclid-jdk and euclid-pdk use, which is what makes a file
38
+ * uploaded by one of them arrive in the same pieces as one uploaded by another.
39
+ */
40
+ export const DEFAULT_PART_SIZE = 5 * 1024 * 1024;
41
+ /** How many parts travel at once. */
42
+ export const DEFAULT_CONCURRENCY = 4;
43
+ /**
44
+ * How many attempts one step of a transfer gets. Transfers are long and made of many steps, so a
45
+ * transient failure in any of them would otherwise throw away everything already transferred.
46
+ */
47
+ export const MAX_PART_ATTEMPTS = 4;
48
+ /** The delay before retrying, in milliseconds, multiplied by the attempt number. */
49
+ export const PART_RETRY_BASE_DELAY_MS = 500;
50
+ /**
51
+ * What `get-object` answers when the object is at or above the size the caller said it would accept.
52
+ * Not an error in {@link EuclidEsm.downloadFile}: it is the server saying the object needs the
53
+ * multipart path.
54
+ */
55
+ export const PAYLOAD_TOO_LARGE = 413;
56
+ /** The actions that carry raw bytes rather than JSON - see this module's documentation. */
57
+ export const BYTE_ACTIONS = ["put-object", "get-object", "upload-part", "download-part"];
58
+ /**
59
+ * The notification a bucket subscription delivered, out of the message that carried it.
60
+ *
61
+ * A subscription puts its notification into a queue or a topic as an ordinary message, so nothing
62
+ * about receiving it is special - whatever reads that queue hands the message body to this.
63
+ *
64
+ * @throws {SyntaxError} if the body is not JSON at all.
65
+ */
66
+ export function parseBucketEvent(messageBody) {
67
+ return toBucketEvent(JSON.parse(typeof messageBody === "string" ? messageBody : messageBody.toString("utf8")));
68
+ }
69
+ /**
70
+ * ESM's operations, on the credentials of the session that created it.
71
+ *
72
+ * Built by {@link EuclidSession.esm} rather than directly, so that it shares that session's identity,
73
+ * namespace and connection settings - and follows them as they change.
74
+ */
75
+ export class EuclidEsm extends ModuleClient {
76
+ /**
77
+ * How long the byte-carrying actions may take, in milliseconds, or null for the session's own
78
+ * timeout. Worth raising: the session's default is sized for an action that answers from a
79
+ * database, and a 5 MiB part on a slow link is not that.
80
+ */
81
+ transferTimeoutMs = null;
82
+ /**
83
+ * The delay before one step of a transfer is tried again, multiplied by the attempt number.
84
+ *
85
+ * A field rather than a constant because it is the one thing about a retry a caller may reasonably
86
+ * want to change: nothing in a test suite wants to wait out a backoff that exists to be kind to a
87
+ * struggling server.
88
+ */
89
+ retryBaseDelayMs = PART_RETRY_BASE_DELAY_MS;
90
+ constructor(session) {
91
+ super(session, { target: TARGET, byteActions: BYTE_ACTIONS });
92
+ }
93
+ /**
94
+ * {@link parseBucketEvent}, reachable from the client so that the call that reads a subscription's
95
+ * messages is found next to the call that created the subscription.
96
+ */
97
+ static parseBucketEvent = parseBucketEvent;
98
+ // -- buckets ---------------------------------------------------------------------------------
99
+ /**
100
+ * Creates a bucket, and answers with the ERN everything else names it by.
101
+ *
102
+ * `internal` marks it as euclid's own plumbing rather than somebody's bucket, which leaves it out of
103
+ * an ordinary listing - see {@link setBucketInternal}, which is how a bucket that already exists
104
+ * changes its mind about that.
105
+ */
106
+ async createBucket(name, internal = false) {
107
+ return toCreateBucketResult(await this.call("create-bucket", { name, internal }));
108
+ }
109
+ /** Deletes a bucket. It has to be empty; {@link purgeBucket} is what makes it so. */
110
+ async deleteBucket(ern) {
111
+ await this.call("delete-bucket", { ern });
112
+ }
113
+ /**
114
+ * One page of buckets, and how many exist in total.
115
+ *
116
+ * euclid's own buckets are left out unless `includeInternal` asks for them, so a listing shows what
117
+ * a person would recognise rather than the artifact bucket applications are deployed from.
118
+ */
119
+ async listBuckets(options = {}) {
120
+ const payload = { ...listPayload(options, "name"), includeInternal: options.includeInternal ?? false };
121
+ return toPage(await this.call("list-buckets", payload), "buckets", toBucket);
122
+ }
123
+ /** The ERN of the bucket of this name, in the session's account and namespace. */
124
+ async getBucketErn(name) {
125
+ return this.textOf("get-bucket-ern", { name }, "ern");
126
+ }
127
+ /** How many bytes a bucket holds. */
128
+ async getBucketSize(ern) {
129
+ return this.numberOf("get-bucket-size", { ern }, "size");
130
+ }
131
+ /**
132
+ * Renames a bucket, and with it every object and subscription that named the old one.
133
+ *
134
+ * The ERN changes too, and nothing answers to the old one afterwards, so the one in the result is
135
+ * what later calls have to use. Refused rather than merged when a bucket of the new name exists.
136
+ */
137
+ async renameBucket(ern, newName) {
138
+ return toRenameBucketResult(await this.call("rename-bucket", { ern, newName }));
139
+ }
140
+ /**
141
+ * Marks a bucket as euclid's own plumbing, or stops doing so.
142
+ *
143
+ * Separate from creating one because the bucket this exists for usually predates anybody thinking
144
+ * about it, and reversible for the same reason: a flag that can only be set is one nobody dares set.
145
+ */
146
+ async setBucketInternal(ern, internal = true) {
147
+ return toSetBucketInternalResult(await this.call("set-bucket-internal", { ern, internal }));
148
+ }
149
+ /**
150
+ * Deletes a bucket's objects, leaving the bucket itself in place.
151
+ *
152
+ * A prefix narrows it to the keys that start with that; an empty one purges everything.
153
+ */
154
+ async purgeBucket(ern, prefix = "") {
155
+ return toPurgeBucketResult(await this.call("purge-bucket", { ern, prefix }));
156
+ }
157
+ /**
158
+ * Encrypts every object written to this bucket from now on, under an EKM key.
159
+ *
160
+ * What it does not do is touch the objects already there: their bytes stay as they were stored, each
161
+ * one records the key it is under, and the result says how many such objects there are.
162
+ * Re-encrypting them is a decision for whoever owns the data.
163
+ *
164
+ * A named key has to exist and be usable for encryption. An unnamed one is created here as AES-256
165
+ * and belongs to EKM from that moment on - which means deleting it there is what makes this
166
+ * bucket's objects unrecoverable.
167
+ */
168
+ async enableEncryption(bucketErn, keyId = "") {
169
+ return toEnableEncryptionResult(await this.call("enable-encryption", { bucketErn, keyId }));
170
+ }
171
+ /**
172
+ * Stops encrypting new objects written to a bucket.
173
+ *
174
+ * The mirror image of {@link enableEncryption} in one respect and no other: it says what happens to
175
+ * the next upload, and it is not an undo. Nothing already stored is decrypted or rewritten, and the
176
+ * key is left alone rather than revoked - those objects are still under it.
177
+ */
178
+ async disableEncryption(bucketErn) {
179
+ return toDisableEncryptionResult(await this.call("disable-encryption", { bucketErn }));
180
+ }
181
+ /** Tags a bucket. A key that is already tagged keeps its value - {@link setBucketTag} overwrites. */
182
+ async addBucketTag(bucketErn, key, value) {
183
+ await this.call("add-bucket-tag", { ern: bucketErn, key, value });
184
+ }
185
+ /** Tags a bucket, overwriting any value the key already had. */
186
+ async setBucketTag(bucketErn, key, value) {
187
+ await this.call("set-bucket-tag", { ern: bucketErn, key, value });
188
+ }
189
+ /** Removes a tag from a bucket. */
190
+ async deleteBucketTag(bucketErn, key) {
191
+ await this.call("delete-bucket-tag", { ern: bucketErn, key });
192
+ }
193
+ // -- objects ---------------------------------------------------------------------------------
194
+ /**
195
+ * One page of a bucket's objects, and how many it holds in total.
196
+ *
197
+ * Keys are opaque strings, so a bucket only has "directories" in the sense that keys share a prefix;
198
+ * the markers for them are left out unless `includeDirectories` asks for them.
199
+ */
200
+ async listObjects(bucketErn, options = {}) {
201
+ const payload = {
202
+ bucketErn,
203
+ ...listPayload(options, "name"),
204
+ includeDirectories: options.includeDirectories ?? false,
205
+ };
206
+ return toPage(await this.call("list-objects", payload), "objects", toEsmObject);
207
+ }
208
+ /**
209
+ * How many objects a bucket holds. Cheaper than listing them when only the number matters - the
210
+ * server counts rather than paging every object back to the caller.
211
+ */
212
+ async getObjectCount(bucketErn, prefix = "") {
213
+ return this.numberOf("get-object-count", { ern: bucketErn, prefix }, "count");
214
+ }
215
+ /** Deletes one object, by its own ERN. */
216
+ async deleteObject(ern) {
217
+ await this.call("delete-object", { ern });
218
+ }
219
+ /**
220
+ * Deletes several named objects from a bucket in one call.
221
+ *
222
+ * A key that names no object is not an error, so the result reports both how many keys were asked
223
+ * for and how many objects went. Deleting everything under a prefix is {@link purgeBucket} rather
224
+ * than a variant of this - the server refuses keys and a prefix in the same request, since answering
225
+ * both would delete more than either.
226
+ *
227
+ * `background` has the server answer as soon as it has taken the work on rather than when it has
228
+ * finished, in which case the count is what it took on.
229
+ */
230
+ async deleteObjects(bucketErn, keys, background = false) {
231
+ const payload = { ern: bucketErn, keys: [...keys], async: background };
232
+ return toDeleteObjectsResult(await this.call("delete-objects", payload));
233
+ }
234
+ /**
235
+ * Copies an object, leaving the source in place.
236
+ *
237
+ * The copy gets its own bytes on disk and its own ERN, so the two are independent from here on. Both
238
+ * ends are permission-checked, and an existing object at the target is refused with HTTP 409 rather
239
+ * than silently replaced.
240
+ */
241
+ async copyObject(sourceBucketErn, sourceKey, targetBucketErn, targetKey) {
242
+ return this.#transferObject("copy-object", sourceBucketErn, sourceKey, targetBucketErn, targetKey);
243
+ }
244
+ /**
245
+ * Moves an object to another bucket or key, removing the source.
246
+ *
247
+ * The bytes are not copied - the same file answers to a different key from now on - so this costs
248
+ * the same whatever the object's size. Refuses an existing target exactly as {@link copyObject} does.
249
+ */
250
+ async moveObject(sourceBucketErn, sourceKey, targetBucketErn, targetKey) {
251
+ return this.#transferObject("move-object", sourceBucketErn, sourceKey, targetBucketErn, targetKey);
252
+ }
253
+ /**
254
+ * Renames an object within its bucket - a {@link moveObject} that cannot leave it, which is the
255
+ * whole difference between the two.
256
+ */
257
+ async renameObject(bucketErn, key, newKey) {
258
+ return toEsmObject(await this.call("rename-object", { bucketErn, key, newKey }));
259
+ }
260
+ /**
261
+ * Re-announces objects already in a bucket, so a listener that missed their creation events hears
262
+ * about them now.
263
+ *
264
+ * Nothing about the objects changes - not a byte, not their modified time. "Touch" here means what
265
+ * it does to listeners, not what it does to storage: a timestamp is something consumers compare
266
+ * against, and moving it would make this destructive in exactly the way it is trying not to be.
267
+ *
268
+ * `background` is what a bucket of any size wants: the announcement is per object, and holding a
269
+ * request open for all of them is a request that times out.
270
+ */
271
+ async touchObject(bucketErn, options = {}) {
272
+ const payload = { ern: bucketErn, prefix: options.prefix ?? "", async: options.background ?? false };
273
+ return toTouchObjectResult(await this.call("touch-object", payload));
274
+ }
275
+ /** copy-object and move-object take the same request and differ only in whether the source survives. */
276
+ async #transferObject(action, sourceBucketErn, sourceKey, targetBucketErn, targetKey) {
277
+ return toEsmObject(await this.call(action, { sourceBucketErn, sourceKey, targetBucketErn, targetKey }));
278
+ }
279
+ // -- object attributes -----------------------------------------------------------------------
280
+ /**
281
+ * Adds a user-defined attribute to an object. One of that name already there keeps its value -
282
+ * {@link setObjectAttribute} overwrites.
283
+ *
284
+ * The value is a {@link Variant}, or a plain value to be tagged as one - see
285
+ * {@link import("../dto/com.js").variantOf}.
286
+ */
287
+ async addObjectAttribute(ern, name, value) {
288
+ return this.#objectAttribute("add-object-attribute", ern, name, value);
289
+ }
290
+ /** Sets a user-defined attribute on an object, overwriting any value it already had. */
291
+ async setObjectAttribute(ern, name, value) {
292
+ return this.#objectAttribute("set-object-attribute", ern, name, value);
293
+ }
294
+ /** Every user-defined attribute of an object, keyed by name. */
295
+ async listObjectAttributes(ern) {
296
+ const response = await this.call("list-object-attributes", { ern });
297
+ return toVariantMap(response["attributes"]);
298
+ }
299
+ /** Deletes one user-defined attribute from an object. */
300
+ async deleteObjectAttribute(ern, name) {
301
+ await this.call("delete-object-attribute", { ern, name });
302
+ }
303
+ async #objectAttribute(action, ern, name, value) {
304
+ return toObjectAttribute(await this.call(action, { ern, name, value: variantToJson(variantOf(value)) }));
305
+ }
306
+ // -- subscriptions ---------------------------------------------------------------------------
307
+ /**
308
+ * Announces a bucket's object events to a queue or a topic from now on.
309
+ *
310
+ * What lands there is a {@link BucketEvent}, carried as the body of an ordinary message - see
311
+ * {@link parseBucketEvent}. The filters are applied by the server as it publishes, so a subscription
312
+ * only ever delivers what it asked for rather than the target receiving everything and discarding
313
+ * most of it.
314
+ *
315
+ * Not idempotent: a second call registers a second subscription and the target then receives every
316
+ * matching event twice, so a caller that may run twice checks {@link listSubscriptions} first.
317
+ *
318
+ * @param bucketErn bucket resource name
319
+ * @param targetType {@link QUEUE} or {@link TOPIC}, which is also what decides how a bare target
320
+ * name is resolved.
321
+ * @param targetErn target resource name
322
+ * @param options call options
323
+ */
324
+ async subscribe(bucketErn, targetType, targetErn, options = {}) {
325
+ return toSubscribeResult(await this.call("subscribe", {
326
+ sourceErn: bucketErn,
327
+ type: targetType,
328
+ targetErn,
329
+ eventTypes: [...(options.eventTypes ?? [])],
330
+ prefix: options.prefix ?? "",
331
+ directories: options.directories ?? false,
332
+ }));
333
+ }
334
+ /**
335
+ * Removes a subscription, by the ERN {@link subscribe} answered with - not the bucket's, and not the
336
+ * target's.
337
+ */
338
+ async unsubscribe(ern) {
339
+ await this.call("unsubscribe", { ern });
340
+ }
341
+ /** Every subscription currently registered on a bucket. */
342
+ async listSubscriptions(bucketErn) {
343
+ const response = await this.call("list-subscriptions", { bucketErn });
344
+ const subscriptions = response["subscriptions"];
345
+ return Array.isArray(subscriptions) ? subscriptions.map(toSubscription) : [];
346
+ }
347
+ // -- objects, in bytes -----------------------------------------------------------------------
348
+ /**
349
+ * Uploads an object in a single request, skipping the multipart sequence entirely.
350
+ *
351
+ * A string is stored as its UTF-8 bytes, which is what the object then is; anything that matters
352
+ * about the encoding is the caller's to decide before calling this.
353
+ *
354
+ * Takes two attribute maps, which are not the same one - see {@link AttributeOptions}.
355
+ */
356
+ async putObject(bucketErn, key, data, options = {}) {
357
+ const headers = {
358
+ "x-euclid-bucket-ern": bucketErn,
359
+ "x-euclid-key": key,
360
+ ...attributeHeaders(options),
361
+ };
362
+ // Answers with JSON even though the request carried bytes: the same payload complete-upload
363
+ // answers with, so a caller that needs the ERN does not have to take the multipart path.
364
+ const response = await this.postBytes("put-object", data, headers);
365
+ return toStoredObject(this.result("put-object", response));
366
+ }
367
+ /**
368
+ * Downloads an object's bytes in a single request.
369
+ *
370
+ * The size limit is the server's to enforce rather than this client's: a download's size is not
371
+ * known until the server is asked, unlike an upload's, so the caller declares how large a response
372
+ * it is willing to take and an object at or above that comes back as HTTP 413.
373
+ * {@link downloadFile} uses exactly that to decide whether an object needs the multipart path.
374
+ */
375
+ async getObject(bucketErn, key, maxInlineSize = DEFAULT_PART_SIZE) {
376
+ const response = await this.#getObject(bucketErn, key, maxInlineSize);
377
+ if (!response.ok)
378
+ throw new EuclidServiceError(TARGET, "get-object", response.status, response.text);
379
+ return response.content;
380
+ }
381
+ /**
382
+ * Uploads a local file in parts, several at a time.
383
+ *
384
+ * The file is read a part at a time rather than into memory, and no more than `concurrency` parts
385
+ * are ever in flight, so the memory this costs is bounded by the two together whatever the file's
386
+ * size. An empty file is one empty part, so that the object exists.
387
+ *
388
+ * Attributes belong on the upload rather than added afterwards: completing an upload is finished off
389
+ * in the background, and the object row written at the end carries what this call supplied - an
390
+ * attribute added between here and there is overwritten and silently lost.
391
+ *
392
+ * @throws {EuclidServiceError} if a part or one of the calls bracketing them failed for good.
393
+ * @throws {Error} if `partSize` is less than a byte, which the server rejects too.
394
+ */
395
+ async uploadFile(bucketErn, key, file, options = {}) {
396
+ const partSize = options.partSize ?? DEFAULT_PART_SIZE;
397
+ checkPartSize(partSize);
398
+ const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
399
+ // Opened before anything is sent, so a path that is not there costs no round trip at all.
400
+ const source = await open(file, "r");
401
+ try {
402
+ const { size } = await source.stat();
403
+ const upload = await this.#createUpload(bucketErn, key, concurrency);
404
+ // An empty file is one empty part rather than none, so that the object exists afterwards.
405
+ const parts = Math.max(1, Math.ceil(size / partSize));
406
+ await runBounded(parts, concurrency, async (index) => {
407
+ const offset = index * partSize;
408
+ const data = await readPart(source, offset, Math.min(partSize, Math.max(0, size - offset)));
409
+ await this.#uploadPart(upload.uploadId, index + 1, data);
410
+ });
411
+ return this.#completeUpload(upload.uploadId, options);
412
+ }
413
+ finally {
414
+ await source.close();
415
+ }
416
+ }
417
+ /**
418
+ * Downloads an object to a local file, fetching its parts several at a time.
419
+ *
420
+ * An object that fits in one part skips multipart entirely. Unlike an upload - whose source this
421
+ * client has already stat'ed - a download's size is not known before asking, so the single-request
422
+ * path is tried first and HTTP 413 is what says the object was too large for it.
423
+ *
424
+ * Missing parent directories are created. Answers with the number of bytes written.
425
+ *
426
+ * @throws {Error} if `partSize` is less than a byte, which the server rejects too.
427
+ */
428
+ async downloadFile(bucketErn, key, file, options = {}) {
429
+ const partSize = options.partSize ?? DEFAULT_PART_SIZE;
430
+ checkPartSize(partSize);
431
+ const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
432
+ const inline = await this.#getObject(bucketErn, key, partSize);
433
+ if (inline.status !== PAYLOAD_TOO_LARGE) {
434
+ if (!inline.ok)
435
+ throw new EuclidServiceError(TARGET, "get-object", inline.status, inline.text);
436
+ await mkdir(dirname(file), { recursive: true });
437
+ await writeFile(file, inline.content);
438
+ return inline.content.length;
439
+ }
440
+ const download = await this.#createDownload(bucketErn, key, concurrency);
441
+ await mkdir(dirname(file), { recursive: true });
442
+ const sink = await open(file, "w");
443
+ try {
444
+ // Sized up front so that each part can be written at its own offset whatever order the parts
445
+ // arrive in - the download's answer to uploadFile() reading its source in order while letting
446
+ // the parts themselves complete out of order.
447
+ await sink.truncate(download.size);
448
+ const parts = Math.ceil(download.size / partSize);
449
+ await runBounded(parts, concurrency, async (index) => {
450
+ const part = await this.#downloadPart(download.downloadId, index + 1, partSize);
451
+ if (part.length > 0)
452
+ await sink.write(part, 0, part.length, index * partSize);
453
+ });
454
+ }
455
+ finally {
456
+ await sink.close();
457
+ }
458
+ await this.#completeDownload(download.downloadId);
459
+ return download.size;
460
+ }
461
+ // -- monitoring ------------------------------------------------------------------------------
462
+ /**
463
+ * ESM's own metrics, as the server collects them. Answered unparsed - the shape belongs to the
464
+ * monitoring module rather than to ESM.
465
+ */
466
+ async metrics() {
467
+ return this.call("get-metrics");
468
+ }
469
+ // -- the multipart sequence ------------------------------------------------------------------
470
+ /**
471
+ * Opens a multipart upload, declaring the concurrency it is about to use so that the gateway's
472
+ * autoscaler can ramp storage instances toward it rather than discover the load.
473
+ *
474
+ * Retried on 5xx: the object row the server seeds is keyed on the bucket and key, so a second
475
+ * attempt updates the same row, and the only cost of a repeat is the scratch directory the abandoned
476
+ * upload ID left behind.
477
+ */
478
+ async #createUpload(bucketErn, key, concurrency) {
479
+ return toCreateUploadResult(await this.#callWithRetry("create-upload", { bucketErn, key }, concurrencyHeader(concurrency)));
480
+ }
481
+ async #uploadPart(uploadId, number, data) {
482
+ await this.#withRetry("upload-part", () => this.postBytes("upload-part", data, {
483
+ "x-euclid-upload-id": uploadId,
484
+ "x-euclid-part-number": String(number),
485
+ }));
486
+ }
487
+ /**
488
+ * Assembles the parts into the object.
489
+ *
490
+ * The attributes ride on this request because the background pass that finishes the upload builds
491
+ * the object row from what this call was given. Retried on 5xx like the create: failing here
492
+ * discards every part already uploaded, and an upload the server did accept fails a retry with 404
493
+ * rather than being assembled twice.
494
+ */
495
+ async #completeUpload(uploadId, options) {
496
+ return toStoredObject(await this.#callWithRetry("complete-upload", { uploadId }, attributeHeaders(options)));
497
+ }
498
+ /**
499
+ * Opens a multipart download, which stages the object and says how large it is.
500
+ *
501
+ * Retried on 5xx: the session it opens is scratch state keyed by a fresh download ID, so a retried
502
+ * attempt starts a new one and the abandoned session is simply never used.
503
+ */
504
+ async #createDownload(bucketErn, key, concurrency) {
505
+ return toCreateDownloadResult(await this.#callWithRetry("create-download", { bucketErn, key }, concurrencyHeader(concurrency)));
506
+ }
507
+ async #downloadPart(downloadId, number, partSize) {
508
+ const response = await this.#withRetry("download-part", () => this.postBytes("download-part", Buffer.alloc(0), {
509
+ "x-euclid-download-id": downloadId,
510
+ "x-euclid-part-number": String(number),
511
+ "x-euclid-part-size": String(partSize),
512
+ }));
513
+ return response.content;
514
+ }
515
+ /**
516
+ * Releases the download's server-side scratch state. Retried on 5xx for the same reason completing
517
+ * an upload is: failing here throws away every part already fetched.
518
+ */
519
+ async #completeDownload(downloadId) {
520
+ await this.#callWithRetry("complete-download", { downloadId }, {});
521
+ }
522
+ /** The raw response, so a caller can tell an object that was too large from one that failed. */
523
+ async #getObject(bucketErn, key, maxInlineSize) {
524
+ return this.postBytes("get-object", Buffer.alloc(0), {
525
+ "x-euclid-bucket-ern": bucketErn,
526
+ "x-euclid-key": key,
527
+ "x-euclid-part-size": String(maxInlineSize),
528
+ });
529
+ }
530
+ // -- transport -------------------------------------------------------------------------------
531
+ /**
532
+ * One of the JSON actions bracketing a transfer, retried the way the parts between them are.
533
+ *
534
+ * They run once per transfer rather than once per part, but giving up on a transient failure in one
535
+ * of them discards the whole file, which is what makes them worth the same treatment.
536
+ */
537
+ async #callWithRetry(action, payload, headers) {
538
+ const response = await this.#withRetry(action, () => this.post(action, payload, headers));
539
+ return this.result(action, response);
540
+ }
541
+ /**
542
+ * Sends one step of a transfer, retrying while it looks transient.
543
+ *
544
+ * A 4xx means the request itself is wrong and a repeat would be answered identically, so only a 5xx
545
+ * and a request that never got an answer are tried again. An error thrown here is the transport
546
+ * failing rather than the server refusing - a refusal arrives as a status.
547
+ */
548
+ async #withRetry(action, send) {
549
+ for (let attempt = 1;; attempt += 1) {
550
+ const last = attempt === MAX_PART_ATTEMPTS;
551
+ let response = null;
552
+ try {
553
+ response = await send();
554
+ }
555
+ catch (error) {
556
+ if (last)
557
+ throw error;
558
+ }
559
+ if (response !== null && (response.status < 500 || last)) {
560
+ if (!response.ok)
561
+ throw new EuclidServiceError(TARGET, action, response.status, response.text);
562
+ return response;
563
+ }
564
+ await delay(this.retryBaseDelayMs * attempt);
565
+ }
566
+ }
567
+ /**
568
+ * The byte-carrying actions, on this client's transfer timeout rather than the session's - a 5 MiB
569
+ * part on a slow link is not an action that answers from a database.
570
+ */
571
+ async postBytes(action, data, headers = {}, timeoutMs) {
572
+ return super.postBytes(action, data, headers, timeoutMs ?? this.transferTimeoutMs ?? undefined);
573
+ }
574
+ }
575
+ /**
576
+ * The two attribute maps, as the headers that carry them.
577
+ *
578
+ * Headers rather than body fields because the actions that take them are the ones whose body is either
579
+ * the object's bytes or nothing at all. An empty map is left out entirely, so a request that has
580
+ * nothing to say about attributes says nothing.
581
+ */
582
+ function attributeHeaders(options) {
583
+ const headers = {};
584
+ if (options.attributes !== undefined && Object.keys(options.attributes).length > 0) {
585
+ headers["x-euclid-attributes"] = JSON.stringify(variantMapToJson(options.attributes));
586
+ }
587
+ if (options.systemAttributes !== undefined && Object.keys(options.systemAttributes).length > 0) {
588
+ headers["x-euclid-system-attributes"] = JSON.stringify(variantMapToJson(options.systemAttributes));
589
+ }
590
+ return headers;
591
+ }
592
+ /** What a transfer declares up front, so the gateway can ramp toward it rather than discover it. */
593
+ function concurrencyHeader(concurrency) {
594
+ return { "x-euclid-expected-concurrency": String(concurrency) };
595
+ }
596
+ /**
597
+ * Refused here rather than on arrival: a part size of zero would otherwise upload a file as one empty
598
+ * part and call it stored, which is a corrupt object rather than an error.
599
+ */
600
+ function checkPartSize(partSize) {
601
+ if (!Number.isInteger(partSize) || partSize < 1)
602
+ throw new Error("partSize must be at least 1 byte");
603
+ }
604
+ /**
605
+ * One part of a file, read at its own offset.
606
+ *
607
+ * Positioned reads rather than a shared file position, so the parts can be read in whatever order the
608
+ * pool gets to them. A short read is read again rather than treated as the end of the file: a read may
609
+ * answer with fewer bytes than it was asked for, and a part shorter than it should be is a corrupt
610
+ * object at the other end.
611
+ */
612
+ async function readPart(source, offset, length) {
613
+ const part = Buffer.alloc(length);
614
+ let filled = 0;
615
+ while (filled < length) {
616
+ const { bytesRead } = await source.read(part, filled, length - filled, offset + filled);
617
+ if (bytesRead === 0)
618
+ return part.subarray(0, filled);
619
+ filled += bytesRead;
620
+ }
621
+ return part;
622
+ }
623
+ /**
624
+ * Runs `task` for every index below `count`, no more than `concurrency` of them outstanding.
625
+ *
626
+ * Workers pull the next index as they finish one rather than the work being handed out in advance, so
627
+ * a part that takes longer than the rest does not leave a worker idle - and the memory in flight is
628
+ * bounded by the concurrency rather than by the number of parts.
629
+ *
630
+ * The first failure is what the caller sees, thrown after the pool has drained: a part still in flight
631
+ * when another one failed is one the server is already writing, and abandoning it would not stop that.
632
+ */
633
+ async function runBounded(count, concurrency, task) {
634
+ let next = 0;
635
+ // An array rather than one slot, so that "has anything failed yet" stays readable from inside the
636
+ // workers; only the first one is ever thrown.
637
+ const failures = [];
638
+ const worker = async () => {
639
+ while (failures.length === 0) {
640
+ const index = next;
641
+ next += 1;
642
+ if (index >= count)
643
+ return;
644
+ try {
645
+ await task(index);
646
+ }
647
+ catch (error) {
648
+ failures.push(error);
649
+ return;
650
+ }
651
+ }
652
+ };
653
+ await Promise.all(Array.from({ length: Math.min(concurrency, count) }, worker));
654
+ if (failures.length > 0)
655
+ throw failures[0];
656
+ }
657
+ function delay(milliseconds) {
658
+ return milliseconds > 0 ? new Promise((resolve) => setTimeout(resolve, milliseconds)) : Promise.resolve();
659
+ }
660
+ //# sourceMappingURL=esm.js.map