@hollware/server 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +106 -26
- package/dist/index.mjs +1093 -81
- package/migrations/0003_events_and_schedules.sql +57 -0
- package/migrations/meta/0003_snapshot.json +1406 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { HollwareError, OPERATIONS, describeBuiltInContracts, describeResolvedSite, describeSite, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
|
|
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
2
|
import { Hono } from "hono";
|
|
3
3
|
import { drizzle } from "drizzle-orm/d1";
|
|
4
|
-
import { and, desc, eq, getTableColumns, ne, or, sql } from "drizzle-orm";
|
|
4
|
+
import { and, desc, eq, getTableColumns, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
|
|
5
5
|
import { alias, check, foreignKey, index, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
|
6
6
|
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, acceptedContent, createMcpHandler, inputRequired, inputResponse } from "@modelcontextprotocol/server";
|
|
7
7
|
import * as z from "zod";
|
|
8
8
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
9
9
|
//#region package.json
|
|
10
|
-
var version = "0.
|
|
10
|
+
var version = "0.2.0";
|
|
11
11
|
//#endregion
|
|
12
12
|
//#region src/auth/bearer.ts
|
|
13
13
|
/**
|
|
@@ -65,6 +65,17 @@ function unauthorized(reason) {
|
|
|
65
65
|
fix: "Tokens expire and can be revoked. Create a new one with `hollware token create` and send it as \"Authorization: Bearer <token>\"."
|
|
66
66
|
}, `${REALM}, error="invalid_token"`);
|
|
67
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
|
+
}
|
|
68
79
|
//#endregion
|
|
69
80
|
//#region src/auth/guard.ts
|
|
70
81
|
/**
|
|
@@ -628,6 +639,104 @@ const publications = sqliteTable("publications", {
|
|
|
628
639
|
check("publications_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
|
|
629
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}))`)
|
|
630
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
|
+
]);
|
|
631
740
|
//#endregion
|
|
632
741
|
//#region src/db/rows.ts
|
|
633
742
|
/**
|
|
@@ -985,50 +1094,6 @@ function lookup(db, site) {
|
|
|
985
1094
|
};
|
|
986
1095
|
}
|
|
987
1096
|
//#endregion
|
|
988
|
-
//#region src/db/errors.ts
|
|
989
|
-
/**
|
|
990
|
-
* What D1 says when a statement breaks a unique key: `D1_ERROR: UNIQUE constraint failed:
|
|
991
|
-
* entries.site_id, …: SQLITE_CONSTRAINT`, with the same text again in the `cause`. Inside a batch
|
|
992
|
-
* the error arrives as it is, and the batch has rolled back.
|
|
993
|
-
*/
|
|
994
|
-
function isUniqueViolation(error) {
|
|
995
|
-
let current = error;
|
|
996
|
-
for (let depth = 0; depth < 4 && current instanceof Error; depth += 1) {
|
|
997
|
-
if (current.message.includes("UNIQUE constraint failed")) return true;
|
|
998
|
-
current = current.cause;
|
|
999
|
-
}
|
|
1000
|
-
return false;
|
|
1001
|
-
}
|
|
1002
|
-
//#endregion
|
|
1003
|
-
//#region src/db/size.ts
|
|
1004
|
-
/**
|
|
1005
|
-
* D1 refuses rows over 2 MB, and Miniflare does not enforce it, so the store counts the bytes of
|
|
1006
|
-
* a row before writing it and refuses a row over `MAX_ROW_BYTES` (`limits.ts`).
|
|
1007
|
-
*/
|
|
1008
|
-
const encoder$1 = new TextEncoder();
|
|
1009
|
-
/** The bytes of `text` in UTF-8, which is how D1 stores it. */
|
|
1010
|
-
function utf8Bytes(text) {
|
|
1011
|
-
return encoder$1.encode(text).byteLength;
|
|
1012
|
-
}
|
|
1013
|
-
/** What SQLite keeps for an integer, at most. */
|
|
1014
|
-
const INTEGER_BYTES = 8;
|
|
1015
|
-
/** The bytes of every column of a row: text in UTF-8, an integer as 8 bytes, a null as none. */
|
|
1016
|
-
function rowBytes(row) {
|
|
1017
|
-
let bytes = 0;
|
|
1018
|
-
for (const value of Object.values(row)) if (typeof value === "string") bytes += utf8Bytes(value);
|
|
1019
|
-
else if (value !== null) bytes += INTEGER_BYTES;
|
|
1020
|
-
return bytes;
|
|
1021
|
-
}
|
|
1022
|
-
/** The refusal for a row over the limit, or `null` when it fits. */
|
|
1023
|
-
function tooLarge(row) {
|
|
1024
|
-
const bytes = rowBytes(row);
|
|
1025
|
-
return bytes > 19e5 ? {
|
|
1026
|
-
outcome: "too_large",
|
|
1027
|
-
bytes,
|
|
1028
|
-
limit: MAX_ROW_BYTES
|
|
1029
|
-
} : null;
|
|
1030
|
-
}
|
|
1031
|
-
//#endregion
|
|
1032
1097
|
//#region src/db/statements.ts
|
|
1033
1098
|
function actorColumns(actor) {
|
|
1034
1099
|
return {
|
|
@@ -1122,6 +1187,133 @@ function insertUnpublishIfPublished(db, row) {
|
|
|
1122
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"))}`);
|
|
1123
1188
|
}
|
|
1124
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
|
|
1125
1317
|
//#region src/db/writes.ts
|
|
1126
1318
|
/**
|
|
1127
1319
|
* Waits for a write batch. A batch that broke a unique key of `entries` has rolled back, and the
|
|
@@ -1325,14 +1517,20 @@ async function publish$2(db, clock, site, write) {
|
|
|
1325
1517
|
});
|
|
1326
1518
|
const refusal = tooLarge(row);
|
|
1327
1519
|
if (refusal !== null) return refusal;
|
|
1328
|
-
const [changed] = (await db.batch([
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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];
|
|
1336
1534
|
if (changed === void 0) return whyNothingChanged(db, site, write.entryId);
|
|
1337
1535
|
const entry = toEntry(changed);
|
|
1338
1536
|
const revision = entry.publishedRevision === null ? null : await findRevision(db, site, entry.publishedRevision);
|
|
@@ -1345,6 +1543,72 @@ async function publish$2(db, clock, site, write) {
|
|
|
1345
1543
|
}
|
|
1346
1544
|
};
|
|
1347
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
|
+
}
|
|
1348
1612
|
async function unpublish$1(db, clock, site, write) {
|
|
1349
1613
|
const now = clock.now().toISOString();
|
|
1350
1614
|
const row = publicationRow({
|
|
@@ -1356,19 +1620,47 @@ async function unpublish$1(db, clock, site, write) {
|
|
|
1356
1620
|
});
|
|
1357
1621
|
const refusal = tooLarge(row);
|
|
1358
1622
|
if (refusal !== null) return refusal;
|
|
1359
|
-
await db.batch([
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
+
]);
|
|
1366
1636
|
const view = await findEntryById(db, site, write.entryId, "head");
|
|
1367
1637
|
return view === null ? { outcome: "not_found" } : {
|
|
1368
1638
|
outcome: "unpublished",
|
|
1369
1639
|
view
|
|
1370
1640
|
};
|
|
1371
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
|
+
}
|
|
1372
1664
|
//#endregion
|
|
1373
1665
|
//#region src/db/store.ts
|
|
1374
1666
|
const systemClock$1 = { now: () => /* @__PURE__ */ new Date() };
|
|
@@ -1390,10 +1682,216 @@ function createD1Store(db, options = {}) {
|
|
|
1390
1682
|
createEntry: (site, entry) => createEntry(database, clock, site, entry),
|
|
1391
1683
|
appendRevision: (site, write) => appendRevision(database, clock, site, write),
|
|
1392
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),
|
|
1393
1689
|
unpublish: (site, write) => unpublish$1(database, clock, site, write)
|
|
1394
1690
|
};
|
|
1395
1691
|
}
|
|
1396
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
|
|
1397
1895
|
//#region src/mcp/errors.ts
|
|
1398
1896
|
/**
|
|
1399
1897
|
* The error a caller gets for anything that is not a `HollwareError`. The message is fixed: what
|
|
@@ -1485,6 +1983,179 @@ function installErrorHandling(app, log) {
|
|
|
1485
1983
|
}));
|
|
1486
1984
|
}
|
|
1487
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
|
|
1488
2159
|
//#region src/http/gateway.ts
|
|
1489
2160
|
/**
|
|
1490
2161
|
* The HTTP gateway of the Worker (ADR-019): a Hono 5 app, built once, that runs in this order for
|
|
@@ -1546,16 +2217,38 @@ function createGateway(options) {
|
|
|
1546
2217
|
return unauthorized(check.reason);
|
|
1547
2218
|
}
|
|
1548
2219
|
if (check.touch !== null) c.executionCtx.waitUntil(check.touch);
|
|
2220
|
+
const db = c.env.DB;
|
|
1549
2221
|
return await mcp.fetch(c.req.raw, {
|
|
1550
2222
|
principal: check.principal,
|
|
1551
2223
|
requestId,
|
|
1552
2224
|
context: {
|
|
1553
2225
|
site,
|
|
1554
|
-
store: createD1Store(
|
|
1555
|
-
clock
|
|
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
|
+
}
|
|
1556
2243
|
}
|
|
1557
2244
|
});
|
|
1558
2245
|
});
|
|
2246
|
+
if (options.read !== void 0) installReadApi(app, {
|
|
2247
|
+
read: options.read,
|
|
2248
|
+
site: site.key,
|
|
2249
|
+
clock,
|
|
2250
|
+
log
|
|
2251
|
+
});
|
|
1559
2252
|
installErrorHandling(app, log);
|
|
1560
2253
|
return async (request, env, ctx) => await app.fetch(request, env, ctx);
|
|
1561
2254
|
}
|
|
@@ -2189,6 +2882,66 @@ function createEntrypoint(deps) {
|
|
|
2189
2882
|
};
|
|
2190
2883
|
}
|
|
2191
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
|
|
2192
2945
|
//#region src/compose.ts
|
|
2193
2946
|
const systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
2194
2947
|
/** What the MCP endpoint calls the server, in `server/discover` and `initialize`. */
|
|
@@ -2224,17 +2977,55 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
2224
2977
|
const [site] = resolveConfig(config).sites.values();
|
|
2225
2978
|
if (site === void 0) throw new Error("The resolved config has no site, which resolveConfig never lets through");
|
|
2226
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
|
+
});
|
|
2227
2995
|
return {
|
|
2228
2996
|
fetch: createGateway({
|
|
2229
2997
|
site,
|
|
2230
|
-
mcp
|
|
2998
|
+
mcp,
|
|
2999
|
+
clock,
|
|
3000
|
+
log: writeLog,
|
|
3001
|
+
read: (env) => createRpc({
|
|
3002
|
+
site,
|
|
2231
3003
|
handlers,
|
|
2232
|
-
|
|
3004
|
+
clock,
|
|
2233
3005
|
log: writeLog
|
|
2234
|
-
})
|
|
2235
|
-
clock,
|
|
2236
|
-
log: writeLog
|
|
3006
|
+
}, env)
|
|
2237
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
|
+
},
|
|
2238
3029
|
Hollware: createEntrypoint({
|
|
2239
3030
|
site,
|
|
2240
3031
|
handlers,
|
|
@@ -2431,7 +3222,7 @@ function processFor(site, collection, markdown) {
|
|
|
2431
3222
|
* saved under; `publish` is what `validate` and `publish` check, with every contract of the
|
|
2432
3223
|
* collection, against the other entries of the site as they are now.
|
|
2433
3224
|
*/
|
|
2434
|
-
function judge(context, collection, target, markdown, phase) {
|
|
3225
|
+
function judge(context, collection, target, markdown, phase, entries = context.store.lookup(context.site.key)) {
|
|
2435
3226
|
return runContracts({
|
|
2436
3227
|
site: context.site,
|
|
2437
3228
|
collection,
|
|
@@ -2440,7 +3231,7 @@ function judge(context, collection, target, markdown, phase) {
|
|
|
2440
3231
|
entryId: target.entryId,
|
|
2441
3232
|
phase,
|
|
2442
3233
|
markdown,
|
|
2443
|
-
entries
|
|
3234
|
+
entries
|
|
2444
3235
|
});
|
|
2445
3236
|
}
|
|
2446
3237
|
/**
|
|
@@ -2612,6 +3403,31 @@ const listEntries$1 = async (input, context) => {
|
|
|
2612
3403
|
};
|
|
2613
3404
|
};
|
|
2614
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
|
|
2615
3431
|
//#region src/ops/publish.ts
|
|
2616
3432
|
/** A warning by what an acknowledgement names: its contract and its path, never its line. */
|
|
2617
3433
|
function warningKey(warning) {
|
|
@@ -2625,6 +3441,7 @@ function warningKey(warning) {
|
|
|
2625
3441
|
* verdict as acknowledged (ADR-021).
|
|
2626
3442
|
*/
|
|
2627
3443
|
const publish$1 = async (input, context) => {
|
|
3444
|
+
if (input.group.length > 0) return publishGroup(input, context);
|
|
2628
3445
|
const actor = actorOf(context);
|
|
2629
3446
|
const found = await context.store.findEntryById(context.site.key, input.id, "head");
|
|
2630
3447
|
if (found === null) return { outcome: "not_found" };
|
|
@@ -2657,7 +3474,48 @@ const publish$1 = async (input, context) => {
|
|
|
2657
3474
|
entry: summary,
|
|
2658
3475
|
verdict: verdictView(verdict)
|
|
2659
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
|
+
});
|
|
2660
3517
|
const written = await context.store.publish(context.site.key, {
|
|
3518
|
+
event,
|
|
2661
3519
|
entryId: entry.id,
|
|
2662
3520
|
expectedVersion: input.version,
|
|
2663
3521
|
verdict,
|
|
@@ -2669,11 +3527,13 @@ const publish$1 = async (input, context) => {
|
|
|
2669
3527
|
sortKey: sortKeyOf(collection, processed.value.frontmatter) ?? entry.publishedAt ?? context.clock.now().toISOString()
|
|
2670
3528
|
});
|
|
2671
3529
|
switch (written.outcome) {
|
|
2672
|
-
case "published":
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
3530
|
+
case "published":
|
|
3531
|
+
context.notify?.([event.id]);
|
|
3532
|
+
return {
|
|
3533
|
+
outcome: "published",
|
|
3534
|
+
entry: entrySummary(written.view),
|
|
3535
|
+
verdict: verdictView(verdict)
|
|
3536
|
+
};
|
|
2677
3537
|
case "conflict": return {
|
|
2678
3538
|
outcome: "conflict",
|
|
2679
3539
|
conflict: conflictAt(written.currentVersion, written.headRevision)
|
|
@@ -2687,6 +3547,139 @@ const publish$1 = async (input, context) => {
|
|
|
2687
3547
|
case "taken": throw unexpectedAnswer(context, "publish", written);
|
|
2688
3548
|
}
|
|
2689
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
|
+
}
|
|
2690
3683
|
//#endregion
|
|
2691
3684
|
//#region src/ops/unpublish.ts
|
|
2692
3685
|
/**
|
|
@@ -2695,15 +3688,34 @@ const publish$1 = async (input, context) => {
|
|
|
2695
3688
|
*/
|
|
2696
3689
|
const unpublish = async (input, context) => {
|
|
2697
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
|
+
});
|
|
2698
3707
|
const written = await context.store.unpublish(context.site.key, {
|
|
2699
3708
|
entryId: input.id,
|
|
2700
|
-
actor
|
|
3709
|
+
actor,
|
|
3710
|
+
event
|
|
2701
3711
|
});
|
|
2702
3712
|
switch (written.outcome) {
|
|
2703
|
-
case "unpublished":
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
3713
|
+
case "unpublished":
|
|
3714
|
+
if (entry.status === "published") context.notify?.([event.id]);
|
|
3715
|
+
return {
|
|
3716
|
+
outcome: "unpublished",
|
|
3717
|
+
entry: entrySummary(written.view)
|
|
3718
|
+
};
|
|
2707
3719
|
case "not_found": return { outcome: "not_found" };
|
|
2708
3720
|
case "conflict":
|
|
2709
3721
|
case "taken":
|