@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/README.md +3 -3
- package/dist/index.d.mts +387 -1
- package/dist/index.mjs +3844 -1
- package/migrations/0001_core.sql +132 -0
- package/migrations/0002_append_only.sql +70 -0
- package/migrations/0003_events_and_schedules.sql +57 -0
- package/migrations/meta/0000_snapshot.json +42 -0
- package/migrations/meta/0001_snapshot.json +992 -0
- package/migrations/meta/0002_snapshot.json +992 -0
- package/migrations/meta/0003_snapshot.json +1406 -0
- package/migrations/meta/_journal.json +34 -0
- package/package.json +21 -4
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 };
|