@superbee/server 0.1.6 → 0.2.0-pre.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/legacy-router.d.ts +11 -2
- package/dist/legacy-router.d.ts.map +1 -1
- package/dist/legacy-router.js +10 -6
- package/dist/legacy-router.js.map +1 -1
- package/dist/operation-outcomes.d.ts +98 -0
- package/dist/operation-outcomes.d.ts.map +1 -0
- package/dist/operation-outcomes.js +122 -0
- package/dist/operation-outcomes.js.map +1 -0
- package/dist/router.d.ts +32 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +448 -24
- package/dist/router.js.map +1 -1
- package/dist/serve.d.ts +12 -1
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +24 -4
- package/dist/serve.js.map +1 -1
- package/package.json +7 -3
package/dist/router.js
CHANGED
|
@@ -16,10 +16,80 @@
|
|
|
16
16
|
* See `docs/WIRE-PROTOCOL.md` "Implemented by reference" section for the exact
|
|
17
17
|
* endpoint set and any recorded deviations from the draft.
|
|
18
18
|
*/
|
|
19
|
-
import { InvalidInputError, VersionConflict, assertSafeBlobKey, assertSafeConceptId, assertSafeReservedDir, isReservedFile, pathFromConceptId, stripETagWrapper, } from "@superbee/core/storage";
|
|
19
|
+
import { InvalidInputError, MalformedDocumentError, VersionConflict, assertSafeBlobKey, assertSafeConceptId, assertSafeReservedDir, headsDigest, sortHeads, isReservedFile, isContentVersion, pathFromConceptId, stripETagWrapper, } from "@superbee/core/storage";
|
|
20
20
|
import { queryHeads, writeDocVersioned } from "@superbee/core/engine";
|
|
21
|
+
import { isRequestIdentity } from "@superbee/core/storage";
|
|
22
|
+
import { captureRemoteFrontmatter, RemoteDocumentValueError } from "@superbee/core/document-codec";
|
|
21
23
|
/** Default page size for `GET /docs` when `limit` is not supplied. */
|
|
22
24
|
const DEFAULT_LIST_LIMIT = 50;
|
|
25
|
+
/** Documents read per backend batch while a snapshot streams; one NDJSON chunk per batch. */
|
|
26
|
+
const SNAPSHOT_BATCH_SIZE = 50;
|
|
27
|
+
/** The snapshot body's media type: one JSON object per line (`docs/WIRE-PROTOCOL.md`, "Heads and snapshot"). */
|
|
28
|
+
const NDJSON_CONTENT_TYPE = "application/x-ndjson; charset=utf-8";
|
|
29
|
+
/** The header that carries a document write's durable request identity (`docs/WIRE-PROTOCOL.md`, "Identified writes"). */
|
|
30
|
+
const IDENTITY_HEADER = "Idempotency-Key";
|
|
31
|
+
/** The only endpoints that accept {@link IDENTITY_HEADER}; every other endpoint refuses it as `400 USAGE`. */
|
|
32
|
+
const IDENTIFIED_ENDPOINTS = new Set(["doc-write", "doc-delete"]);
|
|
33
|
+
/** The one answer for identity on a host that records no outcomes; a client must never mistake it for silent acceptance. */
|
|
34
|
+
const IDENTITY_UNSUPPORTED = "request identity is not supported by this host";
|
|
35
|
+
/** A request identity from an `operations/{key}` path segment: decoded once, then held to the wire's key rule. */
|
|
36
|
+
function decodeOperationKey(rawSegment) {
|
|
37
|
+
let key;
|
|
38
|
+
try {
|
|
39
|
+
key = decodeURIComponent(rawSegment);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new InvalidInputError(`invalid percent-encoding in operation key '${rawSegment}'`);
|
|
43
|
+
}
|
|
44
|
+
assertRequestIdentity(key);
|
|
45
|
+
return key;
|
|
46
|
+
}
|
|
47
|
+
function assertRequestIdentity(key) {
|
|
48
|
+
if (!isRequestIdentity(key)) {
|
|
49
|
+
throw new InvalidInputError("Idempotency-Key must be 1 to 128 printable ASCII characters with no space");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Capture a response so it can be recorded and replayed verbatim to a duplicate submission. */
|
|
53
|
+
async function recordableResponse(response) {
|
|
54
|
+
const headers = [];
|
|
55
|
+
response.headers.forEach((value, name) => headers.push([name, value]));
|
|
56
|
+
return { status: response.status, headers, body: await response.text() };
|
|
57
|
+
}
|
|
58
|
+
function replayResponse(recorded) {
|
|
59
|
+
return new Response(recorded.body, { status: recorded.status, headers: recorded.headers });
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The outcome a recorded response reports through the lookup route: a success is `committed`
|
|
63
|
+
* at the version the response carried, a `412` is `conflict` at the envelope's `actual`, and
|
|
64
|
+
* anything else is `refused` with the envelope's code. The same mapping a client applies to a
|
|
65
|
+
* live response, so a looked-up outcome and a replayed response never disagree.
|
|
66
|
+
*/
|
|
67
|
+
function outcomeOf(recorded) {
|
|
68
|
+
let payload = {};
|
|
69
|
+
try {
|
|
70
|
+
payload = JSON.parse(recorded.body);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
payload = {};
|
|
74
|
+
}
|
|
75
|
+
if (recorded.status === 200 || recorded.status === 201) {
|
|
76
|
+
const version = recorded.headers.find(([name]) => name.toLowerCase() === "x-version")?.[1];
|
|
77
|
+
if (!version)
|
|
78
|
+
throw new Error("an identified write produced a success response that carries no version");
|
|
79
|
+
return { kind: "committed", version };
|
|
80
|
+
}
|
|
81
|
+
if (recorded.status === 412) {
|
|
82
|
+
const actual = payload.error?.details?.actual;
|
|
83
|
+
return { kind: "conflict", actual: typeof actual === "string" ? actual : null };
|
|
84
|
+
}
|
|
85
|
+
const code = payload.error?.code;
|
|
86
|
+
const message = payload.error?.message;
|
|
87
|
+
return {
|
|
88
|
+
kind: "refused",
|
|
89
|
+
code: typeof code === "string" ? code : "RUNTIME",
|
|
90
|
+
message: typeof message === "string" ? message : `status ${recorded.status}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
23
93
|
/** True when `err` carries the `ENOENT`-shaped `.code` the seam's adapters use for "absent". */
|
|
24
94
|
function isEnoent(err) {
|
|
25
95
|
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
@@ -60,11 +130,42 @@ function decodeBlobKey(rawPathTail) {
|
|
|
60
130
|
* principle 4: OKF/id-safety invariants are enforced server-side too).
|
|
61
131
|
*/
|
|
62
132
|
function assertValidDocId(id) {
|
|
133
|
+
if (hasControlCharacter(id)) {
|
|
134
|
+
throw new InvalidInputError("document ids cannot contain control characters");
|
|
135
|
+
}
|
|
63
136
|
assertSafeConceptId(id);
|
|
64
137
|
if (isReservedFile(pathFromConceptId(id))) {
|
|
65
138
|
throw new InvalidInputError(`'${id}' is a reserved file, not a concept document`);
|
|
66
139
|
}
|
|
67
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* True when `id` holds any code unit below U+0020 or equal to U+007F. The core id rule admits
|
|
143
|
+
* such characters (a local bundle may hold `"line\nbreak"`), but the heads digest concatenates
|
|
144
|
+
* `id`, `\n`, `version`, `\n` per row and is injective only when no id contains a line feed. The
|
|
145
|
+
* wire therefore refuses the whole control range on every document route
|
|
146
|
+
* ({@link assertValidDocId}) and refuses to list such an id ({@link UnservableIdError}), so no
|
|
147
|
+
* digest is ever minted over a listing the recipe cannot tell apart from another.
|
|
148
|
+
*/
|
|
149
|
+
function hasControlCharacter(id) {
|
|
150
|
+
for (let i = 0; i < id.length; i += 1) {
|
|
151
|
+
const unit = id.charCodeAt(i);
|
|
152
|
+
if (unit < 0x20 || unit === 0x7f)
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* A bundle written outside the wire holds a document id the wire cannot serve through heads or
|
|
159
|
+
* snapshot (see {@link hasControlCharacter}). Mapped to `500 RUNTIME` with this message: the
|
|
160
|
+
* request is well formed, the bundle is what cannot be served, and the listing fails closed
|
|
161
|
+
* rather than minting a non-injective digest.
|
|
162
|
+
*/
|
|
163
|
+
class UnservableIdError extends Error {
|
|
164
|
+
constructor() {
|
|
165
|
+
super("bundle holds a document id the wire cannot serve");
|
|
166
|
+
this.name = "UnservableIdError";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
68
169
|
/** Build a JSON `Response` with the standard `content-type`, merging in any extra headers. */
|
|
69
170
|
function jsonResponse(status, body, headers = {}) {
|
|
70
171
|
return new Response(JSON.stringify(body), {
|
|
@@ -91,9 +192,24 @@ function errorFromCaught(err) {
|
|
|
91
192
|
if (isEnoent(err)) {
|
|
92
193
|
return errorResponse(404, "NOT_FOUND", "document or blob not found");
|
|
93
194
|
}
|
|
195
|
+
if (err instanceof RemoteDocumentValueError) {
|
|
196
|
+
return errorResponse(500, "RUNTIME", err.message);
|
|
197
|
+
}
|
|
94
198
|
if (err instanceof InvalidInputError) {
|
|
95
199
|
return errorResponse(400, "USAGE", err.message);
|
|
96
200
|
}
|
|
201
|
+
if (err instanceof UnservableIdError) {
|
|
202
|
+
return errorResponse(500, "RUNTIME", err.message);
|
|
203
|
+
}
|
|
204
|
+
if (err instanceof MalformedDocumentError) {
|
|
205
|
+
// The request is well formed; the stored document is what cannot be served. Naming it lets
|
|
206
|
+
// a client report the one document to fix instead of an opaque server failure.
|
|
207
|
+
// Backends attribute the error to the document's path or its id; the wire names the id.
|
|
208
|
+
const id = err.context === undefined ? undefined : err.context.endsWith(".md") ? err.context.slice(0, -3) : err.context;
|
|
209
|
+
return errorResponse(500, "RUNTIME", err.message, {
|
|
210
|
+
malformed: { ...(id === undefined ? {} : { id }), reason: err.detail },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
97
213
|
return errorResponse(500, "RUNTIME", "internal server error");
|
|
98
214
|
}
|
|
99
215
|
/**
|
|
@@ -170,6 +286,20 @@ export const WIRE_ENDPOINTS = [
|
|
|
170
286
|
path: "/v0/bundles/{bundle}/docs:read-many",
|
|
171
287
|
accessClass: "read",
|
|
172
288
|
},
|
|
289
|
+
{
|
|
290
|
+
id: "docs-heads",
|
|
291
|
+
resource: "docs-heads",
|
|
292
|
+
method: "GET",
|
|
293
|
+
path: "/v0/bundles/{bundle}/heads",
|
|
294
|
+
accessClass: "read",
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: "docs-snapshot",
|
|
298
|
+
resource: "docs-snapshot",
|
|
299
|
+
method: "GET",
|
|
300
|
+
path: "/v0/bundles/{bundle}/snapshot",
|
|
301
|
+
accessClass: "read",
|
|
302
|
+
},
|
|
173
303
|
{
|
|
174
304
|
id: "doc-read",
|
|
175
305
|
resource: "doc",
|
|
@@ -254,6 +384,13 @@ export const WIRE_ENDPOINTS = [
|
|
|
254
384
|
path: "/v0/bundles/{bundle}/blobs/{key...}",
|
|
255
385
|
accessClass: "write",
|
|
256
386
|
},
|
|
387
|
+
{
|
|
388
|
+
id: "operation-lookup",
|
|
389
|
+
resource: "operation",
|
|
390
|
+
method: "GET",
|
|
391
|
+
path: "/v0/bundles/{bundle}/operations/{key}",
|
|
392
|
+
accessClass: "write",
|
|
393
|
+
},
|
|
257
394
|
];
|
|
258
395
|
/** Typed resolution failure retained as a stable host-side rejection seam. */
|
|
259
396
|
export class WireRequestResolutionError extends Error {
|
|
@@ -312,6 +449,10 @@ function resourceFromMatch(resource, params, searchParams) {
|
|
|
312
449
|
return { kind: "docs" };
|
|
313
450
|
case "docs-read-many":
|
|
314
451
|
return { kind: "docs-read-many" };
|
|
452
|
+
case "docs-heads":
|
|
453
|
+
return { kind: "docs-heads" };
|
|
454
|
+
case "docs-snapshot":
|
|
455
|
+
return { kind: "docs-snapshot" };
|
|
315
456
|
case "doc": {
|
|
316
457
|
const id = decodeId(params.id);
|
|
317
458
|
assertValidDocId(id);
|
|
@@ -338,6 +479,8 @@ function resourceFromMatch(resource, params, searchParams) {
|
|
|
338
479
|
assertSafeBlobKey(key);
|
|
339
480
|
return { kind: "blob", key };
|
|
340
481
|
}
|
|
482
|
+
case "operation":
|
|
483
|
+
return { kind: "operation", key: decodeOperationKey(params.key) };
|
|
341
484
|
}
|
|
342
485
|
}
|
|
343
486
|
function invalidBundleFromPath(pathname) {
|
|
@@ -423,6 +566,12 @@ function routeLabel(resource) {
|
|
|
423
566
|
case "docs-read-many":
|
|
424
567
|
label = "/docs:read-many";
|
|
425
568
|
break;
|
|
569
|
+
case "docs-heads":
|
|
570
|
+
label = "/heads";
|
|
571
|
+
break;
|
|
572
|
+
case "docs-snapshot":
|
|
573
|
+
label = "/snapshot";
|
|
574
|
+
break;
|
|
426
575
|
case "doc":
|
|
427
576
|
case "doc-versions":
|
|
428
577
|
label = "a doc route";
|
|
@@ -436,6 +585,9 @@ function routeLabel(resource) {
|
|
|
436
585
|
case "blob":
|
|
437
586
|
label = "a blob route";
|
|
438
587
|
break;
|
|
588
|
+
case "operation":
|
|
589
|
+
label = "an operation route";
|
|
590
|
+
break;
|
|
439
591
|
case "capabilities":
|
|
440
592
|
label = "/v0/capabilities";
|
|
441
593
|
break;
|
|
@@ -496,7 +648,7 @@ function buildRouter(options) {
|
|
|
496
648
|
assertValidDocId(id);
|
|
497
649
|
try {
|
|
498
650
|
const { doc, version } = await backend.read(id);
|
|
499
|
-
return jsonResponse(200, { id: doc.id, frontmatter: doc.frontmatter, body: doc.body }, versionHeaders(version));
|
|
651
|
+
return jsonResponse(200, { id: doc.id, frontmatter: captureRemoteFrontmatter(doc.frontmatter), body: doc.body }, versionHeaders(version));
|
|
500
652
|
}
|
|
501
653
|
catch (err) {
|
|
502
654
|
if (isEnoent(err))
|
|
@@ -551,12 +703,98 @@ function buildRouter(options) {
|
|
|
551
703
|
* catch-all (`VersionConflict` -> `412` via `errorFromCaught`, same as a write). `200
|
|
552
704
|
* { deleted }` unconditionally — never a `404`, matching the wire's absence-is-success
|
|
553
705
|
* contract (AXI P6).
|
|
706
|
+
*
|
|
707
|
+
* A delete that carried `If-Match` answers with the version headers naming that token: the
|
|
708
|
+
* revision the delete acted on, and the version an identified delete's recorded outcome is
|
|
709
|
+
* committed at. When the target was already absent the delete is still the idempotent success
|
|
710
|
+
* the wire promises, and the token the client supplied is still the revision its outcome
|
|
711
|
+
* names. An unconditional delete has no revision to name and sends no version headers.
|
|
554
712
|
*/
|
|
555
713
|
async function handleDeleteDoc(backend, id, req) {
|
|
556
714
|
assertValidDocId(id);
|
|
557
715
|
const options = deleteOptionsFromHeaders(req);
|
|
558
716
|
const deleted = await backend.delete(id, options);
|
|
559
|
-
|
|
717
|
+
const headers = options.expectedVersion === undefined ? {} : versionHeaders(options.expectedVersion);
|
|
718
|
+
return jsonResponse(200, { deleted }, headers);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Apply one identified write at most once under `key`. The key is claimed before `apply`
|
|
722
|
+
* runs; a duplicate that finds the key recorded replays the recorded response, and one that
|
|
723
|
+
* finds the first application still in progress waits for it. A typed rejection thrown by the
|
|
724
|
+
* engine (`412`, `400`, `404`) is the write's answer and is recorded like a returned response;
|
|
725
|
+
* a runtime failure produced no answer, so the claim is released with nothing recorded and a
|
|
726
|
+
* later submission applies fresh. A recorded outcome is bound to its method and document id,
|
|
727
|
+
* so the same key resubmitted for a different operation is refused, never replayed.
|
|
728
|
+
*/
|
|
729
|
+
async function applyIdentified(scope, key, method, id, apply) {
|
|
730
|
+
try {
|
|
731
|
+
assertRequestIdentity(key);
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
return errorFromCaught(err);
|
|
735
|
+
}
|
|
736
|
+
const store = options.outcomes;
|
|
737
|
+
if (!store)
|
|
738
|
+
return errorResponse(400, "USAGE", IDENTITY_UNSUPPORTED);
|
|
739
|
+
for (;;) {
|
|
740
|
+
const claim = await store.claim(scope, key);
|
|
741
|
+
if (claim.kind === "recorded")
|
|
742
|
+
return replayRecorded(claim.operation, method, id);
|
|
743
|
+
if (claim.kind === "in_progress") {
|
|
744
|
+
const settled = await claim.settled;
|
|
745
|
+
if (settled === null)
|
|
746
|
+
continue;
|
|
747
|
+
return replayRecorded(settled, method, id);
|
|
748
|
+
}
|
|
749
|
+
let response;
|
|
750
|
+
try {
|
|
751
|
+
response = await apply();
|
|
752
|
+
}
|
|
753
|
+
catch (err) {
|
|
754
|
+
if (!(err instanceof VersionConflict || err instanceof InvalidInputError || isEnoent(err))) {
|
|
755
|
+
await settleOutcomeClaim(() => claim.release());
|
|
756
|
+
throw err;
|
|
757
|
+
}
|
|
758
|
+
response = errorFromCaught(err);
|
|
759
|
+
}
|
|
760
|
+
let recorded;
|
|
761
|
+
let outcome;
|
|
762
|
+
try {
|
|
763
|
+
recorded = await recordableResponse(response);
|
|
764
|
+
outcome = outcomeOf(recorded);
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
await settleOutcomeClaim(() => claim.release());
|
|
768
|
+
throw err;
|
|
769
|
+
}
|
|
770
|
+
await settleOutcomeClaim(() => claim.record({ method, id, response: recorded, outcome }));
|
|
771
|
+
return replayResponse(recorded);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
/** Persistence errors are not document refusals, even when an adapter uses engine error types. */
|
|
775
|
+
async function settleOutcomeClaim(settle) {
|
|
776
|
+
try {
|
|
777
|
+
await settle();
|
|
778
|
+
}
|
|
779
|
+
catch (cause) {
|
|
780
|
+
throw new Error("operation outcome persistence failed", { cause });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function replayRecorded(operation, method, id) {
|
|
784
|
+
if (operation.method !== method || operation.id !== id) {
|
|
785
|
+
return errorResponse(400, "USAGE", `Idempotency-Key was recorded for ${operation.method} '${operation.id}', not ${method} '${id}'`, { recorded: { method: operation.method, id: operation.id } });
|
|
786
|
+
}
|
|
787
|
+
return replayResponse(operation.response);
|
|
788
|
+
}
|
|
789
|
+
/** `GET /operations/{key}`: the recorded outcome, or `404` when the store holds nothing under the key. */
|
|
790
|
+
async function handleOperationLookup(scope, key) {
|
|
791
|
+
const store = options.outcomes;
|
|
792
|
+
if (!store)
|
|
793
|
+
return errorResponse(400, "USAGE", IDENTITY_UNSUPPORTED);
|
|
794
|
+
const operation = await store.lookup(scope, key);
|
|
795
|
+
if (!operation)
|
|
796
|
+
return errorResponse(404, "NOT_FOUND", `no recorded outcome for '${key}'`);
|
|
797
|
+
return jsonResponse(200, operation.outcome);
|
|
560
798
|
}
|
|
561
799
|
async function handleVersions(backend, id) {
|
|
562
800
|
assertValidDocId(id);
|
|
@@ -583,8 +821,9 @@ function buildRouter(options) {
|
|
|
583
821
|
try {
|
|
584
822
|
assertValidDocId(id);
|
|
585
823
|
}
|
|
586
|
-
catch {
|
|
587
|
-
|
|
824
|
+
catch (err) {
|
|
825
|
+
const reason = err instanceof InvalidInputError ? err.message : `invalid document id '${id}'`;
|
|
826
|
+
return errorResponse(400, "USAGE", reason, { id });
|
|
588
827
|
}
|
|
589
828
|
}
|
|
590
829
|
// Determine the missing set up front (§ readMany: "404 + { missing }" if ANY id is
|
|
@@ -596,7 +835,7 @@ function buildRouter(options) {
|
|
|
596
835
|
}
|
|
597
836
|
const results = await backend.readMany(ids);
|
|
598
837
|
return jsonResponse(200, {
|
|
599
|
-
results: results.map((r) => ({ id: r.doc.id, frontmatter: r.doc.frontmatter, body: r.doc.body, version: r.version })),
|
|
838
|
+
results: results.map((r) => ({ id: r.doc.id, frontmatter: captureRemoteFrontmatter(r.doc.frontmatter), body: r.doc.body, version: r.version })),
|
|
600
839
|
});
|
|
601
840
|
}
|
|
602
841
|
async function handleList(backend, searchParams) {
|
|
@@ -613,11 +852,13 @@ function buildRouter(options) {
|
|
|
613
852
|
// push-down (an indexed adapter can answer without reading bodies),
|
|
614
853
|
// re-applies the canonical `matchesFilter` to whatever came back, and falls back to
|
|
615
854
|
// the delete-tolerant `list` + batch-read walk for every other backend (a doc deleted
|
|
616
|
-
// mid-scan is SKIPPED, not a scan-failing 404 — the server half of STATUS item 33
|
|
617
|
-
// MALFORMED doc
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
|
|
855
|
+
// mid-scan is SKIPPED, not a scan-failing 404 — the server half of STATUS item 33). With
|
|
856
|
+
// `malformed=skip`, a MALFORMED doc is left out of the rows and named in `skipped` on every
|
|
857
|
+
// page, so one bad file never fails the listing; without it (a client that cannot read
|
|
858
|
+
// `skipped`) the listing fails naming the document, so no client silently loses one. The
|
|
859
|
+
// router deliberately does NOT re-implement the prefer-else-fallback dance itself.
|
|
860
|
+
const skipped = [];
|
|
861
|
+
const heads = await queryHeads(backend, { prefix, type, tags }, searchParams.get("malformed") === "skip" ? { onSkip: (skip) => skipped.push(skip) } : {});
|
|
621
862
|
const count = heads.length;
|
|
622
863
|
let page = heads;
|
|
623
864
|
if (cursor) {
|
|
@@ -630,16 +871,168 @@ function buildRouter(options) {
|
|
|
630
871
|
}
|
|
631
872
|
const limited = page.slice(0, limit);
|
|
632
873
|
const nextCursor = page.length > limit ? (limited[limited.length - 1]?.id ?? null) : null;
|
|
633
|
-
const docs = limited.map(({ id, frontmatter, version }) =>
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
874
|
+
const docs = limited.map(({ id, frontmatter, version }) => {
|
|
875
|
+
if (fields === "frontmatter")
|
|
876
|
+
return { id, version, frontmatter: captureRemoteFrontmatter(frontmatter) };
|
|
877
|
+
// Only emitted fields need wire compatibility. Copy descriptors so projecting a field
|
|
878
|
+
// cannot invoke its getter before the shared capture checks it; absent fields stay absent.
|
|
879
|
+
const projected = Object.create(null);
|
|
880
|
+
for (const key of ["type", "title", "timestamp"]) {
|
|
881
|
+
const descriptor = Object.getOwnPropertyDescriptor(frontmatter, key);
|
|
882
|
+
if (descriptor)
|
|
883
|
+
Object.defineProperty(projected, key, { ...descriptor, enumerable: true });
|
|
884
|
+
}
|
|
885
|
+
return { id, version, ...captureRemoteFrontmatter(projected) };
|
|
886
|
+
});
|
|
887
|
+
return jsonResponse(200, {
|
|
888
|
+
count,
|
|
889
|
+
docs,
|
|
890
|
+
next_cursor: nextCursor,
|
|
891
|
+
...(skipped.length > 0 ? { skipped: skipped.map(({ id, reason }) => ({ id, reason })) } : {}),
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Every document's id and current version, in the wire's id order. The one listing behind
|
|
896
|
+
* both `GET /heads` and `GET /snapshot`: `list` for the ids, then `readMany` in batches of
|
|
897
|
+
* `SNAPSHOT_BATCH_SIZE`, keeping only each result's version and dropping the bodies with the
|
|
898
|
+
* batch. Neither reference backend pushes `queryHeads` down, and the engine's scan reads every
|
|
899
|
+
* body in one `readMany`, which is why the listing is computed here rather than through it.
|
|
900
|
+
* Every document is still read once for the listing. A document deleted between `list` and
|
|
901
|
+
* its batch is simply not a head, as in the engine's scan; a malformed document fails the
|
|
902
|
+
* listing, naming it (deviation 4): a working copy reads a missing head as a deletion, so heads
|
|
903
|
+
* cannot skip a document the way `GET /docs` does.
|
|
904
|
+
* An id the wire cannot serve (a control character, which the digest recipe cannot delimit)
|
|
905
|
+
* fails the listing closed before any document is read: heads answers `500 RUNTIME`, and the
|
|
906
|
+
* snapshot fails before its header line exists, so the client sees no snapshot rather than
|
|
907
|
+
* a digest that another listing could share.
|
|
908
|
+
*/
|
|
909
|
+
async function listHeads(backend) {
|
|
910
|
+
const ids = await backend.list();
|
|
911
|
+
if (ids.some(hasControlCharacter))
|
|
912
|
+
throw new UnservableIdError();
|
|
913
|
+
const heads = [];
|
|
914
|
+
for (let offset = 0; offset < ids.length; offset += SNAPSHOT_BATCH_SIZE) {
|
|
915
|
+
const batch = ids.slice(offset, offset + SNAPSHOT_BATCH_SIZE);
|
|
916
|
+
for (const { id, version } of await readBatchExisting(backend, batch))
|
|
917
|
+
heads.push({ id, version });
|
|
918
|
+
}
|
|
919
|
+
return sortHeads(heads);
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* `readMany` for one batch, tolerating a document that vanished after `list` named it: on an
|
|
923
|
+
* ENOENT-shaped failure the batch is re-read one id at a time and absent ids are dropped.
|
|
924
|
+
* Any other failure (a malformed document included) propagates.
|
|
925
|
+
*/
|
|
926
|
+
async function readBatchExisting(backend, ids) {
|
|
927
|
+
try {
|
|
928
|
+
const results = await backend.readMany(ids);
|
|
929
|
+
assertBatchShape(results.length, ids.length);
|
|
930
|
+
return results.map((result, index) => ({ id: ids[index], version: result.version }));
|
|
931
|
+
}
|
|
932
|
+
catch (err) {
|
|
933
|
+
if (!isEnoent(err))
|
|
934
|
+
throw err;
|
|
935
|
+
}
|
|
936
|
+
const out = [];
|
|
937
|
+
for (const id of ids) {
|
|
938
|
+
try {
|
|
939
|
+
out.push({ id, version: (await backend.read(id)).version });
|
|
940
|
+
}
|
|
941
|
+
catch (err) {
|
|
942
|
+
if (!isEnoent(err))
|
|
943
|
+
throw err;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return out;
|
|
947
|
+
}
|
|
948
|
+
/** A conforming `readMany` answers one result per id, in order; anything else is a backend defect, never silently paired. */
|
|
949
|
+
function assertBatchShape(resultCount, idCount) {
|
|
950
|
+
if (resultCount !== idCount) {
|
|
951
|
+
throw new Error(`readMany answered ${resultCount} result(s) for ${idCount} id(s); results are paired to ids by index`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
/** True when `If-None-Match` names `digest` in bare, quoted, or weak form, alone or in a list. */
|
|
955
|
+
function ifNoneMatchNames(req, digest) {
|
|
956
|
+
const header = req.headers.get("If-None-Match");
|
|
957
|
+
if (header === null)
|
|
958
|
+
return false;
|
|
959
|
+
return header.split(",").some((token) => stripETagWrapper(token) === digest);
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* `GET /heads`: `{ count, digest, heads }` with the digest as a quoted `ETag`, or a bodyless
|
|
963
|
+
* `304` when the client already holds that digest. A `304` means nothing changed; a `200` is
|
|
964
|
+
* diffed by the client against its own copy (a missing id is a deletion, a differing version a
|
|
965
|
+
* change). No pagination: the whole listing is the point of the route.
|
|
966
|
+
*/
|
|
967
|
+
async function handleHeads(backend, req) {
|
|
968
|
+
const heads = await listHeads(backend);
|
|
969
|
+
const digest = headsDigest(heads);
|
|
970
|
+
if (ifNoneMatchNames(req, digest)) {
|
|
971
|
+
return new Response(null, { status: 304, headers: { ETag: `"${digest}"` } });
|
|
972
|
+
}
|
|
973
|
+
return jsonResponse(200, { count: heads.length, digest, heads }, { ETag: `"${digest}"` });
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* The snapshot's NDJSON lines: the header, one chunk of document lines per backend batch, and
|
|
977
|
+
* the `end` terminator. The listing holds only ids and versions; bodies are read in batches of
|
|
978
|
+
* `SNAPSHOT_BATCH_SIZE` after the header has been produced and encoded as each batch arrives,
|
|
979
|
+
* so at most one batch of bodies is held at a time. A document that vanishes between the heads
|
|
980
|
+
* listing and its batch makes the batch read throw, which errors the stream: the client then
|
|
981
|
+
* sees a body without its terminator and treats the snapshot as truncated. A document that
|
|
982
|
+
* changes in that window is caught the same way: its read version no longer matches the listed
|
|
983
|
+
* head, and the stream errors rather than emitting a line the header digest does not describe,
|
|
984
|
+
* so the digest always describes exactly the emitted lines and the client retries.
|
|
985
|
+
*/
|
|
986
|
+
async function* snapshotLines(backend, heads, digest) {
|
|
987
|
+
yield `${JSON.stringify({ kind: "snapshot", count: heads.length, digest })}\n`;
|
|
988
|
+
for (let offset = 0; offset < heads.length; offset += SNAPSHOT_BATCH_SIZE) {
|
|
989
|
+
const batch = heads.slice(offset, offset + SNAPSHOT_BATCH_SIZE);
|
|
990
|
+
const results = await backend.readMany(batch.map((head) => head.id));
|
|
991
|
+
assertBatchShape(results.length, batch.length);
|
|
992
|
+
let chunk = "";
|
|
993
|
+
results.forEach((result, index) => {
|
|
994
|
+
// The heads listing owns identity, as the read-many route does: never a payload id.
|
|
995
|
+
const head = batch[index];
|
|
996
|
+
if (result.version !== head.version) {
|
|
997
|
+
throw new Error(`document '${head.id}' changed from version ${head.version} to ${result.version} while the snapshot streamed`);
|
|
998
|
+
}
|
|
999
|
+
const line = { kind: "doc", id: head.id, version: head.version, frontmatter: captureRemoteFrontmatter(result.doc.frontmatter), body: result.doc.body };
|
|
1000
|
+
chunk += `${JSON.stringify(line)}\n`;
|
|
641
1001
|
});
|
|
642
|
-
|
|
1002
|
+
yield chunk;
|
|
1003
|
+
}
|
|
1004
|
+
yield `${JSON.stringify({ kind: "end", count: heads.length })}\n`;
|
|
1005
|
+
}
|
|
1006
|
+
/** Encode produced lines into a pull-driven byte stream: one enqueue per produced chunk, nothing buffered ahead of the reader. */
|
|
1007
|
+
function ndjsonStream(lines) {
|
|
1008
|
+
const encoder = new TextEncoder();
|
|
1009
|
+
return new ReadableStream({
|
|
1010
|
+
async pull(controller) {
|
|
1011
|
+
const next = await lines.next();
|
|
1012
|
+
if (next.done)
|
|
1013
|
+
controller.close();
|
|
1014
|
+
else
|
|
1015
|
+
controller.enqueue(encoder.encode(next.value));
|
|
1016
|
+
},
|
|
1017
|
+
async cancel() {
|
|
1018
|
+
await lines.return(undefined);
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* `GET /snapshot`: every document as a streamed NDJSON body whose header carries the same
|
|
1024
|
+
* digest `GET /heads` would return for this state, so a bootstrap can start its later heads
|
|
1025
|
+
* checks from it. The heads listing (and so a malformed-document failure) completes before any
|
|
1026
|
+
* byte of the response exists; only the batched body reads stream. Reserved files are not part
|
|
1027
|
+
* of a snapshot.
|
|
1028
|
+
*/
|
|
1029
|
+
async function handleSnapshot(backend) {
|
|
1030
|
+
const heads = await listHeads(backend);
|
|
1031
|
+
const digest = headsDigest(heads);
|
|
1032
|
+
return new Response(ndjsonStream(snapshotLines(backend, heads, digest)), {
|
|
1033
|
+
status: 200,
|
|
1034
|
+
headers: { "content-type": NDJSON_CONTENT_TYPE, ETag: `"${digest}"` },
|
|
1035
|
+
});
|
|
643
1036
|
}
|
|
644
1037
|
async function handleReadReserved(backend, dir, name) {
|
|
645
1038
|
assertSafeReservedDir(dir);
|
|
@@ -748,9 +1141,19 @@ function buildRouter(options) {
|
|
|
748
1141
|
projections: caps.projections ?? true,
|
|
749
1142
|
backlinks: caps.backlinks ?? false,
|
|
750
1143
|
blobs: caps.blobs,
|
|
1144
|
+
operations: options.outcomes !== undefined,
|
|
1145
|
+
heads: true,
|
|
1146
|
+
snapshot: true,
|
|
751
1147
|
});
|
|
752
1148
|
}
|
|
753
1149
|
return registeredWireRouter(async (req, { resolved }) => {
|
|
1150
|
+
// Identity is refused wherever it is not honored, so a client never believes an
|
|
1151
|
+
// unsupported write was identified. The refusal precedes context resolution the way route
|
|
1152
|
+
// resolution does; on the two identified endpoints the host's own answer comes first.
|
|
1153
|
+
const identity = req.headers.get(IDENTITY_HEADER);
|
|
1154
|
+
if (identity !== null && !IDENTIFIED_ENDPOINTS.has(resolved.endpointId)) {
|
|
1155
|
+
return errorResponse(400, "USAGE", "Idempotency-Key is accepted only on document PUT and DELETE");
|
|
1156
|
+
}
|
|
754
1157
|
if (resolved.scope === "deployment")
|
|
755
1158
|
return handleCapabilities();
|
|
756
1159
|
const context = await options.resolveContext(req, resolved);
|
|
@@ -764,16 +1167,37 @@ function buildRouter(options) {
|
|
|
764
1167
|
return await handleList(backend, resolved.searchParams);
|
|
765
1168
|
case "docs-read-many":
|
|
766
1169
|
return await handleReadMany(backend, req);
|
|
1170
|
+
case "docs-heads":
|
|
1171
|
+
return await handleHeads(backend, req);
|
|
1172
|
+
case "docs-snapshot":
|
|
1173
|
+
return await handleSnapshot(backend);
|
|
767
1174
|
case "doc-versions":
|
|
768
1175
|
return await handleVersions(backend, resolved.resource.id);
|
|
769
1176
|
case "doc-read":
|
|
770
1177
|
return await handleReadDoc(backend, resolved.resource.id);
|
|
771
|
-
case "doc-write":
|
|
772
|
-
|
|
1178
|
+
case "doc-write": {
|
|
1179
|
+
const { id } = resolved.resource;
|
|
1180
|
+
const write = () => handleWriteDoc(backend, attribution, id, req);
|
|
1181
|
+
return identity === null ? await write() : await applyIdentified(resolved.bundleId, identity, "PUT", id, write);
|
|
1182
|
+
}
|
|
773
1183
|
case "doc-head":
|
|
774
1184
|
return await handleHeadDoc(backend, resolved.resource.id);
|
|
775
|
-
case "doc-delete":
|
|
776
|
-
|
|
1185
|
+
case "doc-delete": {
|
|
1186
|
+
const { id } = resolved.resource;
|
|
1187
|
+
if (identity !== null) {
|
|
1188
|
+
// The token becomes the version the recorded outcome is committed at and the version
|
|
1189
|
+
// headers the answer carries, so it must be a real content-addressed version, checked
|
|
1190
|
+
// before the key is claimed: an absent, empty or malformed premise records nothing.
|
|
1191
|
+
const premise = req.headers.get("If-Match");
|
|
1192
|
+
if (premise === null || !isContentVersion(stripETagWrapper(premise))) {
|
|
1193
|
+
return errorResponse(400, "USAGE", "an identified delete must carry a well-formed If-Match token");
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const remove = () => handleDeleteDoc(backend, id, req);
|
|
1197
|
+
return identity === null ? await remove() : await applyIdentified(resolved.bundleId, identity, "DELETE", id, remove);
|
|
1198
|
+
}
|
|
1199
|
+
case "operation-lookup":
|
|
1200
|
+
return await handleOperationLookup(resolved.bundleId, resolved.resource.key);
|
|
777
1201
|
case "reserved-read": {
|
|
778
1202
|
const resource = resolved.resource;
|
|
779
1203
|
return await handleReadReserved(backend, resource.dir, resource.name);
|