@hollware/server 0.0.1 → 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/index.mjs CHANGED
@@ -1,4 +1,2835 @@
1
+ import { HollwareError, OPERATIONS, describeBuiltInContracts, describeResolvedSite, describeSite, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
2
+ import { Hono } from "hono";
3
+ import { drizzle } from "drizzle-orm/d1";
4
+ import { and, desc, eq, getTableColumns, ne, or, sql } from "drizzle-orm";
5
+ import { alias, check, foreignKey, index, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
6
+ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, acceptedContent, createMcpHandler, inputRequired, inputResponse } from "@modelcontextprotocol/server";
7
+ import * as z from "zod";
8
+ import { WorkerEntrypoint } from "cloudflare:workers";
9
+ //#region package.json
10
+ var version = "0.1.0";
11
+ //#endregion
12
+ //#region src/auth/bearer.ts
13
+ /**
14
+ * `Bearer`, one or more spaces, then one token with no whitespace in it, and nothing else. The
15
+ * scheme is case-insensitive (RFC 7235); the runtime has already trimmed the whole value.
16
+ */
17
+ const BEARER = /^bearer +(\S+)$/i;
18
+ /**
19
+ * The token of an `Authorization: Bearer <token>` header, as sent, or `null` when the request
20
+ * presents none: no header, another scheme, no token, or more than one (two headers arrive joined
21
+ * with a comma, so they read as two tokens). It never looks at the URL: a token in a query string
22
+ * ends up in logs, referrers and history, so Hollware does not accept one.
23
+ *
24
+ * It does not judge the token. A string that is not a personal token is a token that was
25
+ * presented and refused, which `checkPersonalToken` says and `unauthorized` answers differently
26
+ * from a request without credentials.
27
+ */
28
+ function readBearerToken(request) {
29
+ const header = request.headers.get("authorization");
30
+ if (header === null) return null;
31
+ return BEARER.exec(header)?.[1] ?? null;
32
+ }
33
+ //#endregion
34
+ //#region src/auth/responses.ts
35
+ /** The challenge of every 401 and 403 (RFC 6750). */
36
+ const REALM = "Bearer realm=\"hollware\"";
37
+ /**
38
+ * A JSON error response that no cache keeps. `challenge` is the `WWW-Authenticate` value of a
39
+ * 401 or a 403 that answers a bearer token.
40
+ */
41
+ function authErrorResponse(status, body, challenge) {
42
+ const headers = new Headers({ "cache-control": "no-store" });
43
+ if (challenge !== void 0) headers.set("www-authenticate", challenge);
44
+ return Response.json(body, {
45
+ status,
46
+ headers
47
+ });
48
+ }
49
+ /**
50
+ * 401. Without a `reason` the request presented no token, and the challenge only names the realm
51
+ * (RFC 6750: a request without credentials gets no error code). With one, a token was presented
52
+ * and refused, and the challenge says `invalid_token`. The answer is the same for every reason:
53
+ * a caller that sends tokens at random never learns which of them exist, or ever did. The reason
54
+ * is for the log; the fix says how to get a token that works.
55
+ */
56
+ function unauthorized(reason) {
57
+ if (reason === void 0) return authErrorResponse(401, {
58
+ code: "unauthorized",
59
+ message: "The request carries no personal token.",
60
+ fix: "Send a personal token as \"Authorization: Bearer <token>\". Create one with `hollware token create`."
61
+ }, REALM);
62
+ return authErrorResponse(401, {
63
+ code: "unauthorized",
64
+ message: "The personal token was not accepted.",
65
+ fix: "Tokens expire and can be revoked. Create a new one with `hollware token create` and send it as \"Authorization: Bearer <token>\"."
66
+ }, `${REALM}, error="invalid_token"`);
67
+ }
68
+ //#endregion
69
+ //#region src/auth/guard.ts
70
+ /**
71
+ * What the Host and Origin of a request are compared with: the host and the origin that `new URL`
72
+ * makes of the variable (lowercase, no default port). Path, query and fragment play no part.
73
+ * `null` when the variable is not an absolute `http` or `https` URL, or is not a string at all.
74
+ */
75
+ function readPublicUrl(value) {
76
+ if (typeof value !== "string") return null;
77
+ let url;
78
+ try {
79
+ url = new URL(value);
80
+ } catch {
81
+ return null;
82
+ }
83
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
84
+ return {
85
+ host: url.host,
86
+ hostWithDefaultPort: url.port === "" ? `${url.host}:${url.protocol === "https:" ? 443 : 80}` : null,
87
+ origin: url.origin
88
+ };
89
+ }
90
+ /**
91
+ * The guard that runs in front of every route, against DNS rebinding and cross-site requests
92
+ * from a browser (ADR-019). It fails closed: every request it does not recognize is refused.
93
+ *
94
+ * - `HOLLWARE_PUBLIC_URL` missing or not an `http` or `https` URL: 500. Without it nothing can
95
+ * be compared, and skipping the check would leave the Worker open.
96
+ * - A `Host` header that is missing, or is not the host of the public URL, port included: 403.
97
+ * The comparison ignores case, and takes the default port of the scheme written out
98
+ * (`cms.example.com:443` for an `https` URL), which some clients send for the same server.
99
+ * Any other port is another host.
100
+ * - An `Origin` header that is present and is not the origin of the public URL, scheme and port
101
+ * included: 403. That covers the opaque `null` origin and an empty value. A request without
102
+ * `Origin` passes: only browsers send it, and they are what the check is for.
103
+ *
104
+ * It returns the response to send, or `null` when the request may go on. No answer repeats the
105
+ * request: the 403s say what to send instead, and the 500 only says what is missing.
106
+ *
107
+ * The helpers of the MCP SDK (`hostHeaderValidationResponse`, `originValidationResponse`) do not
108
+ * fit: they compare hostnames only, so they accept any port and, for `Origin`, any scheme, and
109
+ * they accept a `Host` such as `user@cms.example.com` or `cms.example.com/x`.
110
+ *
111
+ * @param publicUrl The `HOLLWARE_PUBLIC_URL` variable as the environment holds it.
112
+ */
113
+ function checkHostAndOrigin(request, publicUrl) {
114
+ const expected = readPublicUrl(publicUrl);
115
+ if (expected === null) return authErrorResponse(500, {
116
+ code: "misconfigured",
117
+ message: "HOLLWARE_PUBLIC_URL is required: it is missing, or it is not an http or https URL.",
118
+ fix: "Set the HOLLWARE_PUBLIC_URL variable of the Worker to its public URL, such as https://cms.example.com, and deploy again."
119
+ });
120
+ const host = request.headers.get("host")?.toLowerCase() ?? null;
121
+ if (host === null || host !== expected.host && host !== expected.hostWithDefaultPort) return authErrorResponse(403, {
122
+ code: "forbidden_host",
123
+ message: "The Host header of the request is not the public URL of this Hollware.",
124
+ fix: `Send the request to ${expected.origin}, the public URL of this Hollware (HOLLWARE_PUBLIC_URL).`
125
+ });
126
+ const origin = request.headers.get("origin");
127
+ if (origin !== null && origin.toLowerCase() !== expected.origin) return authErrorResponse(403, {
128
+ code: "forbidden_origin",
129
+ message: "The Origin header of the request is not the public URL of this Hollware.",
130
+ fix: `Call Hollware from a client that sends no Origin header, or from a page served at ${expected.origin}.`
131
+ });
132
+ return null;
133
+ }
134
+ //#endregion
135
+ //#region src/auth/roles.ts
136
+ /**
137
+ * The roles of a user, as the `users_role` CHECK of the schema lists them. In v0.1 only the owner
138
+ * exists: `hollware init` creates it. The other two are in the CHECK already, so that v0.3 can
139
+ * invite people without a rebuild of the table (ADR-023).
140
+ */
141
+ const ROLES = [
142
+ "owner",
143
+ "editor",
144
+ "reviewer"
145
+ ];
146
+ /** Whether a string is one of the roles. */
147
+ function isRole(value) {
148
+ return ROLES.some((role) => role === value);
149
+ }
150
+ /**
151
+ * The scopes each role allows. What a token may do is its own scopes intersected with these,
152
+ * read from the user's current role on every request (ADR-005, ADR-019). An operation asks for
153
+ * scopes, never for a role.
154
+ *
155
+ * - `owner` may do everything: all four scopes.
156
+ * - `editor` and `reviewer` allow none in v0.1. Nothing creates such a user yet, and which scopes
157
+ * each one gets (an editor writes, a reviewer approves) is for v0.3 to decide, together with
158
+ * the admin that invites them. Until then a token of such a user authenticates, but every
159
+ * operation answers 403 `insufficient_scope`.
160
+ */
161
+ const ROLE_SCOPES = {
162
+ owner: [
163
+ "content:read",
164
+ "content:write",
165
+ "content:publish",
166
+ "schema:admin"
167
+ ],
168
+ editor: [],
169
+ reviewer: []
170
+ };
171
+ /**
172
+ * The scopes a token really has: those it carries that the role of its user allows, in the order
173
+ * of the role. A role that is not one of the roles allows nothing, so a role added to the schema
174
+ * before this table knows it grants no access.
175
+ */
176
+ function effectiveScopes(role, tokenScopes) {
177
+ if (!isRole(role)) return [];
178
+ return ROLE_SCOPES[role].filter((scope) => tokenScopes.includes(scope));
179
+ }
180
+ //#endregion
181
+ //#region src/auth/token.ts
182
+ /** `last_used_at` of a token is written at most once in this time. */
183
+ const TOUCH_INTERVAL_MS = 36e5;
184
+ /** The token with the role of its user, by the hash of the token, in the one site. */
185
+ const LOOKUP = `SELECT t.id AS token_id, t.site_id AS site_id, t.user_id AS user_id,
186
+ t.scopes AS scopes, t.expires_at AS expires_at, t.revoked_at AS revoked_at,
187
+ t.last_used_at AS last_used_at, u.role AS role
188
+ FROM tokens AS t JOIN users AS u ON u.id = t.user_id
189
+ WHERE t.hash = ?1 AND t.site_id = ?2`;
190
+ /**
191
+ * The scopes a token carries, from the JSON array the `tokens` table stores. Anything that is not
192
+ * one of the scopes is left out, and a value that is not an array of scopes has none: a token
193
+ * never gets more than what the catalog names.
194
+ */
195
+ function parseTokenScopes(json) {
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(json);
199
+ } catch {
200
+ return [];
201
+ }
202
+ if (!Array.isArray(parsed)) return [];
203
+ return parsed.filter((value) => typeof value === "string" && isScope(value));
204
+ }
205
+ /**
206
+ * Whether an expiry has passed. A token is expired at the very instant of its expiry, and one
207
+ * whose expiry cannot be read is expired too: the check fails closed. The expiry is read as an
208
+ * instant, so any ISO 8601 spelling of it works, with or without milliseconds.
209
+ */
210
+ function hasExpired(expiresAt, now) {
211
+ if (expiresAt === null) return false;
212
+ const instant = Date.parse(expiresAt);
213
+ return Number.isNaN(instant) || instant <= now.getTime();
214
+ }
215
+ /** The instant an hour before `now`, spelled as `last_used_at` is: ISO 8601 UTC with milliseconds. */
216
+ function anHourBefore(now) {
217
+ return (/* @__PURE__ */ new Date(now.getTime() - TOUCH_INTERVAL_MS)).toISOString();
218
+ }
219
+ /**
220
+ * Writes `last_used_at` (and `updated_at`) of a token to `now`, unless it was written within the
221
+ * hour before: the condition is in the `UPDATE`, so two requests that both read an old value
222
+ * still write once. A failure is logged and swallowed: a statistic never fails the request, and
223
+ * the returned promise never rejects, so nobody has to handle it.
224
+ */
225
+ async function recordTokenUse(db, token, now) {
226
+ try {
227
+ await db.prepare(`UPDATE tokens SET last_used_at = ?1, updated_at = ?1
228
+ WHERE id = ?2 AND site_id = ?3 AND (last_used_at IS NULL OR last_used_at < ?4)`).bind(now.toISOString(), token.id, token.site, anHourBefore(now)).run();
229
+ } catch (error) {
230
+ console.error("hollware: could not record the last use of a token", error);
231
+ }
232
+ }
233
+ /**
234
+ * Authenticates a personal token (ADR-019). A string without the shape of a token is `unknown`
235
+ * without a query. Otherwise one query finds the token by the hash of the whole string, in this
236
+ * site only, together with the role of its user; the answer is, in this order:
237
+ *
238
+ * - `unknown`: no such token in this site. A token of another site is the same as no token, and
239
+ * nothing says it exists elsewhere.
240
+ * - `revoked`: it has a revocation date, whatever the date says: it is never read, so a clock that
241
+ * runs ahead of the Worker's, where the token was revoked, cannot keep it alive for a while.
242
+ * - `expired`: its expiry is now or past (see `hasExpired`).
243
+ * - `ok`: the principal, whose scopes are the token's own intersected with those of the role its
244
+ * user has now. A token whose user has no scopes (an editor, in v0.1) still authenticates, and
245
+ * every operation then answers `insufficient_scope`.
246
+ *
247
+ * Pass the `D1Database` binding itself and not a replica session: a token that was revoked must
248
+ * stop working on the next request, and only the primary knows. A D1 failure rejects, and a caller
249
+ * must never read that as a valid token.
250
+ */
251
+ async function checkPersonalToken(db, token, options) {
252
+ if (!isPersonalToken(token)) return {
253
+ ok: false,
254
+ reason: "unknown"
255
+ };
256
+ const row = await db.prepare(LOOKUP).bind(await hashPersonalToken(token), options.site).first();
257
+ if (row === null) return {
258
+ ok: false,
259
+ reason: "unknown"
260
+ };
261
+ if (row.revoked_at !== null) return {
262
+ ok: false,
263
+ reason: "revoked"
264
+ };
265
+ if (hasExpired(row.expires_at, options.now)) return {
266
+ ok: false,
267
+ reason: "expired"
268
+ };
269
+ const recent = row.last_used_at !== null && row.last_used_at >= anHourBefore(options.now);
270
+ return {
271
+ ok: true,
272
+ principal: {
273
+ kind: "token",
274
+ userId: row.user_id,
275
+ tokenId: row.token_id,
276
+ site: row.site_id,
277
+ scopes: effectiveScopes(row.role, parseTokenScopes(row.scopes))
278
+ },
279
+ touch: recent ? null : recordTokenUse(db, {
280
+ id: row.token_id,
281
+ site: row.site_id
282
+ }, options.now)
283
+ };
284
+ }
285
+ //#endregion
286
+ //#region src/db/d1.ts
287
+ /**
288
+ * Drizzle's D1 driver is typed over a `D1Database`, but at runtime it only calls `prepare` and
289
+ * `batch` (drizzle-orm d1/session.js), so it also runs on a session. This is a `D1Database` over
290
+ * a `D1Like` without a cast: the three methods drizzle never calls throw, so a driver that starts
291
+ * calling one fails loudly instead of reaching the wrong database (spike S4).
292
+ */
293
+ function asD1Database(db) {
294
+ const unavailable = (method) => {
295
+ throw new Error(`The store runs on prepare() and batch() only, so D1 ${method}() is not there`);
296
+ };
297
+ return {
298
+ prepare: (query) => db.prepare(query),
299
+ batch: (statements) => db.batch(statements),
300
+ exec: () => unavailable("exec"),
301
+ withSession: () => unavailable("withSession"),
302
+ dump: () => unavailable("dump")
303
+ };
304
+ }
305
+ /** Drizzle over `db`, with no schema (the store builds its queries from the table objects). */
306
+ function connect(db) {
307
+ return drizzle(asD1Database(db));
308
+ }
309
+ //#endregion
310
+ //#region src/limits.ts
311
+ /**
312
+ * The limits every operation runs under, pinned for the adapters and the store (the v0.1 plan).
313
+ */
314
+ /**
315
+ * The largest answer of an operation, in UTF-8 bytes of its JSON. A larger one is the error
316
+ * `output_too_large`, with a fix that says how to ask for less; content is never truncated.
317
+ */
318
+ const OUTPUT_CAP_BYTES = 1048576;
319
+ /** How long an operation may take before it answers the error `timeout`. */
320
+ const OPERATION_TIMEOUT_MS = 1e4;
321
+ /**
322
+ * The largest row the store writes, counted in UTF-8 bytes across all its columns. D1 refuses
323
+ * rows over 2 MB in production, and Miniflare does not enforce it, so the store checks first.
324
+ */
325
+ const MAX_ROW_BYTES = 19e5;
326
+ /** The most values bound in one statement: D1 allows 100, and lookups go in chunks of this. */
327
+ const MAX_BOUND_PARAMETERS = 90;
328
+ //#endregion
329
+ //#region src/db/chunks.ts
330
+ /**
331
+ * D1 refuses a statement with more than 100 bound values, and Miniflare enforces it too. Lookups
332
+ * and multi-row inserts go in chunks that stay under `MAX_BOUND_PARAMETERS`.
333
+ */
334
+ /**
335
+ * How many rows of `perRow` bound values one statement can take when it also binds `shared`
336
+ * values of its own. At least one: a row that does not fit alone is a bug of the statement.
337
+ */
338
+ function rowsPerStatement(shared, perRow) {
339
+ return Math.max(1, Math.floor((90 - shared) / perRow));
340
+ }
341
+ /** `items` in groups of at most `size`, in order. */
342
+ function chunk(items, size) {
343
+ const groups = [];
344
+ for (let start = 0; start < items.length; start += size) groups.push(items.slice(start, start + size));
345
+ return groups;
346
+ }
347
+ //#endregion
348
+ //#region src/db/cursor.ts
349
+ function invalidCursor() {
350
+ return new HollwareError("invalid_input", "The cursor is not valid for this listing.", "Pass the nextCursor of the previous answer unchanged, to the same operation, or leave cursor out to start from the first page.");
351
+ }
352
+ function encodeBase64Url(text) {
353
+ let binary = "";
354
+ for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);
355
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
356
+ }
357
+ /** The text a base64url string holds, or `null` when it is not base64url of UTF-8. */
358
+ function decodeBase64Url(text) {
359
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
360
+ try {
361
+ const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
362
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
363
+ return new TextDecoder("utf-8", {
364
+ fatal: true,
365
+ ignoreBOM: true
366
+ }).decode(bytes);
367
+ } catch {
368
+ return null;
369
+ }
370
+ }
371
+ function parseJson(text) {
372
+ try {
373
+ return JSON.parse(text);
374
+ } catch {
375
+ return;
376
+ }
377
+ }
378
+ /** The cursor that continues a `view` listing after `position`. */
379
+ function encodeCursor(view, position) {
380
+ return encodeBase64Url(JSON.stringify({
381
+ v: view,
382
+ k: position.key,
383
+ i: position.id
384
+ }));
385
+ }
386
+ /**
387
+ * The position a cursor stands for. A cursor that no listing of this view returned, whatever is
388
+ * wrong with it, is the error `invalid_input` with a fix.
389
+ */
390
+ function decodeCursor(cursor, view) {
391
+ const text = decodeBase64Url(cursor);
392
+ const value = text === null ? void 0 : parseJson(text);
393
+ if (typeof value === "object" && value !== null && "v" in value && "k" in value && "i" in value && value.v === view && typeof value.k === "string" && typeof value.i === "string") return {
394
+ key: value.k,
395
+ id: value.i
396
+ };
397
+ throw invalidCursor();
398
+ }
399
+ //#endregion
400
+ //#region src/db/columns.ts
401
+ /**
402
+ * The columns every table shares. Each helper returns a new builder, because a Drizzle column
403
+ * belongs to one table. Only the repository functions write rows, so these defaults are what
404
+ * every row gets.
405
+ */
406
+ /** The current time in UTC, as ISO 8601 with milliseconds: `2026-10-12T18:30:00.000Z`. */
407
+ function nowIso() {
408
+ return (/* @__PURE__ */ new Date()).toISOString();
409
+ }
410
+ /** The primary key: a prefixed ULID, such as `ent_01JAB3XQ8ZK4M6N2P5R7S9T0VW`. */
411
+ function id(prefix) {
412
+ return text("id").primaryKey().$defaultFn(() => newId(prefix));
413
+ }
414
+ /**
415
+ * The site a row belongs to: the site's key in `hollware.config.ts`, which is also the id of its
416
+ * row in `sites`. Every table but the deployment-wide ones carries it (ADR-006).
417
+ */
418
+ function siteId() {
419
+ return text("site_id").notNull();
420
+ }
421
+ /** `created_at` and `updated_at`, in UTC; `updated_at` moves on every update. */
422
+ function timestamps() {
423
+ return {
424
+ createdAt: text("created_at").notNull().$defaultFn(nowIso),
425
+ updatedAt: text("updated_at").notNull().$defaultFn(nowIso).$onUpdateFn(nowIso)
426
+ };
427
+ }
428
+ sqliteTable("_hollware_meta", {
429
+ key: text("key").primaryKey().notNull(),
430
+ value: text("value").notNull()
431
+ });
432
+ /** The sites of the deployment. The id is the site's key in `hollware.config.ts`. */
433
+ const sites = sqliteTable("sites", {
434
+ id: text("id").primaryKey(),
435
+ ...timestamps()
436
+ });
437
+ /** People. A user belongs to the deployment; tokens carry the site. */
438
+ const users = sqliteTable("users", {
439
+ id: id("usr"),
440
+ name: text("name"),
441
+ role: text("role").notNull(),
442
+ ...timestamps()
443
+ }, (t) => [check("users_role", sql`${t.role} IN ('owner', 'editor', 'reviewer')`)]);
444
+ /** Personal tokens (ADR-019): only the hash is stored. */
445
+ const tokens = sqliteTable("tokens", {
446
+ id: id("tok"),
447
+ siteId: siteId(),
448
+ userId: text("user_id").notNull(),
449
+ label: text("label").notNull(),
450
+ hash: text("hash").notNull(),
451
+ /** A JSON array of scopes. */
452
+ scopes: text("scopes").notNull(),
453
+ expiresAt: text("expires_at"),
454
+ revokedAt: text("revoked_at"),
455
+ lastUsedAt: text("last_used_at"),
456
+ ...timestamps()
457
+ }, (t) => [
458
+ unique("tokens_id_site").on(t.id, t.siteId),
459
+ unique("tokens_hash").on(t.hash),
460
+ foreignKey({
461
+ name: "tokens_site",
462
+ columns: [t.siteId],
463
+ foreignColumns: [sites.id]
464
+ }),
465
+ foreignKey({
466
+ name: "tokens_user",
467
+ columns: [t.userId],
468
+ foreignColumns: [users.id]
469
+ }),
470
+ check("tokens_scopes", sql`json_valid(${t.scopes}) AND json_type(${t.scopes}) = 'array'`)
471
+ ]);
472
+ /** One entry per site, collection, locale and slug; its content lives in `revisions`. */
473
+ const entries = sqliteTable("entries", {
474
+ id: id("ent"),
475
+ siteId: siteId(),
476
+ collection: text("collection").notNull(),
477
+ locale: text("locale").notNull(),
478
+ slug: text("slug").notNull(),
479
+ translationGroup: text("translation_group"),
480
+ status: text("status").notNull(),
481
+ /** Bumped by every change of the entry: the optimistic write compares it. */
482
+ version: integer("version").notNull(),
483
+ headRev: text("head_rev").notNull(),
484
+ publishedRev: text("published_rev"),
485
+ publishedAt: text("published_at"),
486
+ publishedSortKey: text("published_sort_key"),
487
+ scheduledAt: text("scheduled_at"),
488
+ ...timestamps()
489
+ }, (t) => [
490
+ unique("entries_id_site").on(t.id, t.siteId),
491
+ unique("entries_slug").on(t.siteId, t.collection, t.locale, t.slug),
492
+ unique("entries_translation").on(t.siteId, t.collection, t.translationGroup, t.locale),
493
+ index("entries_head").on(t.siteId, t.collection, t.updatedAt, t.id),
494
+ index("entries_head_locale").on(t.siteId, t.collection, t.locale, t.updatedAt, t.id),
495
+ index("entries_published").on(t.siteId, t.collection, t.locale, t.publishedSortKey, t.id).where(sql`${t.status} = 'published'`),
496
+ foreignKey({
497
+ name: "entries_site",
498
+ columns: [t.siteId],
499
+ foreignColumns: [sites.id]
500
+ }),
501
+ check("entries_status", sql`${t.status} IN ('draft', 'scheduled', 'published')`),
502
+ check("entries_version", sql`${t.version} >= 1`),
503
+ check("entries_draft", sql`${t.status} <> 'draft' OR (${t.publishedRev} IS NULL AND ${t.publishedSortKey} IS NULL AND ${t.scheduledAt} IS NULL)`),
504
+ check("entries_published", sql`${t.status} <> 'published' OR (${t.publishedRev} IS NOT NULL AND ${t.publishedAt} IS NOT NULL AND ${t.publishedSortKey} IS NOT NULL)`),
505
+ check("entries_scheduled", sql`${t.status} <> 'scheduled' OR ${t.scheduledAt} IS NOT NULL`)
506
+ ]);
507
+ /**
508
+ * Every version of an entry, appended and never changed (ADR-021). `markdown` is the whole file,
509
+ * frontmatter included; the derived columns can be rebuilt by `reparse`.
510
+ */
511
+ const revisions = sqliteTable("revisions", {
512
+ id: id("rev"),
513
+ siteId: siteId(),
514
+ entryId: text("entry_id").notNull(),
515
+ markdown: text("markdown").notNull(),
516
+ /** Derived: the frontmatter as JSON. */
517
+ frontmatter: text("frontmatter").notNull(),
518
+ /** Derived: the Comark document as JSON. */
519
+ document: text("document").notNull(),
520
+ comarkVersion: text("comark_version").notNull(),
521
+ pipelineVersion: text("pipeline_version").notNull(),
522
+ wordCount: integer("word_count").notNull(),
523
+ /** The verdict of the contracts when the revision was written, as JSON. */
524
+ verdict: text("verdict").notNull(),
525
+ actorKind: text("actor_kind").notNull(),
526
+ actorUserId: text("actor_user_id").notNull(),
527
+ actorTokenId: text("actor_token_id"),
528
+ /** The client and, when it says, the model behind the write, as JSON. */
529
+ actorMeta: text("actor_meta"),
530
+ ...timestamps()
531
+ }, (t) => [
532
+ unique("revisions_id_site").on(t.id, t.siteId),
533
+ index("revisions_entry").on(t.entryId, t.createdAt),
534
+ foreignKey({
535
+ name: "revisions_site",
536
+ columns: [t.siteId],
537
+ foreignColumns: [sites.id]
538
+ }),
539
+ foreignKey({
540
+ name: "revisions_entry",
541
+ columns: [t.entryId, t.siteId],
542
+ foreignColumns: [entries.id, entries.siteId]
543
+ }),
544
+ foreignKey({
545
+ name: "revisions_user",
546
+ columns: [t.actorUserId],
547
+ foreignColumns: [users.id]
548
+ }),
549
+ foreignKey({
550
+ name: "revisions_token",
551
+ columns: [t.actorTokenId, t.siteId],
552
+ foreignColumns: [tokens.id, tokens.siteId]
553
+ }),
554
+ check("revisions_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
555
+ check("revisions_json", sql`json_valid(${t.frontmatter}) AND json_valid(${t.document}) AND json_valid(${t.verdict}) AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`),
556
+ check("revisions_word_count", sql`${t.wordCount} >= 0`)
557
+ ]);
558
+ /** The tags of a revision, for listing published entries by tag. Append-only, like revisions. */
559
+ const revisionTags = sqliteTable("revision_tags", {
560
+ id: id("tag"),
561
+ siteId: siteId(),
562
+ revisionId: text("revision_id").notNull(),
563
+ tag: text("tag").notNull(),
564
+ ...timestamps()
565
+ }, (t) => [
566
+ unique("revision_tags_id_site").on(t.id, t.siteId),
567
+ unique("revision_tags_tag").on(t.revisionId, t.tag),
568
+ index("revision_tags_by_tag").on(t.siteId, t.tag),
569
+ foreignKey({
570
+ name: "revision_tags_site",
571
+ columns: [t.siteId],
572
+ foreignColumns: [sites.id]
573
+ }),
574
+ foreignKey({
575
+ name: "revision_tags_revision",
576
+ columns: [t.revisionId, t.siteId],
577
+ foreignColumns: [revisions.id, revisions.siteId]
578
+ })
579
+ ]);
580
+ /**
581
+ * Every publish and unpublish, appended: the verdict of the publish phase and the warnings the
582
+ * caller acknowledged (ADR-021).
583
+ */
584
+ const publications = sqliteTable("publications", {
585
+ id: id("pub"),
586
+ siteId: siteId(),
587
+ entryId: text("entry_id").notNull(),
588
+ action: text("action").notNull(),
589
+ revisionId: text("revision_id"),
590
+ verdict: text("verdict"),
591
+ /** A JSON array: the warnings the caller acknowledged to publish. */
592
+ acknowledged: text("acknowledged").notNull(),
593
+ actorKind: text("actor_kind").notNull(),
594
+ actorUserId: text("actor_user_id").notNull(),
595
+ actorTokenId: text("actor_token_id"),
596
+ actorMeta: text("actor_meta"),
597
+ ...timestamps()
598
+ }, (t) => [
599
+ unique("publications_id_site").on(t.id, t.siteId),
600
+ index("publications_entry").on(t.entryId, t.createdAt),
601
+ foreignKey({
602
+ name: "publications_site",
603
+ columns: [t.siteId],
604
+ foreignColumns: [sites.id]
605
+ }),
606
+ foreignKey({
607
+ name: "publications_entry",
608
+ columns: [t.entryId, t.siteId],
609
+ foreignColumns: [entries.id, entries.siteId]
610
+ }),
611
+ foreignKey({
612
+ name: "publications_revision",
613
+ columns: [t.revisionId, t.siteId],
614
+ foreignColumns: [revisions.id, revisions.siteId]
615
+ }),
616
+ foreignKey({
617
+ name: "publications_user",
618
+ columns: [t.actorUserId],
619
+ foreignColumns: [users.id]
620
+ }),
621
+ foreignKey({
622
+ name: "publications_token",
623
+ columns: [t.actorTokenId, t.siteId],
624
+ foreignColumns: [tokens.id, tokens.siteId]
625
+ }),
626
+ check("publications_action", sql`${t.action} IN ('publish', 'unpublish')`),
627
+ check("publications_shape", sql`(${t.action} = 'publish' AND ${t.revisionId} IS NOT NULL AND ${t.verdict} IS NOT NULL) OR (${t.action} = 'unpublish' AND ${t.revisionId} IS NULL AND ${t.verdict} IS NULL)`),
628
+ check("publications_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
629
+ check("publications_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.verdict} IS NULL OR json_valid(${t.verdict})) AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`)
630
+ ]);
631
+ //#endregion
632
+ //#region src/db/rows.ts
633
+ /**
634
+ * The columns the store reads and how they become the types of `ports.ts`. The JSON columns come
635
+ * back as the store wrote them: the `CHECK`s of the schema guarantee valid JSON, and only the
636
+ * store writes the shapes.
637
+ */
638
+ /** The columns of `entries` that `StoredEntry` holds. */
639
+ const entryFields = {
640
+ id: entries.id,
641
+ collection: entries.collection,
642
+ locale: entries.locale,
643
+ slug: entries.slug,
644
+ translationGroup: entries.translationGroup,
645
+ status: entries.status,
646
+ version: entries.version,
647
+ headRev: entries.headRev,
648
+ publishedRev: entries.publishedRev,
649
+ publishedAt: entries.publishedAt,
650
+ updatedAt: entries.updatedAt
651
+ };
652
+ /** The columns of `revisions` that `StoredRevision` holds, except its tags. */
653
+ const revisionFields = {
654
+ id: revisions.id,
655
+ entryId: revisions.entryId,
656
+ markdown: revisions.markdown,
657
+ frontmatter: revisions.frontmatter,
658
+ document: revisions.document,
659
+ comarkVersion: revisions.comarkVersion,
660
+ pipelineVersion: revisions.pipelineVersion,
661
+ wordCount: revisions.wordCount,
662
+ verdict: revisions.verdict,
663
+ actorKind: revisions.actorKind,
664
+ actorUserId: revisions.actorUserId,
665
+ actorTokenId: revisions.actorTokenId,
666
+ actorMeta: revisions.actorMeta,
667
+ createdAt: revisions.createdAt
668
+ };
669
+ /**
670
+ * The columns of `revisions` that a listing reads: the id and the frontmatter of the revision of
671
+ * the view, and nothing else. The Markdown, the document and the verdict can weigh megabytes, so
672
+ * a listing never selects them (`tests/db/list.workerd.test.ts` holds the statements to it).
673
+ */
674
+ const revisionSummaryFields = {
675
+ id: revisions.id,
676
+ frontmatter: revisions.frontmatter
677
+ };
678
+ /** The columns of `publications` that `StoredPublication` holds. */
679
+ const publicationFields = {
680
+ id: publications.id,
681
+ entryId: publications.entryId,
682
+ action: publications.action,
683
+ revisionId: publications.revisionId,
684
+ verdict: publications.verdict,
685
+ acknowledged: publications.acknowledged,
686
+ actorKind: publications.actorKind,
687
+ actorUserId: publications.actorUserId,
688
+ actorTokenId: publications.actorTokenId,
689
+ actorMeta: publications.actorMeta,
690
+ createdAt: publications.createdAt
691
+ };
692
+ /**
693
+ * The tags of a revision as a JSON array, read in the same statement as the revision: one query
694
+ * per page, and the same snapshot. `revision` is the revision table the statement joins.
695
+ */
696
+ function tagsOf$1(revision) {
697
+ return sql`(select json_group_array(${revisionTags.tag}) from ${revisionTags} where ${and(eq(revisionTags.revisionId, revision.id), eq(revisionTags.siteId, revision.siteId))})`;
698
+ }
699
+ /** The value of `candidates` that `value` is, or a failure: the schema's `CHECK`s forbid the rest. */
700
+ function oneOf(candidates, value, what) {
701
+ for (const candidate of candidates) if (candidate === value) return candidate;
702
+ throw new Error(`A row has ${what} ${JSON.stringify(value)}, which the schema does not allow`);
703
+ }
704
+ /** The status of an entry, which the schema's `CHECK` limits to these three. */
705
+ function entryStatus(value) {
706
+ return oneOf([
707
+ "draft",
708
+ "scheduled",
709
+ "published"
710
+ ], value, "the status");
711
+ }
712
+ function toEntry(row) {
713
+ return {
714
+ id: row.id,
715
+ collection: row.collection,
716
+ locale: row.locale,
717
+ slug: row.slug,
718
+ translationGroup: row.translationGroup,
719
+ status: entryStatus(row.status),
720
+ version: row.version,
721
+ headRevision: row.headRev,
722
+ publishedRevision: row.publishedRev,
723
+ publishedAt: row.publishedAt,
724
+ updatedAt: row.updatedAt
725
+ };
726
+ }
727
+ function toActor(row) {
728
+ const meta = row.actorMeta === null ? null : JSON.parse(row.actorMeta);
729
+ return {
730
+ kind: oneOf([
731
+ "token",
732
+ "user",
733
+ "oauth_client"
734
+ ], row.actorKind, "the actor kind"),
735
+ userId: row.actorUserId,
736
+ tokenId: row.actorTokenId,
737
+ meta
738
+ };
739
+ }
740
+ /** The tags come back sorted, so that a revision reads the same however its tags were written. */
741
+ function parseTags(tagsJson) {
742
+ return JSON.parse(tagsJson).toSorted();
743
+ }
744
+ function toRevision(row, tagsJson) {
745
+ const frontmatter = JSON.parse(row.frontmatter);
746
+ const document = JSON.parse(row.document);
747
+ const verdict = JSON.parse(row.verdict);
748
+ return {
749
+ id: row.id,
750
+ entryId: row.entryId,
751
+ markdown: row.markdown,
752
+ frontmatter,
753
+ document,
754
+ comarkVersion: row.comarkVersion,
755
+ pipelineVersion: row.pipelineVersion,
756
+ wordCount: row.wordCount,
757
+ verdict,
758
+ tags: parseTags(tagsJson),
759
+ actor: toActor(row),
760
+ createdAt: row.createdAt
761
+ };
762
+ }
763
+ function toRevisionSummary(row, tagsJson) {
764
+ const frontmatter = JSON.parse(row.frontmatter);
765
+ return {
766
+ id: row.id,
767
+ frontmatter,
768
+ tags: parseTags(tagsJson)
769
+ };
770
+ }
771
+ function toPublication(row) {
772
+ const verdict = row.verdict === null ? null : JSON.parse(row.verdict);
773
+ const acknowledged = JSON.parse(row.acknowledged);
774
+ return {
775
+ id: row.id,
776
+ entryId: row.entryId,
777
+ action: oneOf(["publish", "unpublish"], row.action, "the action"),
778
+ revisionId: row.revisionId,
779
+ verdict,
780
+ acknowledged,
781
+ actor: toActor(row),
782
+ createdAt: row.createdAt
783
+ };
784
+ }
785
+ //#endregion
786
+ //#region src/db/reads.ts
787
+ /**
788
+ * The reads of the store. Every query carries the `site_id` of the site it was asked for, so a
789
+ * natural key, an id or a translation group of another site finds nothing.
790
+ */
791
+ /**
792
+ * Whether an entry is published, as the partial index of published listings says it
793
+ * (`entries_published` covers `status = 'published'`). It is a literal and not a bound value:
794
+ * the index serves a query that says what the index says, and SQLite can tell that of a `?` only
795
+ * by looking at the value bound to it when it plans, which is for the engine to do or not.
796
+ */
797
+ const publishedOnly = sql`${entries.status} = 'published'`;
798
+ /** How an entry reaches the revision of a view: the head revision, or the published one. */
799
+ function revisionOf$1(view) {
800
+ const revisionId = view === "head" ? entries.headRev : entries.publishedRev;
801
+ return and(eq(revisions.id, revisionId), eq(revisions.siteId, entries.siteId));
802
+ }
803
+ /** What picks the entries of a view: the caller's conditions, and in the published view only published ones. */
804
+ function viewWhere(view, conditions) {
805
+ return and(...conditions, view === "published" ? publishedOnly : void 0);
806
+ }
807
+ /**
808
+ * The entries of a site with the whole revision of a view, for the reads of one entry. The caller
809
+ * adds the conditions that pick the entries and what limits them.
810
+ */
811
+ function viewQuery(db, view, conditions) {
812
+ return db.select({
813
+ entry: entryFields,
814
+ revision: revisionFields,
815
+ tags: tagsOf$1(revisions)
816
+ }).from(entries).innerJoin(revisions, revisionOf$1(view)).where(viewWhere(view, conditions));
817
+ }
818
+ function toEntryView(row) {
819
+ return {
820
+ entry: toEntry(row.entry),
821
+ revision: toRevision(row.revision, row.tags)
822
+ };
823
+ }
824
+ /**
825
+ * The entries of a site with a summary of the revision of a view, for listings: the entry, and of
826
+ * the revision only its id, its frontmatter and its tags. The sort key of the published view
827
+ * comes along for the cursor.
828
+ */
829
+ function summaryQuery(db, view, conditions) {
830
+ return db.select({
831
+ entry: entryFields,
832
+ revision: revisionSummaryFields,
833
+ tags: tagsOf$1(revisions),
834
+ sortKey: entries.publishedSortKey
835
+ }).from(entries).innerJoin(revisions, revisionOf$1(view)).where(viewWhere(view, conditions));
836
+ }
837
+ function toEntrySummaryView(row) {
838
+ return {
839
+ entry: toEntry(row.entry),
840
+ revision: toRevisionSummary(row.revision, row.tags)
841
+ };
842
+ }
843
+ async function findEntry(db, site, ref, view) {
844
+ const [row] = await viewQuery(db, view, [
845
+ eq(entries.siteId, site),
846
+ eq(entries.collection, ref.collection),
847
+ eq(entries.locale, ref.locale),
848
+ eq(entries.slug, ref.slug)
849
+ ]).limit(1);
850
+ return row === void 0 ? null : toEntryView(row);
851
+ }
852
+ async function findEntryById(db, site, id, view) {
853
+ const [row] = await viewQuery(db, view, [eq(entries.siteId, site), eq(entries.id, id)]).limit(1);
854
+ return row === void 0 ? null : toEntryView(row);
855
+ }
856
+ /** A revision of the site by its id, with its tags: what a publish answers with. */
857
+ async function findRevision(db, site, revisionId) {
858
+ const [row] = await db.select({
859
+ revision: revisionFields,
860
+ tags: tagsOf$1(revisions)
861
+ }).from(revisions).where(and(eq(revisions.id, revisionId), eq(revisions.siteId, site))).limit(1);
862
+ return row === void 0 ? null : toRevision(row.revision, row.tags);
863
+ }
864
+ /** Whether the revision a view joined has the tag. */
865
+ function hasTag(site, tag) {
866
+ return sql`exists (select 1 from ${revisionTags} where ${and(eq(revisionTags.revisionId, revisions.id), eq(revisionTags.siteId, site), eq(revisionTags.tag, tag))})`;
867
+ }
868
+ /**
869
+ * The statement of a page of a listing: the entries newest first by the sort key of the view and
870
+ * then by id, after the position of a cursor if there is one, and one more than the page holds,
871
+ * which only tells whether there is a next page.
872
+ *
873
+ * It is shaped for the indexes of `entries`, so that a page costs its own rows and not a sort of
874
+ * the collection (`tests/db/list-plans.workerd.test.ts` reads the plans). The head view reads
875
+ * `entries_head`, or `entries_head_locale` when it names a locale. The published view of a locale
876
+ * reads the partial `entries_published`, whose status is the literal the index says. The cursor
877
+ * is a row value, `(sort key, id) < (?, ?)`, which the index seeks to; the same condition written
878
+ * with `OR` is read from the first row of the collection. The published view with no locale
879
+ * named is the exception: the index has the locale before the sort key, so SQLite sorts the
880
+ * published entries of the collection.
881
+ */
882
+ function listEntriesQuery(db, site, query, cursor) {
883
+ const sortKey = query.view === "head" ? entries.updatedAt : entries.publishedSortKey;
884
+ return summaryQuery(db, query.view, [
885
+ eq(entries.siteId, site),
886
+ eq(entries.collection, query.collection),
887
+ query.locale === void 0 ? void 0 : eq(entries.locale, query.locale),
888
+ query.view === "head" && query.status !== void 0 ? eq(entries.status, query.status) : void 0,
889
+ query.tag === void 0 ? void 0 : hasTag(site, query.tag),
890
+ cursor === null ? void 0 : sql`(${sortKey}, ${entries.id}) < (${cursor.key}, ${cursor.id})`
891
+ ]).orderBy(desc(sortKey), desc(entries.id)).limit(query.limit + 1);
892
+ }
893
+ async function listEntries$2(db, site, query) {
894
+ if (!Number.isInteger(query.limit) || query.limit < 1) throw new HollwareError("invalid_input", `The limit ${query.limit} is not a whole number of at least 1.`, "Ask for at least one entry per page.");
895
+ const cursor = query.cursor === void 0 ? null : decodeCursor(query.cursor, query.view);
896
+ if (query.view === "published" && query.status !== void 0 && query.status !== "published") return {
897
+ entries: [],
898
+ nextCursor: null
899
+ };
900
+ const rows = await listEntriesQuery(db, site, query, cursor);
901
+ const page = rows.slice(0, query.limit);
902
+ const last = page.at(-1);
903
+ if (last === void 0 || rows.length === page.length) return {
904
+ entries: page.map(toEntrySummaryView),
905
+ nextCursor: null
906
+ };
907
+ const key = query.view === "head" ? last.entry.updatedAt : last.sortKey;
908
+ if (key === null) throw new Error(`The published entry ${last.entry.id} has no published_sort_key`);
909
+ return {
910
+ entries: page.map(toEntrySummaryView),
911
+ nextCursor: encodeCursor(query.view, {
912
+ key,
913
+ id: last.entry.id
914
+ })
915
+ };
916
+ }
917
+ /**
918
+ * The statement of the entries of a translation group: the unique key of the group ends in the
919
+ * locale, so the index gives them in order of locale with no sort.
920
+ */
921
+ function listTranslationsQuery(db, site, collection, translationGroup, view) {
922
+ return summaryQuery(db, view, [
923
+ eq(entries.siteId, site),
924
+ eq(entries.collection, collection),
925
+ eq(entries.translationGroup, translationGroup)
926
+ ]).orderBy(entries.locale);
927
+ }
928
+ async function listTranslations(db, site, collection, translationGroup, view) {
929
+ return (await listTranslationsQuery(db, site, collection, translationGroup, view)).map(toEntrySummaryView);
930
+ }
931
+ /**
932
+ * The latest publish or unpublish of an entry. Rows are never deleted, so the insertion order
933
+ * (`rowid`) breaks a tie between two written in the same millisecond.
934
+ */
935
+ async function lastPublication(db, site, entryId) {
936
+ const [row] = await db.select(publicationFields).from(publications).where(and(eq(publications.siteId, site), eq(publications.entryId, entryId))).orderBy(desc(publications.createdAt), sql`${publications}.rowid desc`).limit(1);
937
+ return row === void 0 ? null : toPublication(row);
938
+ }
939
+ const factFields = {
940
+ id: entries.id,
941
+ collection: entries.collection,
942
+ locale: entries.locale,
943
+ slug: entries.slug,
944
+ status: entries.status,
945
+ translationGroup: entries.translationGroup
946
+ };
947
+ function refKey(ref) {
948
+ return JSON.stringify([
949
+ ref.collection,
950
+ ref.locale,
951
+ ref.slug
952
+ ]);
953
+ }
954
+ function refMatches(ref) {
955
+ return and(eq(entries.collection, ref.collection), eq(entries.locale, ref.locale), eq(entries.slug, ref.slug));
956
+ }
957
+ /** What cross-entry contracts read, scoped to one site. */
958
+ function lookup(db, site) {
959
+ return {
960
+ /**
961
+ * One query per chunk of refs, in one batch: each ref binds three values and the site one
962
+ * more, under D1's limit of bound values. A ref asked twice is asked once.
963
+ */
964
+ async lookupMany(refs) {
965
+ const [first, ...rest] = chunk([...new Map(refs.map((ref) => [refKey(ref), ref])).values()], rowsPerStatement(1, 3)).map((group) => db.select(factFields).from(entries).where(and(eq(entries.siteId, site), or(...group.map(refMatches)))));
966
+ if (first === void 0) return [];
967
+ const found = /* @__PURE__ */ new Map();
968
+ for (const row of (await db.batch([first, ...rest])).flat()) found.set(refKey(row), {
969
+ id: row.id,
970
+ status: entryStatus(row.status),
971
+ translationGroup: row.translationGroup
972
+ });
973
+ return refs.map((ref) => found.get(refKey(ref)) ?? null);
974
+ },
975
+ async translations(collection, translationGroup) {
976
+ return (await db.select(factFields).from(entries).where(and(eq(entries.siteId, site), eq(entries.collection, collection), eq(entries.translationGroup, translationGroup))).orderBy(entries.locale)).map((row) => ({
977
+ collection: row.collection,
978
+ locale: row.locale,
979
+ slug: row.slug,
980
+ id: row.id,
981
+ status: entryStatus(row.status),
982
+ translationGroup: row.translationGroup
983
+ }));
984
+ }
985
+ };
986
+ }
987
+ //#endregion
988
+ //#region src/db/errors.ts
989
+ /**
990
+ * What D1 says when a statement breaks a unique key: `D1_ERROR: UNIQUE constraint failed:
991
+ * entries.site_id, …: SQLITE_CONSTRAINT`, with the same text again in the `cause`. Inside a batch
992
+ * the error arrives as it is, and the batch has rolled back.
993
+ */
994
+ function isUniqueViolation(error) {
995
+ let current = error;
996
+ for (let depth = 0; depth < 4 && current instanceof Error; depth += 1) {
997
+ if (current.message.includes("UNIQUE constraint failed")) return true;
998
+ current = current.cause;
999
+ }
1000
+ return false;
1001
+ }
1002
+ //#endregion
1003
+ //#region src/db/size.ts
1004
+ /**
1005
+ * D1 refuses rows over 2 MB, and Miniflare does not enforce it, so the store counts the bytes of
1006
+ * a row before writing it and refuses a row over `MAX_ROW_BYTES` (`limits.ts`).
1007
+ */
1008
+ const encoder$1 = new TextEncoder();
1009
+ /** The bytes of `text` in UTF-8, which is how D1 stores it. */
1010
+ function utf8Bytes(text) {
1011
+ return encoder$1.encode(text).byteLength;
1012
+ }
1013
+ /** What SQLite keeps for an integer, at most. */
1014
+ const INTEGER_BYTES = 8;
1015
+ /** The bytes of every column of a row: text in UTF-8, an integer as 8 bytes, a null as none. */
1016
+ function rowBytes(row) {
1017
+ let bytes = 0;
1018
+ for (const value of Object.values(row)) if (typeof value === "string") bytes += utf8Bytes(value);
1019
+ else if (value !== null) bytes += INTEGER_BYTES;
1020
+ return bytes;
1021
+ }
1022
+ /** The refusal for a row over the limit, or `null` when it fits. */
1023
+ function tooLarge(row) {
1024
+ const bytes = rowBytes(row);
1025
+ return bytes > 19e5 ? {
1026
+ outcome: "too_large",
1027
+ bytes,
1028
+ limit: MAX_ROW_BYTES
1029
+ } : null;
1030
+ }
1031
+ //#endregion
1032
+ //#region src/db/statements.ts
1033
+ function actorColumns(actor) {
1034
+ return {
1035
+ actorKind: actor.kind,
1036
+ actorUserId: actor.userId,
1037
+ actorTokenId: actor.tokenId,
1038
+ actorMeta: actor.meta === null ? null : JSON.stringify(actor.meta)
1039
+ };
1040
+ }
1041
+ /** The tags of a revision as written: each once, sorted, so that a revision always reads the same. */
1042
+ function distinctTags(tags) {
1043
+ return [...new Set(tags)].toSorted();
1044
+ }
1045
+ function revisionRow(input) {
1046
+ const { revision } = input;
1047
+ return {
1048
+ id: input.id,
1049
+ siteId: input.site,
1050
+ entryId: input.entryId,
1051
+ markdown: revision.markdown,
1052
+ frontmatter: JSON.stringify(revision.frontmatter),
1053
+ document: JSON.stringify(revision.document),
1054
+ comarkVersion: revision.comarkVersion,
1055
+ pipelineVersion: revision.pipelineVersion,
1056
+ wordCount: revision.wordCount,
1057
+ verdict: JSON.stringify(revision.verdict),
1058
+ ...actorColumns(revision.actor),
1059
+ createdAt: input.now,
1060
+ updatedAt: input.now
1061
+ };
1062
+ }
1063
+ function publicationRow(input) {
1064
+ return {
1065
+ id: newId("pub"),
1066
+ siteId: input.site,
1067
+ entryId: input.entryId,
1068
+ action: input.publish === null ? "unpublish" : "publish",
1069
+ revisionId: input.publish?.revisionId ?? null,
1070
+ verdict: input.publish === null ? null : JSON.stringify(input.publish.verdict),
1071
+ acknowledged: JSON.stringify(input.publish?.acknowledged ?? []),
1072
+ ...actorColumns(input.actor),
1073
+ createdAt: input.now,
1074
+ updatedAt: input.now
1075
+ };
1076
+ }
1077
+ /**
1078
+ * The values of a row in the order of the table's columns, for `insert into t select …`: the
1079
+ * insert lists every column of the table, so the select has to give them all, and a column that
1080
+ * the row lacks fails here instead of shifting the ones after it. A value may be a column or any
1081
+ * SQL, for what only the database knows.
1082
+ */
1083
+ function valuesOf(table, row) {
1084
+ const values = Object.keys(getTableColumns(table)).map((field) => {
1085
+ const value = row[field];
1086
+ if (value === void 0) throw new Error(`The row has no value for the column ${field}`);
1087
+ return sql`${value}`;
1088
+ });
1089
+ return sql.join(values, sql`, `);
1090
+ }
1091
+ /** Inserts the revision of an entry only if the entry is still at `version`. */
1092
+ function insertRevisionAtVersion(db, row, version) {
1093
+ return db.insert(revisions).select(sql`select ${valuesOf(revisions, row)} from ${entries} where ${and(eq(entries.id, row.entryId), eq(entries.siteId, row.siteId), eq(entries.version, version))}`);
1094
+ }
1095
+ /**
1096
+ * Inserts the tags of a revision, in as many statements as D1's limit of bound values needs, and
1097
+ * only where the revision is there: a tag whose revision was never inserted would fail the
1098
+ * foreign key and turn a conflict into a database error. Each tag binds its id and its text;
1099
+ * the site, the revision and the time are bound once, and the existence check binds two more.
1100
+ * The tags are a `VALUES` list: D1 allows a compound `SELECT` only five terms long, so a
1101
+ * `UNION ALL` of tags would fail from the sixth.
1102
+ */
1103
+ function insertTags(db, input) {
1104
+ const perStatement = rowsPerStatement(6, 2);
1105
+ return chunk(input.tags, perStatement).map((tags) => {
1106
+ const rows = tags.map((tag) => sql`(${newId("tag")}, ${tag})`);
1107
+ return db.insert(revisionTags).select(sql`with "t" ("id", "tag") as (values ${sql.join(rows, sql`, `)}) select "t"."id", ${input.site}, ${input.revisionId}, "t"."tag", ${input.now}, ${input.now} from "t" where exists (select 1 from ${revisions} where ${and(eq(revisions.id, input.revisionId), eq(revisions.siteId, input.site))})`);
1108
+ });
1109
+ }
1110
+ /**
1111
+ * Appends a publication that publishes the head revision of an entry that is still at `version`.
1112
+ * The revision is whatever `head_rev` is when the statement runs.
1113
+ */
1114
+ function insertPublishAtVersion(db, row, version) {
1115
+ return db.insert(publications).select(sql`select ${valuesOf(publications, {
1116
+ ...row,
1117
+ revisionId: entries.headRev
1118
+ })} from ${entries} where ${and(eq(entries.id, row.entryId), eq(entries.siteId, row.siteId), eq(entries.version, version))}`);
1119
+ }
1120
+ /** Appends an unpublish, only if the entry is published when the statement runs. */
1121
+ function insertUnpublishIfPublished(db, row) {
1122
+ return db.insert(publications).select(sql`select ${valuesOf(publications, row)} from ${entries} where ${and(eq(entries.id, row.entryId), eq(entries.siteId, row.siteId), eq(entries.status, "published"))}`);
1123
+ }
1124
+ //#endregion
1125
+ //#region src/db/writes.ts
1126
+ /**
1127
+ * Waits for a write batch. A batch that broke a unique key of `entries` has rolled back, and the
1128
+ * answer is `taken` by the entry that holds the key, found by `holder`. A unique violation nobody
1129
+ * holds the key for, and any other error, is not an outcome: it is thrown as it came.
1130
+ */
1131
+ async function attempt(batch, holder) {
1132
+ try {
1133
+ return { written: await batch };
1134
+ } catch (error) {
1135
+ const taken = isUniqueViolation(error) ? await holder() : null;
1136
+ if (taken === null) throw error;
1137
+ return { taken };
1138
+ }
1139
+ }
1140
+ /** The entry that holds the slug or the translation that a new entry asks for; the slug first. */
1141
+ async function holderOfNewEntry(db, site, entry) {
1142
+ const rows = await db.select({
1143
+ id: entries.id,
1144
+ slug: entries.slug
1145
+ }).from(entries).where(and(eq(entries.siteId, site), eq(entries.collection, entry.collection), eq(entries.locale, entry.locale), or(eq(entries.slug, entry.slug), entry.translationGroup === null ? void 0 : eq(entries.translationGroup, entry.translationGroup))));
1146
+ const bySlug = rows.find((row) => row.slug === entry.slug);
1147
+ if (bySlug !== void 0) return {
1148
+ outcome: "taken",
1149
+ field: "slug",
1150
+ by: bySlug.id
1151
+ };
1152
+ const [byGroup] = rows;
1153
+ return byGroup === void 0 ? null : {
1154
+ outcome: "taken",
1155
+ field: "translationGroup",
1156
+ by: byGroup.id
1157
+ };
1158
+ }
1159
+ /** The other entry of the collection and locale of `entryId` that is in `translationGroup`. */
1160
+ async function holderOfTranslation(db, site, entryId, translationGroup) {
1161
+ if (translationGroup === null) return null;
1162
+ const other = alias(entries, "other");
1163
+ const [row] = await db.select({ id: other.id }).from(entries).innerJoin(other, and(eq(other.siteId, entries.siteId), eq(other.collection, entries.collection), eq(other.locale, entries.locale), eq(other.translationGroup, translationGroup), ne(other.id, entries.id))).where(and(eq(entries.siteId, site), eq(entries.id, entryId))).limit(1);
1164
+ return row === void 0 ? null : {
1165
+ outcome: "taken",
1166
+ field: "translationGroup",
1167
+ by: row.id
1168
+ };
1169
+ }
1170
+ /** Why an update touched nothing: the entry moved on (`conflict`) or it is not there. */
1171
+ async function whyNothingChanged(db, site, entryId) {
1172
+ const [row] = await db.select({
1173
+ version: entries.version,
1174
+ headRev: entries.headRev
1175
+ }).from(entries).where(and(eq(entries.siteId, site), eq(entries.id, entryId))).limit(1);
1176
+ return row === void 0 ? { outcome: "not_found" } : {
1177
+ outcome: "conflict",
1178
+ currentVersion: row.version,
1179
+ headRevision: row.headRev
1180
+ };
1181
+ }
1182
+ /** The revision as a read of it would give it back, for the answer of a write that made it. */
1183
+ function storedRevision(input) {
1184
+ const { revision } = input;
1185
+ return {
1186
+ id: input.id,
1187
+ entryId: input.entryId,
1188
+ markdown: revision.markdown,
1189
+ frontmatter: revision.frontmatter,
1190
+ document: revision.document,
1191
+ comarkVersion: revision.comarkVersion,
1192
+ pipelineVersion: revision.pipelineVersion,
1193
+ wordCount: revision.wordCount,
1194
+ verdict: revision.verdict,
1195
+ tags: input.tags,
1196
+ actor: revision.actor,
1197
+ createdAt: input.now
1198
+ };
1199
+ }
1200
+ async function createEntry(db, clock, site, entry) {
1201
+ const now = clock.now().toISOString();
1202
+ const entryId = newId("ent");
1203
+ const revisionId = newId("rev");
1204
+ const row = revisionRow({
1205
+ id: revisionId,
1206
+ site,
1207
+ entryId,
1208
+ now,
1209
+ revision: entry.revision
1210
+ });
1211
+ const refusal = tooLarge(row);
1212
+ if (refusal !== null) return refusal;
1213
+ const tags = distinctTags(entry.revision.tags);
1214
+ const result = await attempt(db.batch([
1215
+ db.insert(entries).values({
1216
+ id: entryId,
1217
+ siteId: site,
1218
+ collection: entry.collection,
1219
+ locale: entry.locale,
1220
+ slug: entry.slug,
1221
+ translationGroup: entry.translationGroup,
1222
+ status: "draft",
1223
+ version: 1,
1224
+ headRev: revisionId,
1225
+ publishedRev: null,
1226
+ publishedAt: null,
1227
+ publishedSortKey: null,
1228
+ scheduledAt: null,
1229
+ createdAt: now,
1230
+ updatedAt: now
1231
+ }),
1232
+ db.insert(revisions).values(row),
1233
+ ...insertTags(db, {
1234
+ site,
1235
+ revisionId,
1236
+ tags,
1237
+ now
1238
+ })
1239
+ ]), () => holderOfNewEntry(db, site, entry));
1240
+ if ("taken" in result) return result.taken;
1241
+ return {
1242
+ outcome: "saved",
1243
+ view: {
1244
+ entry: {
1245
+ id: entryId,
1246
+ collection: entry.collection,
1247
+ locale: entry.locale,
1248
+ slug: entry.slug,
1249
+ translationGroup: entry.translationGroup,
1250
+ status: "draft",
1251
+ version: 1,
1252
+ headRevision: revisionId,
1253
+ publishedRevision: null,
1254
+ publishedAt: null,
1255
+ updatedAt: now
1256
+ },
1257
+ revision: storedRevision({
1258
+ id: revisionId,
1259
+ entryId,
1260
+ now,
1261
+ revision: entry.revision,
1262
+ tags
1263
+ })
1264
+ }
1265
+ };
1266
+ }
1267
+ async function appendRevision(db, clock, site, write) {
1268
+ const now = clock.now().toISOString();
1269
+ const revisionId = newId("rev");
1270
+ const row = revisionRow({
1271
+ id: revisionId,
1272
+ site,
1273
+ entryId: write.entryId,
1274
+ now,
1275
+ revision: write.revision
1276
+ });
1277
+ const refusal = tooLarge(row);
1278
+ if (refusal !== null) return refusal;
1279
+ const tags = distinctTags(write.revision.tags);
1280
+ const result = await attempt(db.batch([
1281
+ insertRevisionAtVersion(db, row, write.expectedVersion),
1282
+ ...insertTags(db, {
1283
+ site,
1284
+ revisionId,
1285
+ tags,
1286
+ now
1287
+ }),
1288
+ db.update(entries).set({
1289
+ version: sql`${entries.version} + 1`,
1290
+ headRev: revisionId,
1291
+ translationGroup: write.translationGroup,
1292
+ updatedAt: now
1293
+ }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))).returning(entryFields)
1294
+ ]), () => holderOfTranslation(db, site, write.entryId, write.translationGroup));
1295
+ if ("taken" in result) return result.taken;
1296
+ const updated = result.written.at(-1);
1297
+ const [changed] = Array.isArray(updated) ? updated : [];
1298
+ if (changed === void 0) return whyNothingChanged(db, site, write.entryId);
1299
+ return {
1300
+ outcome: "saved",
1301
+ view: {
1302
+ entry: toEntry(changed),
1303
+ revision: storedRevision({
1304
+ id: revisionId,
1305
+ entryId: write.entryId,
1306
+ now,
1307
+ revision: write.revision,
1308
+ tags
1309
+ })
1310
+ }
1311
+ };
1312
+ }
1313
+ async function publish$2(db, clock, site, write) {
1314
+ const now = clock.now().toISOString();
1315
+ const row = publicationRow({
1316
+ site,
1317
+ entryId: write.entryId,
1318
+ now,
1319
+ actor: write.actor,
1320
+ publish: {
1321
+ revisionId: newId("rev"),
1322
+ verdict: write.verdict,
1323
+ acknowledged: write.acknowledged
1324
+ }
1325
+ });
1326
+ const refusal = tooLarge(row);
1327
+ if (refusal !== null) return refusal;
1328
+ const [changed] = (await db.batch([insertPublishAtVersion(db, row, write.expectedVersion), db.update(entries).set({
1329
+ status: "published",
1330
+ publishedRev: entries.headRev,
1331
+ publishedAt: sql`coalesce(${entries.publishedAt}, ${now})`,
1332
+ publishedSortKey: write.sortKey,
1333
+ version: sql`${entries.version} + 1`,
1334
+ updatedAt: now
1335
+ }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))).returning(entryFields)]))[1];
1336
+ if (changed === void 0) return whyNothingChanged(db, site, write.entryId);
1337
+ const entry = toEntry(changed);
1338
+ const revision = entry.publishedRevision === null ? null : await findRevision(db, site, entry.publishedRevision);
1339
+ if (revision === null) throw new Error(`The revision that entry ${entry.id} was published at is not there`);
1340
+ return {
1341
+ outcome: "published",
1342
+ view: {
1343
+ entry,
1344
+ revision
1345
+ }
1346
+ };
1347
+ }
1348
+ async function unpublish$1(db, clock, site, write) {
1349
+ const now = clock.now().toISOString();
1350
+ const row = publicationRow({
1351
+ site,
1352
+ entryId: write.entryId,
1353
+ now,
1354
+ actor: write.actor,
1355
+ publish: null
1356
+ });
1357
+ const refusal = tooLarge(row);
1358
+ if (refusal !== null) return refusal;
1359
+ await db.batch([insertUnpublishIfPublished(db, row), db.update(entries).set({
1360
+ status: "draft",
1361
+ publishedRev: null,
1362
+ publishedSortKey: null,
1363
+ version: sql`${entries.version} + 1`,
1364
+ updatedAt: now
1365
+ }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.status, "published")))]);
1366
+ const view = await findEntryById(db, site, write.entryId, "head");
1367
+ return view === null ? { outcome: "not_found" } : {
1368
+ outcome: "unpublished",
1369
+ view
1370
+ };
1371
+ }
1372
+ //#endregion
1373
+ //#region src/db/store.ts
1374
+ const systemClock$1 = { now: () => /* @__PURE__ */ new Date() };
1375
+ /**
1376
+ * The content store on D1 (`ports.ts`). `db` is a D1 database, which the writes need, or a D1
1377
+ * session, which is enough for reads: `env.DB.withSession('first-unconstrained')` is what RPC
1378
+ * reads use. Every method takes the site first and only reads and writes that site's rows.
1379
+ */
1380
+ function createD1Store(db, options = {}) {
1381
+ const database = connect(db);
1382
+ const clock = options.clock ?? systemClock$1;
1383
+ return {
1384
+ findEntry: (site, ref, view) => findEntry(database, site, ref, view),
1385
+ findEntryById: (site, id, view) => findEntryById(database, site, id, view),
1386
+ listEntries: (site, query) => listEntries$2(database, site, query),
1387
+ listTranslations: (site, collection, translationGroup, view) => listTranslations(database, site, collection, translationGroup, view),
1388
+ lookup: (site) => lookup(database, site),
1389
+ lastPublication: (site, entryId) => lastPublication(database, site, entryId),
1390
+ createEntry: (site, entry) => createEntry(database, clock, site, entry),
1391
+ appendRevision: (site, write) => appendRevision(database, clock, site, write),
1392
+ publish: (site, write) => publish$2(database, clock, site, write),
1393
+ unpublish: (site, write) => unpublish$1(database, clock, site, write)
1394
+ };
1395
+ }
1396
+ //#endregion
1397
+ //#region src/mcp/errors.ts
1398
+ /**
1399
+ * The error a caller gets for anything that is not a `HollwareError`. The message is fixed: what
1400
+ * went wrong goes to the log and never to the caller, whose agent could repeat it to a person or
1401
+ * to another system. The request id is how the operator finds the log line.
1402
+ */
1403
+ function internalError$1(requestId, cause) {
1404
+ return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${requestId}.`, { cause });
1405
+ }
1406
+ /** The fix of `timeout`, by effect: a write that ran out of time may still finish. */
1407
+ const TIMEOUT_FIX = {
1408
+ read: "Try again. If it keeps timing out, ask for less.",
1409
+ edit: "The change may still be applied after this answer: read the entry with get_entry before you repeat the call.",
1410
+ write: "The change may still be applied after this answer: read the entry with get_entry before you repeat the call.",
1411
+ destructive: "The change may still be applied after this answer: read the entry with get_entry before you repeat the call."
1412
+ };
1413
+ function timeoutError(spec, timeoutMs) {
1414
+ return new HollwareError("timeout", `${spec.name} did not finish within ${timeoutMs} ms.`, TIMEOUT_FIX[spec.effect]);
1415
+ }
1416
+ /** A value that is not an error, as text: handlers may throw anything. */
1417
+ function printable(value) {
1418
+ if (typeof value === "string") return value;
1419
+ try {
1420
+ return JSON.stringify(value) ?? typeof value;
1421
+ } catch {
1422
+ return typeof value;
1423
+ }
1424
+ }
1425
+ /**
1426
+ * What the operator needs to know about an error, for the log: its stack (or its type and
1427
+ * message), then the same for each cause. It is never sent to the caller.
1428
+ */
1429
+ function describeError(error) {
1430
+ const lines = [];
1431
+ let current = error;
1432
+ for (let depth = 0; depth < 5 && current !== void 0; depth++) if (current instanceof Error) {
1433
+ lines.push(current.stack ?? `${current.name}: ${current.message}`);
1434
+ current = current.cause;
1435
+ } else {
1436
+ lines.push(printable(current));
1437
+ current = void 0;
1438
+ }
1439
+ return lines.join("\ncaused by: ");
1440
+ }
1441
+ //#endregion
1442
+ //#region src/http/errors.ts
1443
+ /** A JSON error response that no cache keeps. */
1444
+ function gatewayError(status, body) {
1445
+ return Response.json(body, {
1446
+ status,
1447
+ headers: { "cache-control": "no-store" }
1448
+ });
1449
+ }
1450
+ /**
1451
+ * Everything that differs between Hono 4 and Hono 5 error handling lives in this file (spike S1).
1452
+ *
1453
+ * Hono 4: `app.onError((error, c) => Response)`, one handler for the app.
1454
+ * Hono 5: `app.onError((c, next) => Response)` registers error middleware, and the error is
1455
+ * `c.error`. A Hono 4 handler registered on Hono 5 is not rejected: it is called with the context
1456
+ * where it expects the error, throws, and the app answers a plain-text 500. So the tests read the
1457
+ * JSON body of both answers.
1458
+ *
1459
+ * - An error that a route throws is a JSON 500 with a fixed message, and the id of the request
1460
+ * in its fix, which is how the operator finds the line of the log that has the cause. The cause
1461
+ * goes to the log and never to the caller, whose agent could repeat it to a person or to another
1462
+ * system. This holds for every error, a failed D1 query included: nothing turns one into a 401.
1463
+ * - A path that no route has is a JSON 404.
1464
+ */
1465
+ function installErrorHandling(app, log) {
1466
+ app.onError((c) => {
1467
+ const requestId = c.get("requestId");
1468
+ log({
1469
+ kind: "request_error",
1470
+ requestId,
1471
+ method: c.req.method,
1472
+ path: c.req.path,
1473
+ detail: describeError(c.error)
1474
+ });
1475
+ return gatewayError(500, {
1476
+ code: "internal",
1477
+ message: "The request failed on the server.",
1478
+ fix: `Try again. If it keeps failing, give the site operator this request id: ${requestId}.`
1479
+ });
1480
+ });
1481
+ app.notFound(() => gatewayError(404, {
1482
+ code: "not_found",
1483
+ message: "This Hollware has no route for that path.",
1484
+ fix: "Send MCP requests to /mcp with \"Authorization: Bearer <token>\". GET /health tells whether the Worker is up."
1485
+ }));
1486
+ }
1487
+ //#endregion
1488
+ //#region src/http/gateway.ts
1489
+ /**
1490
+ * The HTTP gateway of the Worker (ADR-019): a Hono 5 app, built once, that runs in this order for
1491
+ * every request:
1492
+ *
1493
+ * 1. an id for the request, which its errors and its log lines carry;
1494
+ * 2. the Host and Origin guard (`checkHostAndOrigin`), before any route, health and unknown
1495
+ * paths included. It fails closed: no valid `HOLLWARE_PUBLIC_URL` is a 500;
1496
+ * 3. the routes: `GET /health` answers `{ "ok": true }`, and `/mcp` answers every method, after
1497
+ * the token check below. Any other path is a JSON 404.
1498
+ *
1499
+ * The token check is the 401 that stands in front of the MCP endpoint, which never authenticates:
1500
+ * no token, or a token that `checkPersonalToken` refuses, is `unauthorized()` and the endpoint is
1501
+ * not called. It looks the token up on the `D1Database` binding and never on a replica session,
1502
+ * so a revoked token stops at once. Its reason goes to the log and never to the answer. The write
1503
+ * of `last_used_at` is handed to `ctx.waitUntil`: Workers cancel a promise nobody waits for.
1504
+ *
1505
+ * The endpoint then gets the request as it came, with its body and its `Mcp-*` headers untouched
1506
+ * (a 2026 client's headers must agree with its body), and the primary database: an agent reads
1507
+ * what it has just written.
1508
+ *
1509
+ * Whatever a route throws ends in the error middleware (`errors.ts`) as a JSON 500 that leaks
1510
+ * nothing. A failed D1 query while the token is checked is one of those: it is never a 401.
1511
+ */
1512
+ function createGateway(options) {
1513
+ const { site, mcp, clock, log } = options;
1514
+ const app = new Hono();
1515
+ app.use("*", async (c, next) => {
1516
+ c.set("requestId", crypto.randomUUID());
1517
+ await next();
1518
+ });
1519
+ app.use("*", async (c, next) => {
1520
+ const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL);
1521
+ if (refusal !== null) return refusal;
1522
+ await next();
1523
+ });
1524
+ app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
1525
+ app.all("/mcp", async (c) => {
1526
+ const requestId = c.get("requestId");
1527
+ const token = readBearerToken(c.req.raw);
1528
+ if (token === null) {
1529
+ log({
1530
+ kind: "auth_refused",
1531
+ requestId,
1532
+ reason: "no_token"
1533
+ });
1534
+ return unauthorized();
1535
+ }
1536
+ const check = await checkPersonalToken(c.env.DB, token, {
1537
+ site: site.key,
1538
+ now: clock.now()
1539
+ });
1540
+ if (!check.ok) {
1541
+ log({
1542
+ kind: "auth_refused",
1543
+ requestId,
1544
+ reason: check.reason
1545
+ });
1546
+ return unauthorized(check.reason);
1547
+ }
1548
+ if (check.touch !== null) c.executionCtx.waitUntil(check.touch);
1549
+ return await mcp.fetch(c.req.raw, {
1550
+ principal: check.principal,
1551
+ requestId,
1552
+ context: {
1553
+ site,
1554
+ store: createD1Store(c.env.DB, { clock }),
1555
+ clock
1556
+ }
1557
+ });
1558
+ });
1559
+ installErrorHandling(app, log);
1560
+ return async (request, env, ctx) => await app.fetch(request, env, ctx);
1561
+ }
1562
+ //#endregion
1563
+ //#region src/mcp/annotations.ts
1564
+ /**
1565
+ * The MCP hints of each effect (ADR-017 and the v0.1 plan). A `read` is read-only and
1566
+ * idempotent; an `edit` or a `write` is neither read-only nor destructive; a `destructive`
1567
+ * operation is destructive, and repeating it changes nothing more. No tool reaches outside the
1568
+ * deployment, so every tool is closed-world.
1569
+ */
1570
+ const EFFECT_ANNOTATIONS = {
1571
+ read: {
1572
+ readOnlyHint: true,
1573
+ destructiveHint: false,
1574
+ idempotentHint: true,
1575
+ openWorldHint: false
1576
+ },
1577
+ edit: {
1578
+ readOnlyHint: false,
1579
+ destructiveHint: false,
1580
+ idempotentHint: false,
1581
+ openWorldHint: false
1582
+ },
1583
+ write: {
1584
+ readOnlyHint: false,
1585
+ destructiveHint: false,
1586
+ idempotentHint: false,
1587
+ openWorldHint: false
1588
+ },
1589
+ destructive: {
1590
+ readOnlyHint: false,
1591
+ destructiveHint: true,
1592
+ idempotentHint: true,
1593
+ openWorldHint: false
1594
+ }
1595
+ };
1596
+ //#endregion
1597
+ //#region src/mcp/acknowledge.ts
1598
+ /** The key of the question in `inputRequests`, and of the answer in `inputResponses`. */
1599
+ const QUESTION_KEY = "acknowledge_warnings";
1600
+ const form = z.object({ acknowledge: z.boolean().describe("Publish with these warnings.") });
1601
+ function warningsOf(output) {
1602
+ return output.verdict?.warnings ?? [];
1603
+ }
1604
+ function question(output, warnings) {
1605
+ const subject = output.entry === void 0 ? "this entry" : `${output.entry.collection}/${output.entry.locale}/${output.entry.slug}`;
1606
+ const count = warnings.length === 1 ? "1 warning" : `${warnings.length} warnings`;
1607
+ const lines = warnings.map((warning, index) => {
1608
+ const where = warning.line === void 0 ? warning.path : `${warning.path}, line ${warning.line}`;
1609
+ return `${index + 1}. ${warning.contract} at ${where}: ${warning.message} Fix: ${warning.fix}`;
1610
+ });
1611
+ return [
1612
+ `Publish ${subject} with ${count}?`,
1613
+ "",
1614
+ ...lines
1615
+ ].join("\n");
1616
+ }
1617
+ /**
1618
+ * The question to put to the person: every warning with its fix, and one box to tick. The
1619
+ * `needs_acknowledgement` answer travels in `requestState`, because the retry is a request of its
1620
+ * own and the server keeps nothing between the two: it is what a refusal answers, and where the
1621
+ * warnings to acknowledge come from. The client sends the state back as it got it, and it could
1622
+ * write any state it likes; the state grants nothing that `acknowledgeWarnings` does not, since a
1623
+ * client sends that argument by hand.
1624
+ */
1625
+ function askToAcknowledge(output) {
1626
+ return inputRequired({
1627
+ inputRequests: { [QUESTION_KEY]: inputRequired.elicit({
1628
+ message: question(output, warningsOf(output)),
1629
+ requestedSchema: form
1630
+ }) },
1631
+ requestState: JSON.stringify(output)
1632
+ });
1633
+ }
1634
+ /** The answer that the state of a retry carries, or `undefined` if it carries none that we wrote. */
1635
+ function askedIn(state) {
1636
+ if (typeof state !== "string") return void 0;
1637
+ let json;
1638
+ try {
1639
+ json = JSON.parse(state);
1640
+ } catch {
1641
+ return;
1642
+ }
1643
+ const parsed = publish.output.safeParse(json);
1644
+ return parsed.success && parsed.data.outcome === "needs_acknowledgement" ? parsed.data : void 0;
1645
+ }
1646
+ /**
1647
+ * The person's reply, when the call is the retry of a question we asked, and `null` when it is
1648
+ * not: a first call, or a retry that lost the state, which is treated as a first call. A decline,
1649
+ * a cancel and a form accepted with the box empty are all a refusal.
1650
+ */
1651
+ function replyTo(mcp) {
1652
+ if (inputResponse(mcp.mcpReq.inputResponses, QUESTION_KEY).kind !== "elicit") return null;
1653
+ const asked = askedIn(mcp.mcpReq.requestState());
1654
+ if (asked === void 0) return null;
1655
+ return {
1656
+ given: acceptedContent(mcp.mcpReq.inputResponses, QUESTION_KEY, form)?.acknowledge === true,
1657
+ asked
1658
+ };
1659
+ }
1660
+ /**
1661
+ * The input of the publish that follows an acceptance: the warnings the person was shown, added
1662
+ * to those the client already acknowledged, each contract and path once.
1663
+ */
1664
+ function withAcknowledged(input, asked) {
1665
+ const acknowledged = new Map(input.acknowledgeWarnings.map((warning) => [`${warning.contract}\n${warning.path}`, warning]));
1666
+ for (const { contract, path } of warningsOf(asked)) acknowledged.set(`${contract}\n${path}`, {
1667
+ contract,
1668
+ path
1669
+ });
1670
+ return {
1671
+ ...input,
1672
+ acknowledgeWarnings: [...acknowledged.values()]
1673
+ };
1674
+ }
1675
+ //#endregion
1676
+ //#region src/mcp/envelope.ts
1677
+ /**
1678
+ * What a client says about itself. A 2026-07-28 client says it on every request, in the `_meta`
1679
+ * envelope; a 2025-11-25 client says it once, in `initialize`, and the stateless fallback that
1680
+ * serves those clients keeps nothing between requests, so for them there is nothing to read.
1681
+ */
1682
+ function isRecord(value) {
1683
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1684
+ }
1685
+ /** The longest name, version or title kept of a client: it is recorded with what it writes. */
1686
+ const MAX_CLIENT_FIELD_LENGTH = 120;
1687
+ /**
1688
+ * The client as the envelope names it: its name, and its version and title when it gives them,
1689
+ * each cut short. A client could write anything there, and what it writes is stored with every
1690
+ * revision and publication it makes. `null` when the request has no envelope.
1691
+ */
1692
+ function declaredClient(envelope) {
1693
+ const info = isRecord(envelope) ? envelope[CLIENT_INFO_META_KEY] : void 0;
1694
+ if (!isRecord(info) || typeof info.name !== "string") return null;
1695
+ const client = { name: info.name.slice(0, MAX_CLIENT_FIELD_LENGTH) };
1696
+ if (typeof info.version === "string") client.version = info.version.slice(0, MAX_CLIENT_FIELD_LENGTH);
1697
+ if (typeof info.title === "string") client.title = info.title.slice(0, MAX_CLIENT_FIELD_LENGTH);
1698
+ return client;
1699
+ }
1700
+ /**
1701
+ * Whether the server may ask the client a question in a form, as a multi round-trip request: the
1702
+ * request is in the 2026 era, and the client declared the elicitation capability for forms. A
1703
+ * bare `elicitation: {}` counts as forms, as it does for the SDK, which answers `-32021` to a
1704
+ * question that the client did not declare; `{ url: {} }` alone does not. A 2025 client in the
1705
+ * stateless fallback can never be asked: that fallback has no way to send a request to a client.
1706
+ */
1707
+ function canElicitForm(era, envelope) {
1708
+ if (era !== "modern" || !isRecord(envelope)) return false;
1709
+ const capabilities = envelope[CLIENT_CAPABILITIES_META_KEY];
1710
+ const elicitation = isRecord(capabilities) ? capabilities.elicitation : void 0;
1711
+ if (!isRecord(elicitation)) return false;
1712
+ return elicitation.form !== void 0 || elicitation.url === void 0;
1713
+ }
1714
+ //#endregion
1715
+ //#region src/mcp/result.ts
1716
+ /**
1717
+ * Checks what a handler returned against the output schema of its spec, and keeps only the
1718
+ * fields the schema names, so a handler that hands over a row with more columns than the schema
1719
+ * has cannot leak them. The SDK checks the same schema again, but only for results that are not
1720
+ * errors; an answer that does not match is a bug of the handler, and it throws, so that the call
1721
+ * ends as `internal`.
1722
+ */
1723
+ function readAnswer(spec, value) {
1724
+ const parsed = spec.output.safeParse(value);
1725
+ if (!parsed.success) {
1726
+ const issues = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
1727
+ throw new TypeError(`${spec.name} answered something that is not an output of its spec: ${issues.join("; ")}`);
1728
+ }
1729
+ const { outcome } = parsed.data;
1730
+ const isError = Object.entries(spec.outcomes).some(([name, info]) => name === outcome && info.isError);
1731
+ return {
1732
+ output: parsed.data,
1733
+ outcome,
1734
+ isError
1735
+ };
1736
+ }
1737
+ const encoder = new TextEncoder();
1738
+ /**
1739
+ * The fix of `output_too_large`, for each operation. A read says how to ask for less. A write
1740
+ * has already run when its answer turns out too large, so it says to look at the state it left.
1741
+ */
1742
+ const OUTPUT_TOO_LARGE_FIX = {
1743
+ create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
1744
+ describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
1745
+ get_entry: "Ask for one format at a time with formats: markdown, document or html, instead of several together.",
1746
+ get_translations: "The translations do not fit in one answer. Read each one with get_entry instead.",
1747
+ list_entries: "Ask for less: pass a smaller limit, or narrow the list with locale, tag or status.",
1748
+ publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
1749
+ unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
1750
+ update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
1751
+ validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
1752
+ };
1753
+ /**
1754
+ * The result of a call: the answer as `structuredContent`, the same JSON as text for clients
1755
+ * that do not read structured content, and `isError` as the spec says for the outcome. An answer
1756
+ * whose JSON is over the cap, counted in UTF-8 bytes, is the error `output_too_large`: Hollware
1757
+ * never truncates content.
1758
+ */
1759
+ function answerResult(spec, answer) {
1760
+ const text = JSON.stringify(answer.output);
1761
+ const bytes = encoder.encode(text).length;
1762
+ if (bytes > 1048576) throw new HollwareError("output_too_large", `The answer of ${spec.name} is ${bytes} bytes, over the cap of ${OUTPUT_CAP_BYTES} bytes.`, OUTPUT_TOO_LARGE_FIX[spec.name]);
1763
+ return {
1764
+ content: [{
1765
+ type: "text",
1766
+ text
1767
+ }],
1768
+ structuredContent: answer.output,
1769
+ isError: answer.isError
1770
+ };
1771
+ }
1772
+ /**
1773
+ * The result of a call that ended in an error: `{ code, message, fix }` as text, and no
1774
+ * structured content. The tools declare an output schema, and clients check the structured
1775
+ * content they get against it even when `isError` is set, so an error that is not an output of
1776
+ * the operation must not carry any.
1777
+ */
1778
+ function errorResult(error) {
1779
+ return {
1780
+ content: [{
1781
+ type: "text",
1782
+ text: JSON.stringify({
1783
+ code: error.code,
1784
+ message: error.message,
1785
+ fix: error.fix
1786
+ })
1787
+ }],
1788
+ isError: true
1789
+ };
1790
+ }
1791
+ //#endregion
1792
+ //#region src/mcp/scopes.ts
1793
+ /**
1794
+ * The scope challenge of a tool, which the SDK asks before it serves a call of the tool. A
1795
+ * principal that has every scope the tool needs gets nothing, and the call goes on. One that
1796
+ * lacks any of them is refused with 403 and a challenge that names every scope the tool needs,
1797
+ * the ones it has included, so that a client that can ask for more asks for all of them at once.
1798
+ * A request that comes without scopes is refused too: it never passes by lacking a principal.
1799
+ *
1800
+ * `onDenied` runs when a call is refused, for the log: the call never reaches the tool.
1801
+ */
1802
+ function scopeChallenge(tool, onDenied) {
1803
+ return ({ authInfo }) => {
1804
+ const granted = new Set(authInfo?.scopes);
1805
+ if (tool.scopes.every((scope) => granted.has(scope))) return void 0;
1806
+ onDenied();
1807
+ return {
1808
+ scopes: tool.scopes,
1809
+ errorDescription: `${tool.spec.name} requires: ${tool.scopes.join(" ")}`
1810
+ };
1811
+ };
1812
+ }
1813
+ //#endregion
1814
+ //#region src/mcp/timeout.ts
1815
+ /**
1816
+ * Runs `work` and gives up on it when it takes longer than `limitMs`: the answer is the error
1817
+ * that `onTimeout` makes. The work is not stopped, because a promise cannot be: an operation that
1818
+ * ran out of time may still finish, and the error says so. Whichever finishes first, the timer
1819
+ * is cleared, and a failure of the work after the timeout is already handled by the race.
1820
+ */
1821
+ async function within(work, limitMs, onTimeout) {
1822
+ let timer;
1823
+ const timeout = new Promise((_resolve, reject) => {
1824
+ timer = setTimeout(() => reject(onTimeout()), limitMs);
1825
+ });
1826
+ try {
1827
+ return await Promise.race([work, timeout]);
1828
+ } finally {
1829
+ if (timer !== void 0) clearTimeout(timer);
1830
+ }
1831
+ }
1832
+ //#endregion
1833
+ //#region src/mcp/tools.ts
1834
+ /**
1835
+ * `schema` with the same validation, whose conversions to JSON Schema are done once and kept.
1836
+ * The SDK builds a server for every request, and a server converts the schemas of its tools on
1837
+ * its own, so without this every `tools/list` pays for the conversion again (about 1.4 ms for
1838
+ * the nine operations, measured in the v0.1 spike). Zod stays the only definition: it validates,
1839
+ * applies the defaults and words the errors; only the JSON Schema is cached.
1840
+ *
1841
+ * What the cache returns is shared by every request: the SDK reads it and never changes it.
1842
+ */
1843
+ function memoizeJsonSchema(schema) {
1844
+ const standard = schema["~standard"];
1845
+ const cache = /* @__PURE__ */ new Map();
1846
+ const convert = (io) => (options) => {
1847
+ if (options.libraryOptions !== void 0) return standard.jsonSchema[io](options);
1848
+ const key = `${io}:${options.target}`;
1849
+ let json = cache.get(key);
1850
+ if (json === void 0) {
1851
+ json = standard.jsonSchema[io](options);
1852
+ cache.set(key, json);
1853
+ }
1854
+ return json;
1855
+ };
1856
+ return { "~standard": {
1857
+ version: standard.version,
1858
+ vendor: standard.vendor,
1859
+ types: standard.types,
1860
+ validate: (value, options) => standard.validate(value, options),
1861
+ jsonSchema: {
1862
+ input: convert("input"),
1863
+ output: convert("output")
1864
+ }
1865
+ } };
1866
+ }
1867
+ const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
1868
+ /**
1869
+ * The tools of `operations`, sorted by name. The SDK lists tools in the order they are
1870
+ * registered, and `tools/list` must be the same for every client and every call, so the order
1871
+ * is decided here, by code unit and never by locale. The schemas of each tool are cached.
1872
+ *
1873
+ * An operation that asks for no scope is refused: a tool that anyone may call has to be a
1874
+ * decision someone makes, not a spec that forgot its scopes.
1875
+ */
1876
+ function createToolTable(operations = OPERATIONS) {
1877
+ return operations.toSorted(byName).map((spec) => {
1878
+ const [first, ...rest] = spec.scopes;
1879
+ if (first === void 0) throw new Error(`${spec.name} asks for no scope; every tool needs at least one`);
1880
+ return {
1881
+ spec,
1882
+ input: memoizeJsonSchema(spec.input),
1883
+ output: memoizeJsonSchema(spec.output),
1884
+ scopes: [first, ...rest]
1885
+ };
1886
+ });
1887
+ }
1888
+ //#endregion
1889
+ //#region src/mcp/endpoint.ts
1890
+ /**
1891
+ * `tools/list` is the same for every client and changes only when the Worker is deployed again:
1892
+ * it depends on the operations of the code and on nothing else, not on the site, the data or the
1893
+ * caller. So any cache may keep it (`public`). Five minutes is long enough for an agent that
1894
+ * works through a conversation to stop asking, and short enough that a client that cached the
1895
+ * list just before a deploy converges on the new one before long: the list can never push a
1896
+ * change (`listChanged` is false), so this is the only way a client learns of one.
1897
+ */
1898
+ const TOOLS_LIST_CACHE_HINT = {
1899
+ ttlMs: 3e5,
1900
+ cacheScope: "public"
1901
+ };
1902
+ /**
1903
+ * What the SDK is told about a principal: its scopes, which are the effective ones, and the id of
1904
+ * its token as the client. It carries no token: the gateway checked it, and nothing here needs it.
1905
+ */
1906
+ function authInfoOf(principal) {
1907
+ return {
1908
+ token: "",
1909
+ clientId: principal.kind === "token" ? principal.tokenId : principal.kind,
1910
+ scopes: [...principal.scopes]
1911
+ };
1912
+ }
1913
+ /**
1914
+ * Calls the handler of an operation. This is the one place where the typed pairing of specs and
1915
+ * handlers meets the dynamic protocol: the SDK parsed `input` with the input schema of `spec`,
1916
+ * which is the schema that types the handler of the same name.
1917
+ */
1918
+ function invoke(handlers, spec, input, context) {
1919
+ const handler = handlers[spec.name];
1920
+ return handler(input, context);
1921
+ }
1922
+ /**
1923
+ * The MCP endpoint of Hollware: one tool for each operation, served in both protocol eras, for
1924
+ * the gateway to mount at `/mcp` after it has checked the Host, the Origin and the token.
1925
+ *
1926
+ * One SDK handler serves the whole isolate, and each request gets a server of its own, built from
1927
+ * the tool table: the SDK's model, which needs no session. The JSON Schema of every tool is
1928
+ * converted once, the first time a request needs it.
1929
+ */
1930
+ function createMcpEndpoint(options) {
1931
+ const timeoutMs = options.timeoutMs ?? 1e4;
1932
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new RangeError(`timeoutMs must be a positive number of milliseconds, got ${timeoutMs}`);
1933
+ const tools = createToolTable();
1934
+ /** The call the gateway made for each request, found again by the factory through its authInfo. */
1935
+ const calls = /* @__PURE__ */ new WeakMap();
1936
+ const emit = (entry) => {
1937
+ try {
1938
+ options.log?.(entry);
1939
+ } catch {}
1940
+ };
1941
+ /** One call of a tool: the operation, its answer as a result, and one line of log. */
1942
+ const runTool = async (spec, input, mcp, call, era) => {
1943
+ const started = performance.now();
1944
+ const envelope = mcp.mcpReq.envelope;
1945
+ const context = {
1946
+ ...call.context,
1947
+ view: "head",
1948
+ principal: call.principal,
1949
+ requestId: call.requestId,
1950
+ client: declaredClient(envelope)
1951
+ };
1952
+ let outcome = null;
1953
+ let isError = false;
1954
+ let failure = null;
1955
+ let result;
1956
+ /** What the log says of the answer, kept as soon as there is one. */
1957
+ const record = (answer) => {
1958
+ outcome = answer.outcome;
1959
+ isError = answer.isError;
1960
+ return answer;
1961
+ };
1962
+ /** Runs the handler within the time limit and checks its answer. */
1963
+ const perform = async (operationInput) => {
1964
+ const value = await within(invoke(options.handlers, spec, operationInput, context), timeoutMs, () => timeoutError(spec, timeoutMs));
1965
+ return record(readAnswer(spec, value));
1966
+ };
1967
+ /**
1968
+ * Publish, which may ask the person to acknowledge the warnings. The first call asks, if the
1969
+ * client can be asked; the retry carries the reply. A refusal answers what the person was
1970
+ * shown without calling the handler again, and never asks twice: the client would retry
1971
+ * until it gave up.
1972
+ */
1973
+ const performPublish = async () => {
1974
+ const publishInput = publish.input.parse(input);
1975
+ const reply = replyTo(mcp);
1976
+ if (reply !== null && !reply.given) return answerResult(spec, record(readAnswer(spec, reply.asked)));
1977
+ if (reply !== null) return answerResult(spec, await perform(withAcknowledged(publishInput, reply.asked)));
1978
+ const answer = await perform(publishInput);
1979
+ if (answer.outcome === "needs_acknowledgement" && canElicitForm(era, envelope)) {
1980
+ const asked = publish.output.parse(answer.output);
1981
+ if (warningsOf(asked).length > 0) return askToAcknowledge(asked);
1982
+ }
1983
+ return answerResult(spec, answer);
1984
+ };
1985
+ try {
1986
+ result = spec.name === "publish" ? await performPublish() : answerResult(spec, await perform(input));
1987
+ } catch (error) {
1988
+ failure = error instanceof HollwareError ? error : internalError$1(call.requestId, error);
1989
+ isError = true;
1990
+ result = errorResult(failure);
1991
+ }
1992
+ emit({
1993
+ kind: "call",
1994
+ requestId: call.requestId,
1995
+ tool: spec.name,
1996
+ outcome,
1997
+ errorCode: failure?.code ?? null,
1998
+ isError,
1999
+ durationMs: Math.round(performance.now() - started),
2000
+ detail: failure === null || failure.cause === void 0 ? null : describeError(failure.cause)
2001
+ });
2002
+ return result;
2003
+ };
2004
+ const buildServer = (request) => {
2005
+ const call = request.authInfo === void 0 ? void 0 : calls.get(request.authInfo);
2006
+ if (call === void 0) throw new Error("A request reached the MCP server without the call the gateway made for it");
2007
+ const server = new McpServer(options.serverInfo, {
2008
+ capabilities: { tools: { listChanged: false } },
2009
+ cacheHints: { "tools/list": TOOLS_LIST_CACHE_HINT }
2010
+ });
2011
+ for (const tool of tools) server.registerTool(tool.spec.name, {
2012
+ title: tool.spec.title,
2013
+ description: tool.spec.description,
2014
+ inputSchema: tool.input,
2015
+ outputSchema: tool.output,
2016
+ annotations: EFFECT_ANNOTATIONS[tool.spec.effect],
2017
+ scopeChallenge: scopeChallenge(tool, () => emit({
2018
+ kind: "call",
2019
+ requestId: call.requestId,
2020
+ tool: tool.spec.name,
2021
+ outcome: null,
2022
+ errorCode: "insufficient_scope",
2023
+ isError: true,
2024
+ durationMs: 0,
2025
+ detail: null
2026
+ }))
2027
+ }, (input, mcp) => runTool(tool.spec, input, mcp, call, request.era));
2028
+ return server;
2029
+ };
2030
+ const handler = createMcpHandler(buildServer, { onerror: (error) => emit({
2031
+ kind: "protocol_error",
2032
+ message: error.message
2033
+ }) });
2034
+ return { async fetch(request, call) {
2035
+ if (!call?.principal) throw new Error("The MCP endpoint serves a request only as the principal the gateway found");
2036
+ const authInfo = authInfoOf(call.principal);
2037
+ calls.set(authInfo, call);
2038
+ return handler.fetch(request, { authInfo });
2039
+ } };
2040
+ }
2041
+ //#endregion
2042
+ //#region src/rpc/methods.ts
2043
+ /**
2044
+ * Who calls over RPC: a Worker of the same account, which reads published content and nothing
2045
+ * else. Frozen, so that no handler can widen it for the calls that come after.
2046
+ */
2047
+ const BINDING_PRINCIPAL = Object.freeze({
2048
+ kind: "binding",
2049
+ scopes: Object.freeze(["content:read"])
2050
+ });
2051
+ /**
2052
+ * The site of a call is not the site of this deployment. v0.1 serves one site, and the message
2053
+ * and the fix both say which, for a caller that only gets one of them.
2054
+ */
2055
+ function wrongSite(served) {
2056
+ return new HollwareError("invalid_input", `This Hollware serves the site "${served}", not the one the call names.`, `Pass "${served}", the site this deployment serves, as the site argument.`);
2057
+ }
2058
+ /**
2059
+ * The options object of a call, without the keys that hold `undefined`, so that the handler sees
2060
+ * the same input as over MCP. A key that is none of `allowed` is an error: the specs are strict
2061
+ * objects so that a misspelled field is not ignored, and a caller that writes `lcoale` would
2062
+ * otherwise get every locale. Only the keys of `HollwareRpc` get through, so an input the RPC
2063
+ * does not offer, such as the `status` of `list_entries`, can never be set.
2064
+ */
2065
+ function readOptions(options, allowed) {
2066
+ if (options === void 0 || options === null) return {};
2067
+ if (typeof options !== "object" || Array.isArray(options)) throw new HollwareError("invalid_input", "The options of the call are not an object.", `Pass an object with only these options: ${allowed.join(", ")}, or none.`);
2068
+ const entries = Object.entries(options);
2069
+ const unknown = entries.map(([key]) => key).filter((key) => !allowed.includes(key));
2070
+ if (unknown.length > 0) throw new HollwareError("invalid_input", `The call has options that Hollware does not know: ${unknown.join(", ")}.`, `Pass only these options: ${allowed.join(", ")}.`);
2071
+ return Object.fromEntries(entries.filter(([, value]) => value !== void 0));
2072
+ }
2073
+ /**
2074
+ * The input of an operation, parsed by its spec: the defaults (`limit`, `formats`) apply and a
2075
+ * value that breaks the schema is `invalid_input` that says which.
2076
+ */
2077
+ function parseInput(schema, raw) {
2078
+ const parsed = schema.safeParse(raw);
2079
+ if (parsed.success) return parsed.data;
2080
+ const problems = parsed.error.issues.map((issue) => `${issue.path.map(String).join(".")}: ${issue.message}`);
2081
+ throw new HollwareError("invalid_input", `The arguments of the call are not valid. ${problems.join("; ")}.`, "Fix the arguments named above and call again. describeSite lists the collections and locales of the site.");
2082
+ }
2083
+ /**
2084
+ * What a caller gets of a failure: an error it can read and act on, and none of the cause, which
2085
+ * an error carries when a handler wraps a failure of D1 or of its own. A `HollwareError` goes as
2086
+ * it is, rebuilt without its cause, and anything else is `internal` with the id of the call. What
2087
+ * the caller does not get goes to the log, under that id.
2088
+ */
2089
+ function forCaller(deps, requestId, method, error) {
2090
+ const known = error instanceof HollwareError ? error : null;
2091
+ if (known === null || known.cause !== void 0) deps.log({
2092
+ kind: "rpc_error",
2093
+ requestId,
2094
+ method,
2095
+ errorCode: known === null ? null : known.code,
2096
+ detail: describeError(error)
2097
+ });
2098
+ if (known === null) return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${requestId}.`);
2099
+ return new HollwareError(known.code, known.message, known.fix);
2100
+ }
2101
+ /**
2102
+ * One call of an operation over RPC. In this order: the site is the one of this deployment, the
2103
+ * input is built and parsed by the spec's schema, and the handler runs with
2104
+ *
2105
+ * - `view: 'published'`, so a draft is never read, and the principal `{ kind: 'binding', scopes:
2106
+ * ['content:read'] }`, which can read and nothing else;
2107
+ * - a store over `env.DB.withSession('first-unconstrained')`: published content, from the nearest
2108
+ * replica, where MCP reads the primary;
2109
+ * - the clock, `client: null` (a binding says nothing about itself) and a request id of its own.
2110
+ *
2111
+ * `build` makes the raw input, so a wrong site is answered before anything else is looked at and
2112
+ * a call that fails never opens a session.
2113
+ */
2114
+ async function run(deps, env, method, site, schema, build, handler) {
2115
+ if (site !== deps.site.key) throw wrongSite(deps.site.key);
2116
+ const input = parseInput(schema, build());
2117
+ const requestId = crypto.randomUUID();
2118
+ const context = {
2119
+ site: deps.site,
2120
+ principal: BINDING_PRINCIPAL,
2121
+ view: "published",
2122
+ store: createD1Store(env.DB.withSession("first-unconstrained")),
2123
+ clock: deps.clock,
2124
+ client: null,
2125
+ requestId
2126
+ };
2127
+ try {
2128
+ return await handler(input, context);
2129
+ } catch (error) {
2130
+ throw forCaller(deps, requestId, method, error);
2131
+ }
2132
+ }
2133
+ /**
2134
+ * The read-only RPC surface (`HollwareRpc`) over the bindings of one invocation. It is a function
2135
+ * and not the entrypoint so that it runs in any test; `entrypoint.ts` is the class that calls it.
2136
+ * It has the four methods and nothing that writes, and none of them reaches a handler other than
2137
+ * the one that reads.
2138
+ */
2139
+ function createRpc(deps, env) {
2140
+ return {
2141
+ getEntry: (site, collection, slug, locale, options) => run(deps, env, "getEntry", site, getEntry.input, () => ({
2142
+ collection,
2143
+ locale,
2144
+ slug,
2145
+ ...readOptions(options, ["formats"])
2146
+ }), deps.handlers.get_entry),
2147
+ listEntries: (site, collection, options) => run(deps, env, "listEntries", site, listEntries.input, () => ({
2148
+ collection,
2149
+ ...readOptions(options, [
2150
+ "locale",
2151
+ "tag",
2152
+ "limit",
2153
+ "cursor"
2154
+ ])
2155
+ }), deps.handlers.list_entries),
2156
+ getTranslations: (site, collection, translationGroup) => run(deps, env, "getTranslations", site, getTranslations.input, () => ({
2157
+ collection,
2158
+ translationGroup
2159
+ }), deps.handlers.get_translations),
2160
+ describeSite: (site) => run(deps, env, "describeSite", site, describeSite.input, () => ({}), deps.handlers.describe_site)
2161
+ };
2162
+ }
2163
+ //#endregion
2164
+ //#region src/rpc/entrypoint.ts
2165
+ /**
2166
+ * The class a site reaches over a service binding: the user's Worker exports it as
2167
+ * `export class Hollware extends hollware.Hollware {}`. It is made here, closed over what the
2168
+ * four methods stand on, because that is known only once the config is resolved.
2169
+ *
2170
+ * It has the four methods of `HollwareRpc` and nothing else, and none of them writes. That is the
2171
+ * security surface of the binding: a Worker with a binding can call whatever is on this
2172
+ * prototype, so a helper method added here would be public. Anything else lives in `methods.ts`,
2173
+ * where `createRpc` does the work and runs in a test without a `WorkerEntrypoint`.
2174
+ */
2175
+ function createEntrypoint(deps) {
2176
+ return class Hollware extends WorkerEntrypoint {
2177
+ getEntry(...args) {
2178
+ return createRpc(deps, this.env).getEntry(...args);
2179
+ }
2180
+ listEntries(...args) {
2181
+ return createRpc(deps, this.env).listEntries(...args);
2182
+ }
2183
+ getTranslations(...args) {
2184
+ return createRpc(deps, this.env).getTranslations(...args);
2185
+ }
2186
+ describeSite(...args) {
2187
+ return createRpc(deps, this.env).describeSite(...args);
2188
+ }
2189
+ };
2190
+ }
2191
+ //#endregion
2192
+ //#region src/compose.ts
2193
+ const systemClock = { now: () => /* @__PURE__ */ new Date() };
2194
+ /** What the MCP endpoint calls the server, in `server/discover` and `initialize`. */
2195
+ const SERVER_INFO = {
2196
+ name: "hollware",
2197
+ title: "Hollware",
2198
+ version
2199
+ };
2200
+ /** One JSON line per entry, so that Workers Logs indexes its fields. */
2201
+ function writeLog(entry) {
2202
+ console.log(JSON.stringify(entry));
2203
+ }
2204
+ /**
2205
+ * Builds the Worker from the config and the handlers of the operations (WP9). `createHollware`
2206
+ * is this with the real handlers.
2207
+ *
2208
+ * Everything that can be built once is built here, when the user's module loads, and never again:
2209
+ *
2210
+ * - the config is resolved with `resolveConfig`, so a broken `hollware.config.ts` throws its
2211
+ * `ConfigError` here, and the Worker fails at startup with a message that says what to change.
2212
+ * v0.1 serves the one site of the config;
2213
+ * - the MCP endpoint, whose tool schemas are converted once, and the Hono app of the gateway,
2214
+ * which is `fetch`: MCP at `/mcp` behind the guard and the token check, and the other routes
2215
+ * (`http/gateway.ts`);
2216
+ * - the `Hollware` class for the user's Worker to export, the read-only RPC surface
2217
+ * (`rpc/entrypoint.ts`).
2218
+ *
2219
+ * Both surfaces run the same handlers on a store of their own: MCP on the primary database, RPC
2220
+ * on a replica session and the published view. Every log line, those of the MCP endpoint
2221
+ * included, is one JSON line written with `console.log`.
2222
+ */
2223
+ function createHollwareWith(config, handlers, options = {}) {
2224
+ const [site] = resolveConfig(config).sites.values();
2225
+ if (site === void 0) throw new Error("The resolved config has no site, which resolveConfig never lets through");
2226
+ const clock = options.clock ?? systemClock;
2227
+ return {
2228
+ fetch: createGateway({
2229
+ site,
2230
+ mcp: createMcpEndpoint({
2231
+ handlers,
2232
+ serverInfo: SERVER_INFO,
2233
+ log: writeLog
2234
+ }),
2235
+ clock,
2236
+ log: writeLog
2237
+ }),
2238
+ Hollware: createEntrypoint({
2239
+ site,
2240
+ handlers,
2241
+ clock,
2242
+ log: writeLog
2243
+ })
2244
+ };
2245
+ }
2246
+ //#endregion
2247
+ //#region src/ops/errors.ts
2248
+ /**
2249
+ * The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
2250
+ * Like the `internal` error of the adapters, its message says nothing about the cause, which goes
2251
+ * to the log through `cause`, and its fix names the request id the operator finds the log line by.
2252
+ */
2253
+ function internalError(context, cause) {
2254
+ return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${context.requestId}.`, { cause: new Error(cause) });
2255
+ }
2256
+ /**
2257
+ * The error for an answer of the store that the call it came from can never get: an entry that
2258
+ * is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
2259
+ * operation, so it is not one of the outcomes of its spec.
2260
+ */
2261
+ function unexpectedAnswer(context, call, answer) {
2262
+ return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
2263
+ }
2264
+ //#endregion
2265
+ //#region src/ops/actor.ts
2266
+ /**
2267
+ * Who a write is made by: the token of the call, with what its client said about itself. Only a
2268
+ * personal token writes (ADR-019). The adapters check the scopes of the operation before any
2269
+ * handler runs and never route a write for another principal, so reaching one with a principal
2270
+ * that is not a token is a bug, answered as `internal` before anything is read or written.
2271
+ */
2272
+ function actorOf(context) {
2273
+ const { principal } = context;
2274
+ if (principal.kind !== "token") throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a token writes.`);
2275
+ return {
2276
+ kind: "token",
2277
+ userId: principal.userId,
2278
+ tokenId: principal.tokenId,
2279
+ meta: context.client
2280
+ };
2281
+ }
2282
+ //#endregion
2283
+ //#region src/ops/answers.ts
2284
+ /**
2285
+ * The summary of an entry, from a listing (`EntrySummaryView`) or from a read of one entry
2286
+ * (`EntryView`, which has all of the summary's fields and more). `hasUnpublishedChanges` says
2287
+ * whether the latest revision differs from the one that is live, so an entry that was never
2288
+ * published always has them.
2289
+ */
2290
+ function entrySummary(view) {
2291
+ const { entry, revision } = view;
2292
+ return {
2293
+ id: entry.id,
2294
+ collection: entry.collection,
2295
+ locale: entry.locale,
2296
+ slug: entry.slug,
2297
+ translationGroup: entry.translationGroup,
2298
+ status: entry.status,
2299
+ version: entry.version,
2300
+ frontmatter: revision.frontmatter,
2301
+ tags: [...revision.tags],
2302
+ hasUnpublishedChanges: entry.publishedRevision !== entry.headRevision,
2303
+ publishedAt: entry.publishedAt,
2304
+ updatedAt: entry.updatedAt
2305
+ };
2306
+ }
2307
+ /** A verdict as an output: the same facts, in arrays that the caller may own. */
2308
+ function verdictView(verdict) {
2309
+ return {
2310
+ ok: verdict.ok,
2311
+ phase: verdict.phase,
2312
+ errors: [...verdict.errors],
2313
+ warnings: [...verdict.warnings],
2314
+ contractsHash: verdict.contractsHash
2315
+ };
2316
+ }
2317
+ /**
2318
+ * The verdict of a refusal: the violations of the pipeline, or of a write that the store turned
2319
+ * down, in the phase of the operation. No contract ran, and the empty hash says so: a hash names
2320
+ * the rules a verdict answers to, and this one answers to none.
2321
+ */
2322
+ function refusalVerdict(phase, violations) {
2323
+ return {
2324
+ ok: false,
2325
+ phase,
2326
+ errors: [...violations],
2327
+ warnings: [],
2328
+ contractsHash: ""
2329
+ };
2330
+ }
2331
+ /** What `conflict` carries: the version the entry is at now, and its latest revision. */
2332
+ function conflictAt(currentVersion, headRevision) {
2333
+ return {
2334
+ currentVersion,
2335
+ headRevision
2336
+ };
2337
+ }
2338
+ /**
2339
+ * The violation of a write that the store turned down because of what is stored: another entry
2340
+ * holds its slug or its translation, or its row would be over the limit of D1. These are the
2341
+ * refusals that make a write `refused`; the store never answers `taken` to a collection that has
2342
+ * no translations, since its entries have no group.
2343
+ */
2344
+ function violationOf(refusal, collection) {
2345
+ if (refusal.outcome === "too_large") return sizeLimitViolation(refusal.bytes, refusal.limit);
2346
+ if (refusal.field === "slug") return uniqueSlugViolation(refusal.by);
2347
+ const field = collection.translationGroupField;
2348
+ if (field === null) throw new Error(`The store said that a translation group is taken in the collection ${collection.name}, which has no translations`);
2349
+ return uniqueTranslationViolation(refusal.by, field);
2350
+ }
2351
+ /**
2352
+ * The last publication of an entry as `get_entry` shows it: the latest one when it is a publish,
2353
+ * and nothing when the entry was unpublished since, or never published.
2354
+ */
2355
+ function publicationView(publication) {
2356
+ if (publication === null || publication.action !== "publish") return null;
2357
+ if (publication.revisionId === null || publication.verdict === null) throw new Error(`The publication ${publication.id} is a publish with no revision or verdict`);
2358
+ return {
2359
+ revision: publication.revisionId,
2360
+ verdict: verdictView(publication.verdict),
2361
+ acknowledged: publication.acknowledged.map(({ contract, path }) => ({
2362
+ contract,
2363
+ path
2364
+ })),
2365
+ at: publication.createdAt
2366
+ };
2367
+ }
2368
+ //#endregion
2369
+ //#region src/ops/collections.ts
2370
+ /**
2371
+ * Which collection and locale a call names, checked against the config of the site before
2372
+ * anything else is done with it. The fixes list what the site has, so that an agent that guessed
2373
+ * a name has the right one in the answer.
2374
+ */
2375
+ /** The longest name an error repeats: a caller could send any string. */
2376
+ const NAME_LIMIT = 60;
2377
+ /** A name the caller sent, as an error shows it: in JSON quotes, so on one line, and cut when it is long. */
2378
+ function shown(name) {
2379
+ if (name.length <= NAME_LIMIT) return JSON.stringify(name);
2380
+ const head = name.slice(0, NAME_LIMIT);
2381
+ const last = head.charCodeAt(59);
2382
+ return JSON.stringify(`${last >= 55296 && last <= 56319 ? head.slice(0, -1) : head}…`);
2383
+ }
2384
+ /** The collection of the site with that name, or `unknown_collection` listing the ones it has. */
2385
+ function requireCollection(site, name) {
2386
+ const collection = site.collections.get(name);
2387
+ if (collection !== void 0) return collection;
2388
+ throw new HollwareError("unknown_collection", `The site has no collection ${shown(name)}.`, `Use one of its collections: ${[...site.collections.keys()].join(", ")}. describe_site lists them with their schemas.`);
2389
+ }
2390
+ /** Whether the site has the locale, or `unknown_locale` listing the ones it has. */
2391
+ function requireLocale(site, locale) {
2392
+ if (site.locales.includes(locale)) return;
2393
+ throw new HollwareError("unknown_locale", `The site has no locale ${shown(locale)}.`, `Use one of its locales: ${site.locales.join(", ")}.`);
2394
+ }
2395
+ /**
2396
+ * The collection of an entry that is stored. The caller named the entry by its id, so a
2397
+ * collection that is gone from the config is no mistake of the caller: the entry and its
2398
+ * revisions are kept, and whoever maintains the config can declare the collection again. Without
2399
+ * it nothing can be checked, so the entry cannot be written or published until then.
2400
+ */
2401
+ function collectionOfEntry(site, entry) {
2402
+ const collection = site.collections.get(entry.collection);
2403
+ if (collection !== void 0) return collection;
2404
+ throw new HollwareError("unknown_collection", `The entry ${entry.id} belongs to the collection ${shown(entry.collection)}, which the config of the site no longer declares.`, `Ask whoever maintains hollware.config.ts to declare the collection ${shown(entry.collection)} again: the entry and its revisions are kept.`);
2405
+ }
2406
+ //#endregion
2407
+ //#region src/ops/draft.ts
2408
+ /**
2409
+ * What the operations that take Markdown share: running the pipeline and the contracts of a
2410
+ * collection on it, and reading from the frontmatter that the pipeline returned the fields that
2411
+ * the store keeps beside the revision.
2412
+ */
2413
+ /**
2414
+ * What the pipeline needs of a site and a collection: the components the collection allows, the
2415
+ * largest file it accepts, and the routes of every collection of the site, by collection name, so
2416
+ * that a link to another collection's entry is recognized.
2417
+ */
2418
+ function optionsFor(site, collection) {
2419
+ return {
2420
+ components: [...collection.components.keys()],
2421
+ maxMarkdownBytes: collection.maxMarkdownBytes,
2422
+ routes: Object.fromEntries([...site.collections].map(([name, other]) => [name, other.routes]))
2423
+ };
2424
+ }
2425
+ /** Runs the Markdown pipeline on a file for a collection. It never throws; see `processMarkdown`. */
2426
+ function processFor(site, collection, markdown) {
2427
+ return processMarkdown(markdown, optionsFor(site, collection));
2428
+ }
2429
+ /**
2430
+ * Runs the contracts of a collection on a file the pipeline accepted. `write` is what a draft is
2431
+ * saved under; `publish` is what `validate` and `publish` check, with every contract of the
2432
+ * collection, against the other entries of the site as they are now.
2433
+ */
2434
+ function judge(context, collection, target, markdown, phase) {
2435
+ return runContracts({
2436
+ site: context.site,
2437
+ collection,
2438
+ locale: target.locale,
2439
+ slug: target.slug,
2440
+ entryId: target.entryId,
2441
+ phase,
2442
+ markdown,
2443
+ entries: context.store.lookup(context.site.key)
2444
+ });
2445
+ }
2446
+ /**
2447
+ * The key that the translations of an entry share: the value of the field that the collection
2448
+ * names, when it is a string. An empty string is no key: `get_translations` never takes one, and
2449
+ * the `translation-pair` contract reads it as a missing group, so it is stored as none, which
2450
+ * also keeps two drafts with an empty group from refusing one another.
2451
+ */
2452
+ function translationGroupOf(collection, frontmatter) {
2453
+ const field = collection.translationGroupField;
2454
+ const value = field === null ? void 0 : frontmatter[field];
2455
+ return typeof value === "string" && value !== "" ? value : null;
2456
+ }
2457
+ /** The tags of an entry: the value of the field that the collection names, when it is a list of strings. */
2458
+ function tagsOf(collection, frontmatter) {
2459
+ const field = collection.tagsField;
2460
+ const value = field === null ? void 0 : frontmatter[field];
2461
+ return Array.isArray(value) && value.every((tag) => typeof tag === "string") ? value : [];
2462
+ }
2463
+ /** What orders the entry in published listings: the value of the sorting field, when it is a string. */
2464
+ function sortKeyOf(collection, frontmatter) {
2465
+ const field = collection.sortByField;
2466
+ const value = field === null ? void 0 : frontmatter[field];
2467
+ return typeof value === "string" ? value : null;
2468
+ }
2469
+ /**
2470
+ * The revision to store for a file. The Markdown is exactly the file the caller sent: its bytes
2471
+ * are the source of truth, and everything else is derived from them (ADR-001).
2472
+ */
2473
+ function revisionOf(input) {
2474
+ const { processed } = input;
2475
+ return {
2476
+ markdown: input.markdown,
2477
+ frontmatter: processed.frontmatter,
2478
+ document: processed.document,
2479
+ comarkVersion: processed.comarkVersion,
2480
+ pipelineVersion: processed.pipelineVersion,
2481
+ wordCount: processed.extracted.words,
2482
+ verdict: input.verdict,
2483
+ tags: tagsOf(input.collection, processed.frontmatter),
2484
+ actor: input.actor
2485
+ };
2486
+ }
2487
+ //#endregion
2488
+ //#region src/ops/create-draft.ts
2489
+ /**
2490
+ * Creates an entry at version 1 from a Markdown file. A file the pipeline cannot store is
2491
+ * `refused`; any other problem, a contract error included, is saved with the draft in its verdict
2492
+ * and only blocks publishing (ADR-004). The store refuses a slug or a translation that another
2493
+ * entry holds, and a row over the limit of D1, and those are `refused` too.
2494
+ */
2495
+ const createDraft = async (input, context) => {
2496
+ const collection = requireCollection(context.site, input.collection);
2497
+ requireLocale(context.site, input.locale);
2498
+ const actor = actorOf(context);
2499
+ const processed = await processFor(context.site, collection, input.markdown);
2500
+ if (!processed.ok) return {
2501
+ outcome: "refused",
2502
+ verdict: refusalVerdict("write", processed.violations)
2503
+ };
2504
+ const verdict = await judge(context, collection, {
2505
+ locale: input.locale,
2506
+ slug: input.slug,
2507
+ entryId: null
2508
+ }, processed.value, "write");
2509
+ const written = await context.store.createEntry(context.site.key, {
2510
+ collection: input.collection,
2511
+ locale: input.locale,
2512
+ slug: input.slug,
2513
+ translationGroup: translationGroupOf(collection, processed.value.frontmatter),
2514
+ revision: revisionOf({
2515
+ markdown: input.markdown,
2516
+ processed: processed.value,
2517
+ collection,
2518
+ verdict,
2519
+ actor
2520
+ })
2521
+ });
2522
+ switch (written.outcome) {
2523
+ case "saved": return {
2524
+ outcome: "saved",
2525
+ entry: entrySummary(written.view),
2526
+ verdict: verdictView(verdict)
2527
+ };
2528
+ case "taken":
2529
+ case "too_large": return {
2530
+ outcome: "refused",
2531
+ verdict: refusalVerdict("write", [violationOf(written, collection)])
2532
+ };
2533
+ case "conflict":
2534
+ case "not_found": throw unexpectedAnswer(context, "createEntry", written);
2535
+ }
2536
+ };
2537
+ //#endregion
2538
+ //#region src/ops/describe-site.ts
2539
+ /**
2540
+ * The always-on contracts as every collection lists them, described once for the isolate:
2541
+ * `describeResolvedSite` memoizes a site's description by the array it is given, so passing this
2542
+ * same array on every call is what makes the schemas convert once and not on every request.
2543
+ */
2544
+ const builtInContracts = describeBuiltInContracts();
2545
+ /** Describes the site the call is for: what an agent reads before it writes. */
2546
+ const describeSite$1 = async (_input, context) => ({
2547
+ outcome: "ok",
2548
+ site: describeResolvedSite(context.site, { builtInContracts })
2549
+ });
2550
+ //#endregion
2551
+ //#region src/ops/get-entry.ts
2552
+ /**
2553
+ * Reads an entry in the view of the call, `head` over MCP and `published` over RPC, with only the
2554
+ * formats that were asked for.
2555
+ */
2556
+ const getEntry$1 = async (input, context) => {
2557
+ requireCollection(context.site, input.collection);
2558
+ requireLocale(context.site, input.locale);
2559
+ const found = await context.store.findEntry(context.site.key, {
2560
+ collection: input.collection,
2561
+ locale: input.locale,
2562
+ slug: input.slug
2563
+ }, context.view);
2564
+ if (found === null) return { outcome: "not_found" };
2565
+ const { entry, revision } = found;
2566
+ const publication = await context.store.lastPublication(context.site.key, entry.id);
2567
+ return {
2568
+ outcome: "ok",
2569
+ entry: {
2570
+ ...entrySummary(found),
2571
+ revision: revision.id,
2572
+ verdict: verdictView(revision.verdict),
2573
+ lastPublication: publicationView(publication),
2574
+ ...input.formats.includes("markdown") ? { markdown: revision.markdown } : {},
2575
+ ...input.formats.includes("document") ? { document: revision.document } : {},
2576
+ ...input.formats.includes("html") ? { html: await renderHtml(revision.document) } : {}
2577
+ }
2578
+ };
2579
+ };
2580
+ //#endregion
2581
+ //#region src/ops/get-translations.ts
2582
+ /** Reads the entries of a translation group in the view of the call, one per locale. */
2583
+ const getTranslations$1 = async (input, context) => {
2584
+ const collection = requireCollection(context.site, input.collection);
2585
+ if (collection.translationGroupField === null) throw new HollwareError("invalid_input", `The collection ${JSON.stringify(collection.name)} has no translations, so its entries belong to no translation group.`, "describe_site shows translationGroupField as null for this collection: read its entries with get_entry or list_entries instead.");
2586
+ const group = await context.store.listTranslations(context.site.key, input.collection, input.translationGroup, context.view);
2587
+ if (group.length === 0) return { outcome: "not_found" };
2588
+ return {
2589
+ outcome: "ok",
2590
+ entries: group.map(entrySummary)
2591
+ };
2592
+ };
2593
+ //#endregion
2594
+ //#region src/ops/list-entries.ts
2595
+ /** Lists a page of the entries of a collection, in the view of the call. */
2596
+ const listEntries$1 = async (input, context) => {
2597
+ requireCollection(context.site, input.collection);
2598
+ if (input.locale !== void 0) requireLocale(context.site, input.locale);
2599
+ const page = await context.store.listEntries(context.site.key, {
2600
+ collection: input.collection,
2601
+ locale: input.locale,
2602
+ tag: input.tag,
2603
+ status: input.status,
2604
+ view: context.view,
2605
+ limit: input.limit,
2606
+ cursor: input.cursor
2607
+ });
2608
+ return {
2609
+ outcome: "ok",
2610
+ entries: page.entries.map(entrySummary),
2611
+ nextCursor: page.nextCursor
2612
+ };
2613
+ };
2614
+ //#endregion
2615
+ //#region src/ops/publish.ts
2616
+ /** A warning by what an acknowledgement names: its contract and its path, never its line. */
2617
+ function warningKey(warning) {
2618
+ return JSON.stringify([warning.contract, warning.path]);
2619
+ }
2620
+ /**
2621
+ * Publishes the latest revision of an entry, if it is still at the version the caller read. The
2622
+ * contracts run again, in the publish phase, because the rules and the other entries may have
2623
+ * changed since the draft was saved: an error blocks, and a warning has to be acknowledged, by
2624
+ * contract and path, before the publication is made. The publication records every warning of its
2625
+ * verdict as acknowledged (ADR-021).
2626
+ */
2627
+ const publish$1 = async (input, context) => {
2628
+ const actor = actorOf(context);
2629
+ const found = await context.store.findEntryById(context.site.key, input.id, "head");
2630
+ if (found === null) return { outcome: "not_found" };
2631
+ const { entry, revision } = found;
2632
+ const collection = collectionOfEntry(context.site, entry);
2633
+ if (entry.version !== input.version) return {
2634
+ outcome: "conflict",
2635
+ conflict: conflictAt(entry.version, entry.headRevision)
2636
+ };
2637
+ const summary = entrySummary(found);
2638
+ const processed = await processFor(context.site, collection, revision.markdown);
2639
+ if (!processed.ok) return {
2640
+ outcome: "blocked",
2641
+ entry: summary,
2642
+ verdict: refusalVerdict("publish", processed.violations)
2643
+ };
2644
+ const verdict = await judge(context, collection, {
2645
+ locale: entry.locale,
2646
+ slug: entry.slug,
2647
+ entryId: entry.id
2648
+ }, processed.value, "publish");
2649
+ if (!verdict.ok) return {
2650
+ outcome: "blocked",
2651
+ entry: summary,
2652
+ verdict: verdictView(verdict)
2653
+ };
2654
+ const acknowledged = new Set(input.acknowledgeWarnings.map(warningKey));
2655
+ if (verdict.warnings.some((warning) => !acknowledged.has(warningKey(warning)))) return {
2656
+ outcome: "needs_acknowledgement",
2657
+ entry: summary,
2658
+ verdict: verdictView(verdict)
2659
+ };
2660
+ const written = await context.store.publish(context.site.key, {
2661
+ entryId: entry.id,
2662
+ expectedVersion: input.version,
2663
+ verdict,
2664
+ acknowledged: verdict.warnings.map(({ contract, path }) => ({
2665
+ contract,
2666
+ path
2667
+ })),
2668
+ actor,
2669
+ sortKey: sortKeyOf(collection, processed.value.frontmatter) ?? entry.publishedAt ?? context.clock.now().toISOString()
2670
+ });
2671
+ switch (written.outcome) {
2672
+ case "published": return {
2673
+ outcome: "published",
2674
+ entry: entrySummary(written.view),
2675
+ verdict: verdictView(verdict)
2676
+ };
2677
+ case "conflict": return {
2678
+ outcome: "conflict",
2679
+ conflict: conflictAt(written.currentVersion, written.headRevision)
2680
+ };
2681
+ case "not_found": return { outcome: "not_found" };
2682
+ case "too_large": return {
2683
+ outcome: "blocked",
2684
+ entry: summary,
2685
+ verdict: refusalVerdict("publish", [violationOf(written, collection)])
2686
+ };
2687
+ case "taken": throw unexpectedAnswer(context, "publish", written);
2688
+ }
2689
+ };
2690
+ //#endregion
2691
+ //#region src/ops/unpublish.ts
2692
+ /**
2693
+ * Takes an entry off the public site and keeps it, with its revisions, as a draft. The store
2694
+ * leaves an entry that is not published as it is.
2695
+ */
2696
+ const unpublish = async (input, context) => {
2697
+ const actor = actorOf(context);
2698
+ const written = await context.store.unpublish(context.site.key, {
2699
+ entryId: input.id,
2700
+ actor
2701
+ });
2702
+ switch (written.outcome) {
2703
+ case "unpublished": return {
2704
+ outcome: "unpublished",
2705
+ entry: entrySummary(written.view)
2706
+ };
2707
+ case "not_found": return { outcome: "not_found" };
2708
+ case "conflict":
2709
+ case "taken":
2710
+ case "too_large": throw unexpectedAnswer(context, "unpublish", written);
2711
+ }
2712
+ };
2713
+ //#endregion
2714
+ //#region src/ops/update-draft.ts
2715
+ /**
2716
+ * Saves a new revision of an entry, if it is still at the version the caller read. The version is
2717
+ * checked before anything else, so that a caller that is behind learns where the entry is now and
2718
+ * is not told what its file would have been. The file that the head already holds is `unchanged`:
2719
+ * no revision and no version, which is what makes importing the same files twice a no-op.
2720
+ */
2721
+ const updateDraft = async (input, context) => {
2722
+ const actor = actorOf(context);
2723
+ const found = await context.store.findEntryById(context.site.key, input.id, "head");
2724
+ if (found === null) return { outcome: "not_found" };
2725
+ const { entry, revision } = found;
2726
+ const collection = collectionOfEntry(context.site, entry);
2727
+ if (entry.version !== input.version) return {
2728
+ outcome: "conflict",
2729
+ conflict: conflictAt(entry.version, entry.headRevision)
2730
+ };
2731
+ if (input.markdown === revision.markdown) return {
2732
+ outcome: "unchanged",
2733
+ entry: entrySummary(found),
2734
+ verdict: verdictView(revision.verdict)
2735
+ };
2736
+ const processed = await processFor(context.site, collection, input.markdown);
2737
+ if (!processed.ok) return {
2738
+ outcome: "refused",
2739
+ verdict: refusalVerdict("write", processed.violations)
2740
+ };
2741
+ const verdict = await judge(context, collection, {
2742
+ locale: entry.locale,
2743
+ slug: entry.slug,
2744
+ entryId: entry.id
2745
+ }, processed.value, "write");
2746
+ const written = await context.store.appendRevision(context.site.key, {
2747
+ entryId: entry.id,
2748
+ expectedVersion: input.version,
2749
+ translationGroup: translationGroupOf(collection, processed.value.frontmatter),
2750
+ revision: revisionOf({
2751
+ markdown: input.markdown,
2752
+ processed: processed.value,
2753
+ collection,
2754
+ verdict,
2755
+ actor
2756
+ })
2757
+ });
2758
+ switch (written.outcome) {
2759
+ case "saved": return {
2760
+ outcome: "saved",
2761
+ entry: entrySummary(written.view),
2762
+ verdict: verdictView(verdict)
2763
+ };
2764
+ case "conflict": return {
2765
+ outcome: "conflict",
2766
+ conflict: conflictAt(written.currentVersion, written.headRevision)
2767
+ };
2768
+ case "not_found": return { outcome: "not_found" };
2769
+ case "taken":
2770
+ case "too_large": return {
2771
+ outcome: "refused",
2772
+ verdict: refusalVerdict("write", [violationOf(written, collection)])
2773
+ };
2774
+ }
2775
+ };
2776
+ //#endregion
2777
+ //#region src/ops/validate.ts
2778
+ /** Checks a file as `publish` would, and writes nothing: not even when the entry does not exist. */
2779
+ const validate = async (input, context) => {
2780
+ const collection = requireCollection(context.site, input.collection);
2781
+ requireLocale(context.site, input.locale);
2782
+ const processed = await processFor(context.site, collection, input.markdown);
2783
+ if (!processed.ok) return {
2784
+ outcome: "ok",
2785
+ verdict: refusalVerdict("publish", processed.violations)
2786
+ };
2787
+ const existing = await context.store.findEntry(context.site.key, {
2788
+ collection: input.collection,
2789
+ locale: input.locale,
2790
+ slug: input.slug
2791
+ }, "head");
2792
+ return {
2793
+ outcome: "ok",
2794
+ verdict: verdictView(await judge(context, collection, {
2795
+ locale: input.locale,
2796
+ slug: input.slug,
2797
+ entryId: existing?.entry.id ?? null
2798
+ }, processed.value, "publish"))
2799
+ };
2800
+ };
2801
+ //#endregion
2802
+ //#region src/ops/types.ts
2803
+ /** Declares the handlers; the type checks that they pair with the specs one to one. */
2804
+ function defineHandlers(handlers) {
2805
+ return handlers;
2806
+ }
2807
+ //#endregion
2808
+ //#region src/ops/index.ts
2809
+ /**
2810
+ * The handler of every operation of v0.1, paired with its spec of `@hollware/core` by type
2811
+ * (ADR-017). Adapters check the scopes and build the `OperationContext`; a handler never sees a
2812
+ * request, and reads and writes only the site of its context.
2813
+ */
2814
+ const handlers = defineHandlers({
2815
+ create_draft: createDraft,
2816
+ describe_site: describeSite$1,
2817
+ get_entry: getEntry$1,
2818
+ get_translations: getTranslations$1,
2819
+ list_entries: listEntries$1,
2820
+ publish: publish$1,
2821
+ unpublish,
2822
+ update_draft: updateDraft,
2823
+ validate
2824
+ });
2825
+ //#endregion
1
2826
  //#region src/index.ts
2
2827
  const name = "@hollware/server";
2828
+ /**
2829
+ * Builds the Worker from the config: `const hollware = createHollware(config)`, then
2830
+ * `export default { fetch: hollware.fetch }` and `export class Hollware extends hollware.Hollware {}`.
2831
+ * The config is resolved once, here; a broken one throws its `ConfigError`.
2832
+ */
2833
+ const createHollware = (config) => createHollwareWith(config, handlers);
3
2834
  //#endregion
4
- export { name };
2835
+ export { MAX_BOUND_PARAMETERS, MAX_ROW_BYTES, OPERATION_TIMEOUT_MS, OUTPUT_CAP_BYTES, createHollware, defineHandlers, name };