@stowage/core 0.0.0 → 0.1.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/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +191 -0
- package/package.json +38 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Kaufmann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @stowage/core
|
|
2
|
+
|
|
3
|
+
The types every stowage adapter implements, `StorageError`, and the utilities an adapter calls. It
|
|
4
|
+
does nothing without an adapter.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @stowage/core
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Example
|
|
13
|
+
|
|
14
|
+
A function written against `Storage` runs against every adapter: `memoryStorage()` in a test,
|
|
15
|
+
`fsStorage()` on a laptop, `s3Storage()` in production.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { isStorageError, type Storage } from "@stowage/core";
|
|
19
|
+
|
|
20
|
+
export async function readSettings(storage: Storage): Promise<unknown> {
|
|
21
|
+
try {
|
|
22
|
+
const object = await storage.get("settings.json");
|
|
23
|
+
|
|
24
|
+
return await object.json();
|
|
25
|
+
} catch (failure) {
|
|
26
|
+
if (isStorageError(failure) && failure.code === "NotFound") return {};
|
|
27
|
+
|
|
28
|
+
throw failure;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Runtimes
|
|
34
|
+
|
|
35
|
+
Node 24 and later, Bun, Deno and `workerd` at the compatibility date `2026-09-01`. CI last ran green on Bun 1.4.2 and Deno 2.9.6.
|
|
36
|
+
|
|
37
|
+
The bundle measures 1.3 kB minified and gzipped.
|
|
38
|
+
|
|
39
|
+
## Limits
|
|
40
|
+
|
|
41
|
+
This section is empty. `@stowage/core` declares no capability; each storage declares its own, out
|
|
42
|
+
of the four names of [spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/core@0.1.0/docs/spec.md#49-capabilities).
|
|
43
|
+
|
|
44
|
+
## Notes
|
|
45
|
+
|
|
46
|
+
A failure reaches the caller in one of two shapes: a `StorageError`, which `isStorageError` tells
|
|
47
|
+
apart, or the runtime's `AbortError` once a signal fired
|
|
48
|
+
([spec 4.10](https://github.com/stowage-js/stowage/blob/@stowage/core@0.1.0/docs/spec.md#410-errors)).
|
|
49
|
+
A name added to `StorageErrorCode` or `capabilityNames` is a minor release, so a `switch` over
|
|
50
|
+
either needs a default branch
|
|
51
|
+
([spec 9](https://github.com/stowage-js/stowage/blob/@stowage/core@0.1.0/docs/spec.md#9-versions)).
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { isStorageError } from "@stowage/core";
|
|
55
|
+
|
|
56
|
+
export function describeFailure(failure: unknown): string {
|
|
57
|
+
if (failure instanceof Error && failure.name === "AbortError") return "canceled";
|
|
58
|
+
if (!isStorageError(failure)) throw failure;
|
|
59
|
+
|
|
60
|
+
switch (failure.code) {
|
|
61
|
+
case "NotFound":
|
|
62
|
+
return "missing";
|
|
63
|
+
case "AccessDenied":
|
|
64
|
+
case "InvalidCredentials":
|
|
65
|
+
case "Expired":
|
|
66
|
+
return "refused";
|
|
67
|
+
default:
|
|
68
|
+
return failure.retryable ? "try again" : "failed";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Specification
|
|
74
|
+
|
|
75
|
+
[`docs/spec.md` at `@stowage/core@0.1.0`](https://github.com/stowage-js/stowage/blob/@stowage/core@0.1.0/docs/spec.md#4-the-core-api-stowagecore)
|
|
76
|
+
is the contract: a caller may rely on what it states and on nothing else this package happens to
|
|
77
|
+
export. The [terms it uses](https://github.com/stowage-js/stowage/blob/@stowage/core@0.1.0/CONTEXT.md)
|
|
78
|
+
and the [decisions behind it](https://github.com/stowage-js/stowage/tree/@stowage/core@0.1.0/docs/adr)
|
|
79
|
+
are at the same tag.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
//#region src/capabilities.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Every capability a storage can declare (spec 4.9). The list grows in minor releases, so a
|
|
4
|
+
* `switch` over it needs a default branch.
|
|
5
|
+
*
|
|
6
|
+
* - `keyBytesPreserved`: a key comes back byte for byte as it was written. Where not
|
|
7
|
+
* declared, it comes back Unicode-equivalent.
|
|
8
|
+
* - `presignedUrls`: the concrete type carries `presignGet` and `presignPut`. Where not
|
|
9
|
+
* declared, neither method exists on the type.
|
|
10
|
+
* - `rangeReads`: `get` honors `range`. Where not declared, a `range` is `Unsupported`.
|
|
11
|
+
* - `userMetadata`: `put` stores `userMetadata`, `stat` and `get` return it, `copy` keeps it.
|
|
12
|
+
* Where not declared, a non-empty `userMetadata` is `Unsupported` and reads return `{}`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const capabilityNames: readonly ["keyBytesPreserved", "presignedUrls", "rangeReads", "userMetadata"];
|
|
15
|
+
/** One name out of {@link capabilityNames}. */
|
|
16
|
+
type CapabilityName = (typeof capabilityNames)[number];
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/credentials.d.ts
|
|
19
|
+
/** What a resolver is told when the adapter asks for a credential again (spec 4.12). */
|
|
20
|
+
type ResolverOptions = {
|
|
21
|
+
forceRefresh: boolean;
|
|
22
|
+
};
|
|
23
|
+
/** A value, or a function that yields one. No core signature mentions it (ADR 0007). */
|
|
24
|
+
type Resolvable<T> = T | ((options?: ResolverOptions) => T | Promise<T>);
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/errors.d.ts
|
|
27
|
+
/**
|
|
28
|
+
* What went wrong, as the one field a caller branches on (spec 4.10). A code added here is a
|
|
29
|
+
* minor release, so a `switch` over it needs a default branch.
|
|
30
|
+
*
|
|
31
|
+
* - `NotFound`: no object under the key, or no bucket; `stat` cannot tell the two apart.
|
|
32
|
+
* - `AccessDenied`: the credential is valid and may not do this.
|
|
33
|
+
* - `InvalidCredentials`: the provider does not accept the credential, or a required
|
|
34
|
+
* credential field is empty or unknown.
|
|
35
|
+
* - `Expired`: the credential or session token has expired.
|
|
36
|
+
* - `InvalidRequest`: the provider or stowage refused the request for what it asked, such as
|
|
37
|
+
* metadata over the limit, an unsatisfiable range, a copy onto itself or a second read of a
|
|
38
|
+
* body.
|
|
39
|
+
* - `NetworkError`: the request received no response.
|
|
40
|
+
* - `ProviderError`: the provider answered with a failure stowage has no other name for;
|
|
41
|
+
* `providerCode` carries its string.
|
|
42
|
+
* - `InvalidKey`: the key violates the key rule, or a rule the adapter adds to it.
|
|
43
|
+
* - `InvalidOption`: an option or configuration value stowage refused, such as an unknown key,
|
|
44
|
+
* a value out of range or a cursor it did not produce.
|
|
45
|
+
* - `Unsupported`: the call needs a capability the storage does not declare; `capability`
|
|
46
|
+
* names it.
|
|
47
|
+
*/
|
|
48
|
+
type StorageErrorCode = "NotFound" | "AccessDenied" | "InvalidCredentials" | "Expired" | "InvalidRequest" | "NetworkError" | "ProviderError" | "InvalidKey" | "InvalidOption" | "Unsupported";
|
|
49
|
+
interface StorageErrorFields {
|
|
50
|
+
readonly code: StorageErrorCode;
|
|
51
|
+
readonly message: string;
|
|
52
|
+
readonly operation: string;
|
|
53
|
+
readonly bucket: string;
|
|
54
|
+
readonly provider: string;
|
|
55
|
+
readonly attempts: number;
|
|
56
|
+
readonly key?: string;
|
|
57
|
+
readonly status?: number;
|
|
58
|
+
readonly providerCode?: string;
|
|
59
|
+
readonly requestId?: string;
|
|
60
|
+
readonly retryable?: boolean;
|
|
61
|
+
readonly capability?: CapabilityName;
|
|
62
|
+
readonly cause?: unknown;
|
|
63
|
+
}
|
|
64
|
+
export declare class StorageError extends Error {
|
|
65
|
+
readonly code: StorageErrorCode;
|
|
66
|
+
readonly operation: string;
|
|
67
|
+
readonly bucket: string;
|
|
68
|
+
readonly provider: string;
|
|
69
|
+
/**
|
|
70
|
+
* How often the failing step was attempted: `0` where stowage refused before the first
|
|
71
|
+
* attempt, `1` where a single attempt failed, more where the adapter repeated it.
|
|
72
|
+
*/
|
|
73
|
+
readonly attempts: number;
|
|
74
|
+
/** The condition is transient. It says nothing about whether stowage sent the request again. */
|
|
75
|
+
readonly retryable: boolean;
|
|
76
|
+
readonly key?: string;
|
|
77
|
+
readonly status?: number;
|
|
78
|
+
readonly providerCode?: string;
|
|
79
|
+
readonly requestId?: string;
|
|
80
|
+
/** The capability an `Unsupported` failure needs, and set for no other code. */
|
|
81
|
+
readonly capability?: CapabilityName;
|
|
82
|
+
constructor(fields: StorageErrorFields);
|
|
83
|
+
}
|
|
84
|
+
export declare function isStorageError(value: unknown): value is StorageError;
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/keys.d.ts
|
|
87
|
+
type KeyRule = "writable" | "addressable" | "prefix";
|
|
88
|
+
/** The reason a key violates the rule, or `undefined` where it holds. */
|
|
89
|
+
export declare function invalidKeyReason(key: string, rule: KeyRule): string | undefined;
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/retry.d.ts
|
|
92
|
+
interface RetryOptions {
|
|
93
|
+
readonly maxAttempts: number;
|
|
94
|
+
readonly signal?: AbortSignal;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Repeats `attempt` while it rejects with a `StorageError` whose `retryable` is `true`,
|
|
98
|
+
* up to `maxAttempts` times, waiting a random delay between zero and
|
|
99
|
+
* `min(5 s, 100 ms × 2^n)` before attempt `n + 1`. The error it finally rejects with
|
|
100
|
+
* carries the number of attempts made.
|
|
101
|
+
*
|
|
102
|
+
* Spec 4.13 has this be the one definition of the loop of spec 7.5, so that an adapter
|
|
103
|
+
* written outside this repository repeats on the same curve. `adapter-fs` and
|
|
104
|
+
* `adapter-memory` have no request to send again and do not call it.
|
|
105
|
+
*/
|
|
106
|
+
export declare function withRetry<T>(attempt: () => Promise<T>, options: RetryOptions): Promise<T>;
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/status.d.ts
|
|
109
|
+
/**
|
|
110
|
+
* The code the status decides on its own, or `undefined` for a status that decides
|
|
111
|
+
* nothing. It is the fallback of spec 4.10: an adapter maps a provider code first and
|
|
112
|
+
* reaches here where it recognizes none.
|
|
113
|
+
*/
|
|
114
|
+
export declare function errorCodeForStatus(status: number): StorageErrorCode | undefined;
|
|
115
|
+
/** Whether the status names a condition that may be gone a moment later (spec 4.10). */
|
|
116
|
+
export declare function isTransientStatus(status: number): boolean;
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/storage.d.ts
|
|
119
|
+
type PutBody = Uint8Array | string | ReadableStream<Uint8Array>;
|
|
120
|
+
interface OperationOptions {
|
|
121
|
+
signal?: AbortSignal;
|
|
122
|
+
}
|
|
123
|
+
interface PutOptions extends OperationOptions {
|
|
124
|
+
contentType?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Stored where the storage declares `userMetadata`, and `Unsupported` elsewhere unless it is
|
|
127
|
+
* empty. Keys are non-empty ASCII HTTP tokens compared case-insensitively; values may hold
|
|
128
|
+
* any Unicode. Keys and values together hold at most 2 KB of encoded header bytes, and more
|
|
129
|
+
* is `InvalidRequest`.
|
|
130
|
+
*/
|
|
131
|
+
userMetadata?: Record<string, string>;
|
|
132
|
+
}
|
|
133
|
+
/** Both ends inclusive; `end` absent means to the end of the object. */
|
|
134
|
+
interface ByteRange {
|
|
135
|
+
/**
|
|
136
|
+
* A non-negative integer no greater than `end`, else `InvalidOption`. At or beyond the
|
|
137
|
+
* object's size it is `InvalidRequest`.
|
|
138
|
+
*/
|
|
139
|
+
start: number;
|
|
140
|
+
/** A non-negative integer, else `InvalidOption`. Beyond the object's size it is clipped. */
|
|
141
|
+
end?: number;
|
|
142
|
+
}
|
|
143
|
+
interface GetOptions extends OperationOptions {
|
|
144
|
+
range?: ByteRange;
|
|
145
|
+
}
|
|
146
|
+
interface ListOptions extends OperationOptions {
|
|
147
|
+
prefix?: string;
|
|
148
|
+
/** One or more characters; an empty string is `InvalidOption`. */
|
|
149
|
+
delimiter?: string;
|
|
150
|
+
/** 1 to 1000, and 1000 where absent. Outside that range it is `InvalidOption`. */
|
|
151
|
+
pageSize?: number;
|
|
152
|
+
/**
|
|
153
|
+
* The `cursor` of a page this storage produced, which a new listing in another process may
|
|
154
|
+
* continue from. Any other string is `InvalidOption`.
|
|
155
|
+
*/
|
|
156
|
+
cursor?: string;
|
|
157
|
+
}
|
|
158
|
+
interface ObjectEntry {
|
|
159
|
+
readonly key: string;
|
|
160
|
+
readonly size: number;
|
|
161
|
+
readonly lastModified: Date;
|
|
162
|
+
readonly etag?: string;
|
|
163
|
+
}
|
|
164
|
+
interface ObjectStat extends ObjectEntry {
|
|
165
|
+
readonly contentType: string;
|
|
166
|
+
readonly userMetadata: Readonly<Record<string, string>>;
|
|
167
|
+
}
|
|
168
|
+
interface StoredObject {
|
|
169
|
+
readonly stat: ObjectStat;
|
|
170
|
+
stream(): ReadableStream<Uint8Array>;
|
|
171
|
+
bytes(): Promise<Uint8Array>;
|
|
172
|
+
text(): Promise<string>;
|
|
173
|
+
json<T = unknown>(): Promise<T>;
|
|
174
|
+
}
|
|
175
|
+
interface ListPage {
|
|
176
|
+
readonly objects: readonly ObjectEntry[];
|
|
177
|
+
readonly prefixes: readonly string[];
|
|
178
|
+
readonly cursor?: string;
|
|
179
|
+
}
|
|
180
|
+
interface ObjectListing extends AsyncIterable<ObjectEntry> {
|
|
181
|
+
page(): Promise<ListPage>;
|
|
182
|
+
}
|
|
183
|
+
interface DeleteReport {
|
|
184
|
+
readonly requested: number;
|
|
185
|
+
readonly failed: readonly StorageError[];
|
|
186
|
+
}
|
|
187
|
+
interface Storage {
|
|
188
|
+
readonly provider: string;
|
|
189
|
+
readonly bucket: string;
|
|
190
|
+
/** Every capability the storage implements, each once, fixed when it was constructed. */
|
|
191
|
+
readonly capabilities: readonly CapabilityName[];
|
|
192
|
+
put(key: string, body: PutBody, options?: PutOptions): Promise<ObjectStat>;
|
|
193
|
+
get(key: string, options?: GetOptions): Promise<StoredObject>;
|
|
194
|
+
stat(key: string, options?: OperationOptions): Promise<ObjectStat>;
|
|
195
|
+
exists(key: string, options?: OperationOptions): Promise<boolean>;
|
|
196
|
+
list(options?: ListOptions): ObjectListing;
|
|
197
|
+
delete(...keys: readonly string[]): Promise<DeleteReport>;
|
|
198
|
+
deleteAll(prefix: string, options?: OperationOptions): Promise<DeleteReport>;
|
|
199
|
+
copy(from: string, to: string, options?: OperationOptions): Promise<ObjectStat>;
|
|
200
|
+
move(from: string, to: string, options?: OperationOptions): Promise<ObjectStat>;
|
|
201
|
+
}
|
|
202
|
+
//#endregion
|
|
203
|
+
export type { ByteRange, CapabilityName, DeleteReport, GetOptions, KeyRule, ListOptions, ListPage, ObjectEntry, ObjectListing, ObjectStat, OperationOptions, PutBody, PutOptions, Resolvable, ResolverOptions, RetryOptions, Storage, StorageErrorCode, StorageErrorFields, StoredObject };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
//#region src/capabilities.ts
|
|
2
|
+
/**
|
|
3
|
+
* Every capability a storage can declare (spec 4.9). The list grows in minor releases, so a
|
|
4
|
+
* `switch` over it needs a default branch.
|
|
5
|
+
*
|
|
6
|
+
* - `keyBytesPreserved`: a key comes back byte for byte as it was written. Where not
|
|
7
|
+
* declared, it comes back Unicode-equivalent.
|
|
8
|
+
* - `presignedUrls`: the concrete type carries `presignGet` and `presignPut`. Where not
|
|
9
|
+
* declared, neither method exists on the type.
|
|
10
|
+
* - `rangeReads`: `get` honors `range`. Where not declared, a `range` is `Unsupported`.
|
|
11
|
+
* - `userMetadata`: `put` stores `userMetadata`, `stat` and `get` return it, `copy` keeps it.
|
|
12
|
+
* Where not declared, a non-empty `userMetadata` is `Unsupported` and reads return `{}`.
|
|
13
|
+
*/
|
|
14
|
+
const capabilityNames = [
|
|
15
|
+
"keyBytesPreserved",
|
|
16
|
+
"presignedUrls",
|
|
17
|
+
"rangeReads",
|
|
18
|
+
"userMetadata"
|
|
19
|
+
];
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/errors.ts
|
|
22
|
+
const storageErrorBrand = Symbol.for("stowage.error");
|
|
23
|
+
var StorageError = class extends Error {
|
|
24
|
+
code;
|
|
25
|
+
operation;
|
|
26
|
+
bucket;
|
|
27
|
+
provider;
|
|
28
|
+
/**
|
|
29
|
+
* How often the failing step was attempted: `0` where stowage refused before the first
|
|
30
|
+
* attempt, `1` where a single attempt failed, more where the adapter repeated it.
|
|
31
|
+
*/
|
|
32
|
+
attempts;
|
|
33
|
+
/** The condition is transient. It says nothing about whether stowage sent the request again. */
|
|
34
|
+
retryable;
|
|
35
|
+
key;
|
|
36
|
+
status;
|
|
37
|
+
providerCode;
|
|
38
|
+
requestId;
|
|
39
|
+
/** The capability an `Unsupported` failure needs, and set for no other code. */
|
|
40
|
+
capability;
|
|
41
|
+
constructor(fields) {
|
|
42
|
+
super(fields.message);
|
|
43
|
+
if (fields.code === "Unsupported" && fields.capability === void 0) throw new TypeError("An `Unsupported` storage error names the capability it needs");
|
|
44
|
+
this.name = "StorageError";
|
|
45
|
+
this.code = fields.code;
|
|
46
|
+
this.operation = fields.operation;
|
|
47
|
+
this.bucket = fields.bucket;
|
|
48
|
+
this.provider = fields.provider;
|
|
49
|
+
this.attempts = fields.attempts;
|
|
50
|
+
this.retryable = fields.retryable ?? false;
|
|
51
|
+
this.key = fields.key;
|
|
52
|
+
this.status = fields.status;
|
|
53
|
+
this.providerCode = fields.providerCode;
|
|
54
|
+
this.requestId = fields.requestId;
|
|
55
|
+
this.capability = fields.capability;
|
|
56
|
+
if (fields.cause !== void 0) this.cause = fields.cause;
|
|
57
|
+
Object.defineProperty(this, storageErrorBrand, { value: true });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
function isStorageError(value) {
|
|
61
|
+
return typeof value === "object" && value !== null && storageErrorBrand in value;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The same failure counting the attempts a whole retry loop made rather than the one it
|
|
65
|
+
* was raised in. It lives beside the field list, because every field has to be named
|
|
66
|
+
* again here or it is dropped on the way through; the stack travels along, because it
|
|
67
|
+
* points at where the request failed and this is no other place it could have failed.
|
|
68
|
+
*
|
|
69
|
+
* Not published: spec 4.13 lists what an adapter calls, and `withRetry` is the one
|
|
70
|
+
* caller this has.
|
|
71
|
+
*/
|
|
72
|
+
function withAttempts(failure, attempts) {
|
|
73
|
+
if (failure.attempts === attempts) return failure;
|
|
74
|
+
const counted = new StorageError({
|
|
75
|
+
code: failure.code,
|
|
76
|
+
message: failure.message,
|
|
77
|
+
operation: failure.operation,
|
|
78
|
+
bucket: failure.bucket,
|
|
79
|
+
provider: failure.provider,
|
|
80
|
+
attempts,
|
|
81
|
+
key: failure.key,
|
|
82
|
+
status: failure.status,
|
|
83
|
+
providerCode: failure.providerCode,
|
|
84
|
+
requestId: failure.requestId,
|
|
85
|
+
retryable: failure.retryable,
|
|
86
|
+
capability: failure.capability,
|
|
87
|
+
cause: failure.cause
|
|
88
|
+
});
|
|
89
|
+
counted.stack = failure.stack;
|
|
90
|
+
return counted;
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/keys.ts
|
|
94
|
+
const writableKeyLimit = 1024;
|
|
95
|
+
const utf8 = new TextEncoder();
|
|
96
|
+
/** The reason a key violates the rule, or `undefined` where it holds. */
|
|
97
|
+
function invalidKeyReason(key, rule) {
|
|
98
|
+
if (key === "") return rule === "prefix" ? void 0 : "is empty";
|
|
99
|
+
const controlCharacter = firstControlCharacter(key);
|
|
100
|
+
if (controlCharacter !== void 0) return `holds the control character ${controlCharacter}`;
|
|
101
|
+
if (rule === "writable") {
|
|
102
|
+
if (key.includes("\\")) return "holds a backslash";
|
|
103
|
+
if (key.endsWith("/")) return "ends with a slash";
|
|
104
|
+
const bytes = utf8.encode(key).length;
|
|
105
|
+
if (bytes > writableKeyLimit) return `is ${bytes} UTF-8 bytes, above the limit of ${writableKeyLimit}`;
|
|
106
|
+
}
|
|
107
|
+
return invalidSegmentReason(key);
|
|
108
|
+
}
|
|
109
|
+
function firstControlCharacter(key) {
|
|
110
|
+
for (let index = 0; index < key.length; index += 1) {
|
|
111
|
+
const code = key.charCodeAt(index);
|
|
112
|
+
if (code <= 31 || code === 127) return `U+${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function invalidSegmentReason(key) {
|
|
116
|
+
const segments = key.split("/");
|
|
117
|
+
for (const [index, segment] of segments.entries()) {
|
|
118
|
+
if (segment === "." || segment === "..") return `holds ${JSON.stringify(segment)} as a segment`;
|
|
119
|
+
if (segment !== "") continue;
|
|
120
|
+
if (index === 0) return "starts with a slash";
|
|
121
|
+
if (index < segments.length - 1) return "holds an empty segment";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/retry.ts
|
|
126
|
+
const baseDelay = 100;
|
|
127
|
+
const maximumDelay = 5e3;
|
|
128
|
+
/**
|
|
129
|
+
* Repeats `attempt` while it rejects with a `StorageError` whose `retryable` is `true`,
|
|
130
|
+
* up to `maxAttempts` times, waiting a random delay between zero and
|
|
131
|
+
* `min(5 s, 100 ms × 2^n)` before attempt `n + 1`. The error it finally rejects with
|
|
132
|
+
* carries the number of attempts made.
|
|
133
|
+
*
|
|
134
|
+
* Spec 4.13 has this be the one definition of the loop of spec 7.5, so that an adapter
|
|
135
|
+
* written outside this repository repeats on the same curve. `adapter-fs` and
|
|
136
|
+
* `adapter-memory` have no request to send again and do not call it.
|
|
137
|
+
*/
|
|
138
|
+
async function withRetry(attempt, options) {
|
|
139
|
+
let requestsSent = 0;
|
|
140
|
+
for (let attemptsMade = 1;; attemptsMade += 1) try {
|
|
141
|
+
return await attempt();
|
|
142
|
+
} catch (failure) {
|
|
143
|
+
requestsSent += costOf(failure);
|
|
144
|
+
if (!isStorageError(failure)) throw failure;
|
|
145
|
+
if (!failure.retryable || attemptsMade >= options.maxAttempts) throw withAttempts(failure, requestsSent);
|
|
146
|
+
await waitBefore(attemptsMade, options.signal);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function costOf(failure) {
|
|
150
|
+
return isStorageError(failure) ? failure.attempts : 1;
|
|
151
|
+
}
|
|
152
|
+
/** Full jitter (ADR 0013), interrupted by the signal that is the caller's whole budget. */
|
|
153
|
+
async function waitBefore(attemptsMade, signal) {
|
|
154
|
+
const milliseconds = Math.random() * Math.min(maximumDelay, baseDelay * 2 ** attemptsMade);
|
|
155
|
+
await new Promise((resolve, reject) => {
|
|
156
|
+
if (signal === void 0) {
|
|
157
|
+
setTimeout(resolve, milliseconds);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
signal.throwIfAborted();
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
signal.removeEventListener("abort", aborted);
|
|
163
|
+
resolve();
|
|
164
|
+
}, milliseconds);
|
|
165
|
+
const aborted = () => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
reject(signal.reason);
|
|
168
|
+
};
|
|
169
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/status.ts
|
|
174
|
+
/**
|
|
175
|
+
* The code the status decides on its own, or `undefined` for a status that decides
|
|
176
|
+
* nothing. It is the fallback of spec 4.10: an adapter maps a provider code first and
|
|
177
|
+
* reaches here where it recognizes none.
|
|
178
|
+
*/
|
|
179
|
+
function errorCodeForStatus(status) {
|
|
180
|
+
if (status >= 200 && status <= 299) return void 0;
|
|
181
|
+
if (status === 401) return "InvalidCredentials";
|
|
182
|
+
if (status === 403) return "AccessDenied";
|
|
183
|
+
if (status === 404) return "NotFound";
|
|
184
|
+
return "ProviderError";
|
|
185
|
+
}
|
|
186
|
+
/** Whether the status names a condition that may be gone a moment later (spec 4.10). */
|
|
187
|
+
function isTransientStatus(status) {
|
|
188
|
+
return status === 408 || status === 429 || status >= 500 && status <= 599;
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
export { StorageError, capabilityNames, errorCodeForStatus, invalidKeyReason, isStorageError, isTransientStatus, withRetry };
|
package/package.json
CHANGED
|
@@ -1 +1,38 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@stowage/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One typed API for object storage: the types every stowage adapter implements, StorageError, and the utilities an adapter calls.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"adapter",
|
|
7
|
+
"object-storage",
|
|
8
|
+
"s3",
|
|
9
|
+
"storage",
|
|
10
|
+
"stowage",
|
|
11
|
+
"typescript"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/stowage-js/stowage/tree/main/packages/core#readme",
|
|
14
|
+
"bugs": "https://github.com/stowage-js/stowage/issues",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Alexander Kaufmann",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/stowage-js/stowage.git",
|
|
20
|
+
"directory": "packages/core"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./dist/index.js",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"provenance": true
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=24"
|
|
37
|
+
}
|
|
38
|
+
}
|