@ultimat3/storage 27.12.0 → 27.13.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.
package/CLAUDE.md CHANGED
@@ -83,7 +83,9 @@ Gotchas:
83
83
  in which the sidecar may not describe the bytes: `get`/`stat`/`copy` re-check against the bytes,
84
84
  `list` reports no type and `etag: ''`. A fresh key is absent until its bytes land.
85
85
  `driver-local-crash.test.ts` dies at every step through `WriteSteps`. Writers and `get()` of one
86
- key also queue in-process (`keyedQueue`) — unqueued, two puts interleaved 3/3.
86
+ key also queue in-process (`keyedQueue`) — unqueued, two puts interleaved 3/3. `copy()` holds BOTH
87
+ keys' turns (`inTurnOfBoth`, key order): measured in one and read in the other, an overwritten
88
+ source committed new bytes under the old etag.
87
89
  - **A refused write is `X_STORAGE_PUT_FAILED` and a refused read `X_STORAGE_READ_FAILED`**, on
88
90
  local and s3 (both were bare throws); a 404 mid-read stays `X_STORAGE_NOT_FOUND`; `assertPutOptions` holds `metadata`/`cacheControl`/`contentType` to their types on
89
91
  all three drivers before a byte moves.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/storage",
3
- "version": "27.12.0",
3
+ "version": "27.13.1",
4
4
  "description": "Named disks over Bun.file and Bun.s3: safe keys, signed URLs, sniffed uploads",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,6 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "27.12.0"
35
+ "@ultimat3/core": "27.13.1"
36
36
  }
37
37
  }
@@ -164,6 +164,23 @@ export async function commitObject(
164
164
  /** Runs `task` after every earlier task queued under the same key has settled. */
165
165
  export type KeyedQueue = <T>(key: string, task: () => Promise<T>) => Promise<T>;
166
166
 
167
+ /**
168
+ * `task` holding BOTH keys' turns — what a copy needs: the measured source must be the source it
169
+ * then reads, and the destination must be its alone. Taken in key order, so two crossing copies
170
+ * (`a → b`, `b → a`) never each hold the turn the other waits on; one key is taken once, since a
171
+ * queue's second turn on a key it already holds would wait on itself.
172
+ */
173
+ export const inTurnOfBoth = <T>(
174
+ queue: KeyedQueue,
175
+ one: string,
176
+ other: string,
177
+ task: () => Promise<T>,
178
+ ): Promise<T> => {
179
+ if (one === other) return queue(one, task);
180
+ const [first, second] = one < other ? [one, other] : [other, one];
181
+ return queue(first, () => queue(second, task));
182
+ };
183
+
167
184
  /**
168
185
  * One writer — or one reader — per key at a time, inside this process. An object is two files, so
169
186
  * two unserialised `put()`s of one key interleaved their four writes and left one's bytes under the
@@ -35,6 +35,7 @@ import { etagOfFile, headObject, readObjectBytes, readSidecar } from './driver-l
35
35
  import { lockOfSidecar, type Sidecar, sidecarLock } from './driver-local-sidecar';
36
36
  import {
37
37
  commitObject,
38
+ inTurnOfBoth,
38
39
  type KeyedQueue,
39
40
  keyedQueue,
40
41
  pendingPathOf,
@@ -286,20 +287,22 @@ export function localDriver(options: LocalDriverOptions): StorageDriver {
286
287
  async copy(from: string, to: string): Promise<StorageObject> {
287
288
  const source = assertSafeKey(from);
288
289
  const destination = assertSafeKey(to);
289
- // Measured, not merely read: the destination gets a sidecar, and one carrying `etag: ''` or
290
- // a torn source's borrowed type is a durable lie every later `get()` of the copy would
291
- // trust. The hash is streamed, and only for a sidecar-less source or a pair in doubt.
292
- const entry = await measured(source);
293
- if (entry === undefined) throw objectNotFound(registered, source);
294
- const lastModified = clock.now();
295
- const sidecar: Sidecar = {
296
- contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE,
297
- etag: entry.etag,
298
- cacheControl: entry.cacheControl,
299
- metadata: entry.metadata,
300
- lastModified: lastModified.toISOString(),
301
- };
302
- await oneAtATime(destination, async () => {
290
+ // Measured AND read in one turn on both keys. Measured in the source's turn and read in the
291
+ // destination's, a put() of the source in between committed its NEW bytes under the OLD
292
+ // etag and type — a pair no marker flags, so every later `get()` of the copy trusted it.
293
+ return inTurnOfBoth(oneAtATime, source, destination, async () => {
294
+ // Measured, not merely read: a sidecar carrying `etag: ''` or a torn source's borrowed
295
+ // type is the same durable lie. The hash is streamed, and only for a pair in doubt.
296
+ const entry = await headObject(root, source, () => etagOfFile(root, source, registered));
297
+ if (entry === undefined) throw objectNotFound(registered, source);
298
+ const lastModified = clock.now();
299
+ const sidecar: Sidecar = {
300
+ contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE,
301
+ etag: entry.etag,
302
+ cacheControl: entry.cacheControl,
303
+ metadata: entry.metadata,
304
+ lastModified: lastModified.toISOString(),
305
+ };
303
306
  await refuseOverwrite(destination, 'copy onto');
304
307
  await commitObject({
305
308
  root,
@@ -307,18 +310,14 @@ export function localDriver(options: LocalDriverOptions): StorageDriver {
307
310
  disk: registered,
308
311
  body: Bun.file(filePath(source)),
309
312
  sidecar,
313
+ }).catch(async (error: unknown) => {
314
+ // The queue is this process's: another process can still take the source mid-copy.
315
+ if (!(await Bun.file(filePath(source)).exists()))
316
+ throw objectNotFound(registered, source);
317
+ throw error;
310
318
  });
311
- }).catch(async (error: unknown) => {
312
- // The source was deleted while this copy waited its turn on the destination.
313
- if (!(await Bun.file(filePath(source)).exists())) throw objectNotFound(registered, source);
314
- throw error;
319
+ return { ...entry, key: destination, contentType: sidecar.contentType, lastModified };
315
320
  });
316
- return {
317
- ...entry,
318
- key: destination,
319
- contentType: sidecar.contentType,
320
- lastModified,
321
- };
322
321
  },
323
322
 
324
323
  async delete(key: string): Promise<void> {