@firetail/finchly 0.0.9 → 0.0.11
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/CHANGELOG.md +20 -0
- package/README.md +79 -1
- package/dist/blobs.d.ts +101 -0
- package/dist/blobs.js +253 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to `@firetail/finchly`. Versions follow semver.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.0.11 - 2026-11-12
|
|
8
|
+
|
|
9
|
+
- `blobs`: objects go straight to the object store and come straight from
|
|
10
|
+
it, at an address the platform signs for each read and write. An object
|
|
11
|
+
is no longer limited to 16 MiB. **Needs a platform that signs
|
|
12
|
+
addresses**; with one whose store signs none, objects pass through the
|
|
13
|
+
platform as before.
|
|
14
|
+
- `blobs`: `presignGet(key)` and `presignPut(key, { size })` give an
|
|
15
|
+
address to hand on, for a browser to fetch or send one object.
|
|
16
|
+
- `createBlobs` takes `fetch`, which is what reaches the object store.
|
|
17
|
+
|
|
18
|
+
## 0.0.10 - 2026-11-12
|
|
19
|
+
|
|
20
|
+
- `createBlobs({ service })`: objects kept with the platform for each
|
|
21
|
+
tenant. `blobs.forTenant(caller.tenantId)` gives `get`, `put`,
|
|
22
|
+
`putIfAbsent`, `putIfMatch`, `delete` and `list`. The manifest must set
|
|
23
|
+
`permissions.blobs`.
|
|
24
|
+
- New error codes: `blobs_not_granted`, `blobs_unavailable`.
|
|
25
|
+
- **Needs a platform that serves objects.**
|
|
26
|
+
|
|
7
27
|
## 0.0.9 - 2026-11-12
|
|
8
28
|
|
|
9
29
|
- `createSecrets({ service })`: secrets kept with the platform for each
|
package/README.md
CHANGED
|
@@ -274,6 +274,84 @@ been changed, rejects with `secrets_cannot_decrypt`.
|
|
|
274
274
|
A manifest that does not ask for secrets gets `secrets_not_granted`. Any
|
|
275
275
|
other refusal is `secrets_unavailable`.
|
|
276
276
|
|
|
277
|
+
### Objects
|
|
278
|
+
|
|
279
|
+
A service whose manifest sets `permissions.blobs` keeps objects with the
|
|
280
|
+
platform, separately for each tenant: receipts, exports, files. The service
|
|
281
|
+
holds no credentials for the object store. For each read and write the
|
|
282
|
+
platform signs an address for that one object, and the bytes go straight
|
|
283
|
+
to the store or come straight from it.
|
|
284
|
+
|
|
285
|
+
```js
|
|
286
|
+
import { createBlobs } from "@firetail/finchly";
|
|
287
|
+
|
|
288
|
+
const blobs = createBlobs({ service });
|
|
289
|
+
|
|
290
|
+
// Per request: the objects of the caller's tenant.
|
|
291
|
+
const mine = blobs.forTenant(caller.tenantId);
|
|
292
|
+
|
|
293
|
+
await mine.put(`receipts/${id}.pdf`, bytes, { contentType: "application/pdf" });
|
|
294
|
+
const receipt = await mine.get(`receipts/${id}.pdf`); // undefined if there is none
|
|
295
|
+
receipt.data; // a Uint8Array
|
|
296
|
+
receipt.contentType; // "application/pdf"
|
|
297
|
+
|
|
298
|
+
const { objects, next } = await mine.list({ prefix: "receipts/", limit: 100 });
|
|
299
|
+
await mine.delete(`receipts/${id}.pdf`);
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
- A key is 1 to 512 letters, digits, dots, dashes and underscores, in
|
|
303
|
+
segments divided by `/`. Do not use a key that is also the start of
|
|
304
|
+
another followed by `/`, such as `a` and `a/b`.
|
|
305
|
+
- An object is text or bytes, held in memory, of up to 5 GiB.
|
|
306
|
+
- A tenant's objects are reached with that tenant's id and no other. Take
|
|
307
|
+
the id from a verified identity, never from the request itself.
|
|
308
|
+
- A listing is in key order. When `next` is not undefined there are more:
|
|
309
|
+
ask again with `{ after: next }`.
|
|
310
|
+
|
|
311
|
+
Two writers do not lose each other's work if each writes on a condition:
|
|
312
|
+
|
|
313
|
+
```js
|
|
314
|
+
// Only if there is none by that key. Undefined if there was.
|
|
315
|
+
const made = await mine.putIfAbsent("state.json", "{}", { contentType: "application/json" });
|
|
316
|
+
|
|
317
|
+
// Only if it has not changed since it was read. Undefined if it has:
|
|
318
|
+
// read again and decide again.
|
|
319
|
+
const state = await mine.get("state.json");
|
|
320
|
+
const next = change(JSON.parse(Buffer.from(state.data).toString()));
|
|
321
|
+
const written = await mine.putIfMatch("state.json", JSON.stringify(next), state.etag, { contentType: "application/json" });
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
State that needs transactions or queries belongs in the service's
|
|
325
|
+
database, not in an object.
|
|
326
|
+
|
|
327
|
+
An address can be handed on, so that a file goes between a browser and the
|
|
328
|
+
store without passing through the service:
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
// To fetch one object with a plain GET.
|
|
332
|
+
const url = await mine.presignGet(`receipts/${id}.pdf`, { expiresSeconds: 60 });
|
|
333
|
+
|
|
334
|
+
// To send one object of a stated size: a PUT to `url` with exactly `headers`.
|
|
335
|
+
const { url, headers } = await mine.presignPut(`uploads/${id}`, { size: file.size, contentType: file.type, expiresSeconds: 300 });
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
- An address is for that one object and that one use. One made to send
|
|
339
|
+
takes exactly that content type and that many bytes.
|
|
340
|
+
- Whoever holds an address can use it until it runs out, with no sign-in:
|
|
341
|
+
1 to 3600 seconds, default 300. Ask for the shortest that will do, and do
|
|
342
|
+
not log one.
|
|
343
|
+
- The same addresses serve to stream an object too large to hold in
|
|
344
|
+
memory.
|
|
345
|
+
|
|
346
|
+
A manifest that does not ask for objects gets `blobs_not_granted`. Any
|
|
347
|
+
other refusal, a deployment with no object store among them, is
|
|
348
|
+
`blobs_unavailable`.
|
|
349
|
+
|
|
350
|
+
A deployment whose store signs no addresses (a development store) passes
|
|
351
|
+
objects through the platform instead: `get` and `put` work the same, up to
|
|
352
|
+
16 MiB an object, and `presignGet` and `presignPut` reject with
|
|
353
|
+
`blobs_unavailable`.
|
|
354
|
+
|
|
277
355
|
### The queue
|
|
278
356
|
|
|
279
357
|
A message belongs to a tenant. The manifest lists the topics the service
|
|
@@ -358,7 +436,7 @@ not leak them.
|
|
|
358
436
|
| `app` | Register with the platform, serve `/_platform/*`, renew keys, fetch settings | Self-registration and key renewal are done, through `service`. Serving `/_platform/*` and fetching settings are not |
|
|
359
437
|
| `service` | Call other services through the gateway, as the service or for a user | Done, for built-in services and for apps |
|
|
360
438
|
| `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Done |
|
|
361
|
-
| `blobs` |
|
|
439
|
+
| `blobs` | Objects, with conditional writes and addresses to hand on | Done |
|
|
362
440
|
| `queue` | Publish, lease, ack, retry, dead-letter | Done |
|
|
363
441
|
| `secrets` | Secrets, and encryption under the tenant's key | Done |
|
|
364
442
|
| `llm` | Model calls through the platform | Planned |
|
package/dist/blobs.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ServiceClient } from "./service.js";
|
|
2
|
+
/** An object, as it was read. */
|
|
3
|
+
export interface StoredBlob {
|
|
4
|
+
readonly data: Uint8Array;
|
|
5
|
+
/** Changes whenever the content does. Hand it to `putIfMatch` as it came. */
|
|
6
|
+
readonly etag: string;
|
|
7
|
+
readonly contentType: string;
|
|
8
|
+
}
|
|
9
|
+
/** What a listing says of an object. */
|
|
10
|
+
export interface BlobInfo {
|
|
11
|
+
readonly key: string;
|
|
12
|
+
/** In bytes. */
|
|
13
|
+
readonly size: number;
|
|
14
|
+
readonly etag: string;
|
|
15
|
+
readonly modifiedAt: Date;
|
|
16
|
+
}
|
|
17
|
+
export interface PutOptions {
|
|
18
|
+
/** Kept with the object. Default: text/plain for text, application/octet-stream for bytes. */
|
|
19
|
+
readonly contentType?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ListOptions {
|
|
22
|
+
/** The start of the keys to list. May end anywhere. Default: every key. */
|
|
23
|
+
readonly prefix?: string;
|
|
24
|
+
/** List the keys after this one: the `next` of the page before. */
|
|
25
|
+
readonly after?: string;
|
|
26
|
+
/** The most objects to list. 1 to 1000, default 100. */
|
|
27
|
+
readonly limit?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface AddressOptions {
|
|
30
|
+
/** How long the address lasts, in seconds. 1 to 3600, default 300. Ask for the shortest that will do. */
|
|
31
|
+
readonly expiresSeconds?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface PutAddressOptions extends AddressOptions {
|
|
34
|
+
/** The length of what will be sent, in bytes. The address takes exactly that many. */
|
|
35
|
+
readonly size: number;
|
|
36
|
+
/** Kept with the object, and must be sent as given. Default application/octet-stream. */
|
|
37
|
+
readonly contentType?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Where to send one object: a `PUT` to `url` with exactly these `headers`. */
|
|
40
|
+
export interface PutAddress {
|
|
41
|
+
readonly url: string;
|
|
42
|
+
readonly method: "PUT";
|
|
43
|
+
readonly headers: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
export interface BlobPage {
|
|
46
|
+
/** In key order. */
|
|
47
|
+
readonly objects: BlobInfo[];
|
|
48
|
+
/** Pass as `after` for the next page. Undefined when there are no more. */
|
|
49
|
+
readonly next: string | undefined;
|
|
50
|
+
}
|
|
51
|
+
/** A service's objects in one tenant. */
|
|
52
|
+
export interface TenantBlobs {
|
|
53
|
+
readonly tenantId: string;
|
|
54
|
+
/** The object, or undefined if there is none by that key. */
|
|
55
|
+
get(key: string): Promise<StoredBlob | undefined>;
|
|
56
|
+
/** Write an object, replacing any there was. Resolves with its etag. */
|
|
57
|
+
put(key: string, data: string | Uint8Array, options?: PutOptions): Promise<string>;
|
|
58
|
+
/**
|
|
59
|
+
* Write an object only if there is none by that key. Resolves with its
|
|
60
|
+
* etag, or undefined if there was one, which is left as it is.
|
|
61
|
+
*/
|
|
62
|
+
putIfAbsent(key: string, data: string | Uint8Array, options?: PutOptions): Promise<string | undefined>;
|
|
63
|
+
/**
|
|
64
|
+
* Write an object only if it still has the etag it was read with.
|
|
65
|
+
* Resolves with the new etag, or undefined if it has changed or gone:
|
|
66
|
+
* read it again and decide again. This is how two writers do not lose
|
|
67
|
+
* each other's work.
|
|
68
|
+
*/
|
|
69
|
+
putIfMatch(key: string, data: string | Uint8Array, etag: string, options?: PutOptions): Promise<string | undefined>;
|
|
70
|
+
/** Delete an object. Not an error if there is none. */
|
|
71
|
+
delete(key: string): Promise<void>;
|
|
72
|
+
/** The service's objects in this tenant, a page at a time. */
|
|
73
|
+
list(options?: ListOptions): Promise<BlobPage>;
|
|
74
|
+
/**
|
|
75
|
+
* An address the object can be fetched from with a plain GET, by whoever
|
|
76
|
+
* holds it, until it runs out: to hand to a browser, or to stream a
|
|
77
|
+
* large object. It is for this one object. Do not log it.
|
|
78
|
+
*/
|
|
79
|
+
presignGet(key: string, options?: AddressOptions): Promise<string>;
|
|
80
|
+
/**
|
|
81
|
+
* An address one object of a stated size can be sent to, by whoever
|
|
82
|
+
* holds it, until it runs out: to let a browser send a file without it
|
|
83
|
+
* passing through the service. Do not log it.
|
|
84
|
+
*/
|
|
85
|
+
presignPut(key: string, options: PutAddressOptions): Promise<PutAddress>;
|
|
86
|
+
}
|
|
87
|
+
export interface Blobs {
|
|
88
|
+
/**
|
|
89
|
+
* The objects of one tenant: `caller.tenantId` of the request being
|
|
90
|
+
* handled. Take the id from a verified identity, never from the request
|
|
91
|
+
* itself.
|
|
92
|
+
*/
|
|
93
|
+
forTenant(tenantId: string): TenantBlobs;
|
|
94
|
+
}
|
|
95
|
+
export interface BlobsOptions {
|
|
96
|
+
/** The service's client. Every call to the platform goes through it. */
|
|
97
|
+
readonly service: Pick<ServiceClient, "call">;
|
|
98
|
+
/** What reaches the object store. Default: the global `fetch`. */
|
|
99
|
+
readonly fetch?: typeof fetch;
|
|
100
|
+
}
|
|
101
|
+
export declare function createBlobs(options: BlobsOptions): Blobs;
|
package/dist/blobs.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Objects a service keeps with the platform, for each tenant. See
|
|
2
|
+
// spec/blobs.md in the platform repository.
|
|
3
|
+
//
|
|
4
|
+
// The manifest must set permissions.blobs. Everything here is for one
|
|
5
|
+
// tenant: the handle that reads and writes is made with `forTenant`. The
|
|
6
|
+
// service holds no credentials for the object store; the platform does.
|
|
7
|
+
//
|
|
8
|
+
// To read or write, the platform is asked for an address signed for that
|
|
9
|
+
// one object, and the bytes go straight to the store or come straight from
|
|
10
|
+
// it. Where the deployment's store signs no addresses (a development
|
|
11
|
+
// store), they pass through the platform instead, at most 16 MiB an object.
|
|
12
|
+
import { FinchlyError } from "./errors.js";
|
|
13
|
+
const TENANT_ID = /^t_[a-z2-7]{16}$/;
|
|
14
|
+
const KEY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
15
|
+
const PREFIX = /^[A-Za-z0-9._/-]*$/;
|
|
16
|
+
const MAX_KEY_LENGTH = 512;
|
|
17
|
+
/** The largest object that can pass through the platform, where the store signs no addresses. */
|
|
18
|
+
const MAX_THROUGH_PLATFORM = 16 * 1024 * 1024;
|
|
19
|
+
const BASE = "/api/apps/self/blobs";
|
|
20
|
+
const ADDRESSES = "/api/apps/self/blob-urls";
|
|
21
|
+
const unavailable = (message) => new FinchlyError("blobs_unavailable", message);
|
|
22
|
+
function isKey(key) {
|
|
23
|
+
return typeof key === "string" && key.length <= MAX_KEY_LENGTH && KEY.test(key) && !key.split("/").some((segment) => segment === "." || segment === "..");
|
|
24
|
+
}
|
|
25
|
+
function checkKey(key) {
|
|
26
|
+
if (!isKey(key)) {
|
|
27
|
+
throw unavailable('A key is 1 to 512 letters, digits, dots, dashes and underscores, in segments divided by "/"');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function failure(response, what) {
|
|
31
|
+
const answer = (await response.json().catch(() => null));
|
|
32
|
+
const error = typeof answer?.error === "string" ? answer.error : undefined;
|
|
33
|
+
if (error === "not_granted") {
|
|
34
|
+
return new FinchlyError("blobs_not_granted", "The platform keeps no objects for this service", [
|
|
35
|
+
"Set permissions.blobs to true in the manifest",
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
return new FinchlyError("blobs_unavailable", `The platform did not ${what}`, [
|
|
39
|
+
`it answered ${response.status}${error ? ` ${error}` : ""}${typeof answer?.detail === "string" ? `: ${answer.detail}` : ""}`,
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
export function createBlobs(options) {
|
|
43
|
+
const { service } = options;
|
|
44
|
+
const doFetch = options.fetch ?? fetch;
|
|
45
|
+
// Whether the deployment's store signs addresses. Not known until asked;
|
|
46
|
+
// once it has said no, it is not asked again.
|
|
47
|
+
let signsAddresses = true;
|
|
48
|
+
return {
|
|
49
|
+
forTenant(tenantId) {
|
|
50
|
+
if (typeof tenantId !== "string" || !TENANT_ID.test(tenantId)) {
|
|
51
|
+
throw unavailable("forTenant needs a tenant id, as in caller.tenantId");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Ask the platform for an address for one object. "none" if the store
|
|
55
|
+
* signs none; "unmet" if it is to write on a condition that already
|
|
56
|
+
* cannot hold.
|
|
57
|
+
*/
|
|
58
|
+
async function address(asked) {
|
|
59
|
+
const response = await service.call("core", ADDRESSES, {
|
|
60
|
+
tenantId,
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "content-type": "application/json" },
|
|
63
|
+
body: JSON.stringify(asked),
|
|
64
|
+
});
|
|
65
|
+
if (response.status === 501) {
|
|
66
|
+
await response.body?.cancel();
|
|
67
|
+
signsAddresses = false;
|
|
68
|
+
return "none";
|
|
69
|
+
}
|
|
70
|
+
if (response.status === 412) {
|
|
71
|
+
await response.body?.cancel();
|
|
72
|
+
return "unmet";
|
|
73
|
+
}
|
|
74
|
+
if (response.status !== 200)
|
|
75
|
+
throw await failure(response, "give an address for the object");
|
|
76
|
+
const { url, headers } = (await response.json());
|
|
77
|
+
return { url, headers };
|
|
78
|
+
}
|
|
79
|
+
/** Make a request to the store, at an address. The address itself is never part of an error. */
|
|
80
|
+
async function atStore(to, init) {
|
|
81
|
+
try {
|
|
82
|
+
return await doFetch(to.url, { ...init, headers: to.headers });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const cause = err.cause;
|
|
86
|
+
throw new FinchlyError("blobs_unavailable", "Cannot reach the object store", [
|
|
87
|
+
typeof cause?.code === "string" ? cause.code : "the request failed",
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const refusedByStore = async (response, what) => {
|
|
92
|
+
await response.body?.cancel();
|
|
93
|
+
return new FinchlyError("blobs_unavailable", `The object store did not ${what}`, [`it answered ${response.status}`]);
|
|
94
|
+
};
|
|
95
|
+
/** Write, with a condition or none. Undefined if the condition did not hold. */
|
|
96
|
+
async function write(key, data, condition, putOptions = {}) {
|
|
97
|
+
checkKey(key);
|
|
98
|
+
const text = typeof data === "string";
|
|
99
|
+
if (!text && !(data instanceof Uint8Array))
|
|
100
|
+
throw unavailable("An object is text or bytes");
|
|
101
|
+
const bytes = text ? Buffer.from(data, "utf8") : data;
|
|
102
|
+
const contentType = putOptions.contentType ?? (text ? "text/plain; charset=utf-8" : "application/octet-stream");
|
|
103
|
+
if (typeof contentType !== "string" || !/^[\x20-\x7e]{1,200}$/.test(contentType))
|
|
104
|
+
throw unavailable("The content type is not one");
|
|
105
|
+
if (signsAddresses) {
|
|
106
|
+
const to = await address({ key, method: "PUT", contentType, size: bytes.byteLength, ...condition });
|
|
107
|
+
if (to === "unmet")
|
|
108
|
+
return undefined;
|
|
109
|
+
if (to !== "none") {
|
|
110
|
+
const response = await atStore(to, { method: "PUT", body: bytes });
|
|
111
|
+
const conditional = condition.ifAbsent === true || condition.ifMatch !== undefined;
|
|
112
|
+
// 412: the condition did not hold. 409: another write to the same key was under way.
|
|
113
|
+
if (conditional && (response.status === 412 || response.status === 409)) {
|
|
114
|
+
await response.body?.cancel();
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
if (response.status !== 200)
|
|
118
|
+
throw await refusedByStore(response, "take the object");
|
|
119
|
+
await response.body?.cancel();
|
|
120
|
+
return response.headers.get("etag") ?? "";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Through the platform: the store signs no addresses.
|
|
124
|
+
if (bytes.byteLength > MAX_THROUGH_PLATFORM) {
|
|
125
|
+
throw unavailable("This deployment's object store takes objects through the platform only, at most 16 MiB each");
|
|
126
|
+
}
|
|
127
|
+
const response = await service.call("core", `${BASE}/${key}`, {
|
|
128
|
+
tenantId,
|
|
129
|
+
method: "PUT",
|
|
130
|
+
headers: {
|
|
131
|
+
"content-type": contentType,
|
|
132
|
+
...(condition.ifAbsent ? { "if-none-match": "*" } : {}),
|
|
133
|
+
...(condition.ifMatch !== undefined ? { "if-match": condition.ifMatch } : {}),
|
|
134
|
+
},
|
|
135
|
+
body: bytes,
|
|
136
|
+
});
|
|
137
|
+
if (response.status === 412) {
|
|
138
|
+
await response.body?.cancel();
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
if (response.status !== 200)
|
|
142
|
+
throw await failure(response, "keep the object");
|
|
143
|
+
return (await response.json()).etag;
|
|
144
|
+
}
|
|
145
|
+
const read = async (response) => ({
|
|
146
|
+
data: new Uint8Array(await response.arrayBuffer()),
|
|
147
|
+
etag: response.headers.get("etag") ?? "",
|
|
148
|
+
contentType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
149
|
+
});
|
|
150
|
+
const seconds = (addressOptions) => {
|
|
151
|
+
const expiresSeconds = addressOptions?.expiresSeconds;
|
|
152
|
+
if (expiresSeconds === undefined)
|
|
153
|
+
return {};
|
|
154
|
+
if (!Number.isInteger(expiresSeconds) || expiresSeconds < 1 || expiresSeconds > 3600)
|
|
155
|
+
throw unavailable("expiresSeconds is 1 to 3600");
|
|
156
|
+
return { expiresSeconds };
|
|
157
|
+
};
|
|
158
|
+
/** An address to hand on. There is none where the store signs none. */
|
|
159
|
+
async function handOn(asked) {
|
|
160
|
+
const to = await address(asked);
|
|
161
|
+
if (to === "none" || to === "unmet")
|
|
162
|
+
throw unavailable("This deployment's object store gives no addresses");
|
|
163
|
+
return to;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
tenantId,
|
|
167
|
+
async get(key) {
|
|
168
|
+
checkKey(key);
|
|
169
|
+
if (signsAddresses) {
|
|
170
|
+
const from = await address({ key, method: "GET" });
|
|
171
|
+
if (from !== "none" && from !== "unmet") {
|
|
172
|
+
const response = await atStore(from, {});
|
|
173
|
+
if (response.status === 404) {
|
|
174
|
+
await response.body?.cancel();
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
if (response.status !== 200)
|
|
178
|
+
throw await refusedByStore(response, "give the object");
|
|
179
|
+
return read(response);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const response = await service.call("core", `${BASE}/${key}`, { tenantId });
|
|
183
|
+
if (response.status === 404) {
|
|
184
|
+
await response.body?.cancel();
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
if (response.status !== 200)
|
|
188
|
+
throw await failure(response, "give the object");
|
|
189
|
+
return read(response);
|
|
190
|
+
},
|
|
191
|
+
async put(key, data, putOptions) {
|
|
192
|
+
// With no condition there is nothing not to hold.
|
|
193
|
+
return (await write(key, data, {}, putOptions));
|
|
194
|
+
},
|
|
195
|
+
putIfAbsent(key, data, putOptions) {
|
|
196
|
+
return write(key, data, { ifAbsent: true }, putOptions);
|
|
197
|
+
},
|
|
198
|
+
async putIfMatch(key, data, etag, putOptions) {
|
|
199
|
+
if (typeof etag !== "string" || etag === "" || etag.length > 200) {
|
|
200
|
+
throw unavailable("putIfMatch needs the etag the object was read with");
|
|
201
|
+
}
|
|
202
|
+
return write(key, data, { ifMatch: etag }, putOptions);
|
|
203
|
+
},
|
|
204
|
+
async delete(key) {
|
|
205
|
+
checkKey(key);
|
|
206
|
+
const response = await service.call("core", `${BASE}/${key}`, { tenantId, method: "DELETE" });
|
|
207
|
+
if (response.status !== 204)
|
|
208
|
+
throw await failure(response, "delete the object");
|
|
209
|
+
},
|
|
210
|
+
async list(listOptions = {}) {
|
|
211
|
+
const { prefix, after, limit } = listOptions;
|
|
212
|
+
if (prefix !== undefined && (typeof prefix !== "string" || prefix.length > MAX_KEY_LENGTH || !PREFIX.test(prefix))) {
|
|
213
|
+
throw unavailable("A prefix is the start of a key");
|
|
214
|
+
}
|
|
215
|
+
if (after !== undefined)
|
|
216
|
+
checkKey(after);
|
|
217
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 1000)) {
|
|
218
|
+
throw unavailable("limit is 1 to 1000");
|
|
219
|
+
}
|
|
220
|
+
const query = new URLSearchParams();
|
|
221
|
+
if (prefix)
|
|
222
|
+
query.set("prefix", prefix);
|
|
223
|
+
if (after !== undefined)
|
|
224
|
+
query.set("after", after);
|
|
225
|
+
if (limit !== undefined)
|
|
226
|
+
query.set("limit", String(limit));
|
|
227
|
+
const response = await service.call("core", query.size > 0 ? `${BASE}?${query}` : BASE, { tenantId });
|
|
228
|
+
if (response.status !== 200)
|
|
229
|
+
throw await failure(response, "list the objects");
|
|
230
|
+
const page = (await response.json());
|
|
231
|
+
return {
|
|
232
|
+
objects: page.objects.map(({ key, size, etag, modifiedAt }) => ({ key, size, etag, modifiedAt: new Date(modifiedAt) })),
|
|
233
|
+
next: page.next ?? undefined,
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
async presignGet(key, addressOptions) {
|
|
237
|
+
checkKey(key);
|
|
238
|
+
return (await handOn({ key, method: "GET", ...seconds(addressOptions) })).url;
|
|
239
|
+
},
|
|
240
|
+
async presignPut(key, addressOptions) {
|
|
241
|
+
checkKey(key);
|
|
242
|
+
const { size, contentType = "application/octet-stream" } = addressOptions ?? {};
|
|
243
|
+
if (!Number.isInteger(size) || size < 0)
|
|
244
|
+
throw unavailable("presignPut needs the size of what will be sent, in bytes");
|
|
245
|
+
if (typeof contentType !== "string" || !/^[\x20-\x7e]{1,200}$/.test(contentType))
|
|
246
|
+
throw unavailable("The content type is not one");
|
|
247
|
+
const to = await handOn({ key, method: "PUT", contentType, size, ...seconds(addressOptions) });
|
|
248
|
+
return { url: to.url, method: "PUT", headers: to.headers };
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FinchlyErrorCode = "config_invalid" | "manifest_invalid" | "manifest_unreadable" | "identity_invalid" | "gateway_unreachable" | "platform_api_unsupported" | "service_call_invalid" | "service_key_rejected" | "app_registration_refused" | "app_pending_approval" | "database_unavailable" | "database_not_granted" | "migration_invalid" | "secrets_not_granted" | "secrets_unavailable" | "secrets_cannot_decrypt" | "queue_not_granted" | "queue_unavailable";
|
|
1
|
+
export type FinchlyErrorCode = "config_invalid" | "manifest_invalid" | "manifest_unreadable" | "identity_invalid" | "gateway_unreachable" | "platform_api_unsupported" | "service_call_invalid" | "service_key_rejected" | "app_registration_refused" | "app_pending_approval" | "database_unavailable" | "database_not_granted" | "migration_invalid" | "secrets_not_granted" | "secrets_unavailable" | "secrets_cannot_decrypt" | "queue_not_granted" | "queue_unavailable" | "blobs_not_granted" | "blobs_unavailable";
|
|
2
2
|
/**
|
|
3
3
|
* Every error this SDK throws on purpose. `code` is stable and safe to
|
|
4
4
|
* branch on; `problems` lists each thing that was wrong, so a caller can
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { createBlobs } from "./blobs.js";
|
|
2
|
+
export type { AddressOptions, BlobInfo, BlobPage, Blobs, BlobsOptions, ListOptions, PutAddress, PutAddressOptions, PutOptions, StoredBlob, TenantBlobs, } from "./blobs.js";
|
|
1
3
|
export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
|
|
2
4
|
export type { PlatformConfig } from "./config.js";
|
|
3
5
|
export { connectDatabase, fetchSettings } from "./db.js";
|
package/dist/index.js
CHANGED