@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/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
- return jsonResponse(200, { deleted });
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
- return errorResponse(400, "USAGE", `invalid document id '${id}'`, { id });
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; a
617
- // MALFORMED doc still fails loudly, since quarantining it over the wire needs a
618
- // `skipped` response shape — recorded as an open question). The router deliberately
619
- // does NOT re-implement the prefer-else-fallback dance itself.
620
- const heads = await queryHeads(backend, { prefix, type, tags });
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 }) => fields === "frontmatter"
634
- ? { id, version, frontmatter }
635
- : {
636
- id,
637
- version,
638
- type: frontmatter.type,
639
- title: frontmatter.title,
640
- timestamp: frontmatter.timestamp,
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
- return jsonResponse(200, { count, docs, next_cursor: nextCursor });
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
- return await handleWriteDoc(backend, attribution, resolved.resource.id, req);
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
- return await handleDeleteDoc(backend, resolved.resource.id, req);
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);