@mdbase-dev/connect 0.1.0-beta.100

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 (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +696 -0
  3. package/dist/advanced.d.ts +9 -0
  4. package/dist/advanced.js +6 -0
  5. package/dist/application-contract.d.ts +22 -0
  6. package/dist/application-contract.js +75 -0
  7. package/dist/application-identity.d.ts +34 -0
  8. package/dist/application-identity.js +148 -0
  9. package/dist/application-session.d.ts +167 -0
  10. package/dist/application-session.js +722 -0
  11. package/dist/async.d.ts +1 -0
  12. package/dist/async.js +19 -0
  13. package/dist/authority-proof.d.ts +3 -0
  14. package/dist/authority-proof.js +24 -0
  15. package/dist/authority-sync-request.d.ts +3 -0
  16. package/dist/authority-sync-request.js +20 -0
  17. package/dist/authorization-refresh.d.ts +14 -0
  18. package/dist/authorization-refresh.js +41 -0
  19. package/dist/authorization-types.d.ts +7 -0
  20. package/dist/authorization-types.js +1 -0
  21. package/dist/authorization-url.d.ts +5 -0
  22. package/dist/authorization-url.js +34 -0
  23. package/dist/base64.d.ts +3 -0
  24. package/dist/base64.js +21 -0
  25. package/dist/browser/integrity.json +4 -0
  26. package/dist/browser/mdbase-connect.min.js +3 -0
  27. package/dist/browser/mdbase-connect.min.js.sha384 +1 -0
  28. package/dist/browser.d.ts +3 -0
  29. package/dist/browser.js +5 -0
  30. package/dist/capabilities.d.ts +24 -0
  31. package/dist/capabilities.js +91 -0
  32. package/dist/collection-client.d.ts +66 -0
  33. package/dist/collection-client.js +686 -0
  34. package/dist/connect-authorization-helpers.d.ts +4 -0
  35. package/dist/connect-authorization-helpers.js +25 -0
  36. package/dist/connect-options.d.ts +35 -0
  37. package/dist/connect-options.js +1 -0
  38. package/dist/connect.d.ts +66 -0
  39. package/dist/connect.js +711 -0
  40. package/dist/connection-file-transport.d.ts +34 -0
  41. package/dist/connection-file-transport.js +85 -0
  42. package/dist/connection-index.d.ts +3 -0
  43. package/dist/connection-index.js +20 -0
  44. package/dist/connection-notifications.d.ts +31 -0
  45. package/dist/connection-notifications.js +197 -0
  46. package/dist/connection-transport-internals.d.ts +24 -0
  47. package/dist/connection-transport-internals.js +1 -0
  48. package/dist/connection-transport.d.ts +64 -0
  49. package/dist/connection-transport.js +725 -0
  50. package/dist/connection-types.d.ts +38 -0
  51. package/dist/connection-types.js +1 -0
  52. package/dist/connection.d.ts +175 -0
  53. package/dist/connection.js +606 -0
  54. package/dist/crypto-entry.d.ts +7 -0
  55. package/dist/crypto-entry.js +4 -0
  56. package/dist/crypto.d.ts +63 -0
  57. package/dist/crypto.js +409 -0
  58. package/dist/direct-access.d.ts +3 -0
  59. package/dist/direct-access.js +29 -0
  60. package/dist/errors.d.ts +45 -0
  61. package/dist/errors.js +142 -0
  62. package/dist/external-store.d.ts +10 -0
  63. package/dist/external-store.js +10 -0
  64. package/dist/file-crypto.d.ts +25 -0
  65. package/dist/file-crypto.js +132 -0
  66. package/dist/file-sha256.d.ts +12 -0
  67. package/dist/file-sha256.js +143 -0
  68. package/dist/file-stream-source.d.ts +17 -0
  69. package/dist/file-stream-source.js +157 -0
  70. package/dist/file-transfer-internals.d.ts +23 -0
  71. package/dist/file-transfer-internals.js +228 -0
  72. package/dist/files.d.ts +115 -0
  73. package/dist/files.js +563 -0
  74. package/dist/fresh-encrypted-request.d.ts +4 -0
  75. package/dist/fresh-encrypted-request.js +28 -0
  76. package/dist/grant-key-leases.d.ts +24 -0
  77. package/dist/grant-key-leases.js +144 -0
  78. package/dist/hosted-file-request.d.ts +6 -0
  79. package/dist/hosted-file-request.js +63 -0
  80. package/dist/hosted-operation-response.d.ts +5 -0
  81. package/dist/hosted-operation-response.js +26 -0
  82. package/dist/index.d.ts +27 -0
  83. package/dist/index.js +10 -0
  84. package/dist/internal-types.d.ts +100 -0
  85. package/dist/internal-types.js +1 -0
  86. package/dist/local-file-transport.d.ts +37 -0
  87. package/dist/local-file-transport.js +457 -0
  88. package/dist/mdbase-connect.d.ts +28 -0
  89. package/dist/mdbase-connect.js +53 -0
  90. package/dist/notifications.d.ts +48 -0
  91. package/dist/notifications.js +52 -0
  92. package/dist/operation-helpers.d.ts +27 -0
  93. package/dist/operation-helpers.js +144 -0
  94. package/dist/operation-types.d.ts +707 -0
  95. package/dist/operation-types.js +1 -0
  96. package/dist/outcomes.d.ts +51 -0
  97. package/dist/outcomes.js +194 -0
  98. package/dist/pending-mutation-store.d.ts +13 -0
  99. package/dist/pending-mutation-store.js +64 -0
  100. package/dist/pending-recovery-transports.d.ts +5 -0
  101. package/dist/pending-recovery-transports.js +52 -0
  102. package/dist/popup-authorization.d.ts +31 -0
  103. package/dist/popup-authorization.js +100 -0
  104. package/dist/query-pagination.d.ts +6 -0
  105. package/dist/query-pagination.js +119 -0
  106. package/dist/request-budget.d.ts +34 -0
  107. package/dist/request-budget.js +104 -0
  108. package/dist/request-coordinator.d.ts +29 -0
  109. package/dist/request-coordinator.js +213 -0
  110. package/dist/retired-grant-keys.d.ts +9 -0
  111. package/dist/retired-grant-keys.js +56 -0
  112. package/dist/runtime-utils.d.ts +52 -0
  113. package/dist/runtime-utils.js +368 -0
  114. package/dist/saved-view-pagination.d.ts +6 -0
  115. package/dist/saved-view-pagination.js +76 -0
  116. package/dist/selection.d.ts +48 -0
  117. package/dist/selection.js +112 -0
  118. package/dist/session.d.ts +114 -0
  119. package/dist/session.js +569 -0
  120. package/dist/shared-operation.d.ts +7 -0
  121. package/dist/shared-operation.js +41 -0
  122. package/dist/stored-token.d.ts +10 -0
  123. package/dist/stored-token.js +64 -0
  124. package/dist/structured-readiness.d.ts +77 -0
  125. package/dist/structured-readiness.js +126 -0
  126. package/dist/token-response.d.ts +12 -0
  127. package/dist/token-response.js +81 -0
  128. package/dist/transient-retry.d.ts +22 -0
  129. package/dist/transient-retry.js +59 -0
  130. package/dist/watch-policy.d.ts +8 -0
  131. package/dist/watch-policy.js +12 -0
  132. package/package.json +51 -0
package/dist/files.js ADDED
@@ -0,0 +1,563 @@
1
+ import { FILE_PROTOCOL_VERSION } from "@mdbase-dev/connect-protocol";
2
+ import { abortableDelay } from "./async.js";
3
+ import { MdbaseConnectError, connectError } from "./errors.js";
4
+ import { IncrementalSha256 } from "./file-sha256.js";
5
+ import { BinaryPartReader } from "./file-stream-source.js";
6
+ import { MAX_BUFFERED_DOWNLOAD_BYTES, MAX_OBJECT_ATTEMPTS, SHA256_DIGEST, UUID, browserObjectHeaders, chunkLength, hashBlob, mapConcurrent, normalizeFileError, requirePreparedPart, requireTransferSession, requireTransferStatus, retryChunk, sourceBlob, throwIfAborted, uploadBodyBytes, uploadBodyLength, validConcurrency, validPageSize } from "./file-transfer-internals.js";
7
+ import { createRequestBudget, resolveConnectTimeouts, withCooperativeRequestBudget } from "./request-budget.js";
8
+ /** Ergonomic facade over resumable, authority-specific file delivery. */
9
+ export class MdbaseFileClient {
10
+ capability;
11
+ request;
12
+ framed;
13
+ hosted;
14
+ timeouts;
15
+ constructor(capability, request, framed, hosted, timeouts = resolveConnectTimeouts()) {
16
+ this.capability = capability;
17
+ this.request = request;
18
+ this.framed = framed;
19
+ this.hosted = hosted;
20
+ this.timeouts = timeouts;
21
+ }
22
+ async *list(options = {}) {
23
+ const budget = createRequestBudget(options, this.timeouts.fileIndexMs);
24
+ const signal = budget.signal;
25
+ try {
26
+ this.requireAction("list");
27
+ let after;
28
+ do {
29
+ throwIfAborted(signal);
30
+ const query = new URLSearchParams({
31
+ protocol_version: String(FILE_PROTOCOL_VERSION),
32
+ ...(options.folder ? { folder: options.folder } : {}),
33
+ ...(after ? { after } : {}),
34
+ ...(options.pageSize ? { limit: String(validPageSize(options.pageSize)) } : {})
35
+ });
36
+ let page;
37
+ while (true) {
38
+ try {
39
+ page = await this.request("GET", `?${query.toString()}`, undefined, signal);
40
+ break;
41
+ }
42
+ catch (error) {
43
+ const normalized = normalizeFileError(error);
44
+ if (normalized.code !== "file_index_warming")
45
+ throw normalized;
46
+ options.onIndexing?.();
47
+ await abortableDelay(500, signal);
48
+ }
49
+ }
50
+ if (page.protocol_version !== 1 || page.type !== "files_page" || !Array.isArray(page.files)) {
51
+ throw connectError("invalid_operation_response", "The authority returned an invalid file page.");
52
+ }
53
+ for (const file of page.files) {
54
+ throwIfAborted(signal);
55
+ yield clientFileDescriptor(file);
56
+ }
57
+ after = page.next;
58
+ } while (after);
59
+ }
60
+ finally {
61
+ budget.dispose();
62
+ }
63
+ }
64
+ async upload(path, source, options = {}) {
65
+ return withCooperativeRequestBudget(options, this.timeouts.uploadMs, (budget) => this.uploadWithinBudget(path, source, { ...options, signal: budget.signal, timeoutMs: null }));
66
+ }
67
+ async uploadWithinBudget(path, source, options) {
68
+ this.requireAction(options.ifRevision ? "replace" : "add");
69
+ const blob = sourceBlob(source, options.mediaType);
70
+ const concurrency = validConcurrency(options.concurrency);
71
+ const digest = await hashBlob(blob, options.signal, (transferredBytes) => {
72
+ options.onProgress?.({
73
+ phase: "hashing",
74
+ transferredBytes,
75
+ totalBytes: blob.size
76
+ });
77
+ });
78
+ return this.uploadKnownSource(path, blob.size, `sha256:${digest}`, (options.mediaType ?? blob.type) || undefined, options, concurrency, async (_partIndex, offset, length) => blob.slice(offset, offset + length));
79
+ }
80
+ /**
81
+ * Upload a one-shot byte stream without accumulating multiple file parts.
82
+ * SDK memory is bounded by one assembled negotiated part plus at most one
83
+ * source chunk; a single-part upload may therefore hold the complete file.
84
+ * Reuse `transferId` with a newly opened source after an ambiguous failure.
85
+ */
86
+ async uploadStream(path, source, options = {}) {
87
+ return withCooperativeRequestBudget(options, this.timeouts.uploadMs, (budget) => this.uploadStreamWithinBudget(path, source, {
88
+ ...options,
89
+ signal: budget.signal,
90
+ timeoutMs: null
91
+ }));
92
+ }
93
+ async uploadStreamWithinBudget(path, source, options) {
94
+ this.requireAction(options.ifRevision ? "replace" : "add");
95
+ if (!Number.isSafeInteger(source.size) || source.size < 0) {
96
+ throw connectError("invalid_request", "Streamed file size must be a non-negative safe integer.");
97
+ }
98
+ if (!SHA256_DIGEST.test(source.contentDigest)) {
99
+ throw connectError("invalid_request", "Streamed files require a lowercase SHA-256 content digest.");
100
+ }
101
+ const reader = new BinaryPartReader(source.stream, options.signal);
102
+ const hash = new IncrementalSha256();
103
+ return this.uploadKnownSource(path, source.size, source.contentDigest, options.mediaType ?? source.mediaType, options, 1, async (_partIndex, _offset, length) => {
104
+ const bytes = await reader.read(length);
105
+ hash.update(bytes);
106
+ return bytes;
107
+ }, async () => {
108
+ await reader.expectEnd();
109
+ if (`sha256:${hash.digestHex()}` !== source.contentDigest) {
110
+ throw connectError("invalid_request", "Streamed file bytes do not match the declared SHA-256 digest.");
111
+ }
112
+ }, () => reader.close(), (partSize) => reader.setMaxSourceChunkBytes(partSize));
113
+ }
114
+ async uploadKnownSource(path, size, contentDigest, mediaType, options, concurrency, readPart, finishSource, closeSource, prepareSource) {
115
+ const transferId = options.transferId ?? crypto.randomUUID();
116
+ if (!UUID.test(transferId)) {
117
+ throw connectError("invalid_request", "File transfer ID must be a UUID.");
118
+ }
119
+ let committed = false;
120
+ try {
121
+ const session = await this.request("POST", "uploads", {
122
+ protocol_version: FILE_PROTOCOL_VERSION,
123
+ type: "open_file_upload",
124
+ transfer_id: transferId,
125
+ path,
126
+ size,
127
+ content_digest: contentDigest,
128
+ ...(mediaType ? { media_type: mediaType } : {}),
129
+ ...(options.ifRevision ? { if_revision: options.ifRevision } : {})
130
+ }, options.signal);
131
+ requireTransferSession(session, transferId, "upload");
132
+ if (session.total_size !== size || session.strategy.kind === "object_ranges") {
133
+ throw connectError("invalid_operation_response", "The authority returned an incompatible upload strategy.");
134
+ }
135
+ const framed = session.strategy.kind === "framed_chunks";
136
+ if (framed && !this.framed) {
137
+ throw connectError("invalid_operation_response", "Encrypted file chunk delivery is unavailable.");
138
+ }
139
+ const partSize = session.strategy.kind === "object_put"
140
+ ? Math.max(1, size)
141
+ : session.strategy.kind === "framed_chunks"
142
+ ? session.strategy.chunk_size
143
+ : session.strategy.part_size;
144
+ const partCount = session.strategy.kind === "object_put"
145
+ ? 1
146
+ : Math.ceil(size / partSize);
147
+ const status = await this.request("GET", `transfers/${encodeURIComponent(transferId)}`, undefined, options.signal);
148
+ requireTransferStatus(status, session);
149
+ if (status.state !== "open" && status.state !== "committed") {
150
+ throw connectError("invalid_operation_response", `The authority cannot resume a ${status.state} file transfer.`);
151
+ }
152
+ prepareSource?.(partSize);
153
+ if (!framed && status.received.length === partCount) {
154
+ const replay = await this.tryReplayUploadCommit(transferId, session.strategy.kind === "object_multipart" ? status.uploaded_parts : undefined, options.signal);
155
+ if (replay) {
156
+ committed = true;
157
+ return clientFileDescriptor(replay.file);
158
+ }
159
+ }
160
+ const received = new Set(status.received);
161
+ const uploadedParts = new Map(status.uploaded_parts.map((part) => [part.part_number - 1, part]));
162
+ let transferredBytes = [...received].reduce((total, index) => total + chunkLength(size, partSize, index), 0);
163
+ const parts = await mapConcurrent(partCount, concurrency, async (partIndex) => {
164
+ const offset = partIndex * partSize;
165
+ const length = Math.min(partSize, Math.max(0, size - offset));
166
+ const part = await readPart(partIndex, offset, length);
167
+ if (uploadBodyLength(part) !== length) {
168
+ throw connectError("invalid_request", "Streamed file bytes ended before the declared size.");
169
+ }
170
+ if (received.has(partIndex))
171
+ return uploadedParts.get(partIndex) ?? null;
172
+ const uploaded = framed
173
+ ? await retryChunk(async () => this.framed.uploadChunk(session, partIndex, await uploadBodyBytes(part), options.signal), options.signal).then(() => null)
174
+ : await this.uploadPart(transferId, partIndex, offset, part, length, session.strategy.kind === "object_multipart", options.signal);
175
+ transferredBytes += length;
176
+ options.onProgress?.({
177
+ phase: "uploading",
178
+ transferredBytes,
179
+ totalBytes: size
180
+ });
181
+ return uploaded;
182
+ });
183
+ await finishSource?.();
184
+ const receipt = await this.commitUpload(transferId, session.strategy.kind === "object_multipart"
185
+ ? parts.filter((part) => part !== null)
186
+ : undefined, options.signal);
187
+ if (receipt.protocol_version !== 1
188
+ || receipt.type !== "file_upload_committed"
189
+ || receipt.transfer_id !== transferId
190
+ || receipt.file.path !== path
191
+ || receipt.file.size !== size
192
+ || receipt.file.content_digest !== contentDigest) {
193
+ throw connectError("invalid_operation_response", "The authority returned an invalid file receipt.");
194
+ }
195
+ committed = true;
196
+ return clientFileDescriptor(receipt.file);
197
+ }
198
+ catch (error) {
199
+ throw normalizeFileError(error);
200
+ }
201
+ finally {
202
+ await closeSource?.().catch(() => undefined);
203
+ if (!committed && options.transferId === undefined)
204
+ await this.abort(transferId);
205
+ }
206
+ }
207
+ async download(file, options = {}) {
208
+ if (file.size > MAX_BUFFERED_DOWNLOAD_BYTES) {
209
+ throw connectError("invalid_request", `download() buffers at most ${MAX_BUFFERED_DOWNLOAD_BYTES} bytes; use downloadStream() for larger files.`);
210
+ }
211
+ const stream = await this.downloadStream(file, options);
212
+ const chunks = [];
213
+ const reader = stream.getReader();
214
+ while (true) {
215
+ const result = await reader.read();
216
+ if (result.done)
217
+ break;
218
+ chunks.push(result.value);
219
+ }
220
+ return new Blob(chunks.map((chunk) => {
221
+ const copy = new Uint8Array(chunk.byteLength);
222
+ copy.set(chunk);
223
+ return copy.buffer;
224
+ }), { type: file.mediaType ?? "" });
225
+ }
226
+ /** Download and verify a file with network backpressure and bounded buffering. */
227
+ async downloadStream(file, options = {}) {
228
+ return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.openDownloadStream(file, options, budget.signal));
229
+ }
230
+ async openDownloadStream(file, options, startupSignal) {
231
+ this.requireAction("read");
232
+ const concurrency = validConcurrency(options.concurrency);
233
+ const transferId = crypto.randomUUID();
234
+ let session;
235
+ try {
236
+ session = await this.request("POST", "downloads", {
237
+ protocol_version: FILE_PROTOCOL_VERSION,
238
+ type: "open_file_download",
239
+ transfer_id: transferId,
240
+ file_id: file.fileId,
241
+ revision: file.revision
242
+ }, startupSignal);
243
+ requireTransferSession(session, transferId, "download");
244
+ if (session.total_size !== file.size
245
+ || session.strategy.kind !== "object_ranges"
246
+ && session.strategy.kind !== "framed_chunks") {
247
+ throw connectError("invalid_operation_response", "The authority returned an incompatible download session.");
248
+ }
249
+ if (session.strategy.kind === "framed_chunks" && !this.framed) {
250
+ throw connectError("invalid_operation_response", "Encrypted file chunk delivery is unavailable.");
251
+ }
252
+ if (session.strategy.kind === "object_ranges" && file.size > 0 && !this.hosted) {
253
+ throw connectError("invalid_operation_response", "Authenticated hosted file delivery is unavailable.");
254
+ }
255
+ }
256
+ catch (error) {
257
+ await this.abort(transferId);
258
+ throw normalizeFileError(error);
259
+ }
260
+ const partSize = session.strategy.kind === "framed_chunks"
261
+ ? session.strategy.chunk_size
262
+ : session.strategy.part_size;
263
+ const partCount = Math.ceil(file.size / partSize);
264
+ const hash = new IncrementalSha256();
265
+ let partIndex = 0;
266
+ let partRemaining = 0;
267
+ let hostedReader = null;
268
+ let receivedBytes = 0;
269
+ let closed = false;
270
+ const deliveryController = new AbortController();
271
+ const deliverySignal = options.signal
272
+ ? AbortSignal.any([options.signal, deliveryController.signal])
273
+ : deliveryController.signal;
274
+ const framedChunks = new Map();
275
+ let nextFramedPart = 0;
276
+ const queueFramedChunks = () => {
277
+ if (session.strategy.kind !== "framed_chunks")
278
+ return;
279
+ while (nextFramedPart < partCount && framedChunks.size < concurrency) {
280
+ const index = nextFramedPart;
281
+ nextFramedPart += 1;
282
+ const pending = retryChunk(() => this.framed.downloadChunk(session, index, deliverySignal), deliverySignal).then((chunk) => ({ ok: true, chunk }), (error) => ({ ok: false, error }));
283
+ framedChunks.set(index, pending);
284
+ }
285
+ };
286
+ const finish = async () => {
287
+ if (closed)
288
+ return;
289
+ closed = true;
290
+ deliveryController.abort();
291
+ await hostedReader?.cancel().catch(() => undefined);
292
+ hostedReader = null;
293
+ await Promise.all(framedChunks.values());
294
+ framedChunks.clear();
295
+ await this.abort(transferId);
296
+ };
297
+ const verify = () => {
298
+ if (receivedBytes !== file.size || `sha256:${hash.digestHex()}` !== file.contentDigest) {
299
+ throw connectError("invalid_operation_response", "Downloaded file bytes failed integrity verification.");
300
+ }
301
+ };
302
+ return new ReadableStream({
303
+ pull: async (controller) => {
304
+ try {
305
+ throwIfAborted(deliverySignal);
306
+ if (session.strategy.kind === "object_ranges") {
307
+ while (true) {
308
+ if (!hostedReader) {
309
+ if (partIndex >= partCount) {
310
+ verify();
311
+ controller.close();
312
+ void finish();
313
+ return;
314
+ }
315
+ const offset = partIndex * partSize;
316
+ partRemaining = Math.min(partSize, file.size - offset);
317
+ const body = await retryChunk(() => this.hosted.downloadPart(session, partIndex, partRemaining, deliverySignal), deliverySignal);
318
+ hostedReader = body.getReader();
319
+ }
320
+ const next = await hostedReader.read();
321
+ if (next.done) {
322
+ hostedReader.releaseLock();
323
+ hostedReader = null;
324
+ if (partRemaining !== 0) {
325
+ throw connectError("invalid_operation_response", "A hosted file range ended before its declared byte length.");
326
+ }
327
+ partIndex += 1;
328
+ continue;
329
+ }
330
+ if (!(next.value instanceof Uint8Array)) {
331
+ throw connectError("invalid_operation_response", "A hosted file range returned a non-byte chunk.");
332
+ }
333
+ if (next.value.byteLength === 0)
334
+ continue;
335
+ if (next.value.byteLength > partRemaining) {
336
+ throw connectError("invalid_operation_response", "A hosted file range exceeded its declared byte length.");
337
+ }
338
+ partRemaining -= next.value.byteLength;
339
+ hash.update(next.value);
340
+ receivedBytes += next.value.byteLength;
341
+ options.onProgress?.({
342
+ phase: "downloading",
343
+ transferredBytes: receivedBytes,
344
+ totalBytes: file.size
345
+ });
346
+ controller.enqueue(next.value);
347
+ return;
348
+ }
349
+ }
350
+ if (partIndex >= partCount) {
351
+ verify();
352
+ controller.close();
353
+ void finish();
354
+ return;
355
+ }
356
+ const offset = partIndex * partSize;
357
+ const length = Math.min(partSize, file.size - offset);
358
+ queueFramedChunks();
359
+ const pending = framedChunks.get(partIndex);
360
+ if (!pending) {
361
+ throw connectError("invalid_operation_response", "A file chunk was not scheduled.");
362
+ }
363
+ const result = await pending;
364
+ framedChunks.delete(partIndex);
365
+ if (!result.ok)
366
+ throw result.error;
367
+ const chunk = result.chunk;
368
+ if (chunk.byteLength !== length) {
369
+ throw connectError("invalid_operation_response", "A file chunk had the wrong byte length.");
370
+ }
371
+ hash.update(chunk);
372
+ receivedBytes += chunk.byteLength;
373
+ partIndex += 1;
374
+ queueFramedChunks();
375
+ options.onProgress?.({
376
+ phase: "downloading",
377
+ transferredBytes: receivedBytes,
378
+ totalBytes: file.size
379
+ });
380
+ if (partIndex === partCount) {
381
+ verify();
382
+ }
383
+ controller.enqueue(chunk);
384
+ if (partIndex === partCount) {
385
+ controller.close();
386
+ void finish();
387
+ }
388
+ }
389
+ catch (error) {
390
+ const failure = options.signal?.aborted
391
+ ? connectError("operation_cancelled", "The file transfer was cancelled.", {
392
+ operationOutcome: "not_sent",
393
+ cause: options.signal.reason
394
+ })
395
+ : error;
396
+ await finish();
397
+ controller.error(normalizeFileError(failure));
398
+ }
399
+ },
400
+ cancel: finish
401
+ });
402
+ }
403
+ async downloadBytes(file, options = {}) {
404
+ return new Uint8Array(await (await this.download(file, options)).arrayBuffer());
405
+ }
406
+ async move(file, path, options = {}) {
407
+ return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.moveWithinBudget(file, path, { ...options, signal: budget.signal, timeoutMs: null }));
408
+ }
409
+ async moveWithinBudget(file, path, options) {
410
+ this.requireAction("move");
411
+ const mutationId = options.mutationId ?? crypto.randomUUID();
412
+ let receipt;
413
+ try {
414
+ receipt = await this.request("POST", `${encodeURIComponent(file.fileId)}/move`, {
415
+ protocol_version: FILE_PROTOCOL_VERSION,
416
+ type: "move_file",
417
+ mutation_id: mutationId,
418
+ file_id: file.fileId,
419
+ if_revision: options.ifRevision ?? file.revision,
420
+ from_path: file.path,
421
+ path,
422
+ // Reserved for a future protocol that can update record links
423
+ // atomically with a file move. Protocol v1 must remain false.
424
+ update_references: false
425
+ }, options.signal);
426
+ }
427
+ catch (error) {
428
+ throw normalizeFileError(error);
429
+ }
430
+ if (receipt.protocol_version !== 1
431
+ || receipt.type !== "file_moved"
432
+ || receipt.mutation_id !== mutationId
433
+ || receipt.file.file_id !== file.fileId
434
+ || receipt.file.path !== path) {
435
+ throw connectError("invalid_operation_response", "The authority returned an invalid file move receipt.");
436
+ }
437
+ return clientFileDescriptor(receipt.file);
438
+ }
439
+ async delete(file, options = {}) {
440
+ return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.deleteWithinBudget(file, { ...options, signal: budget.signal, timeoutMs: null }));
441
+ }
442
+ async deleteWithinBudget(file, options) {
443
+ this.requireAction("delete");
444
+ const mutationId = options.mutationId ?? crypto.randomUUID();
445
+ let receipt;
446
+ try {
447
+ receipt = await this.request("POST", `${encodeURIComponent(file.fileId)}/delete`, {
448
+ protocol_version: FILE_PROTOCOL_VERSION,
449
+ type: "delete_file",
450
+ mutation_id: mutationId,
451
+ file_id: file.fileId,
452
+ if_revision: options.ifRevision ?? file.revision,
453
+ path: file.path
454
+ }, options.signal);
455
+ }
456
+ catch (error) {
457
+ throw normalizeFileError(error);
458
+ }
459
+ if (receipt.protocol_version !== 1
460
+ || receipt.type !== "file_deleted"
461
+ || receipt.mutation_id !== mutationId
462
+ || receipt.file_id !== file.fileId
463
+ || receipt.previous_path !== file.path) {
464
+ throw connectError("invalid_operation_response", "The authority returned an invalid file delete receipt.");
465
+ }
466
+ return {
467
+ protocolVersion: receipt.protocol_version,
468
+ type: receipt.type,
469
+ mutationId: receipt.mutation_id,
470
+ fileId: receipt.file_id,
471
+ previousPath: receipt.previous_path,
472
+ revision: receipt.revision
473
+ };
474
+ }
475
+ async uploadPart(transferId, partIndex, offset, body, contentLength, requireEtag, signal) {
476
+ for (let attempt = 1; attempt <= MAX_OBJECT_ATTEMPTS; attempt += 1) {
477
+ throwIfAborted(signal);
478
+ try {
479
+ const prepared = await this.request("POST", `uploads/${encodeURIComponent(transferId)}/parts`, {
480
+ protocol_version: FILE_PROTOCOL_VERSION,
481
+ type: "prepare_file_upload_part",
482
+ transfer_id: transferId,
483
+ part_number: partIndex + 1,
484
+ content_length: contentLength
485
+ }, signal);
486
+ requirePreparedPart(prepared, transferId, partIndex, offset, contentLength, "PUT");
487
+ const response = await fetch(prepared.url, {
488
+ method: prepared.method,
489
+ headers: browserObjectHeaders(prepared.headers),
490
+ body,
491
+ redirect: "error",
492
+ signal
493
+ });
494
+ if (!response.ok)
495
+ throw new Error(`Object upload failed with HTTP ${response.status}.`);
496
+ const etag = response.headers.get("etag");
497
+ if (requireEtag && !etag) {
498
+ throw new Error("Multipart object upload did not return an ETag.");
499
+ }
500
+ return { part_number: partIndex + 1, etag: etag ?? "" };
501
+ }
502
+ catch (error) {
503
+ if (signal?.aborted)
504
+ throw error;
505
+ if (attempt === MAX_OBJECT_ATTEMPTS)
506
+ throw error;
507
+ }
508
+ }
509
+ throw new Error("Unreachable upload retry state.");
510
+ }
511
+ async tryReplayUploadCommit(transferId, parts, signal) {
512
+ try {
513
+ return await this.commitUpload(transferId, parts, signal);
514
+ }
515
+ catch (error) {
516
+ if (error instanceof MdbaseConnectError && error.code === "file_upload_incomplete") {
517
+ return null;
518
+ }
519
+ throw error;
520
+ }
521
+ }
522
+ async commitUpload(transferId, parts, signal) {
523
+ let lastError;
524
+ for (let attempt = 1; attempt <= MAX_OBJECT_ATTEMPTS; attempt += 1) {
525
+ throwIfAborted(signal);
526
+ try {
527
+ return await this.request("POST", `uploads/${encodeURIComponent(transferId)}/commit`, {
528
+ protocol_version: FILE_PROTOCOL_VERSION,
529
+ type: "commit_file_upload",
530
+ transfer_id: transferId,
531
+ ...(parts === undefined ? {} : { parts })
532
+ }, signal);
533
+ }
534
+ catch (error) {
535
+ lastError = error;
536
+ if (signal?.aborted || error instanceof MdbaseConnectError || attempt === MAX_OBJECT_ATTEMPTS) {
537
+ throw error;
538
+ }
539
+ }
540
+ }
541
+ throw lastError;
542
+ }
543
+ requireAction(action) {
544
+ if (!this.capability()?.actions.includes(action)) {
545
+ throw connectError("not_authorized", `This connection is not authorized to ${action} files.`);
546
+ }
547
+ }
548
+ async abort(transferId) {
549
+ await this.request("DELETE", `transfers/${encodeURIComponent(transferId)}`).catch(() => undefined);
550
+ }
551
+ }
552
+ function clientFileDescriptor(file) {
553
+ return {
554
+ fileId: file.file_id,
555
+ path: file.path,
556
+ revision: file.revision,
557
+ contentDigest: file.content_digest,
558
+ size: file.size,
559
+ ...(file.media_type ? { mediaType: file.media_type } : {}),
560
+ mediaClass: file.media_class,
561
+ modifiedAt: file.modified_at
562
+ };
563
+ }
@@ -0,0 +1,4 @@
1
+ import type { CollectionOperation, EncryptedRelayOperationRequest } from "@mdbase-dev/connect-protocol";
2
+ import { type GrantKeyStore } from "./crypto.js";
3
+ import type { StoredToken } from "./internal-types.js";
4
+ export declare function freshEncryptedRequest(keyStore: GrantKeyStore, token: StoredToken, operation: CollectionOperation, input: unknown, deadlineUnixMs?: number): Promise<EncryptedRelayOperationRequest>;
@@ -0,0 +1,28 @@
1
+ import { encryptRelayRequest, RelayCryptoError } from "./crypto.js";
2
+ import { connectError, serverConnectError } from "./errors.js";
3
+ export async function freshEncryptedRequest(keyStore, token, operation, input, deadlineUnixMs) {
4
+ if (!token.encryption || !token.grantId || !token.keyHandle) {
5
+ throw connectError("authorization_binding_incompatible", "The refreshed authorization no longer supports encrypted connector operations.", {
6
+ details: {
7
+ contract: "grant_encryption",
8
+ required: [1],
9
+ supported: token.encryption ? [token.encryption.protocol_version] : [],
10
+ peer: "connector",
11
+ operation
12
+ }
13
+ });
14
+ }
15
+ try {
16
+ return await encryptRelayRequest(keyStore, token.keyHandle, {
17
+ grantId: token.grantId,
18
+ applicationId: token.clientId,
19
+ encryption: token.encryption
20
+ }, operation, input, undefined, deadlineUnixMs);
21
+ }
22
+ catch (error) {
23
+ if (error instanceof RelayCryptoError) {
24
+ throw serverConnectError(error.code, error.message);
25
+ }
26
+ throw error;
27
+ }
28
+ }
@@ -0,0 +1,24 @@
1
+ import type { StoredToken } from "./internal-types.js";
2
+ export declare class GrantKeyLeaseRegistry {
3
+ private readonly namespace;
4
+ private readonly onFinalRelease;
5
+ private readonly leases;
6
+ private readonly locks;
7
+ readonly retainAutomaticallyRetiredKeys: boolean;
8
+ constructor(namespace: string, onFinalRelease: (collectionId: string, keyHandle: string) => void);
9
+ acquire(collectionId: string, keyHandle: string, signal?: AbortSignal): Promise<() => void>;
10
+ deleteExclusive(collectionId: string, keyHandle: string, action: () => void | Promise<void>): void;
11
+ has(collectionId: string, keyHandle: string): boolean;
12
+ handles(collectionId: string): Iterable<string>;
13
+ private acquireLocal;
14
+ private lockName;
15
+ }
16
+ export declare class GrantKeyLeaseSet {
17
+ private readonly collectionId;
18
+ private readonly acquire;
19
+ private readonly releases;
20
+ constructor(collectionId: string, acquire: (collectionId: string, keyHandle: string, signal?: AbortSignal) => Promise<() => void>);
21
+ retain(tokenOrHandle: StoredToken | string | null | undefined, signal?: AbortSignal): Promise<void>;
22
+ release(): void;
23
+ }
24
+ export declare function retainCurrentGrantToken(readCurrent: () => StoredToken | null, leases: GrantKeyLeaseSet, signal?: AbortSignal): Promise<StoredToken | null>;