@teepin/sdk 0.2.1 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +41 -36
  2. package/dist/cjs/client.js +42 -4
  3. package/dist/cjs/client.js.map +1 -1
  4. package/dist/cjs/direct/bytes.js +50 -0
  5. package/dist/cjs/direct/bytes.js.map +1 -0
  6. package/dist/cjs/direct/common.js +167 -0
  7. package/dist/cjs/direct/common.js.map +1 -0
  8. package/dist/cjs/direct/downloads-plan.js +127 -0
  9. package/dist/cjs/direct/downloads-plan.js.map +1 -0
  10. package/dist/cjs/direct/engine.js +405 -0
  11. package/dist/cjs/direct/engine.js.map +1 -0
  12. package/dist/cjs/direct/formats.js +44 -0
  13. package/dist/cjs/direct/formats.js.map +1 -0
  14. package/dist/cjs/direct/hpke.js +275 -0
  15. package/dist/cjs/direct/hpke.js.map +1 -0
  16. package/dist/cjs/direct/journal.js +80 -0
  17. package/dist/cjs/direct/journal.js.map +1 -0
  18. package/dist/cjs/direct/large.js +850 -0
  19. package/dist/cjs/direct/large.js.map +1 -0
  20. package/dist/cjs/direct/reads.js +1314 -0
  21. package/dist/cjs/direct/reads.js.map +1 -0
  22. package/dist/cjs/direct/sha256.js +122 -0
  23. package/dist/cjs/direct/sha256.js.map +1 -0
  24. package/dist/cjs/direct/tws1.js +480 -0
  25. package/dist/cjs/direct/tws1.js.map +1 -0
  26. package/dist/cjs/direct/uploads.js +1706 -0
  27. package/dist/cjs/direct/uploads.js.map +1 -0
  28. package/dist/cjs/errors.js +70 -13
  29. package/dist/cjs/errors.js.map +1 -1
  30. package/dist/cjs/index.js +5 -1
  31. package/dist/cjs/index.js.map +1 -1
  32. package/dist/cjs/links.js +195 -0
  33. package/dist/cjs/links.js.map +1 -0
  34. package/dist/cjs/node/downloads.js +835 -0
  35. package/dist/cjs/node/downloads.js.map +1 -0
  36. package/dist/cjs/node/journal.js +841 -0
  37. package/dist/cjs/node/journal.js.map +1 -0
  38. package/dist/cjs/node/uploads.js +219 -0
  39. package/dist/cjs/node/uploads.js.map +1 -0
  40. package/dist/cjs/node.js +26 -39
  41. package/dist/cjs/node.js.map +1 -1
  42. package/dist/cjs/storage.js +214 -184
  43. package/dist/cjs/storage.js.map +1 -1
  44. package/dist/cjs/transfer.js +18 -351
  45. package/dist/cjs/transfer.js.map +1 -1
  46. package/dist/esm/client.js +42 -4
  47. package/dist/esm/client.js.map +1 -1
  48. package/dist/esm/direct/bytes.js +42 -0
  49. package/dist/esm/direct/bytes.js.map +1 -0
  50. package/dist/esm/direct/common.js +155 -0
  51. package/dist/esm/direct/common.js.map +1 -0
  52. package/dist/esm/direct/downloads-plan.js +117 -0
  53. package/dist/esm/direct/downloads-plan.js.map +1 -0
  54. package/dist/esm/direct/engine.js +385 -0
  55. package/dist/esm/direct/engine.js.map +1 -0
  56. package/dist/esm/direct/formats.js +40 -0
  57. package/dist/esm/direct/formats.js.map +1 -0
  58. package/dist/esm/direct/hpke.js +262 -0
  59. package/dist/esm/direct/hpke.js.map +1 -0
  60. package/dist/esm/direct/journal.js +75 -0
  61. package/dist/esm/direct/journal.js.map +1 -0
  62. package/dist/esm/direct/large.js +816 -0
  63. package/dist/esm/direct/large.js.map +1 -0
  64. package/dist/esm/direct/reads.js +1275 -0
  65. package/dist/esm/direct/reads.js.map +1 -0
  66. package/dist/esm/direct/sha256.js +118 -0
  67. package/dist/esm/direct/sha256.js.map +1 -0
  68. package/dist/esm/direct/tws1.js +460 -0
  69. package/dist/esm/direct/tws1.js.map +1 -0
  70. package/dist/esm/direct/uploads.js +1660 -0
  71. package/dist/esm/direct/uploads.js.map +1 -0
  72. package/dist/esm/errors.js +64 -11
  73. package/dist/esm/errors.js.map +1 -1
  74. package/dist/esm/index.js +1 -0
  75. package/dist/esm/index.js.map +1 -1
  76. package/dist/esm/links.js +189 -0
  77. package/dist/esm/links.js.map +1 -0
  78. package/dist/esm/node/downloads.js +829 -0
  79. package/dist/esm/node/downloads.js.map +1 -0
  80. package/dist/esm/node/journal.js +824 -0
  81. package/dist/esm/node/journal.js.map +1 -0
  82. package/dist/esm/node/uploads.js +187 -0
  83. package/dist/esm/node/uploads.js.map +1 -0
  84. package/dist/esm/node.js +17 -14
  85. package/dist/esm/node.js.map +1 -1
  86. package/dist/esm/storage.js +214 -184
  87. package/dist/esm/storage.js.map +1 -1
  88. package/dist/esm/transfer.js +19 -349
  89. package/dist/esm/transfer.js.map +1 -1
  90. package/dist/types/client.d.ts +45 -4
  91. package/dist/types/client.d.ts.map +1 -1
  92. package/dist/types/direct/bytes.d.ts +11 -0
  93. package/dist/types/direct/bytes.d.ts.map +1 -0
  94. package/dist/types/direct/common.d.ts +47 -0
  95. package/dist/types/direct/common.d.ts.map +1 -0
  96. package/dist/types/direct/downloads-plan.d.ts +75 -0
  97. package/dist/types/direct/downloads-plan.d.ts.map +1 -0
  98. package/dist/types/direct/engine.d.ts +191 -0
  99. package/dist/types/direct/engine.d.ts.map +1 -0
  100. package/dist/types/direct/formats.d.ts +9 -0
  101. package/dist/types/direct/formats.d.ts.map +1 -0
  102. package/dist/types/direct/hpke.d.ts +55 -0
  103. package/dist/types/direct/hpke.d.ts.map +1 -0
  104. package/dist/types/direct/journal.d.ts +55 -0
  105. package/dist/types/direct/journal.d.ts.map +1 -0
  106. package/dist/types/direct/large.d.ts +170 -0
  107. package/dist/types/direct/large.d.ts.map +1 -0
  108. package/dist/types/direct/reads.d.ts +306 -0
  109. package/dist/types/direct/reads.d.ts.map +1 -0
  110. package/dist/types/direct/sha256.d.ts +16 -0
  111. package/dist/types/direct/sha256.d.ts.map +1 -0
  112. package/dist/types/direct/tws1.d.ts +157 -0
  113. package/dist/types/direct/tws1.d.ts.map +1 -0
  114. package/dist/types/direct/uploads.d.ts +461 -0
  115. package/dist/types/direct/uploads.d.ts.map +1 -0
  116. package/dist/types/errors.d.ts +47 -8
  117. package/dist/types/errors.d.ts.map +1 -1
  118. package/dist/types/index.d.ts +1 -0
  119. package/dist/types/index.d.ts.map +1 -1
  120. package/dist/types/links.d.ts +86 -0
  121. package/dist/types/links.d.ts.map +1 -0
  122. package/dist/types/node/downloads.d.ts +73 -0
  123. package/dist/types/node/downloads.d.ts.map +1 -0
  124. package/dist/types/node/journal.d.ts +162 -0
  125. package/dist/types/node/journal.d.ts.map +1 -0
  126. package/dist/types/node/uploads.d.ts +86 -0
  127. package/dist/types/node/uploads.d.ts.map +1 -0
  128. package/dist/types/node.d.ts +4 -2
  129. package/dist/types/node.d.ts.map +1 -1
  130. package/dist/types/storage.d.ts +107 -64
  131. package/dist/types/storage.d.ts.map +1 -1
  132. package/dist/types/transfer.d.ts +14 -23
  133. package/dist/types/transfer.d.ts.map +1 -1
  134. package/package.json +2 -2
@@ -0,0 +1,1660 @@
1
+ // Copyright 2026 TEEPIN Project
2
+ // Licensed under the Apache License, Version 2.0
3
+ import { APIError, ConnectionError, DataIntegrityError, FileChangedError, ForbiddenError, PaymentRequiredError, PreconditionFailedError, StorageUnavailableError, TeepinError, UnauthorizedError, apiError, } from '../errors.js';
4
+ import { md5Hex } from '../md5.js';
5
+ import { fromBase64 } from './bytes.js';
6
+ import { AdaptiveLimit, COMMIT_VERIFY_TRIES, MAX_REBUILDS, PACK_MAX_OBJECTS, PACK_MIN_TARGET, PACK_OBJECT_LIMIT, PACK_TARGET_BYTES, PART_RULES, RetryBudget, SIGN_MARGIN_MS, START_CONCURRENCY, STREAM_CONCURRENCY, SpeedMeter, directPartSize, envNumber, isPassing, isThrottle, nextPack, packObjectLimit, packTarget, storageOutcome, streamPartSize, waitDelayMs, } from './engine.js';
7
+ import { abortable, Deferred, joinCapped, linked, objectFrom, Progress, toBase64, uuid7 } from './common.js';
8
+ import * as hpke from './hpke.js';
9
+ import { MemoryJournal } from './journal.js';
10
+ import { Large, Parts, StreamUpload } from './large.js';
11
+ import { Sha256 } from './sha256.js';
12
+ import * as tws1 from './tws1.js';
13
+ export { abortable, Deferred, joinCapped, linked, objectFrom, Progress, Slots, toBase64, uuid7 } from './common.js';
14
+ /** An unfinished run is shown as expired after this long untouched (7 days). */
15
+ export const RUN_EXPIRY_MS = 7 * 86400000;
16
+ /** The state directory's size that earns a warning (section 17.12), unless TEEPIN_STATE_WARN_BYTES says. */
17
+ export const STATE_WARN_ENV = 'TEEPIN_STATE_WARN_BYTES';
18
+ export const STATE_WARN_BYTES = 2 ** 30;
19
+ /** How long a stopping step waits for its tasks, and a cleanup call may take, in ms (section 22.13's cap). */
20
+ export const CLEANUP_MS = 5000;
21
+ /** The longest a timer can be set for (setTimeout's limit); a longer one would fire at once. */
22
+ const MAX_TIMER_MS = 2 ** 31 - 1;
23
+ const str = (v) => (v === undefined || v === null ? '' : String(v));
24
+ const int = (v) => Math.trunc(Number(v || 0)) || 0;
25
+ const bucketPath = (bucket) => `/v1/storage/buckets/${encodeURIComponent(bucket)}`;
26
+ const unquote = (etag) => etag.trim().replace(/^W\//, '').replace(/^"|"$/g, '');
27
+ const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
28
+ /** What an error says, for a journal: its code (or name) and message. */
29
+ export function describe(err) {
30
+ if (err === undefined || err === null)
31
+ return '';
32
+ const code = (err instanceof APIError && err.code) || (err instanceof Error ? err.name : 'Error');
33
+ return `${code}: ${err instanceof Error ? err.message : String(err)}`;
34
+ }
35
+ /** Errors that stop a whole run, with its files kept for resumeUploads. */
36
+ export function stopsRun(err) {
37
+ return err instanceof UnauthorizedError || err instanceof ForbiddenError || err instanceof PaymentRequiredError;
38
+ }
39
+ /**
40
+ * Whether an empty file's failed put is final. A passing failure (the network, a busy server) or one that stops the run
41
+ * (a revoked key, no credit) leaves it queued in the journal for resumeUploads, as a pack's files are left.
42
+ */
43
+ export function emptyPutFinal(err) {
44
+ return !(isPassing(err) || stopsRun(err));
45
+ }
46
+ /** An object as a journal keeps it (JSON), and back. */
47
+ function objectJSON(o) {
48
+ return { ...o, createdAt: o.createdAt.toISOString(), updatedAt: o.updatedAt.toISOString(), uploadedAt: o.uploadedAt?.toISOString() };
49
+ }
50
+ function reviveObject(v) {
51
+ if (!v || typeof v !== 'object')
52
+ return undefined;
53
+ const d = v;
54
+ const date = (x) => new Date(str(x) || NaN);
55
+ return {
56
+ id: str(d.id),
57
+ key: str(d.key),
58
+ size: Number(d.size ?? 0),
59
+ contentType: d.contentType || undefined,
60
+ metadata: d.metadata,
61
+ checksumSha256: d.checksumSha256 || undefined,
62
+ etag: str(d.etag),
63
+ partCount: Number(d.partCount ?? 0),
64
+ status: str(d.status),
65
+ backend: str(d.backend),
66
+ createdAt: date(d.createdAt),
67
+ updatedAt: date(d.updatedAt),
68
+ uploadedAt: d.uploadedAt ? date(d.uploadedAt) : undefined,
69
+ };
70
+ }
71
+ /** Runs fn for every item, at most n at once; the first failure stops the rest. An abort throws its reason. */
72
+ async function pool(n, items, signal, fn) {
73
+ let next = 0;
74
+ let failure;
75
+ const worker = async () => {
76
+ while (!failure && next < items.length && !signal?.aborted) {
77
+ const item = items[next++];
78
+ try {
79
+ await fn(item);
80
+ }
81
+ catch (err) {
82
+ failure ??= { err };
83
+ }
84
+ }
85
+ };
86
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(n, items.length)) }, worker));
87
+ if (signal?.aborted)
88
+ throw signal.reason;
89
+ if (failure)
90
+ throw failure.err;
91
+ }
92
+ export const webDigests = {
93
+ md5: md5Hex,
94
+ sha256: async (data) => hex(new Uint8Array(await crypto.subtle.digest('SHA-256', data))),
95
+ incremental: () => {
96
+ const h = new Sha256();
97
+ return { update: (d) => void h.update(d), hex: () => h.hexDigest() };
98
+ },
99
+ };
100
+ /** Pins kept for the life of this client (browsers; the node entry keeps them in the state directory). */
101
+ export class MemoryPins {
102
+ pins = new Map();
103
+ async check(accountId, version, publicKey) {
104
+ const id = JSON.stringify([accountId, version]);
105
+ const encoded = toBase64(publicKey);
106
+ const have = this.pins.get(id);
107
+ if (have !== undefined)
108
+ return have === encoded;
109
+ this.pins.set(id, encoded);
110
+ return true;
111
+ }
112
+ }
113
+ // --- items and packs ----------------------------------------------------------------------------------------------
114
+ /** One object on its way into a pack. */
115
+ export class Item {
116
+ key;
117
+ size;
118
+ sha256;
119
+ read;
120
+ ifNoneMatch;
121
+ path;
122
+ fileId;
123
+ objectId = '';
124
+ result;
125
+ error;
126
+ /** false when the error leaves the item journaled for resumeUploads. */
127
+ final = true;
128
+ /** putObject's: set when a pack takes the item, resolved when its result is in, and to wake its waiting caller. */
129
+ picked = false;
130
+ done;
131
+ wake;
132
+ /** The pack and object id its file row last journaled. */
133
+ journaled = '';
134
+ contentType;
135
+ metadata;
136
+ ifMatch;
137
+ constructor(key, size, sha256, read, contentType = '', metadata, ifNoneMatch = false, ifMatch = '', path, fileId) {
138
+ this.key = key;
139
+ this.size = size;
140
+ this.sha256 = sha256;
141
+ this.read = read;
142
+ this.ifNoneMatch = ifNoneMatch;
143
+ this.path = path;
144
+ this.fileId = fileId;
145
+ this.contentType = contentType || '';
146
+ this.metadata = { ...(metadata ?? {}) };
147
+ this.ifMatch = ifMatch || '';
148
+ }
149
+ reserveJSON() {
150
+ const o = { key: this.key, size: this.size, sha256: this.sha256 };
151
+ if (this.contentType)
152
+ o.content_type = this.contentType;
153
+ if (Object.keys(this.metadata).length)
154
+ o.metadata = this.metadata;
155
+ if (this.ifNoneMatch)
156
+ o.if_none_match = true;
157
+ if (this.ifMatch)
158
+ o.if_match = this.ifMatch;
159
+ return o;
160
+ }
161
+ }
162
+ /** One pack's state, kept in its store (a run's journal, or memory). */
163
+ export class Pack {
164
+ bucket;
165
+ items;
166
+ store;
167
+ id;
168
+ state = 'new';
169
+ idem = uuid7();
170
+ reservation = {};
171
+ signBody = {};
172
+ signed = {};
173
+ size = 0;
174
+ md5 = '';
175
+ sha256 = '';
176
+ fileName = '';
177
+ data;
178
+ attempts = 0;
179
+ rebuilds = 0;
180
+ verifies = 0;
181
+ resigns = 0;
182
+ waited = 0;
183
+ throttled = false;
184
+ lastError = '';
185
+ constructor(bucket, items, store, id) {
186
+ this.bucket = bucket;
187
+ this.items = items;
188
+ this.store = store;
189
+ this.id = id ?? uuid7();
190
+ }
191
+ get serverId() {
192
+ return str(this.reservation.pack);
193
+ }
194
+ }
195
+ /** The pack can't go on: its items fail with err (final: false keeps them journaled for resumeUploads). */
196
+ class Stop extends Error {
197
+ err;
198
+ final;
199
+ constructor(err, final = true) {
200
+ super(err instanceof Error ? err.message : String(err));
201
+ this.err = err;
202
+ this.final = final;
203
+ }
204
+ }
205
+ /** A pack's state and bytes in memory: putObject's packs, and the packs of a run journaled in memory. */
206
+ export class PackStore {
207
+ async savePack(p) {
208
+ void p;
209
+ }
210
+ async putBytes(p, data) {
211
+ p.data = data;
212
+ }
213
+ async getBytes(p) {
214
+ return p.data;
215
+ }
216
+ async dropBytes(p) {
217
+ p.data = undefined;
218
+ }
219
+ async itemsDone(done) {
220
+ for (const [it, obj, err, final] of done) {
221
+ it.result = obj;
222
+ it.error = err;
223
+ it.final = final;
224
+ }
225
+ }
226
+ }
227
+ /** A run's packs in its journal, and their bytes in its directory (or in memory, for a run journaled in memory). */
228
+ export class RunStore extends PackStore {
229
+ journal;
230
+ place;
231
+ constructor(journal, place) {
232
+ super();
233
+ this.journal = journal;
234
+ this.place = place;
235
+ }
236
+ async savePack(p) {
237
+ const entries = [
238
+ {
239
+ table: 'packs',
240
+ id: p.id,
241
+ row: {
242
+ _id: p.id,
243
+ idempotency_key: p.idem,
244
+ state: p.state,
245
+ reservation: p.reservation,
246
+ file_name: p.fileName,
247
+ size: p.size,
248
+ md5: p.md5,
249
+ sha256: p.sha256,
250
+ sign_body: p.signBody,
251
+ signed: p.signed,
252
+ attempts: p.attempts,
253
+ last_error: p.lastError,
254
+ updated: Date.now(),
255
+ },
256
+ },
257
+ ];
258
+ for (const it of p.items) {
259
+ const mark = `${p.id}|${it.objectId}`;
260
+ if (it.fileId === undefined || it.result || it.error !== undefined || it.journaled === mark)
261
+ continue;
262
+ const row = this.journal.get('files', it.fileId);
263
+ if (!row)
264
+ continue;
265
+ entries.push({ table: 'files', id: it.fileId, row: { ...row, pack: p.id, object_id: it.objectId || null, state: 'packed' } });
266
+ }
267
+ entries.push(...this.touch());
268
+ await this.journal.write(entries);
269
+ for (const it of p.items)
270
+ if (it.fileId !== undefined && !it.result && it.error === undefined)
271
+ it.journaled = `${p.id}|${it.objectId}`;
272
+ }
273
+ /** The run row with its updated time now. */
274
+ touch() {
275
+ const run = this.journal.get('run', 'run');
276
+ return run ? [{ table: 'run', id: 'run', row: { ...run, updated: Date.now() } }] : [];
277
+ }
278
+ async putBytes(p, data) {
279
+ if (!this.place) {
280
+ p.data = data;
281
+ return;
282
+ }
283
+ p.fileName = `${p.id}.pack`;
284
+ await this.place.writePack(p.fileName, data);
285
+ }
286
+ async getBytes(p) {
287
+ if (!this.place)
288
+ return p.data;
289
+ if (!p.fileName)
290
+ return undefined;
291
+ return this.place.readPack(p.fileName);
292
+ }
293
+ async dropBytes(p) {
294
+ p.data = undefined;
295
+ if (this.place && p.fileName) {
296
+ await this.place.removePack(p.fileName).catch(() => undefined);
297
+ p.fileName = '';
298
+ }
299
+ }
300
+ async itemsDone(done) {
301
+ await super.itemsDone(done);
302
+ const entries = [];
303
+ for (const [it, obj, err, final] of done) {
304
+ if (it.fileId === undefined)
305
+ continue;
306
+ const row = this.journal.get('files', it.fileId);
307
+ if (!row)
308
+ continue;
309
+ if (obj)
310
+ entries.push({ table: 'files', id: it.fileId, row: { ...row, state: 'saved', object_id: obj.id, result: objectJSON(obj), error: '' } });
311
+ else if (final)
312
+ entries.push({ table: 'files', id: it.fileId, row: { ...row, state: 'failed', error: describe(err) } });
313
+ else
314
+ entries.push({ table: 'files', id: it.fileId, row: { ...row, error: describe(err) } });
315
+ }
316
+ if (entries.length)
317
+ await this.journal.write(entries);
318
+ }
319
+ }
320
+ /**
321
+ * Who releases an interrupted run: whichever ends last, the run's own step (once it has stopped) or its last pack task.
322
+ * A run that isn't interrupted is finished by its own step as usual, and this never fires.
323
+ */
324
+ export class RunCloser {
325
+ live = 0;
326
+ stopped = false;
327
+ fired = false;
328
+ started() {
329
+ this.live++;
330
+ }
331
+ /** A task ended (taskDone) or the run's step stopped: true for the one call that should release the run. */
332
+ ended(taskDone) {
333
+ if (taskDone)
334
+ this.live--;
335
+ else
336
+ this.stopped = true;
337
+ if (this.stopped && this.live === 0 && !this.fired) {
338
+ this.fired = true;
339
+ return true;
340
+ }
341
+ return false;
342
+ }
343
+ }
344
+ /**
345
+ * Direct uploads for one storage service: the account key, putObject's shared packs, upload runs, resume and cleanup.
346
+ * The methods tests replace (as the Python tests patch _direct.py) are newDataKey, encryptObject, wrapKey, putSigned,
347
+ * expiresSoon, dispatch, send and hashParts.
348
+ */
349
+ export class DirectUploads {
350
+ client;
351
+ limit;
352
+ budget = new RetryBudget();
353
+ parts;
354
+ /** The sizes it works with (tests shrink them). */
355
+ sizes = {
356
+ ...PART_RULES,
357
+ packObjectLimit: PACK_OBJECT_LIMIT,
358
+ packTarget: PACK_TARGET_BYTES,
359
+ packMinTarget: PACK_MIN_TARGET,
360
+ packMaxObjects: PACK_MAX_OBJECTS,
361
+ streamConcurrency: STREAM_CONCURRENCY,
362
+ };
363
+ /**
364
+ * The measured upload speed to the storage network: shrinks packs and new uploads' parts on a slow link (section
365
+ * 22.7). Tests set it with speed.set.
366
+ */
367
+ speed = new SpeedMeter();
368
+ /**
369
+ * A storage PUT's own timer: at least minMs, and its bytes at bytesPerSecond (30 s and 64 KiB/s); on a link measured
370
+ * slower, twice its time at that speed (section 22.7). Tests shorten it.
371
+ */
372
+ putTimeout = { minMs: 30000, bytesPerSecond: 65536 };
373
+ /** How long a stopping step waits for its tasks, and a cleanup call may take (ms). */
374
+ cleanupMs = CLEANUP_MS;
375
+ digests = webDigests;
376
+ pins = new MemoryPins();
377
+ /** Runs on disk: the node entry's (undefined in browsers). */
378
+ host;
379
+ /** Where warnings go: a run removed by cleanUploads, a large state directory. */
380
+ warn = (m) => console.warn(m);
381
+ accountId;
382
+ queue = [];
383
+ /** Wake-ups of putObject calls waiting for a slot: resolved when one comes free. */
384
+ slotWaiters = [];
385
+ memory = new PackStore();
386
+ constructor(client) {
387
+ this.client = client;
388
+ this.limit = new AdaptiveLimit(START_CONCURRENCY, client.uploadConcurrency);
389
+ this.parts = new Parts(this);
390
+ }
391
+ // --- what tests replace -----------------------------------------------------
392
+ newDataKey() {
393
+ return tws1.newDataKey();
394
+ }
395
+ encryptObject(dek, plain) {
396
+ return tws1.encrypt(dek, plain);
397
+ }
398
+ wrapKey(publicKey, account, objectId, dek) {
399
+ return hpke.wrapKey(publicKey, account, objectId, dek);
400
+ }
401
+ /** Whether a signature is too close to its expiry to use. */
402
+ expiresSoon(signed) {
403
+ const exp = Date.parse(str(signed.expires_at));
404
+ return Number.isNaN(exp) || exp - Date.now() < SIGN_MARGIN_MS;
405
+ }
406
+ /**
407
+ * Sends one signed PUT to the storage network, with exactly the signed headers and never the API key; returns its
408
+ * ETag, unquoted ("" when the answer has none, as a browser sees it: the storage network doesn't expose it).
409
+ */
410
+ async putSigned(signed, data, signal) {
411
+ const bps = this.speed.bytesPerSecond;
412
+ const t = this.putTimeout;
413
+ const ms = Math.max(t.minMs, (data.length / t.bytesPerSecond) * 1000, bps ? ((2 * data.length) / bps) * 1000 : 0);
414
+ const { ctl, unlink } = linked(signal);
415
+ const timer = setTimeout(() => ctl.abort(new PutTimedOut(ms)), ms);
416
+ try {
417
+ const headers = {};
418
+ for (const [k, v] of Object.entries(signed.headers ?? {}))
419
+ headers[k] = str(v);
420
+ const res = await this.client.sendSigned({ method: str(signed.method) || 'PUT', url: str(signed.url), headers, body: data, signal: ctl.signal });
421
+ await res.arrayBuffer().catch(() => undefined);
422
+ return unquote(res.headers.get('etag') ?? '');
423
+ }
424
+ catch (err) {
425
+ if (signal?.aborted)
426
+ throw signal.reason;
427
+ throw err;
428
+ }
429
+ finally {
430
+ clearTimeout(timer);
431
+ unlink();
432
+ }
433
+ }
434
+ /**
435
+ * putSigned, measured (section 22.7): the PUT every pack and part goes through. One that succeeds is a sample; one its
436
+ * own timer ended with no answer is an upper bound (a slow link, measured from a cold start too). Any other failure (an
437
+ * error status, a refused connection, the caller's abort) is never measured.
438
+ */
439
+ async putMeasured(signed, data, signal) {
440
+ const start = performance.now();
441
+ let etag;
442
+ try {
443
+ etag = await this.putSigned(signed, data, signal);
444
+ }
445
+ catch (err) {
446
+ if (err instanceof PutTimedOut && !signal?.aborted)
447
+ this.speed.observeTimeout(data.length, performance.now() - start);
448
+ throw err;
449
+ }
450
+ this.speed.observe(data.length, performance.now() - start);
451
+ return etag;
452
+ }
453
+ /**
454
+ * The pack/parts choice for every way in (putObject, upload, uploadFiles, copyObject): an object over this goes in
455
+ * parts. At the speed measured so far (section 22.7).
456
+ */
457
+ packObjectLimit() {
458
+ return packObjectLimit(this.speed.requestBytes(), this.sizes.packObjectLimit, this.sizes.minBytes);
459
+ }
460
+ /** The next pack's target size, at the speed measured so far. */
461
+ packTarget() {
462
+ return packTarget(this.speed.requestBytes(), this.sizes.packTarget, this.sizes.packMinTarget);
463
+ }
464
+ // --- shared plumbing ----------------------------------------------------------
465
+ /** Where runs keep their state, when it's on disk. */
466
+ get runHost() {
467
+ if (!this.host)
468
+ throw new TeepinError('upload runs on disk need @teepin/sdk/node');
469
+ return this.host;
470
+ }
471
+ /**
472
+ * A control-plane call (each is idempotent, so retried by the client as safe), with rounds of waiting while the
473
+ * control plane is busy or failing, up to the upload's wait (p's; with no p, none). Returns the status and the answer.
474
+ */
475
+ async control(method, path, body, p, signal, timeout) {
476
+ for (let round = 0;; round++) {
477
+ try {
478
+ const { response } = await this.client.send({ method, path, json: body ?? undefined, mode: 'safe', signal, timeout });
479
+ const text = response.status === 204 ? '' : await response.text();
480
+ let data = {};
481
+ try {
482
+ data = text ? JSON.parse(text) : {};
483
+ }
484
+ catch {
485
+ throw new TeepinError(`${method} ${path}: the answer isn't JSON`);
486
+ }
487
+ return { status: response.status, data: data };
488
+ }
489
+ catch (err) {
490
+ if (signal?.aborted)
491
+ throw signal.reason;
492
+ if (err instanceof APIError) {
493
+ // 501: direct storage is off. A 404 with no code of ours: a deployment from before the routes existed (a missing
494
+ // bucket or pack always names itself).
495
+ if (err.code === 'direct_upload_unsupported' || err.statusCode === 501 || (err.statusCode === 404 && (err.code === '' || err.code === 'not_found'))) {
496
+ throw unsupported(err);
497
+ }
498
+ }
499
+ if (!isPassing(err) || !p)
500
+ throw err;
501
+ await this.wait(p, round, err, signal);
502
+ }
503
+ }
504
+ }
505
+ /** One round of waiting for a service to come back; StorageUnavailableError once p has waited as long as it may. */
506
+ async wait(p, round, err, signal) {
507
+ const delay = waitDelayMs(round, err instanceof APIError ? err.retryAfterMs : undefined);
508
+ if (isThrottle(err))
509
+ p.throttled = true;
510
+ if (p.waited + delay > this.client.uploadWait) {
511
+ throw new StorageUnavailableError(`storage didn't come back within ${Math.round(this.client.uploadWait / 1000)} s: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
512
+ }
513
+ p.waited += delay;
514
+ await this.client.sleep(delay, signal);
515
+ }
516
+ /** The account key from an answer, checked against the suite, this client's account and the pin. */
517
+ async checkKey(raw) {
518
+ const ak = (raw ?? {});
519
+ if (ak.suite !== hpke.SUITE)
520
+ throw new DataIntegrityError(`unsupported key suite ${JSON.stringify(ak.suite)}`, 'account_key');
521
+ const account = str(ak.account_id);
522
+ if (!account)
523
+ throw new DataIntegrityError("the API didn't name the account its key belongs to", 'account_key');
524
+ const publicKey = fromBase64(str(ak.public_key));
525
+ const version = int(ak.version);
526
+ if (this.accountId === undefined)
527
+ this.accountId = account;
528
+ else if (this.accountId !== account) {
529
+ throw new DataIntegrityError(`the API named account ${account}, not ${this.accountId}, in one client`, 'account_key');
530
+ }
531
+ if (!(await this.pins.check(account, version, publicKey))) {
532
+ throw new DataIntegrityError(`account ${account}'s key version ${version} changed; refusing to encrypt to it`, 'account_key');
533
+ }
534
+ return { account: tws1.objectIdBytes(account), publicKey, version };
535
+ }
536
+ /** Items' results: journaled together, then their callers woken. */
537
+ async done(store, done) {
538
+ if (!done.length)
539
+ return;
540
+ await store.itemsDone(done);
541
+ for (const [it] of done)
542
+ it.done?.resolve();
543
+ }
544
+ // --- a pack's life --------------------------------------------------------------
545
+ /**
546
+ * Takes a pack to committed, delivering every item's result. Throws only an error that stops a whole run (auth,
547
+ * permission, credit), after recording it on the pack's items, or the abort's reason, recording nothing.
548
+ */
549
+ async drive(p, signal) {
550
+ try {
551
+ while (p.state !== 'committed' && p.state !== 'failed') {
552
+ signal?.throwIfAborted();
553
+ switch (p.state) {
554
+ case 'new':
555
+ await this.reserve(p, signal);
556
+ break;
557
+ case 'reserved':
558
+ await this.build(p);
559
+ break;
560
+ case 'built':
561
+ await this.sign(p, signal);
562
+ break;
563
+ case 'signed':
564
+ await this.send(p, signal);
565
+ break;
566
+ case 'conflict':
567
+ case 'bad':
568
+ await this.renew(p);
569
+ break;
570
+ default:
571
+ await this.commit(p, signal);
572
+ }
573
+ }
574
+ }
575
+ catch (err) {
576
+ // Interrupted: the store holds the state for resume, and the abort's reason wins over whatever was met.
577
+ if (signal?.aborted)
578
+ throw signal.reason;
579
+ if (err instanceof Stop)
580
+ return this.fail(p, err.err, err.final);
581
+ if (stopsRun(err)) {
582
+ await this.fail(p, err, false);
583
+ throw err;
584
+ }
585
+ if (err instanceof StorageUnavailableError)
586
+ return this.fail(p, err, false);
587
+ if (err instanceof TeepinError)
588
+ return this.fail(p, err, true);
589
+ throw err;
590
+ }
591
+ }
592
+ async fail(p, err, final) {
593
+ p.lastError = describe(err);
594
+ if (final) {
595
+ p.state = 'failed';
596
+ await p.store.dropBytes(p);
597
+ }
598
+ await p.store.savePack(p);
599
+ await this.done(p.store, p.items.filter((it) => !it.result && it.error === undefined).map((it) => [it, undefined, err, final]));
600
+ }
601
+ async reserve(p, signal) {
602
+ await p.store.savePack(p); // the idempotency key, before the call
603
+ const body = { idempotency_key: p.idem, objects: p.items.map((it) => it.reserveJSON()) };
604
+ let res;
605
+ try {
606
+ res = (await this.control(`POST`, `${bucketPath(p.bucket)}/packs`, body, p, signal)).data;
607
+ }
608
+ catch (err) {
609
+ if (err instanceof APIError && err.code === 'reservation_abandoned') {
610
+ p.state = 'conflict';
611
+ await p.store.savePack(p);
612
+ return;
613
+ }
614
+ if (err instanceof APIError && err.code === 'pack_committed') {
615
+ // Committed earlier, its answer lost: commit again for the stored result.
616
+ p.reservation = { pack: str(err.details.pack) };
617
+ p.state = 'committing';
618
+ await p.store.savePack(p);
619
+ return;
620
+ }
621
+ throw err;
622
+ }
623
+ await this.checkKey(res.account_key);
624
+ const ids = new Map((res.objects ?? []).map((o) => [str(o.key), str(o.object_id)]));
625
+ for (const it of p.items)
626
+ it.objectId = ids.get(it.key) ?? '';
627
+ p.reservation = res;
628
+ p.state = 'reserved';
629
+ await p.store.savePack(p);
630
+ }
631
+ async build(p) {
632
+ const key = await this.checkKey(p.reservation.account_key);
633
+ const built = await this.buildPack(p, key);
634
+ if ('changed' in built) {
635
+ await this.done(p.store, built.changed.map(([it, err]) => [it, undefined, err, true]));
636
+ // The reservation names files that changed: the rest go under a new one.
637
+ const gone = new Set(built.changed.map(([it]) => it));
638
+ p.items = p.items.filter((it) => !gone.has(it));
639
+ p.state = p.items.length ? 'conflict' : 'failed';
640
+ p.rebuilds--; // not the pack's fault
641
+ await p.store.savePack(p);
642
+ return;
643
+ }
644
+ await p.store.putBytes(p, built.data);
645
+ p.size = built.data.length;
646
+ p.md5 = this.digests.md5(built.data);
647
+ p.sha256 = await this.digests.sha256(built.data);
648
+ p.signBody = { size: p.size, md5: p.md5, sha256: p.sha256, objects: built.objects };
649
+ p.state = 'built';
650
+ await p.store.savePack(p);
651
+ }
652
+ /**
653
+ * Every item read, checked against its queued hash, encrypted with a fresh key and appended to the pack, then
654
+ * decrypt-checked ("saved" means readable, section 19.3) and its key wrapped; or the items that changed since they
655
+ * were queued.
656
+ */
657
+ async buildPack(p, key) {
658
+ const pack = new tws1.Pack();
659
+ const built = [];
660
+ const changed = [];
661
+ try {
662
+ for (const it of p.items) {
663
+ let plain;
664
+ try {
665
+ plain = await it.read();
666
+ }
667
+ catch (err) {
668
+ changed.push([it, new FileChangedError(it.path ?? it.key, err instanceof Error ? err.message : String(err))]);
669
+ continue;
670
+ }
671
+ if (plain.length !== it.size || (await this.digests.sha256(plain)) !== it.sha256) {
672
+ changed.push([it, new FileChangedError(it.path ?? it.key)]);
673
+ continue;
674
+ }
675
+ const dek = this.newDataKey();
676
+ const stored = await this.encryptObject(dek, plain);
677
+ const off = await pack.appendNeedle(tws1.objectIdBytes(it.objectId), stored);
678
+ built.push({ it, dek, off, n: stored.length });
679
+ }
680
+ if (changed.length)
681
+ return { changed };
682
+ const data = pack.bytes();
683
+ for (const { it, dek, off, n } of built) {
684
+ let ok;
685
+ try {
686
+ const stored = await tws1.readNeedle(data, off, tws1.objectIdBytes(it.objectId), n);
687
+ ok = (await this.digests.sha256(await tws1.decrypt(dek, stored))) === it.sha256;
688
+ }
689
+ catch (err) {
690
+ if (!(err instanceof DataIntegrityError))
691
+ throw err;
692
+ ok = false;
693
+ }
694
+ if (!ok)
695
+ throw new Stop(new DataIntegrityError(`${it.key} didn't decrypt back to its source`, 'build'));
696
+ }
697
+ const objects = [];
698
+ for (const { it, dek, off, n } of built) {
699
+ const wrapped = await this.wrapKey(key.publicKey, key.account, tws1.objectIdBytes(it.objectId), dek);
700
+ objects.push({ object_id: it.objectId, offset: off, stored_size: n, wrapped_key: toBase64(wrapped), key_version: key.version });
701
+ }
702
+ return { data, objects };
703
+ }
704
+ finally {
705
+ for (const { dek } of built)
706
+ dek.fill(0);
707
+ }
708
+ }
709
+ async sign(p, signal) {
710
+ let signed;
711
+ try {
712
+ signed = (await this.control(`POST`, `${bucketPath(p.bucket)}/packs/${p.serverId}/sign`, p.signBody, p, signal)).data;
713
+ }
714
+ catch (err) {
715
+ if (err instanceof APIError && err.code === 'reservation_abandoned') {
716
+ p.state = 'conflict';
717
+ await p.store.savePack(p);
718
+ return;
719
+ }
720
+ if (err instanceof APIError && ['manifest_mismatch', 'bad_wrapped_key', 'invalid_request', 'pack_not_found'].includes(err.code))
721
+ throw new Stop(err);
722
+ throw err;
723
+ }
724
+ p.signed = signed;
725
+ p.state = 'signed';
726
+ await p.store.savePack(p);
727
+ }
728
+ /** A pack's one PUT. Replaced in tests. */
729
+ async send(p, signal) {
730
+ if (this.expiresSoon(p.signed)) {
731
+ p.state = 'built'; // the same manifest signs again
732
+ await p.store.savePack(p);
733
+ return;
734
+ }
735
+ const data = await p.store.getBytes(p);
736
+ if (!data || (await this.digests.sha256(data)) !== p.sha256) {
737
+ p.state = 'bad'; // the pack file is lost or damaged: a new reservation, fresh keys
738
+ await p.store.savePack(p);
739
+ return;
740
+ }
741
+ p.state = 'uploading';
742
+ await p.store.savePack(p);
743
+ let err;
744
+ this.budget.onRequest();
745
+ try {
746
+ await this.putMeasured(p.signed, data, signal);
747
+ }
748
+ catch (e) {
749
+ if (signal?.aborted)
750
+ throw signal.reason;
751
+ if (!(e instanceof TeepinError))
752
+ throw e;
753
+ err = e;
754
+ }
755
+ switch (storageOutcome(err)) {
756
+ case 'sent':
757
+ p.state = 'sent';
758
+ break;
759
+ case 'unknown':
760
+ p.state = 'unknown';
761
+ break;
762
+ case 'resign':
763
+ if (++p.resigns > 3)
764
+ throw new Stop(err);
765
+ p.state = 'built';
766
+ break;
767
+ case 'bad':
768
+ p.state = 'bad';
769
+ break;
770
+ case 'unsent':
771
+ p.state = 'signed';
772
+ if (!this.budget.tryRetry())
773
+ await this.wait(p, p.attempts, err, signal);
774
+ p.attempts++;
775
+ break;
776
+ case 'busy':
777
+ p.state = 'signed';
778
+ await this.wait(p, p.attempts, err, signal);
779
+ p.attempts++;
780
+ break;
781
+ default:
782
+ throw new Stop(err);
783
+ }
784
+ await p.store.savePack(p);
785
+ }
786
+ async commit(p, signal) {
787
+ const unknown = p.state === 'unknown' || p.state === 'uploading';
788
+ if (p.state === 'sent') {
789
+ p.state = 'committing';
790
+ await p.store.savePack(p);
791
+ }
792
+ let status;
793
+ let res;
794
+ try {
795
+ ({ status, data: res } = await this.control(`POST`, `${bucketPath(p.bucket)}/packs/${p.serverId}/commit`, undefined, p, signal, 70000));
796
+ }
797
+ catch (err) {
798
+ if (!(err instanceof APIError))
799
+ throw err;
800
+ if (err.code === 'reservation_abandoned')
801
+ p.state = 'conflict';
802
+ else if (err.code === 'pack_mismatch')
803
+ p.state = 'bad';
804
+ else if (err.code === 'pack_not_signed')
805
+ p.state = 'built';
806
+ else if (err.code === 'bucket_not_found')
807
+ throw new Stop(err);
808
+ else
809
+ throw err;
810
+ await p.store.savePack(p);
811
+ return;
812
+ }
813
+ if (status === 202) {
814
+ p.verifies++;
815
+ if (unknown || p.verifies > COMMIT_VERIFY_TRIES) {
816
+ p.state = 'signed'; // not there: send the same bytes again
817
+ p.verifies = 0;
818
+ }
819
+ else {
820
+ await this.client.sleep(1000, signal);
821
+ }
822
+ await p.store.savePack(p);
823
+ return;
824
+ }
825
+ await this.deliver(p, res);
826
+ p.state = 'committed';
827
+ await p.store.dropBytes(p);
828
+ await p.store.savePack(p);
829
+ }
830
+ /** A new reservation and fresh keys for the pack's items (section 17.3). */
831
+ async renew(p) {
832
+ p.rebuilds++;
833
+ if (p.rebuilds > MAX_REBUILDS)
834
+ throw new Stop(new TeepinError(`the pack was rebuilt ${MAX_REBUILDS} times: ${p.lastError || p.state}`));
835
+ p.lastError = p.state;
836
+ await p.store.dropBytes(p);
837
+ p.idem = uuid7();
838
+ p.reservation = {};
839
+ p.signBody = {};
840
+ p.signed = {};
841
+ p.verifies = 0;
842
+ p.resigns = 0;
843
+ for (const it of p.items)
844
+ it.objectId = '';
845
+ p.state = 'new';
846
+ await p.store.savePack(p);
847
+ }
848
+ async deliver(p, res) {
849
+ const when = new Date(str(res.committed_at) || NaN);
850
+ const backend = str(res.backend);
851
+ const objects = res.objects ?? [];
852
+ const byId = new Map(objects.map((o) => [str(o.object_id), o]));
853
+ const byKey = new Map(objects.map((o) => [str(o.key), o]));
854
+ const done = [];
855
+ for (const it of p.items) {
856
+ if (it.result || it.error !== undefined)
857
+ continue;
858
+ const r = byId.get(it.objectId) ?? byKey.get(it.key);
859
+ if (!r)
860
+ done.push([it, undefined, new TeepinError(`the commit didn't report ${it.key}`), true]);
861
+ else if (r.result === 'saved') {
862
+ const obj = {
863
+ id: str(r.object_id) || it.objectId,
864
+ key: it.key,
865
+ size: it.size,
866
+ contentType: it.contentType || undefined,
867
+ metadata: it.metadata,
868
+ checksumSha256: it.sha256,
869
+ etag: it.sha256,
870
+ partCount: 0,
871
+ status: 'available',
872
+ backend,
873
+ createdAt: when,
874
+ updatedAt: when,
875
+ uploadedAt: when,
876
+ };
877
+ done.push([it, obj, undefined, true]);
878
+ }
879
+ else {
880
+ const code = str(r.error) || 'upload_failed';
881
+ done.push([it, undefined, apiError({ statusCode: code === 'precondition_failed' ? 412 : 409, message: `${it.key}: ${code.replaceAll('_', ' ')}`, code }), true]);
882
+ }
883
+ }
884
+ await this.done(p.store, done);
885
+ }
886
+ // --- putObject: shared packs ----------------------------------------------------
887
+ /**
888
+ * Stores one object of up to 16 MiB in a pack shared with the other calls waiting now, and returns it once its pack
889
+ * commits. A waiting call dispatches the queue itself whenever a slot comes free (release wakes it), so a slot freed
890
+ * anywhere serves the queue. The wait for a slot is bounded by uploadWait: an item no pack took by then leaves the
891
+ * queue (so it certainly wasn't saved) with StorageUnavailableError; once a pack has it, the pack's own bounded waits
892
+ * decide (section 22.4). An abort while it waits takes it out of the queue; once a pack has it, the pack goes on (it
893
+ * is shared) and the call stops waiting for it.
894
+ */
895
+ async put(bucket, key, data, opts = {}, signal) {
896
+ signal?.throwIfAborted();
897
+ const sha = await this.digests.sha256(data);
898
+ const it = new Item(key, data.length, sha, async () => data, opts.contentType, opts.metadata, Boolean(opts.ifNoneMatch), normalizeIfMatch(opts.ifMatch));
899
+ it.done = new Deferred();
900
+ this.queue.push([bucket, it]);
901
+ this.limit.setPriority(true);
902
+ const wait = this.client.uploadWait;
903
+ const deadline = Date.now() + wait;
904
+ const leave = () => {
905
+ const queued = this.queue.some(([, i]) => i === it);
906
+ if (queued) {
907
+ this.queue = this.queue.filter(([, i]) => i !== it);
908
+ this.limit.setPriority(this.queue.length > 0);
909
+ }
910
+ return queued;
911
+ };
912
+ try {
913
+ while (!it.picked) {
914
+ // Registered before dispatching, so a slot freed meanwhile wakes this call.
915
+ const woken = new Deferred();
916
+ it.wake = woken;
917
+ this.slotWaiters.push(woken);
918
+ this.dispatch();
919
+ if (it.picked)
920
+ break;
921
+ const left = deadline - Date.now();
922
+ if (left <= 0)
923
+ break;
924
+ let timer;
925
+ try {
926
+ // A timer past 2^31 - 1 ms fires at once in Node and browsers, so a very long uploadWait waits in slices.
927
+ const slice = Math.min(left, MAX_TIMER_MS);
928
+ await abortable(Promise.race([woken.promise, new Promise((r) => (timer = setTimeout(r, slice)))]), signal);
929
+ }
930
+ finally {
931
+ clearTimeout(timer);
932
+ }
933
+ }
934
+ }
935
+ catch (err) {
936
+ leave();
937
+ throw err;
938
+ }
939
+ if (!it.picked && leave())
940
+ throw new StorageUnavailableError(`no upload slot came free within ${Math.round(wait / 1000)} s; ${key} wasn't saved`);
941
+ await abortable(it.done.promise, signal);
942
+ if (it.error !== undefined)
943
+ throw it.error;
944
+ if (!it.result)
945
+ throw new TeepinError(`${key}: no result`);
946
+ return it.result;
947
+ }
948
+ /** Forms a pack from what's waiting whenever a send slot is free. Replaced in tests. */
949
+ dispatch() {
950
+ while (this.queue.length && this.limit.tryAcquire()) {
951
+ const bucket = this.queue[0][0];
952
+ const waiting = this.queue.filter(([b]) => b === bucket).map(([, it]) => it);
953
+ const [batch] = nextPack(waiting, (i) => i.size, (i) => i.key, this.packTarget(), this.sizes.packMaxObjects);
954
+ const chosen = new Set(batch);
955
+ this.queue = this.queue.filter(([, it]) => !chosen.has(it));
956
+ this.limit.setPriority(this.queue.length > 0);
957
+ for (const it of batch) {
958
+ it.picked = true;
959
+ it.wake?.resolve(); // its caller may be waiting for a slot: it has one now
960
+ }
961
+ void this.runShared(new Pack(bucket, batch, this.memory));
962
+ }
963
+ }
964
+ async runShared(p) {
965
+ let outcome = 'failed';
966
+ try {
967
+ await this.drive(p);
968
+ outcome = p.throttled ? 'throttled' : p.state === 'committed' ? 'ok' : 'failed';
969
+ }
970
+ catch (err) {
971
+ for (const it of p.items)
972
+ if (!it.result && it.error === undefined)
973
+ it.error = err; // every caller gets its answer
974
+ }
975
+ finally {
976
+ for (const it of p.items)
977
+ it.done?.resolve();
978
+ this.release(outcome);
979
+ }
980
+ }
981
+ /**
982
+ * Every slot comes back through here, from putObject's packs and a run's alike, and putObject's waiting calls are
983
+ * woken to dispatch: while they wait, a run's next pack waits too (AdaptiveLimit.priority).
984
+ */
985
+ release(outcome) {
986
+ this.limit.release(outcome);
987
+ const waiters = this.slotWaiters;
988
+ this.slotWaiters = [];
989
+ for (const w of waiters)
990
+ w.resolve();
991
+ }
992
+ // --- empty objects, large objects and streams ------------------------------------
993
+ /**
994
+ * An empty object: the metadata-only route (section 23.5, A2), which carries no bytes (a pack holds none, and an
995
+ * upload needs none). Retried by the client as a safe call; no rounds of waiting.
996
+ */
997
+ async putEmpty(bucket, key, opts = {}, signal) {
998
+ const body = { key };
999
+ if (opts.contentType)
1000
+ body.content_type = opts.contentType;
1001
+ if (opts.metadata && Object.keys(opts.metadata).length)
1002
+ body.metadata = opts.metadata;
1003
+ if (opts.ifNoneMatch)
1004
+ body.if_none_match = true;
1005
+ const ifMatch = normalizeIfMatch(opts.ifMatch);
1006
+ if (ifMatch)
1007
+ body.if_match = ifMatch;
1008
+ try {
1009
+ const { data } = await this.client.call({ method: 'POST', path: `${bucketPath(bucket)}/object/empty`, json: body, mode: 'safe', signal });
1010
+ return objectFrom(data);
1011
+ }
1012
+ catch (err) {
1013
+ if (signal?.aborted)
1014
+ throw signal.reason;
1015
+ if (err instanceof APIError &&
1016
+ (err.code === 'direct_upload_unsupported' || err.statusCode === 501 || (err.statusCode === 404 && (err.code === '' || err.code === 'not_found')))) {
1017
+ throw unsupported(err);
1018
+ }
1019
+ if (err instanceof PreconditionFailedError && opts.ifNoneMatch && (err.attempts ?? 1) > 1) {
1020
+ throw new PreconditionFailedError({
1021
+ statusCode: 412,
1022
+ code: err.code,
1023
+ message: `${err.serverMessage}; if this upload was retried, an earlier attempt may have stored it`,
1024
+ requestId: err.requestId,
1025
+ });
1026
+ }
1027
+ throw err;
1028
+ }
1029
+ }
1030
+ /** An object over the pack limit from bytes, a Blob or any seekable source, in parts. Kept in memory: nothing could resume it after a crash. */
1031
+ async putLarge(bucket, key, source, opts = {}, signal) {
1032
+ const psize = this.partSize(source.size, opts.partSize ?? 0, key);
1033
+ let hashed;
1034
+ try {
1035
+ hashed = await this.hashParts(source, psize);
1036
+ }
1037
+ catch (err) {
1038
+ if (signal?.aborted)
1039
+ throw signal.reason;
1040
+ throw new FileChangedError(key, err instanceof Error ? err.message : String(err));
1041
+ }
1042
+ const u = new Large(bucket, key, source.size, hashed.whole, (o, n) => source.readAt(o, n), {
1043
+ contentType: opts.contentType,
1044
+ metadata: opts.metadata,
1045
+ ifNoneMatch: opts.ifNoneMatch,
1046
+ ifMatch: normalizeIfMatch(opts.ifMatch),
1047
+ partSize: psize,
1048
+ partHashes: hashed.parts,
1049
+ });
1050
+ return this.parts.run(u, opts.onProgress ? new Progress(source.size, opts.onProgress) : undefined, signal);
1051
+ }
1052
+ /**
1053
+ * A stream over the pack limit whose size isn't known (section 22.1, E2 b), in parts held in memory (concurrency of
1054
+ * them, the sizes' streamConcurrency unless given). Nothing could resume it after a crash.
1055
+ */
1056
+ async putStream(bucket, key, source, opts = {}, signal) {
1057
+ const s = new StreamUpload(bucket, key, this.streamPartSize(opts.expectedSize ?? 0, opts.partSize ?? 0, key), {
1058
+ contentType: opts.contentType,
1059
+ metadata: opts.metadata,
1060
+ ifNoneMatch: opts.ifNoneMatch,
1061
+ ifMatch: normalizeIfMatch(opts.ifMatch),
1062
+ });
1063
+ const link = this.speed.link;
1064
+ // Its parts were shrunk for a slow link: running out of them is the connection's speed (section 22.7).
1065
+ if (link && s.partSize < streamPartSize(opts.expectedSize ?? 0, opts.partSize ?? 0, this.sizes))
1066
+ s.slow = link;
1067
+ const n = opts.concurrency && opts.concurrency > 0 ? opts.concurrency : this.sizes.streamConcurrency;
1068
+ return this.parts.runStream(s, source, n, opts.onProgress ? new Progress(-1, opts.onProgress) : undefined, signal);
1069
+ }
1070
+ /**
1071
+ * A new large upload's part size for size bytes, by this engine's rules, at the speed measured so far: TooLargeError
1072
+ * with reason connection_speed when it can't be sent at it (section 22.7). name is for the error.
1073
+ */
1074
+ partSize(size, want = 0, name = '') {
1075
+ return directPartSize(size, want, this.sizes, this.speed.link, name);
1076
+ }
1077
+ /** A new stream's part size (section 22.1, E2 b), at the speed measured so far, as partSize. */
1078
+ streamPartSize(expectedSize = 0, want = 0, name = '') {
1079
+ return streamPartSize(expectedSize, want, this.sizes, this.speed.link, name);
1080
+ }
1081
+ /**
1082
+ * In one pass: the whole source's SHA-256 (declared at create) and each part's (checked as the part is sent, D1).
1083
+ * Replaced in tests.
1084
+ */
1085
+ async hashParts(source, partSize) {
1086
+ if (source.hashParts)
1087
+ return source.hashParts(partSize);
1088
+ const whole = this.digests.incremental();
1089
+ const parts = new Map();
1090
+ for (const p of tws1.parts(source.size, partSize)) {
1091
+ const data = await source.readAt(p.plainFirst, p.plainSize);
1092
+ if (data.length !== p.plainSize)
1093
+ throw new TeepinError('the source changed size while it was read');
1094
+ whole.update(data);
1095
+ parts.set(p.number, await this.digests.sha256(data));
1096
+ }
1097
+ return { whole: whole.hex(), parts };
1098
+ }
1099
+ // --- upload runs ------------------------------------------------------------------
1100
+ /** A run's first step: journaled (on disk unless journal is false), and every file hashed. */
1101
+ async beginFiles(bucket, entries, opts, signal) {
1102
+ const host = this.runHost;
1103
+ const place = opts.journal === false ? undefined : await host.create();
1104
+ const j = place?.journal ?? new MemoryJournal();
1105
+ const now = Date.now();
1106
+ const store = new RunStore(j, place);
1107
+ try {
1108
+ await j.put('run', 'run', {
1109
+ id: place?.id ?? uuid7(),
1110
+ kind: 'upload',
1111
+ bucket,
1112
+ created: now,
1113
+ updated: now,
1114
+ status: 'open',
1115
+ options: { if_none_match: Boolean(opts.ifNoneMatch) },
1116
+ });
1117
+ const hashed = new Array(entries.length);
1118
+ await pool(this.limit.ceiling, [...entries.keys()], signal, async (i) => {
1119
+ hashed[i] = await host.hashFile(entries[i][1]);
1120
+ });
1121
+ return await this.journalFiles(bucket, store, entries, hashed, opts);
1122
+ }
1123
+ catch (err) {
1124
+ await this.abandon(store, place); // nothing is under way
1125
+ throw signal?.aborted ? signal.reason : err;
1126
+ }
1127
+ }
1128
+ async journalFiles(bucket, store, entries, hashed, opts) {
1129
+ const j = store.journal;
1130
+ const host = this.runHost;
1131
+ const results = new Map();
1132
+ const items = [];
1133
+ const rows = [];
1134
+ entries.forEach(([key, path], i) => {
1135
+ const h = hashed[i];
1136
+ const id = j.nextId('files');
1137
+ if (h instanceof Error) {
1138
+ rows.push({ table: 'files', id, row: { _id: id, path, key, size: -1, mtime: '', sha256: '', state: 'failed', error: describe(h) } });
1139
+ results.set(id, { key, path, status: 'failed', error: h });
1140
+ return;
1141
+ }
1142
+ const ct = opts.contentType || host.contentType(path) || '';
1143
+ rows.push({
1144
+ table: 'files',
1145
+ id,
1146
+ row: {
1147
+ _id: id,
1148
+ path,
1149
+ key,
1150
+ size: h.size,
1151
+ mtime: h.mtime,
1152
+ sha256: h.sha256,
1153
+ content_type: ct,
1154
+ metadata: { ...(opts.metadata ?? {}) },
1155
+ if_none_match: Boolean(opts.ifNoneMatch),
1156
+ if_match: '',
1157
+ state: 'queued',
1158
+ },
1159
+ });
1160
+ items.push(this.fileItem(key, path, h.size, h.sha256, ct, opts.metadata, Boolean(opts.ifNoneMatch), id));
1161
+ });
1162
+ await j.write([...rows, ...store.touch()]);
1163
+ return { bucket, store, place: store.place, items, packs: [], results };
1164
+ }
1165
+ fileItem(key, path, size, sha, contentType, metadata, ifNoneMatch, fileId) {
1166
+ const host = this.runHost;
1167
+ return new Item(key, size, sha, () => host.readWhole(path, size), contentType, metadata, ifNoneMatch, '', path, fileId);
1168
+ }
1169
+ /**
1170
+ * A run's middle and last steps: its empty files put (the A2 route), then its queued items packed, its packs already
1171
+ * under way and its large files sent, every file's result recorded and the run finished. partSize is for files sent
1172
+ * in parts (0: the default).
1173
+ */
1174
+ async runState(state, onProgress, partSize = 0, signal) {
1175
+ try {
1176
+ const empties = state.items.filter((it) => it.size === 0);
1177
+ for (const it of empties) {
1178
+ try {
1179
+ const obj = await this.putEmpty(state.bucket, it.key, { contentType: it.contentType || undefined, metadata: it.metadata, ifNoneMatch: it.ifNoneMatch }, signal);
1180
+ await this.done(state.store, [[it, obj, undefined, true]]);
1181
+ }
1182
+ catch (err) {
1183
+ if (signal?.aborted || !(err instanceof TeepinError))
1184
+ throw err;
1185
+ await this.done(state.store, [[it, undefined, err, emptyPutFinal(err)]]);
1186
+ }
1187
+ }
1188
+ }
1189
+ catch (err) {
1190
+ await this.abandon(state.store, state.place); // interrupted before any pack started
1191
+ throw signal?.aborted ? signal.reason : err;
1192
+ }
1193
+ // The empty files stay in state.items, so send reports them with the rest (it packs only sizes above 0).
1194
+ return this.sendRun(state, onProgress, partSize, signal);
1195
+ }
1196
+ async sendRun(state, onProgress, partSize, signal) {
1197
+ const { bucket, store, place, items } = state;
1198
+ const lim = this.packObjectLimit();
1199
+ // A file whose upload in parts a crash left open goes on in parts, whatever the speed now (section 22.7).
1200
+ const inParts = new Set(store.journal
1201
+ .rows('uploads')
1202
+ .filter((r) => r.state !== 'completed' && r.state !== 'failed')
1203
+ .map((r) => str(r.file_id)));
1204
+ const large = items.filter((it) => it.size > lim || (it.size > 0 && it.fileId !== undefined && inParts.has(it.fileId)));
1205
+ const chosen = new Set(large);
1206
+ const small = items.filter((it) => it.size > 0 && !chosen.has(it));
1207
+ // The small files are packed as slots come free, each pack at the speed measured by then (section 22.7).
1208
+ const packs = [...state.packs];
1209
+ let total = 0;
1210
+ for (const p of packs)
1211
+ for (const it of p.items)
1212
+ total += it.size;
1213
+ for (const it of [...small, ...large])
1214
+ total += it.size;
1215
+ const prog = new Progress(total, onProgress);
1216
+ const stop = [];
1217
+ const closer = new RunCloser();
1218
+ // The run's tasks stop when the caller aborts, or when this step fails.
1219
+ const { ctl, unlink } = linked(signal);
1220
+ let failure;
1221
+ const failed = (err) => {
1222
+ if (failure)
1223
+ return;
1224
+ failure = { err };
1225
+ ctl.abort(err);
1226
+ };
1227
+ const pack = async (p) => {
1228
+ let outcome = 'failed';
1229
+ try {
1230
+ if (stop.length) {
1231
+ await this.fail(p, stop[0], false);
1232
+ return;
1233
+ }
1234
+ await this.drive(p, ctl.signal);
1235
+ outcome = p.throttled ? 'throttled' : p.state === 'committed' ? 'ok' : 'failed';
1236
+ for (const it of p.items)
1237
+ if (it.result)
1238
+ prog.add(it.size);
1239
+ }
1240
+ catch (err) {
1241
+ if (ctl.signal.aborted || !stopsRun(err))
1242
+ throw err;
1243
+ stop.push(err);
1244
+ }
1245
+ finally {
1246
+ this.release(outcome);
1247
+ }
1248
+ };
1249
+ const go = async (p) => {
1250
+ try {
1251
+ await pack(p);
1252
+ }
1253
+ finally {
1254
+ if (closer.ended(true))
1255
+ await this.abandon(store, place); // the last task of an interrupted run releases it
1256
+ }
1257
+ };
1258
+ const tasks = [];
1259
+ try {
1260
+ const start = (p) => {
1261
+ closer.started();
1262
+ tasks.push(go(p).catch(failed));
1263
+ };
1264
+ for (const p of [...packs]) {
1265
+ await this.limit.acquire(ctl.signal);
1266
+ if (failure) {
1267
+ this.release('failed');
1268
+ throw failure.err;
1269
+ }
1270
+ start(p);
1271
+ }
1272
+ for (let rest = small; rest.length;) {
1273
+ await this.limit.acquire(ctl.signal);
1274
+ if (failure) {
1275
+ this.release('failed');
1276
+ throw failure.err;
1277
+ }
1278
+ let chunk;
1279
+ [chunk, rest] = nextPack(rest, (i) => i.size, (i) => i.key, this.packTarget(), this.sizes.packMaxObjects);
1280
+ const p = new Pack(bucket, chunk, store);
1281
+ packs.push(p);
1282
+ start(p);
1283
+ }
1284
+ for (const it of large) {
1285
+ if (stop.length) {
1286
+ await this.done(store, [[it, undefined, stop[0], false]]);
1287
+ continue;
1288
+ }
1289
+ try {
1290
+ const u = await this.largeItem(bucket, it, store, partSize);
1291
+ const obj = await this.parts.run(u, prog, ctl.signal);
1292
+ await this.done(store, [[it, obj, undefined, true]]);
1293
+ }
1294
+ catch (err) {
1295
+ if (ctl.signal.aborted)
1296
+ throw ctl.signal.reason;
1297
+ if (stopsRun(err)) {
1298
+ await this.done(store, [[it, undefined, err, false]]);
1299
+ stop.push(err);
1300
+ }
1301
+ else if (err instanceof StorageUnavailableError)
1302
+ await this.done(store, [[it, undefined, err, false]]);
1303
+ else if (err instanceof TeepinError)
1304
+ await this.done(store, [[it, undefined, err, true]]);
1305
+ else
1306
+ throw err;
1307
+ }
1308
+ }
1309
+ // A task that fails stops the run at once (its controller aborts), as joining it first would.
1310
+ await abortable(Promise.all(tasks), ctl.signal);
1311
+ if (failure)
1312
+ throw failure.err;
1313
+ }
1314
+ catch (err) {
1315
+ // Interrupted, or failed: every pack still under way is stopped and waited for (up to the cap); then the run's
1316
+ // journal is closed and its lock released, so resumeUploads in this same process can finish it. Never while a pack
1317
+ // might still commit (it could be saved twice): whichever ends last, this step or the last pack task, releases it.
1318
+ ctl.abort(err);
1319
+ await joinCapped(tasks, this.cleanupMs);
1320
+ if (closer.ended(false))
1321
+ await this.abandon(store, place);
1322
+ unlink();
1323
+ throw signal?.aborted ? signal.reason : failure ? failure.err : err;
1324
+ }
1325
+ unlink();
1326
+ const seen = new Set(items);
1327
+ const every = [...items, ...packs.flatMap((p) => p.items).filter((it) => !seen.has(it))];
1328
+ const results = state.results;
1329
+ for (const it of every) {
1330
+ if (it.fileId === undefined || results.has(it.fileId))
1331
+ continue;
1332
+ // pending: a passing error (storage down, a revoked key) left it journaled for resumeUploads; failed is final.
1333
+ const status = it.result ? 'saved' : it.final ? 'failed' : 'pending';
1334
+ results.set(it.fileId, { key: it.key, path: it.path, status, object: it.result, error: it.error });
1335
+ }
1336
+ await this.finish(store, place);
1337
+ if (stop.length)
1338
+ throw stop[0];
1339
+ return [...results.entries()].sort(([a], [b]) => Number(a) - Number(b)).map(([, r]) => r);
1340
+ }
1341
+ /** A large file's upload: the one its journal left unfinished, or a new one. */
1342
+ async largeItem(bucket, it, store, partSize) {
1343
+ const host = this.runHost;
1344
+ const path = it.path ?? '';
1345
+ const readAt = (off, n) => host.readAt(path, off, n);
1346
+ const open = store.journal
1347
+ .rows('uploads')
1348
+ .filter((r) => str(r.file_id) === it.fileId && r.state !== 'completed' && r.state !== 'failed')
1349
+ .sort((a, b) => Number(b.updated) - Number(a.updated));
1350
+ const common = { contentType: it.contentType, metadata: it.metadata, ifNoneMatch: it.ifNoneMatch, ifMatch: it.ifMatch, path, store, fileId: it.fileId };
1351
+ if (open.length) {
1352
+ const u = await Large.fromRow(open[0], readAt, common);
1353
+ if (u.state === 'new' && u.plain.size === 0) {
1354
+ // Never created, so no part hashes were journaled: take them now, against the queued hash.
1355
+ const h = await this.hashFileParts(it, readAt, u.partSize);
1356
+ if (h.whole !== it.sha256)
1357
+ throw new FileChangedError(path);
1358
+ u.plain = h.parts;
1359
+ }
1360
+ return u;
1361
+ }
1362
+ const psize = this.partSize(it.size, partSize, it.key);
1363
+ const h = await this.hashFileParts(it, readAt, psize);
1364
+ if (h.whole !== it.sha256)
1365
+ throw new FileChangedError(path);
1366
+ return new Large(bucket, it.key, it.size, it.sha256, readAt, { ...common, partSize: psize, partHashes: h.parts });
1367
+ }
1368
+ /** A large file's whole and part hashes; a file that can't be read the same way is a changed file. */
1369
+ async hashFileParts(it, readAt, partSize) {
1370
+ const host = this.runHost;
1371
+ const path = it.path ?? '';
1372
+ const source = { size: it.size, readAt };
1373
+ if (host.hashParts)
1374
+ source.hashParts = (ps) => host.hashParts(path, it.size, ps);
1375
+ try {
1376
+ return await this.hashParts(source, partSize);
1377
+ }
1378
+ catch (err) {
1379
+ if (err instanceof FileChangedError)
1380
+ throw err;
1381
+ throw new FileChangedError(path || it.key, err instanceof Error ? err.message : String(err));
1382
+ }
1383
+ }
1384
+ /** An interrupted run, left for resumeUploads: its journal closed and its lock released (its directory stays). Safe to call twice. */
1385
+ async abandon(store, place) {
1386
+ if (place)
1387
+ await place.release();
1388
+ else
1389
+ await store.journal.close().catch(() => undefined);
1390
+ }
1391
+ async finish(store, place) {
1392
+ const j = store.journal;
1393
+ const counts = countStates(j.rows('files'));
1394
+ const pending = (counts.get('queued') ?? 0) + (counts.get('packed') ?? 0);
1395
+ const run = j.get('run', 'run');
1396
+ if (pending) {
1397
+ if (run)
1398
+ await j.put('run', 'run', { ...run, updated: Date.now(), status: 'open' });
1399
+ await this.abandon(store, place);
1400
+ return;
1401
+ }
1402
+ await j.close();
1403
+ if (!place)
1404
+ return;
1405
+ const host = this.runHost;
1406
+ await host.history(`run ${str(run?.id) || place.id} bucket=${str(run?.bucket)} saved=${counts.get('saved') ?? 0} failed=${counts.get('failed') ?? 0}`);
1407
+ await place.remove();
1408
+ const size = await host.size().catch(() => 0);
1409
+ const warnAt = envNumber(STATE_WARN_ENV, STATE_WARN_BYTES);
1410
+ if (size > warnAt)
1411
+ this.warn(`teepin: the upload state directory holds ${size} bytes; cleanUploads() removes old runs`);
1412
+ }
1413
+ // --- resume, listing and cleanup ------------------------------------------------------
1414
+ /** Continues every interrupted run no other process (or run in this one) is working on, and returns their files' results. */
1415
+ async resume(onProgress, signal) {
1416
+ const host = this.runHost;
1417
+ const out = [];
1418
+ for (const id of await host.ids()) {
1419
+ signal?.throwIfAborted();
1420
+ let place;
1421
+ try {
1422
+ place = await host.take(id);
1423
+ }
1424
+ catch (err) {
1425
+ // A damaged journal: never skipped silently, and never in the way of the other runs.
1426
+ this.warn(`teepin: upload run ${id} can't be resumed: ${err instanceof Error ? err.message : String(err)}`);
1427
+ continue;
1428
+ }
1429
+ if (!place)
1430
+ continue;
1431
+ let state;
1432
+ try {
1433
+ state = await this.beginResume(place, signal);
1434
+ }
1435
+ catch (err) {
1436
+ await place.release();
1437
+ throw signal?.aborted ? signal.reason : err;
1438
+ }
1439
+ out.push(...(await this.runState(state, onProgress, 0, signal))); // it releases the run itself, as it ends
1440
+ }
1441
+ return out;
1442
+ }
1443
+ /** A run's first step on resume: its journal read back, its queued files checked again (a changed one fails, and its large upload is cancelled). */
1444
+ async beginResume(place, signal) {
1445
+ const store = new RunStore(place.journal, place);
1446
+ const { state, changed } = await this.readRun(place, store);
1447
+ for (const [fid, it, path] of changed) {
1448
+ signal?.throwIfAborted();
1449
+ await this.endUploads(store, fid); // its large upload, if any: cancelled, its key deleted
1450
+ const err = new FileChangedError(path);
1451
+ await this.done(store, [[it, undefined, err, true]]);
1452
+ state.results.set(fid, { key: it.key, path, status: 'failed', error: err });
1453
+ }
1454
+ return state;
1455
+ }
1456
+ async readRun(place, store) {
1457
+ const host = this.runHost;
1458
+ const j = store.journal;
1459
+ const bucket = str(j.get('run', 'run')?.bucket);
1460
+ const results = new Map();
1461
+ const byPack = new Map();
1462
+ const queued = [];
1463
+ const changed = [];
1464
+ for (const f of j.rows('files')) {
1465
+ const id = str(f._id);
1466
+ const key = str(f.key);
1467
+ const path = str(f.path);
1468
+ if (f.state === 'saved') {
1469
+ results.set(id, { key, path, status: 'saved', object: reviveObject(f.result) });
1470
+ continue;
1471
+ }
1472
+ if (f.state === 'failed') {
1473
+ results.set(id, { key, path, status: 'failed', error: new TeepinError(str(f.error)) });
1474
+ continue;
1475
+ }
1476
+ const it = this.fileItem(key, path, int(f.size), str(f.sha256), str(f.content_type), f.metadata ?? {}, Boolean(f.if_none_match), id);
1477
+ it.objectId = str(f.object_id);
1478
+ if (f.state === 'packed' && f.pack) {
1479
+ it.journaled = `${str(f.pack)}|${it.objectId}`;
1480
+ const list = byPack.get(str(f.pack)) ?? [];
1481
+ list.push(it);
1482
+ byPack.set(str(f.pack), list);
1483
+ }
1484
+ else {
1485
+ // Queued: checked again as it's queued again (section 17.5); a change fails it.
1486
+ const h = await host.hashFile(path);
1487
+ if (h instanceof Error || h.size !== int(f.size) || h.sha256 !== str(f.sha256)) {
1488
+ changed.push([id, it, path]);
1489
+ continue;
1490
+ }
1491
+ queued.push(it);
1492
+ }
1493
+ }
1494
+ const packs = [];
1495
+ for (const r of j.rows('packs')) {
1496
+ const pid = str(r._id);
1497
+ if (r.state === 'committed' || r.state === 'failed')
1498
+ continue;
1499
+ const its = byPack.get(pid);
1500
+ byPack.delete(pid);
1501
+ if (!its?.length)
1502
+ continue;
1503
+ const p = new Pack(bucket, its, store, pid);
1504
+ p.state = (str(r.state) || 'new');
1505
+ p.idem = str(r.idempotency_key);
1506
+ p.reservation = r.reservation ?? {};
1507
+ p.signBody = r.sign_body ?? {};
1508
+ p.signed = r.signed ?? {};
1509
+ p.fileName = str(r.file_name);
1510
+ p.size = int(r.size);
1511
+ p.md5 = str(r.md5);
1512
+ p.sha256 = str(r.sha256);
1513
+ p.attempts = int(r.attempts);
1514
+ if (p.state === 'uploading')
1515
+ p.state = 'unknown'; // the PUT may have landed: commit first
1516
+ packs.push(p);
1517
+ }
1518
+ for (const its of byPack.values())
1519
+ queued.push(...its); // packed into a pack that's gone: queue them again
1520
+ await this.sweepOrphans(place, j, packs);
1521
+ return { state: { bucket, store, place, items: queued, packs, results }, changed };
1522
+ }
1523
+ /** Pack files the journal doesn't name, and data keys of uploads that have ended, are deleted (section 17.12, E3). */
1524
+ async sweepOrphans(place, j, packs) {
1525
+ const keep = new Set(packs.map((p) => p.fileName).filter(Boolean));
1526
+ for (const name of await place.packNames().catch(() => []))
1527
+ if (!keep.has(name))
1528
+ await place.removePack(name).catch(() => undefined);
1529
+ const open = new Set(j
1530
+ .rows('uploads')
1531
+ .filter((r) => OPEN_UPLOAD.has(str(r.state)))
1532
+ .map((r) => str(r._id)));
1533
+ for (const name of await place.keyNames().catch(() => []))
1534
+ if (!open.has(name))
1535
+ await place.removeKey(name).catch(() => undefined);
1536
+ }
1537
+ /**
1538
+ * Cancels a file's unfinished large uploads at the storage network and deletes their journaled data keys (E3): a file
1539
+ * that won't be sent must not leave parts behind to be kept, and billed, until they expire.
1540
+ */
1541
+ async endUploads(store, fileId) {
1542
+ for (const r of store.journal.rows('uploads')) {
1543
+ if (str(r.file_id) !== fileId || !OPEN_UPLOAD.has(str(r.state)))
1544
+ continue;
1545
+ if (r.upload_id)
1546
+ await this.abortOne(str(r.bucket), str(r.upload_id));
1547
+ await store.journal.put('uploads', str(r._id), { ...r, state: 'failed', updated: Date.now() });
1548
+ await store.place?.removeKey(str(r._id)).catch(() => undefined);
1549
+ }
1550
+ }
1551
+ /** Cancels an upload: one call (the client's own retries, none while stopping), best effort, since the upload sweeper ends what it misses. */
1552
+ async abortOne(bucket, uploadId, stopping = false) {
1553
+ try {
1554
+ await this.client.send({
1555
+ method: 'DELETE',
1556
+ path: `${bucketPath(bucket)}/uploads/${encodeURIComponent(uploadId)}`,
1557
+ mode: 'safe',
1558
+ maxRetries: stopping ? 0 : undefined,
1559
+ timeout: this.cleanupMs,
1560
+ });
1561
+ }
1562
+ catch {
1563
+ // the upload sweeper ends it
1564
+ }
1565
+ }
1566
+ /** The runs kept in the state directory. */
1567
+ async listRuns() {
1568
+ const host = this.runHost;
1569
+ const out = [];
1570
+ const now = Date.now();
1571
+ for (const id of await host.ids()) {
1572
+ const seen = await host.peek(id).catch(() => undefined); // a damaged journal is left out, not fatal
1573
+ if (!seen)
1574
+ continue;
1575
+ const run = seen.journal.get('run', 'run');
1576
+ if (!run)
1577
+ continue;
1578
+ const counts = countStates(seen.journal.rows('files'));
1579
+ let files = 0;
1580
+ for (const n of counts.values())
1581
+ files += n;
1582
+ const updated = new Date(Number(run.updated));
1583
+ out.push({
1584
+ id: str(run.id) || id,
1585
+ bucket: str(run.bucket),
1586
+ created: new Date(Number(run.created)),
1587
+ updated,
1588
+ files,
1589
+ saved: counts.get('saved') ?? 0,
1590
+ failed: counts.get('failed') ?? 0,
1591
+ pending: (counts.get('queued') ?? 0) + (counts.get('packed') ?? 0),
1592
+ expired: now - updated.getTime() > RUN_EXPIRY_MS,
1593
+ inUse: seen.inUse,
1594
+ });
1595
+ }
1596
+ return out;
1597
+ }
1598
+ /**
1599
+ * Removes runs untouched for longer than olderThan ms that no process is working on, with a warning for each: never
1600
+ * silently. Their open large uploads are cancelled, and their data keys go with their journals (E3).
1601
+ */
1602
+ async clean(olderThan, signal) {
1603
+ const host = this.runHost;
1604
+ const removed = [];
1605
+ const cutoff = Date.now() - olderThan;
1606
+ for (const r of await this.listRuns()) {
1607
+ signal?.throwIfAborted();
1608
+ if (r.inUse || r.updated.getTime() > cutoff)
1609
+ continue;
1610
+ const place = await host.take(r.id).catch(() => undefined);
1611
+ if (!place)
1612
+ continue;
1613
+ try {
1614
+ for (const u of place.journal.rows('uploads')) {
1615
+ if (u.upload_id && OPEN_UPLOAD.has(str(u.state)))
1616
+ await this.abortOne(str(u.bucket), str(u.upload_id));
1617
+ }
1618
+ this.warn(`teepin: removing upload run ${r.id} (bucket ${r.bucket}): ${r.pending} files not uploaded, untouched since ${r.updated.toISOString()}`);
1619
+ await place.remove();
1620
+ removed.push(r);
1621
+ }
1622
+ catch (err) {
1623
+ await place.release();
1624
+ throw err;
1625
+ }
1626
+ }
1627
+ await host.trimHistory?.();
1628
+ return removed;
1629
+ }
1630
+ }
1631
+ // --- helpers ------------------------------------------------------------------------------------------------------------
1632
+ /** A storage PUT its own timer ended with no answer (putSigned): a ConnectionError, and evidence of a slow link. */
1633
+ class PutTimedOut extends ConnectionError {
1634
+ constructor(ms) {
1635
+ super(`no answer from the storage network in ${Math.round(ms)} ms`);
1636
+ }
1637
+ }
1638
+ /** An If-Match value as the control plane takes it: unquoted, or "*". */
1639
+ export function normalizeIfMatch(v) {
1640
+ if (!v)
1641
+ return '';
1642
+ return v === '*' ? '*' : unquote(v);
1643
+ }
1644
+ function countStates(rows) {
1645
+ const out = new Map();
1646
+ for (const r of rows)
1647
+ out.set(str(r.state), (out.get(str(r.state)) ?? 0) + 1);
1648
+ return out;
1649
+ }
1650
+ /** The states of a large upload that may still have parts at the storage network. */
1651
+ const OPEN_UPLOAD = new Set(['new', 'sending', 'completing']);
1652
+ function unsupported(err) {
1653
+ return apiError({
1654
+ statusCode: err.statusCode,
1655
+ message: "this deployment doesn't accept direct uploads, and this SDK uploads no other way",
1656
+ code: err.code || 'direct_upload_unsupported',
1657
+ requestId: err.requestId,
1658
+ });
1659
+ }
1660
+ //# sourceMappingURL=uploads.js.map