@takosjp/yurucommu-core 3.4.5 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,14 +8,14 @@
8
8
  import type {
9
9
  FirstResult,
10
10
  IDatabase,
11
- IObjectStorage,
11
+ ObjectStore,
12
+ ObjectStoreBody,
13
+ ObjectStoreObject,
14
+ ObjectStorePutOptions,
12
15
  IStaticAssets,
13
- ListObjectsResult,
14
- ObjectMetadata,
15
16
  PreparedStatement,
16
17
  QueryResult,
17
18
  RunResult,
18
- StorageObject,
19
19
  } from "./types.ts";
20
20
  import { constants as fsConstants } from "node:fs";
21
21
  import type { FileHandle } from "node:fs/promises";
@@ -37,8 +37,18 @@ declare const require: (specifier: string) => unknown;
37
37
  // Re-export MemoryKV as it works in Bun too.
38
38
  export { MemoryKV };
39
39
 
40
- const { mkdir, unlink, readdir, stat, lstat, realpath, open, rename, utimes } =
41
- await import("fs/promises");
40
+ const {
41
+ mkdir,
42
+ unlink,
43
+ readdir,
44
+ stat,
45
+ lstat,
46
+ realpath,
47
+ open,
48
+ rename,
49
+ readFile,
50
+ readlink,
51
+ } = await import("fs/promises");
42
52
 
43
53
  // Generation state is deliberately kept outside the public object namespace.
44
54
  // The sibling directory name is derived from the storage directory, so a
@@ -50,13 +60,16 @@ const GENERATION_MARKER = "generation-";
50
60
  const TEMP_MARKER = "tmp-";
51
61
  const LEASE_SUFFIX = "lease";
52
62
  const READER_LEASE_MARKER = "reader-";
53
- const TEMP_LEASE_TTL_MS = 30_000;
54
- const TEMP_LEASE_HEARTBEAT_MS = 1_000;
55
63
  const MARKER_READ_ATTEMPTS = 32;
56
64
  const OBJECT_RESOLVE_ATTEMPTS = 64;
57
65
  const OPEN_READ_FLAGS = fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW;
58
66
  const GENERATION_ID_PATTERN = /^[0-9a-f-]{16,}$/u;
59
67
  const DIGEST_PATTERN = /^[0-9a-f]{64}$/u;
68
+ const LEASE_BOOT_ID_PATTERN =
69
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
70
+ const LEASE_PID_NAMESPACE_ID_PATTERN = /^pid:\[[1-9][0-9]*\]$/u;
71
+ const LEASE_PROCESS_START_TOKEN_PATTERN = /^[0-9]{1,32}$/u;
72
+ const LEASE_TOKEN_MAX_LENGTH = 128;
60
73
 
61
74
  type CommitState =
62
75
  | { version: 1; state: "committed"; generation: string }
@@ -68,10 +81,46 @@ type ResolvedObject = {
68
81
  filePath: string;
69
82
  bodyHandle?: FileHandle;
70
83
  metadata: {
71
- httpMetadata?: ObjectMetadata["httpMetadata"];
72
- customMetadata?: Record<string, string>;
84
+ contentType?: string;
73
85
  };
74
- releaseLease?: () => Promise<void>;
86
+ releaseBody?: () => Promise<void>;
87
+ };
88
+
89
+ type TempLease = (() => Promise<void>) & {
90
+ assertOwned(): Promise<void>;
91
+ };
92
+
93
+ type LeaseProcessIdentity = {
94
+ bootId: string;
95
+ pidNamespaceId: string;
96
+ processStartToken: string;
97
+ };
98
+
99
+ type LeaseOwner = {
100
+ version: 2;
101
+ pid: number;
102
+ token: string;
103
+ processIdentity: LeaseProcessIdentity;
104
+ };
105
+
106
+ type LeaseOwnerState = "live" | "dead" | "unknown";
107
+
108
+ type LeaseProcessProbe = {
109
+ readPidNamespace(pid: number): Promise<string>;
110
+ readPidStat(pid: number): Promise<string>;
111
+ signal0(pid: number): void;
112
+ };
113
+
114
+ const defaultLeaseProcessProbe: LeaseProcessProbe = {
115
+ readPidNamespace(pid) {
116
+ return readlink(`/proc/${pid}/ns/pid`);
117
+ },
118
+ readPidStat(pid) {
119
+ return readFile(`/proc/${pid}/stat`, "utf8");
120
+ },
121
+ signal0(pid) {
122
+ process.kill(pid, 0);
123
+ },
75
124
  };
76
125
 
77
126
  type LiveLeaseState = {
@@ -86,6 +135,14 @@ type FileIdentity = {
86
135
  mode: number;
87
136
  };
88
137
 
138
+ type BunStorageOptions = {
139
+ syncDirectory?: typeof syncDirectory;
140
+ /** Deterministic test seam at the lease-assertion/publication boundary. */
141
+ beforeCommitRename?: (commitPath: string) => Promise<void>;
142
+ /** Deterministic test seam for Linux /proc visibility and PID incarnation. */
143
+ leaseProcessProbe?: LeaseProcessProbe;
144
+ };
145
+
89
146
  function fileIdentity(stats: FileIdentity): FileIdentity {
90
147
  return {
91
148
  dev: stats.dev,
@@ -138,6 +195,190 @@ function isFileIdentityRace(error: unknown): boolean {
138
195
  );
139
196
  }
140
197
 
198
+ function hasExactObjectKeys(
199
+ value: Record<string, unknown>,
200
+ expected: readonly string[],
201
+ ): boolean {
202
+ const keys = Object.keys(value).sort();
203
+ return (
204
+ keys.length === expected.length &&
205
+ expected.every((key, index) => keys[index] === key)
206
+ );
207
+ }
208
+
209
+ function parseLeaseProcessIdentity(
210
+ value: unknown,
211
+ ): LeaseProcessIdentity | undefined {
212
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
213
+ return undefined;
214
+ }
215
+ const record = value as Record<string, unknown>;
216
+ if (
217
+ !hasExactObjectKeys(record, [
218
+ "bootId",
219
+ "pidNamespaceId",
220
+ "processStartToken",
221
+ ]) ||
222
+ typeof record.bootId !== "string" ||
223
+ !LEASE_BOOT_ID_PATTERN.test(record.bootId) ||
224
+ typeof record.pidNamespaceId !== "string" ||
225
+ !LEASE_PID_NAMESPACE_ID_PATTERN.test(record.pidNamespaceId) ||
226
+ typeof record.processStartToken !== "string" ||
227
+ !LEASE_PROCESS_START_TOKEN_PATTERN.test(record.processStartToken)
228
+ ) {
229
+ return undefined;
230
+ }
231
+ return {
232
+ bootId: record.bootId,
233
+ pidNamespaceId: record.pidNamespaceId,
234
+ processStartToken: record.processStartToken,
235
+ };
236
+ }
237
+
238
+ function parseLeaseOwner(ownerText: string): LeaseOwner | undefined {
239
+ try {
240
+ const value = JSON.parse(ownerText) as unknown;
241
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
242
+ return undefined;
243
+ }
244
+ const record = value as Record<string, unknown>;
245
+ const processIdentity = parseLeaseProcessIdentity(record.processIdentity);
246
+ if (
247
+ !hasExactObjectKeys(record, [
248
+ "pid",
249
+ "processIdentity",
250
+ "token",
251
+ "version",
252
+ ]) ||
253
+ record.version !== 2 ||
254
+ typeof record.pid !== "number" ||
255
+ !Number.isSafeInteger(record.pid) ||
256
+ record.pid <= 0 ||
257
+ typeof record.token !== "string" ||
258
+ record.token.length === 0 ||
259
+ record.token.length > LEASE_TOKEN_MAX_LENGTH ||
260
+ !processIdentity
261
+ ) {
262
+ return undefined;
263
+ }
264
+ return {
265
+ version: 2,
266
+ pid: record.pid,
267
+ token: record.token,
268
+ processIdentity,
269
+ };
270
+ } catch {
271
+ return undefined;
272
+ }
273
+ }
274
+
275
+ function parseProcessStartToken(statText: string): string | undefined {
276
+ // /proc/<pid>/stat field 2 is parenthesized and may itself contain spaces or
277
+ // parentheses. The final ')' closes it; field 22 is then tail index 19.
278
+ const commandEnd = statText.lastIndexOf(")");
279
+ if (commandEnd < 0) return undefined;
280
+ const token = statText
281
+ .slice(commandEnd + 1)
282
+ .trim()
283
+ .split(/\s+/u)[19];
284
+ return token && LEASE_PROCESS_START_TOKEN_PATTERN.test(token)
285
+ ? token
286
+ : undefined;
287
+ }
288
+
289
+ async function loadLocalLeaseProcessIdentity(): Promise<
290
+ LeaseProcessIdentity | undefined
291
+ > {
292
+ try {
293
+ const [bootId, pidNamespaceId, statText] = await Promise.all([
294
+ readFile("/proc/sys/kernel/random/boot_id", "utf8"),
295
+ readlink("/proc/self/ns/pid"),
296
+ readFile("/proc/self/stat", "utf8"),
297
+ ]);
298
+ return parseLeaseProcessIdentity({
299
+ bootId: bootId.trim(),
300
+ pidNamespaceId,
301
+ processStartToken: parseProcessStartToken(statText),
302
+ });
303
+ } catch {
304
+ return undefined;
305
+ }
306
+ }
307
+
308
+ let localLeaseProcessIdentity:
309
+ Promise<LeaseProcessIdentity | undefined> | undefined;
310
+
311
+ function getLocalLeaseProcessIdentity(): Promise<
312
+ LeaseProcessIdentity | undefined
313
+ > {
314
+ localLeaseProcessIdentity ??= loadLocalLeaseProcessIdentity();
315
+ return localLeaseProcessIdentity;
316
+ }
317
+
318
+ function pidIsDefinitelyAbsent(pid: number, probe: LeaseProcessProbe): boolean {
319
+ try {
320
+ probe.signal0(pid);
321
+ return false;
322
+ } catch (error) {
323
+ return (
324
+ typeof error === "object" &&
325
+ error !== null &&
326
+ "code" in error &&
327
+ (error as { code?: unknown }).code === "ESRCH"
328
+ );
329
+ }
330
+ }
331
+
332
+ async function classifyLeaseOwner(
333
+ owner: LeaseOwner,
334
+ probe: LeaseProcessProbe,
335
+ ): Promise<LeaseOwnerState> {
336
+ const localIdentity = await getLocalLeaseProcessIdentity();
337
+ if (
338
+ !localIdentity ||
339
+ owner.processIdentity.bootId !== localIdentity.bootId ||
340
+ owner.processIdentity.pidNamespaceId !== localIdentity.pidNamespaceId
341
+ ) {
342
+ return "unknown";
343
+ }
344
+
345
+ let targetNamespaceId: string;
346
+ try {
347
+ targetNamespaceId = await probe.readPidNamespace(owner.pid);
348
+ } catch (error) {
349
+ if (!isNotFoundError(error)) return "unknown";
350
+ // hidepid=2 deliberately presents a live different-UID process as ENOENT.
351
+ // Only ESRCH from signal 0 corroborates that this same-boot/same-namespace
352
+ // PID is absent; EPERM and every other result remain unknown/fail-safe.
353
+ return pidIsDefinitelyAbsent(owner.pid, probe) ? "dead" : "unknown";
354
+ }
355
+ if (targetNamespaceId !== localIdentity.pidNamespaceId) return "unknown";
356
+
357
+ let targetStat: string;
358
+ try {
359
+ targetStat = await probe.readPidStat(owner.pid);
360
+ } catch (error) {
361
+ if (!isNotFoundError(error)) return "unknown";
362
+ return pidIsDefinitelyAbsent(owner.pid, probe) ? "dead" : "unknown";
363
+ }
364
+ const targetStartToken = parseProcessStartToken(targetStat);
365
+ if (!targetStartToken) return "unknown";
366
+ return targetStartToken === owner.processIdentity.processStartToken
367
+ ? "live"
368
+ : "dead";
369
+ }
370
+
371
+ const emittedLeaseDiagnostics = new Set<string>();
372
+
373
+ function reportUnknownLeaseRetention(): void {
374
+ const code = "lease-owner-unknown";
375
+ if (emittedLeaseDiagnostics.has(code)) return;
376
+ emittedLeaseDiagnostics.add(code);
377
+ console.warn(
378
+ "BunStorage retained an unverifiable lease; automatic reclaim is disabled. Confirm that no writer is active before operator cleanup (lease-owner-unknown).",
379
+ );
380
+ }
381
+
141
382
  function yieldForFilesystem(): Promise<void> {
142
383
  return new Promise((resolve) => setTimeout(resolve, 0));
143
384
  }
@@ -251,22 +492,114 @@ async function readFileHandleFully(handle: FileHandle): Promise<Uint8Array> {
251
492
  return content;
252
493
  }
253
494
 
495
+ /**
496
+ * Expose an already-open object file as a lazy body stream.
497
+ *
498
+ * Pathname leases are released before this stream is returned. A writer may
499
+ * publish a replacement or unlink the selected generation while a response is
500
+ * still being sent, but the open descriptor keeps those exact bytes stable on
501
+ * POSIX until the stream is consumed or cancelled.
502
+ */
503
+ function createFileBodyStream(
504
+ handle: FileHandle,
505
+ byteLength: number,
506
+ release: () => Promise<void>,
507
+ ): ReadableStream<Uint8Array> {
508
+ const chunkSize = 64 * 1024;
509
+ let offset = 0;
510
+ let released = false;
511
+
512
+ const finish = async (): Promise<void> => {
513
+ if (released) return;
514
+ released = true;
515
+ await release().catch(() => undefined);
516
+ };
517
+
518
+ return new ReadableStream<Uint8Array>(
519
+ {
520
+ async pull(controller) {
521
+ if (offset >= byteLength) {
522
+ controller.close();
523
+ await finish();
524
+ return;
525
+ }
526
+ const buffer = new Uint8Array(Math.min(chunkSize, byteLength - offset));
527
+ try {
528
+ const result = await handle.read(
529
+ buffer,
530
+ 0,
531
+ buffer.byteLength,
532
+ offset,
533
+ );
534
+ if (
535
+ !Number.isInteger(result.bytesRead) ||
536
+ result.bytesRead <= 0 ||
537
+ result.bytesRead > buffer.byteLength
538
+ ) {
539
+ throw new Error(
540
+ `filesystem short read: expected ${buffer.byteLength} bytes, received ${String(result.bytesRead)}`,
541
+ );
542
+ }
543
+ offset += result.bytesRead;
544
+ controller.enqueue(buffer.subarray(0, result.bytesRead));
545
+ if (offset >= byteLength) {
546
+ controller.close();
547
+ await finish();
548
+ }
549
+ } catch (error) {
550
+ controller.error(error);
551
+ await finish();
552
+ }
553
+ },
554
+ async cancel() {
555
+ await finish();
556
+ },
557
+ },
558
+ { highWaterMark: 0 },
559
+ );
560
+ }
561
+
254
562
  async function readFileHandleText(handle: FileHandle): Promise<string> {
255
563
  return new TextDecoder().decode(await readFileHandleFully(handle));
256
564
  }
257
565
 
566
+ function parseObjectMetadata(valueText: string): {
567
+ contentType?: string;
568
+ } {
569
+ const value = JSON.parse(valueText) as {
570
+ contentType?: unknown;
571
+ httpMetadata?: unknown;
572
+ };
573
+ if (value === null || typeof value !== "object") {
574
+ throw new Error("Invalid BunStorage generation metadata");
575
+ }
576
+ const legacyHttpMetadata = value.httpMetadata;
577
+ const legacyContentType =
578
+ legacyHttpMetadata !== null &&
579
+ typeof legacyHttpMetadata === "object" &&
580
+ "contentType" in legacyHttpMetadata
581
+ ? (legacyHttpMetadata as { contentType?: unknown }).contentType
582
+ : undefined;
583
+ const contentType =
584
+ typeof value.contentType === "string"
585
+ ? value.contentType
586
+ : typeof legacyContentType === "string"
587
+ ? legacyContentType
588
+ : undefined;
589
+ return contentType === undefined ? {} : { contentType };
590
+ }
591
+
258
592
  /**
259
593
  * Read the JSON metadata sidecar for a storage key.
260
594
  * Returns an empty object if the sidecar doesn't exist or can't be parsed.
261
595
  */
262
596
  async function readMetadata(metaPath: string): Promise<{
263
- httpMetadata?: ObjectMetadata["httpMetadata"];
264
- customMetadata?: Record<string, string>;
597
+ contentType?: string;
265
598
  }> {
266
599
  try {
267
600
  const metaFile = Bun.file(metaPath);
268
601
  if (await metaFile.exists()) {
269
- return JSON.parse(await metaFile.text());
602
+ return parseObjectMetadata(await metaFile.text());
270
603
  }
271
604
  } catch {
272
605
  // No metadata file or unreadable
@@ -391,15 +724,23 @@ class BunPreparedStatement implements PreparedStatement {
391
724
  }
392
725
 
393
726
  /**
394
- * Bun Filesystem Storage Adapter
727
+ * Bun Filesystem Storage Adapter.
728
+ *
729
+ * The generation/lease protocol requires a local Linux POSIX filesystem with
730
+ * atomic same-directory rename and stable open-descriptor semantics. It does
731
+ * not claim multi-host fencing. A lease from another boot, PID namespace, or
732
+ * unreadable /proc owner is retained for explicit operator inspection rather
733
+ * than automatically reclaimed.
395
734
  */
396
- export class BunStorage implements IObjectStorage {
735
+ export class BunStorage implements ObjectStore {
397
736
  private basePath: string;
398
737
  /**
399
738
  * Keep the directory-sync dependency injectable for deterministic storage
400
739
  * durability tests. Production callers use the real fsync implementation.
401
740
  */
402
741
  private readonly syncDirectory: typeof syncDirectory;
742
+ private readonly beforeCommitRename?: (commitPath: string) => Promise<void>;
743
+ private readonly leaseProcessProbe: LeaseProcessProbe;
403
744
  private realBasePath: string | null = null;
404
745
  private realInternalStorePath: string | null = null;
405
746
  /**
@@ -420,6 +761,16 @@ export class BunStorage implements IObjectStorage {
420
761
  * acquiring the generation-specific lease.
421
762
  */
422
763
  private readonly keyReadReservations = new Map<string, number>();
764
+ /**
765
+ * Serialize the conservative post-reader reclamation pass per key. A hot
766
+ * object can have many concurrent streaming readers; running a full
767
+ * directory scan for every EOF makes writers wait behind an unbounded queue
768
+ * of equivalent cleanup work.
769
+ */
770
+ private readonly reclaimTasks = new Map<
771
+ string,
772
+ { requested: boolean; promise: Promise<void> }
773
+ >();
423
774
  /**
424
775
  * Last marker successfully read by this adapter. Atomic marker replacement
425
776
  * can make a path disappear or resolve to the next inode for a few syscalls;
@@ -428,17 +779,17 @@ export class BunStorage implements IObjectStorage {
428
779
  */
429
780
  private readonly lastCommitRecords = new Map<string, CommitRecord>();
430
781
 
431
- constructor(
432
- basePath: string,
433
- options: { syncDirectory?: typeof syncDirectory } = {},
434
- ) {
782
+ constructor(basePath: string, options: BunStorageOptions = {}) {
435
783
  this.basePath = basePath;
436
784
  this.syncDirectory = options.syncDirectory ?? syncDirectory;
785
+ this.beforeCommitRename = options.beforeCommitRename;
786
+ this.leaseProcessProbe =
787
+ options.leaseProcessProbe ?? defaultLeaseProcessProbe;
437
788
  }
438
789
 
439
790
  static async create(
440
791
  basePath: string,
441
- options: { syncDirectory?: typeof syncDirectory } = {},
792
+ options: BunStorageOptions = {},
442
793
  ): Promise<BunStorage> {
443
794
  await mkdir(basePath, { recursive: true });
444
795
  const storage = new BunStorage(basePath, options);
@@ -516,8 +867,9 @@ export class BunStorage implements IObjectStorage {
516
867
  else this.generationReaders.set(leaseKey, count - 1);
517
868
  // A writer may have deferred this generation while the reader held its
518
869
  // lease. Re-run conservative GC after release; failure is non-fatal and
519
- // startup recovery remains the final orphan cleanup authority.
520
- await this.reclaimUnreferencedGenerations(key);
870
+ // startup recovery remains the final orphan cleanup authority. Queue the
871
+ // pass so EOF never blocks behind another reader's identical scan.
872
+ this.scheduleReclaim(key);
521
873
  };
522
874
  }
523
875
 
@@ -547,49 +899,121 @@ export class BunStorage implements IObjectStorage {
547
899
  return (this.keyReadReservations.get(key) ?? 0) > 0;
548
900
  }
549
901
 
902
+ private scheduleReclaim(key: string): void {
903
+ const active = this.reclaimTasks.get(key);
904
+ if (active) {
905
+ // One trailing pass is enough to observe a lease released while the
906
+ // current scan was in flight; never build one promise per reader.
907
+ active.requested = true;
908
+ return;
909
+ }
910
+ const state: { requested: boolean; promise: Promise<void> } = {
911
+ requested: false,
912
+ promise: Promise.resolve(),
913
+ };
914
+ state.promise = (async () => {
915
+ state.requested = false;
916
+ await this.reclaimUnreferencedGenerations(key).catch(() => undefined);
917
+ })();
918
+ this.reclaimTasks.set(key, state);
919
+ void state.promise
920
+ .finally(() => {
921
+ if (this.reclaimTasks.get(key) === state) {
922
+ this.reclaimTasks.delete(key);
923
+ }
924
+ if (state.requested) this.scheduleReclaim(key);
925
+ })
926
+ .catch(() => undefined);
927
+ }
928
+
550
929
  private async createTempLease(
551
930
  objectPath: string,
552
931
  leaseId: string,
553
932
  kind: "generation" | "reader" = "generation",
554
- ): Promise<() => Promise<void>> {
933
+ ): Promise<TempLease> {
555
934
  const marker = kind === "reader" ? READER_LEASE_MARKER : "";
556
935
  const leasePath = path.join(
557
936
  objectPath,
558
937
  `${TEMP_MARKER}${marker}${leaseId}.${LEASE_SUFFIX}`,
559
938
  );
939
+ const leaseToken = crypto.randomUUID();
940
+ const processIdentity = await getLocalLeaseProcessIdentity();
560
941
  const leaseOwner = JSON.stringify({
942
+ version: 2,
561
943
  pid: process.pid,
562
- token: crypto.randomUUID(),
944
+ token: leaseToken,
945
+ // A missing /proc identity is deliberately persisted as unknown. This
946
+ // writer can still release its own inode/token, but a later process must
947
+ // retain it for operator inspection rather than guess that it is dead.
948
+ processIdentity: processIdentity ?? null,
563
949
  });
564
950
  let leaseHandle: FileHandle | undefined;
951
+ let leaseIdentity: FileIdentity | undefined;
565
952
  try {
566
953
  leaseHandle = await open(leasePath, "wx");
567
954
  await writeBufferFully(leaseHandle, new TextEncoder().encode(leaseOwner));
568
- await syncAndClose(leaseHandle);
569
- leaseHandle = undefined;
955
+ await leaseHandle.sync();
956
+ leaseIdentity = fileIdentity(await leaseHandle.stat());
957
+ await this.assertOwnedInternalFile(leasePath);
570
958
  } catch (error) {
571
959
  if (leaseHandle) await leaseHandle.close().catch(() => undefined);
572
960
  await this.unlinkOwnedInternalFile(leasePath);
573
961
  throw error;
574
962
  }
575
- await this.assertOwnedInternalFile(leasePath);
576
963
 
577
- const heartbeat = setInterval(() => {
578
- void utimes(leasePath, new Date(), new Date()).catch(() => undefined);
579
- }, TEMP_LEASE_HEARTBEAT_MS);
580
- (heartbeat as unknown as { unref?: () => void }).unref?.();
964
+ const ownsLease = async (): Promise<boolean> => {
965
+ if (!leaseHandle || !leaseIdentity) return false;
966
+ let currentPathHandle: FileHandle | undefined;
967
+ try {
968
+ const heldIdentity = fileIdentity(await leaseHandle.stat());
969
+ if (
970
+ heldIdentity.nlink !== 1 ||
971
+ heldIdentity.dev !== leaseIdentity.dev ||
972
+ heldIdentity.ino !== leaseIdentity.ino ||
973
+ heldIdentity.mode !== leaseIdentity.mode
974
+ ) {
975
+ return false;
976
+ }
977
+ const opened = await this.openOwnedInternalFile(leasePath);
978
+ currentPathHandle = opened.handle;
979
+ if (
980
+ opened.identity.dev !== leaseIdentity.dev ||
981
+ opened.identity.ino !== leaseIdentity.ino ||
982
+ opened.identity.mode !== leaseIdentity.mode
983
+ ) {
984
+ return false;
985
+ }
986
+ return (await readFileHandleText(currentPathHandle)) === leaseOwner;
987
+ } catch {
988
+ return false;
989
+ } finally {
990
+ if (currentPathHandle) {
991
+ await currentPathHandle.close().catch(() => undefined);
992
+ }
993
+ }
994
+ };
995
+
581
996
  let released = false;
582
- return async () => {
997
+ const release = async () => {
583
998
  if (released) return;
584
999
  released = true;
585
- clearInterval(heartbeat);
586
- await this.unlinkOwnedInternalFile(leasePath);
1000
+ try {
1001
+ if (await ownsLease()) await this.unlinkOwnedInternalFile(leasePath);
1002
+ } finally {
1003
+ await leaseHandle?.close().catch(() => undefined);
1004
+ leaseHandle = undefined;
1005
+ }
587
1006
  };
1007
+ return Object.assign(release, {
1008
+ async assertOwned(): Promise<void> {
1009
+ if (released || !(await ownsLease())) {
1010
+ throw new Error("BunStorage writer lease ownership lost");
1011
+ }
1012
+ },
1013
+ });
588
1014
  }
589
1015
 
590
- private async createReaderLease(
591
- key: string,
592
- ): Promise<(() => Promise<void>) | undefined> {
1016
+ private async createReaderLease(key: string): Promise<TempLease | undefined> {
593
1017
  const objectPath = this.getInternalObjectPath(key);
594
1018
  try {
595
1019
  await assertPathChainWithinBasePath(
@@ -612,22 +1036,31 @@ export class BunStorage implements IObjectStorage {
612
1036
  private async isLiveTempLease(leasePath: string): Promise<boolean> {
613
1037
  let leaseHandle: FileHandle | undefined;
614
1038
  try {
615
- const opened = await this.openOwnedInternalFile(leasePath);
616
- leaseHandle = opened.handle;
617
- const leaseStats = await leaseHandle.stat();
618
- if (Date.now() - leaseStats.mtimeMs > TEMP_LEASE_TTL_MS) return false;
619
- const lease = JSON.parse(await readFileHandleText(leaseHandle)) as {
620
- pid?: unknown;
621
- };
622
- if (!Number.isInteger(lease.pid) || Number(lease.pid) <= 0) return false;
623
1039
  try {
624
- process.kill(Number(lease.pid), 0);
1040
+ const opened = await this.openOwnedInternalFile(leasePath);
1041
+ leaseHandle = opened.handle;
1042
+ } catch (error) {
1043
+ if (isNotFoundError(error)) return false;
1044
+ reportUnknownLeaseRetention();
1045
+ return true;
1046
+ }
1047
+
1048
+ const leaseOwner = parseLeaseOwner(await readFileHandleText(leaseHandle));
1049
+ if (!leaseOwner) {
1050
+ reportUnknownLeaseRetention();
625
1051
  return true;
626
- } catch {
627
- return false;
628
1052
  }
1053
+ const state = await classifyLeaseOwner(
1054
+ leaseOwner,
1055
+ this.leaseProcessProbe,
1056
+ );
1057
+ if (state === "unknown") reportUnknownLeaseRetention();
1058
+ // Absence or a different start token is proof that this exact local
1059
+ // process incarnation is dead. Every other state is fail-safe retained.
1060
+ return state !== "dead";
629
1061
  } catch {
630
- return false;
1062
+ reportUnknownLeaseRetention();
1063
+ return true;
631
1064
  } finally {
632
1065
  if (leaseHandle) await leaseHandle.close().catch(() => undefined);
633
1066
  }
@@ -842,8 +1275,7 @@ export class BunStorage implements IObjectStorage {
842
1275
  }
843
1276
 
844
1277
  private async readOwnedInternalFileMetadata(filePath: string): Promise<{
845
- httpMetadata?: ObjectMetadata["httpMetadata"];
846
- customMetadata?: Record<string, string>;
1278
+ contentType?: string;
847
1279
  }> {
848
1280
  return this.parseGenerationMetadata(
849
1281
  await this.readOwnedInternalFileText(filePath),
@@ -925,6 +1357,7 @@ export class BunStorage implements IObjectStorage {
925
1357
  resolvedObjectPath,
926
1358
  files,
927
1359
  );
1360
+ let objectKey: string | undefined;
928
1361
  let retainedGeneration: string | null = null;
929
1362
  try {
930
1363
  const markerPath = path.join(resolvedObjectPath, COMMIT_FILE);
@@ -937,7 +1370,12 @@ export class BunStorage implements IObjectStorage {
937
1370
  record.version === 1 &&
938
1371
  typeof record.key === "string" &&
939
1372
  record.keyHash === objectEntry.name &&
940
- keyHash(record.key) === objectEntry.name &&
1373
+ keyHash(record.key) === objectEntry.name
1374
+ ) {
1375
+ objectKey = record.key;
1376
+ }
1377
+ if (
1378
+ objectKey !== undefined &&
941
1379
  record.state === "committed" &&
942
1380
  typeof record.generation === "string" &&
943
1381
  GENERATION_ID_PATTERN.test(record.generation)
@@ -1009,6 +1447,16 @@ export class BunStorage implements IObjectStorage {
1009
1447
  !liveLeaseState.generations.has(generationMatch[1]!) &&
1010
1448
  !liveLeaseState.reader
1011
1449
  ) {
1450
+ // Recovery can overlap a rolling writer. The marker and lease
1451
+ // values above are only scan snapshots: if a valid marker gave us
1452
+ // the object key, re-establish the same fresh deletion authority
1453
+ // used by steady-state GC immediately before unlinking.
1454
+ if (
1455
+ objectKey !== undefined &&
1456
+ !(await this.canReclaimGeneration(objectKey, generationMatch[1]!))
1457
+ ) {
1458
+ continue;
1459
+ }
1012
1460
  await this.unlinkOwnedInternalFile(
1013
1461
  path.join(resolvedObjectPath, entry.name),
1014
1462
  );
@@ -1026,6 +1474,7 @@ export class BunStorage implements IObjectStorage {
1026
1474
 
1027
1475
  private async readCommitRecord(
1028
1476
  key: string,
1477
+ allowCachedFallback = true,
1029
1478
  ): Promise<CommitRecord | undefined> {
1030
1479
  const markerPath = this.getCommitPath(key);
1031
1480
  let markerObserved = false;
@@ -1076,7 +1525,11 @@ export class BunStorage implements IObjectStorage {
1076
1525
  // legacy path after a rename race. The caller's resolve loop will reread
1077
1526
  // the marker; a truly absent marker still enables legacy compatibility.
1078
1527
  const cached = this.lastCommitRecords.get(key);
1079
- if (cached && lastNotFound) return cached;
1528
+ if (allowCachedFallback && cached && lastNotFound) return cached;
1529
+ // Reclamation must never act on a cached marker. An absent/racing marker
1530
+ // is insufficient authority to delete a generation; a later pass or
1531
+ // startup recovery can retry after the marker is readable.
1532
+ if (!allowCachedFallback && lastNotFound) throw lastNotFound;
1080
1533
  if (markerObserved && lastNotFound) throw lastNotFound;
1081
1534
  return undefined;
1082
1535
  }
@@ -1107,29 +1560,19 @@ export class BunStorage implements IObjectStorage {
1107
1560
  }
1108
1561
 
1109
1562
  private async readGenerationMetadata(metaPath: string): Promise<{
1110
- httpMetadata?: ObjectMetadata["httpMetadata"];
1111
- customMetadata?: Record<string, string>;
1563
+ contentType?: string;
1112
1564
  }> {
1113
1565
  return this.readOwnedInternalFileMetadata(metaPath);
1114
1566
  }
1115
1567
 
1116
1568
  private parseGenerationMetadata(valueText: string): {
1117
- httpMetadata?: ObjectMetadata["httpMetadata"];
1118
- customMetadata?: Record<string, string>;
1569
+ contentType?: string;
1119
1570
  } {
1120
- const value = JSON.parse(valueText) as {
1121
- httpMetadata?: ObjectMetadata["httpMetadata"];
1122
- customMetadata?: Record<string, string>;
1123
- };
1124
- if (value === null || typeof value !== "object") {
1125
- throw new Error("Invalid BunStorage generation metadata");
1126
- }
1127
- return value;
1571
+ return parseObjectMetadata(valueText);
1128
1572
  }
1129
1573
 
1130
1574
  private async readGenerationMetadataHandle(handle: FileHandle): Promise<{
1131
- httpMetadata?: ObjectMetadata["httpMetadata"];
1132
- customMetadata?: Record<string, string>;
1575
+ contentType?: string;
1133
1576
  }> {
1134
1577
  return this.parseGenerationMetadata(await readFileHandleText(handle));
1135
1578
  }
@@ -1141,13 +1584,12 @@ export class BunStorage implements IObjectStorage {
1141
1584
  const releaseKeyRead = withLease
1142
1585
  ? this.acquireKeyReadReservation(key)
1143
1586
  : undefined;
1144
- let readerLease: (() => Promise<void>) | undefined;
1145
- let readerLeaseOwned = true;
1587
+ let readerLease: TempLease | undefined;
1146
1588
  const releaseReaderLease = async () => {
1147
1589
  const release = readerLease;
1148
1590
  readerLease = undefined;
1149
1591
  await release?.();
1150
- if (release) await this.reclaimUnreferencedGenerations(key);
1592
+ if (release) this.scheduleReclaim(key);
1151
1593
  };
1152
1594
  try {
1153
1595
  if (withLease) readerLease = await this.createReaderLease(key);
@@ -1173,10 +1615,16 @@ export class BunStorage implements IObjectStorage {
1173
1615
  : undefined;
1174
1616
  let bodyHandle: FileHandle | undefined;
1175
1617
  let metadataHandle: FileHandle | undefined;
1176
- let released = false;
1177
- const releaseGenerationLease = async () => {
1178
- if (released) return;
1179
- released = true;
1618
+ let generationProtectionReleased = false;
1619
+ const releaseGenerationProtection = async () => {
1620
+ if (generationProtectionReleased) return;
1621
+ generationProtectionReleased = true;
1622
+ await generationLease?.();
1623
+ };
1624
+ let bodyReleased = false;
1625
+ const releaseBody = async () => {
1626
+ if (bodyReleased) return;
1627
+ bodyReleased = true;
1180
1628
  if (metadataHandle) {
1181
1629
  await metadataHandle.close().catch(() => undefined);
1182
1630
  metadataHandle = undefined;
@@ -1185,11 +1633,10 @@ export class BunStorage implements IObjectStorage {
1185
1633
  await bodyHandle.close().catch(() => undefined);
1186
1634
  bodyHandle = undefined;
1187
1635
  }
1188
- await generationLease?.();
1189
1636
  };
1190
- const releaseLease = async () => {
1191
- await releaseGenerationLease();
1192
- await releaseReaderLease();
1637
+ const releaseAttempt = async () => {
1638
+ await releaseBody();
1639
+ await releaseGenerationProtection();
1193
1640
  };
1194
1641
  try {
1195
1642
  const filePath = this.getGenerationPath(key, generation, "body");
@@ -1203,7 +1650,7 @@ export class BunStorage implements IObjectStorage {
1203
1650
  const resolvedMetaPath =
1204
1651
  await this.resolveExistingInternalPath(metaPath);
1205
1652
  if (!resolvedFilePath || !resolvedMetaPath) {
1206
- await releaseGenerationLease();
1653
+ await releaseAttempt();
1207
1654
  await yieldForFilesystem();
1208
1655
  continue;
1209
1656
  }
@@ -1220,12 +1667,20 @@ export class BunStorage implements IObjectStorage {
1220
1667
  .handle;
1221
1668
  const metadata =
1222
1669
  await this.readGenerationMetadataHandle(metadataHandle);
1223
- readerLeaseOwned = false;
1670
+ await metadataHandle.close();
1671
+ metadataHandle = undefined;
1672
+ // Once the body FD is open and metadata is materialized, POSIX
1673
+ // unlink semantics keep those exact bytes readable without any
1674
+ // pathname lease. Release both local and cross-instance path
1675
+ // protection now so an undrained lazy stream cannot retain every
1676
+ // superseded generation or one lease FD per object lifetime.
1677
+ await releaseGenerationProtection();
1678
+ await releaseReaderLease();
1224
1679
  return {
1225
1680
  filePath: resolvedFilePath,
1226
1681
  bodyHandle,
1227
1682
  metadata,
1228
- releaseLease,
1683
+ releaseBody,
1229
1684
  };
1230
1685
  }
1231
1686
 
@@ -1235,10 +1690,10 @@ export class BunStorage implements IObjectStorage {
1235
1690
  await openedMeta.handle.close();
1236
1691
  const metadata =
1237
1692
  await this.readGenerationMetadata(resolvedMetaPath);
1238
- await releaseGenerationLease();
1693
+ await releaseGenerationProtection();
1239
1694
  return { filePath: resolvedFilePath, metadata };
1240
1695
  } catch (error) {
1241
- await releaseGenerationLease();
1696
+ await releaseAttempt();
1242
1697
  if (
1243
1698
  isNotFoundError(error) &&
1244
1699
  attempt + 1 < OBJECT_RESOLVE_ATTEMPTS
@@ -1296,27 +1751,24 @@ export class BunStorage implements IObjectStorage {
1296
1751
  metadata = this.parseGenerationMetadata(
1297
1752
  await readFileHandleText(metadataHandle),
1298
1753
  );
1754
+ await metadataHandle.close();
1755
+ metadataHandle = undefined;
1299
1756
  }
1300
1757
  let released = false;
1301
- const releaseLease = async () => {
1758
+ const releaseBody = async () => {
1302
1759
  if (released) return;
1303
1760
  released = true;
1304
- if (metadataHandle) {
1305
- await metadataHandle.close().catch(() => undefined);
1306
- metadataHandle = undefined;
1307
- }
1308
1761
  if (bodyHandle) {
1309
1762
  await bodyHandle.close().catch(() => undefined);
1310
1763
  bodyHandle = undefined;
1311
1764
  }
1312
- await releaseReaderLease();
1313
1765
  };
1314
- readerLeaseOwned = false;
1766
+ await releaseReaderLease();
1315
1767
  return {
1316
1768
  filePath: resolvedFilePath,
1317
1769
  bodyHandle,
1318
1770
  metadata,
1319
- releaseLease,
1771
+ releaseBody,
1320
1772
  };
1321
1773
  } catch (error) {
1322
1774
  if (metadataHandle)
@@ -1337,7 +1789,7 @@ export class BunStorage implements IObjectStorage {
1337
1789
  return null;
1338
1790
  } finally {
1339
1791
  releaseKeyRead?.();
1340
- if (readerLeaseOwned) await releaseReaderLease();
1792
+ await releaseReaderLease();
1341
1793
  }
1342
1794
  }
1343
1795
 
@@ -1380,6 +1832,8 @@ export class BunStorage implements IObjectStorage {
1380
1832
  );
1381
1833
  await syncAndClose(markerHandle);
1382
1834
  markerHandle = undefined;
1835
+ await releaseLease.assertOwned();
1836
+ await this.beforeCommitRename?.(markerPath);
1383
1837
  await rename(markerTempPath, markerPath);
1384
1838
  // The marker rename is the publication point. Mark the temporary path
1385
1839
  // gone immediately so a subsequent directory-fsync error cannot make
@@ -1397,44 +1851,72 @@ export class BunStorage implements IObjectStorage {
1397
1851
  key: string,
1398
1852
  generation: string | null | undefined,
1399
1853
  ): Promise<void> {
1400
- if (!generation) {
1401
- await this.reclaimUnreferencedGenerations(key);
1402
- return;
1854
+ if (generation && (await this.canReclaimGeneration(key, generation))) {
1855
+ for (const suffix of ["body", "meta.json"] as const) {
1856
+ // Re-establish authority for each unlink. A reader or delayed writer
1857
+ // can publish/acquire a lease after removal of the paired file starts;
1858
+ // leaving one orphan is safer than deleting a newly-current pair.
1859
+ if (!(await this.canReclaimGeneration(key, generation))) break;
1860
+ const filePath = this.getGenerationPath(key, generation, suffix);
1861
+ await this.unlinkOwnedInternalFile(filePath);
1862
+ }
1403
1863
  }
1864
+ await this.reclaimUnreferencedGenerations(key);
1865
+ }
1866
+
1867
+ /**
1868
+ * Establish deletion authority for one generation without trusting a stale
1869
+ * marker snapshot.
1870
+ *
1871
+ * Writer ordering is lease -> generation files -> commit marker -> lease
1872
+ * release. Reader ordering is reader lease -> marker/open body -> lease
1873
+ * release. Checking both leases around an uncached marker read therefore
1874
+ * prevents GC from unlinking either the currently published generation or a
1875
+ * generation that an already-started reader can still reference.
1876
+ */
1877
+ private async canReclaimGeneration(
1878
+ key: string,
1879
+ generation: string,
1880
+ ): Promise<boolean> {
1881
+ if (
1882
+ this.activeGenerations.has(`${keyHash(key)}:${generation}`) ||
1883
+ this.hasGenerationLease(key, generation) ||
1884
+ this.hasKeyReadReservation(key)
1885
+ ) {
1886
+ return false;
1887
+ }
1888
+
1889
+ const objectPath = this.getInternalObjectPath(key);
1890
+ const generationLeasePath = path.join(
1891
+ objectPath,
1892
+ `${TEMP_MARKER}${generation}.${LEASE_SUFFIX}`,
1893
+ );
1894
+ if (
1895
+ (await this.isLiveTempLease(generationLeasePath)) ||
1896
+ (await this.hasLiveReaderLease(objectPath))
1897
+ ) {
1898
+ return false;
1899
+ }
1900
+
1404
1901
  let current: CommitRecord | undefined;
1405
1902
  try {
1406
- current = await this.readCommitRecord(key);
1903
+ current = await this.readCommitRecord(key, false);
1407
1904
  } catch {
1408
- // A malformed marker is fail-closed; leave bytes for startup recovery
1409
- // rather than risking removal of a generation we cannot identify.
1410
- return;
1905
+ return false;
1411
1906
  }
1412
- if (!(
1413
- current?.state === "committed" && current.generation === generation
1414
- )) {
1415
- const activeKey = `${keyHash(key)}:${generation}`;
1416
- const leasePath = path.join(
1417
- this.getInternalObjectPath(key),
1418
- `${TEMP_MARKER}${generation}.${LEASE_SUFFIX}`,
1419
- );
1420
- const liveLease = await this.isLiveTempLease(leasePath);
1421
- const readerLease = await this.hasLiveReaderLease(
1422
- this.getInternalObjectPath(key),
1423
- );
1424
- if (
1425
- !this.activeGenerations.has(activeKey) &&
1426
- !this.hasGenerationLease(key, generation) &&
1427
- !this.hasKeyReadReservation(key) &&
1428
- !liveLease &&
1429
- !readerLease
1430
- ) {
1431
- for (const suffix of ["body", "meta.json"] as const) {
1432
- const filePath = this.getGenerationPath(key, generation, suffix);
1433
- await this.unlinkOwnedInternalFile(filePath);
1434
- }
1435
- }
1907
+ if (current?.state === "committed" && current.generation === generation) {
1908
+ return false;
1436
1909
  }
1437
- await this.reclaimUnreferencedGenerations(key);
1910
+
1911
+ // A reader can acquire its cross-instance lease immediately before the
1912
+ // authoritative marker read and still hold the previous generation after
1913
+ // a concurrent publication. Recheck after the marker decision closes that
1914
+ // window. Rechecking the writer lease keeps deletion fail-closed if a
1915
+ // generation lease was transiently unreadable during the first probe.
1916
+ return !(
1917
+ (await this.hasLiveReaderLease(objectPath)) ||
1918
+ (await this.isLiveTempLease(generationLeasePath))
1919
+ );
1438
1920
  }
1439
1921
 
1440
1922
  private async reclaimUnreferencedGenerations(key: string): Promise<void> {
@@ -1482,14 +1964,19 @@ export class BunStorage implements IObjectStorage {
1482
1964
  ) {
1483
1965
  continue;
1484
1966
  }
1967
+ // `retainedGeneration` and `liveLeaseState` are scan snapshots. A writer
1968
+ // may publish and release its lease while this reclamation pass is in
1969
+ // flight, so re-establish current marker + lease authority immediately
1970
+ // before every destructive unlink.
1971
+ if (!(await this.canReclaimGeneration(key, generation))) continue;
1485
1972
  await this.unlinkOwnedInternalFile(
1486
1973
  path.join(resolvedObjectPath, entry.name),
1487
1974
  );
1488
1975
  }
1489
- // A crashed writer can leave body/metadata/marker temp files after its
1490
- // lease expires. Keep only temps belonging to an active/live generation;
1491
- // stale artifacts must be reclaimed during steady-state GC as well as
1492
- // startup recovery.
1976
+ // A crashed local writer can leave body/metadata/marker temp files after
1977
+ // its exact owner incarnation is proved dead. Keep only temps belonging to
1978
+ // an active/retained generation; proved-unowned artifacts are reclaimed
1979
+ // during steady-state GC as well as startup recovery.
1493
1980
  for (const entry of entries) {
1494
1981
  if (entry.isDirectory() || !entry.name.startsWith(TEMP_MARKER)) {
1495
1982
  continue;
@@ -1513,17 +2000,17 @@ export class BunStorage implements IObjectStorage {
1513
2000
 
1514
2001
  async put(
1515
2002
  key: string,
1516
- value: Blob | ReadableStream | ArrayBuffer | string,
1517
- options?: {
1518
- httpMetadata?: ObjectMetadata["httpMetadata"];
1519
- customMetadata?: Record<string, string>;
1520
- },
2003
+ value: ObjectStoreBody,
2004
+ options?: ObjectStorePutOptions,
1521
2005
  ): Promise<void> {
1522
2006
  // Serialize before touching the filesystem. A metadata-shape failure must
1523
2007
  // leave the currently committed generation completely untouched.
1524
2008
  const metadataPayload = JSON.stringify({
1525
- httpMetadata: options?.httpMetadata,
1526
- customMetadata: options?.customMetadata,
2009
+ contentType: options?.contentType,
2010
+ httpMetadata:
2011
+ options?.contentType === undefined
2012
+ ? undefined
2013
+ : { contentType: options.contentType },
1527
2014
  });
1528
2015
  const filePath = this.getFilePath(key);
1529
2016
 
@@ -1575,7 +2062,7 @@ export class BunStorage implements IObjectStorage {
1575
2062
  );
1576
2063
  const activeGenerationKey = `${keyHash(key)}:${generation}`;
1577
2064
  this.activeGenerations.add(activeGenerationKey);
1578
- let releaseLease: (() => Promise<void>) | undefined;
2065
+ let releaseLease: TempLease | undefined;
1579
2066
  let bodyHandle: FileHandle | undefined;
1580
2067
  let metadataHandle: FileHandle | undefined;
1581
2068
  let commitHandle: FileHandle | undefined;
@@ -1625,6 +2112,12 @@ export class BunStorage implements IObjectStorage {
1625
2112
  );
1626
2113
  await syncAndClose(commitHandle);
1627
2114
  commitHandle = undefined;
2115
+ // Publication is permitted only while this exact lease inode/token is
2116
+ // still owned. Recovery never expires a possibly-live owner solely by
2117
+ // age, so this assertion and the marker rename form the fencing edge:
2118
+ // a dead owner cannot resume, and a live owner cannot be reclaimed.
2119
+ await releaseLease.assertOwned();
2120
+ await this.beforeCommitRename?.(commitPath);
1628
2121
  await rename(commitTempPath, commitPath);
1629
2122
  // Rename makes the complete body+metadata generation authoritative.
1630
2123
  // Set both flags before fsync: if syncing the containing directory
@@ -1665,52 +2158,46 @@ export class BunStorage implements IObjectStorage {
1665
2158
  await this.removeGenerationIfUnreferenced(key, previousGeneration);
1666
2159
  }
1667
2160
 
1668
- async get(key: string): Promise<StorageObject | null> {
1669
- let releaseLease: (() => Promise<void>) | undefined;
2161
+ async get(key: string): Promise<ObjectStoreObject | null> {
2162
+ let releaseBody: (() => Promise<void>) | undefined;
2163
+ let bodyHandedOff = false;
1670
2164
  try {
1671
2165
  const resolvedObject = await this.resolveObject(key, true);
1672
2166
  if (!resolvedObject) return null;
1673
- releaseLease = resolvedObject.releaseLease;
1674
-
1675
- const content = resolvedObject.bodyHandle
1676
- ? await readFileHandleFully(resolvedObject.bodyHandle)
1677
- : new Uint8Array(await Bun.file(resolvedObject.filePath).arrayBuffer());
2167
+ releaseBody = resolvedObject.releaseBody;
1678
2168
  const metadata = resolvedObject.metadata;
1679
2169
 
1680
- let bodyUsed = false;
2170
+ if (!resolvedObject.bodyHandle) {
2171
+ throw new Error("BunStorage object body is not open");
2172
+ }
2173
+ const byteLength = (await resolvedObject.bodyHandle.stat()).size;
2174
+ if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
2175
+ throw new Error("invalid filesystem object size");
2176
+ }
2177
+ const body = createFileBodyStream(
2178
+ resolvedObject.bodyHandle,
2179
+ byteLength,
2180
+ async () => {
2181
+ await releaseBody?.();
2182
+ releaseBody = undefined;
2183
+ },
2184
+ );
2185
+ bodyHandedOff = true;
1681
2186
 
1682
2187
  return {
1683
2188
  key,
1684
- body: new ReadableStream({
1685
- start(controller) {
1686
- controller.enqueue(content);
1687
- controller.close();
1688
- },
1689
- }),
1690
- bodyUsed,
1691
- arrayBuffer: async () => {
1692
- bodyUsed = true;
1693
- return content.buffer as ArrayBuffer;
1694
- },
1695
- text: async () => {
1696
- bodyUsed = true;
1697
- return new TextDecoder().decode(content);
1698
- },
1699
- json: async <T>() => {
1700
- bodyUsed = true;
1701
- return JSON.parse(new TextDecoder().decode(content)) as T;
1702
- },
1703
- httpMetadata: metadata.httpMetadata,
1704
- customMetadata: metadata.customMetadata,
2189
+ body,
2190
+ contentType: metadata.contentType,
2191
+ byteLength,
1705
2192
  };
1706
2193
  } catch {
1707
2194
  return null;
1708
2195
  } finally {
1709
- await releaseLease?.();
2196
+ if (!bodyHandedOff) await releaseBody?.();
1710
2197
  }
1711
2198
  }
1712
2199
 
1713
- async delete(key: string | string[]): Promise<void> {
2200
+ async delete(key: string | readonly string[]): Promise<void> {
1714
2201
  const keys = Array.isArray(key) ? key : [key];
1715
2202
  for (const k of keys) {
1716
2203
  let markerPath: string;
@@ -1783,158 +2270,6 @@ export class BunStorage implements IObjectStorage {
1783
2270
  await this.removeGenerationIfUnreferenced(k, previousGeneration);
1784
2271
  }
1785
2272
  }
1786
-
1787
- async list(options?: {
1788
- prefix?: string;
1789
- limit?: number;
1790
- cursor?: string;
1791
- delimiter?: string;
1792
- }): Promise<ListObjectsResult> {
1793
- const objects: ListObjectsResult["objects"] = [];
1794
- const realBasePath = await this.getRealBasePath();
1795
- const committed = new Map<string, ResolvedObject>();
1796
- const deleted = new Set<string>();
1797
- const legacy: Array<{ key: string; filePath: string }> = [];
1798
-
1799
- const readDirRecursive = async (dir: string, prefix: string = "") => {
1800
- try {
1801
- const entries = await readdir(dir, { withFileTypes: true });
1802
- for (const entry of entries) {
1803
- const fullPath = `${dir}/${entry.name}`;
1804
- const realFullPath = await realpath(fullPath);
1805
- if (!isPathWithinBasePath(realBasePath, realFullPath)) continue;
1806
- const key = prefix ? `${prefix}/${entry.name}` : entry.name;
1807
-
1808
- if (entry.isDirectory()) {
1809
- await readDirRecursive(fullPath, key);
1810
- } else if (!isInternalStorageName(entry.name)) {
1811
- legacy.push({ key, filePath: fullPath });
1812
- }
1813
- }
1814
- } catch {
1815
- // Directory doesn't exist
1816
- }
1817
- };
1818
-
1819
- await readDirRecursive(realBasePath);
1820
-
1821
- // Enumerate only validated commit records from the hidden generation
1822
- // store. This is deliberately separate from the legacy body scan so no
1823
- // user key can collide with an internal filename.
1824
- const internalRoot = this.getInternalStorePath();
1825
- try {
1826
- const resolvedRoot = await this.resolveExistingInternalPath(internalRoot);
1827
- if (resolvedRoot) {
1828
- const objectEntries = await readdir(resolvedRoot, {
1829
- withFileTypes: true,
1830
- });
1831
- for (const objectEntry of objectEntries) {
1832
- if (
1833
- !objectEntry.isDirectory() ||
1834
- !DIGEST_PATTERN.test(objectEntry.name)
1835
- ) {
1836
- continue;
1837
- }
1838
- const objectPath = path.join(resolvedRoot, objectEntry.name);
1839
- const resolvedObjectPath =
1840
- await this.resolveExistingInternalPath(objectPath);
1841
- if (!resolvedObjectPath) continue;
1842
- const markerPath = path.join(resolvedObjectPath, COMMIT_FILE);
1843
- try {
1844
- const record = JSON.parse(
1845
- await this.readOwnedInternalFileText(markerPath),
1846
- ) as Partial<CommitRecord>;
1847
- if (
1848
- record.version !== 1 ||
1849
- typeof record.key !== "string" ||
1850
- record.keyHash !== objectEntry.name ||
1851
- keyHash(record.key) !== objectEntry.name
1852
- ) {
1853
- continue;
1854
- }
1855
- if (record.state === "deleted" && record.generation === null) {
1856
- deleted.add(record.key);
1857
- continue;
1858
- }
1859
- if (
1860
- record.state !== "committed" ||
1861
- typeof record.generation !== "string" ||
1862
- !GENERATION_ID_PATTERN.test(record.generation)
1863
- ) {
1864
- continue;
1865
- }
1866
- const resolved = await this.resolveObject(record.key, true);
1867
- if (resolved) committed.set(record.key, resolved);
1868
- } catch {
1869
- // A malformed or incomplete marker is never exposed as an object.
1870
- }
1871
- }
1872
- }
1873
- } catch {
1874
- // The hidden store may not exist yet.
1875
- }
1876
-
1877
- for (const [key, resolved] of committed) {
1878
- try {
1879
- if (deleted.has(key)) continue;
1880
- if (options?.prefix && !key.startsWith(options.prefix)) continue;
1881
- const stats = resolved.bodyHandle
1882
- ? await resolved.bodyHandle.stat()
1883
- : await stat(resolved.filePath);
1884
- objects.push({ key, size: stats.size, uploaded: stats.mtime });
1885
- } catch {
1886
- // The generation disappeared between the marker and this read.
1887
- } finally {
1888
- await resolved.releaseLease?.();
1889
- }
1890
- }
1891
- for (const candidate of legacy) {
1892
- if (committed.has(candidate.key) || deleted.has(candidate.key)) continue;
1893
- if (options?.prefix && !candidate.key.startsWith(options.prefix)) {
1894
- continue;
1895
- }
1896
- try {
1897
- const stats = await stat(candidate.filePath);
1898
- objects.push({
1899
- key: candidate.key,
1900
- size: stats.size,
1901
- uploaded: stats.mtime,
1902
- });
1903
- } catch {
1904
- // The legacy object disappeared between traversal and stat.
1905
- }
1906
- }
1907
-
1908
- const limit = options?.limit ?? 1000;
1909
- const truncated = objects.length > limit;
1910
-
1911
- return {
1912
- objects: objects.slice(0, limit),
1913
- truncated,
1914
- cursor: truncated ? String(limit) : undefined,
1915
- };
1916
- }
1917
-
1918
- async head(key: string): Promise<ObjectMetadata | null> {
1919
- let releaseLease: (() => Promise<void>) | undefined;
1920
- try {
1921
- const resolvedObject = await this.resolveObject(key, true);
1922
- if (!resolvedObject) return null;
1923
- releaseLease = resolvedObject.releaseLease;
1924
- const metadata = resolvedObject.metadata;
1925
- return {
1926
- contentLength: resolvedObject.bodyHandle
1927
- ? (await resolvedObject.bodyHandle.stat()).size
1928
- : Bun.file(resolvedObject.filePath).size,
1929
- httpMetadata: metadata.httpMetadata,
1930
- customMetadata: metadata.customMetadata,
1931
- };
1932
- } catch {
1933
- return null;
1934
- } finally {
1935
- await releaseLease?.();
1936
- }
1937
- }
1938
2273
  }
1939
2274
 
1940
2275
  /**