@opengeni/storage 0.2.126 → 0.2.127-canary.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/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export type ObjectHead = {
14
14
  /** Opaque provider generation/etag used only for conditional internal reads. */
15
15
  VersionToken?: string;
16
16
  };
17
+ export { uploadWorkspaceArchiveSpool, downloadWorkspaceArchiveSpool, WorkspaceArchiveStorageError, } from "./workspace-archive-spool.js";
17
18
  export type ObjectStorage = {
18
19
  bucket: string;
19
20
  backend: "s3-compatible" | "aws-s3" | "azure-blob" | "gcs";
@@ -85,6 +86,19 @@ export type ObjectStorage = {
85
86
  body: Uint8Array;
86
87
  sha256?: string | null;
87
88
  }) => Promise<void>;
89
+ /**
90
+ * Unconditional authenticated upload from a bounded byte stream. May overwrite
91
+ * an existing key; this is NOT an atomic create-only operation. Callers needing
92
+ * write isolation must supply a fresh unique key and verify stored content.
93
+ */
94
+ putObjectStream?: (args: {
95
+ key: string;
96
+ contentType: string;
97
+ chunks: AsyncIterable<Uint8Array>;
98
+ byteSize: number;
99
+ sha256?: string;
100
+ signal?: AbortSignal;
101
+ }) => Promise<void>;
88
102
  /** Atomic create-only raw PUT. Returns false when the key already exists. */
89
103
  putObjectIfAbsent?: (args: {
90
104
  key: string;
package/dist/index.js CHANGED
@@ -629,6 +629,263 @@ function throwIfAborted4(signal) {
629
629
  if (signal?.aborted) throw new Error("Object storage operation was cancelled");
630
630
  }
631
631
 
632
+ // src/workspace-archive-spool.ts
633
+ import { createHash as createHash3 } from "crypto";
634
+ import { createReadStream } from "fs";
635
+ import { chmod as chmod2, mkdtemp as mkdtemp2, open as open2, rm as rm2 } from "fs/promises";
636
+ import { tmpdir as tmpdir2 } from "os";
637
+ import { join as join2 } from "path";
638
+ var CHUNK_BYTES = DEFAULT_BOUNDED_OBJECT_CHUNK_BYTES;
639
+ var WorkspaceArchiveStorageError = class extends Error {
640
+ constructor(code, message, retryable, options) {
641
+ super(message, options);
642
+ this.code = code;
643
+ this.retryable = retryable;
644
+ this.name = "WorkspaceArchiveStorageError";
645
+ }
646
+ };
647
+ async function uploadWorkspaceArchiveSpool(storage, key, spool) {
648
+ requireBoundedReads(storage);
649
+ if (!storage.putObjectStream) {
650
+ throw new WorkspaceArchiveStorageError(
651
+ "archive_hydration_failed",
652
+ "Workspace archive storage unsupported: unconditional streaming upload required",
653
+ false
654
+ );
655
+ }
656
+ const expected = { bytes: spool.byteSize, sha256: spool.sha256 };
657
+ validateExpected(expected);
658
+ let validated = false;
659
+ let streamFailed = false;
660
+ let streamFailure;
661
+ const chunks = async function* () {
662
+ const digest = createHash3("sha256");
663
+ let bytes = 0;
664
+ try {
665
+ for await (const chunk of spool.open()) {
666
+ if (!(chunk instanceof Uint8Array) || chunk.byteLength > expected.bytes - bytes) {
667
+ throw new WorkspaceArchiveStorageError(
668
+ "archive_hash_mismatch",
669
+ "Workspace archive upload stream has invalid size",
670
+ false
671
+ );
672
+ }
673
+ bytes += chunk.byteLength;
674
+ digest.update(chunk);
675
+ yield chunk;
676
+ }
677
+ if (bytes !== expected.bytes || digest.digest("hex") !== expected.sha256) {
678
+ throw new WorkspaceArchiveStorageError(
679
+ "archive_hash_mismatch",
680
+ "Workspace archive upload stream digest or size mismatch",
681
+ false
682
+ );
683
+ }
684
+ validated = true;
685
+ } catch (error) {
686
+ streamFailed = true;
687
+ streamFailure = error;
688
+ throw error;
689
+ }
690
+ }();
691
+ try {
692
+ await storage.putObjectStream({
693
+ key,
694
+ contentType: "application/x-tar",
695
+ chunks,
696
+ byteSize: expected.bytes,
697
+ sha256: expected.sha256
698
+ });
699
+ if (!validated) {
700
+ if (streamFailed) throw streamFailure;
701
+ throw new WorkspaceArchiveStorageError(
702
+ "archive_hydration_failed",
703
+ "Workspace archive upload provider did not completely consume and validate the stream",
704
+ false
705
+ );
706
+ }
707
+ await verifyRanges(storage, key, expected);
708
+ } catch (error) {
709
+ const failure = streamFailed ? streamFailure : error;
710
+ if (failure instanceof WorkspaceArchiveStorageError) throw failure;
711
+ throw new WorkspaceArchiveStorageError(
712
+ "archive_hydration_failed",
713
+ "Workspace archive upload or readback failed",
714
+ true,
715
+ { cause: failure }
716
+ );
717
+ } finally {
718
+ await chunks.return(void 0);
719
+ }
720
+ }
721
+ async function downloadWorkspaceArchiveSpool(storage, key, inputExpected) {
722
+ const expected = { bytes: inputExpected.bytes, sha256: inputExpected.sha256 };
723
+ requireBoundedReads(storage);
724
+ validateExpected(expected);
725
+ const directory = await mkdtemp2(join2(tmpdir2(), "opengeni-workspace-archive-"));
726
+ const path = join2(directory, "archive.tar");
727
+ let handle;
728
+ try {
729
+ await chmod2(directory, 448);
730
+ handle = await open2(path, "wx", 384);
731
+ await verifyRanges(storage, key, expected, async (chunk) => {
732
+ let offset = 0;
733
+ while (offset < chunk.byteLength) {
734
+ const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset);
735
+ if (bytesWritten <= 0)
736
+ throw new WorkspaceArchiveStorageError(
737
+ "archive_hydration_failed",
738
+ "Workspace archive spool write made no progress",
739
+ true
740
+ );
741
+ offset += bytesWritten;
742
+ }
743
+ });
744
+ await handle.close();
745
+ handle = void 0;
746
+ let disposed = false;
747
+ return {
748
+ path,
749
+ byteSize: expected.bytes,
750
+ sha256: expected.sha256,
751
+ async *open() {
752
+ if (disposed) throw new Error("Workspace archive spool is disposed");
753
+ const stream = createReadStream(path, { highWaterMark: CHUNK_BYTES });
754
+ try {
755
+ for await (const chunk of stream) yield chunk;
756
+ } finally {
757
+ stream.destroy();
758
+ }
759
+ },
760
+ async dispose() {
761
+ disposed = true;
762
+ await rm2(directory, { recursive: true, force: true });
763
+ }
764
+ };
765
+ } catch (error) {
766
+ await handle?.close().catch(() => void 0);
767
+ await rm2(directory, { recursive: true, force: true });
768
+ if (error instanceof WorkspaceArchiveStorageError) throw error;
769
+ throw new WorkspaceArchiveStorageError(
770
+ "archive_hydration_failed",
771
+ "Workspace archive download failed",
772
+ true,
773
+ { cause: error }
774
+ );
775
+ }
776
+ }
777
+ function requireBoundedReads(storage) {
778
+ if (!storage.headObject || !storage.getObjectRange) {
779
+ throw new WorkspaceArchiveStorageError(
780
+ "archive_hydration_failed",
781
+ "Workspace archive storage unsupported: versioned head/range reads required",
782
+ false
783
+ );
784
+ }
785
+ }
786
+ function validateExpected(expected) {
787
+ if (!Number.isSafeInteger(expected.bytes) || expected.bytes < 0 || !/^[0-9a-f]{64}$/u.test(expected.sha256)) {
788
+ throw new WorkspaceArchiveStorageError(
789
+ "archive_hydration_failed",
790
+ "Workspace archive expected size or SHA-256 is invalid",
791
+ false
792
+ );
793
+ }
794
+ }
795
+ async function verifyRanges(storage, key, expected, consume) {
796
+ const head = await storage.headObject(key);
797
+ if (!head)
798
+ throw new WorkspaceArchiveStorageError(
799
+ "archive_base64_invalid",
800
+ "Workspace archive object is missing",
801
+ false
802
+ );
803
+ if (head.ContentLength !== expected.bytes)
804
+ throw new WorkspaceArchiveStorageError(
805
+ "archive_hash_mismatch",
806
+ "Workspace archive object size mismatch",
807
+ false
808
+ );
809
+ const version = head.VersionToken;
810
+ if (typeof version !== "string" || version.length === 0 || version.length > 2048) {
811
+ throw new WorkspaceArchiveStorageError(
812
+ "archive_hydration_failed",
813
+ "Workspace archive storage unsupported: valid object version token required",
814
+ false
815
+ );
816
+ }
817
+ const digest = createHash3("sha256");
818
+ let bytes = 0;
819
+ while (bytes < expected.bytes) {
820
+ const length = Math.min(CHUNK_BYTES, expected.bytes - bytes);
821
+ const result = await storage.getObjectRange({
822
+ key,
823
+ start: bytes,
824
+ endInclusive: bytes + length - 1,
825
+ expectedVersionToken: version
826
+ });
827
+ if (!result) {
828
+ const current = await storage.headObject(key);
829
+ if (!current) {
830
+ throw new WorkspaceArchiveStorageError(
831
+ "archive_base64_invalid",
832
+ "Workspace archive object is missing during range read",
833
+ false
834
+ );
835
+ }
836
+ throw new WorkspaceArchiveStorageError(
837
+ "archive_hydration_failed",
838
+ current.VersionToken !== version ? "Workspace archive object version changed" : "Workspace archive pinned range is temporarily unavailable",
839
+ true
840
+ );
841
+ }
842
+ if (result.versionToken !== version)
843
+ throw new WorkspaceArchiveStorageError(
844
+ "archive_hydration_failed",
845
+ "Workspace archive object version changed",
846
+ true
847
+ );
848
+ if (!(result.bytes instanceof Uint8Array) || result.bytes.byteLength !== length) {
849
+ throw new WorkspaceArchiveStorageError(
850
+ "archive_hash_mismatch",
851
+ "Workspace archive object range is truncated or has invalid size",
852
+ false
853
+ );
854
+ }
855
+ digest.update(result.bytes);
856
+ await consume?.(result.bytes);
857
+ bytes += result.bytes.byteLength;
858
+ }
859
+ const finalHead = await storage.headObject(key);
860
+ if (!finalHead)
861
+ throw new WorkspaceArchiveStorageError(
862
+ "archive_base64_invalid",
863
+ "Workspace archive object is missing after range read",
864
+ false
865
+ );
866
+ if (finalHead.VersionToken !== version) {
867
+ throw new WorkspaceArchiveStorageError(
868
+ "archive_hydration_failed",
869
+ "Workspace archive object version changed",
870
+ true
871
+ );
872
+ }
873
+ if (finalHead.ContentLength !== expected.bytes) {
874
+ throw new WorkspaceArchiveStorageError(
875
+ "archive_hash_mismatch",
876
+ "Workspace archive object size changed",
877
+ false
878
+ );
879
+ }
880
+ if (bytes !== expected.bytes || digest.digest("hex") !== expected.sha256) {
881
+ throw new WorkspaceArchiveStorageError(
882
+ "archive_hash_mismatch",
883
+ "Workspace archive object digest or size mismatch",
884
+ false
885
+ );
886
+ }
887
+ }
888
+
632
889
  // src/index.ts
633
890
  var MAX_SINGLE_PUT_SIZE_BYTES = 5e9;
634
891
  var UPLOAD_URL_TTL_SECONDS = 15 * 60;
@@ -741,6 +998,46 @@ function createS3CompatibleObjectStorage(settings) {
741
998
  throw error;
742
999
  }
743
1000
  },
1001
+ async putObjectStream(args) {
1002
+ const body = Readable.from(args.chunks, {
1003
+ objectMode: false,
1004
+ highWaterMark: INTERNAL_STREAM_BUFFER_BYTES
1005
+ });
1006
+ const request = new AbortController();
1007
+ let producerFailed = false;
1008
+ let producerError;
1009
+ const onBodyError = (error) => {
1010
+ producerFailed = true;
1011
+ producerError = error;
1012
+ request.abort(error);
1013
+ };
1014
+ const onCallerAbort = () => request.abort(args.signal?.reason);
1015
+ body.once("error", onBodyError);
1016
+ const detachBodyError = () => body.off("error", onBodyError);
1017
+ body.once("close", detachBodyError);
1018
+ args.signal?.addEventListener("abort", onCallerAbort, { once: true });
1019
+ if (args.signal?.aborted) onCallerAbort();
1020
+ try {
1021
+ await requestClient.send(
1022
+ new PutObjectCommand({
1023
+ Bucket: settings.objectStorageBucket,
1024
+ Key: args.key,
1025
+ ContentType: args.contentType,
1026
+ ContentLength: args.byteSize,
1027
+ Body: body,
1028
+ Metadata: args.sha256 ? { sha256: args.sha256 } : void 0
1029
+ }),
1030
+ { abortSignal: request.signal }
1031
+ );
1032
+ if (producerFailed) throw producerError;
1033
+ } catch (error) {
1034
+ if (producerFailed) throw producerError;
1035
+ throw error;
1036
+ } finally {
1037
+ args.signal?.removeEventListener("abort", onCallerAbort);
1038
+ body.destroy();
1039
+ }
1040
+ },
744
1041
  async putObjectStreamIfAbsent(args) {
745
1042
  try {
746
1043
  await requestClient.send(
@@ -986,6 +1283,23 @@ function createGcsObjectStorage(settings) {
986
1283
  throw error;
987
1284
  }
988
1285
  },
1286
+ async putObjectStream(args) {
1287
+ const destination = bucket.file(args.key).createWriteStream({
1288
+ resumable: false,
1289
+ contentType: args.contentType,
1290
+ highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
1291
+ ...args.sha256 ? { metadata: { metadata: { sha256: args.sha256 } } } : {}
1292
+ });
1293
+ const source = Readable.from(args.chunks, {
1294
+ objectMode: false,
1295
+ highWaterMark: INTERNAL_STREAM_BUFFER_BYTES
1296
+ });
1297
+ if (args.signal) {
1298
+ await pipeline(source, destination, { signal: args.signal });
1299
+ } else {
1300
+ await pipeline(source, destination);
1301
+ }
1302
+ },
989
1303
  async putObjectStreamIfAbsent(args) {
990
1304
  const destination = bucket.file(args.key).createWriteStream({
991
1305
  resumable: false,
@@ -1165,6 +1479,27 @@ function createAzureBlobObjectStorage(settings) {
1165
1479
  throw error;
1166
1480
  }
1167
1481
  },
1482
+ async putObjectStream(args) {
1483
+ const blobClient = requestContainerClient.getBlockBlobClient(args.key);
1484
+ const source = Readable.from(args.chunks, {
1485
+ objectMode: false,
1486
+ highWaterMark: INTERNAL_STREAM_BUFFER_BYTES
1487
+ });
1488
+ try {
1489
+ await blobClient.uploadStream(
1490
+ source,
1491
+ INTERNAL_STREAM_BUFFER_BYTES,
1492
+ INTERNAL_STREAM_CONCURRENCY,
1493
+ {
1494
+ blobHTTPHeaders: { blobContentType: args.contentType },
1495
+ ...args.sha256 ? { metadata: { sha256: args.sha256 } } : {},
1496
+ ...args.signal ? { abortSignal: args.signal } : {}
1497
+ }
1498
+ );
1499
+ } finally {
1500
+ source.destroy();
1501
+ }
1502
+ },
1168
1503
  async putObjectStreamIfAbsent(args) {
1169
1504
  const blobClient = requestContainerClient.getBlockBlobClient(args.key);
1170
1505
  try {
@@ -1429,11 +1764,14 @@ export {
1429
1764
  MAX_SINGLE_PUT_SIZE_BYTES,
1430
1765
  OBJECT_VISIBILITY_RETRY_DELAYS_MS,
1431
1766
  UPLOAD_URL_TTL_SECONDS,
1767
+ WorkspaceArchiveStorageError,
1432
1768
  bytesToDataUrl,
1433
1769
  createBoundedImmutableObjectWritePort,
1434
1770
  createBoundedObjectReadPort,
1435
1771
  createObjectStorage,
1436
1772
  createObjectStorageBoundedPorts,
1437
- retryWhileMissing
1773
+ downloadWorkspaceArchiveSpool,
1774
+ retryWhileMissing,
1775
+ uploadWorkspaceArchiveSpool
1438
1776
  };
1439
1777
  //# sourceMappingURL=index.js.map