@btravstack/storage 0.36.0 → 0.38.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.
package/README.md CHANGED
@@ -121,7 +121,7 @@ which is this list's one detailed home.
121
121
  ## What it decides, and what it does not
122
122
 
123
123
  **It decides** that a missing object is an ordinary answer rather than a fault
124
- (counted `not_found`, logged at `info`), that `delete` is idempotent, that
124
+ (counted `not_found`, logged at `info`), that a held key under `putIfAbsent` is an ordinary answer (`ObjectExists`, counted `exists`), that `delete` is idempotent, that `copy` and `head` never move the bytes through the process, that
125
125
  presigning asks the store nothing — and that an adapter which cannot presign
126
126
  says so instead of minting a URL that would fail only in production.
127
127
 
package/dist/index.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_storage = require("./storage-QVPXOcO6.cjs");
2
+ const require_storage = require("./storage-EA671z1R.cjs");
3
+ let node_crypto = require("node:crypto");
3
4
  let _btravstack_di = require("@btravstack/di");
4
5
  let unthrown = require("unthrown");
5
6
  let _btravstack_core = require("@btravstack/core");
@@ -11,6 +12,8 @@ let _btravstack_core = require("@btravstack/core");
11
12
  * which would be the worst kind of double — one that passes locally and fails in
12
13
  * the deployment for a reason no test could have shown.
13
14
  *
15
+ * `kmsKeyId` is accepted and ignored: nothing is encrypted at rest here.
16
+ *
14
17
  * ponytail: no size limit, so a process storing unbounded objects grows
15
18
  * unbounded. The upgrade path is the S3 adapter.
16
19
  */
@@ -19,14 +22,42 @@ const memoryStorageBackend = () => {
19
22
  return {
20
23
  put: (key, bytes, options) => {
21
24
  objects.set(key, {
22
- bytes,
25
+ bytes: bytes.slice(),
26
+ contentType: options.contentType
27
+ });
28
+ return (0, unthrown.OkAsync)();
29
+ },
30
+ putIfAbsent: (key, bytes, options) => {
31
+ if (objects.has(key)) return (0, unthrown.ErrAsync)(new require_storage.ObjectExists({ key }));
32
+ objects.set(key, {
33
+ bytes: bytes.slice(),
34
+ contentType: options.contentType
35
+ });
36
+ return (0, unthrown.OkAsync)();
37
+ },
38
+ copy: (from, to, options) => {
39
+ const object = objects.get(from);
40
+ if (object === void 0) return (0, unthrown.ErrAsync)(new require_storage.ObjectNotFound({ key: from }));
41
+ objects.set(to, {
42
+ bytes: object.bytes.slice(),
23
43
  contentType: options.contentType
24
44
  });
25
45
  return (0, unthrown.OkAsync)();
26
46
  },
47
+ head: (key) => {
48
+ const object = objects.get(key);
49
+ return object === void 0 ? (0, unthrown.ErrAsync)(new require_storage.ObjectNotFound({ key })) : (0, unthrown.OkAsync)({
50
+ size: object.bytes.byteLength,
51
+ contentType: object.contentType,
52
+ sha256: (0, node_crypto.createHash)("sha256").update(object.bytes).digest("hex")
53
+ });
54
+ },
27
55
  get: (key) => {
28
56
  const object = objects.get(key);
29
- return object === void 0 ? (0, unthrown.ErrAsync)(new require_storage.ObjectNotFound({ key })) : (0, unthrown.OkAsync)(object);
57
+ return object === void 0 ? (0, unthrown.ErrAsync)(new require_storage.ObjectNotFound({ key })) : (0, unthrown.OkAsync)({
58
+ ...object,
59
+ bytes: object.bytes.slice()
60
+ });
30
61
  },
31
62
  delete: (key) => {
32
63
  objects.delete(key);
@@ -59,17 +90,23 @@ const memoryStorage = () => (0, _btravstack_di.Module)("MemoryStorage")({
59
90
  */
60
91
  const failed = (failure) => {
61
92
  const cause = failure.tag === "Err" ? failure.error : failure.cause;
62
- const ordinary = failure.tag !== "Err" ? void 0 : failure.error._tag === "ObjectNotFound" ? "the object was not there" : failure.error._tag === "PresignNotSupported" ? "this store cannot mint a url" : void 0;
93
+ const ordinary = failure.tag !== "Err" ? void 0 : failure.error._tag === "ObjectExists" ? {
94
+ result: "exists",
95
+ reason: "an object already held the key"
96
+ } : failure.error._tag === "ObjectNotFound" ? {
97
+ result: "not_found",
98
+ reason: "the object was not there"
99
+ } : failure.error._tag === "PresignNotSupported" ? {
100
+ result: "not_found",
101
+ reason: "this store cannot mint a url"
102
+ } : void 0;
63
103
  return ordinary === void 0 ? {
64
104
  outcome: "error",
65
105
  attributes: { result: "error" },
66
106
  cause
67
107
  } : {
68
108
  outcome: "ok",
69
- attributes: {
70
- result: "not_found",
71
- reason: ordinary
72
- }
109
+ attributes: ordinary
73
110
  };
74
111
  };
75
112
  /** The KEY is a detail, not a dimension: on the span and the line, never on an instrument. */
@@ -90,7 +127,10 @@ const instrument = (backend, observers) => {
90
127
  const observe = through(observers);
91
128
  return {
92
129
  put: (key, bytes, options) => observe("put", key, () => backend.put(key, bytes, options)),
130
+ putIfAbsent: (key, bytes, options) => observe("put_if_absent", key, () => backend.putIfAbsent(key, bytes, options)),
93
131
  get: (key) => observe("get", key, () => backend.get(key)),
132
+ copy: (from, to, options) => observe("copy", from, () => backend.copy(from, to, options)),
133
+ head: (key) => observe("head", key, () => backend.head(key)),
94
134
  delete: (key) => observe("delete", key, () => backend.delete(key)),
95
135
  presignedUrl: (key, options) => observe("presigned_url", key, () => backend.presignedUrl(key, options)),
96
136
  presignedUpload: (key, options) => observe("presigned_upload", key, () => backend.presignedUpload(key, options))
@@ -139,6 +179,7 @@ const storage = ({ adapter }) => {
139
179
  });
140
180
  };
141
181
  //#endregion
182
+ exports.ObjectExists = require_storage.ObjectExists;
142
183
  exports.ObjectNotFound = require_storage.ObjectNotFound;
143
184
  exports.PresignNotSupported = require_storage.PresignNotSupported;
144
185
  exports.Storage = require_storage.Storage;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as StorageService, i as StorageBackend, n as PresignNotSupported, o as StorageUnavailable, r as Storage, s as StoredObject, t as ObjectNotFound } from "./storage-BLJtJ7KF.cjs";
1
+ import { a as Storage, c as StorageUnavailable, i as PresignNotSupported, l as StoredObject, n as ObjectHead, o as StorageBackend, r as ObjectNotFound, s as StorageService, t as ObjectExists, u as WriteOptions } from "./storage-BavPXZpA.cjs";
2
2
  import { Module, Provider } from "@btravstack/di";
3
3
  import { HealthChecks } from "@btravstack/core";
4
4
  //#region src/memory.d.ts
@@ -9,6 +9,8 @@ import { HealthChecks } from "@btravstack/core";
9
9
  * which would be the worst kind of double — one that passes locally and fails in
10
10
  * the deployment for a reason no test could have shown.
11
11
  *
12
+ * `kmsKeyId` is accepted and ignored: nothing is encrypted at rest here.
13
+ *
12
14
  * ponytail: no size limit, so a process storing unbounded objects grows
13
15
  * unbounded. The upgrade path is the S3 adapter.
14
16
  */
@@ -47,4 +49,4 @@ type StorageOptions<E, N> = {
47
49
  */
48
50
  export declare const storage: <E, N>({ adapter }: StorageOptions<E, N>) => Module<Storage | HealthChecks, E, N>;
49
51
  //#endregion
50
- export { ObjectNotFound, PresignNotSupported, Storage, StorageBackend, type StorageOptions, type StorageService, StorageUnavailable, type StoredObject };
52
+ export { ObjectExists, type ObjectHead, ObjectNotFound, PresignNotSupported, Storage, StorageBackend, type StorageOptions, type StorageService, StorageUnavailable, type StoredObject, type WriteOptions };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as StorageService, i as StorageBackend, n as PresignNotSupported, o as StorageUnavailable, r as Storage, s as StoredObject, t as ObjectNotFound } from "./storage-BLJtJ7KF.mjs";
1
+ import { a as Storage, c as StorageUnavailable, i as PresignNotSupported, l as StoredObject, n as ObjectHead, o as StorageBackend, r as ObjectNotFound, s as StorageService, t as ObjectExists, u as WriteOptions } from "./storage-BavPXZpA.mjs";
2
2
  import { Module, Provider } from "@btravstack/di";
3
3
  import { HealthChecks } from "@btravstack/core";
4
4
  //#region src/memory.d.ts
@@ -9,6 +9,8 @@ import { HealthChecks } from "@btravstack/core";
9
9
  * which would be the worst kind of double — one that passes locally and fails in
10
10
  * the deployment for a reason no test could have shown.
11
11
  *
12
+ * `kmsKeyId` is accepted and ignored: nothing is encrypted at rest here.
13
+ *
12
14
  * ponytail: no size limit, so a process storing unbounded objects grows
13
15
  * unbounded. The upgrade path is the S3 adapter.
14
16
  */
@@ -47,4 +49,4 @@ type StorageOptions<E, N> = {
47
49
  */
48
50
  export declare const storage: <E, N>({ adapter }: StorageOptions<E, N>) => Module<Storage | HealthChecks, E, N>;
49
51
  //#endregion
50
- export { ObjectNotFound, PresignNotSupported, Storage, StorageBackend, type StorageOptions, type StorageService, StorageUnavailable, type StoredObject };
52
+ export { ObjectExists, type ObjectHead, ObjectNotFound, PresignNotSupported, Storage, StorageBackend, type StorageOptions, type StorageService, StorageUnavailable, type StoredObject, type WriteOptions };
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { a as StorageUnavailable, i as StorageBackend, n as PresignNotSupported, r as Storage, t as ObjectNotFound } from "./storage-CRkwIhac.mjs";
1
+ import { a as StorageBackend, i as Storage, n as ObjectNotFound, o as StorageUnavailable, r as PresignNotSupported, t as ObjectExists } from "./storage-BIKstQ72.mjs";
2
+ import { createHash } from "node:crypto";
2
3
  import { Module, Provider } from "@btravstack/di";
3
4
  import { Err, ErrAsync, Ok, OkAsync, P } from "unthrown";
4
5
  import { HealthCheckFailed, HealthChecks, Observers, noObserverMember, observed } from "@btravstack/core";
@@ -10,6 +11,8 @@ import { HealthCheckFailed, HealthChecks, Observers, noObserverMember, observed
10
11
  * which would be the worst kind of double — one that passes locally and fails in
11
12
  * the deployment for a reason no test could have shown.
12
13
  *
14
+ * `kmsKeyId` is accepted and ignored: nothing is encrypted at rest here.
15
+ *
13
16
  * ponytail: no size limit, so a process storing unbounded objects grows
14
17
  * unbounded. The upgrade path is the S3 adapter.
15
18
  */
@@ -18,14 +21,42 @@ const memoryStorageBackend = () => {
18
21
  return {
19
22
  put: (key, bytes, options) => {
20
23
  objects.set(key, {
21
- bytes,
24
+ bytes: bytes.slice(),
25
+ contentType: options.contentType
26
+ });
27
+ return OkAsync();
28
+ },
29
+ putIfAbsent: (key, bytes, options) => {
30
+ if (objects.has(key)) return ErrAsync(new ObjectExists({ key }));
31
+ objects.set(key, {
32
+ bytes: bytes.slice(),
33
+ contentType: options.contentType
34
+ });
35
+ return OkAsync();
36
+ },
37
+ copy: (from, to, options) => {
38
+ const object = objects.get(from);
39
+ if (object === void 0) return ErrAsync(new ObjectNotFound({ key: from }));
40
+ objects.set(to, {
41
+ bytes: object.bytes.slice(),
22
42
  contentType: options.contentType
23
43
  });
24
44
  return OkAsync();
25
45
  },
46
+ head: (key) => {
47
+ const object = objects.get(key);
48
+ return object === void 0 ? ErrAsync(new ObjectNotFound({ key })) : OkAsync({
49
+ size: object.bytes.byteLength,
50
+ contentType: object.contentType,
51
+ sha256: createHash("sha256").update(object.bytes).digest("hex")
52
+ });
53
+ },
26
54
  get: (key) => {
27
55
  const object = objects.get(key);
28
- return object === void 0 ? ErrAsync(new ObjectNotFound({ key })) : OkAsync(object);
56
+ return object === void 0 ? ErrAsync(new ObjectNotFound({ key })) : OkAsync({
57
+ ...object,
58
+ bytes: object.bytes.slice()
59
+ });
29
60
  },
30
61
  delete: (key) => {
31
62
  objects.delete(key);
@@ -58,17 +89,23 @@ const memoryStorage = () => Module("MemoryStorage")({
58
89
  */
59
90
  const failed = (failure) => {
60
91
  const cause = failure.tag === "Err" ? failure.error : failure.cause;
61
- const ordinary = failure.tag !== "Err" ? void 0 : failure.error._tag === "ObjectNotFound" ? "the object was not there" : failure.error._tag === "PresignNotSupported" ? "this store cannot mint a url" : void 0;
92
+ const ordinary = failure.tag !== "Err" ? void 0 : failure.error._tag === "ObjectExists" ? {
93
+ result: "exists",
94
+ reason: "an object already held the key"
95
+ } : failure.error._tag === "ObjectNotFound" ? {
96
+ result: "not_found",
97
+ reason: "the object was not there"
98
+ } : failure.error._tag === "PresignNotSupported" ? {
99
+ result: "not_found",
100
+ reason: "this store cannot mint a url"
101
+ } : void 0;
62
102
  return ordinary === void 0 ? {
63
103
  outcome: "error",
64
104
  attributes: { result: "error" },
65
105
  cause
66
106
  } : {
67
107
  outcome: "ok",
68
- attributes: {
69
- result: "not_found",
70
- reason: ordinary
71
- }
108
+ attributes: ordinary
72
109
  };
73
110
  };
74
111
  /** The KEY is a detail, not a dimension: on the span and the line, never on an instrument. */
@@ -89,7 +126,10 @@ const instrument = (backend, observers) => {
89
126
  const observe = through(observers);
90
127
  return {
91
128
  put: (key, bytes, options) => observe("put", key, () => backend.put(key, bytes, options)),
129
+ putIfAbsent: (key, bytes, options) => observe("put_if_absent", key, () => backend.putIfAbsent(key, bytes, options)),
92
130
  get: (key) => observe("get", key, () => backend.get(key)),
131
+ copy: (from, to, options) => observe("copy", from, () => backend.copy(from, to, options)),
132
+ head: (key) => observe("head", key, () => backend.head(key)),
93
133
  delete: (key) => observe("delete", key, () => backend.delete(key)),
94
134
  presignedUrl: (key, options) => observe("presigned_url", key, () => backend.presignedUrl(key, options)),
95
135
  presignedUpload: (key, options) => observe("presigned_upload", key, () => backend.presignedUpload(key, options))
@@ -138,4 +178,4 @@ const storage = ({ adapter }) => {
138
178
  });
139
179
  };
140
180
  //#endregion
141
- export { ObjectNotFound, PresignNotSupported, Storage, StorageBackend, StorageUnavailable, memoryStorage, memoryStorageBackend, memoryStorageProvider, storage };
181
+ export { ObjectExists, ObjectNotFound, PresignNotSupported, Storage, StorageBackend, StorageUnavailable, memoryStorage, memoryStorageBackend, memoryStorageProvider, storage };
package/dist/s3.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_storage = require("./storage-QVPXOcO6.cjs");
2
+ const require_storage = require("./storage-EA671z1R.cjs");
3
3
  let _btravstack_di = require("@btravstack/di");
4
4
  let unthrown = require("unthrown");
5
5
  let _aws_sdk_client_s3 = require("@aws-sdk/client-s3");
@@ -35,8 +35,18 @@ const unavailable = (operation, key, cause) => new require_storage.StorageUnavai
35
35
  key,
36
36
  reason: cause instanceof Error ? cause.message : String(cause)
37
37
  });
38
+ /** S3's answer to `If-None-Match: *` on a key that is held: HTTP 412. */
39
+ const isPreconditionFailed = (cause) => cause?.$metadata?.httpStatusCode === 412;
38
40
  /** A missing key is S3's `NoSuchKey`, which is a different fact from "the store is down". */
39
41
  const isMissing = (cause) => cause instanceof Error && (cause.name === "NoSuchKey" || cause.name === "NotFound");
42
+ /** SSE-KMS parameters when a key is named; none otherwise, so the bucket's default applies. */
43
+ const encryption = (options) => options.kmsKeyId === void 0 ? {} : {
44
+ ServerSideEncryption: "aws:kms",
45
+ SSEKMSKeyId: options.kmsKeyId
46
+ };
47
+ /** S3 reports and takes checksums in base64; the port speaks hex. */
48
+ const hexToBase64 = (hex) => Buffer.from(hex, "hex").toString("base64");
49
+ const base64ToHex = (base64) => Buffer.from(base64, "base64").toString("hex");
40
50
  /**
41
51
  * The adapter's service over a client and a bucket. A delete of a key nobody
42
52
  * stored answers `Ok`, which is S3's own idempotent behaviour rather than a
@@ -47,8 +57,17 @@ const s3StorageBackend = (client, bucket) => ({
47
57
  Bucket: bucket,
48
58
  Key: key,
49
59
  Body: bytes,
50
- ContentType: options.contentType
60
+ ContentType: options.contentType,
61
+ ...encryption(options)
51
62
  })), (cause) => unavailable("put", key, cause)).map(() => void 0),
63
+ putIfAbsent: (key, bytes, options) => (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_s3.PutObjectCommand({
64
+ Bucket: bucket,
65
+ Key: key,
66
+ Body: bytes,
67
+ ContentType: options.contentType,
68
+ IfNoneMatch: "*",
69
+ ...encryption(options)
70
+ })), (cause) => isPreconditionFailed(cause) ? new require_storage.ObjectExists({ key }) : unavailable("putIfAbsent", key, cause)).map(() => void 0),
52
71
  get: (key) => (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_s3.GetObjectCommand({
53
72
  Bucket: bucket,
54
73
  Key: key
@@ -56,6 +75,25 @@ const s3StorageBackend = (client, bucket) => ({
56
75
  bytes: await response.Body.transformToByteArray(),
57
76
  contentType: response.ContentType ?? "application/octet-stream"
58
77
  })), (cause) => isMissing(cause) ? new require_storage.ObjectNotFound({ key }) : unavailable("get", key, cause)),
78
+ copy: (from, to, options) => (0, unthrown.fromPromise)((async () => client.send(new _aws_sdk_client_s3.CopyObjectCommand({
79
+ Bucket: bucket,
80
+ Key: to,
81
+ CopySource: `${bucket}/${from.split("/").map(encodeURIComponent).join("/")}`,
82
+ MetadataDirective: "REPLACE",
83
+ TaggingDirective: "REPLACE",
84
+ AnnotationDirective: "EXCLUDE",
85
+ ContentType: options.contentType,
86
+ ...encryption(options)
87
+ })))(), (cause) => isMissing(cause) ? new require_storage.ObjectNotFound({ key: from }) : unavailable("copy", from, cause)).map(() => void 0),
88
+ head: (key) => (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_s3.HeadObjectCommand({
89
+ Bucket: bucket,
90
+ Key: key,
91
+ ChecksumMode: "ENABLED"
92
+ })).then((response) => ({
93
+ size: response.ContentLength ?? 0,
94
+ contentType: response.ContentType ?? "application/octet-stream",
95
+ ...response.ChecksumSHA256 === void 0 || response.ChecksumType === "COMPOSITE" ? {} : { sha256: base64ToHex(response.ChecksumSHA256) }
96
+ })), (cause) => isMissing(cause) ? new require_storage.ObjectNotFound({ key }) : unavailable("head", key, cause)),
59
97
  delete: (key) => (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_s3.DeleteObjectCommand({
60
98
  Bucket: bucket,
61
99
  Key: key
@@ -68,10 +106,12 @@ const s3StorageBackend = (client, bucket) => ({
68
106
  Bucket: bucket,
69
107
  Key: key,
70
108
  ContentType: options.contentType,
71
- ContentLength: options.contentLength
109
+ ContentLength: options.contentLength,
110
+ ...options.sha256 === void 0 ? {} : { ChecksumSHA256: hexToBase64(options.sha256) }
72
111
  }), {
73
112
  expiresIn: Math.ceil(options.ttlMs / 1e3),
74
- signableHeaders: /* @__PURE__ */ new Set(["content-type"])
113
+ signableHeaders: /* @__PURE__ */ new Set(["content-type"]),
114
+ ...options.sha256 === void 0 ? {} : { unhoistableHeaders: /* @__PURE__ */ new Set(["x-amz-checksum-sha256"]) }
75
115
  }), (cause) => unavailable("presignedUpload", key, cause))
76
116
  });
77
117
  /**
package/dist/s3.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as StorageService, i as StorageBackend } from "./storage-BLJtJ7KF.cjs";
1
+ import { o as StorageBackend, s as StorageService } from "./storage-BavPXZpA.cjs";
2
2
  import { Module, Scope } from "@btravstack/di";
3
3
  import { S3Client } from "@aws-sdk/client-s3";
4
4
  import { ConfigInvalid, EnvReading } from "@btravstack/config";
package/dist/s3.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as StorageService, i as StorageBackend } from "./storage-BLJtJ7KF.mjs";
1
+ import { o as StorageBackend, s as StorageService } from "./storage-BavPXZpA.mjs";
2
2
  import { Module, Scope } from "@btravstack/di";
3
3
  import { S3Client } from "@aws-sdk/client-s3";
4
4
  import { ConfigInvalid, EnvReading } from "@btravstack/config";
package/dist/s3.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { a as StorageUnavailable, i as StorageBackend, t as ObjectNotFound } from "./storage-CRkwIhac.mjs";
1
+ import { a as StorageBackend, n as ObjectNotFound, o as StorageUnavailable, t as ObjectExists } from "./storage-BIKstQ72.mjs";
2
2
  import { Module, Port, Provider } from "@btravstack/di";
3
3
  import { Ok, fromPromise } from "unthrown";
4
- import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
4
+ import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
5
5
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
6
6
  import { Config, Env } from "@btravstack/config";
7
7
  //#region src/s3.ts
@@ -34,8 +34,18 @@ const unavailable = (operation, key, cause) => new StorageUnavailable({
34
34
  key,
35
35
  reason: cause instanceof Error ? cause.message : String(cause)
36
36
  });
37
+ /** S3's answer to `If-None-Match: *` on a key that is held: HTTP 412. */
38
+ const isPreconditionFailed = (cause) => cause?.$metadata?.httpStatusCode === 412;
37
39
  /** A missing key is S3's `NoSuchKey`, which is a different fact from "the store is down". */
38
40
  const isMissing = (cause) => cause instanceof Error && (cause.name === "NoSuchKey" || cause.name === "NotFound");
41
+ /** SSE-KMS parameters when a key is named; none otherwise, so the bucket's default applies. */
42
+ const encryption = (options) => options.kmsKeyId === void 0 ? {} : {
43
+ ServerSideEncryption: "aws:kms",
44
+ SSEKMSKeyId: options.kmsKeyId
45
+ };
46
+ /** S3 reports and takes checksums in base64; the port speaks hex. */
47
+ const hexToBase64 = (hex) => Buffer.from(hex, "hex").toString("base64");
48
+ const base64ToHex = (base64) => Buffer.from(base64, "base64").toString("hex");
39
49
  /**
40
50
  * The adapter's service over a client and a bucket. A delete of a key nobody
41
51
  * stored answers `Ok`, which is S3's own idempotent behaviour rather than a
@@ -46,8 +56,17 @@ const s3StorageBackend = (client, bucket) => ({
46
56
  Bucket: bucket,
47
57
  Key: key,
48
58
  Body: bytes,
49
- ContentType: options.contentType
59
+ ContentType: options.contentType,
60
+ ...encryption(options)
50
61
  })), (cause) => unavailable("put", key, cause)).map(() => void 0),
62
+ putIfAbsent: (key, bytes, options) => fromPromise(client.send(new PutObjectCommand({
63
+ Bucket: bucket,
64
+ Key: key,
65
+ Body: bytes,
66
+ ContentType: options.contentType,
67
+ IfNoneMatch: "*",
68
+ ...encryption(options)
69
+ })), (cause) => isPreconditionFailed(cause) ? new ObjectExists({ key }) : unavailable("putIfAbsent", key, cause)).map(() => void 0),
51
70
  get: (key) => fromPromise(client.send(new GetObjectCommand({
52
71
  Bucket: bucket,
53
72
  Key: key
@@ -55,6 +74,25 @@ const s3StorageBackend = (client, bucket) => ({
55
74
  bytes: await response.Body.transformToByteArray(),
56
75
  contentType: response.ContentType ?? "application/octet-stream"
57
76
  })), (cause) => isMissing(cause) ? new ObjectNotFound({ key }) : unavailable("get", key, cause)),
77
+ copy: (from, to, options) => fromPromise((async () => client.send(new CopyObjectCommand({
78
+ Bucket: bucket,
79
+ Key: to,
80
+ CopySource: `${bucket}/${from.split("/").map(encodeURIComponent).join("/")}`,
81
+ MetadataDirective: "REPLACE",
82
+ TaggingDirective: "REPLACE",
83
+ AnnotationDirective: "EXCLUDE",
84
+ ContentType: options.contentType,
85
+ ...encryption(options)
86
+ })))(), (cause) => isMissing(cause) ? new ObjectNotFound({ key: from }) : unavailable("copy", from, cause)).map(() => void 0),
87
+ head: (key) => fromPromise(client.send(new HeadObjectCommand({
88
+ Bucket: bucket,
89
+ Key: key,
90
+ ChecksumMode: "ENABLED"
91
+ })).then((response) => ({
92
+ size: response.ContentLength ?? 0,
93
+ contentType: response.ContentType ?? "application/octet-stream",
94
+ ...response.ChecksumSHA256 === void 0 || response.ChecksumType === "COMPOSITE" ? {} : { sha256: base64ToHex(response.ChecksumSHA256) }
95
+ })), (cause) => isMissing(cause) ? new ObjectNotFound({ key }) : unavailable("head", key, cause)),
58
96
  delete: (key) => fromPromise(client.send(new DeleteObjectCommand({
59
97
  Bucket: bucket,
60
98
  Key: key
@@ -67,10 +105,12 @@ const s3StorageBackend = (client, bucket) => ({
67
105
  Bucket: bucket,
68
106
  Key: key,
69
107
  ContentType: options.contentType,
70
- ContentLength: options.contentLength
108
+ ContentLength: options.contentLength,
109
+ ...options.sha256 === void 0 ? {} : { ChecksumSHA256: hexToBase64(options.sha256) }
71
110
  }), {
72
111
  expiresIn: Math.ceil(options.ttlMs / 1e3),
73
- signableHeaders: /* @__PURE__ */ new Set(["content-type"])
112
+ signableHeaders: /* @__PURE__ */ new Set(["content-type"]),
113
+ ...options.sha256 === void 0 ? {} : { unhoistableHeaders: /* @__PURE__ */ new Set(["x-amz-checksum-sha256"]) }
74
114
  }), (cause) => unavailable("presignedUpload", key, cause))
75
115
  });
76
116
  /**
@@ -1,8 +1,10 @@
1
1
  import { Port } from "@btravstack/di";
2
2
  import { TaggedError } from "unthrown";
3
3
  //#region src/storage.ts
4
- /** The key is not there. Only `get` answers it — see `delete` and `presignedUrl` below. */
4
+ /** The key is not there. Only `get`, `head` and `copy` (its source) answer it — see `delete` and `presignedUrl` below. */
5
5
  var ObjectNotFound = class extends TaggedError("ObjectNotFound") {};
6
+ /** An object already holds the key. Only `putIfAbsent` answers it. */
7
+ var ObjectExists = class extends TaggedError("ObjectExists") {};
6
8
  /** The store could not answer — the endpoint is down, the credentials were refused, the bucket is gone. */
7
9
  var StorageUnavailable = class extends TaggedError("StorageUnavailable") {};
8
10
  /** The adapter cannot mint a time-limited URL, and says so rather than pretending. */
@@ -22,4 +24,4 @@ var Storage = class extends Port("Storage") {};
22
24
  /** The port every adapter provides, and the one an application never depends on. */
23
25
  var StorageBackend = class extends Port("StorageBackend") {};
24
26
  //#endregion
25
- export { StorageUnavailable as a, StorageBackend as i, PresignNotSupported as n, Storage as r, ObjectNotFound as t };
27
+ export { StorageBackend as a, Storage as i, ObjectNotFound as n, StorageUnavailable as o, PresignNotSupported as r, ObjectExists as t };
@@ -6,14 +6,19 @@ type StoredObject = {
6
6
  readonly contentType: string;
7
7
  };
8
8
  declare const ObjectNotFound_base: import("unthrown").TaggedErrorConstructor<"ObjectNotFound">;
9
- /** The key is not there. Only `get` answers it — see `delete` and `presignedUrl` below. */
9
+ /** The key is not there. Only `get`, `head` and `copy` (its source) answer it — see `delete` and `presignedUrl` below. */
10
10
  declare class ObjectNotFound extends ObjectNotFound_base<{
11
11
  readonly key: string;
12
12
  }> {}
13
+ declare const ObjectExists_base: import("unthrown").TaggedErrorConstructor<"ObjectExists">;
14
+ /** An object already holds the key. Only `putIfAbsent` answers it. */
15
+ declare class ObjectExists extends ObjectExists_base<{
16
+ readonly key: string;
17
+ }> {}
13
18
  declare const StorageUnavailable_base: import("unthrown").TaggedErrorConstructor<"StorageUnavailable">;
14
19
  /** The store could not answer — the endpoint is down, the credentials were refused, the bucket is gone. */
15
20
  declare class StorageUnavailable extends StorageUnavailable_base<{
16
- readonly operation: "put" | "get" | "delete" | "presignedUrl" | "presignedUpload";
21
+ readonly operation: "put" | "putIfAbsent" | "get" | "copy" | "head" | "delete" | "presignedUrl" | "presignedUpload";
17
22
  readonly key: string;
18
23
  readonly reason: string;
19
24
  }> {}
@@ -22,11 +27,39 @@ declare const PresignNotSupported_base: import("unthrown").TaggedErrorConstructo
22
27
  declare class PresignNotSupported extends PresignNotSupported_base<{
23
28
  readonly key: string;
24
29
  }> {}
30
+ /** What the store knows about an object without sending its bytes. */
31
+ type ObjectHead = {
32
+ readonly size: number;
33
+ readonly contentType: string;
34
+ /** Lowercase hex of the SHA-256 the store holds for the object; absent when none was supplied at write time. */
35
+ readonly sha256?: string;
36
+ };
37
+ /**
38
+ * Written with `kmsKeyId`, an object is encrypted at rest with that KMS key
39
+ * (SSE-KMS); without it, the store's own default applies.
40
+ */
41
+ type WriteOptions = {
42
+ readonly contentType: string;
43
+ readonly kmsKeyId?: string;
44
+ };
25
45
  type StorageService = {
26
- readonly put: (key: string, bytes: Uint8Array, options: {
27
- readonly contentType: string;
28
- }) => AsyncResult<void, StorageUnavailable>;
46
+ readonly put: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, StorageUnavailable>;
47
+ /** Writes the object only if no object holds the key: the first writer wins, atomically, in the store. */
48
+ readonly putIfAbsent: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, ObjectExists | StorageUnavailable>;
29
49
  readonly get: (key: string) => AsyncResult<StoredObject, ObjectNotFound | StorageUnavailable>;
50
+ /**
51
+ * Copies `from` to `to` inside the store: no bytes pass through this process.
52
+ * The copy takes `contentType` (the source's is replaced) and, with
53
+ * `kmsKeyId`, is encrypted with that key. An existing `to` is overwritten.
54
+ */
55
+ readonly copy: (from: string, to: string, options: WriteOptions) => AsyncResult<void, ObjectNotFound | StorageUnavailable>;
56
+ /**
57
+ * Size, type and checksum of an object, without reading it. `sha256` is
58
+ * present only if the store holds one: S3 keeps it for an object written with
59
+ * a SHA-256 checksum (such as through `presignedUpload` with `sha256`), and
60
+ * reports it base64-encoded, which the adapter converts to hex.
61
+ */
62
+ readonly head: (key: string) => AsyncResult<ObjectHead, ObjectNotFound | StorageUnavailable>;
30
63
  /** Deleting a key nobody stored is `Ok`: delete is idempotent, which is S3's own behaviour rather than a fiction over it. */
31
64
  readonly delete: (key: string) => AsyncResult<void, StorageUnavailable>;
32
65
  /**
@@ -53,6 +86,11 @@ type StorageService = {
53
86
  * length would allow an unbounded single write, and there is no ceiling a
54
87
  * presigned PUT can express other than the exact number.
55
88
  *
89
+ * **`sha256` (lowercase hex, 64 characters) is optional.** When given, the
90
+ * checksum header is signed too, so the store refuses any body that does not
91
+ * hash to it, and keeps it for `head`. The client must send the same value as
92
+ * `x-amz-checksum-sha256` (base64 of the digest).
93
+ *
56
94
  * The application still decides every input: whether this caller may write
57
95
  * this key, how long the URL lives, and what size it is willing to accept.
58
96
  * The adapter computes a signature over that decision and nothing more.
@@ -61,6 +99,7 @@ type StorageService = {
61
99
  readonly ttlMs: number;
62
100
  readonly contentType: string;
63
101
  readonly contentLength: number;
102
+ readonly sha256?: string;
64
103
  }) => AsyncResult<string, PresignNotSupported | StorageUnavailable>;
65
104
  };
66
105
  declare const Storage_base: import("@btravstack/di").PortClass<"Storage">;
@@ -80,4 +119,4 @@ declare const StorageBackend_base: import("@btravstack/di").PortClass<"StorageBa
80
119
  /** The port every adapter provides, and the one an application never depends on. */
81
120
  declare class StorageBackend extends StorageBackend_base<StorageService> {}
82
121
  //#endregion
83
- export { StorageService as a, StorageBackend as i, PresignNotSupported as n, StorageUnavailable as o, Storage as r, StoredObject as s, ObjectNotFound as t };
122
+ export { Storage as a, StorageUnavailable as c, PresignNotSupported as i, StoredObject as l, ObjectHead as n, StorageBackend as o, ObjectNotFound as r, StorageService as s, ObjectExists as t, WriteOptions as u };
@@ -6,14 +6,19 @@ type StoredObject = {
6
6
  readonly contentType: string;
7
7
  };
8
8
  declare const ObjectNotFound_base: import("unthrown").TaggedErrorConstructor<"ObjectNotFound">;
9
- /** The key is not there. Only `get` answers it — see `delete` and `presignedUrl` below. */
9
+ /** The key is not there. Only `get`, `head` and `copy` (its source) answer it — see `delete` and `presignedUrl` below. */
10
10
  declare class ObjectNotFound extends ObjectNotFound_base<{
11
11
  readonly key: string;
12
12
  }> {}
13
+ declare const ObjectExists_base: import("unthrown").TaggedErrorConstructor<"ObjectExists">;
14
+ /** An object already holds the key. Only `putIfAbsent` answers it. */
15
+ declare class ObjectExists extends ObjectExists_base<{
16
+ readonly key: string;
17
+ }> {}
13
18
  declare const StorageUnavailable_base: import("unthrown").TaggedErrorConstructor<"StorageUnavailable">;
14
19
  /** The store could not answer — the endpoint is down, the credentials were refused, the bucket is gone. */
15
20
  declare class StorageUnavailable extends StorageUnavailable_base<{
16
- readonly operation: "put" | "get" | "delete" | "presignedUrl" | "presignedUpload";
21
+ readonly operation: "put" | "putIfAbsent" | "get" | "copy" | "head" | "delete" | "presignedUrl" | "presignedUpload";
17
22
  readonly key: string;
18
23
  readonly reason: string;
19
24
  }> {}
@@ -22,11 +27,39 @@ declare const PresignNotSupported_base: import("unthrown").TaggedErrorConstructo
22
27
  declare class PresignNotSupported extends PresignNotSupported_base<{
23
28
  readonly key: string;
24
29
  }> {}
30
+ /** What the store knows about an object without sending its bytes. */
31
+ type ObjectHead = {
32
+ readonly size: number;
33
+ readonly contentType: string;
34
+ /** Lowercase hex of the SHA-256 the store holds for the object; absent when none was supplied at write time. */
35
+ readonly sha256?: string;
36
+ };
37
+ /**
38
+ * Written with `kmsKeyId`, an object is encrypted at rest with that KMS key
39
+ * (SSE-KMS); without it, the store's own default applies.
40
+ */
41
+ type WriteOptions = {
42
+ readonly contentType: string;
43
+ readonly kmsKeyId?: string;
44
+ };
25
45
  type StorageService = {
26
- readonly put: (key: string, bytes: Uint8Array, options: {
27
- readonly contentType: string;
28
- }) => AsyncResult<void, StorageUnavailable>;
46
+ readonly put: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, StorageUnavailable>;
47
+ /** Writes the object only if no object holds the key: the first writer wins, atomically, in the store. */
48
+ readonly putIfAbsent: (key: string, bytes: Uint8Array, options: WriteOptions) => AsyncResult<void, ObjectExists | StorageUnavailable>;
29
49
  readonly get: (key: string) => AsyncResult<StoredObject, ObjectNotFound | StorageUnavailable>;
50
+ /**
51
+ * Copies `from` to `to` inside the store: no bytes pass through this process.
52
+ * The copy takes `contentType` (the source's is replaced) and, with
53
+ * `kmsKeyId`, is encrypted with that key. An existing `to` is overwritten.
54
+ */
55
+ readonly copy: (from: string, to: string, options: WriteOptions) => AsyncResult<void, ObjectNotFound | StorageUnavailable>;
56
+ /**
57
+ * Size, type and checksum of an object, without reading it. `sha256` is
58
+ * present only if the store holds one: S3 keeps it for an object written with
59
+ * a SHA-256 checksum (such as through `presignedUpload` with `sha256`), and
60
+ * reports it base64-encoded, which the adapter converts to hex.
61
+ */
62
+ readonly head: (key: string) => AsyncResult<ObjectHead, ObjectNotFound | StorageUnavailable>;
30
63
  /** Deleting a key nobody stored is `Ok`: delete is idempotent, which is S3's own behaviour rather than a fiction over it. */
31
64
  readonly delete: (key: string) => AsyncResult<void, StorageUnavailable>;
32
65
  /**
@@ -53,6 +86,11 @@ type StorageService = {
53
86
  * length would allow an unbounded single write, and there is no ceiling a
54
87
  * presigned PUT can express other than the exact number.
55
88
  *
89
+ * **`sha256` (lowercase hex, 64 characters) is optional.** When given, the
90
+ * checksum header is signed too, so the store refuses any body that does not
91
+ * hash to it, and keeps it for `head`. The client must send the same value as
92
+ * `x-amz-checksum-sha256` (base64 of the digest).
93
+ *
56
94
  * The application still decides every input: whether this caller may write
57
95
  * this key, how long the URL lives, and what size it is willing to accept.
58
96
  * The adapter computes a signature over that decision and nothing more.
@@ -61,6 +99,7 @@ type StorageService = {
61
99
  readonly ttlMs: number;
62
100
  readonly contentType: string;
63
101
  readonly contentLength: number;
102
+ readonly sha256?: string;
64
103
  }) => AsyncResult<string, PresignNotSupported | StorageUnavailable>;
65
104
  };
66
105
  declare const Storage_base: import("@btravstack/di").PortClass<"Storage">;
@@ -80,4 +119,4 @@ declare const StorageBackend_base: import("@btravstack/di").PortClass<"StorageBa
80
119
  /** The port every adapter provides, and the one an application never depends on. */
81
120
  declare class StorageBackend extends StorageBackend_base<StorageService> {}
82
121
  //#endregion
83
- export { StorageService as a, StorageBackend as i, PresignNotSupported as n, StorageUnavailable as o, Storage as r, StoredObject as s, ObjectNotFound as t };
122
+ export { Storage as a, StorageUnavailable as c, PresignNotSupported as i, StoredObject as l, ObjectHead as n, StorageBackend as o, ObjectNotFound as r, StorageService as s, ObjectExists as t, WriteOptions as u };
@@ -1,8 +1,10 @@
1
1
  let _btravstack_di = require("@btravstack/di");
2
2
  let unthrown = require("unthrown");
3
3
  //#region src/storage.ts
4
- /** The key is not there. Only `get` answers it — see `delete` and `presignedUrl` below. */
4
+ /** The key is not there. Only `get`, `head` and `copy` (its source) answer it — see `delete` and `presignedUrl` below. */
5
5
  var ObjectNotFound = class extends (0, unthrown.TaggedError)("ObjectNotFound") {};
6
+ /** An object already holds the key. Only `putIfAbsent` answers it. */
7
+ var ObjectExists = class extends (0, unthrown.TaggedError)("ObjectExists") {};
6
8
  /** The store could not answer — the endpoint is down, the credentials were refused, the bucket is gone. */
7
9
  var StorageUnavailable = class extends (0, unthrown.TaggedError)("StorageUnavailable") {};
8
10
  /** The adapter cannot mint a time-limited URL, and says so rather than pretending. */
@@ -22,6 +24,12 @@ var Storage = class extends (0, _btravstack_di.Port)("Storage") {};
22
24
  /** The port every adapter provides, and the one an application never depends on. */
23
25
  var StorageBackend = class extends (0, _btravstack_di.Port)("StorageBackend") {};
24
26
  //#endregion
27
+ Object.defineProperty(exports, "ObjectExists", {
28
+ enumerable: true,
29
+ get: function() {
30
+ return ObjectExists;
31
+ }
32
+ });
25
33
  Object.defineProperty(exports, "ObjectNotFound", {
26
34
  enumerable: true,
27
35
  get: function() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/storage",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "description": "A Storage port for @btravstack/core: an in-memory adapter, an S3-compatible adapter with presigned reads, and a composition that spans, counts and logs every operation",
5
5
  "keywords": [
6
6
  "dependency-injection",
@@ -55,11 +55,11 @@
55
55
  "devDependencies": {
56
56
  "@aws-sdk/client-s3": "3.1149.0",
57
57
  "@aws-sdk/s3-request-presigner": "3.1149.0",
58
- "@btravstack/config": "0.36.0",
59
- "@btravstack/core": "0.36.0",
60
- "@btravstack/di": "0.36.0",
58
+ "@btravstack/config": "0.38.0",
59
+ "@btravstack/core": "0.38.0",
60
+ "@btravstack/di": "0.38.0",
61
61
  "@btravstack/internal-test-infra": "0.0.0",
62
- "@btravstack/observability": "0.36.0",
62
+ "@btravstack/observability": "0.38.0",
63
63
  "@btravstack/tsconfig": "0.4.0",
64
64
  "@opentelemetry/api": "1.9.1",
65
65
  "@opentelemetry/sdk-metrics": "2.12.0",
@@ -76,9 +76,9 @@
76
76
  "peerDependencies": {
77
77
  "@aws-sdk/client-s3": "^3.1000.0",
78
78
  "@aws-sdk/s3-request-presigner": "^3.1000.0",
79
- "@btravstack/config": "^0.36.0",
80
- "@btravstack/core": "^0.36.0",
81
- "@btravstack/di": "^0.36.0",
79
+ "@btravstack/config": "^0.38.0",
80
+ "@btravstack/core": "^0.38.0",
81
+ "@btravstack/di": "^0.38.0",
82
82
  "unthrown": "^5.0.0"
83
83
  },
84
84
  "peerDependenciesMeta": {