@hollware/server 0.0.2 → 0.2.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,3847 @@
1
+ import { EVENT_ID_HEADER, HollwareError, OPERATIONS, SIGNATURE_HEADER, TIMESTAMP_HEADER, describeBuiltInContracts, describeResolvedSite, describeSite, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
2
+ import { Hono } from "hono";
3
+ import { drizzle } from "drizzle-orm/d1";
4
+ import { and, desc, eq, getTableColumns, inArray, isNull, lte, 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.2.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
+ /**
69
+ * 403 for a token that authenticated but lacks scopes. It names every scope that is missing, in
70
+ * the body and in the challenge (RFC 6750), so that a client asks for all of them at once.
71
+ */
72
+ function insufficientScope(missing) {
73
+ return authErrorResponse(403, {
74
+ code: "insufficient_scope",
75
+ message: `The token lacks a scope this operation requires. Missing: ${missing.join(", ")}.`,
76
+ fix: "Send a token that carries every missing scope. Create one with `hollware token create`; a token only gets the scopes its user's role allows."
77
+ }, `${REALM}, error="insufficient_scope", scope="${missing.join(" ")}"`);
78
+ }
79
+ //#endregion
80
+ //#region src/auth/guard.ts
81
+ /**
82
+ * What the Host and Origin of a request are compared with: the host and the origin that `new URL`
83
+ * makes of the variable (lowercase, no default port). Path, query and fragment play no part.
84
+ * `null` when the variable is not an absolute `http` or `https` URL, or is not a string at all.
85
+ */
86
+ function readPublicUrl(value) {
87
+ if (typeof value !== "string") return null;
88
+ let url;
89
+ try {
90
+ url = new URL(value);
91
+ } catch {
92
+ return null;
93
+ }
94
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
95
+ return {
96
+ host: url.host,
97
+ hostWithDefaultPort: url.port === "" ? `${url.host}:${url.protocol === "https:" ? 443 : 80}` : null,
98
+ origin: url.origin
99
+ };
100
+ }
101
+ /**
102
+ * The guard that runs in front of every route, against DNS rebinding and cross-site requests
103
+ * from a browser (ADR-019). It fails closed: every request it does not recognize is refused.
104
+ *
105
+ * - `HOLLWARE_PUBLIC_URL` missing or not an `http` or `https` URL: 500. Without it nothing can
106
+ * be compared, and skipping the check would leave the Worker open.
107
+ * - A `Host` header that is missing, or is not the host of the public URL, port included: 403.
108
+ * The comparison ignores case, and takes the default port of the scheme written out
109
+ * (`cms.example.com:443` for an `https` URL), which some clients send for the same server.
110
+ * Any other port is another host.
111
+ * - An `Origin` header that is present and is not the origin of the public URL, scheme and port
112
+ * included: 403. That covers the opaque `null` origin and an empty value. A request without
113
+ * `Origin` passes: only browsers send it, and they are what the check is for.
114
+ *
115
+ * It returns the response to send, or `null` when the request may go on. No answer repeats the
116
+ * request: the 403s say what to send instead, and the 500 only says what is missing.
117
+ *
118
+ * The helpers of the MCP SDK (`hostHeaderValidationResponse`, `originValidationResponse`) do not
119
+ * fit: they compare hostnames only, so they accept any port and, for `Origin`, any scheme, and
120
+ * they accept a `Host` such as `user@cms.example.com` or `cms.example.com/x`.
121
+ *
122
+ * @param publicUrl The `HOLLWARE_PUBLIC_URL` variable as the environment holds it.
123
+ */
124
+ function checkHostAndOrigin(request, publicUrl) {
125
+ const expected = readPublicUrl(publicUrl);
126
+ if (expected === null) return authErrorResponse(500, {
127
+ code: "misconfigured",
128
+ message: "HOLLWARE_PUBLIC_URL is required: it is missing, or it is not an http or https URL.",
129
+ fix: "Set the HOLLWARE_PUBLIC_URL variable of the Worker to its public URL, such as https://cms.example.com, and deploy again."
130
+ });
131
+ const host = request.headers.get("host")?.toLowerCase() ?? null;
132
+ if (host === null || host !== expected.host && host !== expected.hostWithDefaultPort) return authErrorResponse(403, {
133
+ code: "forbidden_host",
134
+ message: "The Host header of the request is not the public URL of this Hollware.",
135
+ fix: `Send the request to ${expected.origin}, the public URL of this Hollware (HOLLWARE_PUBLIC_URL).`
136
+ });
137
+ const origin = request.headers.get("origin");
138
+ if (origin !== null && origin.toLowerCase() !== expected.origin) return authErrorResponse(403, {
139
+ code: "forbidden_origin",
140
+ message: "The Origin header of the request is not the public URL of this Hollware.",
141
+ fix: `Call Hollware from a client that sends no Origin header, or from a page served at ${expected.origin}.`
142
+ });
143
+ return null;
144
+ }
145
+ //#endregion
146
+ //#region src/auth/roles.ts
147
+ /**
148
+ * The roles of a user, as the `users_role` CHECK of the schema lists them. In v0.1 only the owner
149
+ * exists: `hollware init` creates it. The other two are in the CHECK already, so that v0.3 can
150
+ * invite people without a rebuild of the table (ADR-023).
151
+ */
152
+ const ROLES = [
153
+ "owner",
154
+ "editor",
155
+ "reviewer"
156
+ ];
157
+ /** Whether a string is one of the roles. */
158
+ function isRole(value) {
159
+ return ROLES.some((role) => role === value);
160
+ }
161
+ /**
162
+ * The scopes each role allows. What a token may do is its own scopes intersected with these,
163
+ * read from the user's current role on every request (ADR-005, ADR-019). An operation asks for
164
+ * scopes, never for a role.
165
+ *
166
+ * - `owner` may do everything: all four scopes.
167
+ * - `editor` and `reviewer` allow none in v0.1. Nothing creates such a user yet, and which scopes
168
+ * each one gets (an editor writes, a reviewer approves) is for v0.3 to decide, together with
169
+ * the admin that invites them. Until then a token of such a user authenticates, but every
170
+ * operation answers 403 `insufficient_scope`.
171
+ */
172
+ const ROLE_SCOPES = {
173
+ owner: [
174
+ "content:read",
175
+ "content:write",
176
+ "content:publish",
177
+ "schema:admin"
178
+ ],
179
+ editor: [],
180
+ reviewer: []
181
+ };
182
+ /**
183
+ * The scopes a token really has: those it carries that the role of its user allows, in the order
184
+ * of the role. A role that is not one of the roles allows nothing, so a role added to the schema
185
+ * before this table knows it grants no access.
186
+ */
187
+ function effectiveScopes(role, tokenScopes) {
188
+ if (!isRole(role)) return [];
189
+ return ROLE_SCOPES[role].filter((scope) => tokenScopes.includes(scope));
190
+ }
191
+ //#endregion
192
+ //#region src/auth/token.ts
193
+ /** `last_used_at` of a token is written at most once in this time. */
194
+ const TOUCH_INTERVAL_MS = 36e5;
195
+ /** The token with the role of its user, by the hash of the token, in the one site. */
196
+ const LOOKUP = `SELECT t.id AS token_id, t.site_id AS site_id, t.user_id AS user_id,
197
+ t.scopes AS scopes, t.expires_at AS expires_at, t.revoked_at AS revoked_at,
198
+ t.last_used_at AS last_used_at, u.role AS role
199
+ FROM tokens AS t JOIN users AS u ON u.id = t.user_id
200
+ WHERE t.hash = ?1 AND t.site_id = ?2`;
201
+ /**
202
+ * The scopes a token carries, from the JSON array the `tokens` table stores. Anything that is not
203
+ * one of the scopes is left out, and a value that is not an array of scopes has none: a token
204
+ * never gets more than what the catalog names.
205
+ */
206
+ function parseTokenScopes(json) {
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(json);
210
+ } catch {
211
+ return [];
212
+ }
213
+ if (!Array.isArray(parsed)) return [];
214
+ return parsed.filter((value) => typeof value === "string" && isScope(value));
215
+ }
216
+ /**
217
+ * Whether an expiry has passed. A token is expired at the very instant of its expiry, and one
218
+ * whose expiry cannot be read is expired too: the check fails closed. The expiry is read as an
219
+ * instant, so any ISO 8601 spelling of it works, with or without milliseconds.
220
+ */
221
+ function hasExpired(expiresAt, now) {
222
+ if (expiresAt === null) return false;
223
+ const instant = Date.parse(expiresAt);
224
+ return Number.isNaN(instant) || instant <= now.getTime();
225
+ }
226
+ /** The instant an hour before `now`, spelled as `last_used_at` is: ISO 8601 UTC with milliseconds. */
227
+ function anHourBefore(now) {
228
+ return (/* @__PURE__ */ new Date(now.getTime() - TOUCH_INTERVAL_MS)).toISOString();
229
+ }
230
+ /**
231
+ * Writes `last_used_at` (and `updated_at`) of a token to `now`, unless it was written within the
232
+ * hour before: the condition is in the `UPDATE`, so two requests that both read an old value
233
+ * still write once. A failure is logged and swallowed: a statistic never fails the request, and
234
+ * the returned promise never rejects, so nobody has to handle it.
235
+ */
236
+ async function recordTokenUse(db, token, now) {
237
+ try {
238
+ await db.prepare(`UPDATE tokens SET last_used_at = ?1, updated_at = ?1
239
+ 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();
240
+ } catch (error) {
241
+ console.error("hollware: could not record the last use of a token", error);
242
+ }
243
+ }
244
+ /**
245
+ * Authenticates a personal token (ADR-019). A string without the shape of a token is `unknown`
246
+ * without a query. Otherwise one query finds the token by the hash of the whole string, in this
247
+ * site only, together with the role of its user; the answer is, in this order:
248
+ *
249
+ * - `unknown`: no such token in this site. A token of another site is the same as no token, and
250
+ * nothing says it exists elsewhere.
251
+ * - `revoked`: it has a revocation date, whatever the date says: it is never read, so a clock that
252
+ * runs ahead of the Worker's, where the token was revoked, cannot keep it alive for a while.
253
+ * - `expired`: its expiry is now or past (see `hasExpired`).
254
+ * - `ok`: the principal, whose scopes are the token's own intersected with those of the role its
255
+ * user has now. A token whose user has no scopes (an editor, in v0.1) still authenticates, and
256
+ * every operation then answers `insufficient_scope`.
257
+ *
258
+ * Pass the `D1Database` binding itself and not a replica session: a token that was revoked must
259
+ * stop working on the next request, and only the primary knows. A D1 failure rejects, and a caller
260
+ * must never read that as a valid token.
261
+ */
262
+ async function checkPersonalToken(db, token, options) {
263
+ if (!isPersonalToken(token)) return {
264
+ ok: false,
265
+ reason: "unknown"
266
+ };
267
+ const row = await db.prepare(LOOKUP).bind(await hashPersonalToken(token), options.site).first();
268
+ if (row === null) return {
269
+ ok: false,
270
+ reason: "unknown"
271
+ };
272
+ if (row.revoked_at !== null) return {
273
+ ok: false,
274
+ reason: "revoked"
275
+ };
276
+ if (hasExpired(row.expires_at, options.now)) return {
277
+ ok: false,
278
+ reason: "expired"
279
+ };
280
+ const recent = row.last_used_at !== null && row.last_used_at >= anHourBefore(options.now);
281
+ return {
282
+ ok: true,
283
+ principal: {
284
+ kind: "token",
285
+ userId: row.user_id,
286
+ tokenId: row.token_id,
287
+ site: row.site_id,
288
+ scopes: effectiveScopes(row.role, parseTokenScopes(row.scopes))
289
+ },
290
+ touch: recent ? null : recordTokenUse(db, {
291
+ id: row.token_id,
292
+ site: row.site_id
293
+ }, options.now)
294
+ };
295
+ }
296
+ //#endregion
297
+ //#region src/db/d1.ts
298
+ /**
299
+ * Drizzle's D1 driver is typed over a `D1Database`, but at runtime it only calls `prepare` and
300
+ * `batch` (drizzle-orm d1/session.js), so it also runs on a session. This is a `D1Database` over
301
+ * a `D1Like` without a cast: the three methods drizzle never calls throw, so a driver that starts
302
+ * calling one fails loudly instead of reaching the wrong database (spike S4).
303
+ */
304
+ function asD1Database(db) {
305
+ const unavailable = (method) => {
306
+ throw new Error(`The store runs on prepare() and batch() only, so D1 ${method}() is not there`);
307
+ };
308
+ return {
309
+ prepare: (query) => db.prepare(query),
310
+ batch: (statements) => db.batch(statements),
311
+ exec: () => unavailable("exec"),
312
+ withSession: () => unavailable("withSession"),
313
+ dump: () => unavailable("dump")
314
+ };
315
+ }
316
+ /** Drizzle over `db`, with no schema (the store builds its queries from the table objects). */
317
+ function connect(db) {
318
+ return drizzle(asD1Database(db));
319
+ }
320
+ //#endregion
321
+ //#region src/limits.ts
322
+ /**
323
+ * The limits every operation runs under, pinned for the adapters and the store (the v0.1 plan).
324
+ */
325
+ /**
326
+ * The largest answer of an operation, in UTF-8 bytes of its JSON. A larger one is the error
327
+ * `output_too_large`, with a fix that says how to ask for less; content is never truncated.
328
+ */
329
+ const OUTPUT_CAP_BYTES = 1048576;
330
+ /** How long an operation may take before it answers the error `timeout`. */
331
+ const OPERATION_TIMEOUT_MS = 1e4;
332
+ /**
333
+ * The largest row the store writes, counted in UTF-8 bytes across all its columns. D1 refuses
334
+ * rows over 2 MB in production, and Miniflare does not enforce it, so the store checks first.
335
+ */
336
+ const MAX_ROW_BYTES = 19e5;
337
+ /** The most values bound in one statement: D1 allows 100, and lookups go in chunks of this. */
338
+ const MAX_BOUND_PARAMETERS = 90;
339
+ //#endregion
340
+ //#region src/db/chunks.ts
341
+ /**
342
+ * D1 refuses a statement with more than 100 bound values, and Miniflare enforces it too. Lookups
343
+ * and multi-row inserts go in chunks that stay under `MAX_BOUND_PARAMETERS`.
344
+ */
345
+ /**
346
+ * How many rows of `perRow` bound values one statement can take when it also binds `shared`
347
+ * values of its own. At least one: a row that does not fit alone is a bug of the statement.
348
+ */
349
+ function rowsPerStatement(shared, perRow) {
350
+ return Math.max(1, Math.floor((90 - shared) / perRow));
351
+ }
352
+ /** `items` in groups of at most `size`, in order. */
353
+ function chunk(items, size) {
354
+ const groups = [];
355
+ for (let start = 0; start < items.length; start += size) groups.push(items.slice(start, start + size));
356
+ return groups;
357
+ }
358
+ //#endregion
359
+ //#region src/db/cursor.ts
360
+ function invalidCursor() {
361
+ 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.");
362
+ }
363
+ function encodeBase64Url(text) {
364
+ let binary = "";
365
+ for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);
366
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
367
+ }
368
+ /** The text a base64url string holds, or `null` when it is not base64url of UTF-8. */
369
+ function decodeBase64Url(text) {
370
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
371
+ try {
372
+ const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
373
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
374
+ return new TextDecoder("utf-8", {
375
+ fatal: true,
376
+ ignoreBOM: true
377
+ }).decode(bytes);
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+ function parseJson(text) {
383
+ try {
384
+ return JSON.parse(text);
385
+ } catch {
386
+ return;
387
+ }
388
+ }
389
+ /** The cursor that continues a `view` listing after `position`. */
390
+ function encodeCursor(view, position) {
391
+ return encodeBase64Url(JSON.stringify({
392
+ v: view,
393
+ k: position.key,
394
+ i: position.id
395
+ }));
396
+ }
397
+ /**
398
+ * The position a cursor stands for. A cursor that no listing of this view returned, whatever is
399
+ * wrong with it, is the error `invalid_input` with a fix.
400
+ */
401
+ function decodeCursor(cursor, view) {
402
+ const text = decodeBase64Url(cursor);
403
+ const value = text === null ? void 0 : parseJson(text);
404
+ 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 {
405
+ key: value.k,
406
+ id: value.i
407
+ };
408
+ throw invalidCursor();
409
+ }
410
+ //#endregion
411
+ //#region src/db/columns.ts
412
+ /**
413
+ * The columns every table shares. Each helper returns a new builder, because a Drizzle column
414
+ * belongs to one table. Only the repository functions write rows, so these defaults are what
415
+ * every row gets.
416
+ */
417
+ /** The current time in UTC, as ISO 8601 with milliseconds: `2026-10-12T18:30:00.000Z`. */
418
+ function nowIso() {
419
+ return (/* @__PURE__ */ new Date()).toISOString();
420
+ }
421
+ /** The primary key: a prefixed ULID, such as `ent_01JAB3XQ8ZK4M6N2P5R7S9T0VW`. */
422
+ function id(prefix) {
423
+ return text("id").primaryKey().$defaultFn(() => newId(prefix));
424
+ }
425
+ /**
426
+ * The site a row belongs to: the site's key in `hollware.config.ts`, which is also the id of its
427
+ * row in `sites`. Every table but the deployment-wide ones carries it (ADR-006).
428
+ */
429
+ function siteId() {
430
+ return text("site_id").notNull();
431
+ }
432
+ /** `created_at` and `updated_at`, in UTC; `updated_at` moves on every update. */
433
+ function timestamps() {
434
+ return {
435
+ createdAt: text("created_at").notNull().$defaultFn(nowIso),
436
+ updatedAt: text("updated_at").notNull().$defaultFn(nowIso).$onUpdateFn(nowIso)
437
+ };
438
+ }
439
+ sqliteTable("_hollware_meta", {
440
+ key: text("key").primaryKey().notNull(),
441
+ value: text("value").notNull()
442
+ });
443
+ /** The sites of the deployment. The id is the site's key in `hollware.config.ts`. */
444
+ const sites = sqliteTable("sites", {
445
+ id: text("id").primaryKey(),
446
+ ...timestamps()
447
+ });
448
+ /** People. A user belongs to the deployment; tokens carry the site. */
449
+ const users = sqliteTable("users", {
450
+ id: id("usr"),
451
+ name: text("name"),
452
+ role: text("role").notNull(),
453
+ ...timestamps()
454
+ }, (t) => [check("users_role", sql`${t.role} IN ('owner', 'editor', 'reviewer')`)]);
455
+ /** Personal tokens (ADR-019): only the hash is stored. */
456
+ const tokens = sqliteTable("tokens", {
457
+ id: id("tok"),
458
+ siteId: siteId(),
459
+ userId: text("user_id").notNull(),
460
+ label: text("label").notNull(),
461
+ hash: text("hash").notNull(),
462
+ /** A JSON array of scopes. */
463
+ scopes: text("scopes").notNull(),
464
+ expiresAt: text("expires_at"),
465
+ revokedAt: text("revoked_at"),
466
+ lastUsedAt: text("last_used_at"),
467
+ ...timestamps()
468
+ }, (t) => [
469
+ unique("tokens_id_site").on(t.id, t.siteId),
470
+ unique("tokens_hash").on(t.hash),
471
+ foreignKey({
472
+ name: "tokens_site",
473
+ columns: [t.siteId],
474
+ foreignColumns: [sites.id]
475
+ }),
476
+ foreignKey({
477
+ name: "tokens_user",
478
+ columns: [t.userId],
479
+ foreignColumns: [users.id]
480
+ }),
481
+ check("tokens_scopes", sql`json_valid(${t.scopes}) AND json_type(${t.scopes}) = 'array'`)
482
+ ]);
483
+ /** One entry per site, collection, locale and slug; its content lives in `revisions`. */
484
+ const entries = sqliteTable("entries", {
485
+ id: id("ent"),
486
+ siteId: siteId(),
487
+ collection: text("collection").notNull(),
488
+ locale: text("locale").notNull(),
489
+ slug: text("slug").notNull(),
490
+ translationGroup: text("translation_group"),
491
+ status: text("status").notNull(),
492
+ /** Bumped by every change of the entry: the optimistic write compares it. */
493
+ version: integer("version").notNull(),
494
+ headRev: text("head_rev").notNull(),
495
+ publishedRev: text("published_rev"),
496
+ publishedAt: text("published_at"),
497
+ publishedSortKey: text("published_sort_key"),
498
+ scheduledAt: text("scheduled_at"),
499
+ ...timestamps()
500
+ }, (t) => [
501
+ unique("entries_id_site").on(t.id, t.siteId),
502
+ unique("entries_slug").on(t.siteId, t.collection, t.locale, t.slug),
503
+ unique("entries_translation").on(t.siteId, t.collection, t.translationGroup, t.locale),
504
+ index("entries_head").on(t.siteId, t.collection, t.updatedAt, t.id),
505
+ index("entries_head_locale").on(t.siteId, t.collection, t.locale, t.updatedAt, t.id),
506
+ index("entries_published").on(t.siteId, t.collection, t.locale, t.publishedSortKey, t.id).where(sql`${t.status} = 'published'`),
507
+ foreignKey({
508
+ name: "entries_site",
509
+ columns: [t.siteId],
510
+ foreignColumns: [sites.id]
511
+ }),
512
+ check("entries_status", sql`${t.status} IN ('draft', 'scheduled', 'published')`),
513
+ check("entries_version", sql`${t.version} >= 1`),
514
+ check("entries_draft", sql`${t.status} <> 'draft' OR (${t.publishedRev} IS NULL AND ${t.publishedSortKey} IS NULL AND ${t.scheduledAt} IS NULL)`),
515
+ 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)`),
516
+ check("entries_scheduled", sql`${t.status} <> 'scheduled' OR ${t.scheduledAt} IS NOT NULL`)
517
+ ]);
518
+ /**
519
+ * Every version of an entry, appended and never changed (ADR-021). `markdown` is the whole file,
520
+ * frontmatter included; the derived columns can be rebuilt by `reparse`.
521
+ */
522
+ const revisions = sqliteTable("revisions", {
523
+ id: id("rev"),
524
+ siteId: siteId(),
525
+ entryId: text("entry_id").notNull(),
526
+ markdown: text("markdown").notNull(),
527
+ /** Derived: the frontmatter as JSON. */
528
+ frontmatter: text("frontmatter").notNull(),
529
+ /** Derived: the Comark document as JSON. */
530
+ document: text("document").notNull(),
531
+ comarkVersion: text("comark_version").notNull(),
532
+ pipelineVersion: text("pipeline_version").notNull(),
533
+ wordCount: integer("word_count").notNull(),
534
+ /** The verdict of the contracts when the revision was written, as JSON. */
535
+ verdict: text("verdict").notNull(),
536
+ actorKind: text("actor_kind").notNull(),
537
+ actorUserId: text("actor_user_id").notNull(),
538
+ actorTokenId: text("actor_token_id"),
539
+ /** The client and, when it says, the model behind the write, as JSON. */
540
+ actorMeta: text("actor_meta"),
541
+ ...timestamps()
542
+ }, (t) => [
543
+ unique("revisions_id_site").on(t.id, t.siteId),
544
+ index("revisions_entry").on(t.entryId, t.createdAt),
545
+ foreignKey({
546
+ name: "revisions_site",
547
+ columns: [t.siteId],
548
+ foreignColumns: [sites.id]
549
+ }),
550
+ foreignKey({
551
+ name: "revisions_entry",
552
+ columns: [t.entryId, t.siteId],
553
+ foreignColumns: [entries.id, entries.siteId]
554
+ }),
555
+ foreignKey({
556
+ name: "revisions_user",
557
+ columns: [t.actorUserId],
558
+ foreignColumns: [users.id]
559
+ }),
560
+ foreignKey({
561
+ name: "revisions_token",
562
+ columns: [t.actorTokenId, t.siteId],
563
+ foreignColumns: [tokens.id, tokens.siteId]
564
+ }),
565
+ check("revisions_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
566
+ 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}))`),
567
+ check("revisions_word_count", sql`${t.wordCount} >= 0`)
568
+ ]);
569
+ /** The tags of a revision, for listing published entries by tag. Append-only, like revisions. */
570
+ const revisionTags = sqliteTable("revision_tags", {
571
+ id: id("tag"),
572
+ siteId: siteId(),
573
+ revisionId: text("revision_id").notNull(),
574
+ tag: text("tag").notNull(),
575
+ ...timestamps()
576
+ }, (t) => [
577
+ unique("revision_tags_id_site").on(t.id, t.siteId),
578
+ unique("revision_tags_tag").on(t.revisionId, t.tag),
579
+ index("revision_tags_by_tag").on(t.siteId, t.tag),
580
+ foreignKey({
581
+ name: "revision_tags_site",
582
+ columns: [t.siteId],
583
+ foreignColumns: [sites.id]
584
+ }),
585
+ foreignKey({
586
+ name: "revision_tags_revision",
587
+ columns: [t.revisionId, t.siteId],
588
+ foreignColumns: [revisions.id, revisions.siteId]
589
+ })
590
+ ]);
591
+ /**
592
+ * Every publish and unpublish, appended: the verdict of the publish phase and the warnings the
593
+ * caller acknowledged (ADR-021).
594
+ */
595
+ const publications = sqliteTable("publications", {
596
+ id: id("pub"),
597
+ siteId: siteId(),
598
+ entryId: text("entry_id").notNull(),
599
+ action: text("action").notNull(),
600
+ revisionId: text("revision_id"),
601
+ verdict: text("verdict"),
602
+ /** A JSON array: the warnings the caller acknowledged to publish. */
603
+ acknowledged: text("acknowledged").notNull(),
604
+ actorKind: text("actor_kind").notNull(),
605
+ actorUserId: text("actor_user_id").notNull(),
606
+ actorTokenId: text("actor_token_id"),
607
+ actorMeta: text("actor_meta"),
608
+ ...timestamps()
609
+ }, (t) => [
610
+ unique("publications_id_site").on(t.id, t.siteId),
611
+ index("publications_entry").on(t.entryId, t.createdAt),
612
+ foreignKey({
613
+ name: "publications_site",
614
+ columns: [t.siteId],
615
+ foreignColumns: [sites.id]
616
+ }),
617
+ foreignKey({
618
+ name: "publications_entry",
619
+ columns: [t.entryId, t.siteId],
620
+ foreignColumns: [entries.id, entries.siteId]
621
+ }),
622
+ foreignKey({
623
+ name: "publications_revision",
624
+ columns: [t.revisionId, t.siteId],
625
+ foreignColumns: [revisions.id, revisions.siteId]
626
+ }),
627
+ foreignKey({
628
+ name: "publications_user",
629
+ columns: [t.actorUserId],
630
+ foreignColumns: [users.id]
631
+ }),
632
+ foreignKey({
633
+ name: "publications_token",
634
+ columns: [t.actorTokenId, t.siteId],
635
+ foreignColumns: [tokens.id, tokens.siteId]
636
+ }),
637
+ check("publications_action", sql`${t.action} IN ('publish', 'unpublish')`),
638
+ 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)`),
639
+ check("publications_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
640
+ 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}))`)
641
+ ]);
642
+ /**
643
+ * Scheduled publications (ADR-025): one per entry at most. The cron trigger publishes the entry when
644
+ * `scheduled_at` has passed, if it is still at `version`, with the warnings that were acknowledged
645
+ * when it was scheduled, and in the name of who scheduled it.
646
+ */
647
+ const schedules = sqliteTable("schedules", {
648
+ id: id("sch"),
649
+ siteId: siteId(),
650
+ entryId: text("entry_id").notNull(),
651
+ scheduledAt: text("scheduled_at").notNull(),
652
+ /** The version of the entry once scheduled: a later change cancels the schedule. */
653
+ version: integer("version").notNull(),
654
+ /** A JSON array: the warnings acknowledged when the publication was scheduled. */
655
+ acknowledged: text("acknowledged").notNull(),
656
+ actorKind: text("actor_kind").notNull(),
657
+ actorUserId: text("actor_user_id").notNull(),
658
+ actorTokenId: text("actor_token_id"),
659
+ actorMeta: text("actor_meta"),
660
+ ...timestamps()
661
+ }, (t) => [
662
+ unique("schedules_id_site").on(t.id, t.siteId),
663
+ unique("schedules_entry").on(t.siteId, t.entryId),
664
+ index("schedules_due").on(t.scheduledAt),
665
+ foreignKey({
666
+ name: "schedules_site",
667
+ columns: [t.siteId],
668
+ foreignColumns: [sites.id]
669
+ }),
670
+ foreignKey({
671
+ name: "schedules_entry_fk",
672
+ columns: [t.entryId, t.siteId],
673
+ foreignColumns: [entries.id, entries.siteId]
674
+ }),
675
+ foreignKey({
676
+ name: "schedules_user",
677
+ columns: [t.actorUserId],
678
+ foreignColumns: [users.id]
679
+ }),
680
+ foreignKey({
681
+ name: "schedules_token",
682
+ columns: [t.actorTokenId, t.siteId],
683
+ foreignColumns: [tokens.id, tokens.siteId]
684
+ }),
685
+ check("schedules_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
686
+ check("schedules_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`)
687
+ ]);
688
+ /**
689
+ * The effects of a publication (ADR-025), written in the batch that publishes or unpublishes: one
690
+ * row per event. The Queue consumer delivers each event to every subscriber, and records it in
691
+ * `deliveries`; `sent_at` says that the event was put on the Queue.
692
+ */
693
+ const outbox = sqliteTable("outbox", {
694
+ id: id("evt"),
695
+ siteId: siteId(),
696
+ /** `entry.published` or `entry.unpublished`. */
697
+ kind: text("kind").notNull(),
698
+ entryId: text("entry_id").notNull(),
699
+ /** A JSON object: what subscribers get. */
700
+ payload: text("payload").notNull(),
701
+ sentAt: text("sent_at"),
702
+ ...timestamps()
703
+ }, (t) => [
704
+ unique("outbox_id_site").on(t.id, t.siteId),
705
+ index("outbox_unsent").on(t.sentAt, t.createdAt),
706
+ foreignKey({
707
+ name: "outbox_site",
708
+ columns: [t.siteId],
709
+ foreignColumns: [sites.id]
710
+ }),
711
+ check("outbox_kind", sql`${t.kind} IN ('entry.published', 'entry.unpublished')`),
712
+ check("outbox_json", sql`json_valid(${t.payload}) AND json_type(${t.payload}) = 'object'`)
713
+ ]);
714
+ /** Each delivery of an event to a subscriber, so that a retry never delivers it twice. */
715
+ const deliveries = sqliteTable("deliveries", {
716
+ id: id("dlv"),
717
+ siteId: siteId(),
718
+ eventId: text("event_id").notNull(),
719
+ /** The subscriber, by its name in the config, such as `webhook:newsletter` or `indexnow`. */
720
+ subscriber: text("subscriber").notNull(),
721
+ attempts: integer("attempts").notNull(),
722
+ deliveredAt: text("delivered_at"),
723
+ lastError: text("last_error"),
724
+ ...timestamps()
725
+ }, (t) => [
726
+ unique("deliveries_id_site").on(t.id, t.siteId),
727
+ unique("deliveries_event_subscriber").on(t.eventId, t.subscriber),
728
+ foreignKey({
729
+ name: "deliveries_site",
730
+ columns: [t.siteId],
731
+ foreignColumns: [sites.id]
732
+ }),
733
+ foreignKey({
734
+ name: "deliveries_event",
735
+ columns: [t.eventId, t.siteId],
736
+ foreignColumns: [outbox.id, outbox.siteId]
737
+ }),
738
+ check("deliveries_attempts", sql`${t.attempts} >= 0`)
739
+ ]);
740
+ //#endregion
741
+ //#region src/db/rows.ts
742
+ /**
743
+ * The columns the store reads and how they become the types of `ports.ts`. The JSON columns come
744
+ * back as the store wrote them: the `CHECK`s of the schema guarantee valid JSON, and only the
745
+ * store writes the shapes.
746
+ */
747
+ /** The columns of `entries` that `StoredEntry` holds. */
748
+ const entryFields = {
749
+ id: entries.id,
750
+ collection: entries.collection,
751
+ locale: entries.locale,
752
+ slug: entries.slug,
753
+ translationGroup: entries.translationGroup,
754
+ status: entries.status,
755
+ version: entries.version,
756
+ headRev: entries.headRev,
757
+ publishedRev: entries.publishedRev,
758
+ publishedAt: entries.publishedAt,
759
+ updatedAt: entries.updatedAt
760
+ };
761
+ /** The columns of `revisions` that `StoredRevision` holds, except its tags. */
762
+ const revisionFields = {
763
+ id: revisions.id,
764
+ entryId: revisions.entryId,
765
+ markdown: revisions.markdown,
766
+ frontmatter: revisions.frontmatter,
767
+ document: revisions.document,
768
+ comarkVersion: revisions.comarkVersion,
769
+ pipelineVersion: revisions.pipelineVersion,
770
+ wordCount: revisions.wordCount,
771
+ verdict: revisions.verdict,
772
+ actorKind: revisions.actorKind,
773
+ actorUserId: revisions.actorUserId,
774
+ actorTokenId: revisions.actorTokenId,
775
+ actorMeta: revisions.actorMeta,
776
+ createdAt: revisions.createdAt
777
+ };
778
+ /**
779
+ * The columns of `revisions` that a listing reads: the id and the frontmatter of the revision of
780
+ * the view, and nothing else. The Markdown, the document and the verdict can weigh megabytes, so
781
+ * a listing never selects them (`tests/db/list.workerd.test.ts` holds the statements to it).
782
+ */
783
+ const revisionSummaryFields = {
784
+ id: revisions.id,
785
+ frontmatter: revisions.frontmatter
786
+ };
787
+ /** The columns of `publications` that `StoredPublication` holds. */
788
+ const publicationFields = {
789
+ id: publications.id,
790
+ entryId: publications.entryId,
791
+ action: publications.action,
792
+ revisionId: publications.revisionId,
793
+ verdict: publications.verdict,
794
+ acknowledged: publications.acknowledged,
795
+ actorKind: publications.actorKind,
796
+ actorUserId: publications.actorUserId,
797
+ actorTokenId: publications.actorTokenId,
798
+ actorMeta: publications.actorMeta,
799
+ createdAt: publications.createdAt
800
+ };
801
+ /**
802
+ * The tags of a revision as a JSON array, read in the same statement as the revision: one query
803
+ * per page, and the same snapshot. `revision` is the revision table the statement joins.
804
+ */
805
+ function tagsOf$1(revision) {
806
+ return sql`(select json_group_array(${revisionTags.tag}) from ${revisionTags} where ${and(eq(revisionTags.revisionId, revision.id), eq(revisionTags.siteId, revision.siteId))})`;
807
+ }
808
+ /** The value of `candidates` that `value` is, or a failure: the schema's `CHECK`s forbid the rest. */
809
+ function oneOf(candidates, value, what) {
810
+ for (const candidate of candidates) if (candidate === value) return candidate;
811
+ throw new Error(`A row has ${what} ${JSON.stringify(value)}, which the schema does not allow`);
812
+ }
813
+ /** The status of an entry, which the schema's `CHECK` limits to these three. */
814
+ function entryStatus(value) {
815
+ return oneOf([
816
+ "draft",
817
+ "scheduled",
818
+ "published"
819
+ ], value, "the status");
820
+ }
821
+ function toEntry(row) {
822
+ return {
823
+ id: row.id,
824
+ collection: row.collection,
825
+ locale: row.locale,
826
+ slug: row.slug,
827
+ translationGroup: row.translationGroup,
828
+ status: entryStatus(row.status),
829
+ version: row.version,
830
+ headRevision: row.headRev,
831
+ publishedRevision: row.publishedRev,
832
+ publishedAt: row.publishedAt,
833
+ updatedAt: row.updatedAt
834
+ };
835
+ }
836
+ function toActor(row) {
837
+ const meta = row.actorMeta === null ? null : JSON.parse(row.actorMeta);
838
+ return {
839
+ kind: oneOf([
840
+ "token",
841
+ "user",
842
+ "oauth_client"
843
+ ], row.actorKind, "the actor kind"),
844
+ userId: row.actorUserId,
845
+ tokenId: row.actorTokenId,
846
+ meta
847
+ };
848
+ }
849
+ /** The tags come back sorted, so that a revision reads the same however its tags were written. */
850
+ function parseTags(tagsJson) {
851
+ return JSON.parse(tagsJson).toSorted();
852
+ }
853
+ function toRevision(row, tagsJson) {
854
+ const frontmatter = JSON.parse(row.frontmatter);
855
+ const document = JSON.parse(row.document);
856
+ const verdict = JSON.parse(row.verdict);
857
+ return {
858
+ id: row.id,
859
+ entryId: row.entryId,
860
+ markdown: row.markdown,
861
+ frontmatter,
862
+ document,
863
+ comarkVersion: row.comarkVersion,
864
+ pipelineVersion: row.pipelineVersion,
865
+ wordCount: row.wordCount,
866
+ verdict,
867
+ tags: parseTags(tagsJson),
868
+ actor: toActor(row),
869
+ createdAt: row.createdAt
870
+ };
871
+ }
872
+ function toRevisionSummary(row, tagsJson) {
873
+ const frontmatter = JSON.parse(row.frontmatter);
874
+ return {
875
+ id: row.id,
876
+ frontmatter,
877
+ tags: parseTags(tagsJson)
878
+ };
879
+ }
880
+ function toPublication(row) {
881
+ const verdict = row.verdict === null ? null : JSON.parse(row.verdict);
882
+ const acknowledged = JSON.parse(row.acknowledged);
883
+ return {
884
+ id: row.id,
885
+ entryId: row.entryId,
886
+ action: oneOf(["publish", "unpublish"], row.action, "the action"),
887
+ revisionId: row.revisionId,
888
+ verdict,
889
+ acknowledged,
890
+ actor: toActor(row),
891
+ createdAt: row.createdAt
892
+ };
893
+ }
894
+ //#endregion
895
+ //#region src/db/reads.ts
896
+ /**
897
+ * The reads of the store. Every query carries the `site_id` of the site it was asked for, so a
898
+ * natural key, an id or a translation group of another site finds nothing.
899
+ */
900
+ /**
901
+ * Whether an entry is published, as the partial index of published listings says it
902
+ * (`entries_published` covers `status = 'published'`). It is a literal and not a bound value:
903
+ * the index serves a query that says what the index says, and SQLite can tell that of a `?` only
904
+ * by looking at the value bound to it when it plans, which is for the engine to do or not.
905
+ */
906
+ const publishedOnly = sql`${entries.status} = 'published'`;
907
+ /** How an entry reaches the revision of a view: the head revision, or the published one. */
908
+ function revisionOf$1(view) {
909
+ const revisionId = view === "head" ? entries.headRev : entries.publishedRev;
910
+ return and(eq(revisions.id, revisionId), eq(revisions.siteId, entries.siteId));
911
+ }
912
+ /** What picks the entries of a view: the caller's conditions, and in the published view only published ones. */
913
+ function viewWhere(view, conditions) {
914
+ return and(...conditions, view === "published" ? publishedOnly : void 0);
915
+ }
916
+ /**
917
+ * The entries of a site with the whole revision of a view, for the reads of one entry. The caller
918
+ * adds the conditions that pick the entries and what limits them.
919
+ */
920
+ function viewQuery(db, view, conditions) {
921
+ return db.select({
922
+ entry: entryFields,
923
+ revision: revisionFields,
924
+ tags: tagsOf$1(revisions)
925
+ }).from(entries).innerJoin(revisions, revisionOf$1(view)).where(viewWhere(view, conditions));
926
+ }
927
+ function toEntryView(row) {
928
+ return {
929
+ entry: toEntry(row.entry),
930
+ revision: toRevision(row.revision, row.tags)
931
+ };
932
+ }
933
+ /**
934
+ * The entries of a site with a summary of the revision of a view, for listings: the entry, and of
935
+ * the revision only its id, its frontmatter and its tags. The sort key of the published view
936
+ * comes along for the cursor.
937
+ */
938
+ function summaryQuery(db, view, conditions) {
939
+ return db.select({
940
+ entry: entryFields,
941
+ revision: revisionSummaryFields,
942
+ tags: tagsOf$1(revisions),
943
+ sortKey: entries.publishedSortKey
944
+ }).from(entries).innerJoin(revisions, revisionOf$1(view)).where(viewWhere(view, conditions));
945
+ }
946
+ function toEntrySummaryView(row) {
947
+ return {
948
+ entry: toEntry(row.entry),
949
+ revision: toRevisionSummary(row.revision, row.tags)
950
+ };
951
+ }
952
+ async function findEntry(db, site, ref, view) {
953
+ const [row] = await viewQuery(db, view, [
954
+ eq(entries.siteId, site),
955
+ eq(entries.collection, ref.collection),
956
+ eq(entries.locale, ref.locale),
957
+ eq(entries.slug, ref.slug)
958
+ ]).limit(1);
959
+ return row === void 0 ? null : toEntryView(row);
960
+ }
961
+ async function findEntryById(db, site, id, view) {
962
+ const [row] = await viewQuery(db, view, [eq(entries.siteId, site), eq(entries.id, id)]).limit(1);
963
+ return row === void 0 ? null : toEntryView(row);
964
+ }
965
+ /** A revision of the site by its id, with its tags: what a publish answers with. */
966
+ async function findRevision(db, site, revisionId) {
967
+ const [row] = await db.select({
968
+ revision: revisionFields,
969
+ tags: tagsOf$1(revisions)
970
+ }).from(revisions).where(and(eq(revisions.id, revisionId), eq(revisions.siteId, site))).limit(1);
971
+ return row === void 0 ? null : toRevision(row.revision, row.tags);
972
+ }
973
+ /** Whether the revision a view joined has the tag. */
974
+ function hasTag(site, tag) {
975
+ return sql`exists (select 1 from ${revisionTags} where ${and(eq(revisionTags.revisionId, revisions.id), eq(revisionTags.siteId, site), eq(revisionTags.tag, tag))})`;
976
+ }
977
+ /**
978
+ * The statement of a page of a listing: the entries newest first by the sort key of the view and
979
+ * then by id, after the position of a cursor if there is one, and one more than the page holds,
980
+ * which only tells whether there is a next page.
981
+ *
982
+ * It is shaped for the indexes of `entries`, so that a page costs its own rows and not a sort of
983
+ * the collection (`tests/db/list-plans.workerd.test.ts` reads the plans). The head view reads
984
+ * `entries_head`, or `entries_head_locale` when it names a locale. The published view of a locale
985
+ * reads the partial `entries_published`, whose status is the literal the index says. The cursor
986
+ * is a row value, `(sort key, id) < (?, ?)`, which the index seeks to; the same condition written
987
+ * with `OR` is read from the first row of the collection. The published view with no locale
988
+ * named is the exception: the index has the locale before the sort key, so SQLite sorts the
989
+ * published entries of the collection.
990
+ */
991
+ function listEntriesQuery(db, site, query, cursor) {
992
+ const sortKey = query.view === "head" ? entries.updatedAt : entries.publishedSortKey;
993
+ return summaryQuery(db, query.view, [
994
+ eq(entries.siteId, site),
995
+ eq(entries.collection, query.collection),
996
+ query.locale === void 0 ? void 0 : eq(entries.locale, query.locale),
997
+ query.view === "head" && query.status !== void 0 ? eq(entries.status, query.status) : void 0,
998
+ query.tag === void 0 ? void 0 : hasTag(site, query.tag),
999
+ cursor === null ? void 0 : sql`(${sortKey}, ${entries.id}) < (${cursor.key}, ${cursor.id})`
1000
+ ]).orderBy(desc(sortKey), desc(entries.id)).limit(query.limit + 1);
1001
+ }
1002
+ async function listEntries$2(db, site, query) {
1003
+ 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.");
1004
+ const cursor = query.cursor === void 0 ? null : decodeCursor(query.cursor, query.view);
1005
+ if (query.view === "published" && query.status !== void 0 && query.status !== "published") return {
1006
+ entries: [],
1007
+ nextCursor: null
1008
+ };
1009
+ const rows = await listEntriesQuery(db, site, query, cursor);
1010
+ const page = rows.slice(0, query.limit);
1011
+ const last = page.at(-1);
1012
+ if (last === void 0 || rows.length === page.length) return {
1013
+ entries: page.map(toEntrySummaryView),
1014
+ nextCursor: null
1015
+ };
1016
+ const key = query.view === "head" ? last.entry.updatedAt : last.sortKey;
1017
+ if (key === null) throw new Error(`The published entry ${last.entry.id} has no published_sort_key`);
1018
+ return {
1019
+ entries: page.map(toEntrySummaryView),
1020
+ nextCursor: encodeCursor(query.view, {
1021
+ key,
1022
+ id: last.entry.id
1023
+ })
1024
+ };
1025
+ }
1026
+ /**
1027
+ * The statement of the entries of a translation group: the unique key of the group ends in the
1028
+ * locale, so the index gives them in order of locale with no sort.
1029
+ */
1030
+ function listTranslationsQuery(db, site, collection, translationGroup, view) {
1031
+ return summaryQuery(db, view, [
1032
+ eq(entries.siteId, site),
1033
+ eq(entries.collection, collection),
1034
+ eq(entries.translationGroup, translationGroup)
1035
+ ]).orderBy(entries.locale);
1036
+ }
1037
+ async function listTranslations(db, site, collection, translationGroup, view) {
1038
+ return (await listTranslationsQuery(db, site, collection, translationGroup, view)).map(toEntrySummaryView);
1039
+ }
1040
+ /**
1041
+ * The latest publish or unpublish of an entry. Rows are never deleted, so the insertion order
1042
+ * (`rowid`) breaks a tie between two written in the same millisecond.
1043
+ */
1044
+ async function lastPublication(db, site, entryId) {
1045
+ 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);
1046
+ return row === void 0 ? null : toPublication(row);
1047
+ }
1048
+ const factFields = {
1049
+ id: entries.id,
1050
+ collection: entries.collection,
1051
+ locale: entries.locale,
1052
+ slug: entries.slug,
1053
+ status: entries.status,
1054
+ translationGroup: entries.translationGroup
1055
+ };
1056
+ function refKey(ref) {
1057
+ return JSON.stringify([
1058
+ ref.collection,
1059
+ ref.locale,
1060
+ ref.slug
1061
+ ]);
1062
+ }
1063
+ function refMatches(ref) {
1064
+ return and(eq(entries.collection, ref.collection), eq(entries.locale, ref.locale), eq(entries.slug, ref.slug));
1065
+ }
1066
+ /** What cross-entry contracts read, scoped to one site. */
1067
+ function lookup(db, site) {
1068
+ return {
1069
+ /**
1070
+ * One query per chunk of refs, in one batch: each ref binds three values and the site one
1071
+ * more, under D1's limit of bound values. A ref asked twice is asked once.
1072
+ */
1073
+ async lookupMany(refs) {
1074
+ 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)))));
1075
+ if (first === void 0) return [];
1076
+ const found = /* @__PURE__ */ new Map();
1077
+ for (const row of (await db.batch([first, ...rest])).flat()) found.set(refKey(row), {
1078
+ id: row.id,
1079
+ status: entryStatus(row.status),
1080
+ translationGroup: row.translationGroup
1081
+ });
1082
+ return refs.map((ref) => found.get(refKey(ref)) ?? null);
1083
+ },
1084
+ async translations(collection, translationGroup) {
1085
+ 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) => ({
1086
+ collection: row.collection,
1087
+ locale: row.locale,
1088
+ slug: row.slug,
1089
+ id: row.id,
1090
+ status: entryStatus(row.status),
1091
+ translationGroup: row.translationGroup
1092
+ }));
1093
+ }
1094
+ };
1095
+ }
1096
+ //#endregion
1097
+ //#region src/db/statements.ts
1098
+ function actorColumns(actor) {
1099
+ return {
1100
+ actorKind: actor.kind,
1101
+ actorUserId: actor.userId,
1102
+ actorTokenId: actor.tokenId,
1103
+ actorMeta: actor.meta === null ? null : JSON.stringify(actor.meta)
1104
+ };
1105
+ }
1106
+ /** The tags of a revision as written: each once, sorted, so that a revision always reads the same. */
1107
+ function distinctTags(tags) {
1108
+ return [...new Set(tags)].toSorted();
1109
+ }
1110
+ function revisionRow(input) {
1111
+ const { revision } = input;
1112
+ return {
1113
+ id: input.id,
1114
+ siteId: input.site,
1115
+ entryId: input.entryId,
1116
+ markdown: revision.markdown,
1117
+ frontmatter: JSON.stringify(revision.frontmatter),
1118
+ document: JSON.stringify(revision.document),
1119
+ comarkVersion: revision.comarkVersion,
1120
+ pipelineVersion: revision.pipelineVersion,
1121
+ wordCount: revision.wordCount,
1122
+ verdict: JSON.stringify(revision.verdict),
1123
+ ...actorColumns(revision.actor),
1124
+ createdAt: input.now,
1125
+ updatedAt: input.now
1126
+ };
1127
+ }
1128
+ function publicationRow(input) {
1129
+ return {
1130
+ id: newId("pub"),
1131
+ siteId: input.site,
1132
+ entryId: input.entryId,
1133
+ action: input.publish === null ? "unpublish" : "publish",
1134
+ revisionId: input.publish?.revisionId ?? null,
1135
+ verdict: input.publish === null ? null : JSON.stringify(input.publish.verdict),
1136
+ acknowledged: JSON.stringify(input.publish?.acknowledged ?? []),
1137
+ ...actorColumns(input.actor),
1138
+ createdAt: input.now,
1139
+ updatedAt: input.now
1140
+ };
1141
+ }
1142
+ /**
1143
+ * The values of a row in the order of the table's columns, for `insert into t select …`: the
1144
+ * insert lists every column of the table, so the select has to give them all, and a column that
1145
+ * the row lacks fails here instead of shifting the ones after it. A value may be a column or any
1146
+ * SQL, for what only the database knows.
1147
+ */
1148
+ function valuesOf(table, row) {
1149
+ const values = Object.keys(getTableColumns(table)).map((field) => {
1150
+ const value = row[field];
1151
+ if (value === void 0) throw new Error(`The row has no value for the column ${field}`);
1152
+ return sql`${value}`;
1153
+ });
1154
+ return sql.join(values, sql`, `);
1155
+ }
1156
+ /** Inserts the revision of an entry only if the entry is still at `version`. */
1157
+ function insertRevisionAtVersion(db, row, version) {
1158
+ 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))}`);
1159
+ }
1160
+ /**
1161
+ * Inserts the tags of a revision, in as many statements as D1's limit of bound values needs, and
1162
+ * only where the revision is there: a tag whose revision was never inserted would fail the
1163
+ * foreign key and turn a conflict into a database error. Each tag binds its id and its text;
1164
+ * the site, the revision and the time are bound once, and the existence check binds two more.
1165
+ * The tags are a `VALUES` list: D1 allows a compound `SELECT` only five terms long, so a
1166
+ * `UNION ALL` of tags would fail from the sixth.
1167
+ */
1168
+ function insertTags(db, input) {
1169
+ const perStatement = rowsPerStatement(6, 2);
1170
+ return chunk(input.tags, perStatement).map((tags) => {
1171
+ const rows = tags.map((tag) => sql`(${newId("tag")}, ${tag})`);
1172
+ 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))})`);
1173
+ });
1174
+ }
1175
+ /**
1176
+ * Appends a publication that publishes the head revision of an entry that is still at `version`.
1177
+ * The revision is whatever `head_rev` is when the statement runs.
1178
+ */
1179
+ function insertPublishAtVersion(db, row, version) {
1180
+ return db.insert(publications).select(sql`select ${valuesOf(publications, {
1181
+ ...row,
1182
+ revisionId: entries.headRev
1183
+ })} from ${entries} where ${and(eq(entries.id, row.entryId), eq(entries.siteId, row.siteId), eq(entries.version, version))}`);
1184
+ }
1185
+ /** Appends an unpublish, only if the entry is published when the statement runs. */
1186
+ function insertUnpublishIfPublished(db, row) {
1187
+ 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"))}`);
1188
+ }
1189
+ //#endregion
1190
+ //#region src/db/schedules.ts
1191
+ /**
1192
+ * Scheduled publications (ADR-025). An entry waits in status `scheduled` with its `scheduled_at`,
1193
+ * and its row of `schedules` says what was acknowledged and by whom, for the cron trigger that
1194
+ * publishes it once the time has passed.
1195
+ */
1196
+ /** Statements that drop the schedule of an entry, in the batch of a write that ends it. */
1197
+ function dropSchedule(db, site, entryId) {
1198
+ return db.delete(schedules).where(and(eq(schedules.siteId, site), eq(schedules.entryId, entryId)));
1199
+ }
1200
+ async function schedule(db, clock, site, write) {
1201
+ const now = clock.now().toISOString();
1202
+ const row = {
1203
+ id: newId("sch"),
1204
+ siteId: site,
1205
+ entryId: write.entryId,
1206
+ scheduledAt: write.scheduledAt,
1207
+ version: write.expectedVersion + 1,
1208
+ acknowledged: JSON.stringify(write.acknowledged),
1209
+ ...actorColumns(write.actor),
1210
+ createdAt: now,
1211
+ updatedAt: now
1212
+ };
1213
+ const atVersion = and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion), sql`${entries.status} <> 'published'`);
1214
+ const results = await db.batch([
1215
+ db.delete(schedules).where(and(eq(schedules.siteId, site), eq(schedules.entryId, write.entryId), sql`exists (select 1 from ${entries} where ${atVersion})`)),
1216
+ db.insert(schedules).select(sql`select ${valuesOf(schedules, row)} from ${entries} where ${atVersion}`),
1217
+ db.update(entries).set({
1218
+ status: "scheduled",
1219
+ scheduledAt: write.scheduledAt,
1220
+ version: sql`${entries.version} + 1`,
1221
+ updatedAt: now
1222
+ }).where(atVersion).returning({ id: entries.id })
1223
+ ]);
1224
+ const view = await findEntryById(db, site, write.entryId, "head");
1225
+ if (view === null) return { outcome: "not_found" };
1226
+ if (results[2].length === 0) return {
1227
+ outcome: "conflict",
1228
+ currentVersion: view.entry.version,
1229
+ headRevision: view.entry.headRevision
1230
+ };
1231
+ return {
1232
+ outcome: "scheduled",
1233
+ view
1234
+ };
1235
+ }
1236
+ /** The schedules of a site whose time has come, oldest first. */
1237
+ async function dueSchedules(db, site, now) {
1238
+ return (await db.select().from(schedules).where(and(eq(schedules.siteId, site), lte(schedules.scheduledAt, now))).orderBy(schedules.scheduledAt).limit(50)).map((row) => ({
1239
+ entryId: row.entryId,
1240
+ scheduledAt: row.scheduledAt,
1241
+ version: row.version,
1242
+ acknowledged: parseAcknowledged(row.acknowledged),
1243
+ actor: {
1244
+ kind: "token",
1245
+ userId: row.actorUserId,
1246
+ tokenId: row.actorTokenId,
1247
+ meta: row.actorMeta === null ? null : parseMeta(row.actorMeta)
1248
+ }
1249
+ }));
1250
+ }
1251
+ /** Takes an entry out of `scheduled`, back to a draft, and drops its schedule. */
1252
+ async function cancelSchedule(db, clock, site, entryId) {
1253
+ await db.batch([dropSchedule(db, site, entryId), db.update(entries).set({
1254
+ status: "draft",
1255
+ scheduledAt: null,
1256
+ version: sql`${entries.version} + 1`,
1257
+ updatedAt: clock.now().toISOString()
1258
+ }).where(and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.status, "scheduled")))]);
1259
+ }
1260
+ function parseAcknowledged(text) {
1261
+ const value = JSON.parse(text);
1262
+ if (!Array.isArray(value)) return [];
1263
+ return value.flatMap((item) => typeof item === "object" && item !== null && "contract" in item && "path" in item && typeof item.contract === "string" && typeof item.path === "string" ? [{
1264
+ contract: item.contract,
1265
+ path: item.path
1266
+ }] : []);
1267
+ }
1268
+ function parseMeta(text) {
1269
+ const value = JSON.parse(text);
1270
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : null;
1271
+ }
1272
+ //#endregion
1273
+ //#region src/db/errors.ts
1274
+ /**
1275
+ * What D1 says when a statement breaks a unique key: `D1_ERROR: UNIQUE constraint failed:
1276
+ * entries.site_id, …: SQLITE_CONSTRAINT`, with the same text again in the `cause`. Inside a batch
1277
+ * the error arrives as it is, and the batch has rolled back.
1278
+ */
1279
+ function isUniqueViolation(error) {
1280
+ let current = error;
1281
+ for (let depth = 0; depth < 4 && current instanceof Error; depth += 1) {
1282
+ if (current.message.includes("UNIQUE constraint failed")) return true;
1283
+ current = current.cause;
1284
+ }
1285
+ return false;
1286
+ }
1287
+ //#endregion
1288
+ //#region src/db/size.ts
1289
+ /**
1290
+ * D1 refuses rows over 2 MB, and Miniflare does not enforce it, so the store counts the bytes of
1291
+ * a row before writing it and refuses a row over `MAX_ROW_BYTES` (`limits.ts`).
1292
+ */
1293
+ const encoder$1 = new TextEncoder();
1294
+ /** The bytes of `text` in UTF-8, which is how D1 stores it. */
1295
+ function utf8Bytes(text) {
1296
+ return encoder$1.encode(text).byteLength;
1297
+ }
1298
+ /** What SQLite keeps for an integer, at most. */
1299
+ const INTEGER_BYTES = 8;
1300
+ /** The bytes of every column of a row: text in UTF-8, an integer as 8 bytes, a null as none. */
1301
+ function rowBytes(row) {
1302
+ let bytes = 0;
1303
+ for (const value of Object.values(row)) if (typeof value === "string") bytes += utf8Bytes(value);
1304
+ else if (value !== null) bytes += INTEGER_BYTES;
1305
+ return bytes;
1306
+ }
1307
+ /** The refusal for a row over the limit, or `null` when it fits. */
1308
+ function tooLarge(row) {
1309
+ const bytes = rowBytes(row);
1310
+ return bytes > 19e5 ? {
1311
+ outcome: "too_large",
1312
+ bytes,
1313
+ limit: MAX_ROW_BYTES
1314
+ } : null;
1315
+ }
1316
+ //#endregion
1317
+ //#region src/db/writes.ts
1318
+ /**
1319
+ * Waits for a write batch. A batch that broke a unique key of `entries` has rolled back, and the
1320
+ * answer is `taken` by the entry that holds the key, found by `holder`. A unique violation nobody
1321
+ * holds the key for, and any other error, is not an outcome: it is thrown as it came.
1322
+ */
1323
+ async function attempt(batch, holder) {
1324
+ try {
1325
+ return { written: await batch };
1326
+ } catch (error) {
1327
+ const taken = isUniqueViolation(error) ? await holder() : null;
1328
+ if (taken === null) throw error;
1329
+ return { taken };
1330
+ }
1331
+ }
1332
+ /** The entry that holds the slug or the translation that a new entry asks for; the slug first. */
1333
+ async function holderOfNewEntry(db, site, entry) {
1334
+ const rows = await db.select({
1335
+ id: entries.id,
1336
+ slug: entries.slug
1337
+ }).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))));
1338
+ const bySlug = rows.find((row) => row.slug === entry.slug);
1339
+ if (bySlug !== void 0) return {
1340
+ outcome: "taken",
1341
+ field: "slug",
1342
+ by: bySlug.id
1343
+ };
1344
+ const [byGroup] = rows;
1345
+ return byGroup === void 0 ? null : {
1346
+ outcome: "taken",
1347
+ field: "translationGroup",
1348
+ by: byGroup.id
1349
+ };
1350
+ }
1351
+ /** The other entry of the collection and locale of `entryId` that is in `translationGroup`. */
1352
+ async function holderOfTranslation(db, site, entryId, translationGroup) {
1353
+ if (translationGroup === null) return null;
1354
+ const other = alias(entries, "other");
1355
+ 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);
1356
+ return row === void 0 ? null : {
1357
+ outcome: "taken",
1358
+ field: "translationGroup",
1359
+ by: row.id
1360
+ };
1361
+ }
1362
+ /** Why an update touched nothing: the entry moved on (`conflict`) or it is not there. */
1363
+ async function whyNothingChanged(db, site, entryId) {
1364
+ const [row] = await db.select({
1365
+ version: entries.version,
1366
+ headRev: entries.headRev
1367
+ }).from(entries).where(and(eq(entries.siteId, site), eq(entries.id, entryId))).limit(1);
1368
+ return row === void 0 ? { outcome: "not_found" } : {
1369
+ outcome: "conflict",
1370
+ currentVersion: row.version,
1371
+ headRevision: row.headRev
1372
+ };
1373
+ }
1374
+ /** The revision as a read of it would give it back, for the answer of a write that made it. */
1375
+ function storedRevision(input) {
1376
+ const { revision } = input;
1377
+ return {
1378
+ id: input.id,
1379
+ entryId: input.entryId,
1380
+ markdown: revision.markdown,
1381
+ frontmatter: revision.frontmatter,
1382
+ document: revision.document,
1383
+ comarkVersion: revision.comarkVersion,
1384
+ pipelineVersion: revision.pipelineVersion,
1385
+ wordCount: revision.wordCount,
1386
+ verdict: revision.verdict,
1387
+ tags: input.tags,
1388
+ actor: revision.actor,
1389
+ createdAt: input.now
1390
+ };
1391
+ }
1392
+ async function createEntry(db, clock, site, entry) {
1393
+ const now = clock.now().toISOString();
1394
+ const entryId = newId("ent");
1395
+ const revisionId = newId("rev");
1396
+ const row = revisionRow({
1397
+ id: revisionId,
1398
+ site,
1399
+ entryId,
1400
+ now,
1401
+ revision: entry.revision
1402
+ });
1403
+ const refusal = tooLarge(row);
1404
+ if (refusal !== null) return refusal;
1405
+ const tags = distinctTags(entry.revision.tags);
1406
+ const result = await attempt(db.batch([
1407
+ db.insert(entries).values({
1408
+ id: entryId,
1409
+ siteId: site,
1410
+ collection: entry.collection,
1411
+ locale: entry.locale,
1412
+ slug: entry.slug,
1413
+ translationGroup: entry.translationGroup,
1414
+ status: "draft",
1415
+ version: 1,
1416
+ headRev: revisionId,
1417
+ publishedRev: null,
1418
+ publishedAt: null,
1419
+ publishedSortKey: null,
1420
+ scheduledAt: null,
1421
+ createdAt: now,
1422
+ updatedAt: now
1423
+ }),
1424
+ db.insert(revisions).values(row),
1425
+ ...insertTags(db, {
1426
+ site,
1427
+ revisionId,
1428
+ tags,
1429
+ now
1430
+ })
1431
+ ]), () => holderOfNewEntry(db, site, entry));
1432
+ if ("taken" in result) return result.taken;
1433
+ return {
1434
+ outcome: "saved",
1435
+ view: {
1436
+ entry: {
1437
+ id: entryId,
1438
+ collection: entry.collection,
1439
+ locale: entry.locale,
1440
+ slug: entry.slug,
1441
+ translationGroup: entry.translationGroup,
1442
+ status: "draft",
1443
+ version: 1,
1444
+ headRevision: revisionId,
1445
+ publishedRevision: null,
1446
+ publishedAt: null,
1447
+ updatedAt: now
1448
+ },
1449
+ revision: storedRevision({
1450
+ id: revisionId,
1451
+ entryId,
1452
+ now,
1453
+ revision: entry.revision,
1454
+ tags
1455
+ })
1456
+ }
1457
+ };
1458
+ }
1459
+ async function appendRevision(db, clock, site, write) {
1460
+ const now = clock.now().toISOString();
1461
+ const revisionId = newId("rev");
1462
+ const row = revisionRow({
1463
+ id: revisionId,
1464
+ site,
1465
+ entryId: write.entryId,
1466
+ now,
1467
+ revision: write.revision
1468
+ });
1469
+ const refusal = tooLarge(row);
1470
+ if (refusal !== null) return refusal;
1471
+ const tags = distinctTags(write.revision.tags);
1472
+ const result = await attempt(db.batch([
1473
+ insertRevisionAtVersion(db, row, write.expectedVersion),
1474
+ ...insertTags(db, {
1475
+ site,
1476
+ revisionId,
1477
+ tags,
1478
+ now
1479
+ }),
1480
+ db.update(entries).set({
1481
+ version: sql`${entries.version} + 1`,
1482
+ headRev: revisionId,
1483
+ translationGroup: write.translationGroup,
1484
+ updatedAt: now
1485
+ }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))).returning(entryFields)
1486
+ ]), () => holderOfTranslation(db, site, write.entryId, write.translationGroup));
1487
+ if ("taken" in result) return result.taken;
1488
+ const updated = result.written.at(-1);
1489
+ const [changed] = Array.isArray(updated) ? updated : [];
1490
+ if (changed === void 0) return whyNothingChanged(db, site, write.entryId);
1491
+ return {
1492
+ outcome: "saved",
1493
+ view: {
1494
+ entry: toEntry(changed),
1495
+ revision: storedRevision({
1496
+ id: revisionId,
1497
+ entryId: write.entryId,
1498
+ now,
1499
+ revision: write.revision,
1500
+ tags
1501
+ })
1502
+ }
1503
+ };
1504
+ }
1505
+ async function publish$2(db, clock, site, write) {
1506
+ const now = clock.now().toISOString();
1507
+ const row = publicationRow({
1508
+ site,
1509
+ entryId: write.entryId,
1510
+ now,
1511
+ actor: write.actor,
1512
+ publish: {
1513
+ revisionId: newId("rev"),
1514
+ verdict: write.verdict,
1515
+ acknowledged: write.acknowledged
1516
+ }
1517
+ });
1518
+ const refusal = tooLarge(row);
1519
+ if (refusal !== null) return refusal;
1520
+ const [changed] = (await db.batch([
1521
+ insertPublishAtVersion(db, row, write.expectedVersion),
1522
+ dropScheduleAtVersion(db, site, write.entryId, write.expectedVersion),
1523
+ insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.published", now, [eq(entries.version, write.expectedVersion)]),
1524
+ db.update(entries).set({
1525
+ status: "published",
1526
+ scheduledAt: null,
1527
+ publishedRev: entries.headRev,
1528
+ publishedAt: sql`coalesce(${entries.publishedAt}, ${now})`,
1529
+ publishedSortKey: write.sortKey,
1530
+ version: sql`${entries.version} + 1`,
1531
+ updatedAt: now
1532
+ }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))).returning(entryFields)
1533
+ ]))[3];
1534
+ if (changed === void 0) return whyNothingChanged(db, site, write.entryId);
1535
+ const entry = toEntry(changed);
1536
+ const revision = entry.publishedRevision === null ? null : await findRevision(db, site, entry.publishedRevision);
1537
+ if (revision === null) throw new Error(`The revision that entry ${entry.id} was published at is not there`);
1538
+ return {
1539
+ outcome: "published",
1540
+ view: {
1541
+ entry,
1542
+ revision
1543
+ }
1544
+ };
1545
+ }
1546
+ /** The message of the statement that aborts a group publish: SQLite fails to parse it as JSON. */
1547
+ const GROUP_MOVED = "hollware: an entry of the group is not at its version";
1548
+ /**
1549
+ * Publishes a group of entries in one batch, all or none (ADR-024). Each entry gets the two
1550
+ * statements of `publish`; a last statement fails the batch, which D1 then rolls back, when fewer
1551
+ * publications landed than the group has entries: `json()` of a text that is not JSON raises an
1552
+ * error, and it runs only then. A refusal names the entry.
1553
+ */
1554
+ async function publishGroup$1(db, clock, site, group) {
1555
+ const now = clock.now().toISOString();
1556
+ const statements = [];
1557
+ const appended = [];
1558
+ for (const write of group) {
1559
+ const row = publicationRow({
1560
+ site,
1561
+ entryId: write.entryId,
1562
+ now,
1563
+ actor: write.actor,
1564
+ publish: {
1565
+ revisionId: newId("rev"),
1566
+ verdict: write.verdict,
1567
+ acknowledged: write.acknowledged
1568
+ }
1569
+ });
1570
+ const refusal = tooLarge(row);
1571
+ if (refusal !== null) return {
1572
+ ...refusal,
1573
+ entryId: write.entryId
1574
+ };
1575
+ appended.push(row.id);
1576
+ statements.push(insertPublishAtVersion(db, row, write.expectedVersion), dropScheduleAtVersion(db, site, write.entryId, write.expectedVersion), insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.published", now, [eq(entries.version, write.expectedVersion)]), db.update(entries).set({
1577
+ status: "published",
1578
+ scheduledAt: null,
1579
+ publishedRev: entries.headRev,
1580
+ publishedAt: sql`coalesce(${entries.publishedAt}, ${now})`,
1581
+ publishedSortKey: write.sortKey,
1582
+ version: sql`${entries.version} + 1`,
1583
+ updatedAt: now
1584
+ }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))));
1585
+ }
1586
+ const guard = db.select({ moved: sql`json(${GROUP_MOVED})` }).from(sites).where(and(eq(sites.id, site), sql`(select count(*) from ${publications} where ${and(eq(publications.siteId, site), inArray(publications.id, appended))}) < ${group.length}`));
1587
+ try {
1588
+ const [first, ...rest] = [...statements, guard];
1589
+ if (first !== void 0) await db.batch([first, ...rest]);
1590
+ } catch (error) {
1591
+ if (!(error instanceof Error) || !/malformed JSON/i.test(`${error.message} ${String(error.cause)}`)) throw error;
1592
+ for (const write of group) {
1593
+ const why = await whyNothingChanged(db, site, write.entryId);
1594
+ if (why.outcome === "not_found" || why.currentVersion !== write.expectedVersion) return {
1595
+ ...why,
1596
+ entryId: write.entryId
1597
+ };
1598
+ }
1599
+ throw new Error("A group publish failed, and every entry of the group is at its version", { cause: error });
1600
+ }
1601
+ const views = [];
1602
+ for (const write of group) {
1603
+ const view = await findEntryById(db, site, write.entryId, "published");
1604
+ if (view === null) throw new Error(`Entry ${write.entryId} is not published after its group`);
1605
+ views.push(view);
1606
+ }
1607
+ return {
1608
+ outcome: "published",
1609
+ views
1610
+ };
1611
+ }
1612
+ async function unpublish$1(db, clock, site, write) {
1613
+ const now = clock.now().toISOString();
1614
+ const row = publicationRow({
1615
+ site,
1616
+ entryId: write.entryId,
1617
+ now,
1618
+ actor: write.actor,
1619
+ publish: null
1620
+ });
1621
+ const refusal = tooLarge(row);
1622
+ if (refusal !== null) return refusal;
1623
+ await db.batch([
1624
+ insertUnpublishIfPublished(db, row),
1625
+ insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.unpublished", now, [eq(entries.status, "published")]),
1626
+ dropSchedule(db, site, write.entryId),
1627
+ db.update(entries).set({
1628
+ status: "draft",
1629
+ scheduledAt: null,
1630
+ publishedRev: null,
1631
+ publishedSortKey: null,
1632
+ version: sql`${entries.version} + 1`,
1633
+ updatedAt: now
1634
+ }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), inArray(entries.status, ["published", "scheduled"])))
1635
+ ]);
1636
+ const view = await findEntryById(db, site, write.entryId, "head");
1637
+ return view === null ? { outcome: "not_found" } : {
1638
+ outcome: "unpublished",
1639
+ view
1640
+ };
1641
+ }
1642
+ /** Drops the schedule of an entry in the batch that publishes it, if it is at `version`. */
1643
+ function dropScheduleAtVersion(db, site, entryId, version) {
1644
+ return db.delete(schedules).where(and(eq(schedules.siteId, site), eq(schedules.entryId, entryId), sql`exists (select 1 from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version))})`));
1645
+ }
1646
+ /**
1647
+ * Writes the event of a write to the outbox in its batch, only where the entry is in the state the
1648
+ * write expects, so that an event is there exactly when its write is. Nothing when there is no
1649
+ * event: a statement that selects nothing, to keep the batch the same shape.
1650
+ */
1651
+ function insertEventWhere(db, site, entryId, event, kind, now, conditions) {
1652
+ const row = {
1653
+ id: event?.id ?? "evt_none",
1654
+ siteId: site,
1655
+ kind,
1656
+ entryId,
1657
+ payload: event?.payload ?? "{}",
1658
+ sentAt: null,
1659
+ createdAt: now,
1660
+ updatedAt: now
1661
+ };
1662
+ return db.insert(outbox).select(sql`select ${valuesOf(outbox, row)} from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), sql`${event === null ? 0 : 1} = 1`, ...conditions)}`);
1663
+ }
1664
+ //#endregion
1665
+ //#region src/db/store.ts
1666
+ const systemClock$1 = { now: () => /* @__PURE__ */ new Date() };
1667
+ /**
1668
+ * The content store on D1 (`ports.ts`). `db` is a D1 database, which the writes need, or a D1
1669
+ * session, which is enough for reads: `env.DB.withSession('first-unconstrained')` is what RPC
1670
+ * reads use. Every method takes the site first and only reads and writes that site's rows.
1671
+ */
1672
+ function createD1Store(db, options = {}) {
1673
+ const database = connect(db);
1674
+ const clock = options.clock ?? systemClock$1;
1675
+ return {
1676
+ findEntry: (site, ref, view) => findEntry(database, site, ref, view),
1677
+ findEntryById: (site, id, view) => findEntryById(database, site, id, view),
1678
+ listEntries: (site, query) => listEntries$2(database, site, query),
1679
+ listTranslations: (site, collection, translationGroup, view) => listTranslations(database, site, collection, translationGroup, view),
1680
+ lookup: (site) => lookup(database, site),
1681
+ lastPublication: (site, entryId) => lastPublication(database, site, entryId),
1682
+ createEntry: (site, entry) => createEntry(database, clock, site, entry),
1683
+ appendRevision: (site, write) => appendRevision(database, clock, site, write),
1684
+ publish: (site, write) => publish$2(database, clock, site, write),
1685
+ publishGroup: (site, group) => publishGroup$1(database, clock, site, group),
1686
+ schedule: (site, write) => schedule(database, clock, site, write),
1687
+ dueSchedules: (site, now) => dueSchedules(database, site, now),
1688
+ cancelSchedule: (site, entryId) => cancelSchedule(database, clock, site, entryId),
1689
+ unpublish: (site, write) => unpublish$1(database, clock, site, write)
1690
+ };
1691
+ }
1692
+ //#endregion
1693
+ //#region src/db/events.ts
1694
+ async function findEvent(db, site, id) {
1695
+ const [row] = await db.select({
1696
+ id: outbox.id,
1697
+ kind: outbox.kind,
1698
+ payload: outbox.payload
1699
+ }).from(outbox).where(and(eq(outbox.siteId, site), eq(outbox.id, id))).limit(1);
1700
+ return row ?? null;
1701
+ }
1702
+ /** The events that were never put on the Queue and are older than `before`, oldest first. */
1703
+ async function unsentEvents(db, site, before, limit = 100) {
1704
+ return (await db.select({ id: outbox.id }).from(outbox).where(and(eq(outbox.siteId, site), isNull(outbox.sentAt), lte(outbox.createdAt, before))).orderBy(outbox.createdAt).limit(limit)).map((row) => row.id);
1705
+ }
1706
+ async function markSent(db, site, ids, now) {
1707
+ if (ids.length === 0) return;
1708
+ await db.update(outbox).set({
1709
+ sentAt: now,
1710
+ updatedAt: now
1711
+ }).where(and(eq(outbox.siteId, site), inArray(outbox.id, [...ids])));
1712
+ }
1713
+ /** The subscribers that already have an event, so that a retry never delivers it twice. */
1714
+ async function deliveredTo(db, site, eventId) {
1715
+ const rows = await db.select({
1716
+ subscriber: deliveries.subscriber,
1717
+ deliveredAt: deliveries.deliveredAt
1718
+ }).from(deliveries).where(and(eq(deliveries.siteId, site), eq(deliveries.eventId, eventId)));
1719
+ return new Set(rows.filter((row) => row.deliveredAt !== null).map((row) => row.subscriber));
1720
+ }
1721
+ /** Records one attempt to deliver an event to a subscriber, and its result. */
1722
+ async function recordAttempt(db, input) {
1723
+ const deliveredAt = input.error === null ? input.now : null;
1724
+ await db.insert(deliveries).values({
1725
+ id: newId("dlv"),
1726
+ siteId: input.site,
1727
+ eventId: input.eventId,
1728
+ subscriber: input.subscriber,
1729
+ attempts: 1,
1730
+ deliveredAt,
1731
+ lastError: input.error,
1732
+ createdAt: input.now,
1733
+ updatedAt: input.now
1734
+ }).onConflictDoUpdate({
1735
+ target: [deliveries.eventId, deliveries.subscriber],
1736
+ set: {
1737
+ attempts: sqlIncrement(),
1738
+ deliveredAt,
1739
+ lastError: input.error,
1740
+ updatedAt: input.now
1741
+ }
1742
+ });
1743
+ }
1744
+ function sqlIncrement() {
1745
+ return sql`${deliveries.attempts} + 1`;
1746
+ }
1747
+ //#endregion
1748
+ //#region src/events/deliver.ts
1749
+ /** Where IndexNow takes the URLs that changed. */
1750
+ const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
1751
+ async function ensureOk(response, what) {
1752
+ if (response.ok) return;
1753
+ throw new Error(`${what} answered ${response.status}`);
1754
+ }
1755
+ /** The subscribers that the config of the site declares (ADR-025). */
1756
+ function subscribersOf(deps) {
1757
+ const { site } = deps;
1758
+ const subscribers = site.events.webhooks.map((webhook) => ({
1759
+ name: `webhook:${webhook.name}`,
1760
+ async send(payload, body) {
1761
+ const secret = deps.secret(webhook.secret);
1762
+ if (secret === void 0) throw new Error(`the secret ${webhook.secret} is not set`);
1763
+ const timestamp = Math.floor(deps.clock.now().getTime() / 1e3);
1764
+ const request = new Request(webhook.url, {
1765
+ method: "POST",
1766
+ headers: {
1767
+ "content-type": "application/json",
1768
+ [EVENT_ID_HEADER]: payload.id,
1769
+ [TIMESTAMP_HEADER]: String(timestamp),
1770
+ [SIGNATURE_HEADER]: await signWebhook(secret, timestamp, body)
1771
+ },
1772
+ body
1773
+ });
1774
+ await ensureOk(await deps.fetch(request), `the webhook ${webhook.name}`);
1775
+ }
1776
+ }));
1777
+ const indexNow = site.events.indexNow;
1778
+ if (indexNow !== null && site.url !== null) {
1779
+ const host = new URL(site.url).host;
1780
+ subscribers.push({
1781
+ name: "indexnow",
1782
+ async send(payload) {
1783
+ if (payload.url === null) return;
1784
+ const key = deps.secret(indexNow.key);
1785
+ if (key === void 0) throw new Error(`the secret ${indexNow.key} is not set`);
1786
+ const request = new Request(INDEXNOW_ENDPOINT, {
1787
+ method: "POST",
1788
+ headers: { "content-type": "application/json; charset=utf-8" },
1789
+ body: JSON.stringify({
1790
+ host,
1791
+ key,
1792
+ urlList: [payload.url]
1793
+ })
1794
+ });
1795
+ await ensureOk(await deps.fetch(request), "IndexNow");
1796
+ }
1797
+ });
1798
+ }
1799
+ return subscribers;
1800
+ }
1801
+ /**
1802
+ * Delivers one event of the outbox to every subscriber that does not have it yet, and records each
1803
+ * attempt. A subscriber that fails is tried again on the next delivery of the event, and one that
1804
+ * succeeded is never sent it again: the caller retries the message while `failed` is not empty.
1805
+ * An event that is gone, or a site without subscribers, delivers nothing.
1806
+ */
1807
+ async function deliverEvent(deps, eventId) {
1808
+ const event = await findEvent(deps.db, deps.site.key, eventId);
1809
+ if (event === null) return {
1810
+ delivered: [],
1811
+ failed: []
1812
+ };
1813
+ const payload = JSON.parse(event.payload);
1814
+ const done = await deliveredTo(deps.db, deps.site.key, eventId);
1815
+ const delivered = [];
1816
+ const failed = [];
1817
+ for (const subscriber of subscribersOf(deps)) {
1818
+ if (done.has(subscriber.name)) continue;
1819
+ let error = null;
1820
+ try {
1821
+ await subscriber.send(payload, event.payload);
1822
+ } catch (cause) {
1823
+ error = cause instanceof Error ? cause.message : String(cause);
1824
+ }
1825
+ await recordAttempt(deps.db, {
1826
+ site: deps.site.key,
1827
+ eventId,
1828
+ subscriber: subscriber.name,
1829
+ now: deps.clock.now().toISOString(),
1830
+ error
1831
+ });
1832
+ (error === null ? delivered : failed).push(subscriber.name);
1833
+ }
1834
+ return {
1835
+ delivered,
1836
+ failed
1837
+ };
1838
+ }
1839
+ //#endregion
1840
+ //#region src/events/run.ts
1841
+ /**
1842
+ * Puts events on the Queue and marks them sent. Without a Queue binding nothing is sent: the
1843
+ * sweep of the cron trigger delivers them instead, within a minute.
1844
+ */
1845
+ async function enqueue(input) {
1846
+ if (input.queue === void 0 || input.ids.length === 0) return;
1847
+ await input.queue.sendBatch(input.ids.map((id) => ({ body: {
1848
+ site: input.site,
1849
+ id
1850
+ } })));
1851
+ await markSent(input.db, input.site, input.ids, input.clock.now().toISOString());
1852
+ }
1853
+ /**
1854
+ * What the cron trigger does with the outbox: an event that was never put on the Queue (its
1855
+ * request ended before, or the project has no Queue) is put on it, or, without a Queue, delivered
1856
+ * here. An event delivered here is marked sent only when every subscriber has it.
1857
+ */
1858
+ async function sweepOutbox(input) {
1859
+ const { deps, queue } = input;
1860
+ const now = deps.clock.now().toISOString();
1861
+ const ids = await unsentEvents(deps.db, deps.site.key, now);
1862
+ if (queue !== void 0) {
1863
+ await enqueue({
1864
+ queue,
1865
+ db: deps.db,
1866
+ site: deps.site.key,
1867
+ ids,
1868
+ clock: deps.clock
1869
+ });
1870
+ return { swept: ids.length };
1871
+ }
1872
+ for (const id of ids) if ((await deliverEvent(deps, id)).failed.length === 0) await markSent(deps.db, deps.site.key, [id], now);
1873
+ return { swept: ids.length };
1874
+ }
1875
+ function isEventMessage(body) {
1876
+ return typeof body === "object" && body !== null && "site" in body && "id" in body && typeof body.site === "string" && typeof body.id === "string";
1877
+ }
1878
+ /**
1879
+ * The Queue consumer: delivers each event, acknowledges it once every subscriber has it, and asks
1880
+ * for a retry otherwise; after the Queue's retries it lands in the dead-letter queue. A message of
1881
+ * another site, or that is no event, is acknowledged and dropped.
1882
+ */
1883
+ async function consumeEvents(messages, deps) {
1884
+ for (const message of messages) {
1885
+ const { body } = message;
1886
+ if (!isEventMessage(body) || body.site !== deps.site.key) {
1887
+ message.ack();
1888
+ continue;
1889
+ }
1890
+ if ((await deliverEvent(deps, body.id)).failed.length === 0) message.ack();
1891
+ else message.retry();
1892
+ }
1893
+ }
1894
+ //#endregion
1895
+ //#region src/mcp/errors.ts
1896
+ /**
1897
+ * The error a caller gets for anything that is not a `HollwareError`. The message is fixed: what
1898
+ * went wrong goes to the log and never to the caller, whose agent could repeat it to a person or
1899
+ * to another system. The request id is how the operator finds the log line.
1900
+ */
1901
+ function internalError$1(requestId, cause) {
1902
+ 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 });
1903
+ }
1904
+ /** The fix of `timeout`, by effect: a write that ran out of time may still finish. */
1905
+ const TIMEOUT_FIX = {
1906
+ read: "Try again. If it keeps timing out, ask for less.",
1907
+ edit: "The change may still be applied after this answer: read the entry with get_entry before you repeat the call.",
1908
+ write: "The change may still be applied after this answer: read the entry with get_entry before you repeat the call.",
1909
+ destructive: "The change may still be applied after this answer: read the entry with get_entry before you repeat the call."
1910
+ };
1911
+ function timeoutError(spec, timeoutMs) {
1912
+ return new HollwareError("timeout", `${spec.name} did not finish within ${timeoutMs} ms.`, TIMEOUT_FIX[spec.effect]);
1913
+ }
1914
+ /** A value that is not an error, as text: handlers may throw anything. */
1915
+ function printable(value) {
1916
+ if (typeof value === "string") return value;
1917
+ try {
1918
+ return JSON.stringify(value) ?? typeof value;
1919
+ } catch {
1920
+ return typeof value;
1921
+ }
1922
+ }
1923
+ /**
1924
+ * What the operator needs to know about an error, for the log: its stack (or its type and
1925
+ * message), then the same for each cause. It is never sent to the caller.
1926
+ */
1927
+ function describeError(error) {
1928
+ const lines = [];
1929
+ let current = error;
1930
+ for (let depth = 0; depth < 5 && current !== void 0; depth++) if (current instanceof Error) {
1931
+ lines.push(current.stack ?? `${current.name}: ${current.message}`);
1932
+ current = current.cause;
1933
+ } else {
1934
+ lines.push(printable(current));
1935
+ current = void 0;
1936
+ }
1937
+ return lines.join("\ncaused by: ");
1938
+ }
1939
+ //#endregion
1940
+ //#region src/http/errors.ts
1941
+ /** A JSON error response that no cache keeps. */
1942
+ function gatewayError(status, body) {
1943
+ return Response.json(body, {
1944
+ status,
1945
+ headers: { "cache-control": "no-store" }
1946
+ });
1947
+ }
1948
+ /**
1949
+ * Everything that differs between Hono 4 and Hono 5 error handling lives in this file (spike S1).
1950
+ *
1951
+ * Hono 4: `app.onError((error, c) => Response)`, one handler for the app.
1952
+ * Hono 5: `app.onError((c, next) => Response)` registers error middleware, and the error is
1953
+ * `c.error`. A Hono 4 handler registered on Hono 5 is not rejected: it is called with the context
1954
+ * where it expects the error, throws, and the app answers a plain-text 500. So the tests read the
1955
+ * JSON body of both answers.
1956
+ *
1957
+ * - An error that a route throws is a JSON 500 with a fixed message, and the id of the request
1958
+ * in its fix, which is how the operator finds the line of the log that has the cause. The cause
1959
+ * goes to the log and never to the caller, whose agent could repeat it to a person or to another
1960
+ * system. This holds for every error, a failed D1 query included: nothing turns one into a 401.
1961
+ * - A path that no route has is a JSON 404.
1962
+ */
1963
+ function installErrorHandling(app, log) {
1964
+ app.onError((c) => {
1965
+ const requestId = c.get("requestId");
1966
+ log({
1967
+ kind: "request_error",
1968
+ requestId,
1969
+ method: c.req.method,
1970
+ path: c.req.path,
1971
+ detail: describeError(c.error)
1972
+ });
1973
+ return gatewayError(500, {
1974
+ code: "internal",
1975
+ message: "The request failed on the server.",
1976
+ fix: `Try again. If it keeps failing, give the site operator this request id: ${requestId}.`
1977
+ });
1978
+ });
1979
+ app.notFound(() => gatewayError(404, {
1980
+ code: "not_found",
1981
+ message: "This Hollware has no route for that path.",
1982
+ fix: "Send MCP requests to /mcp with \"Authorization: Bearer <token>\". GET /health tells whether the Worker is up."
1983
+ }));
1984
+ }
1985
+ //#endregion
1986
+ //#region src/http/read-api.ts
1987
+ const STATUS = {
1988
+ invalid_input: 400,
1989
+ unknown_collection: 404,
1990
+ unknown_locale: 404
1991
+ };
1992
+ /** A weak ETag of a body: the first 128 bits of its SHA-256, in hex. */
1993
+ async function etagOf(body) {
1994
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)));
1995
+ return `W/"${Array.from(digest.slice(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("")}"`;
1996
+ }
1997
+ /** Whether `If-None-Match` names this ETag, or is `*`. */
1998
+ function matches(header, etag) {
1999
+ if (header === void 0) return false;
2000
+ return header.split(",").map((tag) => tag.trim()).some((tag) => tag === "*" || tag === etag || `W/${tag}` === etag);
2001
+ }
2002
+ async function answer(c, value) {
2003
+ const notFound = typeof value === "object" && value !== null && "outcome" in value && value.outcome === "not_found";
2004
+ const body = JSON.stringify(value);
2005
+ const etag = await etagOf(body);
2006
+ const headers = {
2007
+ etag,
2008
+ "cache-control": "private, no-cache",
2009
+ "content-type": "application/json"
2010
+ };
2011
+ if (!notFound && matches(c.req.header("if-none-match"), etag)) return new Response(null, {
2012
+ status: 304,
2013
+ headers
2014
+ });
2015
+ return new Response(body, {
2016
+ status: notFound ? 404 : 200,
2017
+ headers
2018
+ });
2019
+ }
2020
+ function installReadApi(app, options) {
2021
+ app.use("/v1/*", async (c, next) => {
2022
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.json({
2023
+ code: "invalid_input",
2024
+ message: "The read API only reads.",
2025
+ fix: "Use GET."
2026
+ }, 405, { allow: "GET, HEAD" });
2027
+ if (c.req.path === "/v1/openapi.json") return next();
2028
+ const requestId = c.get("requestId");
2029
+ const token = readBearerToken(c.req.raw);
2030
+ if (token === null) {
2031
+ options.log({
2032
+ kind: "auth_refused",
2033
+ requestId,
2034
+ reason: "no_token"
2035
+ });
2036
+ return unauthorized();
2037
+ }
2038
+ const check = await checkPersonalToken(c.env.DB, token, {
2039
+ site: options.site,
2040
+ now: options.clock.now()
2041
+ });
2042
+ if (!check.ok) {
2043
+ options.log({
2044
+ kind: "auth_refused",
2045
+ requestId,
2046
+ reason: check.reason
2047
+ });
2048
+ return unauthorized(check.reason);
2049
+ }
2050
+ if (!check.principal.scopes.includes("content:read")) return insufficientScope(["content:read"]);
2051
+ if (check.touch !== null) c.executionCtx.waitUntil(check.touch);
2052
+ try {
2053
+ return await next();
2054
+ } catch (error) {
2055
+ if (!(error instanceof HollwareError)) throw error;
2056
+ return c.json({
2057
+ code: error.code,
2058
+ message: error.message,
2059
+ fix: error.fix
2060
+ }, STATUS[error.code] ?? 500);
2061
+ }
2062
+ });
2063
+ app.get("/v1/openapi.json", (c) => c.json(openApiDocument(options.site)));
2064
+ app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(options.site)));
2065
+ app.get("/v1/:collection", async (c) => {
2066
+ const query = c.req.query();
2067
+ const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
2068
+ return answer(c, await options.read(c.env).listEntries(options.site, c.req.param("collection"), {
2069
+ ...query["locale"] === void 0 ? {} : { locale: query["locale"] },
2070
+ ...query["tag"] === void 0 ? {} : { tag: query["tag"] },
2071
+ ...limit === void 0 ? {} : { limit },
2072
+ ...query["cursor"] === void 0 ? {} : { cursor: query["cursor"] }
2073
+ }));
2074
+ });
2075
+ app.get("/v1/:collection/translations/:group", async (c) => answer(c, await options.read(c.env).getTranslations(options.site, c.req.param("collection"), c.req.param("group"))));
2076
+ app.get("/v1/:collection/:locale/:slug", async (c) => {
2077
+ const formats = c.req.query("formats");
2078
+ return answer(c, await options.read(c.env).getEntry(options.site, c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? {} : { formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : []) }));
2079
+ });
2080
+ }
2081
+ /** The OpenAPI 3.1 document of the read API. */
2082
+ function openApiDocument(site) {
2083
+ const ok = (description) => ({
2084
+ description,
2085
+ headers: { ETag: { schema: { type: "string" } } },
2086
+ content: { "application/json": { schema: { type: "object" } } }
2087
+ });
2088
+ const common = {
2089
+ "304": { description: "Not modified: the ETag of If-None-Match is current." },
2090
+ "401": { description: "No token, or one that is unknown, expired or revoked." },
2091
+ "403": { description: "The token lacks content:read." }
2092
+ };
2093
+ const param = (name, location, required) => ({
2094
+ name,
2095
+ in: location,
2096
+ required,
2097
+ schema: { type: "string" }
2098
+ });
2099
+ return {
2100
+ openapi: "3.1.0",
2101
+ info: {
2102
+ title: `Hollware read API (${site})`,
2103
+ version: "1"
2104
+ },
2105
+ components: { securitySchemes: { token: {
2106
+ type: "http",
2107
+ scheme: "bearer"
2108
+ } } },
2109
+ security: [{ token: [] }],
2110
+ paths: {
2111
+ "/v1/site": { get: {
2112
+ operationId: "describe_site",
2113
+ responses: {
2114
+ "200": ok("The site"),
2115
+ ...common
2116
+ }
2117
+ } },
2118
+ "/v1/{collection}": { get: {
2119
+ operationId: "list_entries",
2120
+ parameters: [
2121
+ param("collection", "path", true),
2122
+ param("locale", "query", false),
2123
+ param("tag", "query", false),
2124
+ param("limit", "query", false),
2125
+ param("cursor", "query", false)
2126
+ ],
2127
+ responses: {
2128
+ "200": ok("A page of published entries"),
2129
+ ...common
2130
+ }
2131
+ } },
2132
+ "/v1/{collection}/translations/{group}": { get: {
2133
+ operationId: "get_translations",
2134
+ parameters: [param("collection", "path", true), param("group", "path", true)],
2135
+ responses: {
2136
+ "200": ok("The published entries of the group"),
2137
+ "404": { description: "No such group" },
2138
+ ...common
2139
+ }
2140
+ } },
2141
+ "/v1/{collection}/{locale}/{slug}": { get: {
2142
+ operationId: "get_entry",
2143
+ parameters: [
2144
+ param("collection", "path", true),
2145
+ param("locale", "path", true),
2146
+ param("slug", "path", true),
2147
+ param("formats", "query", false)
2148
+ ],
2149
+ responses: {
2150
+ "200": ok("A published entry"),
2151
+ "404": { description: "No such published entry" },
2152
+ ...common
2153
+ }
2154
+ } }
2155
+ }
2156
+ };
2157
+ }
2158
+ //#endregion
2159
+ //#region src/http/gateway.ts
2160
+ /**
2161
+ * The HTTP gateway of the Worker (ADR-019): a Hono 5 app, built once, that runs in this order for
2162
+ * every request:
2163
+ *
2164
+ * 1. an id for the request, which its errors and its log lines carry;
2165
+ * 2. the Host and Origin guard (`checkHostAndOrigin`), before any route, health and unknown
2166
+ * paths included. It fails closed: no valid `HOLLWARE_PUBLIC_URL` is a 500;
2167
+ * 3. the routes: `GET /health` answers `{ "ok": true }`, and `/mcp` answers every method, after
2168
+ * the token check below. Any other path is a JSON 404.
2169
+ *
2170
+ * The token check is the 401 that stands in front of the MCP endpoint, which never authenticates:
2171
+ * no token, or a token that `checkPersonalToken` refuses, is `unauthorized()` and the endpoint is
2172
+ * not called. It looks the token up on the `D1Database` binding and never on a replica session,
2173
+ * so a revoked token stops at once. Its reason goes to the log and never to the answer. The write
2174
+ * of `last_used_at` is handed to `ctx.waitUntil`: Workers cancel a promise nobody waits for.
2175
+ *
2176
+ * The endpoint then gets the request as it came, with its body and its `Mcp-*` headers untouched
2177
+ * (a 2026 client's headers must agree with its body), and the primary database: an agent reads
2178
+ * what it has just written.
2179
+ *
2180
+ * Whatever a route throws ends in the error middleware (`errors.ts`) as a JSON 500 that leaks
2181
+ * nothing. A failed D1 query while the token is checked is one of those: it is never a 401.
2182
+ */
2183
+ function createGateway(options) {
2184
+ const { site, mcp, clock, log } = options;
2185
+ const app = new Hono();
2186
+ app.use("*", async (c, next) => {
2187
+ c.set("requestId", crypto.randomUUID());
2188
+ await next();
2189
+ });
2190
+ app.use("*", async (c, next) => {
2191
+ const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL);
2192
+ if (refusal !== null) return refusal;
2193
+ await next();
2194
+ });
2195
+ app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
2196
+ app.all("/mcp", async (c) => {
2197
+ const requestId = c.get("requestId");
2198
+ const token = readBearerToken(c.req.raw);
2199
+ if (token === null) {
2200
+ log({
2201
+ kind: "auth_refused",
2202
+ requestId,
2203
+ reason: "no_token"
2204
+ });
2205
+ return unauthorized();
2206
+ }
2207
+ const check = await checkPersonalToken(c.env.DB, token, {
2208
+ site: site.key,
2209
+ now: clock.now()
2210
+ });
2211
+ if (!check.ok) {
2212
+ log({
2213
+ kind: "auth_refused",
2214
+ requestId,
2215
+ reason: check.reason
2216
+ });
2217
+ return unauthorized(check.reason);
2218
+ }
2219
+ if (check.touch !== null) c.executionCtx.waitUntil(check.touch);
2220
+ const db = c.env.DB;
2221
+ return await mcp.fetch(c.req.raw, {
2222
+ principal: check.principal,
2223
+ requestId,
2224
+ context: {
2225
+ site,
2226
+ store: createD1Store(db, { clock }),
2227
+ clock,
2228
+ notify: (ids) => {
2229
+ c.executionCtx.waitUntil(enqueue({
2230
+ queue: c.env.EVENTS,
2231
+ db: connect(db),
2232
+ site: site.key,
2233
+ ids,
2234
+ clock
2235
+ }).catch((error) => {
2236
+ log({
2237
+ kind: "enqueue_failed",
2238
+ requestId,
2239
+ message: String(error)
2240
+ });
2241
+ }));
2242
+ }
2243
+ }
2244
+ });
2245
+ });
2246
+ if (options.read !== void 0) installReadApi(app, {
2247
+ read: options.read,
2248
+ site: site.key,
2249
+ clock,
2250
+ log
2251
+ });
2252
+ installErrorHandling(app, log);
2253
+ return async (request, env, ctx) => await app.fetch(request, env, ctx);
2254
+ }
2255
+ //#endregion
2256
+ //#region src/mcp/annotations.ts
2257
+ /**
2258
+ * The MCP hints of each effect (ADR-017 and the v0.1 plan). A `read` is read-only and
2259
+ * idempotent; an `edit` or a `write` is neither read-only nor destructive; a `destructive`
2260
+ * operation is destructive, and repeating it changes nothing more. No tool reaches outside the
2261
+ * deployment, so every tool is closed-world.
2262
+ */
2263
+ const EFFECT_ANNOTATIONS = {
2264
+ read: {
2265
+ readOnlyHint: true,
2266
+ destructiveHint: false,
2267
+ idempotentHint: true,
2268
+ openWorldHint: false
2269
+ },
2270
+ edit: {
2271
+ readOnlyHint: false,
2272
+ destructiveHint: false,
2273
+ idempotentHint: false,
2274
+ openWorldHint: false
2275
+ },
2276
+ write: {
2277
+ readOnlyHint: false,
2278
+ destructiveHint: false,
2279
+ idempotentHint: false,
2280
+ openWorldHint: false
2281
+ },
2282
+ destructive: {
2283
+ readOnlyHint: false,
2284
+ destructiveHint: true,
2285
+ idempotentHint: true,
2286
+ openWorldHint: false
2287
+ }
2288
+ };
2289
+ //#endregion
2290
+ //#region src/mcp/acknowledge.ts
2291
+ /** The key of the question in `inputRequests`, and of the answer in `inputResponses`. */
2292
+ const QUESTION_KEY = "acknowledge_warnings";
2293
+ const form = z.object({ acknowledge: z.boolean().describe("Publish with these warnings.") });
2294
+ function warningsOf(output) {
2295
+ return output.verdict?.warnings ?? [];
2296
+ }
2297
+ function question(output, warnings) {
2298
+ const subject = output.entry === void 0 ? "this entry" : `${output.entry.collection}/${output.entry.locale}/${output.entry.slug}`;
2299
+ const count = warnings.length === 1 ? "1 warning" : `${warnings.length} warnings`;
2300
+ const lines = warnings.map((warning, index) => {
2301
+ const where = warning.line === void 0 ? warning.path : `${warning.path}, line ${warning.line}`;
2302
+ return `${index + 1}. ${warning.contract} at ${where}: ${warning.message} Fix: ${warning.fix}`;
2303
+ });
2304
+ return [
2305
+ `Publish ${subject} with ${count}?`,
2306
+ "",
2307
+ ...lines
2308
+ ].join("\n");
2309
+ }
2310
+ /**
2311
+ * The question to put to the person: every warning with its fix, and one box to tick. The
2312
+ * `needs_acknowledgement` answer travels in `requestState`, because the retry is a request of its
2313
+ * own and the server keeps nothing between the two: it is what a refusal answers, and where the
2314
+ * warnings to acknowledge come from. The client sends the state back as it got it, and it could
2315
+ * write any state it likes; the state grants nothing that `acknowledgeWarnings` does not, since a
2316
+ * client sends that argument by hand.
2317
+ */
2318
+ function askToAcknowledge(output) {
2319
+ return inputRequired({
2320
+ inputRequests: { [QUESTION_KEY]: inputRequired.elicit({
2321
+ message: question(output, warningsOf(output)),
2322
+ requestedSchema: form
2323
+ }) },
2324
+ requestState: JSON.stringify(output)
2325
+ });
2326
+ }
2327
+ /** The answer that the state of a retry carries, or `undefined` if it carries none that we wrote. */
2328
+ function askedIn(state) {
2329
+ if (typeof state !== "string") return void 0;
2330
+ let json;
2331
+ try {
2332
+ json = JSON.parse(state);
2333
+ } catch {
2334
+ return;
2335
+ }
2336
+ const parsed = publish.output.safeParse(json);
2337
+ return parsed.success && parsed.data.outcome === "needs_acknowledgement" ? parsed.data : void 0;
2338
+ }
2339
+ /**
2340
+ * The person's reply, when the call is the retry of a question we asked, and `null` when it is
2341
+ * not: a first call, or a retry that lost the state, which is treated as a first call. A decline,
2342
+ * a cancel and a form accepted with the box empty are all a refusal.
2343
+ */
2344
+ function replyTo(mcp) {
2345
+ if (inputResponse(mcp.mcpReq.inputResponses, QUESTION_KEY).kind !== "elicit") return null;
2346
+ const asked = askedIn(mcp.mcpReq.requestState());
2347
+ if (asked === void 0) return null;
2348
+ return {
2349
+ given: acceptedContent(mcp.mcpReq.inputResponses, QUESTION_KEY, form)?.acknowledge === true,
2350
+ asked
2351
+ };
2352
+ }
2353
+ /**
2354
+ * The input of the publish that follows an acceptance: the warnings the person was shown, added
2355
+ * to those the client already acknowledged, each contract and path once.
2356
+ */
2357
+ function withAcknowledged(input, asked) {
2358
+ const acknowledged = new Map(input.acknowledgeWarnings.map((warning) => [`${warning.contract}\n${warning.path}`, warning]));
2359
+ for (const { contract, path } of warningsOf(asked)) acknowledged.set(`${contract}\n${path}`, {
2360
+ contract,
2361
+ path
2362
+ });
2363
+ return {
2364
+ ...input,
2365
+ acknowledgeWarnings: [...acknowledged.values()]
2366
+ };
2367
+ }
2368
+ //#endregion
2369
+ //#region src/mcp/envelope.ts
2370
+ /**
2371
+ * What a client says about itself. A 2026-07-28 client says it on every request, in the `_meta`
2372
+ * envelope; a 2025-11-25 client says it once, in `initialize`, and the stateless fallback that
2373
+ * serves those clients keeps nothing between requests, so for them there is nothing to read.
2374
+ */
2375
+ function isRecord(value) {
2376
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2377
+ }
2378
+ /** The longest name, version or title kept of a client: it is recorded with what it writes. */
2379
+ const MAX_CLIENT_FIELD_LENGTH = 120;
2380
+ /**
2381
+ * The client as the envelope names it: its name, and its version and title when it gives them,
2382
+ * each cut short. A client could write anything there, and what it writes is stored with every
2383
+ * revision and publication it makes. `null` when the request has no envelope.
2384
+ */
2385
+ function declaredClient(envelope) {
2386
+ const info = isRecord(envelope) ? envelope[CLIENT_INFO_META_KEY] : void 0;
2387
+ if (!isRecord(info) || typeof info.name !== "string") return null;
2388
+ const client = { name: info.name.slice(0, MAX_CLIENT_FIELD_LENGTH) };
2389
+ if (typeof info.version === "string") client.version = info.version.slice(0, MAX_CLIENT_FIELD_LENGTH);
2390
+ if (typeof info.title === "string") client.title = info.title.slice(0, MAX_CLIENT_FIELD_LENGTH);
2391
+ return client;
2392
+ }
2393
+ /**
2394
+ * Whether the server may ask the client a question in a form, as a multi round-trip request: the
2395
+ * request is in the 2026 era, and the client declared the elicitation capability for forms. A
2396
+ * bare `elicitation: {}` counts as forms, as it does for the SDK, which answers `-32021` to a
2397
+ * question that the client did not declare; `{ url: {} }` alone does not. A 2025 client in the
2398
+ * stateless fallback can never be asked: that fallback has no way to send a request to a client.
2399
+ */
2400
+ function canElicitForm(era, envelope) {
2401
+ if (era !== "modern" || !isRecord(envelope)) return false;
2402
+ const capabilities = envelope[CLIENT_CAPABILITIES_META_KEY];
2403
+ const elicitation = isRecord(capabilities) ? capabilities.elicitation : void 0;
2404
+ if (!isRecord(elicitation)) return false;
2405
+ return elicitation.form !== void 0 || elicitation.url === void 0;
2406
+ }
2407
+ //#endregion
2408
+ //#region src/mcp/result.ts
2409
+ /**
2410
+ * Checks what a handler returned against the output schema of its spec, and keeps only the
2411
+ * fields the schema names, so a handler that hands over a row with more columns than the schema
2412
+ * has cannot leak them. The SDK checks the same schema again, but only for results that are not
2413
+ * errors; an answer that does not match is a bug of the handler, and it throws, so that the call
2414
+ * ends as `internal`.
2415
+ */
2416
+ function readAnswer(spec, value) {
2417
+ const parsed = spec.output.safeParse(value);
2418
+ if (!parsed.success) {
2419
+ const issues = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
2420
+ throw new TypeError(`${spec.name} answered something that is not an output of its spec: ${issues.join("; ")}`);
2421
+ }
2422
+ const { outcome } = parsed.data;
2423
+ const isError = Object.entries(spec.outcomes).some(([name, info]) => name === outcome && info.isError);
2424
+ return {
2425
+ output: parsed.data,
2426
+ outcome,
2427
+ isError
2428
+ };
2429
+ }
2430
+ const encoder = new TextEncoder();
2431
+ /**
2432
+ * The fix of `output_too_large`, for each operation. A read says how to ask for less. A write
2433
+ * has already run when its answer turns out too large, so it says to look at the state it left.
2434
+ */
2435
+ const OUTPUT_TOO_LARGE_FIX = {
2436
+ create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
2437
+ describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
2438
+ get_entry: "Ask for one format at a time with formats: markdown, document or html, instead of several together.",
2439
+ get_translations: "The translations do not fit in one answer. Read each one with get_entry instead.",
2440
+ list_entries: "Ask for less: pass a smaller limit, or narrow the list with locale, tag or status.",
2441
+ publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
2442
+ unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
2443
+ update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
2444
+ validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
2445
+ };
2446
+ /**
2447
+ * The result of a call: the answer as `structuredContent`, the same JSON as text for clients
2448
+ * that do not read structured content, and `isError` as the spec says for the outcome. An answer
2449
+ * whose JSON is over the cap, counted in UTF-8 bytes, is the error `output_too_large`: Hollware
2450
+ * never truncates content.
2451
+ */
2452
+ function answerResult(spec, answer) {
2453
+ const text = JSON.stringify(answer.output);
2454
+ const bytes = encoder.encode(text).length;
2455
+ 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]);
2456
+ return {
2457
+ content: [{
2458
+ type: "text",
2459
+ text
2460
+ }],
2461
+ structuredContent: answer.output,
2462
+ isError: answer.isError
2463
+ };
2464
+ }
2465
+ /**
2466
+ * The result of a call that ended in an error: `{ code, message, fix }` as text, and no
2467
+ * structured content. The tools declare an output schema, and clients check the structured
2468
+ * content they get against it even when `isError` is set, so an error that is not an output of
2469
+ * the operation must not carry any.
2470
+ */
2471
+ function errorResult(error) {
2472
+ return {
2473
+ content: [{
2474
+ type: "text",
2475
+ text: JSON.stringify({
2476
+ code: error.code,
2477
+ message: error.message,
2478
+ fix: error.fix
2479
+ })
2480
+ }],
2481
+ isError: true
2482
+ };
2483
+ }
2484
+ //#endregion
2485
+ //#region src/mcp/scopes.ts
2486
+ /**
2487
+ * The scope challenge of a tool, which the SDK asks before it serves a call of the tool. A
2488
+ * principal that has every scope the tool needs gets nothing, and the call goes on. One that
2489
+ * lacks any of them is refused with 403 and a challenge that names every scope the tool needs,
2490
+ * the ones it has included, so that a client that can ask for more asks for all of them at once.
2491
+ * A request that comes without scopes is refused too: it never passes by lacking a principal.
2492
+ *
2493
+ * `onDenied` runs when a call is refused, for the log: the call never reaches the tool.
2494
+ */
2495
+ function scopeChallenge(tool, onDenied) {
2496
+ return ({ authInfo }) => {
2497
+ const granted = new Set(authInfo?.scopes);
2498
+ if (tool.scopes.every((scope) => granted.has(scope))) return void 0;
2499
+ onDenied();
2500
+ return {
2501
+ scopes: tool.scopes,
2502
+ errorDescription: `${tool.spec.name} requires: ${tool.scopes.join(" ")}`
2503
+ };
2504
+ };
2505
+ }
2506
+ //#endregion
2507
+ //#region src/mcp/timeout.ts
2508
+ /**
2509
+ * Runs `work` and gives up on it when it takes longer than `limitMs`: the answer is the error
2510
+ * that `onTimeout` makes. The work is not stopped, because a promise cannot be: an operation that
2511
+ * ran out of time may still finish, and the error says so. Whichever finishes first, the timer
2512
+ * is cleared, and a failure of the work after the timeout is already handled by the race.
2513
+ */
2514
+ async function within(work, limitMs, onTimeout) {
2515
+ let timer;
2516
+ const timeout = new Promise((_resolve, reject) => {
2517
+ timer = setTimeout(() => reject(onTimeout()), limitMs);
2518
+ });
2519
+ try {
2520
+ return await Promise.race([work, timeout]);
2521
+ } finally {
2522
+ if (timer !== void 0) clearTimeout(timer);
2523
+ }
2524
+ }
2525
+ //#endregion
2526
+ //#region src/mcp/tools.ts
2527
+ /**
2528
+ * `schema` with the same validation, whose conversions to JSON Schema are done once and kept.
2529
+ * The SDK builds a server for every request, and a server converts the schemas of its tools on
2530
+ * its own, so without this every `tools/list` pays for the conversion again (about 1.4 ms for
2531
+ * the nine operations, measured in the v0.1 spike). Zod stays the only definition: it validates,
2532
+ * applies the defaults and words the errors; only the JSON Schema is cached.
2533
+ *
2534
+ * What the cache returns is shared by every request: the SDK reads it and never changes it.
2535
+ */
2536
+ function memoizeJsonSchema(schema) {
2537
+ const standard = schema["~standard"];
2538
+ const cache = /* @__PURE__ */ new Map();
2539
+ const convert = (io) => (options) => {
2540
+ if (options.libraryOptions !== void 0) return standard.jsonSchema[io](options);
2541
+ const key = `${io}:${options.target}`;
2542
+ let json = cache.get(key);
2543
+ if (json === void 0) {
2544
+ json = standard.jsonSchema[io](options);
2545
+ cache.set(key, json);
2546
+ }
2547
+ return json;
2548
+ };
2549
+ return { "~standard": {
2550
+ version: standard.version,
2551
+ vendor: standard.vendor,
2552
+ types: standard.types,
2553
+ validate: (value, options) => standard.validate(value, options),
2554
+ jsonSchema: {
2555
+ input: convert("input"),
2556
+ output: convert("output")
2557
+ }
2558
+ } };
2559
+ }
2560
+ const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
2561
+ /**
2562
+ * The tools of `operations`, sorted by name. The SDK lists tools in the order they are
2563
+ * registered, and `tools/list` must be the same for every client and every call, so the order
2564
+ * is decided here, by code unit and never by locale. The schemas of each tool are cached.
2565
+ *
2566
+ * An operation that asks for no scope is refused: a tool that anyone may call has to be a
2567
+ * decision someone makes, not a spec that forgot its scopes.
2568
+ */
2569
+ function createToolTable(operations = OPERATIONS) {
2570
+ return operations.toSorted(byName).map((spec) => {
2571
+ const [first, ...rest] = spec.scopes;
2572
+ if (first === void 0) throw new Error(`${spec.name} asks for no scope; every tool needs at least one`);
2573
+ return {
2574
+ spec,
2575
+ input: memoizeJsonSchema(spec.input),
2576
+ output: memoizeJsonSchema(spec.output),
2577
+ scopes: [first, ...rest]
2578
+ };
2579
+ });
2580
+ }
2581
+ //#endregion
2582
+ //#region src/mcp/endpoint.ts
2583
+ /**
2584
+ * `tools/list` is the same for every client and changes only when the Worker is deployed again:
2585
+ * it depends on the operations of the code and on nothing else, not on the site, the data or the
2586
+ * caller. So any cache may keep it (`public`). Five minutes is long enough for an agent that
2587
+ * works through a conversation to stop asking, and short enough that a client that cached the
2588
+ * list just before a deploy converges on the new one before long: the list can never push a
2589
+ * change (`listChanged` is false), so this is the only way a client learns of one.
2590
+ */
2591
+ const TOOLS_LIST_CACHE_HINT = {
2592
+ ttlMs: 3e5,
2593
+ cacheScope: "public"
2594
+ };
2595
+ /**
2596
+ * What the SDK is told about a principal: its scopes, which are the effective ones, and the id of
2597
+ * its token as the client. It carries no token: the gateway checked it, and nothing here needs it.
2598
+ */
2599
+ function authInfoOf(principal) {
2600
+ return {
2601
+ token: "",
2602
+ clientId: principal.kind === "token" ? principal.tokenId : principal.kind,
2603
+ scopes: [...principal.scopes]
2604
+ };
2605
+ }
2606
+ /**
2607
+ * Calls the handler of an operation. This is the one place where the typed pairing of specs and
2608
+ * handlers meets the dynamic protocol: the SDK parsed `input` with the input schema of `spec`,
2609
+ * which is the schema that types the handler of the same name.
2610
+ */
2611
+ function invoke(handlers, spec, input, context) {
2612
+ const handler = handlers[spec.name];
2613
+ return handler(input, context);
2614
+ }
2615
+ /**
2616
+ * The MCP endpoint of Hollware: one tool for each operation, served in both protocol eras, for
2617
+ * the gateway to mount at `/mcp` after it has checked the Host, the Origin and the token.
2618
+ *
2619
+ * One SDK handler serves the whole isolate, and each request gets a server of its own, built from
2620
+ * the tool table: the SDK's model, which needs no session. The JSON Schema of every tool is
2621
+ * converted once, the first time a request needs it.
2622
+ */
2623
+ function createMcpEndpoint(options) {
2624
+ const timeoutMs = options.timeoutMs ?? 1e4;
2625
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new RangeError(`timeoutMs must be a positive number of milliseconds, got ${timeoutMs}`);
2626
+ const tools = createToolTable();
2627
+ /** The call the gateway made for each request, found again by the factory through its authInfo. */
2628
+ const calls = /* @__PURE__ */ new WeakMap();
2629
+ const emit = (entry) => {
2630
+ try {
2631
+ options.log?.(entry);
2632
+ } catch {}
2633
+ };
2634
+ /** One call of a tool: the operation, its answer as a result, and one line of log. */
2635
+ const runTool = async (spec, input, mcp, call, era) => {
2636
+ const started = performance.now();
2637
+ const envelope = mcp.mcpReq.envelope;
2638
+ const context = {
2639
+ ...call.context,
2640
+ view: "head",
2641
+ principal: call.principal,
2642
+ requestId: call.requestId,
2643
+ client: declaredClient(envelope)
2644
+ };
2645
+ let outcome = null;
2646
+ let isError = false;
2647
+ let failure = null;
2648
+ let result;
2649
+ /** What the log says of the answer, kept as soon as there is one. */
2650
+ const record = (answer) => {
2651
+ outcome = answer.outcome;
2652
+ isError = answer.isError;
2653
+ return answer;
2654
+ };
2655
+ /** Runs the handler within the time limit and checks its answer. */
2656
+ const perform = async (operationInput) => {
2657
+ const value = await within(invoke(options.handlers, spec, operationInput, context), timeoutMs, () => timeoutError(spec, timeoutMs));
2658
+ return record(readAnswer(spec, value));
2659
+ };
2660
+ /**
2661
+ * Publish, which may ask the person to acknowledge the warnings. The first call asks, if the
2662
+ * client can be asked; the retry carries the reply. A refusal answers what the person was
2663
+ * shown without calling the handler again, and never asks twice: the client would retry
2664
+ * until it gave up.
2665
+ */
2666
+ const performPublish = async () => {
2667
+ const publishInput = publish.input.parse(input);
2668
+ const reply = replyTo(mcp);
2669
+ if (reply !== null && !reply.given) return answerResult(spec, record(readAnswer(spec, reply.asked)));
2670
+ if (reply !== null) return answerResult(spec, await perform(withAcknowledged(publishInput, reply.asked)));
2671
+ const answer = await perform(publishInput);
2672
+ if (answer.outcome === "needs_acknowledgement" && canElicitForm(era, envelope)) {
2673
+ const asked = publish.output.parse(answer.output);
2674
+ if (warningsOf(asked).length > 0) return askToAcknowledge(asked);
2675
+ }
2676
+ return answerResult(spec, answer);
2677
+ };
2678
+ try {
2679
+ result = spec.name === "publish" ? await performPublish() : answerResult(spec, await perform(input));
2680
+ } catch (error) {
2681
+ failure = error instanceof HollwareError ? error : internalError$1(call.requestId, error);
2682
+ isError = true;
2683
+ result = errorResult(failure);
2684
+ }
2685
+ emit({
2686
+ kind: "call",
2687
+ requestId: call.requestId,
2688
+ tool: spec.name,
2689
+ outcome,
2690
+ errorCode: failure?.code ?? null,
2691
+ isError,
2692
+ durationMs: Math.round(performance.now() - started),
2693
+ detail: failure === null || failure.cause === void 0 ? null : describeError(failure.cause)
2694
+ });
2695
+ return result;
2696
+ };
2697
+ const buildServer = (request) => {
2698
+ const call = request.authInfo === void 0 ? void 0 : calls.get(request.authInfo);
2699
+ if (call === void 0) throw new Error("A request reached the MCP server without the call the gateway made for it");
2700
+ const server = new McpServer(options.serverInfo, {
2701
+ capabilities: { tools: { listChanged: false } },
2702
+ cacheHints: { "tools/list": TOOLS_LIST_CACHE_HINT }
2703
+ });
2704
+ for (const tool of tools) server.registerTool(tool.spec.name, {
2705
+ title: tool.spec.title,
2706
+ description: tool.spec.description,
2707
+ inputSchema: tool.input,
2708
+ outputSchema: tool.output,
2709
+ annotations: EFFECT_ANNOTATIONS[tool.spec.effect],
2710
+ scopeChallenge: scopeChallenge(tool, () => emit({
2711
+ kind: "call",
2712
+ requestId: call.requestId,
2713
+ tool: tool.spec.name,
2714
+ outcome: null,
2715
+ errorCode: "insufficient_scope",
2716
+ isError: true,
2717
+ durationMs: 0,
2718
+ detail: null
2719
+ }))
2720
+ }, (input, mcp) => runTool(tool.spec, input, mcp, call, request.era));
2721
+ return server;
2722
+ };
2723
+ const handler = createMcpHandler(buildServer, { onerror: (error) => emit({
2724
+ kind: "protocol_error",
2725
+ message: error.message
2726
+ }) });
2727
+ return { async fetch(request, call) {
2728
+ if (!call?.principal) throw new Error("The MCP endpoint serves a request only as the principal the gateway found");
2729
+ const authInfo = authInfoOf(call.principal);
2730
+ calls.set(authInfo, call);
2731
+ return handler.fetch(request, { authInfo });
2732
+ } };
2733
+ }
2734
+ //#endregion
2735
+ //#region src/rpc/methods.ts
2736
+ /**
2737
+ * Who calls over RPC: a Worker of the same account, which reads published content and nothing
2738
+ * else. Frozen, so that no handler can widen it for the calls that come after.
2739
+ */
2740
+ const BINDING_PRINCIPAL = Object.freeze({
2741
+ kind: "binding",
2742
+ scopes: Object.freeze(["content:read"])
2743
+ });
2744
+ /**
2745
+ * The site of a call is not the site of this deployment. v0.1 serves one site, and the message
2746
+ * and the fix both say which, for a caller that only gets one of them.
2747
+ */
2748
+ function wrongSite(served) {
2749
+ 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.`);
2750
+ }
2751
+ /**
2752
+ * The options object of a call, without the keys that hold `undefined`, so that the handler sees
2753
+ * the same input as over MCP. A key that is none of `allowed` is an error: the specs are strict
2754
+ * objects so that a misspelled field is not ignored, and a caller that writes `lcoale` would
2755
+ * otherwise get every locale. Only the keys of `HollwareRpc` get through, so an input the RPC
2756
+ * does not offer, such as the `status` of `list_entries`, can never be set.
2757
+ */
2758
+ function readOptions(options, allowed) {
2759
+ if (options === void 0 || options === null) return {};
2760
+ 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.`);
2761
+ const entries = Object.entries(options);
2762
+ const unknown = entries.map(([key]) => key).filter((key) => !allowed.includes(key));
2763
+ 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(", ")}.`);
2764
+ return Object.fromEntries(entries.filter(([, value]) => value !== void 0));
2765
+ }
2766
+ /**
2767
+ * The input of an operation, parsed by its spec: the defaults (`limit`, `formats`) apply and a
2768
+ * value that breaks the schema is `invalid_input` that says which.
2769
+ */
2770
+ function parseInput(schema, raw) {
2771
+ const parsed = schema.safeParse(raw);
2772
+ if (parsed.success) return parsed.data;
2773
+ const problems = parsed.error.issues.map((issue) => `${issue.path.map(String).join(".")}: ${issue.message}`);
2774
+ 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.");
2775
+ }
2776
+ /**
2777
+ * What a caller gets of a failure: an error it can read and act on, and none of the cause, which
2778
+ * an error carries when a handler wraps a failure of D1 or of its own. A `HollwareError` goes as
2779
+ * it is, rebuilt without its cause, and anything else is `internal` with the id of the call. What
2780
+ * the caller does not get goes to the log, under that id.
2781
+ */
2782
+ function forCaller(deps, requestId, method, error) {
2783
+ const known = error instanceof HollwareError ? error : null;
2784
+ if (known === null || known.cause !== void 0) deps.log({
2785
+ kind: "rpc_error",
2786
+ requestId,
2787
+ method,
2788
+ errorCode: known === null ? null : known.code,
2789
+ detail: describeError(error)
2790
+ });
2791
+ 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}.`);
2792
+ return new HollwareError(known.code, known.message, known.fix);
2793
+ }
2794
+ /**
2795
+ * One call of an operation over RPC. In this order: the site is the one of this deployment, the
2796
+ * input is built and parsed by the spec's schema, and the handler runs with
2797
+ *
2798
+ * - `view: 'published'`, so a draft is never read, and the principal `{ kind: 'binding', scopes:
2799
+ * ['content:read'] }`, which can read and nothing else;
2800
+ * - a store over `env.DB.withSession('first-unconstrained')`: published content, from the nearest
2801
+ * replica, where MCP reads the primary;
2802
+ * - the clock, `client: null` (a binding says nothing about itself) and a request id of its own.
2803
+ *
2804
+ * `build` makes the raw input, so a wrong site is answered before anything else is looked at and
2805
+ * a call that fails never opens a session.
2806
+ */
2807
+ async function run(deps, env, method, site, schema, build, handler) {
2808
+ if (site !== deps.site.key) throw wrongSite(deps.site.key);
2809
+ const input = parseInput(schema, build());
2810
+ const requestId = crypto.randomUUID();
2811
+ const context = {
2812
+ site: deps.site,
2813
+ principal: BINDING_PRINCIPAL,
2814
+ view: "published",
2815
+ store: createD1Store(env.DB.withSession("first-unconstrained")),
2816
+ clock: deps.clock,
2817
+ client: null,
2818
+ requestId
2819
+ };
2820
+ try {
2821
+ return await handler(input, context);
2822
+ } catch (error) {
2823
+ throw forCaller(deps, requestId, method, error);
2824
+ }
2825
+ }
2826
+ /**
2827
+ * The read-only RPC surface (`HollwareRpc`) over the bindings of one invocation. It is a function
2828
+ * and not the entrypoint so that it runs in any test; `entrypoint.ts` is the class that calls it.
2829
+ * It has the four methods and nothing that writes, and none of them reaches a handler other than
2830
+ * the one that reads.
2831
+ */
2832
+ function createRpc(deps, env) {
2833
+ return {
2834
+ getEntry: (site, collection, slug, locale, options) => run(deps, env, "getEntry", site, getEntry.input, () => ({
2835
+ collection,
2836
+ locale,
2837
+ slug,
2838
+ ...readOptions(options, ["formats"])
2839
+ }), deps.handlers.get_entry),
2840
+ listEntries: (site, collection, options) => run(deps, env, "listEntries", site, listEntries.input, () => ({
2841
+ collection,
2842
+ ...readOptions(options, [
2843
+ "locale",
2844
+ "tag",
2845
+ "limit",
2846
+ "cursor"
2847
+ ])
2848
+ }), deps.handlers.list_entries),
2849
+ getTranslations: (site, collection, translationGroup) => run(deps, env, "getTranslations", site, getTranslations.input, () => ({
2850
+ collection,
2851
+ translationGroup
2852
+ }), deps.handlers.get_translations),
2853
+ describeSite: (site) => run(deps, env, "describeSite", site, describeSite.input, () => ({}), deps.handlers.describe_site)
2854
+ };
2855
+ }
2856
+ //#endregion
2857
+ //#region src/rpc/entrypoint.ts
2858
+ /**
2859
+ * The class a site reaches over a service binding: the user's Worker exports it as
2860
+ * `export class Hollware extends hollware.Hollware {}`. It is made here, closed over what the
2861
+ * four methods stand on, because that is known only once the config is resolved.
2862
+ *
2863
+ * It has the four methods of `HollwareRpc` and nothing else, and none of them writes. That is the
2864
+ * security surface of the binding: a Worker with a binding can call whatever is on this
2865
+ * prototype, so a helper method added here would be public. Anything else lives in `methods.ts`,
2866
+ * where `createRpc` does the work and runs in a test without a `WorkerEntrypoint`.
2867
+ */
2868
+ function createEntrypoint(deps) {
2869
+ return class Hollware extends WorkerEntrypoint {
2870
+ getEntry(...args) {
2871
+ return createRpc(deps, this.env).getEntry(...args);
2872
+ }
2873
+ listEntries(...args) {
2874
+ return createRpc(deps, this.env).listEntries(...args);
2875
+ }
2876
+ getTranslations(...args) {
2877
+ return createRpc(deps, this.env).getTranslations(...args);
2878
+ }
2879
+ describeSite(...args) {
2880
+ return createRpc(deps, this.env).describeSite(...args);
2881
+ }
2882
+ };
2883
+ }
2884
+ //#endregion
2885
+ //#region src/schedule/run.ts
2886
+ /** What the publication of a schedule may do: what `publish` needs, in the scheduler's name. */
2887
+ const SCHEDULER_SCOPES = [
2888
+ "content:read",
2889
+ "content:write",
2890
+ "content:publish"
2891
+ ];
2892
+ /**
2893
+ * Publishes the scheduled entries whose time has come (ADR-025), one by one, through the same
2894
+ * handler as `publish`, in the name of who scheduled each one and with the warnings acknowledged
2895
+ * then. An entry that cannot be published (it changed after it was scheduled, or the contracts now
2896
+ * block it or warn about something new) goes back to a draft, and the log says why. A failure of
2897
+ * the database leaves the schedule for the next run.
2898
+ */
2899
+ async function runDueSchedules(input) {
2900
+ const { site, store, handlers, clock, log } = input;
2901
+ const due = await store.dueSchedules(site.key, clock.now().toISOString());
2902
+ let published = 0;
2903
+ let cancelled = 0;
2904
+ for (const schedule of due) {
2905
+ const requestId = `cron_${crypto.randomUUID()}`;
2906
+ const answer = await handlers.publish({
2907
+ id: schedule.entryId,
2908
+ version: schedule.version,
2909
+ acknowledgeWarnings: [...schedule.acknowledged],
2910
+ group: []
2911
+ }, {
2912
+ site,
2913
+ principal: {
2914
+ kind: "token",
2915
+ userId: schedule.actor.userId,
2916
+ tokenId: schedule.actor.tokenId ?? "",
2917
+ site: site.key,
2918
+ scopes: SCHEDULER_SCOPES
2919
+ },
2920
+ view: "head",
2921
+ store,
2922
+ clock,
2923
+ client: schedule.actor.meta,
2924
+ requestId
2925
+ });
2926
+ if (answer.outcome === "published") published += 1;
2927
+ else {
2928
+ await store.cancelSchedule(site.key, schedule.entryId);
2929
+ cancelled += 1;
2930
+ }
2931
+ log({
2932
+ kind: "schedule",
2933
+ requestId,
2934
+ entryId: schedule.entryId,
2935
+ scheduledAt: schedule.scheduledAt,
2936
+ outcome: answer.outcome
2937
+ });
2938
+ }
2939
+ return {
2940
+ published,
2941
+ cancelled
2942
+ };
2943
+ }
2944
+ //#endregion
2945
+ //#region src/compose.ts
2946
+ const systemClock = { now: () => /* @__PURE__ */ new Date() };
2947
+ /** What the MCP endpoint calls the server, in `server/discover` and `initialize`. */
2948
+ const SERVER_INFO = {
2949
+ name: "hollware",
2950
+ title: "Hollware",
2951
+ version
2952
+ };
2953
+ /** One JSON line per entry, so that Workers Logs indexes its fields. */
2954
+ function writeLog(entry) {
2955
+ console.log(JSON.stringify(entry));
2956
+ }
2957
+ /**
2958
+ * Builds the Worker from the config and the handlers of the operations (WP9). `createHollware`
2959
+ * is this with the real handlers.
2960
+ *
2961
+ * Everything that can be built once is built here, when the user's module loads, and never again:
2962
+ *
2963
+ * - the config is resolved with `resolveConfig`, so a broken `hollware.config.ts` throws its
2964
+ * `ConfigError` here, and the Worker fails at startup with a message that says what to change.
2965
+ * v0.1 serves the one site of the config;
2966
+ * - the MCP endpoint, whose tool schemas are converted once, and the Hono app of the gateway,
2967
+ * which is `fetch`: MCP at `/mcp` behind the guard and the token check, and the other routes
2968
+ * (`http/gateway.ts`);
2969
+ * - the `Hollware` class for the user's Worker to export, the read-only RPC surface
2970
+ * (`rpc/entrypoint.ts`).
2971
+ *
2972
+ * Both surfaces run the same handlers on a store of their own: MCP on the primary database, RPC
2973
+ * on a replica session and the published view. Every log line, those of the MCP endpoint
2974
+ * included, is one JSON line written with `console.log`.
2975
+ */
2976
+ function createHollwareWith(config, handlers, options = {}) {
2977
+ const [site] = resolveConfig(config).sites.values();
2978
+ if (site === void 0) throw new Error("The resolved config has no site, which resolveConfig never lets through");
2979
+ const clock = options.clock ?? systemClock;
2980
+ const mcp = createMcpEndpoint({
2981
+ handlers,
2982
+ serverInfo: SERVER_INFO,
2983
+ log: writeLog
2984
+ });
2985
+ const deliveryDeps = (env) => ({
2986
+ db: connect(env.DB),
2987
+ site,
2988
+ clock,
2989
+ fetch: (request) => fetch(request),
2990
+ secret: (name) => {
2991
+ const value = Reflect.get(env, name);
2992
+ return typeof value === "string" ? value : void 0;
2993
+ }
2994
+ });
2995
+ return {
2996
+ fetch: createGateway({
2997
+ site,
2998
+ mcp,
2999
+ clock,
3000
+ log: writeLog,
3001
+ read: (env) => createRpc({
3002
+ site,
3003
+ handlers,
3004
+ clock,
3005
+ log: writeLog
3006
+ }, env)
3007
+ }),
3008
+ scheduled: async (_controller, env, ctx) => {
3009
+ const deps = deliveryDeps(env);
3010
+ ctx.waitUntil(runDueSchedules({
3011
+ site,
3012
+ store: createD1Store(env.DB, { clock }),
3013
+ handlers,
3014
+ clock,
3015
+ log: writeLog
3016
+ }).then(() => sweepOutbox({
3017
+ queue: env.EVENTS,
3018
+ deps
3019
+ })).then(() => void 0, (error) => {
3020
+ writeLog({
3021
+ kind: "cron_failed",
3022
+ message: String(error)
3023
+ });
3024
+ }));
3025
+ },
3026
+ queue: async (batch, env) => {
3027
+ await consumeEvents(batch.messages, deliveryDeps(env));
3028
+ },
3029
+ Hollware: createEntrypoint({
3030
+ site,
3031
+ handlers,
3032
+ clock,
3033
+ log: writeLog
3034
+ })
3035
+ };
3036
+ }
3037
+ //#endregion
3038
+ //#region src/ops/errors.ts
3039
+ /**
3040
+ * The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
3041
+ * Like the `internal` error of the adapters, its message says nothing about the cause, which goes
3042
+ * to the log through `cause`, and its fix names the request id the operator finds the log line by.
3043
+ */
3044
+ function internalError(context, cause) {
3045
+ 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) });
3046
+ }
3047
+ /**
3048
+ * The error for an answer of the store that the call it came from can never get: an entry that
3049
+ * is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
3050
+ * operation, so it is not one of the outcomes of its spec.
3051
+ */
3052
+ function unexpectedAnswer(context, call, answer) {
3053
+ return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
3054
+ }
3055
+ //#endregion
3056
+ //#region src/ops/actor.ts
3057
+ /**
3058
+ * Who a write is made by: the token of the call, with what its client said about itself. Only a
3059
+ * personal token writes (ADR-019). The adapters check the scopes of the operation before any
3060
+ * handler runs and never route a write for another principal, so reaching one with a principal
3061
+ * that is not a token is a bug, answered as `internal` before anything is read or written.
3062
+ */
3063
+ function actorOf(context) {
3064
+ const { principal } = context;
3065
+ if (principal.kind !== "token") throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a token writes.`);
3066
+ return {
3067
+ kind: "token",
3068
+ userId: principal.userId,
3069
+ tokenId: principal.tokenId,
3070
+ meta: context.client
3071
+ };
3072
+ }
3073
+ //#endregion
3074
+ //#region src/ops/answers.ts
3075
+ /**
3076
+ * The summary of an entry, from a listing (`EntrySummaryView`) or from a read of one entry
3077
+ * (`EntryView`, which has all of the summary's fields and more). `hasUnpublishedChanges` says
3078
+ * whether the latest revision differs from the one that is live, so an entry that was never
3079
+ * published always has them.
3080
+ */
3081
+ function entrySummary(view) {
3082
+ const { entry, revision } = view;
3083
+ return {
3084
+ id: entry.id,
3085
+ collection: entry.collection,
3086
+ locale: entry.locale,
3087
+ slug: entry.slug,
3088
+ translationGroup: entry.translationGroup,
3089
+ status: entry.status,
3090
+ version: entry.version,
3091
+ frontmatter: revision.frontmatter,
3092
+ tags: [...revision.tags],
3093
+ hasUnpublishedChanges: entry.publishedRevision !== entry.headRevision,
3094
+ publishedAt: entry.publishedAt,
3095
+ updatedAt: entry.updatedAt
3096
+ };
3097
+ }
3098
+ /** A verdict as an output: the same facts, in arrays that the caller may own. */
3099
+ function verdictView(verdict) {
3100
+ return {
3101
+ ok: verdict.ok,
3102
+ phase: verdict.phase,
3103
+ errors: [...verdict.errors],
3104
+ warnings: [...verdict.warnings],
3105
+ contractsHash: verdict.contractsHash
3106
+ };
3107
+ }
3108
+ /**
3109
+ * The verdict of a refusal: the violations of the pipeline, or of a write that the store turned
3110
+ * down, in the phase of the operation. No contract ran, and the empty hash says so: a hash names
3111
+ * the rules a verdict answers to, and this one answers to none.
3112
+ */
3113
+ function refusalVerdict(phase, violations) {
3114
+ return {
3115
+ ok: false,
3116
+ phase,
3117
+ errors: [...violations],
3118
+ warnings: [],
3119
+ contractsHash: ""
3120
+ };
3121
+ }
3122
+ /** What `conflict` carries: the version the entry is at now, and its latest revision. */
3123
+ function conflictAt(currentVersion, headRevision) {
3124
+ return {
3125
+ currentVersion,
3126
+ headRevision
3127
+ };
3128
+ }
3129
+ /**
3130
+ * The violation of a write that the store turned down because of what is stored: another entry
3131
+ * holds its slug or its translation, or its row would be over the limit of D1. These are the
3132
+ * refusals that make a write `refused`; the store never answers `taken` to a collection that has
3133
+ * no translations, since its entries have no group.
3134
+ */
3135
+ function violationOf(refusal, collection) {
3136
+ if (refusal.outcome === "too_large") return sizeLimitViolation(refusal.bytes, refusal.limit);
3137
+ if (refusal.field === "slug") return uniqueSlugViolation(refusal.by);
3138
+ const field = collection.translationGroupField;
3139
+ if (field === null) throw new Error(`The store said that a translation group is taken in the collection ${collection.name}, which has no translations`);
3140
+ return uniqueTranslationViolation(refusal.by, field);
3141
+ }
3142
+ /**
3143
+ * The last publication of an entry as `get_entry` shows it: the latest one when it is a publish,
3144
+ * and nothing when the entry was unpublished since, or never published.
3145
+ */
3146
+ function publicationView(publication) {
3147
+ if (publication === null || publication.action !== "publish") return null;
3148
+ if (publication.revisionId === null || publication.verdict === null) throw new Error(`The publication ${publication.id} is a publish with no revision or verdict`);
3149
+ return {
3150
+ revision: publication.revisionId,
3151
+ verdict: verdictView(publication.verdict),
3152
+ acknowledged: publication.acknowledged.map(({ contract, path }) => ({
3153
+ contract,
3154
+ path
3155
+ })),
3156
+ at: publication.createdAt
3157
+ };
3158
+ }
3159
+ //#endregion
3160
+ //#region src/ops/collections.ts
3161
+ /**
3162
+ * Which collection and locale a call names, checked against the config of the site before
3163
+ * anything else is done with it. The fixes list what the site has, so that an agent that guessed
3164
+ * a name has the right one in the answer.
3165
+ */
3166
+ /** The longest name an error repeats: a caller could send any string. */
3167
+ const NAME_LIMIT = 60;
3168
+ /** A name the caller sent, as an error shows it: in JSON quotes, so on one line, and cut when it is long. */
3169
+ function shown(name) {
3170
+ if (name.length <= NAME_LIMIT) return JSON.stringify(name);
3171
+ const head = name.slice(0, NAME_LIMIT);
3172
+ const last = head.charCodeAt(59);
3173
+ return JSON.stringify(`${last >= 55296 && last <= 56319 ? head.slice(0, -1) : head}…`);
3174
+ }
3175
+ /** The collection of the site with that name, or `unknown_collection` listing the ones it has. */
3176
+ function requireCollection(site, name) {
3177
+ const collection = site.collections.get(name);
3178
+ if (collection !== void 0) return collection;
3179
+ 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.`);
3180
+ }
3181
+ /** Whether the site has the locale, or `unknown_locale` listing the ones it has. */
3182
+ function requireLocale(site, locale) {
3183
+ if (site.locales.includes(locale)) return;
3184
+ throw new HollwareError("unknown_locale", `The site has no locale ${shown(locale)}.`, `Use one of its locales: ${site.locales.join(", ")}.`);
3185
+ }
3186
+ /**
3187
+ * The collection of an entry that is stored. The caller named the entry by its id, so a
3188
+ * collection that is gone from the config is no mistake of the caller: the entry and its
3189
+ * revisions are kept, and whoever maintains the config can declare the collection again. Without
3190
+ * it nothing can be checked, so the entry cannot be written or published until then.
3191
+ */
3192
+ function collectionOfEntry(site, entry) {
3193
+ const collection = site.collections.get(entry.collection);
3194
+ if (collection !== void 0) return collection;
3195
+ 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.`);
3196
+ }
3197
+ //#endregion
3198
+ //#region src/ops/draft.ts
3199
+ /**
3200
+ * What the operations that take Markdown share: running the pipeline and the contracts of a
3201
+ * collection on it, and reading from the frontmatter that the pipeline returned the fields that
3202
+ * the store keeps beside the revision.
3203
+ */
3204
+ /**
3205
+ * What the pipeline needs of a site and a collection: the components the collection allows, the
3206
+ * largest file it accepts, and the routes of every collection of the site, by collection name, so
3207
+ * that a link to another collection's entry is recognized.
3208
+ */
3209
+ function optionsFor(site, collection) {
3210
+ return {
3211
+ components: [...collection.components.keys()],
3212
+ maxMarkdownBytes: collection.maxMarkdownBytes,
3213
+ routes: Object.fromEntries([...site.collections].map(([name, other]) => [name, other.routes]))
3214
+ };
3215
+ }
3216
+ /** Runs the Markdown pipeline on a file for a collection. It never throws; see `processMarkdown`. */
3217
+ function processFor(site, collection, markdown) {
3218
+ return processMarkdown(markdown, optionsFor(site, collection));
3219
+ }
3220
+ /**
3221
+ * Runs the contracts of a collection on a file the pipeline accepted. `write` is what a draft is
3222
+ * saved under; `publish` is what `validate` and `publish` check, with every contract of the
3223
+ * collection, against the other entries of the site as they are now.
3224
+ */
3225
+ function judge(context, collection, target, markdown, phase, entries = context.store.lookup(context.site.key)) {
3226
+ return runContracts({
3227
+ site: context.site,
3228
+ collection,
3229
+ locale: target.locale,
3230
+ slug: target.slug,
3231
+ entryId: target.entryId,
3232
+ phase,
3233
+ markdown,
3234
+ entries
3235
+ });
3236
+ }
3237
+ /**
3238
+ * The key that the translations of an entry share: the value of the field that the collection
3239
+ * names, when it is a string. An empty string is no key: `get_translations` never takes one, and
3240
+ * the `translation-pair` contract reads it as a missing group, so it is stored as none, which
3241
+ * also keeps two drafts with an empty group from refusing one another.
3242
+ */
3243
+ function translationGroupOf(collection, frontmatter) {
3244
+ const field = collection.translationGroupField;
3245
+ const value = field === null ? void 0 : frontmatter[field];
3246
+ return typeof value === "string" && value !== "" ? value : null;
3247
+ }
3248
+ /** The tags of an entry: the value of the field that the collection names, when it is a list of strings. */
3249
+ function tagsOf(collection, frontmatter) {
3250
+ const field = collection.tagsField;
3251
+ const value = field === null ? void 0 : frontmatter[field];
3252
+ return Array.isArray(value) && value.every((tag) => typeof tag === "string") ? value : [];
3253
+ }
3254
+ /** What orders the entry in published listings: the value of the sorting field, when it is a string. */
3255
+ function sortKeyOf(collection, frontmatter) {
3256
+ const field = collection.sortByField;
3257
+ const value = field === null ? void 0 : frontmatter[field];
3258
+ return typeof value === "string" ? value : null;
3259
+ }
3260
+ /**
3261
+ * The revision to store for a file. The Markdown is exactly the file the caller sent: its bytes
3262
+ * are the source of truth, and everything else is derived from them (ADR-001).
3263
+ */
3264
+ function revisionOf(input) {
3265
+ const { processed } = input;
3266
+ return {
3267
+ markdown: input.markdown,
3268
+ frontmatter: processed.frontmatter,
3269
+ document: processed.document,
3270
+ comarkVersion: processed.comarkVersion,
3271
+ pipelineVersion: processed.pipelineVersion,
3272
+ wordCount: processed.extracted.words,
3273
+ verdict: input.verdict,
3274
+ tags: tagsOf(input.collection, processed.frontmatter),
3275
+ actor: input.actor
3276
+ };
3277
+ }
3278
+ //#endregion
3279
+ //#region src/ops/create-draft.ts
3280
+ /**
3281
+ * Creates an entry at version 1 from a Markdown file. A file the pipeline cannot store is
3282
+ * `refused`; any other problem, a contract error included, is saved with the draft in its verdict
3283
+ * and only blocks publishing (ADR-004). The store refuses a slug or a translation that another
3284
+ * entry holds, and a row over the limit of D1, and those are `refused` too.
3285
+ */
3286
+ const createDraft = async (input, context) => {
3287
+ const collection = requireCollection(context.site, input.collection);
3288
+ requireLocale(context.site, input.locale);
3289
+ const actor = actorOf(context);
3290
+ const processed = await processFor(context.site, collection, input.markdown);
3291
+ if (!processed.ok) return {
3292
+ outcome: "refused",
3293
+ verdict: refusalVerdict("write", processed.violations)
3294
+ };
3295
+ const verdict = await judge(context, collection, {
3296
+ locale: input.locale,
3297
+ slug: input.slug,
3298
+ entryId: null
3299
+ }, processed.value, "write");
3300
+ const written = await context.store.createEntry(context.site.key, {
3301
+ collection: input.collection,
3302
+ locale: input.locale,
3303
+ slug: input.slug,
3304
+ translationGroup: translationGroupOf(collection, processed.value.frontmatter),
3305
+ revision: revisionOf({
3306
+ markdown: input.markdown,
3307
+ processed: processed.value,
3308
+ collection,
3309
+ verdict,
3310
+ actor
3311
+ })
3312
+ });
3313
+ switch (written.outcome) {
3314
+ case "saved": return {
3315
+ outcome: "saved",
3316
+ entry: entrySummary(written.view),
3317
+ verdict: verdictView(verdict)
3318
+ };
3319
+ case "taken":
3320
+ case "too_large": return {
3321
+ outcome: "refused",
3322
+ verdict: refusalVerdict("write", [violationOf(written, collection)])
3323
+ };
3324
+ case "conflict":
3325
+ case "not_found": throw unexpectedAnswer(context, "createEntry", written);
3326
+ }
3327
+ };
3328
+ //#endregion
3329
+ //#region src/ops/describe-site.ts
3330
+ /**
3331
+ * The always-on contracts as every collection lists them, described once for the isolate:
3332
+ * `describeResolvedSite` memoizes a site's description by the array it is given, so passing this
3333
+ * same array on every call is what makes the schemas convert once and not on every request.
3334
+ */
3335
+ const builtInContracts = describeBuiltInContracts();
3336
+ /** Describes the site the call is for: what an agent reads before it writes. */
3337
+ const describeSite$1 = async (_input, context) => ({
3338
+ outcome: "ok",
3339
+ site: describeResolvedSite(context.site, { builtInContracts })
3340
+ });
3341
+ //#endregion
3342
+ //#region src/ops/get-entry.ts
3343
+ /**
3344
+ * Reads an entry in the view of the call, `head` over MCP and `published` over RPC, with only the
3345
+ * formats that were asked for.
3346
+ */
3347
+ const getEntry$1 = async (input, context) => {
3348
+ requireCollection(context.site, input.collection);
3349
+ requireLocale(context.site, input.locale);
3350
+ const found = await context.store.findEntry(context.site.key, {
3351
+ collection: input.collection,
3352
+ locale: input.locale,
3353
+ slug: input.slug
3354
+ }, context.view);
3355
+ if (found === null) return { outcome: "not_found" };
3356
+ const { entry, revision } = found;
3357
+ const publication = await context.store.lastPublication(context.site.key, entry.id);
3358
+ return {
3359
+ outcome: "ok",
3360
+ entry: {
3361
+ ...entrySummary(found),
3362
+ revision: revision.id,
3363
+ verdict: verdictView(revision.verdict),
3364
+ lastPublication: publicationView(publication),
3365
+ ...input.formats.includes("markdown") ? { markdown: revision.markdown } : {},
3366
+ ...input.formats.includes("document") ? { document: revision.document } : {},
3367
+ ...input.formats.includes("html") ? { html: await renderHtml(revision.document) } : {}
3368
+ }
3369
+ };
3370
+ };
3371
+ //#endregion
3372
+ //#region src/ops/get-translations.ts
3373
+ /** Reads the entries of a translation group in the view of the call, one per locale. */
3374
+ const getTranslations$1 = async (input, context) => {
3375
+ const collection = requireCollection(context.site, input.collection);
3376
+ 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.");
3377
+ const group = await context.store.listTranslations(context.site.key, input.collection, input.translationGroup, context.view);
3378
+ if (group.length === 0) return { outcome: "not_found" };
3379
+ return {
3380
+ outcome: "ok",
3381
+ entries: group.map(entrySummary)
3382
+ };
3383
+ };
3384
+ //#endregion
3385
+ //#region src/ops/list-entries.ts
3386
+ /** Lists a page of the entries of a collection, in the view of the call. */
3387
+ const listEntries$1 = async (input, context) => {
3388
+ requireCollection(context.site, input.collection);
3389
+ if (input.locale !== void 0) requireLocale(context.site, input.locale);
3390
+ const page = await context.store.listEntries(context.site.key, {
3391
+ collection: input.collection,
3392
+ locale: input.locale,
3393
+ tag: input.tag,
3394
+ status: input.status,
3395
+ view: context.view,
3396
+ limit: input.limit,
3397
+ cursor: input.cursor
3398
+ });
3399
+ return {
3400
+ outcome: "ok",
3401
+ entries: page.entries.map(entrySummary),
3402
+ nextCursor: page.nextCursor
3403
+ };
3404
+ };
3405
+ //#endregion
3406
+ //#region src/ops/event.ts
3407
+ /** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
3408
+ function entryPath(collection, locale, slug) {
3409
+ const route = collection.routes[locale];
3410
+ return route === void 0 ? null : route.replace(/:slug(?![A-Za-z0-9_])/, slug);
3411
+ }
3412
+ /** The event of a publish or an unpublish, ready for the outbox. */
3413
+ function eventFor(input) {
3414
+ const id = newId("evt");
3415
+ const path = entryPath(input.collection, input.entry.locale, input.entry.slug);
3416
+ const payload = {
3417
+ id,
3418
+ kind: input.kind,
3419
+ site: input.site.key,
3420
+ at: input.at,
3421
+ entry: input.entry,
3422
+ path,
3423
+ url: path === null || input.site.url === null ? null : `${input.site.url}${path}`
3424
+ };
3425
+ return {
3426
+ id,
3427
+ payload: JSON.stringify(payload)
3428
+ };
3429
+ }
3430
+ //#endregion
3431
+ //#region src/ops/publish.ts
3432
+ /** A warning by what an acknowledgement names: its contract and its path, never its line. */
3433
+ function warningKey(warning) {
3434
+ return JSON.stringify([warning.contract, warning.path]);
3435
+ }
3436
+ /**
3437
+ * Publishes the latest revision of an entry, if it is still at the version the caller read. The
3438
+ * contracts run again, in the publish phase, because the rules and the other entries may have
3439
+ * changed since the draft was saved: an error blocks, and a warning has to be acknowledged, by
3440
+ * contract and path, before the publication is made. The publication records every warning of its
3441
+ * verdict as acknowledged (ADR-021).
3442
+ */
3443
+ const publish$1 = async (input, context) => {
3444
+ if (input.group.length > 0) return publishGroup(input, context);
3445
+ const actor = actorOf(context);
3446
+ const found = await context.store.findEntryById(context.site.key, input.id, "head");
3447
+ if (found === null) return { outcome: "not_found" };
3448
+ const { entry, revision } = found;
3449
+ const collection = collectionOfEntry(context.site, entry);
3450
+ if (entry.version !== input.version) return {
3451
+ outcome: "conflict",
3452
+ conflict: conflictAt(entry.version, entry.headRevision)
3453
+ };
3454
+ const summary = entrySummary(found);
3455
+ const processed = await processFor(context.site, collection, revision.markdown);
3456
+ if (!processed.ok) return {
3457
+ outcome: "blocked",
3458
+ entry: summary,
3459
+ verdict: refusalVerdict("publish", processed.violations)
3460
+ };
3461
+ const verdict = await judge(context, collection, {
3462
+ locale: entry.locale,
3463
+ slug: entry.slug,
3464
+ entryId: entry.id
3465
+ }, processed.value, "publish");
3466
+ if (!verdict.ok) return {
3467
+ outcome: "blocked",
3468
+ entry: summary,
3469
+ verdict: verdictView(verdict)
3470
+ };
3471
+ const acknowledged = new Set(input.acknowledgeWarnings.map(warningKey));
3472
+ if (verdict.warnings.some((warning) => !acknowledged.has(warningKey(warning)))) return {
3473
+ outcome: "needs_acknowledgement",
3474
+ entry: summary,
3475
+ verdict: verdictView(verdict)
3476
+ };
3477
+ const acknowledgedWarnings = verdict.warnings.map(({ contract, path }) => ({
3478
+ contract,
3479
+ path
3480
+ }));
3481
+ if (input.publishAt !== void 0 && Date.parse(input.publishAt) > context.clock.now().getTime()) {
3482
+ if (entry.status === "published") throw new HollwareError("invalid_input", "The entry is published, so its publication cannot be scheduled.", "Publish its changes now, without publishAt, or unpublish it first and then schedule it.");
3483
+ const scheduled = await context.store.schedule(context.site.key, {
3484
+ entryId: entry.id,
3485
+ expectedVersion: input.version,
3486
+ scheduledAt: new Date(input.publishAt).toISOString(),
3487
+ acknowledged: acknowledgedWarnings,
3488
+ actor
3489
+ });
3490
+ switch (scheduled.outcome) {
3491
+ case "scheduled": return {
3492
+ outcome: "scheduled",
3493
+ entry: entrySummary(scheduled.view),
3494
+ verdict: verdictView(verdict)
3495
+ };
3496
+ case "conflict": return {
3497
+ outcome: "conflict",
3498
+ conflict: conflictAt(scheduled.currentVersion, scheduled.headRevision)
3499
+ };
3500
+ case "not_found": return { outcome: "not_found" };
3501
+ default: throw unexpectedAnswer(context, "publish", scheduled);
3502
+ }
3503
+ }
3504
+ const event = eventFor({
3505
+ kind: "entry.published",
3506
+ site: context.site,
3507
+ collection,
3508
+ entry: {
3509
+ id: entry.id,
3510
+ collection: entry.collection,
3511
+ locale: entry.locale,
3512
+ slug: entry.slug,
3513
+ translationGroup: entry.translationGroup
3514
+ },
3515
+ at: context.clock.now().toISOString()
3516
+ });
3517
+ const written = await context.store.publish(context.site.key, {
3518
+ event,
3519
+ entryId: entry.id,
3520
+ expectedVersion: input.version,
3521
+ verdict,
3522
+ acknowledged: verdict.warnings.map(({ contract, path }) => ({
3523
+ contract,
3524
+ path
3525
+ })),
3526
+ actor,
3527
+ sortKey: sortKeyOf(collection, processed.value.frontmatter) ?? entry.publishedAt ?? context.clock.now().toISOString()
3528
+ });
3529
+ switch (written.outcome) {
3530
+ case "published":
3531
+ context.notify?.([event.id]);
3532
+ return {
3533
+ outcome: "published",
3534
+ entry: entrySummary(written.view),
3535
+ verdict: verdictView(verdict)
3536
+ };
3537
+ case "conflict": return {
3538
+ outcome: "conflict",
3539
+ conflict: conflictAt(written.currentVersion, written.headRevision)
3540
+ };
3541
+ case "not_found": return { outcome: "not_found" };
3542
+ case "too_large": return {
3543
+ outcome: "blocked",
3544
+ entry: summary,
3545
+ verdict: refusalVerdict("publish", [violationOf(written, collection)])
3546
+ };
3547
+ case "taken": throw unexpectedAnswer(context, "publish", written);
3548
+ }
3549
+ };
3550
+ /** The lookup of the contracts, with the entries of a group seen as published (ADR-024). */
3551
+ function asPublished(lookup, ids) {
3552
+ return {
3553
+ lookupMany: async (refs) => (await lookup.lookupMany(refs)).map((facts) => facts !== null && ids.has(facts.id) ? {
3554
+ ...facts,
3555
+ status: "published"
3556
+ } : facts),
3557
+ translations: (collection, group) => lookup.translations(collection, group)
3558
+ };
3559
+ }
3560
+ /**
3561
+ * Publishes an entry and the entries of its group in one step, all or none (ADR-024). Each entry
3562
+ * is checked as `publish` checks one, except that the contracts see every entry of the group as
3563
+ * published, so links between them resolve. The first entry that stops the group is named in
3564
+ * `failedEntry`. An acknowledgement, by contract and path, holds for every entry that reports
3565
+ * that warning, and each publication records its own warnings.
3566
+ */
3567
+ async function publishGroup(input, context) {
3568
+ if (input.publishAt !== void 0) throw new HollwareError("invalid_input", "A group cannot be scheduled: publishAt schedules one entry.", "Publish the group now, without publishAt, or schedule its entries one by one.");
3569
+ const actor = actorOf(context);
3570
+ const members = [{
3571
+ id: input.id,
3572
+ version: input.version
3573
+ }, ...input.group].filter((member, index, all) => all.findIndex((other) => other.id === member.id) === index);
3574
+ const lookup = asPublished(context.store.lookup(context.site.key), new Set(members.map((member) => member.id)));
3575
+ const checked = [];
3576
+ for (const member of members) {
3577
+ const found = await context.store.findEntryById(context.site.key, member.id, "head");
3578
+ if (found === null) return {
3579
+ outcome: "not_found",
3580
+ failedEntry: member.id
3581
+ };
3582
+ const { entry, revision } = found;
3583
+ if (entry.version !== member.version) return {
3584
+ outcome: "conflict",
3585
+ conflict: conflictAt(entry.version, entry.headRevision),
3586
+ failedEntry: member.id
3587
+ };
3588
+ const collection = collectionOfEntry(context.site, entry);
3589
+ const summary = entrySummary(found);
3590
+ const processed = await processFor(context.site, collection, revision.markdown);
3591
+ if (!processed.ok) return {
3592
+ outcome: "blocked",
3593
+ entry: summary,
3594
+ verdict: refusalVerdict("publish", processed.violations),
3595
+ failedEntry: member.id
3596
+ };
3597
+ const verdict = await judge(context, collection, {
3598
+ locale: entry.locale,
3599
+ slug: entry.slug,
3600
+ entryId: entry.id
3601
+ }, processed.value, "publish", lookup);
3602
+ if (!verdict.ok) return {
3603
+ outcome: "blocked",
3604
+ entry: summary,
3605
+ verdict: verdictView(verdict),
3606
+ failedEntry: member.id
3607
+ };
3608
+ checked.push({
3609
+ summary,
3610
+ collection,
3611
+ verdict,
3612
+ write: {
3613
+ event: eventFor({
3614
+ kind: "entry.published",
3615
+ site: context.site,
3616
+ collection,
3617
+ entry: {
3618
+ id: entry.id,
3619
+ collection: entry.collection,
3620
+ locale: entry.locale,
3621
+ slug: entry.slug,
3622
+ translationGroup: entry.translationGroup
3623
+ },
3624
+ at: context.clock.now().toISOString()
3625
+ }),
3626
+ entryId: entry.id,
3627
+ expectedVersion: member.version,
3628
+ verdict,
3629
+ acknowledged: verdict.warnings.map(({ contract, path }) => ({
3630
+ contract,
3631
+ path
3632
+ })),
3633
+ actor,
3634
+ sortKey: sortKeyOf(collection, processed.value.frontmatter) ?? entry.publishedAt ?? context.clock.now().toISOString()
3635
+ }
3636
+ });
3637
+ }
3638
+ const [main] = checked;
3639
+ if (main === void 0) throw unexpectedAnswer(context, "publish", { outcome: "empty group" });
3640
+ const warnings = new Map(checked.flatMap(({ verdict }) => verdict.warnings).map((warning) => [warningKey(warning), warning]));
3641
+ const acknowledged = new Set(input.acknowledgeWarnings.map(warningKey));
3642
+ if ([...warnings.keys()].some((key) => !acknowledged.has(key))) return {
3643
+ outcome: "needs_acknowledgement",
3644
+ entry: main.summary,
3645
+ verdict: verdictView({
3646
+ ...main.verdict,
3647
+ warnings: [...warnings.values()]
3648
+ })
3649
+ };
3650
+ const written = await context.store.publishGroup(context.site.key, checked.map(({ write }) => write));
3651
+ switch (written.outcome) {
3652
+ case "published": {
3653
+ const [view] = written.views;
3654
+ if (view === void 0) throw unexpectedAnswer(context, "publish", written);
3655
+ context.notify?.(checked.flatMap(({ write }) => write.event ? [write.event.id] : []));
3656
+ return {
3657
+ outcome: "published",
3658
+ entry: entrySummary(view),
3659
+ verdict: verdictView(main.verdict)
3660
+ };
3661
+ }
3662
+ case "conflict": return {
3663
+ outcome: "conflict",
3664
+ conflict: conflictAt(written.currentVersion, written.headRevision),
3665
+ failedEntry: written.entryId
3666
+ };
3667
+ case "not_found": return {
3668
+ outcome: "not_found",
3669
+ failedEntry: written.entryId
3670
+ };
3671
+ case "too_large": {
3672
+ const failed = checked.find(({ write }) => write.entryId === written.entryId) ?? main;
3673
+ return {
3674
+ outcome: "blocked",
3675
+ entry: failed.summary,
3676
+ verdict: refusalVerdict("publish", [violationOf(written, failed.collection)]),
3677
+ failedEntry: written.entryId
3678
+ };
3679
+ }
3680
+ case "taken": throw unexpectedAnswer(context, "publish", written);
3681
+ }
3682
+ }
3683
+ //#endregion
3684
+ //#region src/ops/unpublish.ts
3685
+ /**
3686
+ * Takes an entry off the public site and keeps it, with its revisions, as a draft. The store
3687
+ * leaves an entry that is not published as it is.
3688
+ */
3689
+ const unpublish = async (input, context) => {
3690
+ const actor = actorOf(context);
3691
+ const found = await context.store.findEntryById(context.site.key, input.id, "head");
3692
+ if (found === null) return { outcome: "not_found" };
3693
+ const { entry } = found;
3694
+ const event = eventFor({
3695
+ kind: "entry.unpublished",
3696
+ site: context.site,
3697
+ collection: collectionOfEntry(context.site, entry),
3698
+ entry: {
3699
+ id: entry.id,
3700
+ collection: entry.collection,
3701
+ locale: entry.locale,
3702
+ slug: entry.slug,
3703
+ translationGroup: entry.translationGroup
3704
+ },
3705
+ at: context.clock.now().toISOString()
3706
+ });
3707
+ const written = await context.store.unpublish(context.site.key, {
3708
+ entryId: input.id,
3709
+ actor,
3710
+ event
3711
+ });
3712
+ switch (written.outcome) {
3713
+ case "unpublished":
3714
+ if (entry.status === "published") context.notify?.([event.id]);
3715
+ return {
3716
+ outcome: "unpublished",
3717
+ entry: entrySummary(written.view)
3718
+ };
3719
+ case "not_found": return { outcome: "not_found" };
3720
+ case "conflict":
3721
+ case "taken":
3722
+ case "too_large": throw unexpectedAnswer(context, "unpublish", written);
3723
+ }
3724
+ };
3725
+ //#endregion
3726
+ //#region src/ops/update-draft.ts
3727
+ /**
3728
+ * Saves a new revision of an entry, if it is still at the version the caller read. The version is
3729
+ * checked before anything else, so that a caller that is behind learns where the entry is now and
3730
+ * is not told what its file would have been. The file that the head already holds is `unchanged`:
3731
+ * no revision and no version, which is what makes importing the same files twice a no-op.
3732
+ */
3733
+ const updateDraft = async (input, context) => {
3734
+ const actor = actorOf(context);
3735
+ const found = await context.store.findEntryById(context.site.key, input.id, "head");
3736
+ if (found === null) return { outcome: "not_found" };
3737
+ const { entry, revision } = found;
3738
+ const collection = collectionOfEntry(context.site, entry);
3739
+ if (entry.version !== input.version) return {
3740
+ outcome: "conflict",
3741
+ conflict: conflictAt(entry.version, entry.headRevision)
3742
+ };
3743
+ if (input.markdown === revision.markdown) return {
3744
+ outcome: "unchanged",
3745
+ entry: entrySummary(found),
3746
+ verdict: verdictView(revision.verdict)
3747
+ };
3748
+ const processed = await processFor(context.site, collection, input.markdown);
3749
+ if (!processed.ok) return {
3750
+ outcome: "refused",
3751
+ verdict: refusalVerdict("write", processed.violations)
3752
+ };
3753
+ const verdict = await judge(context, collection, {
3754
+ locale: entry.locale,
3755
+ slug: entry.slug,
3756
+ entryId: entry.id
3757
+ }, processed.value, "write");
3758
+ const written = await context.store.appendRevision(context.site.key, {
3759
+ entryId: entry.id,
3760
+ expectedVersion: input.version,
3761
+ translationGroup: translationGroupOf(collection, processed.value.frontmatter),
3762
+ revision: revisionOf({
3763
+ markdown: input.markdown,
3764
+ processed: processed.value,
3765
+ collection,
3766
+ verdict,
3767
+ actor
3768
+ })
3769
+ });
3770
+ switch (written.outcome) {
3771
+ case "saved": return {
3772
+ outcome: "saved",
3773
+ entry: entrySummary(written.view),
3774
+ verdict: verdictView(verdict)
3775
+ };
3776
+ case "conflict": return {
3777
+ outcome: "conflict",
3778
+ conflict: conflictAt(written.currentVersion, written.headRevision)
3779
+ };
3780
+ case "not_found": return { outcome: "not_found" };
3781
+ case "taken":
3782
+ case "too_large": return {
3783
+ outcome: "refused",
3784
+ verdict: refusalVerdict("write", [violationOf(written, collection)])
3785
+ };
3786
+ }
3787
+ };
3788
+ //#endregion
3789
+ //#region src/ops/validate.ts
3790
+ /** Checks a file as `publish` would, and writes nothing: not even when the entry does not exist. */
3791
+ const validate = async (input, context) => {
3792
+ const collection = requireCollection(context.site, input.collection);
3793
+ requireLocale(context.site, input.locale);
3794
+ const processed = await processFor(context.site, collection, input.markdown);
3795
+ if (!processed.ok) return {
3796
+ outcome: "ok",
3797
+ verdict: refusalVerdict("publish", processed.violations)
3798
+ };
3799
+ const existing = await context.store.findEntry(context.site.key, {
3800
+ collection: input.collection,
3801
+ locale: input.locale,
3802
+ slug: input.slug
3803
+ }, "head");
3804
+ return {
3805
+ outcome: "ok",
3806
+ verdict: verdictView(await judge(context, collection, {
3807
+ locale: input.locale,
3808
+ slug: input.slug,
3809
+ entryId: existing?.entry.id ?? null
3810
+ }, processed.value, "publish"))
3811
+ };
3812
+ };
3813
+ //#endregion
3814
+ //#region src/ops/types.ts
3815
+ /** Declares the handlers; the type checks that they pair with the specs one to one. */
3816
+ function defineHandlers(handlers) {
3817
+ return handlers;
3818
+ }
3819
+ //#endregion
3820
+ //#region src/ops/index.ts
3821
+ /**
3822
+ * The handler of every operation of v0.1, paired with its spec of `@hollware/core` by type
3823
+ * (ADR-017). Adapters check the scopes and build the `OperationContext`; a handler never sees a
3824
+ * request, and reads and writes only the site of its context.
3825
+ */
3826
+ const handlers = defineHandlers({
3827
+ create_draft: createDraft,
3828
+ describe_site: describeSite$1,
3829
+ get_entry: getEntry$1,
3830
+ get_translations: getTranslations$1,
3831
+ list_entries: listEntries$1,
3832
+ publish: publish$1,
3833
+ unpublish,
3834
+ update_draft: updateDraft,
3835
+ validate
3836
+ });
3837
+ //#endregion
1
3838
  //#region src/index.ts
2
3839
  const name = "@hollware/server";
3840
+ /**
3841
+ * Builds the Worker from the config: `const hollware = createHollware(config)`, then
3842
+ * `export default { fetch: hollware.fetch }` and `export class Hollware extends hollware.Hollware {}`.
3843
+ * The config is resolved once, here; a broken one throws its `ConfigError`.
3844
+ */
3845
+ const createHollware = (config) => createHollwareWith(config, handlers);
3
3846
  //#endregion
4
- export { name };
3847
+ export { MAX_BOUND_PARAMETERS, MAX_ROW_BYTES, OPERATION_TIMEOUT_MS, OUTPUT_CAP_BYTES, createHollware, defineHandlers, name };