@superbee/core 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/dist/backend.d.ts +77 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +380 -0
- package/dist/backend.js.map +1 -0
- package/dist/bundle.d.ts +38 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +107 -0
- package/dist/bundle.js.map +1 -0
- package/dist/content-type.d.ts +72 -0
- package/dist/content-type.d.ts.map +1 -0
- package/dist/content-type.js +149 -0
- package/dist/content-type.js.map +1 -0
- package/dist/document-mutation.d.ts +83 -0
- package/dist/document-mutation.d.ts.map +1 -0
- package/dist/document-mutation.js +319 -0
- package/dist/document-mutation.js.map +1 -0
- package/dist/document-precondition.d.ts +62 -0
- package/dist/document-precondition.d.ts.map +1 -0
- package/dist/document-precondition.js +134 -0
- package/dist/document-precondition.js.map +1 -0
- package/dist/document-write-policy.d.ts +42 -0
- package/dist/document-write-policy.d.ts.map +1 -0
- package/dist/document-write-policy.js +134 -0
- package/dist/document-write-policy.js.map +1 -0
- package/dist/engine.d.ts +48 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +209 -0
- package/dist/engine.js.map +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +50 -0
- package/dist/errors.js.map +1 -0
- package/dist/filesystem-identity.d.ts +208 -0
- package/dist/filesystem-identity.d.ts.map +1 -0
- package/dist/filesystem-identity.js +675 -0
- package/dist/filesystem-identity.js.map +1 -0
- package/dist/filesystem-lock.d.ts +65 -0
- package/dist/filesystem-lock.d.ts.map +1 -0
- package/dist/filesystem-lock.js +460 -0
- package/dist/filesystem-lock.js.map +1 -0
- package/dist/freshness.d.ts +31 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/freshness.js +98 -0
- package/dist/freshness.js.map +1 -0
- package/dist/frontmatter-contract.d.ts +17 -0
- package/dist/frontmatter-contract.d.ts.map +1 -0
- package/dist/frontmatter-contract.js +29 -0
- package/dist/frontmatter-contract.js.map +1 -0
- package/dist/frontmatter.d.ts +30 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +115 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/index-marker.d.ts +3 -0
- package/dist/index-marker.d.ts.map +1 -0
- package/dist/index-marker.js +3 -0
- package/dist/index-marker.js.map +1 -0
- package/dist/index-projection.d.ts +79 -0
- package/dist/index-projection.d.ts.map +1 -0
- package/dist/index-projection.js +245 -0
- package/dist/index-projection.js.map +1 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +110 -0
- package/dist/index.js.map +1 -0
- package/dist/kinds-load.d.ts +13 -0
- package/dist/kinds-load.d.ts.map +1 -0
- package/dist/kinds-load.js +34 -0
- package/dist/kinds-load.js.map +1 -0
- package/dist/kinds.d.ts +241 -0
- package/dist/kinds.d.ts.map +1 -0
- package/dist/kinds.js +1001 -0
- package/dist/kinds.js.map +1 -0
- package/dist/links.d.ts +62 -0
- package/dist/links.d.ts.map +1 -0
- package/dist/links.js +203 -0
- package/dist/links.js.map +1 -0
- package/dist/meaningful-change-time.d.ts +11 -0
- package/dist/meaningful-change-time.d.ts.map +1 -0
- package/dist/meaningful-change-time.js +16 -0
- package/dist/meaningful-change-time.js.map +1 -0
- package/dist/memory-backend.d.ts +55 -0
- package/dist/memory-backend.d.ts.map +1 -0
- package/dist/memory-backend.js +214 -0
- package/dist/memory-backend.js.map +1 -0
- package/dist/mutation-attribution.d.ts +19 -0
- package/dist/mutation-attribution.d.ts.map +1 -0
- package/dist/mutation-attribution.js +34 -0
- package/dist/mutation-attribution.js.map +1 -0
- package/dist/mutation.d.ts +89 -0
- package/dist/mutation.d.ts.map +1 -0
- package/dist/mutation.js +94 -0
- package/dist/mutation.js.map +1 -0
- package/dist/page.d.ts +87 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +151 -0
- package/dist/page.js.map +1 -0
- package/dist/paths.d.ts +78 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +148 -0
- package/dist/paths.js.map +1 -0
- package/dist/portable-frontmatter.d.ts +8 -0
- package/dist/portable-frontmatter.d.ts.map +1 -0
- package/dist/portable-frontmatter.js +35 -0
- package/dist/portable-frontmatter.js.map +1 -0
- package/dist/publication-filesystem.d.ts +6 -0
- package/dist/publication-filesystem.d.ts.map +1 -0
- package/dist/publication-filesystem.js +6 -0
- package/dist/publication-filesystem.js.map +1 -0
- package/dist/query-filter.d.ts +12 -0
- package/dist/query-filter.d.ts.map +1 -0
- package/dist/query-filter.js +29 -0
- package/dist/query-filter.js.map +1 -0
- package/dist/query-selection.d.ts +25 -0
- package/dist/query-selection.d.ts.map +1 -0
- package/dist/query-selection.js +48 -0
- package/dist/query-selection.js.map +1 -0
- package/dist/remote-backend.d.ts +152 -0
- package/dist/remote-backend.d.ts.map +1 -0
- package/dist/remote-backend.js +505 -0
- package/dist/remote-backend.js.map +1 -0
- package/dist/remote.d.ts +4 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +3 -0
- package/dist/remote.js.map +1 -0
- package/dist/storage.d.ts +7 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +6 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +438 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +12 -0
- package/dist/validation.js.map +1 -0
- package/dist/version-transport.d.ts +13 -0
- package/dist/version-transport.d.ts.map +1 -0
- package/dist/version-transport.js +26 -0
- package/dist/version-transport.js.map +1 -0
- package/dist/versioning.d.ts +48 -0
- package/dist/versioning.d.ts.map +1 -0
- package/dist/versioning.js +62 -0
- package/dist/versioning.js.map +1 -0
- package/package.json +84 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RemoteBackend` — a {@link StorageBackend} implemented over the wire-protocol v0
|
|
3
|
+
* reference contract (`docs/WIRE-PROTOCOL.md`, `@superbee/server`).
|
|
4
|
+
*
|
|
5
|
+
* This is the client half of the seam over HTTP: every method maps directly to a wire endpoint,
|
|
6
|
+
* and response versions remain the same content-addressed {@link Version} tokens local backends
|
|
7
|
+
* produce. Tri-backend contract tests pin that invariant.
|
|
8
|
+
*
|
|
9
|
+
* Zero new dependencies: it calls an injectable {@link FetchLike} transport
|
|
10
|
+
* (defaulting to the global `fetch`, available on Node >= 20) with a constructed
|
|
11
|
+
* `Request`. That means the SAME adapter runs against a real HTTP server, or — in
|
|
12
|
+
* tests — directly against an in-process `createRouter(bundle)` function with no
|
|
13
|
+
* sockets: `createRouter` returns exactly the `(req: Request) => Promise<Response>`
|
|
14
|
+
* shape {@link FetchLike} expects, so the router can be injected AS the transport.
|
|
15
|
+
*
|
|
16
|
+
* {@link extractVersion} reads `X-Version` first and a normalized `ETag` second. A missing
|
|
17
|
+
* version is always `VERSION_MISSING`, never `""`: feeding an empty version into a later write
|
|
18
|
+
* would weaken compare-and-swap into an unconditional write. The server emits both headers;
|
|
19
|
+
* `X-Version` is primary because intermediaries may rewrite or strip ETags.
|
|
20
|
+
*
|
|
21
|
+
* Error mapping (so engine callers behave identically regardless of backend):
|
|
22
|
+
* - HTTP `404` -> an ENOENT-shaped rejection
|
|
23
|
+
* (`err.code === "ENOENT"`), matching {@link FilesystemBackend} / {@link MemoryBackend}.
|
|
24
|
+
* (Blob reads are the one exception: {@link StorageBackend.readBlob} returns `null`
|
|
25
|
+
* on a `404`, matching the LOCAL adapters' own "absence is a normal result, not a
|
|
26
|
+
* rejection" posture for blobs — see the blob section below.)
|
|
27
|
+
* - HTTP `412` (`If-Match`/`If-None-Match` failed) -> a reconstructed
|
|
28
|
+
* {@link VersionConflict} from the error envelope's `details: { expected, actual }`.
|
|
29
|
+
* - any other non-2xx -> a {@link RemoteError} carrying the envelope's
|
|
30
|
+
* `message` AND its `code` (falling back to a
|
|
31
|
+
* status-derived guess — `AUTH_REQUIRED` for 401, `RUNTIME` for 5xx, else `USAGE` — when
|
|
32
|
+
* the response carries no parseable envelope) plus the raw HTTP `status`, so callers can
|
|
33
|
+
* branch on `.code` instead of guessing from exception shape.
|
|
34
|
+
*
|
|
35
|
+
* An optional {@link RemoteBackendOptions.authToken} rides as
|
|
36
|
+
* `Authorization: Bearer <token>` on EVERY request. The reference `serve()` ignores it (no
|
|
37
|
+
* auth enforced there), so omitting it is harmless against a local/reference server; a separate
|
|
38
|
+
* gated deployment may require it.
|
|
39
|
+
*
|
|
40
|
+
* This module touches NO filesystem and parses NO markdown — like {@link MemoryBackend},
|
|
41
|
+
* it proves the engine leaks no assumptions beyond the {@link StorageBackend} contract.
|
|
42
|
+
*/
|
|
43
|
+
import type { BlobKey, ConceptId, DeleteOptions, HeadResult, OkfDocument, QueryFilter, ReadBlobResult, ReadResult, ReservedFilename, ReservedReadResult, StorageBackend, Version, VersionInfo, WriteOptions } from "./types.js";
|
|
44
|
+
/**
|
|
45
|
+
* An injectable fetch-like transport: given a `Request`, resolve a `Response`. This
|
|
46
|
+
* is exactly the shape {@link createRouter} (in `@superbee/server`) returns,
|
|
47
|
+
* so a router can be injected directly as the transport — no sockets involved.
|
|
48
|
+
*/
|
|
49
|
+
export type FetchLike = (request: Request) => Promise<Response>;
|
|
50
|
+
/** Construction options for {@link RemoteBackend}. */
|
|
51
|
+
export interface RemoteBackendOptions {
|
|
52
|
+
/** Base URL of the wire-protocol server, e.g. `http://127.0.0.1:4021`. A trailing slash is tolerated. */
|
|
53
|
+
baseUrl: string;
|
|
54
|
+
/** Bundle name segment in the `/v0/bundles/{bundle}/…` path. */
|
|
55
|
+
bundle: string;
|
|
56
|
+
/** Transport override; defaults to the global `fetch` (Node >= 20). Tests inject a router directly. */
|
|
57
|
+
fetchImpl?: FetchLike;
|
|
58
|
+
/**
|
|
59
|
+
* Optional bearer token sent as `Authorization: Bearer <token>` on EVERY request. The
|
|
60
|
+
* reference `serve()` (`@superbee/server`)
|
|
61
|
+
* ignores this header entirely (no auth enforced there), so omitting it is harmless
|
|
62
|
+
* against a local/reference server.
|
|
63
|
+
*/
|
|
64
|
+
authToken?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Max RETRIES (not total attempts) on a TRANSIENT failure — a transient 5xx (500/502/503/504,
|
|
67
|
+
* e.g. a Cloudflare D1 cold-start's 500 "storage caused object to be reset" when a hibernated
|
|
68
|
+
* database is first hit) or a network/transport error. Each retry backs off exponentially with
|
|
69
|
+
* jitter. A 4xx (incl. 412 VersionConflict), 401, or any 2xx is a REAL result, never retried.
|
|
70
|
+
* Default 3; set 0 to disable. Safe because every op is content-addressed + CAS: a retried write
|
|
71
|
+
* lands the same version or a conflict — possibly SPURIOUS, if a prior attempt actually committed
|
|
72
|
+
* before its response was lost — but never silent data loss; and a retried read is idempotent.
|
|
73
|
+
*/
|
|
74
|
+
maxRetries?: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A non-2xx wire response that is neither a `404` (ENOENT-shaped) nor a `412`
|
|
78
|
+
* ({@link VersionConflict}) — the generic case, carrying the error envelope's `code` alongside
|
|
79
|
+
* the raw HTTP `status`, so a caller can distinguish e.g.
|
|
80
|
+
* `AUTH_REQUIRED` (401, an unauthenticated/misconfigured `--remote`) from `RUNTIME` (5xx, a
|
|
81
|
+
* genuine server-side bug) instead of both collapsing into a generically-classified `Error`.
|
|
82
|
+
*/
|
|
83
|
+
export declare class RemoteError extends Error {
|
|
84
|
+
/** The envelope's `code` field, or a status-derived guess when the envelope is missing/unparseable. */
|
|
85
|
+
readonly code: string;
|
|
86
|
+
/** The raw HTTP status that produced this error. */
|
|
87
|
+
readonly status: number;
|
|
88
|
+
constructor(message: string, code: string, status: number);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The client half of the wire-protocol v0 contract: implements {@link StorageBackend}
|
|
92
|
+
* over HTTP against a `@superbee/server` instance (or any conformant
|
|
93
|
+
* implementation of `docs/WIRE-PROTOCOL.md`).
|
|
94
|
+
*/
|
|
95
|
+
export declare class RemoteBackend implements StorageBackend {
|
|
96
|
+
private readonly baseUrl;
|
|
97
|
+
private readonly bundle;
|
|
98
|
+
private readonly fetchImpl;
|
|
99
|
+
private readonly authToken?;
|
|
100
|
+
private readonly maxRetries;
|
|
101
|
+
constructor(options: RemoteBackendOptions);
|
|
102
|
+
/** Build the absolute URL for a bundle-relative wire path (e.g. `/docs/concepts/x`). */
|
|
103
|
+
private url;
|
|
104
|
+
private send;
|
|
105
|
+
/** Parse a non-2xx response into the typed error the engine expects. */
|
|
106
|
+
private toError;
|
|
107
|
+
read(id: ConceptId): Promise<ReadResult>;
|
|
108
|
+
readMany(ids: ConceptId[]): Promise<ReadResult[]>;
|
|
109
|
+
write(id: ConceptId, doc: OkfDocument, options?: WriteOptions): Promise<Version>;
|
|
110
|
+
exists(id: ConceptId): Promise<boolean>;
|
|
111
|
+
/**
|
|
112
|
+
* THE `GET /docs` cursor pager — the pagination contract (the `cursor` param, the
|
|
113
|
+
* `{ docs, next_cursor }` envelope) exists ONCE, here; {@link RemoteBackend.list} and
|
|
114
|
+
* {@link RemoteBackend.queryHeads} are both thin row-mappings over it, so a wire
|
|
115
|
+
* pagination change cannot make the two scans silently paginate differently.
|
|
116
|
+
*/
|
|
117
|
+
private pageDocs;
|
|
118
|
+
list(prefix?: string): Promise<ConceptId[]>;
|
|
119
|
+
/**
|
|
120
|
+
* The seam's OPTIONAL head-projection push-down, over the SAME `GET /docs` route
|
|
121
|
+
* `list()` pages through — with `fields=frontmatter` (the wire's full-frontmatter
|
|
122
|
+
* projection, in the protocol since v0) plus the `prefix`/`type`/repeated-`tag`
|
|
123
|
+
* filter params the reference router evaluates server-side. A filtered scan therefore
|
|
124
|
+
* crosses the wire as thin frontmatter rows: NO bodies, and non-matching docs never
|
|
125
|
+
* leave the server. {@link QueryFilter.fields} equality is NOT pushed (the wire's
|
|
126
|
+
* `fields` param is the projection selector — a recorded name collision, see
|
|
127
|
+
* `docs/WIRE-PROTOCOL.md`); the engine's `queryHeads` re-filter covers it, per the
|
|
128
|
+
* seam contract (over-returning is fine; semantics live in core).
|
|
129
|
+
*/
|
|
130
|
+
queryHeads(filter?: QueryFilter): Promise<HeadResult[]>;
|
|
131
|
+
versions(id: ConceptId): Promise<VersionInfo[]>;
|
|
132
|
+
readReserved(dir: string, name: ReservedFilename): Promise<ReservedReadResult | null>;
|
|
133
|
+
writeReserved(dir: string, name: ReservedFilename, content: string, options?: WriteOptions): Promise<Version>;
|
|
134
|
+
/**
|
|
135
|
+
* `DELETE /docs/{id}`, `If-Match: <expectedVersion>` when given (no unwrapped/null branch
|
|
136
|
+
* to send — {@link DeleteOptions} carries no expect-absent reading, unlike
|
|
137
|
+
* {@link WriteOptions}) and NO `X-Actor` (a delete records no new revision to attribute).
|
|
138
|
+
* `assertValidExpectedVersion` rejects `""` the same way `write` does. There is NO `404`
|
|
139
|
+
* branch: an absent target is a normal `200 { deleted: false }` response by wire contract
|
|
140
|
+
* (idempotency, AXI P6), never a rejection — `!res.ok` still routes a `412` through
|
|
141
|
+
* `toError`'s existing `VersionConflict` reconstruction, and anything else through
|
|
142
|
+
* `RemoteError`.
|
|
143
|
+
*/
|
|
144
|
+
delete(id: ConceptId, options?: DeleteOptions): Promise<boolean>;
|
|
145
|
+
readBlob(key: BlobKey): Promise<ReadBlobResult | null>;
|
|
146
|
+
writeBlob(key: BlobKey, bytes: Uint8Array, contentType?: string, options?: WriteOptions): Promise<Version>;
|
|
147
|
+
/** `DELETE /blobs/{key}`, mirroring `delete`'s `If-Match`/no-404/no-actor posture exactly. */
|
|
148
|
+
deleteBlob(key: BlobKey, options?: DeleteOptions): Promise<boolean>;
|
|
149
|
+
existsBlob(key: BlobKey): Promise<boolean>;
|
|
150
|
+
listBlobs(prefix?: string): Promise<BlobKey[]>;
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=remote-backend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote-backend.d.ts","sourceRoot":"","sources":["../src/remote-backend.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RemoteBackend` — a {@link StorageBackend} implemented over the wire-protocol v0
|
|
3
|
+
* reference contract (`docs/WIRE-PROTOCOL.md`, `@superbee/server`).
|
|
4
|
+
*
|
|
5
|
+
* This is the client half of the seam over HTTP: every method maps directly to a wire endpoint,
|
|
6
|
+
* and response versions remain the same content-addressed {@link Version} tokens local backends
|
|
7
|
+
* produce. Tri-backend contract tests pin that invariant.
|
|
8
|
+
*
|
|
9
|
+
* Zero new dependencies: it calls an injectable {@link FetchLike} transport
|
|
10
|
+
* (defaulting to the global `fetch`, available on Node >= 20) with a constructed
|
|
11
|
+
* `Request`. That means the SAME adapter runs against a real HTTP server, or — in
|
|
12
|
+
* tests — directly against an in-process `createRouter(bundle)` function with no
|
|
13
|
+
* sockets: `createRouter` returns exactly the `(req: Request) => Promise<Response>`
|
|
14
|
+
* shape {@link FetchLike} expects, so the router can be injected AS the transport.
|
|
15
|
+
*
|
|
16
|
+
* {@link extractVersion} reads `X-Version` first and a normalized `ETag` second. A missing
|
|
17
|
+
* version is always `VERSION_MISSING`, never `""`: feeding an empty version into a later write
|
|
18
|
+
* would weaken compare-and-swap into an unconditional write. The server emits both headers;
|
|
19
|
+
* `X-Version` is primary because intermediaries may rewrite or strip ETags.
|
|
20
|
+
*
|
|
21
|
+
* Error mapping (so engine callers behave identically regardless of backend):
|
|
22
|
+
* - HTTP `404` -> an ENOENT-shaped rejection
|
|
23
|
+
* (`err.code === "ENOENT"`), matching {@link FilesystemBackend} / {@link MemoryBackend}.
|
|
24
|
+
* (Blob reads are the one exception: {@link StorageBackend.readBlob} returns `null`
|
|
25
|
+
* on a `404`, matching the LOCAL adapters' own "absence is a normal result, not a
|
|
26
|
+
* rejection" posture for blobs — see the blob section below.)
|
|
27
|
+
* - HTTP `412` (`If-Match`/`If-None-Match` failed) -> a reconstructed
|
|
28
|
+
* {@link VersionConflict} from the error envelope's `details: { expected, actual }`.
|
|
29
|
+
* - any other non-2xx -> a {@link RemoteError} carrying the envelope's
|
|
30
|
+
* `message` AND its `code` (falling back to a
|
|
31
|
+
* status-derived guess — `AUTH_REQUIRED` for 401, `RUNTIME` for 5xx, else `USAGE` — when
|
|
32
|
+
* the response carries no parseable envelope) plus the raw HTTP `status`, so callers can
|
|
33
|
+
* branch on `.code` instead of guessing from exception shape.
|
|
34
|
+
*
|
|
35
|
+
* An optional {@link RemoteBackendOptions.authToken} rides as
|
|
36
|
+
* `Authorization: Bearer <token>` on EVERY request. The reference `serve()` ignores it (no
|
|
37
|
+
* auth enforced there), so omitting it is harmless against a local/reference server; a separate
|
|
38
|
+
* gated deployment may require it.
|
|
39
|
+
*
|
|
40
|
+
* This module touches NO filesystem and parses NO markdown — like {@link MemoryBackend},
|
|
41
|
+
* it proves the engine leaks no assumptions beyond the {@link StorageBackend} contract.
|
|
42
|
+
*/
|
|
43
|
+
import { DEFAULT_BLOB_CONTENT_TYPE } from "./content-type.js";
|
|
44
|
+
import { InvalidInputError } from "./errors.js";
|
|
45
|
+
import { assertSafeBlobKey, assertSafeConceptId } from "./paths.js";
|
|
46
|
+
import { VersionConflict, stripETagWrapper } from "./version-transport.js";
|
|
47
|
+
/**
|
|
48
|
+
* A non-2xx wire response that is neither a `404` (ENOENT-shaped) nor a `412`
|
|
49
|
+
* ({@link VersionConflict}) — the generic case, carrying the error envelope's `code` alongside
|
|
50
|
+
* the raw HTTP `status`, so a caller can distinguish e.g.
|
|
51
|
+
* `AUTH_REQUIRED` (401, an unauthenticated/misconfigured `--remote`) from `RUNTIME` (5xx, a
|
|
52
|
+
* genuine server-side bug) instead of both collapsing into a generically-classified `Error`.
|
|
53
|
+
*/
|
|
54
|
+
export class RemoteError extends Error {
|
|
55
|
+
/** The envelope's `code` field, or a status-derived guess when the envelope is missing/unparseable. */
|
|
56
|
+
code;
|
|
57
|
+
/** The raw HTTP status that produced this error. */
|
|
58
|
+
status;
|
|
59
|
+
constructor(message, code, status) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "RemoteError";
|
|
62
|
+
this.code = code;
|
|
63
|
+
this.status = status;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** An ENOENT-shaped rejection so missing-document handling matches the local adapters. */
|
|
67
|
+
function notFound(id) {
|
|
68
|
+
const err = new Error(`no concept document '${id}'`);
|
|
69
|
+
err.code = "ENOENT";
|
|
70
|
+
return err;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Extract a version-carrying response's {@link Version} from `X-Version` (primary, edge-proof —
|
|
74
|
+
* see this module's doc comment) or, failing that, a quote/weak-prefix-stripped `ETag`
|
|
75
|
+
* (fallback — `stripETagWrapper` tolerates both a properly quoted `"sha256:..."` and a weak
|
|
76
|
+
* `W/"sha256:..."` form). THROWS a loud `RemoteError` (`VERSION_MISSING`) when a response has
|
|
77
|
+
* NEITHER header, instead of ever silently substituting `""` — the production bug this repair
|
|
78
|
+
* closes. `context` is a short human-readable description of the request (e.g. `GET /docs/{id}`)
|
|
79
|
+
* folded into the error message so the failure is actionable without a debugger.
|
|
80
|
+
*/
|
|
81
|
+
function extractVersion(res, context) {
|
|
82
|
+
const xVersion = res.headers.get("x-version");
|
|
83
|
+
if (xVersion)
|
|
84
|
+
return xVersion;
|
|
85
|
+
const etag = res.headers.get("etag");
|
|
86
|
+
if (etag)
|
|
87
|
+
return stripETagWrapper(etag);
|
|
88
|
+
throw new RemoteError(`wire response for ${context} carried neither an X-Version nor an ETag header — the version ` +
|
|
89
|
+
`is unknown, so compare-and-swap integrity cannot be guaranteed for a subsequent write. ` +
|
|
90
|
+
`Likely cause: an intermediary (e.g. a CDN or compressing proxy) stripped the version ` +
|
|
91
|
+
`header from the response.`, "VERSION_MISSING", res.status);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Reject `expectedVersion === ""` as malformed input BEFORE constructing an `If-Match` header —
|
|
95
|
+
* an empty string is never a valid content-addressed {@link Version} token (see `types.ts`'s
|
|
96
|
+
* `Version` doc comment: `sha256:<hex>`), and if it silently reached `If-Match: ` the write
|
|
97
|
+
* would go out UNCONDITIONAL (an absent/empty CAS guard is last-writer-wins on this seam),
|
|
98
|
+
* reopening the exact silent-CAS-downgrade class this repair exists to close — e.g. if a caller
|
|
99
|
+
* naively passed through a version read via {@link extractVersion} from an OLDER, unpatched
|
|
100
|
+
* server that still emitted the empty-string default. An `InvalidInputError` (not a
|
|
101
|
+
* `RemoteError`): a caller-side input problem discovered before any request is sent, not a
|
|
102
|
+
* wire response to classify.
|
|
103
|
+
*/
|
|
104
|
+
function assertValidExpectedVersion(expectedVersion) {
|
|
105
|
+
if (expectedVersion === "") {
|
|
106
|
+
throw new InvalidInputError("expectedVersion must not be an empty string — pass a real version token, null " +
|
|
107
|
+
"(expect-absent create), or omit the option entirely (unconditional write)");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Transient HTTP statuses worth retrying: 500 (a Cloudflare D1 cold-start surfaces as a 500
|
|
112
|
+
* "storage caused object to be reset" the first time a hibernated database is hit), plus the edge
|
|
113
|
+
* gateway family 502/503/504. Deliberately NARROW — a 4xx (incl. 412 VersionConflict) or 401 is a
|
|
114
|
+
* REAL result, never retried; 501/505 are terminal server bugs, not transient, so also excluded.
|
|
115
|
+
*/
|
|
116
|
+
const RETRIABLE_STATUS = new Set([500, 502, 503, 504]);
|
|
117
|
+
/** Retry-backoff timing: exponential base doubling, capped, with jitter to avoid a thundering herd. */
|
|
118
|
+
const RETRY_BASE_MS = 150;
|
|
119
|
+
const RETRY_CAP_MS = 2000;
|
|
120
|
+
const RETRY_JITTER_MS = 100;
|
|
121
|
+
/** Backoff before retry attempt `n` (0-indexed): min(cap, base * 2^n) + [0, jitter). */
|
|
122
|
+
function retryDelayMs(attempt) {
|
|
123
|
+
const backoff = Math.min(RETRY_CAP_MS, RETRY_BASE_MS * 2 ** attempt);
|
|
124
|
+
return backoff + Math.floor(Math.random() * RETRY_JITTER_MS);
|
|
125
|
+
}
|
|
126
|
+
/** Resolve after `ms` milliseconds. */
|
|
127
|
+
function delay(ms) {
|
|
128
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
129
|
+
}
|
|
130
|
+
/** URL-encode a concept id's path segments individually, preserving `/` as the separator. */
|
|
131
|
+
function encodeId(id) {
|
|
132
|
+
return id
|
|
133
|
+
.split("/")
|
|
134
|
+
.map((seg) => encodeURIComponent(seg))
|
|
135
|
+
.join("/");
|
|
136
|
+
}
|
|
137
|
+
/** URL-encode a blob key's path segments individually, preserving `/` as the separator. */
|
|
138
|
+
function encodeBlobKey(key) {
|
|
139
|
+
return key
|
|
140
|
+
.split("/")
|
|
141
|
+
.map((seg) => encodeURIComponent(seg))
|
|
142
|
+
.join("/");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The client half of the wire-protocol v0 contract: implements {@link StorageBackend}
|
|
146
|
+
* over HTTP against a `@superbee/server` instance (or any conformant
|
|
147
|
+
* implementation of `docs/WIRE-PROTOCOL.md`).
|
|
148
|
+
*/
|
|
149
|
+
export class RemoteBackend {
|
|
150
|
+
baseUrl;
|
|
151
|
+
bundle;
|
|
152
|
+
fetchImpl;
|
|
153
|
+
authToken;
|
|
154
|
+
maxRetries;
|
|
155
|
+
constructor(options) {
|
|
156
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
157
|
+
this.bundle = options.bundle;
|
|
158
|
+
this.fetchImpl = options.fetchImpl ?? ((request) => globalThis.fetch(request));
|
|
159
|
+
this.authToken = options.authToken;
|
|
160
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
161
|
+
}
|
|
162
|
+
/** Build the absolute URL for a bundle-relative wire path (e.g. `/docs/concepts/x`). */
|
|
163
|
+
url(bundleRelativePath) {
|
|
164
|
+
return `${this.baseUrl}/v0/bundles/${encodeURIComponent(this.bundle)}${bundleRelativePath}`;
|
|
165
|
+
}
|
|
166
|
+
async send(bundleRelativePath, init = {}) {
|
|
167
|
+
// Attach Authorization on EVERY request when an authToken is configured — the reference
|
|
168
|
+
// server ignores the header (no auth enforced), while a separate gated deployment may
|
|
169
|
+
// require it. Merged onto any caller-supplied headers rather than overwriting `init`.
|
|
170
|
+
if (this.authToken) {
|
|
171
|
+
const headers = new Headers(init.headers);
|
|
172
|
+
headers.set("Authorization", `Bearer ${this.authToken}`);
|
|
173
|
+
init = { ...init, headers };
|
|
174
|
+
}
|
|
175
|
+
const url = this.url(bundleRelativePath);
|
|
176
|
+
// Retry TRANSIENT failures — a transient 5xx (notably a Cloudflare D1 cold-start's 500 "storage
|
|
177
|
+
// object reset" when a hibernated database is first hit; also 502/503/504 from the edge) or a
|
|
178
|
+
// network/transport error — with exponential backoff + jitter, so a hibernated-backend hiccup is
|
|
179
|
+
// transparent instead of a hard failure. A REAL result (2xx, or 4xx incl. 412 VersionConflict,
|
|
180
|
+
// or 401) returns/throws immediately, never retried. Safe because every op is content-addressed
|
|
181
|
+
// + CAS: a retried write lands the same version or a conflict (possibly SPURIOUS — a prior
|
|
182
|
+
// attempt may have committed before its response was lost — but never silent data loss); a
|
|
183
|
+
// retried read is idempotent. `send` rebuilds the Request per attempt from `init` (bodies are
|
|
184
|
+
// strings/bytes, so reusable — no consumed-stream hazard).
|
|
185
|
+
for (let attempt = 0;; attempt++) {
|
|
186
|
+
try {
|
|
187
|
+
const res = await this.fetchImpl(new Request(url, init));
|
|
188
|
+
if (RETRIABLE_STATUS.has(res.status) && attempt < this.maxRetries) {
|
|
189
|
+
await delay(retryDelayMs(attempt));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
return res;
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
if (attempt < this.maxRetries) {
|
|
196
|
+
await delay(retryDelayMs(attempt));
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Parse a non-2xx response into the typed error the engine expects. */
|
|
204
|
+
async toError(res, fallbackId) {
|
|
205
|
+
let envelope = null;
|
|
206
|
+
try {
|
|
207
|
+
envelope = (await res.json());
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
envelope = null;
|
|
211
|
+
}
|
|
212
|
+
if (res.status === 412) {
|
|
213
|
+
const expected = envelope?.error?.details?.expected ?? null;
|
|
214
|
+
const actual = envelope?.error?.details?.actual ?? null;
|
|
215
|
+
return new VersionConflict(fallbackId, expected, actual);
|
|
216
|
+
}
|
|
217
|
+
const message = envelope?.error?.message ?? `wire request failed with status ${res.status}`;
|
|
218
|
+
// The envelope's own `code` wins when present (every route in this repo's servers emits
|
|
219
|
+
// one); a status-derived guess covers a malformed/absent envelope or a conformant-but-
|
|
220
|
+
// foreign server that doesn't populate `code`.
|
|
221
|
+
const code = envelope?.error?.code ?? (res.status === 401 ? "AUTH_REQUIRED" : res.status >= 500 ? "RUNTIME" : "USAGE");
|
|
222
|
+
return new RemoteError(message, code, res.status);
|
|
223
|
+
}
|
|
224
|
+
async read(id) {
|
|
225
|
+
assertSafeConceptId(id);
|
|
226
|
+
const res = await this.send(`/docs/${encodeId(id)}`, { method: "GET" });
|
|
227
|
+
if (res.status === 404)
|
|
228
|
+
throw notFound(id);
|
|
229
|
+
if (!res.ok)
|
|
230
|
+
throw await this.toError(res, id);
|
|
231
|
+
const version = extractVersion(res, `GET /docs/${id}`);
|
|
232
|
+
const payload = (await res.json());
|
|
233
|
+
// The requested route key owns identity. A foreign or buggy server payload must not rename
|
|
234
|
+
// the document the caller asked for (the same rule FilesystemBackend/MemoryBackend enforce).
|
|
235
|
+
return { doc: { id, frontmatter: payload.frontmatter, body: payload.body }, version };
|
|
236
|
+
}
|
|
237
|
+
async readMany(ids) {
|
|
238
|
+
for (const id of ids)
|
|
239
|
+
assertSafeConceptId(id);
|
|
240
|
+
if (ids.length === 0)
|
|
241
|
+
return [];
|
|
242
|
+
const res = await this.send("/docs:read-many", {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "content-type": "application/json" },
|
|
245
|
+
body: JSON.stringify({ ids }),
|
|
246
|
+
});
|
|
247
|
+
if (res.status === 404) {
|
|
248
|
+
let missing = [];
|
|
249
|
+
try {
|
|
250
|
+
const envelope = (await res.json());
|
|
251
|
+
missing = envelope.error.details?.missing ?? [];
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
/* fall through with an empty missing set */
|
|
255
|
+
}
|
|
256
|
+
throw notFound(missing[0] ?? ids[0]);
|
|
257
|
+
}
|
|
258
|
+
if (!res.ok)
|
|
259
|
+
throw await this.toError(res, ids[0] ?? "");
|
|
260
|
+
const payload = (await res.json());
|
|
261
|
+
if (payload.results.length !== ids.length) {
|
|
262
|
+
throw new RemoteError(`wire read-many returned ${payload.results.length} result(s) for ${ids.length} requested id(s)`, "RUNTIME", 502);
|
|
263
|
+
}
|
|
264
|
+
return payload.results.map((r, index) => ({
|
|
265
|
+
// `readMany`'s positional contract pairs each payload with the requested id at that index;
|
|
266
|
+
// never trust a redundant payload id to redefine the route-owned identity.
|
|
267
|
+
doc: { id: ids[index], frontmatter: r.frontmatter, body: r.body },
|
|
268
|
+
version: r.version,
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
async write(id, doc, options = {}) {
|
|
272
|
+
assertSafeConceptId(id);
|
|
273
|
+
assertValidExpectedVersion(options.expectedVersion);
|
|
274
|
+
const headers = { "content-type": "application/json" };
|
|
275
|
+
if (options.expectedVersion === null)
|
|
276
|
+
headers["If-None-Match"] = "*";
|
|
277
|
+
else if (options.expectedVersion !== undefined)
|
|
278
|
+
headers["If-Match"] = options.expectedVersion;
|
|
279
|
+
if (options.actor)
|
|
280
|
+
headers["X-Actor"] = options.actor;
|
|
281
|
+
const res = await this.send(`/docs/${encodeId(id)}`, {
|
|
282
|
+
method: "PUT",
|
|
283
|
+
headers,
|
|
284
|
+
body: JSON.stringify({ frontmatter: doc.frontmatter, body: doc.body ?? "" }),
|
|
285
|
+
});
|
|
286
|
+
if (!res.ok)
|
|
287
|
+
throw await this.toError(res, id);
|
|
288
|
+
const payload = (await res.json());
|
|
289
|
+
return payload.version;
|
|
290
|
+
}
|
|
291
|
+
async exists(id) {
|
|
292
|
+
assertSafeConceptId(id);
|
|
293
|
+
const res = await this.send(`/docs/${encodeId(id)}`, { method: "HEAD" });
|
|
294
|
+
if (res.status === 404)
|
|
295
|
+
return false;
|
|
296
|
+
if (!res.ok)
|
|
297
|
+
throw await this.toError(res, id);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* THE `GET /docs` cursor pager — the pagination contract (the `cursor` param, the
|
|
302
|
+
* `{ docs, next_cursor }` envelope) exists ONCE, here; {@link RemoteBackend.list} and
|
|
303
|
+
* {@link RemoteBackend.queryHeads} are both thin row-mappings over it, so a wire
|
|
304
|
+
* pagination change cannot make the two scans silently paginate differently.
|
|
305
|
+
*/
|
|
306
|
+
async pageDocs(baseParams, mapRow, errorContext) {
|
|
307
|
+
const rows = [];
|
|
308
|
+
let cursor;
|
|
309
|
+
for (;;) {
|
|
310
|
+
const params = new URLSearchParams(baseParams);
|
|
311
|
+
if (cursor)
|
|
312
|
+
params.set("cursor", cursor);
|
|
313
|
+
const qs = params.toString();
|
|
314
|
+
const res = await this.send(`/docs${qs ? `?${qs}` : ""}`, { method: "GET" });
|
|
315
|
+
if (!res.ok)
|
|
316
|
+
throw await this.toError(res, errorContext);
|
|
317
|
+
const payload = (await res.json());
|
|
318
|
+
for (const row of payload.docs)
|
|
319
|
+
rows.push(mapRow(row));
|
|
320
|
+
if (!payload.next_cursor)
|
|
321
|
+
break;
|
|
322
|
+
cursor = payload.next_cursor;
|
|
323
|
+
}
|
|
324
|
+
return rows;
|
|
325
|
+
}
|
|
326
|
+
async list(prefix) {
|
|
327
|
+
const params = new URLSearchParams();
|
|
328
|
+
if (prefix)
|
|
329
|
+
params.set("prefix", prefix);
|
|
330
|
+
return this.pageDocs(params, (row) => row.id, prefix ?? "");
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The seam's OPTIONAL head-projection push-down, over the SAME `GET /docs` route
|
|
334
|
+
* `list()` pages through — with `fields=frontmatter` (the wire's full-frontmatter
|
|
335
|
+
* projection, in the protocol since v0) plus the `prefix`/`type`/repeated-`tag`
|
|
336
|
+
* filter params the reference router evaluates server-side. A filtered scan therefore
|
|
337
|
+
* crosses the wire as thin frontmatter rows: NO bodies, and non-matching docs never
|
|
338
|
+
* leave the server. {@link QueryFilter.fields} equality is NOT pushed (the wire's
|
|
339
|
+
* `fields` param is the projection selector — a recorded name collision, see
|
|
340
|
+
* `docs/WIRE-PROTOCOL.md`); the engine's `queryHeads` re-filter covers it, per the
|
|
341
|
+
* seam contract (over-returning is fine; semantics live in core).
|
|
342
|
+
*/
|
|
343
|
+
async queryHeads(filter = {}) {
|
|
344
|
+
const params = new URLSearchParams();
|
|
345
|
+
params.set("fields", "frontmatter");
|
|
346
|
+
if (filter.prefix)
|
|
347
|
+
params.set("prefix", filter.prefix);
|
|
348
|
+
if (filter.type)
|
|
349
|
+
params.set("type", filter.type);
|
|
350
|
+
for (const tag of filter.tags ?? [])
|
|
351
|
+
params.append("tag", tag);
|
|
352
|
+
return this.pageDocs(params, (row) => ({ id: row.id, frontmatter: row.frontmatter, version: row.version }), filter.prefix ?? "");
|
|
353
|
+
}
|
|
354
|
+
async versions(id) {
|
|
355
|
+
assertSafeConceptId(id);
|
|
356
|
+
const res = await this.send(`/docs/${encodeId(id)}/versions`, { method: "GET" });
|
|
357
|
+
if (!res.ok)
|
|
358
|
+
throw await this.toError(res, id);
|
|
359
|
+
// Parse `agent` explicitly (defensive against foreign/extra fields) rather than trusting
|
|
360
|
+
// the wire payload's shape to already BE `VersionInfo[]` — and omit it from the returned
|
|
361
|
+
// entry (not merely set it to `undefined`) when the server didn't record one.
|
|
362
|
+
const payload = (await res.json());
|
|
363
|
+
return payload.versions.map((v) => v.agent === undefined
|
|
364
|
+
? { version: v.version, actor: v.actor, timestamp: v.timestamp }
|
|
365
|
+
: { version: v.version, actor: v.actor, timestamp: v.timestamp, agent: v.agent });
|
|
366
|
+
}
|
|
367
|
+
async readReserved(dir, name) {
|
|
368
|
+
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
|
369
|
+
const res = await this.send(`/reserved/${name}${qs}`, { method: "GET" });
|
|
370
|
+
if (res.status === 404)
|
|
371
|
+
return null;
|
|
372
|
+
if (!res.ok)
|
|
373
|
+
throw await this.toError(res, `${dir}/${name}`);
|
|
374
|
+
const version = extractVersion(res, `GET /reserved/${name}`);
|
|
375
|
+
const payload = (await res.json());
|
|
376
|
+
return { content: payload.content, version };
|
|
377
|
+
}
|
|
378
|
+
async writeReserved(dir, name, content, options = {}) {
|
|
379
|
+
assertValidExpectedVersion(options.expectedVersion);
|
|
380
|
+
const headers = { "content-type": "application/json" };
|
|
381
|
+
if (options.expectedVersion === null)
|
|
382
|
+
headers["If-None-Match"] = "*";
|
|
383
|
+
else if (options.expectedVersion !== undefined)
|
|
384
|
+
headers["If-Match"] = options.expectedVersion;
|
|
385
|
+
if (options.actor)
|
|
386
|
+
headers["X-Actor"] = options.actor;
|
|
387
|
+
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
|
388
|
+
const res = await this.send(`/reserved/${name}${qs}`, {
|
|
389
|
+
method: "PUT",
|
|
390
|
+
headers,
|
|
391
|
+
body: JSON.stringify({ content }),
|
|
392
|
+
});
|
|
393
|
+
if (!res.ok)
|
|
394
|
+
throw await this.toError(res, `${dir}/${name}`);
|
|
395
|
+
const payload = (await res.json());
|
|
396
|
+
return payload.version;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* `DELETE /docs/{id}`, `If-Match: <expectedVersion>` when given (no unwrapped/null branch
|
|
400
|
+
* to send — {@link DeleteOptions} carries no expect-absent reading, unlike
|
|
401
|
+
* {@link WriteOptions}) and NO `X-Actor` (a delete records no new revision to attribute).
|
|
402
|
+
* `assertValidExpectedVersion` rejects `""` the same way `write` does. There is NO `404`
|
|
403
|
+
* branch: an absent target is a normal `200 { deleted: false }` response by wire contract
|
|
404
|
+
* (idempotency, AXI P6), never a rejection — `!res.ok` still routes a `412` through
|
|
405
|
+
* `toError`'s existing `VersionConflict` reconstruction, and anything else through
|
|
406
|
+
* `RemoteError`.
|
|
407
|
+
*/
|
|
408
|
+
async delete(id, options = {}) {
|
|
409
|
+
assertSafeConceptId(id);
|
|
410
|
+
assertValidExpectedVersion(options.expectedVersion);
|
|
411
|
+
const headers = {};
|
|
412
|
+
if (options.expectedVersion !== undefined)
|
|
413
|
+
headers["If-Match"] = options.expectedVersion;
|
|
414
|
+
const res = await this.send(`/docs/${encodeId(id)}`, { method: "DELETE", headers });
|
|
415
|
+
if (!res.ok)
|
|
416
|
+
throw await this.toError(res, id);
|
|
417
|
+
const payload = (await res.json());
|
|
418
|
+
return payload.deleted;
|
|
419
|
+
}
|
|
420
|
+
// ── blobs: opaque bytes served by content-type (wire-protocol v0.1) ──────────
|
|
421
|
+
//
|
|
422
|
+
// Bytes cross the wire as the RAW request/response body — never JSON (B1): PUT
|
|
423
|
+
// sends a `Uint8Array` directly as `BodyInit`, GET reads back via `arrayBuffer()`.
|
|
424
|
+
// No `Buffer` anywhere in this module, so it stays browser/edge-runtime compatible.
|
|
425
|
+
// Content-type rides `Content-Type`; the version rides `X-Version`/`ETag` (extractVersion),
|
|
426
|
+
// exactly like docs.
|
|
427
|
+
async readBlob(key) {
|
|
428
|
+
assertSafeBlobKey(key);
|
|
429
|
+
const res = await this.send(`/blobs/${encodeBlobKey(key)}`, { method: "GET" });
|
|
430
|
+
if (res.status === 404)
|
|
431
|
+
return null;
|
|
432
|
+
if (!res.ok)
|
|
433
|
+
throw await this.toError(res, key);
|
|
434
|
+
const version = extractVersion(res, `GET /blobs/${key}`);
|
|
435
|
+
const contentType = res.headers.get("content-type") ?? DEFAULT_BLOB_CONTENT_TYPE;
|
|
436
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
437
|
+
return { bytes, contentType, version };
|
|
438
|
+
}
|
|
439
|
+
async writeBlob(key, bytes, contentType, options = {}) {
|
|
440
|
+
assertSafeBlobKey(key);
|
|
441
|
+
assertValidExpectedVersion(options.expectedVersion);
|
|
442
|
+
const headers = {};
|
|
443
|
+
if (contentType)
|
|
444
|
+
headers["content-type"] = contentType;
|
|
445
|
+
if (options.expectedVersion === null)
|
|
446
|
+
headers["If-None-Match"] = "*";
|
|
447
|
+
else if (options.expectedVersion !== undefined)
|
|
448
|
+
headers["If-Match"] = options.expectedVersion;
|
|
449
|
+
if (options.actor)
|
|
450
|
+
headers["X-Actor"] = options.actor;
|
|
451
|
+
const res = await this.send(`/blobs/${encodeBlobKey(key)}`, {
|
|
452
|
+
method: "PUT",
|
|
453
|
+
headers,
|
|
454
|
+
body: bytes,
|
|
455
|
+
});
|
|
456
|
+
if (!res.ok)
|
|
457
|
+
throw await this.toError(res, key);
|
|
458
|
+
const payload = (await res.json());
|
|
459
|
+
return payload.version;
|
|
460
|
+
}
|
|
461
|
+
/** `DELETE /blobs/{key}`, mirroring `delete`'s `If-Match`/no-404/no-actor posture exactly. */
|
|
462
|
+
async deleteBlob(key, options = {}) {
|
|
463
|
+
assertSafeBlobKey(key);
|
|
464
|
+
assertValidExpectedVersion(options.expectedVersion);
|
|
465
|
+
const headers = {};
|
|
466
|
+
if (options.expectedVersion !== undefined)
|
|
467
|
+
headers["If-Match"] = options.expectedVersion;
|
|
468
|
+
const res = await this.send(`/blobs/${encodeBlobKey(key)}`, { method: "DELETE", headers });
|
|
469
|
+
if (!res.ok)
|
|
470
|
+
throw await this.toError(res, key);
|
|
471
|
+
const payload = (await res.json());
|
|
472
|
+
return payload.deleted;
|
|
473
|
+
}
|
|
474
|
+
async existsBlob(key) {
|
|
475
|
+
assertSafeBlobKey(key);
|
|
476
|
+
const res = await this.send(`/blobs/${encodeBlobKey(key)}`, { method: "HEAD" });
|
|
477
|
+
if (res.status === 404)
|
|
478
|
+
return false;
|
|
479
|
+
if (!res.ok)
|
|
480
|
+
throw await this.toError(res, key);
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
async listBlobs(prefix) {
|
|
484
|
+
const keys = [];
|
|
485
|
+
let cursor;
|
|
486
|
+
for (;;) {
|
|
487
|
+
const params = new URLSearchParams();
|
|
488
|
+
if (prefix)
|
|
489
|
+
params.set("prefix", prefix);
|
|
490
|
+
if (cursor)
|
|
491
|
+
params.set("cursor", cursor);
|
|
492
|
+
const qs = params.toString();
|
|
493
|
+
const res = await this.send(`/blobs${qs ? `?${qs}` : ""}`, { method: "GET" });
|
|
494
|
+
if (!res.ok)
|
|
495
|
+
throw await this.toError(res, prefix ?? "");
|
|
496
|
+
const payload = (await res.json());
|
|
497
|
+
keys.push(...payload.keys);
|
|
498
|
+
if (!payload.next_cursor)
|
|
499
|
+
break;
|
|
500
|
+
cursor = payload.next_cursor;
|
|
501
|
+
}
|
|
502
|
+
return keys;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
//# sourceMappingURL=remote-backend.js.map
|