@3sln/trove 0.0.8 → 0.0.10

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 (78) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/encryption/envelope.js +46 -7
  3. package/packages/core/src/encryption/rotation.js +187 -68
  4. package/packages/core/src/uploads.js +130 -29
  5. package/packages/core/src/vfs.js +91 -5
  6. package/packages/server/src/adapters/staticAssets.js +14 -5
  7. package/packages/server/src/engine/index.js +1 -1
  8. package/packages/server/src/engine/providers/core.js +12 -0
  9. package/packages/server/src/index.js +45 -2
  10. package/packages/server/src/routes.js +59 -26
  11. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  12. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  13. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  14. package/packages/web/dist/index.html +2 -2
  15. package/packages/web/dist/sw.js +1 -1
  16. package/packages/web/src/bl/actions.js +1454 -137
  17. package/packages/web/src/bl/commands.js +103 -284
  18. package/packages/web/src/bl/context.js +69 -0
  19. package/packages/web/src/bl/index.js +136 -40
  20. package/packages/web/src/bl/intern.js +129 -0
  21. package/packages/web/src/bl/launcher.js +251 -0
  22. package/packages/web/src/bl/links.js +1 -1
  23. package/packages/web/src/bl/match.js +71 -0
  24. package/packages/web/src/bl/mentions.js +39 -0
  25. package/packages/web/src/bl/offline.js +0 -4
  26. package/packages/web/src/bl/openers.js +65 -33
  27. package/packages/web/src/bl/pluginInstall.js +42 -21
  28. package/packages/web/src/bl/queries.js +669 -0
  29. package/packages/web/src/bl/services.js +77 -141
  30. package/packages/web/src/bl/social.js +3 -1
  31. package/packages/web/src/bl/state.js +144 -0
  32. package/packages/web/src/bl/status.js +96 -0
  33. package/packages/web/src/bl/trust.js +109 -0
  34. package/packages/web/src/bl/viewState.js +60 -0
  35. package/packages/web/src/bl/views.js +64 -0
  36. package/packages/web/src/bl/watchQuery.js +73 -0
  37. package/packages/web/src/platform/api.js +31 -102
  38. package/packages/web/src/platform/commands.js +49 -9
  39. package/packages/web/src/platform/context.js +146 -51
  40. package/packages/web/src/platform/index.js +27 -20
  41. package/packages/web/src/platform/navigation.js +34 -4
  42. package/packages/web/src/platform/pickers.js +46 -0
  43. package/packages/web/src/platform/pluginHost.js +43 -10
  44. package/packages/web/src/platform/pluginRpc.js +6 -2
  45. package/packages/web/src/platform/spatialNav.js +0 -2
  46. package/packages/web/src/platform/viewport.js +1 -6
  47. package/packages/web/src/platform/voiceSearch.js +1 -3
  48. package/packages/web/src/platform/whenclause.js +22 -3
  49. package/packages/web/src/styles.css +74 -4
  50. package/packages/web/src/ui/activate.js +22 -0
  51. package/packages/web/src/ui/components/activityBar.js +7 -3
  52. package/packages/web/src/ui/components/activityPanel.js +10 -11
  53. package/packages/web/src/ui/components/adminView.js +191 -0
  54. package/packages/web/src/ui/components/collectionGate.js +4 -3
  55. package/packages/web/src/ui/components/commandPalette.js +24 -55
  56. package/packages/web/src/ui/components/editorArea.js +16 -13
  57. package/packages/web/src/ui/components/launcher.js +39 -225
  58. package/packages/web/src/ui/components/openers/index.js +7 -4
  59. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  60. package/packages/web/src/ui/components/overlays.js +158 -45
  61. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  62. package/packages/web/src/ui/components/pluginReview.js +62 -23
  63. package/packages/web/src/ui/components/pluginsView.js +30 -28
  64. package/packages/web/src/ui/components/settingsView.js +151 -58
  65. package/packages/web/src/ui/components/social.js +42 -62
  66. package/packages/web/src/ui/components/statusBar.js +30 -74
  67. package/packages/web/src/ui/components/views/grid.js +3 -2
  68. package/packages/web/src/ui/components/views/index.js +7 -62
  69. package/packages/web/src/ui/components/views/list.js +3 -2
  70. package/packages/web/src/ui/components/views/parts.js +15 -4
  71. package/packages/web/src/ui/compositions/workbench.js +166 -58
  72. package/packages/web/src/ui/region.js +53 -0
  73. package/packages/web/src/workbench.js +17 -15
  74. package/packages/web/dist/assets/main-y778bpte.js +0 -356
  75. package/packages/web/dist/assets/main-y778bpte.js.map +0 -106
  76. package/packages/web/dist/assets/styles-nfy8t3n1.css +0 -1
  77. package/packages/web/src/platform/overlay.js +0 -81
  78. package/packages/web/src/platform/workbench.js +0 -156
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/trove",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
6
6
  "repository": {
@@ -375,22 +375,48 @@ export async function decryptStream(rawKey, header, cipherStream, firstChunk = 0
375
375
  * @param {ReadableStream<Uint8Array>} plaintext
376
376
  * @param {{fingerprint: Uint8Array, plaintextSize: number, chunkSize?: number}} opts
377
377
  */
378
- export async function encryptStream(rawKey, plaintext, { fingerprint, plaintextSize, chunkSize = DEFAULT_CHUNK_SIZE } = {}) {
378
+ export async function encryptStream(rawKey, plaintext, { fingerprint, plaintextSize, chunkSize = DEFAULT_CHUNK_SIZE, resume = null, partial = false } = {}) {
379
379
  assertChunkSize(chunkSize);
380
380
  if (!fingerprint || fingerprint.length !== FINGERPRINT_BYTES) {
381
381
  throw TroveError.invalid(`A fingerprint must be ${FINGERPRINT_BYTES} bytes`);
382
382
  }
383
383
  const key = await importKey(rawKey);
384
- const noncePrefix = crypto.getRandomValues(new Uint8Array(NONCE_PREFIX_BYTES));
384
+ // Resuming continues ONE envelope across calls: same nonce prefix, next chunk index, no
385
+ // second header. It exists so a rotation can be interrupted part-way through a large
386
+ // object and carry on rather than start again — see encryption/rotation.js.
387
+ //
388
+ // The nonce is prefix + index, so continuing the sequence is what keeps every nonce used
389
+ // exactly once under this key. Restarting the index (or minting a fresh prefix and
390
+ // stitching the output together) would reuse nonces, and AES-GCM does not survive that:
391
+ // two chunks encrypted with the same key and nonce leak their XOR and forge each other's
392
+ // tags. That is the whole reason this takes the prefix rather than generating one.
393
+ if (resume && (!(resume.noncePrefix instanceof Uint8Array) || resume.noncePrefix.length !== NONCE_PREFIX_BYTES)) {
394
+ throw TroveError.invalid(`Resuming needs the original ${NONCE_PREFIX_BYTES}-byte nonce prefix`);
395
+ }
396
+ if (resume && !Number.isInteger(resume.index)) {
397
+ throw TroveError.invalid('Resuming needs the chunk index to continue from');
398
+ }
399
+ const noncePrefix = resume ? resume.noncePrefix : crypto.getRandomValues(new Uint8Array(NONCE_PREFIX_BYTES));
385
400
  const header = encodeHeader({
386
401
  version: VERSION, algorithm: ALG_AES_256_GCM, chunkSize, plaintextSize, noncePrefix, fingerprint,
387
402
  });
388
403
 
389
404
  const reader = plaintext.getReader();
390
405
  let held = new Uint8Array(0);
391
- let index = 0;
392
- let wroteHeader = false;
406
+ let index = resume ? resume.index : 0;
407
+ // A resumed stream is the MIDDLE of an object; the header went out with the first slice.
408
+ let wroteHeader = !!resume;
393
409
  let seen = 0;
410
+ // What THIS call is expected to consume. The header still names the whole object — it
411
+ // describes the object, not the call — so the guard below has to compare against the
412
+ // remainder or a legitimate resume looks like a truncated encrypt.
413
+ const expected = resume ? plaintextSize - resume.index * chunkSize : plaintextSize;
414
+ if (expected < 0) throw TroveError.invalid('Resuming past the end of the object');
415
+ // `partial` says this call does NOT finish the object — another will continue it. Such a
416
+ // call must be fed a whole number of chunks, because a short chunk is how the envelope
417
+ // marks the end: seal a partial one mid-object and every later chunk index is wrong and
418
+ // the plaintext length is a lie. The completeness check is skipped for the same reason —
419
+ // stopping early is the point, not a truncated encrypt.
394
420
 
395
421
  const seal = async (piece) => new Uint8Array(await crypto.subtle.encrypt(
396
422
  { name: 'AES-GCM', iv: nonceFor(noncePrefix, index++) }, key, piece,
@@ -415,17 +441,30 @@ export async function encryptStream(rawKey, plaintext, { fingerprint, plaintextS
415
441
  if (done) {
416
442
  // The trailing partial chunk — and, for an empty file, the one empty chunk that
417
443
  // makes "is this encrypted" answerable the same way at any size.
418
- if (held.length || seen === 0) {
444
+ // The trailing partial chunk and, for an empty file, the one empty chunk that
445
+ // makes "is this encrypted" answerable the same way at any size. A RESUMED stream
446
+ // never wants that empty chunk: the object already has its chunks, and adding one
447
+ // would append a stray sealed block past the end.
448
+ if (partial) {
449
+ if (held.length) {
450
+ throw TroveError.invalid(
451
+ `A partial encrypt must stop on a chunk boundary; ${held.length} bytes left over`,
452
+ );
453
+ }
454
+ controller.close();
455
+ return;
456
+ }
457
+ if (held.length || (seen === 0 && !resume)) {
419
458
  seen += held.length;
420
459
  controller.enqueue(await seal(held));
421
460
  held = new Uint8Array(0);
422
461
  }
423
- if (seen !== plaintextSize) {
462
+ if (seen !== expected) {
424
463
  // Refused rather than written: an envelope whose header disagrees with its body
425
464
  // decrypts to the wrong length forever, and the header cannot be fixed later
426
465
  // without re-encrypting.
427
466
  throw TroveError.invalid(
428
- `Expected ${plaintextSize} bytes to encrypt and received ${seen}`,
467
+ `Expected ${expected} bytes to encrypt and received ${seen}`,
429
468
  );
430
469
  }
431
470
  controller.close();
@@ -53,6 +53,20 @@ const PART_TARGET_BYTES = 8 * 1024 * 1024;
53
53
  * @property {'running'|'done'} status
54
54
  */
55
55
 
56
+ /**
57
+ * The storage key a rotation writes to.
58
+ *
59
+ * Based on the key with any previous rotation suffix STRIPPED. Appending to
60
+ * `node.storageKey` instead compounds: a collection rotated twice held
61
+ * `obj_x.rotms9gq8j2.rotms9grnhf`, and a key grew by ~11 characters on every rotation for
62
+ * the life of the drive. S3 stops accepting a key at 1024 bytes, so a drive rotated on a
63
+ * schedule would eventually fail to rotate at all — years out, silently, and only for the
64
+ * objects with the longest names.
65
+ */
66
+ export function rotatedKey(storageKey) {
67
+ return `${storageKey.replace(/(\.rot[0-9a-z]+)+$/, '')}.rot${Date.now().toString(36)}`;
68
+ }
69
+
56
70
  export class RotationService {
57
71
  /**
58
72
  * @param {object} deps
@@ -84,6 +98,9 @@ export class RotationService {
84
98
  if (running && running.status === 'running') {
85
99
  throw TroveError.invalid('A key rotation is already running on this collection');
86
100
  }
101
+ // A finished-but-abandoned rotation can still be holding an open multipart. Starting a
102
+ // new one is the last moment anything knows it exists.
103
+ if (running?.inflight) await this.#discard(collectionId, running.inflight);
87
104
  const { fingerprint: to, previous } = await this.collections.beginRotation(collectionId, principal);
88
105
  const state = {
89
106
  collectionId,
@@ -95,6 +112,9 @@ export class RotationService {
95
112
  .map((k) => k.fingerprint)
96
113
  .filter((fp) => fp !== to),
97
114
  cursor: null,
115
+ // The object part-way through, when a slice ran out of budget inside one. See
116
+ // #moveSlice — this is what lets an object larger than a slice be rotated at all.
117
+ inflight: null,
98
118
  moved: 0,
99
119
  failed: 0,
100
120
  startedAt: Date.now(),
@@ -145,6 +165,36 @@ export class RotationService {
145
165
  let moved = state.moved;
146
166
  let failed = state.failed;
147
167
  let sawStragglers = false;
168
+ let inflight = state.inflight || null;
169
+
170
+ // Finish what the last slice started before looking for more. An object part-way
171
+ // through is the one thing that MUST be dealt with first: its multipart is open and
172
+ // billed, and the walk would otherwise pick it up again from the beginning.
173
+ if (inflight) {
174
+ const node = await this.vfs.metadata.getById(inflight.nodeId).catch(() => null);
175
+ // Deleted, or already moved by something else, while we were away. Spend the upload
176
+ // rather than leaving it open forever.
177
+ if (!node || !node.encryption || node.encryption.fingerprint === state.to) {
178
+ await this.#discard(collectionId, inflight);
179
+ inflight = null;
180
+ } else {
181
+ sawStragglers = true;
182
+ try {
183
+ const r = await this.#moveSlice(node, key, fp, inflight, { deadline, now });
184
+ inflight = r.inflight;
185
+ if (r.done) moved++;
186
+ } catch (err) {
187
+ failed++;
188
+ await this.#discard(collectionId, inflight);
189
+ inflight = null;
190
+ console.error(`[trove] resuming ${node.name} failed:`, err?.message || err);
191
+ }
192
+ // Still not finished, or out of time: persist and let the next slice continue.
193
+ if (inflight || now() >= deadline) {
194
+ return this.#save(collectionId, { ...state, cursor, moved, failed, inflight });
195
+ }
196
+ }
197
+ }
148
198
 
149
199
  for (;;) {
150
200
  const page = await this.vfs.metadata.listItems(collectionId, { cursor, limit: 50 });
@@ -155,8 +205,15 @@ export class RotationService {
155
205
  if (!node.encryption || node.encryption.fingerprint === state.to) continue;
156
206
  sawStragglers = true;
157
207
  try {
158
- await this.#move(node, key, fp);
159
- moved++;
208
+ const r = await this.#moveSlice(node, key, fp, null, { deadline, now });
209
+ if (r.done) moved++;
210
+ else {
211
+ // Bigger than the remaining budget. Its parts and its place in the envelope are
212
+ // recorded, and the next slice picks it up where this one stopped instead of
213
+ // starting the object again — which is what used to make a large object
214
+ // unrotatable at all rather than merely slow.
215
+ return this.#save(collectionId, { ...state, cursor, moved, failed, inflight: r.inflight });
216
+ }
160
217
  } catch (err) {
161
218
  // One unreadable object must not stop the rotation: the rest of the collection
162
219
  // still needs to move, and the old key cannot be retired while anything is left
@@ -173,6 +230,7 @@ export class RotationService {
173
230
  const finished = !cursor && !sawStragglers;
174
231
  const next = {
175
232
  ...state,
233
+ inflight: null,
176
234
  // A finished pass starts the next one from the beginning, because items added during
177
235
  // it may still be behind. Two clean passes in a row is what actually ends the job.
178
236
  cursor: finished ? null : cursor,
@@ -199,45 +257,103 @@ export class RotationService {
199
257
  }
200
258
 
201
259
  /**
202
- * Read one object under whichever key it names, and write it back under the new one.
260
+ * Move one object onto the new key, in as many slices as it takes.
261
+ *
262
+ * A rotation slice is bounded by wall-clock time, and until now an object could only be
263
+ * moved whole: one larger than the budget failed its slice, restarted from the beginning
264
+ * on the next, and failed again — forever, with the rotation never completing and the old
265
+ * key never retiring. A collection containing one big video could not be rotated at all.
203
266
  *
204
- * Written to a NEW storage key rather than over the old one. AES-GCM cannot survive a
205
- * nonce being reused with a key, and rewriting in place invites exactly that on a retry;
206
- * a new object also means a failure halfway leaves the original intact and readable
207
- * rather than a half-written file that is neither.
267
+ * The checkpoint is the multipart part list, which the store is already keeping for us.
268
+ * What has to travel with it is the ENVELOPE's position: a nonce is derived from the
269
+ * chunk index, so a resumed encryption must continue the same sequence under the same
270
+ * prefix. Starting a fresh envelope halfway would reuse nonce/key pairs from the first
271
+ * half — the one thing AES-GCM cannot survive — so the prefix and the next chunk index
272
+ * are persisted alongside the parts.
273
+ *
274
+ * @returns {Promise<{done: boolean, inflight: object|null}>}
208
275
  */
209
- async #move(node, newKey, newFingerprint) {
276
+ async #moveSlice(node, newKey, newFingerprint, inflight, { deadline, now }) {
210
277
  const storage = await this.vfs.storageFor(node.collectionId);
211
278
  const chunkSize = node.encryption.chunkSize;
279
+ const plaintextSize = node.size;
280
+
281
+ // A store without multipart is a local one — filesystem, memory — where the whole
282
+ // object is already within reach. There is nothing to check point against, and nothing
283
+ // that needs it: the budget that makes this necessary is a network one.
284
+ if (!storage.capabilities?.multipart) {
285
+ await this.#moveWhole(node, newKey, newFingerprint, storage, chunkSize);
286
+ return { done: true, inflight: null };
287
+ }
288
+
212
289
  // Written to a NEW storage key rather than over the old one. AES-GCM cannot survive a
213
290
  // nonce being reused with a key, and rewriting in place invites exactly that on a
214
291
  // retry; a new object also means a failure halfway leaves the original intact and
215
292
  // readable rather than a half-written file that is neither.
216
- const nextKey = `${node.storageKey}.rot${Date.now().toString(36)}`;
293
+ const nextKey = inflight?.storageKey ?? rotatedKey(node.storageKey);
294
+ const uploadId = inflight?.uploadId ?? await storage.createMultipart(nextKey, { contentType: node.contentType });
295
+ const parts = inflight?.parts ? [...inflight.parts] : [];
296
+ let chunkIndex = inflight?.chunkIndex ?? 0;
297
+ let noncePrefix = inflight?.noncePrefix ? fromHex(inflight.noncePrefix) : null;
217
298
 
218
- const read = await this.vfs.readStream(node.id);
219
- const sealed = await encryptStream(newKey, read.stream, {
220
- fingerprint: newFingerprint,
221
- plaintextSize: read.size ?? node.size,
222
- chunkSize,
223
- });
299
+ // One part per pass, so the checkpoint lands on a part boundary — which is also a chunk
300
+ // boundary, since a part is a whole number of sealed chunks. Sized to clear S3's 5 MiB
301
+ // floor for every part but the last.
302
+ const chunksPerPart = Math.max(1, Math.ceil(PART_TARGET_BYTES / chunkSize));
224
303
 
225
- // Streamed into multipart parts rather than collected.
226
- //
227
- // Buffering meant holding the file twice — once decrypted and once sealed — so a
228
- // rotation was capped at whatever fits in an isolate. On Workers that is 128 MB for
229
- // everything, which put the ceiling somewhere around a 50 MB file and made rotation
230
- // simply unavailable for the collections most likely to want it. Peak memory is now one
231
- // part, whatever the object weighs.
232
- //
233
- // A backend that cannot do multipart is a local one — filesystem, memory — where the
234
- // whole object is already in reach and a single put is both simpler and fine.
235
- if (storage.capabilities?.multipart) {
236
- await this.#putStreamed(storage, nextKey, sealed, node.contentType);
237
- } else {
238
- await storage.put(nextKey, new Uint8Array(await new Response(sealed).arrayBuffer()), {
239
- contentType: node.contentType,
240
- });
304
+ try {
305
+ while (chunkIndex * chunkSize < plaintextSize) {
306
+ const start = chunkIndex * chunkSize;
307
+ const end = Math.min(start + chunksPerPart * chunkSize, plaintextSize) - 1;
308
+ const last = end + 1 >= plaintextSize;
309
+
310
+ const read = await this.vfs.readStream(node.id, { range: { start, end } });
311
+ const sealed = await encryptStream(newKey, read.stream, {
312
+ fingerprint: newFingerprint,
313
+ plaintextSize,
314
+ chunkSize,
315
+ // The first pass mints the prefix and writes the header; every later one continues
316
+ // the same envelope, which is what `resume` means.
317
+ resume: noncePrefix ? { noncePrefix, index: chunkIndex } : null,
318
+ // `partial` says another call will continue this object. Without it a mid-object
319
+ // pass would be sealed as a complete envelope and every later chunk index would be
320
+ // wrong.
321
+ partial: !last,
322
+ });
323
+
324
+ const body = new Uint8Array(await new Response(sealed).arrayBuffer());
325
+ // The prefix the first pass generated, read back out of the header it wrote. It is
326
+ // the only place it exists, and every later pass needs it.
327
+ if (!noncePrefix) noncePrefix = decodeHeader(body).noncePrefix;
328
+
329
+ const partNumber = parts.length + 1;
330
+ const etag = await storage.putPart(nextKey, uploadId, partNumber, body);
331
+ parts.push({ partNumber, etag: etag?.etag ?? etag });
332
+ chunkIndex += Math.ceil((end + 1 - start) / chunkSize);
333
+
334
+ // Out of time, but on a boundary: hand back what has been done so the next slice
335
+ // continues rather than starting over.
336
+ if (!last && now() >= deadline) {
337
+ return {
338
+ done: false,
339
+ inflight: {
340
+ nodeId: node.id,
341
+ storageKey: nextKey,
342
+ uploadId,
343
+ parts,
344
+ chunkIndex,
345
+ noncePrefix: toHex(noncePrefix),
346
+ },
347
+ };
348
+ }
349
+ }
350
+
351
+ await storage.completeMultipart(nextKey, uploadId, parts);
352
+ } catch (err) {
353
+ // The parts already sent stay in the bucket, billed, with nothing left able to
354
+ // reclaim them — so a failure aborts rather than leaving them.
355
+ await storage.abortMultipart(nextKey, uploadId).catch(() => {});
356
+ throw err;
241
357
  }
242
358
 
243
359
  // The item points at the new object before the old one is removed. In the window
@@ -249,57 +365,60 @@ export class RotationService {
249
365
  encryption: { fingerprint: toHex(newFingerprint), chunkSize },
250
366
  });
251
367
  await storage.delete(oldKey).catch(() => {});
368
+ return { done: true, inflight: null };
369
+ }
370
+
371
+ /** The whole object in one put, for a store that cannot do multipart. */
372
+ async #moveWhole(node, newKey, newFingerprint, storage, chunkSize) {
373
+ const nextKey = rotatedKey(node.storageKey);
374
+ const read = await this.vfs.readStream(node.id);
375
+ const sealed = await encryptStream(newKey, read.stream, {
376
+ fingerprint: newFingerprint,
377
+ plaintextSize: read.size ?? node.size,
378
+ chunkSize,
379
+ });
380
+ await storage.put(nextKey, new Uint8Array(await new Response(sealed).arrayBuffer()), {
381
+ contentType: node.contentType,
382
+ });
383
+ const oldKey = node.storageKey;
384
+ await this.vfs.metadata.update(node.id, {
385
+ storageKey: nextKey,
386
+ encryption: { fingerprint: toHex(newFingerprint), chunkSize },
387
+ });
388
+ await storage.delete(oldKey).catch(() => {});
252
389
  }
253
390
 
254
391
  /**
255
- * Upload a stream as a multipart object, holding one part at a time.
392
+ * Abandon a half-written object.
256
393
  *
257
- * Parts are accumulated to a target size because S3 requires every part except the last
258
- * to clear a 5 MiB floor an envelope chunk is far smaller than that, so one chunk per
259
- * part would be rejected.
260
- *
261
- * A failure aborts the multipart. Without that the parts already sent stay in the bucket,
262
- * billed, with nothing left able to reclaim them.
394
+ * A multipart left open is billed until it is completed or aborted, and nothing else can
395
+ * find it: the storage contract has no way to list them, deliberately, so the only record
396
+ * that it exists is the one we wrote. Cancelling or restarting a rotation has to spend it.
263
397
  */
264
- async #putStreamed(storage, key, stream, contentType) {
265
- const uploadId = await storage.createMultipart(key, { contentType });
398
+ async #discard(collectionId, inflight) {
399
+ if (!inflight?.uploadId) return;
266
400
  try {
267
- const reader = stream.getReader();
268
- const parts = [];
269
- let held = [];
270
- let size = 0;
271
- let n = 1;
272
- const flush = async () => {
273
- const part = new Uint8Array(size);
274
- let at = 0;
275
- for (const p of held) { part.set(p, at); at += p.length; }
276
- held = [];
277
- size = 0;
278
- const etag = await storage.putPart(key, uploadId, n, part);
279
- parts.push({ partNumber: n, etag: etag?.etag ?? etag });
280
- n++;
281
- };
282
- for (;;) {
283
- const { value, done } = await reader.read();
284
- if (done) break;
285
- held.push(value);
286
- size += value.length;
287
- if (size >= PART_TARGET_BYTES) await flush();
288
- }
289
- // The final part may be under the floor, and only the final part may be.
290
- if (size || parts.length === 0) await flush();
291
- await storage.completeMultipart(key, uploadId, parts);
401
+ const storage = await this.vfs.storageFor(collectionId);
402
+ await storage.abortMultipart(inflight.storageKey, inflight.uploadId);
292
403
  } catch (err) {
293
- await storage.abortMultipart(key, uploadId).catch(() => {});
294
- throw err;
404
+ console.error(`[trove] abandoning a rotation upload failed:`, err?.message || err);
295
405
  }
296
406
  }
297
407
 
408
+ /** Persist progress mid-pass, so the next slice continues from here. */
409
+ async #save(collectionId, next) {
410
+ await this.kv.set(NS, collectionId, next);
411
+ return next;
412
+ }
413
+
298
414
  /** Abandon a rotation. The new key stays current; what has moved stays moved. */
299
415
  async cancel(collectionId) {
300
416
  const state = await this.state(collectionId);
301
417
  if (!state) return null;
302
- const next = { ...state, status: 'done', cancelled: true };
418
+ // The half-written object goes with it. Nothing else can find that multipart — the
419
+ // storage contract cannot list them — so if this does not spend it, nothing will.
420
+ await this.#discard(collectionId, state.inflight);
421
+ const next = { ...state, status: 'done', cancelled: true, inflight: null };
303
422
  await this.kv.set(NS, collectionId, next);
304
423
  return next;
305
424
  }