@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.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.1.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([insertPublishAtVersion(db, row, write.expectedVersion), db.update(entries).set({
1329
- status: "published",
1330
- publishedRev: entries.headRev,
1331
- publishedAt: sql`coalesce(${entries.publishedAt}, ${now})`,
1332
- publishedSortKey: write.sortKey,
1333
- version: sql`${entries.version} + 1`,
1334
- updatedAt: now
1335
- }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))).returning(entryFields)]))[1];
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([insertUnpublishIfPublished(db, row), db.update(entries).set({
1360
- status: "draft",
1361
- publishedRev: null,
1362
- publishedSortKey: null,
1363
- version: sql`${entries.version} + 1`,
1364
- updatedAt: now
1365
- }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.status, "published")))]);
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(c.env.DB, { clock }),
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: createMcpEndpoint({
2998
+ mcp,
2999
+ clock,
3000
+ log: writeLog,
3001
+ read: (env) => createRpc({
3002
+ site,
2231
3003
  handlers,
2232
- serverInfo: SERVER_INFO,
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: context.store.lookup(context.site.key)
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": return {
2673
- outcome: "published",
2674
- entry: entrySummary(written.view),
2675
- verdict: verdictView(verdict)
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": return {
2704
- outcome: "unpublished",
2705
- entry: entrySummary(written.view)
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":