@hollware/server 0.4.0 → 0.6.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/{ceremonies-V_1BbmZQ.mjs → ceremonies-6mZ3u32c.mjs} +1 -1
- package/dist/index.d.mts +172 -2
- package/dist/index.mjs +1019 -100
- package/dist/{store-CsuER6J7.mjs → store-CYMjP0iN.mjs} +75 -1
- package/migrations/0008_search.sql +47 -0
- package/migrations/0009_embeddings.sql +19 -0
- package/migrations/0010_review_comments.sql +17 -0
- package/migrations/meta/0008_snapshot.json +2110 -0
- package/migrations/meta/0009_snapshot.json +2257 -0
- package/migrations/meta/0010_snapshot.json +2388 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +3 -2
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
import { EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, describeBuiltInContracts, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isReadToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
|
|
1
|
+
import { A as users, C as reviewComments, D as schedules, E as revisions, M as effectiveScopes, N as isRole, O as sites, S as publications, T as revisionTags, _ as entries, b as members, g as deliveries, j as ROLE_SCOPES, k as tokens, l as membershipsOf, n as createInvitation, s as findSession, v as entryEmbeddings, w as reviews, x as outbox, y as invitations } from "./store-CYMjP0iN.mjs";
|
|
2
|
+
import { CHUNKER_VERSION, EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, chunksOf, describeBuiltInContracts, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, search, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { drizzle } from "drizzle-orm/d1";
|
|
5
|
-
import { and, desc, eq, getTableColumns, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
|
|
5
|
+
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
|
|
6
6
|
import { alias } from "drizzle-orm/sqlite-core";
|
|
7
7
|
import { AuthorizationError, CimdFetchError, OAuthAuthorizationServer, OAuthResourceServer, authorizationErrorRedirect } from "@cloudflare/workers-oauth-provider";
|
|
8
8
|
import { getCookie } from "hono/cookie";
|
|
@@ -10,7 +10,7 @@ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, accepted
|
|
|
10
10
|
import * as z from "zod";
|
|
11
11
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
12
12
|
//#region package.json
|
|
13
|
-
var version = "0.
|
|
13
|
+
var version = "0.6.0";
|
|
14
14
|
//#endregion
|
|
15
15
|
//#region src/auth/responses.ts
|
|
16
16
|
/** The challenge of every 401 and 403 (RFC 6750). */
|
|
@@ -632,6 +632,61 @@ function listEntriesQuery(db, site, query, cursor) {
|
|
|
632
632
|
cursor === null ? void 0 : sql`(${sortKey}, ${entries.id}) < (${cursor.key}, ${cursor.id})`
|
|
633
633
|
]).orderBy(desc(sortKey), desc(entries.id)).limit(query.limit + 1);
|
|
634
634
|
}
|
|
635
|
+
/**
|
|
636
|
+
* The FTS5 query of what someone typed: each of its words as a quoted string, so that FTS5's own
|
|
637
|
+
* syntax (`AND`, `NEAR`, `*`, quotes) is never read from it, and every word must match. Empty when
|
|
638
|
+
* it holds no word.
|
|
639
|
+
*/
|
|
640
|
+
function ftsQuery(text) {
|
|
641
|
+
return (text.match(/[\p{L}\p{N}_]+/gu) ?? []).map((word) => `"${word}"`).join(" ");
|
|
642
|
+
}
|
|
643
|
+
/** The weights of the columns of `entries_fts` in bm25: title, tags, then the text. */
|
|
644
|
+
const BM25 = sql.raw("bm25(entries_fts, 10.0, 4.0, 1.0)");
|
|
645
|
+
async function searchEntries(db, site, query) {
|
|
646
|
+
const match = ftsQuery(query.text);
|
|
647
|
+
if (match === "") return [];
|
|
648
|
+
const hits = await db.all(sql`
|
|
649
|
+
select entry_id, snippet(entries_fts, 2, '[', ']', '…', 16) as snippet, ${BM25} as rank
|
|
650
|
+
from entries_fts
|
|
651
|
+
where entries_fts match ${match} and site_id = ${site}
|
|
652
|
+
${query.collection === void 0 ? sql`` : sql`and collection = ${query.collection}`}
|
|
653
|
+
${query.locale === void 0 ? sql`` : sql`and locale = ${query.locale}`}
|
|
654
|
+
order by rank
|
|
655
|
+
limit ${query.limit}
|
|
656
|
+
`);
|
|
657
|
+
if (hits.length === 0) return [];
|
|
658
|
+
const rows = await summaryQuery(db, "published", [eq(entries.siteId, site), inArray(entries.id, hits.map((hit) => hit.entry_id))]);
|
|
659
|
+
const byId = new Map(rows.map((row) => [row.entry.id, toEntrySummaryView(row)]));
|
|
660
|
+
return hits.flatMap((hit) => {
|
|
661
|
+
const entry = byId.get(hit.entry_id);
|
|
662
|
+
return entry === void 0 ? [] : [{
|
|
663
|
+
entry,
|
|
664
|
+
snippet: hit.snippet,
|
|
665
|
+
score: -hit.rank
|
|
666
|
+
}];
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
/** The entries of those ids that the view has, in the order of the ids; the others are left out. */
|
|
670
|
+
async function findSummaries(db, site, ids, view) {
|
|
671
|
+
if (ids.length === 0) return [];
|
|
672
|
+
const rows = await summaryQuery(db, view, [eq(entries.siteId, site), inArray(entries.id, [...ids])]);
|
|
673
|
+
const byId = new Map(rows.map((row) => [row.entry.id, toEntrySummaryView(row)]));
|
|
674
|
+
return ids.flatMap((id) => {
|
|
675
|
+
const entry = byId.get(id);
|
|
676
|
+
return entry === void 0 ? [] : [entry];
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
/** The entries closest to one, closest first, as its vectors found them (ADR-030); `[]` without. */
|
|
680
|
+
async function relatedOf(db, site, entryId) {
|
|
681
|
+
const [row] = await db.select({ related: entryEmbeddings.related }).from(entryEmbeddings).where(and(eq(entryEmbeddings.siteId, site), eq(entryEmbeddings.entryId, entryId))).limit(1);
|
|
682
|
+
if (row === void 0) return [];
|
|
683
|
+
const parsed = JSON.parse(row.related);
|
|
684
|
+
if (!Array.isArray(parsed)) return [];
|
|
685
|
+
return parsed.flatMap((item) => typeof item === "object" && item !== null && "id" in item && "score" in item && typeof item.id === "string" && typeof item.score === "number" ? [{
|
|
686
|
+
id: item.id,
|
|
687
|
+
score: item.score
|
|
688
|
+
}] : []);
|
|
689
|
+
}
|
|
635
690
|
async function listEntries$2(db, site, query) {
|
|
636
691
|
if (!Number.isInteger(query.limit) || query.limit < 1) throw new HollwareError("invalid_input", `The limit ${query.limit} is not a whole number of at least 1.`, "Ask for at least one entry per page.");
|
|
637
692
|
const cursor = query.cursor === void 0 ? null : decodeCursor(query.cursor, query.view);
|
|
@@ -986,7 +1041,7 @@ async function pendingOf(db, site, entryId) {
|
|
|
986
1041
|
const [row] = await db.select(COLUMNS).from(reviews).innerJoin(users, eq(users.id, reviews.requestedBy)).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"))).limit(1);
|
|
987
1042
|
return row === void 0 ? null : toReview(row);
|
|
988
1043
|
}
|
|
989
|
-
async function requestReview(db, clock, site, write, whyNothingChanged) {
|
|
1044
|
+
async function requestReview$1(db, clock, site, write, whyNothingChanged) {
|
|
990
1045
|
const now = clock.now().toISOString();
|
|
991
1046
|
const row = {
|
|
992
1047
|
id: newId("rvw"),
|
|
@@ -1048,6 +1103,59 @@ async function requestChanges$1(db, clock, site, write) {
|
|
|
1048
1103
|
review
|
|
1049
1104
|
};
|
|
1050
1105
|
}
|
|
1106
|
+
function metaOf(text) {
|
|
1107
|
+
if (text === null) return null;
|
|
1108
|
+
const parsed = JSON.parse(text);
|
|
1109
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? Object.fromEntries(Object.entries(parsed)) : null;
|
|
1110
|
+
}
|
|
1111
|
+
const COMMENT_COLUMNS = {
|
|
1112
|
+
...getTableColumns(reviewComments),
|
|
1113
|
+
authorName: users.name
|
|
1114
|
+
};
|
|
1115
|
+
function toComment(row) {
|
|
1116
|
+
return {
|
|
1117
|
+
id: row.id,
|
|
1118
|
+
reviewId: row.reviewId,
|
|
1119
|
+
authorId: row.authorId,
|
|
1120
|
+
authorName: row.authorName,
|
|
1121
|
+
authorMeta: metaOf(row.authorMeta),
|
|
1122
|
+
body: row.body,
|
|
1123
|
+
createdAt: row.createdAt
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Adds a comment to a review in one statement that inserts only while the review is pending, so a
|
|
1128
|
+
* decision that lands first leaves no comment behind it.
|
|
1129
|
+
*/
|
|
1130
|
+
async function commentReview$1(db, clock, site, write) {
|
|
1131
|
+
const id = newId("rvc");
|
|
1132
|
+
const now = clock.now().toISOString();
|
|
1133
|
+
const meta = write.actor.meta === null ? null : JSON.stringify(write.actor.meta);
|
|
1134
|
+
if ((await db.all(sql`
|
|
1135
|
+
insert into ${reviewComments}
|
|
1136
|
+
(id, site_id, review_id, author_id, author_meta, body, created_at, updated_at)
|
|
1137
|
+
select ${id}, ${site}, ${write.reviewId}, ${write.actor.userId}, ${meta}, ${write.body}, ${now}, ${now}
|
|
1138
|
+
where exists (select 1 from ${reviews} where ${and(eq(reviews.id, write.reviewId), eq(reviews.siteId, site), eq(reviews.status, "pending"))})
|
|
1139
|
+
returning id
|
|
1140
|
+
`)).length === 0) return { outcome: await findReview(db, site, write.reviewId) === null ? "not_found" : "not_pending" };
|
|
1141
|
+
const [row] = await db.select(COMMENT_COLUMNS).from(reviewComments).innerJoin(users, eq(users.id, reviewComments.authorId)).where(and(eq(reviewComments.siteId, site), eq(reviewComments.id, id))).limit(1);
|
|
1142
|
+
if (row === void 0) throw new Error(`The comment ${id} that was just written is not there`);
|
|
1143
|
+
return {
|
|
1144
|
+
outcome: "commented",
|
|
1145
|
+
comment: toComment(row)
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
async function reviewCommentsOf(db, site, reviewIds) {
|
|
1149
|
+
const byReview = /* @__PURE__ */ new Map();
|
|
1150
|
+
if (reviewIds.length === 0) return byReview;
|
|
1151
|
+
const rows = await db.select(COMMENT_COLUMNS).from(reviewComments).innerJoin(users, eq(users.id, reviewComments.authorId)).where(and(eq(reviewComments.siteId, site), inArray(reviewComments.reviewId, [...reviewIds]))).orderBy(asc(reviewComments.createdAt), asc(reviewComments.id));
|
|
1152
|
+
for (const row of rows) {
|
|
1153
|
+
const list = byReview.get(row.reviewId) ?? [];
|
|
1154
|
+
list.push(toComment(row));
|
|
1155
|
+
byReview.set(row.reviewId, list);
|
|
1156
|
+
}
|
|
1157
|
+
return byReview;
|
|
1158
|
+
}
|
|
1051
1159
|
//#endregion
|
|
1052
1160
|
//#region src/db/size.ts
|
|
1053
1161
|
/**
|
|
@@ -1442,6 +1550,9 @@ function createD1Store(db, options = {}) {
|
|
|
1442
1550
|
findEntry: (site, ref, view) => findEntry(database, site, ref, view),
|
|
1443
1551
|
findEntryById: (site, id, view) => findEntryById(database, site, id, view),
|
|
1444
1552
|
listEntries: (site, query) => listEntries$2(database, site, query),
|
|
1553
|
+
searchEntries: (site, query) => searchEntries(database, site, query),
|
|
1554
|
+
findSummaries: (site, ids, view) => findSummaries(database, site, ids, view),
|
|
1555
|
+
relatedOf: (site, entryId) => relatedOf(database, site, entryId),
|
|
1445
1556
|
listTranslations: (site, collection, translationGroup, view) => listTranslations(database, site, collection, translationGroup, view),
|
|
1446
1557
|
lookup: (site) => lookup(database, site),
|
|
1447
1558
|
lastPublication: (site, entryId) => lastPublication(database, site, entryId),
|
|
@@ -1453,10 +1564,12 @@ function createD1Store(db, options = {}) {
|
|
|
1453
1564
|
dueSchedules: (site, now) => dueSchedules(database, site, now),
|
|
1454
1565
|
cancelSchedule: (site, entryId) => cancelSchedule(database, clock, site, entryId),
|
|
1455
1566
|
unpublish: (site, write) => unpublish$1(database, clock, site, write),
|
|
1456
|
-
requestReview: (site, write) => requestReview(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
|
|
1567
|
+
requestReview: (site, write) => requestReview$1(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
|
|
1457
1568
|
findReview: (site, id) => findReview(database, site, id),
|
|
1458
1569
|
listReviews: (site, query) => listReviews$1(database, site, query),
|
|
1459
|
-
requestChanges: (site, write) => requestChanges$1(database, clock, site, write)
|
|
1570
|
+
requestChanges: (site, write) => requestChanges$1(database, clock, site, write),
|
|
1571
|
+
commentReview: (site, write) => commentReview$1(database, clock, site, write),
|
|
1572
|
+
reviewComments: (site, reviewIds) => reviewCommentsOf(database, site, reviewIds)
|
|
1460
1573
|
};
|
|
1461
1574
|
}
|
|
1462
1575
|
//#endregion
|
|
@@ -1515,6 +1628,191 @@ function sqlIncrement() {
|
|
|
1515
1628
|
return sql`${deliveries.attempts} + 1`;
|
|
1516
1629
|
}
|
|
1517
1630
|
//#endregion
|
|
1631
|
+
//#region src/semantic/index-entry.ts
|
|
1632
|
+
/** The id of a piece's vector. */
|
|
1633
|
+
function vectorId(entryId, piece) {
|
|
1634
|
+
return `${entryId}:${piece}`;
|
|
1635
|
+
}
|
|
1636
|
+
function excerptOf(text) {
|
|
1637
|
+
if (text.length <= 200) return text;
|
|
1638
|
+
const cut = text.slice(0, 200);
|
|
1639
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
1640
|
+
return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trim()}…`;
|
|
1641
|
+
}
|
|
1642
|
+
function stringOf(value) {
|
|
1643
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
1644
|
+
}
|
|
1645
|
+
/** The ids of the vectors of `pieces` pieces of an entry, from `from` on. */
|
|
1646
|
+
function idsOf(entryId, from, pieces) {
|
|
1647
|
+
return Array.from({ length: Math.max(0, pieces - from) }, (_, offset) => vectorId(entryId, from + offset));
|
|
1648
|
+
}
|
|
1649
|
+
/** The entries nearest to an entry's summary, in its collection and locale; itself left out. */
|
|
1650
|
+
async function nearestTo(semantic, input) {
|
|
1651
|
+
return (await semantic.index.query(input.summary, {
|
|
1652
|
+
topK: 6,
|
|
1653
|
+
site: input.site,
|
|
1654
|
+
collection: input.collection,
|
|
1655
|
+
locale: input.locale,
|
|
1656
|
+
summariesOnly: true
|
|
1657
|
+
})).filter((match) => match.metadata.entry !== input.entryId).slice(0, 5).map((match) => ({
|
|
1658
|
+
id: match.metadata.entry,
|
|
1659
|
+
score: match.score
|
|
1660
|
+
}));
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Brings the vectors of one entry in line with what it has published: the pieces of its published
|
|
1664
|
+
* revision, embedded with the model of `semantic`. It reads the entry as it is now, so it is right
|
|
1665
|
+
* whichever event called it and however late: an entry whose vectors already are of its published
|
|
1666
|
+
* revision, model and chunker is `current` and costs no embedding; an entry with nothing published
|
|
1667
|
+
* (unpublished, deleted, gone) loses its vectors and its row.
|
|
1668
|
+
*/
|
|
1669
|
+
async function syncEntryVectors(db, semantic, input) {
|
|
1670
|
+
const { site, entryId, now } = input;
|
|
1671
|
+
const [published] = await db.select({
|
|
1672
|
+
collection: entries.collection,
|
|
1673
|
+
locale: entries.locale,
|
|
1674
|
+
revisionId: revisions.id,
|
|
1675
|
+
frontmatter: revisions.frontmatter,
|
|
1676
|
+
document: revisions.document
|
|
1677
|
+
}).from(entries).innerJoin(revisions, and(eq(revisions.id, entries.publishedRev), eq(revisions.siteId, entries.siteId))).where(and(eq(entries.siteId, site), eq(entries.id, entryId))).limit(1);
|
|
1678
|
+
const [stored] = await db.select({
|
|
1679
|
+
revisionId: entryEmbeddings.revisionId,
|
|
1680
|
+
model: entryEmbeddings.model,
|
|
1681
|
+
chunkerVersion: entryEmbeddings.chunkerVersion,
|
|
1682
|
+
pieces: entryEmbeddings.pieces
|
|
1683
|
+
}).from(entryEmbeddings).where(and(eq(entryEmbeddings.siteId, site), eq(entryEmbeddings.entryId, entryId))).limit(1);
|
|
1684
|
+
if (published === void 0) {
|
|
1685
|
+
if (stored === void 0) return "absent";
|
|
1686
|
+
await semantic.index.remove(idsOf(entryId, 0, stored.pieces));
|
|
1687
|
+
await db.delete(entryEmbeddings).where(and(eq(entryEmbeddings.siteId, site), eq(entryEmbeddings.entryId, entryId)));
|
|
1688
|
+
return "removed";
|
|
1689
|
+
}
|
|
1690
|
+
if (stored !== void 0 && stored.revisionId === published.revisionId && stored.model === semantic.embedder.model && stored.chunkerVersion === CHUNKER_VERSION) return "current";
|
|
1691
|
+
const frontmatter = JSON.parse(published.frontmatter);
|
|
1692
|
+
const document = JSON.parse(published.document);
|
|
1693
|
+
const description = stringOf(frontmatter["description"]);
|
|
1694
|
+
const chunks = chunksOf({
|
|
1695
|
+
title: stringOf(frontmatter["title"]) ?? "",
|
|
1696
|
+
...description === void 0 ? {} : { description },
|
|
1697
|
+
document
|
|
1698
|
+
});
|
|
1699
|
+
const vectors = await semantic.embedder.embed(chunks.map((chunk) => chunk.text));
|
|
1700
|
+
await semantic.index.upsert(chunks.map((chunk) => ({
|
|
1701
|
+
id: vectorId(entryId, chunk.index),
|
|
1702
|
+
values: vectors[chunk.index] ?? [],
|
|
1703
|
+
metadata: {
|
|
1704
|
+
site,
|
|
1705
|
+
entry: entryId,
|
|
1706
|
+
collection: published.collection,
|
|
1707
|
+
locale: published.locale,
|
|
1708
|
+
piece: chunk.index,
|
|
1709
|
+
excerpt: excerptOf(chunk.text)
|
|
1710
|
+
}
|
|
1711
|
+
})));
|
|
1712
|
+
if (stored !== void 0) await semantic.index.remove(idsOf(entryId, chunks.length, stored.pieces));
|
|
1713
|
+
const related = await nearestTo(semantic, {
|
|
1714
|
+
site,
|
|
1715
|
+
entryId,
|
|
1716
|
+
collection: published.collection,
|
|
1717
|
+
locale: published.locale,
|
|
1718
|
+
summary: vectors[0] ?? []
|
|
1719
|
+
});
|
|
1720
|
+
const row = {
|
|
1721
|
+
related: JSON.stringify(related),
|
|
1722
|
+
revisionId: published.revisionId,
|
|
1723
|
+
model: semantic.embedder.model,
|
|
1724
|
+
chunkerVersion: CHUNKER_VERSION,
|
|
1725
|
+
pieces: chunks.length,
|
|
1726
|
+
updatedAt: now
|
|
1727
|
+
};
|
|
1728
|
+
await db.insert(entryEmbeddings).values({
|
|
1729
|
+
id: newId("emb"),
|
|
1730
|
+
siteId: site,
|
|
1731
|
+
entryId,
|
|
1732
|
+
createdAt: now,
|
|
1733
|
+
...row
|
|
1734
|
+
}).onConflictDoUpdate({
|
|
1735
|
+
target: [entryEmbeddings.siteId, entryEmbeddings.entryId],
|
|
1736
|
+
set: row
|
|
1737
|
+
});
|
|
1738
|
+
return "indexed";
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* The entries whose vectors are not those of what they publish: published with no vectors (before
|
|
1742
|
+
* the Worker had semantic search, or an event that never reached the subscriber), of another
|
|
1743
|
+
* revision, model or chunker; and entries with vectors and nothing published. At most `limit`.
|
|
1744
|
+
*/
|
|
1745
|
+
async function staleEntries(db, input) {
|
|
1746
|
+
const { site, model, limit } = input;
|
|
1747
|
+
const published = await db.select({ id: entries.id }).from(entries).leftJoin(entryEmbeddings, and(eq(entryEmbeddings.entryId, entries.id), eq(entryEmbeddings.siteId, entries.siteId))).where(and(eq(entries.siteId, site), isNotNull(entries.publishedRev), or(isNull(entryEmbeddings.id), ne(entryEmbeddings.revisionId, entries.publishedRev), ne(entryEmbeddings.model, model), ne(entryEmbeddings.chunkerVersion, CHUNKER_VERSION)))).limit(limit);
|
|
1748
|
+
const left = limit - published.length;
|
|
1749
|
+
const orphans = left <= 0 ? [] : await db.select({ id: entryEmbeddings.entryId }).from(entryEmbeddings).innerJoin(entries, and(eq(entries.id, entryEmbeddings.entryId), eq(entries.siteId, entryEmbeddings.siteId))).where(and(eq(entryEmbeddings.siteId, site), isNull(entries.publishedRev))).limit(left);
|
|
1750
|
+
return [...published, ...orphans].map((row) => row.id);
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* What the cron trigger does for the vectors of a site: brings at most `limit` stale entries in
|
|
1754
|
+
* line, so that a site that turns semantic search on, or changes its model, is indexed a few
|
|
1755
|
+
* entries a minute without a command. An entry that fails is left for the next run.
|
|
1756
|
+
*/
|
|
1757
|
+
async function healVectors(db, semantic, input) {
|
|
1758
|
+
const ids = await staleEntries(db, {
|
|
1759
|
+
site: input.site,
|
|
1760
|
+
model: semantic.embedder.model,
|
|
1761
|
+
limit: input.limit ?? 20
|
|
1762
|
+
});
|
|
1763
|
+
let failed = 0;
|
|
1764
|
+
for (const entryId of ids) await syncEntryVectors(db, semantic, {
|
|
1765
|
+
site: input.site,
|
|
1766
|
+
entryId,
|
|
1767
|
+
now: input.now
|
|
1768
|
+
}).catch(() => {
|
|
1769
|
+
failed += 1;
|
|
1770
|
+
});
|
|
1771
|
+
return {
|
|
1772
|
+
synced: ids.length - failed,
|
|
1773
|
+
failed
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
/** How long an entry with no related entries waits before they are looked for again. */
|
|
1777
|
+
const RELATED_RETRY_MS = 6e5;
|
|
1778
|
+
/** How old the related entries of an entry may grow before they are looked for again. */
|
|
1779
|
+
const RELATED_REFRESH_MS = 864e5;
|
|
1780
|
+
/**
|
|
1781
|
+
* What the cron trigger does for related entries: it finds them again for at most `limit`
|
|
1782
|
+
* entries of the site, oldest first, whose related entries are a day old, or empty for ten
|
|
1783
|
+
* minutes. Vectorize shows a vector seconds after it was written, so entries indexed together
|
|
1784
|
+
* miss each other at first; and an entry published later belongs among the related entries of
|
|
1785
|
+
* those before it. Each keeps its vectors: only its summary is read back and compared.
|
|
1786
|
+
*/
|
|
1787
|
+
async function refreshRelated(db, semantic, input) {
|
|
1788
|
+
const now = Date.parse(input.now);
|
|
1789
|
+
const retryBefore = (/* @__PURE__ */ new Date(now - RELATED_RETRY_MS)).toISOString();
|
|
1790
|
+
const refreshBefore = (/* @__PURE__ */ new Date(now - RELATED_REFRESH_MS)).toISOString();
|
|
1791
|
+
const rows = await db.select({
|
|
1792
|
+
entryId: entryEmbeddings.entryId,
|
|
1793
|
+
collection: entries.collection,
|
|
1794
|
+
locale: entries.locale
|
|
1795
|
+
}).from(entryEmbeddings).innerJoin(entries, and(eq(entries.id, entryEmbeddings.entryId), eq(entries.siteId, entryEmbeddings.siteId))).where(and(eq(entryEmbeddings.siteId, input.site), isNotNull(entries.publishedRev), or(and(eq(entryEmbeddings.related, "[]"), lte(entryEmbeddings.updatedAt, retryBefore)), lte(entryEmbeddings.updatedAt, refreshBefore)))).orderBy(entryEmbeddings.updatedAt).limit(input.limit ?? 10);
|
|
1796
|
+
if (rows.length === 0) return 0;
|
|
1797
|
+
const summaries = new Map((await semantic.index.get(rows.map((row) => vectorId(row.entryId, 0)))).map((vector) => [vector.id, vector.values]));
|
|
1798
|
+
let refreshed = 0;
|
|
1799
|
+
for (const row of rows) {
|
|
1800
|
+
const summary = summaries.get(vectorId(row.entryId, 0));
|
|
1801
|
+
if (summary === void 0) continue;
|
|
1802
|
+
const related = await nearestTo(semantic, {
|
|
1803
|
+
site: input.site,
|
|
1804
|
+
...row,
|
|
1805
|
+
summary
|
|
1806
|
+
});
|
|
1807
|
+
await db.update(entryEmbeddings).set({
|
|
1808
|
+
related: JSON.stringify(related),
|
|
1809
|
+
updatedAt: input.now
|
|
1810
|
+
}).where(and(eq(entryEmbeddings.siteId, input.site), eq(entryEmbeddings.entryId, row.entryId)));
|
|
1811
|
+
refreshed += 1;
|
|
1812
|
+
}
|
|
1813
|
+
return refreshed;
|
|
1814
|
+
}
|
|
1815
|
+
//#endregion
|
|
1518
1816
|
//#region src/events/deliver.ts
|
|
1519
1817
|
/** Where IndexNow takes the URLs that changed. */
|
|
1520
1818
|
const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
|
|
@@ -1566,6 +1864,17 @@ function subscribersOf(deps) {
|
|
|
1566
1864
|
}
|
|
1567
1865
|
});
|
|
1568
1866
|
}
|
|
1867
|
+
const semantic = deps.semantic;
|
|
1868
|
+
if (semantic !== void 0 && semantic !== null) subscribers.push({
|
|
1869
|
+
name: "vectors",
|
|
1870
|
+
async send(payload) {
|
|
1871
|
+
await syncEntryVectors(deps.db, semantic, {
|
|
1872
|
+
site: site.key,
|
|
1873
|
+
entryId: payload.entry.id,
|
|
1874
|
+
now: deps.clock.now().toISOString()
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
});
|
|
1569
1878
|
return subscribers;
|
|
1570
1879
|
}
|
|
1571
1880
|
/**
|
|
@@ -2040,6 +2349,208 @@ function installAdminApi(app, options) {
|
|
|
2040
2349
|
}));
|
|
2041
2350
|
}
|
|
2042
2351
|
//#endregion
|
|
2352
|
+
//#region src/semantic/cloudflare.ts
|
|
2353
|
+
/** The embedding model when the config names none: multilingual, so Spanish finds English. */
|
|
2354
|
+
const DEFAULT_EMBEDDING_MODEL = "@cf/baai/bge-m3";
|
|
2355
|
+
function isRecord$3(value) {
|
|
2356
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2357
|
+
}
|
|
2358
|
+
/** Workers AI as an embedder: `{ data: number[][] }` for a batch of texts. */
|
|
2359
|
+
function workersAiEmbedder(ai, model) {
|
|
2360
|
+
return {
|
|
2361
|
+
model,
|
|
2362
|
+
async embed(texts) {
|
|
2363
|
+
if (texts.length === 0) return [];
|
|
2364
|
+
const answer = await ai.run(model, { text: [...texts] });
|
|
2365
|
+
const data = isRecord$3(answer) ? answer["data"] : void 0;
|
|
2366
|
+
if (!Array.isArray(data) || data.length !== texts.length) throw new Error(`${model} answered no vector for each of ${texts.length} texts`);
|
|
2367
|
+
return data.map((vector) => {
|
|
2368
|
+
if (!Array.isArray(vector)) throw new Error(`${model} answered a vector that is no list`);
|
|
2369
|
+
return vector.map(Number);
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
function metadataOf(raw) {
|
|
2375
|
+
if (raw === void 0) return null;
|
|
2376
|
+
const { site, entry, collection, locale, piece, excerpt } = raw;
|
|
2377
|
+
if (typeof excerpt !== "string" || typeof site !== "string" || typeof entry !== "string" || typeof collection !== "string" || typeof locale !== "string" || typeof piece !== "number") return null;
|
|
2378
|
+
return {
|
|
2379
|
+
site,
|
|
2380
|
+
entry,
|
|
2381
|
+
collection,
|
|
2382
|
+
locale,
|
|
2383
|
+
piece,
|
|
2384
|
+
excerpt
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
/** Vectorize as the index. Its metadata indexes on site, collection, locale and piece filter. */
|
|
2388
|
+
function vectorizeIndex(binding) {
|
|
2389
|
+
return {
|
|
2390
|
+
async upsert(vectors) {
|
|
2391
|
+
if (vectors.length === 0) return;
|
|
2392
|
+
await binding.upsert(vectors.map((vector) => ({
|
|
2393
|
+
id: vector.id,
|
|
2394
|
+
values: [...vector.values],
|
|
2395
|
+
metadata: { ...vector.metadata }
|
|
2396
|
+
})));
|
|
2397
|
+
},
|
|
2398
|
+
async remove(ids) {
|
|
2399
|
+
if (ids.length > 0) await binding.deleteByIds([...ids]);
|
|
2400
|
+
},
|
|
2401
|
+
async get(ids) {
|
|
2402
|
+
if (ids.length === 0) return [];
|
|
2403
|
+
return (await binding.getByIds([...ids])).map((vector) => ({
|
|
2404
|
+
id: vector.id,
|
|
2405
|
+
values: Array.from(vector.values)
|
|
2406
|
+
}));
|
|
2407
|
+
},
|
|
2408
|
+
async query(values, options) {
|
|
2409
|
+
return (await binding.query([...values], {
|
|
2410
|
+
topK: options.topK,
|
|
2411
|
+
returnMetadata: "all",
|
|
2412
|
+
filter: {
|
|
2413
|
+
site: options.site,
|
|
2414
|
+
...options.collection === void 0 ? {} : { collection: options.collection },
|
|
2415
|
+
...options.locale === void 0 ? {} : { locale: options.locale },
|
|
2416
|
+
...options.summariesOnly === true ? { piece: 0 } : {}
|
|
2417
|
+
}
|
|
2418
|
+
})).matches.flatMap((match) => {
|
|
2419
|
+
const metadata = metadataOf(match.metadata);
|
|
2420
|
+
return metadata === null ? [] : [{
|
|
2421
|
+
id: match.id,
|
|
2422
|
+
score: match.score,
|
|
2423
|
+
metadata
|
|
2424
|
+
}];
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
/** Semantic search over the Worker's bindings, or `null` when it does not have both. */
|
|
2430
|
+
function semanticOf(env, model) {
|
|
2431
|
+
const ai = Reflect.get(env, "AI");
|
|
2432
|
+
const vectorize = Reflect.get(env, "VECTORIZE");
|
|
2433
|
+
if (!isRecord$3(ai) || typeof ai["run"] !== "function") return null;
|
|
2434
|
+
if (!isRecord$3(vectorize) || typeof vectorize["query"] !== "function") return null;
|
|
2435
|
+
return {
|
|
2436
|
+
embedder: workersAiEmbedder(ai, model),
|
|
2437
|
+
index: vectorizeIndex(vectorize)
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
/**
|
|
2441
|
+
* Semantic search over the Worker's bindings, or `null` without them. The model is the variable
|
|
2442
|
+
* `HOLLWARE_EMBEDDING_MODEL`, or `DEFAULT_EMBEDDING_MODEL`; the Vectorize index must have been
|
|
2443
|
+
* made for its dimensions, so a new model needs a new index.
|
|
2444
|
+
*/
|
|
2445
|
+
function semanticOfEnv(env) {
|
|
2446
|
+
const model = Reflect.get(env, "HOLLWARE_EMBEDDING_MODEL");
|
|
2447
|
+
return semanticOf(env, typeof model === "string" && model !== "" ? model : DEFAULT_EMBEDDING_MODEL);
|
|
2448
|
+
}
|
|
2449
|
+
//#endregion
|
|
2450
|
+
//#region src/analytics/cloudflare.ts
|
|
2451
|
+
/** The dataset of Analytics Engine when the Worker names none. */
|
|
2452
|
+
const DEFAULT_ANALYTICS_DATASET = "hollware_page_views";
|
|
2453
|
+
/** Where the SQL API of Analytics Engine answers. */
|
|
2454
|
+
function sqlApiUrl(accountId) {
|
|
2455
|
+
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`;
|
|
2456
|
+
}
|
|
2457
|
+
function isRecord$2(value) {
|
|
2458
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* Analytics Engine as the writer: one data point per view, indexed by the site (its sampling key),
|
|
2462
|
+
* with the path and the country as blobs and a count of 1.
|
|
2463
|
+
*/
|
|
2464
|
+
function analyticsEngineWriter(binding) {
|
|
2465
|
+
return { record(view) {
|
|
2466
|
+
binding.writeDataPoint({
|
|
2467
|
+
indexes: [view.site],
|
|
2468
|
+
blobs: [view.path, view.country],
|
|
2469
|
+
doubles: [1]
|
|
2470
|
+
});
|
|
2471
|
+
} };
|
|
2472
|
+
}
|
|
2473
|
+
/**
|
|
2474
|
+
* The query of the most read paths. The SQL API takes no parameters, so every value in it is
|
|
2475
|
+
* checked first: a site key (`[a-z0-9-]`), a dataset name and two whole numbers.
|
|
2476
|
+
*/
|
|
2477
|
+
function topPagesSql(dataset, site, query) {
|
|
2478
|
+
if (!/^[a-z_][a-z0-9_]{0,63}$/i.test(dataset)) throw new Error(`No dataset is named ${dataset}`);
|
|
2479
|
+
if (!isSiteKey(site)) throw new Error(`No site key is ${String(site)}`);
|
|
2480
|
+
if (!Number.isInteger(query.days) || query.days < 1 || query.days > 90) throw new Error(`${query.days} days is not a whole number from 1 to 90`);
|
|
2481
|
+
if (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 50) throw new Error(`${query.limit} pages is not a whole number from 1 to 50`);
|
|
2482
|
+
return [
|
|
2483
|
+
"SELECT blob1 AS path, SUM(_sample_interval) AS views",
|
|
2484
|
+
`FROM ${dataset}`,
|
|
2485
|
+
`WHERE index1 = '${site}' AND timestamp > NOW() - INTERVAL '${query.days}' DAY`,
|
|
2486
|
+
"GROUP BY path",
|
|
2487
|
+
"ORDER BY views DESC",
|
|
2488
|
+
`LIMIT ${query.limit}`,
|
|
2489
|
+
"FORMAT JSON"
|
|
2490
|
+
].join(" ");
|
|
2491
|
+
}
|
|
2492
|
+
/** The SQL API of Analytics Engine as the reader, its answers kept for ten minutes. */
|
|
2493
|
+
function sqlApiReader(options) {
|
|
2494
|
+
return { async topPages(site, query) {
|
|
2495
|
+
const sql = topPagesSql(options.dataset, site, query);
|
|
2496
|
+
const key = new Request(`https://hollware.invalid/content-stats/${options.dataset}/${site}?days=${query.days}&limit=${query.limit}`);
|
|
2497
|
+
const cached = await options.cache?.match(key);
|
|
2498
|
+
const answer = cached ?? await options.fetch(new Request(sqlApiUrl(options.accountId), {
|
|
2499
|
+
method: "POST",
|
|
2500
|
+
headers: { authorization: `Bearer ${options.token}` },
|
|
2501
|
+
body: sql
|
|
2502
|
+
}));
|
|
2503
|
+
if (!answer.ok) throw new Error(`The SQL API of Analytics Engine answered ${answer.status}`);
|
|
2504
|
+
const text = await answer.text();
|
|
2505
|
+
if (cached === void 0 && options.cache !== null) await options.cache.put(key, new Response(text, { headers: { "cache-control": `max-age=600` } }));
|
|
2506
|
+
const body = JSON.parse(text);
|
|
2507
|
+
return (isRecord$2(body) && Array.isArray(body["data"]) ? body["data"] : []).flatMap((row) => {
|
|
2508
|
+
if (!isRecord$2(row) || typeof row["path"] !== "string") return [];
|
|
2509
|
+
const views = Number(row["views"]);
|
|
2510
|
+
return Number.isFinite(views) ? [{
|
|
2511
|
+
path: row["path"],
|
|
2512
|
+
views
|
|
2513
|
+
}] : [];
|
|
2514
|
+
});
|
|
2515
|
+
} };
|
|
2516
|
+
}
|
|
2517
|
+
/** The writer over the Worker's `ANALYTICS` binding, or `null` without one. */
|
|
2518
|
+
function analyticsWriterOf(env) {
|
|
2519
|
+
const binding = Reflect.get(env, "ANALYTICS");
|
|
2520
|
+
if (!isRecord$2(binding) || typeof binding["writeDataPoint"] !== "function") return null;
|
|
2521
|
+
return analyticsEngineWriter(binding);
|
|
2522
|
+
}
|
|
2523
|
+
/**
|
|
2524
|
+
* The reader over the Worker's variables: `HOLLWARE_ANALYTICS_ACCOUNT_ID` and the secret
|
|
2525
|
+
* `HOLLWARE_ANALYTICS_TOKEN` (an API token with Account Analytics Read), and the dataset of
|
|
2526
|
+
* `HOLLWARE_ANALYTICS_DATASET` or the default one. `null` without the account or the token.
|
|
2527
|
+
*/
|
|
2528
|
+
function analyticsReaderOf(env) {
|
|
2529
|
+
const accountId = Reflect.get(env, "HOLLWARE_ANALYTICS_ACCOUNT_ID");
|
|
2530
|
+
const token = Reflect.get(env, "HOLLWARE_ANALYTICS_TOKEN");
|
|
2531
|
+
const dataset = Reflect.get(env, "HOLLWARE_ANALYTICS_DATASET");
|
|
2532
|
+
if (typeof accountId !== "string" || accountId === "") return null;
|
|
2533
|
+
if (typeof token !== "string" || token === "") return null;
|
|
2534
|
+
return sqlApiReader({
|
|
2535
|
+
accountId,
|
|
2536
|
+
token,
|
|
2537
|
+
dataset: typeof dataset === "string" && dataset !== "" ? dataset : DEFAULT_ANALYTICS_DATASET,
|
|
2538
|
+
fetch: (request) => fetch(request),
|
|
2539
|
+
cache: typeof caches === "undefined" ? null : caches.default
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
//#endregion
|
|
2543
|
+
//#region src/ops/invoke.ts
|
|
2544
|
+
/**
|
|
2545
|
+
* Calls the handler of an operation. This is the one place where the typed pairing of specs and
|
|
2546
|
+
* handlers meets a dynamic caller (MCP, the ops API): the caller parsed `input` with the input
|
|
2547
|
+
* schema of `spec`, which is the schema that types the handler of the same name.
|
|
2548
|
+
*/
|
|
2549
|
+
function invoke(handlers, spec, input, context) {
|
|
2550
|
+
const handler = handlers[spec.name];
|
|
2551
|
+
return handler(input, context);
|
|
2552
|
+
}
|
|
2553
|
+
//#endregion
|
|
2043
2554
|
//#region src/http/ops-api.ts
|
|
2044
2555
|
const STATUS$1 = {
|
|
2045
2556
|
invalid_input: 400,
|
|
@@ -2054,10 +2565,6 @@ function json(body, status = 200) {
|
|
|
2054
2565
|
});
|
|
2055
2566
|
}
|
|
2056
2567
|
/** Calls the handler of an operation with input its spec has parsed. */
|
|
2057
|
-
function invoke$1(handlers, spec, input, context) {
|
|
2058
|
-
const handler = handlers[spec.name];
|
|
2059
|
-
return handler(input, context);
|
|
2060
|
-
}
|
|
2061
2568
|
function installOpsApi(app, options) {
|
|
2062
2569
|
app.post("/v1/ops/:name", async (c) => options.protect(c, async (principal) => {
|
|
2063
2570
|
const requestId = c.get("requestId");
|
|
@@ -2089,10 +2596,12 @@ function installOpsApi(app, options) {
|
|
|
2089
2596
|
clock: options.clock,
|
|
2090
2597
|
client: { name: "hollware-admin" },
|
|
2091
2598
|
requestId,
|
|
2092
|
-
notify: options.notify(c, requestId, site.key)
|
|
2599
|
+
notify: options.notify(c, requestId, site.key),
|
|
2600
|
+
semantic: semanticOfEnv(c.env),
|
|
2601
|
+
analytics: analyticsReaderOf(c.env)
|
|
2093
2602
|
};
|
|
2094
2603
|
try {
|
|
2095
|
-
return json(spec.output.parse(await invoke
|
|
2604
|
+
return json(spec.output.parse(await invoke(options.handlers, spec, parsed.data, context)));
|
|
2096
2605
|
} catch (error) {
|
|
2097
2606
|
if (error instanceof HollwareError) return json({
|
|
2098
2607
|
code: error.code,
|
|
@@ -2129,6 +2638,31 @@ const STATUS = {
|
|
|
2129
2638
|
unknown_collection: 404,
|
|
2130
2639
|
unknown_locale: 404
|
|
2131
2640
|
};
|
|
2641
|
+
/**
|
|
2642
|
+
* A route of the read API whose `HollwareError` (an unknown collection, a limit out of range) is
|
|
2643
|
+
* the caller's to read, with its status. It is caught here, in the route: Hono 5 hands an error
|
|
2644
|
+
* that a route throws to the app's error handler before any middleware around it sees it, and
|
|
2645
|
+
* that handler answers every error with the same 500. The answer says how long the read took on
|
|
2646
|
+
* the server, after the token was checked (`Server-Timing: read;dur=<ms>`), so that a client can
|
|
2647
|
+
* tell the server's time from the network's.
|
|
2648
|
+
*/
|
|
2649
|
+
function readRoute(handler) {
|
|
2650
|
+
return async (c) => {
|
|
2651
|
+
const started = Date.now();
|
|
2652
|
+
try {
|
|
2653
|
+
const response = await handler(c);
|
|
2654
|
+
response.headers.set("server-timing", `read;dur=${Date.now() - started}`);
|
|
2655
|
+
return response;
|
|
2656
|
+
} catch (error) {
|
|
2657
|
+
if (!(error instanceof HollwareError)) throw error;
|
|
2658
|
+
return c.json({
|
|
2659
|
+
code: error.code,
|
|
2660
|
+
message: error.message,
|
|
2661
|
+
fix: error.fix
|
|
2662
|
+
}, STATUS[error.code] ?? 500);
|
|
2663
|
+
}
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2132
2666
|
/** A weak ETag of a body: the first 128 bits of its SHA-256, in hex. */
|
|
2133
2667
|
async function etagOf(body) {
|
|
2134
2668
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)));
|
|
@@ -2194,19 +2728,8 @@ function installReadApi(app, options) {
|
|
|
2194
2728
|
vary: "Origin",
|
|
2195
2729
|
"access-control-allow-origin": new URL(c.env.HOLLWARE_PUBLIC_URL).origin
|
|
2196
2730
|
});
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
await next();
|
|
2200
|
-
return c.res;
|
|
2201
|
-
} catch (error) {
|
|
2202
|
-
if (!(error instanceof HollwareError)) throw error;
|
|
2203
|
-
return c.json({
|
|
2204
|
-
code: error.code,
|
|
2205
|
-
message: error.message,
|
|
2206
|
-
fix: error.fix
|
|
2207
|
-
}, STATUS[error.code] ?? 500);
|
|
2208
|
-
}
|
|
2209
|
-
})();
|
|
2731
|
+
await next();
|
|
2732
|
+
const answered = c.res;
|
|
2210
2733
|
if (crossOrigin === null) return answered;
|
|
2211
2734
|
const shared = new Response(answered.body, answered);
|
|
2212
2735
|
shared.headers.set("access-control-allow-origin", crossOrigin);
|
|
@@ -2217,8 +2740,18 @@ function installReadApi(app, options) {
|
|
|
2217
2740
|
return c.res;
|
|
2218
2741
|
});
|
|
2219
2742
|
app.get("/v1/openapi.json", (c) => c.json(openApiDocument()));
|
|
2220
|
-
app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(c.get("site"))));
|
|
2221
|
-
app.get("/v1
|
|
2743
|
+
app.get("/v1/site", readRoute(async (c) => answer(c, await options.read(c.env).describeSite(c.get("site")))));
|
|
2744
|
+
app.get("/v1/search", readRoute(async (c) => {
|
|
2745
|
+
const query = c.req.query();
|
|
2746
|
+
const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
|
|
2747
|
+
return answer(c, await options.read(c.env).search(c.get("site"), query["q"] ?? "", {
|
|
2748
|
+
...query["collection"] === void 0 ? {} : { collection: query["collection"] },
|
|
2749
|
+
...query["locale"] === void 0 ? {} : { locale: query["locale"] },
|
|
2750
|
+
...limit === void 0 ? {} : { limit },
|
|
2751
|
+
...query["mode"] === void 0 ? {} : { mode: searchModeOf(query["mode"]) }
|
|
2752
|
+
}));
|
|
2753
|
+
}));
|
|
2754
|
+
app.get("/v1/:collection", readRoute(async (c) => {
|
|
2222
2755
|
const query = c.req.query();
|
|
2223
2756
|
const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
|
|
2224
2757
|
return answer(c, await options.read(c.env).listEntries(c.get("site"), c.req.param("collection"), {
|
|
@@ -2227,12 +2760,27 @@ function installReadApi(app, options) {
|
|
|
2227
2760
|
...limit === void 0 ? {} : { limit },
|
|
2228
2761
|
...query["cursor"] === void 0 ? {} : { cursor: query["cursor"] }
|
|
2229
2762
|
}));
|
|
2230
|
-
});
|
|
2231
|
-
app.get("/v1/:collection/translations/:group", async (c) => answer(c, await options.read(c.env).getTranslations(c.get("site"), c.req.param("collection"), c.req.param("group"))));
|
|
2232
|
-
app.get("/v1/:collection/:locale/:slug", async (c) => {
|
|
2763
|
+
}));
|
|
2764
|
+
app.get("/v1/:collection/translations/:group", readRoute(async (c) => answer(c, await options.read(c.env).getTranslations(c.get("site"), c.req.param("collection"), c.req.param("group")))));
|
|
2765
|
+
app.get("/v1/:collection/:locale/:slug", readRoute(async (c) => {
|
|
2233
2766
|
const formats = c.req.query("formats");
|
|
2234
|
-
|
|
2235
|
-
|
|
2767
|
+
const related = c.req.query("related") === "true" ? { related: true } : {};
|
|
2768
|
+
return answer(c, await options.read(c.env).getEntry(c.get("site"), c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? related : {
|
|
2769
|
+
...related,
|
|
2770
|
+
formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : [])
|
|
2771
|
+
}));
|
|
2772
|
+
}));
|
|
2773
|
+
}
|
|
2774
|
+
const SEARCH_MODES = [
|
|
2775
|
+
"fulltext",
|
|
2776
|
+
"semantic",
|
|
2777
|
+
"hybrid"
|
|
2778
|
+
];
|
|
2779
|
+
/** The `mode` of a search from the query string, or the error that names the ones there are. */
|
|
2780
|
+
function searchModeOf(value) {
|
|
2781
|
+
const mode = SEARCH_MODES.find((known) => known === value);
|
|
2782
|
+
if (mode === void 0) throw new HollwareError("invalid_input", `The search mode ${value} is not one Hollware knows.`, `Pass mode as one of ${SEARCH_MODES.join(", ")}, or leave it out for hybrid.`);
|
|
2783
|
+
return mode;
|
|
2236
2784
|
}
|
|
2237
2785
|
/** The OpenAPI 3.1 document of the read API. */
|
|
2238
2786
|
function openApiDocument() {
|
|
@@ -2271,6 +2819,20 @@ function openApiDocument() {
|
|
|
2271
2819
|
...common
|
|
2272
2820
|
}
|
|
2273
2821
|
} },
|
|
2822
|
+
"/v1/search": { get: {
|
|
2823
|
+
operationId: "search",
|
|
2824
|
+
parameters: [
|
|
2825
|
+
param("q", "query", true),
|
|
2826
|
+
param("collection", "query", false),
|
|
2827
|
+
param("locale", "query", false),
|
|
2828
|
+
param("limit", "query", false),
|
|
2829
|
+
param("mode", "query", false)
|
|
2830
|
+
],
|
|
2831
|
+
responses: {
|
|
2832
|
+
"200": ok("The published entries that match q, best first"),
|
|
2833
|
+
...common
|
|
2834
|
+
}
|
|
2835
|
+
} },
|
|
2274
2836
|
"/v1/{collection}": { get: {
|
|
2275
2837
|
operationId: "list_entries",
|
|
2276
2838
|
parameters: [
|
|
@@ -2300,7 +2862,8 @@ function openApiDocument() {
|
|
|
2300
2862
|
param("collection", "path", true),
|
|
2301
2863
|
param("locale", "path", true),
|
|
2302
2864
|
param("slug", "path", true),
|
|
2303
|
-
param("formats", "query", false)
|
|
2865
|
+
param("formats", "query", false),
|
|
2866
|
+
param("related", "query", false)
|
|
2304
2867
|
],
|
|
2305
2868
|
responses: {
|
|
2306
2869
|
"200": ok("A published entry"),
|
|
@@ -2495,7 +3058,7 @@ function plainPageHeaders() {
|
|
|
2495
3058
|
* The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
|
|
2496
3059
|
* routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
|
|
2497
3060
|
*/
|
|
2498
|
-
const ceremonies = () => import("./ceremonies-
|
|
3061
|
+
const ceremonies = () => import("./ceremonies-6mZ3u32c.mjs");
|
|
2499
3062
|
/** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
|
|
2500
3063
|
const SESSION_COOKIE = "hollware_session";
|
|
2501
3064
|
function sessionCookie(secret) {
|
|
@@ -2888,6 +3451,66 @@ function build(issuer, options) {
|
|
|
2888
3451
|
};
|
|
2889
3452
|
}
|
|
2890
3453
|
//#endregion
|
|
3454
|
+
//#region src/http/events.ts
|
|
3455
|
+
/** The largest beacon the route reads: a site key and a path, with room to spare. */
|
|
3456
|
+
const MAX_BEACON_BYTES = 2048;
|
|
3457
|
+
/** The origins whose pages may count views of a site: its own URL and its `cors.origins`. */
|
|
3458
|
+
function pageOriginsOf(site) {
|
|
3459
|
+
return [...site.url === null ? [] : [new URL(site.url).origin], ...site.cors.origins];
|
|
3460
|
+
}
|
|
3461
|
+
function refusal(status, code, message, fix) {
|
|
3462
|
+
return Response.json({
|
|
3463
|
+
code,
|
|
3464
|
+
message,
|
|
3465
|
+
fix
|
|
3466
|
+
}, {
|
|
3467
|
+
status,
|
|
3468
|
+
headers: { "cache-control": "no-store" }
|
|
3469
|
+
});
|
|
3470
|
+
}
|
|
3471
|
+
/** The path of a view without its query and fragment, or `null` when it is no path. */
|
|
3472
|
+
function pathOf(value) {
|
|
3473
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return null;
|
|
3474
|
+
const { pathname } = new URL(value, "https://hollware.invalid");
|
|
3475
|
+
return pathname.length > 512 ? null : pathname;
|
|
3476
|
+
}
|
|
3477
|
+
/**
|
|
3478
|
+
* `POST /events`, the beacon of the sites (ADR-031): `{ "site": "<key>", "path": "/<path>" }`, sent
|
|
3479
|
+
* by a page with `navigator.sendBeacon`, so with no token. It counts a view of the path when the
|
|
3480
|
+
* page is of one of the site's origins (its URL or its `cors.origins`), and keeps the site, the
|
|
3481
|
+
* path and the country Cloudflare gives the request: never a cookie, an IP or a user agent. It
|
|
3482
|
+
* answers 204 whether or not the Worker has an `ANALYTICS` binding, so that a page cannot tell.
|
|
3483
|
+
*/
|
|
3484
|
+
function installEvents(app, options) {
|
|
3485
|
+
app.post("/events", async (c) => {
|
|
3486
|
+
const text = await c.req.text();
|
|
3487
|
+
if (new TextEncoder().encode(text).byteLength > 2048) return refusal(413, "too_large", `A beacon is at most ${MAX_BEACON_BYTES} bytes.`, "Send the site and the path of the page, and nothing else.");
|
|
3488
|
+
let body;
|
|
3489
|
+
try {
|
|
3490
|
+
body = JSON.parse(text);
|
|
3491
|
+
} catch {
|
|
3492
|
+
body = null;
|
|
3493
|
+
}
|
|
3494
|
+
const key = typeof body === "object" && body !== null && "site" in body ? body.site : null;
|
|
3495
|
+
const site = typeof key === "string" ? options.sites.get(key) : void 0;
|
|
3496
|
+
const path = typeof body === "object" && body !== null && "path" in body ? pathOf(body.path) : null;
|
|
3497
|
+
if (site === void 0 || path === null) return refusal(400, "invalid_input", "A beacon is JSON with the key of a site this Hollware serves and the path of a page.", "Send { \"site\": \"<site key>\", \"path\": \"/<path of the page>\" }.");
|
|
3498
|
+
const origin = c.req.header("origin") ?? null;
|
|
3499
|
+
if (origin === null || !pageOriginsOf(site).includes(origin)) return refusal(403, "forbidden_origin", `Only pages of the site "${site.key}" count its views.`, `Set url of the site in hollware.config.ts to the origin of its pages, or add the origin to cors.origins.`);
|
|
3500
|
+
const cf = Reflect.get(c.req.raw, "cf");
|
|
3501
|
+
const country = typeof cf === "object" && cf !== null && "country" in cf && typeof cf.country === "string" ? cf.country : "XX";
|
|
3502
|
+
analyticsWriterOf(c.env)?.record({
|
|
3503
|
+
site: site.key,
|
|
3504
|
+
path,
|
|
3505
|
+
country
|
|
3506
|
+
});
|
|
3507
|
+
return new Response(null, {
|
|
3508
|
+
status: 204,
|
|
3509
|
+
headers: { "cache-control": "no-store" }
|
|
3510
|
+
});
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
//#endregion
|
|
2891
3514
|
//#region src/http/gateway.ts
|
|
2892
3515
|
/**
|
|
2893
3516
|
* A 401 of a resource server, with Hollware's body: the same JSON as every other refusal, and the
|
|
@@ -3006,6 +3629,7 @@ function createGateway(options) {
|
|
|
3006
3629
|
*/
|
|
3007
3630
|
const readsFromAnyCorsOrigin = (request, origin) => {
|
|
3008
3631
|
const { pathname } = new URL(request.url);
|
|
3632
|
+
if (request.method === "POST" && pathname === "/events") return true;
|
|
3009
3633
|
return corsOrigins.has(origin) && [
|
|
3010
3634
|
"GET",
|
|
3011
3635
|
"HEAD",
|
|
@@ -3047,7 +3671,9 @@ function createGateway(options) {
|
|
|
3047
3671
|
site,
|
|
3048
3672
|
store: createD1Store(db, { clock }),
|
|
3049
3673
|
clock,
|
|
3050
|
-
notify: notify(c, requestId, site.key)
|
|
3674
|
+
notify: notify(c, requestId, site.key),
|
|
3675
|
+
semantic: semanticOfEnv(c.env),
|
|
3676
|
+
analytics: analyticsReaderOf(c.env)
|
|
3051
3677
|
}
|
|
3052
3678
|
});
|
|
3053
3679
|
}));
|
|
@@ -3090,6 +3716,7 @@ function createGateway(options) {
|
|
|
3090
3716
|
protect: (c, proceed) => protect(c, "v1", proceed),
|
|
3091
3717
|
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
|
|
3092
3718
|
});
|
|
3719
|
+
installEvents(app, { sites });
|
|
3093
3720
|
installErrorHandling(app, log);
|
|
3094
3721
|
return async (request, env, ctx) => {
|
|
3095
3722
|
contexts.set(request, ctx);
|
|
@@ -3278,6 +3905,8 @@ const encoder = new TextEncoder();
|
|
|
3278
3905
|
*/
|
|
3279
3906
|
const OUTPUT_TOO_LARGE_FIX = {
|
|
3280
3907
|
approve_review: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
|
|
3908
|
+
comment_review: "The operation ran and its comment may have been added: read the review with list_reviews before you repeat it.",
|
|
3909
|
+
content_stats: "Ask for less: pass a smaller limit.",
|
|
3281
3910
|
create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3282
3911
|
describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
|
|
3283
3912
|
get_entry: "Ask for one format at a time with formats: markdown, document or html, instead of several together.",
|
|
@@ -3286,6 +3915,8 @@ const OUTPUT_TOO_LARGE_FIX = {
|
|
|
3286
3915
|
list_reviews: "Ask for less: pass a smaller limit.",
|
|
3287
3916
|
publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3288
3917
|
request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
|
|
3918
|
+
request_review: "The operation ran and its review may have been asked: read it with list_reviews before you repeat it.",
|
|
3919
|
+
search: "Ask for less: pass a smaller limit.",
|
|
3289
3920
|
unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3290
3921
|
update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3291
3922
|
validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
|
|
@@ -3426,6 +4057,33 @@ function createToolTable(operations = OPERATIONS) {
|
|
|
3426
4057
|
});
|
|
3427
4058
|
}
|
|
3428
4059
|
//#endregion
|
|
4060
|
+
//#region src/mcp/prompts.ts
|
|
4061
|
+
/**
|
|
4062
|
+
* The prompts of the MCP endpoint: workflows that an agent's user picks by name, written once
|
|
4063
|
+
* here. A prompt only says what to do; every step is a tool, checked as any call is.
|
|
4064
|
+
*/
|
|
4065
|
+
const REFRESH_CONTENT = {
|
|
4066
|
+
name: "refresh_content",
|
|
4067
|
+
title: "Refresh what readers read",
|
|
4068
|
+
description: "Pick a much-read entry whose content has aged, find what the site already says on its subject, and update it through the contracts, ready to publish.",
|
|
4069
|
+
args: z.object({ days: z.string().regex(/^[1-9][0-9]?$/).optional().describe("How many days of reading to look at, 1 to 90; 30 when not given.") })
|
|
4070
|
+
};
|
|
4071
|
+
/** The text of `refresh_content`: the steps, each with the tool that takes it. */
|
|
4072
|
+
function refreshContentText(days) {
|
|
4073
|
+
return [
|
|
4074
|
+
`Refresh the content that readers read most, from the last ${days} days.`,
|
|
4075
|
+
"",
|
|
4076
|
+
`1. Call content_stats with days: ${days}. Pick an entry from the pages with the most views: prefer one whose updatedAt is oldest, or whose subject has moved on. Skip pages with no entry.`,
|
|
4077
|
+
"2. Read it with get_entry, with related: true, and read describe_site for its collection: its schema, contracts and style guide.",
|
|
4078
|
+
"3. Call search with the subject of the entry in your own words (mode hybrid) to find what the site already says, so that the update links to it instead of repeating it.",
|
|
4079
|
+
"4. Write the update: correct what is out of date, add what is missing, link the related entries where they help. Keep the voice of the style guide.",
|
|
4080
|
+
"5. Save it with update_draft at the version you read. Fix every error of the verdict, and the warnings you can, saving again; validate checks a version without saving it.",
|
|
4081
|
+
"6. Call publish. Without content:publish it asks for a review: say so, with the link it answers. With warnings left, say which and why before you acknowledge them.",
|
|
4082
|
+
"",
|
|
4083
|
+
"Report the entry you picked and why, what you changed, and how the publish ended."
|
|
4084
|
+
].join("\n");
|
|
4085
|
+
}
|
|
4086
|
+
//#endregion
|
|
3429
4087
|
//#region src/mcp/endpoint.ts
|
|
3430
4088
|
/**
|
|
3431
4089
|
* `tools/list` is the same for every client and changes only when the Worker is deployed again:
|
|
@@ -3453,15 +4111,6 @@ function authInfoOf(principal, resourceMetadataUrl) {
|
|
|
3453
4111
|
};
|
|
3454
4112
|
}
|
|
3455
4113
|
/**
|
|
3456
|
-
* Calls the handler of an operation. This is the one place where the typed pairing of specs and
|
|
3457
|
-
* handlers meets the dynamic protocol: the SDK parsed `input` with the input schema of `spec`,
|
|
3458
|
-
* which is the schema that types the handler of the same name.
|
|
3459
|
-
*/
|
|
3460
|
-
function invoke(handlers, spec, input, context) {
|
|
3461
|
-
const handler = handlers[spec.name];
|
|
3462
|
-
return handler(input, context);
|
|
3463
|
-
}
|
|
3464
|
-
/**
|
|
3465
4114
|
* The MCP endpoint of Hollware: one tool for each operation, served in both protocol eras, for
|
|
3466
4115
|
* the gateway to mount at `/mcp` after it has checked the Host, the Origin and the token.
|
|
3467
4116
|
*
|
|
@@ -3547,7 +4196,10 @@ function createMcpEndpoint(options) {
|
|
|
3547
4196
|
const call = request.authInfo === void 0 ? void 0 : calls.get(request.authInfo);
|
|
3548
4197
|
if (call === void 0) throw new Error("A request reached the MCP server without the call the gateway made for it");
|
|
3549
4198
|
const server = new McpServer(options.serverInfo, {
|
|
3550
|
-
capabilities: {
|
|
4199
|
+
capabilities: {
|
|
4200
|
+
tools: { listChanged: false },
|
|
4201
|
+
prompts: { listChanged: false }
|
|
4202
|
+
},
|
|
3551
4203
|
cacheHints: { "tools/list": TOOLS_LIST_CACHE_HINT }
|
|
3552
4204
|
});
|
|
3553
4205
|
for (const tool of tools) server.registerTool(tool.spec.name, {
|
|
@@ -3567,6 +4219,17 @@ function createMcpEndpoint(options) {
|
|
|
3567
4219
|
detail: null
|
|
3568
4220
|
}))
|
|
3569
4221
|
}, (input, mcp) => runTool(tool.spec, input, mcp, call, request.era));
|
|
4222
|
+
server.registerPrompt(REFRESH_CONTENT.name, {
|
|
4223
|
+
title: REFRESH_CONTENT.title,
|
|
4224
|
+
description: REFRESH_CONTENT.description,
|
|
4225
|
+
argsSchema: REFRESH_CONTENT.args
|
|
4226
|
+
}, ({ days }) => ({ messages: [{
|
|
4227
|
+
role: "user",
|
|
4228
|
+
content: {
|
|
4229
|
+
type: "text",
|
|
4230
|
+
text: refreshContentText(days === void 0 ? 30 : Number(days))
|
|
4231
|
+
}
|
|
4232
|
+
}] }));
|
|
3570
4233
|
return server;
|
|
3571
4234
|
};
|
|
3572
4235
|
const handler = createMcpHandler(buildServer, { onerror: (error) => emit({
|
|
@@ -3666,7 +4329,8 @@ async function run(deps, env, method, site, schema, build, handler) {
|
|
|
3666
4329
|
store: createD1Store(env.DB.withSession("first-unconstrained")),
|
|
3667
4330
|
clock: deps.clock,
|
|
3668
4331
|
client: null,
|
|
3669
|
-
requestId
|
|
4332
|
+
requestId,
|
|
4333
|
+
semantic: semanticOfEnv(env)
|
|
3670
4334
|
};
|
|
3671
4335
|
try {
|
|
3672
4336
|
return await handler(input, context);
|
|
@@ -3686,7 +4350,7 @@ function createRpc(deps, env) {
|
|
|
3686
4350
|
collection,
|
|
3687
4351
|
locale,
|
|
3688
4352
|
slug,
|
|
3689
|
-
...readOptions(options, ["formats"])
|
|
4353
|
+
...readOptions(options, ["formats", "related"])
|
|
3690
4354
|
}), deps.handlers.get_entry),
|
|
3691
4355
|
listEntries: (site, collection, options) => run(deps, env, "listEntries", site, listEntries.input, () => ({
|
|
3692
4356
|
collection,
|
|
@@ -3701,7 +4365,16 @@ function createRpc(deps, env) {
|
|
|
3701
4365
|
collection,
|
|
3702
4366
|
translationGroup
|
|
3703
4367
|
}), deps.handlers.get_translations),
|
|
3704
|
-
describeSite: (site) => run(deps, env, "describeSite", site, describeSite.input, () => ({}), deps.handlers.describe_site)
|
|
4368
|
+
describeSite: (site) => run(deps, env, "describeSite", site, describeSite.input, () => ({}), deps.handlers.describe_site),
|
|
4369
|
+
search: (site, query, options) => run(deps, env, "search", site, search.input, () => ({
|
|
4370
|
+
query,
|
|
4371
|
+
...readOptions(options, [
|
|
4372
|
+
"collection",
|
|
4373
|
+
"locale",
|
|
4374
|
+
"limit",
|
|
4375
|
+
"mode"
|
|
4376
|
+
])
|
|
4377
|
+
}), deps.handlers.search)
|
|
3705
4378
|
};
|
|
3706
4379
|
}
|
|
3707
4380
|
//#endregion
|
|
@@ -3730,6 +4403,9 @@ function createEntrypoint(deps) {
|
|
|
3730
4403
|
describeSite(...args) {
|
|
3731
4404
|
return createRpc(deps, this.env).describeSite(...args);
|
|
3732
4405
|
}
|
|
4406
|
+
search(...args) {
|
|
4407
|
+
return createRpc(deps, this.env).search(...args);
|
|
4408
|
+
}
|
|
3733
4409
|
};
|
|
3734
4410
|
}
|
|
3735
4411
|
//#endregion
|
|
@@ -3813,6 +4489,10 @@ async function runDueSchedules(input) {
|
|
|
3813
4489
|
//#endregion
|
|
3814
4490
|
//#region src/compose.ts
|
|
3815
4491
|
const systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
4492
|
+
/** Whether the run of the cron at `time` wakes the agents: on the minutes that divide by ten. */
|
|
4493
|
+
function wakesAgents(time) {
|
|
4494
|
+
return Math.floor(time / 6e4) % 10 === 0;
|
|
4495
|
+
}
|
|
3816
4496
|
/** What the MCP endpoint calls the server, in `server/discover` and `initialize`. */
|
|
3817
4497
|
const SERVER_INFO = {
|
|
3818
4498
|
name: "hollware",
|
|
@@ -3859,7 +4539,8 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
3859
4539
|
secret: (name) => {
|
|
3860
4540
|
const value = Reflect.get(env, name);
|
|
3861
4541
|
return typeof value === "string" ? value : void 0;
|
|
3862
|
-
}
|
|
4542
|
+
},
|
|
4543
|
+
semantic: semanticOfEnv(env)
|
|
3863
4544
|
});
|
|
3864
4545
|
return {
|
|
3865
4546
|
fetch: createGateway({
|
|
@@ -3876,7 +4557,7 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
3876
4557
|
auth: resolved.auth,
|
|
3877
4558
|
handlers
|
|
3878
4559
|
}),
|
|
3879
|
-
scheduled: async (
|
|
4560
|
+
scheduled: async (controller, env, ctx) => {
|
|
3880
4561
|
const each = async (site) => {
|
|
3881
4562
|
await runDueSchedules({
|
|
3882
4563
|
site,
|
|
@@ -3889,7 +4570,27 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
3889
4570
|
queue: env.EVENTS,
|
|
3890
4571
|
deps: deliveryDeps(env, site)
|
|
3891
4572
|
});
|
|
4573
|
+
const semantic = semanticOfEnv(env);
|
|
4574
|
+
if (semantic !== null) {
|
|
4575
|
+
const now = clock.now().toISOString();
|
|
4576
|
+
await healVectors(connect(env.DB), semantic, {
|
|
4577
|
+
site: site.key,
|
|
4578
|
+
now
|
|
4579
|
+
});
|
|
4580
|
+
await refreshRelated(connect(env.DB), semantic, {
|
|
4581
|
+
site: site.key,
|
|
4582
|
+
now
|
|
4583
|
+
});
|
|
4584
|
+
}
|
|
3892
4585
|
};
|
|
4586
|
+
const agents = env.AGENTS;
|
|
4587
|
+
const run = typeof agents === "object" && agents !== null ? Reflect.get(agents, "run") : void 0;
|
|
4588
|
+
if (typeof run === "function" && wakesAgents(controller.scheduledTime)) ctx.waitUntil(Promise.resolve(Reflect.apply(run, agents, [])).catch((error) => {
|
|
4589
|
+
writeLog({
|
|
4590
|
+
kind: "agents_failed",
|
|
4591
|
+
message: String(error)
|
|
4592
|
+
});
|
|
4593
|
+
}));
|
|
3893
4594
|
ctx.waitUntil((async () => {
|
|
3894
4595
|
for (const site of sites.values()) await each(site).catch((error) => {
|
|
3895
4596
|
writeLog({
|
|
@@ -3915,53 +4616,6 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
3915
4616
|
};
|
|
3916
4617
|
}
|
|
3917
4618
|
//#endregion
|
|
3918
|
-
//#region src/ops/errors.ts
|
|
3919
|
-
/**
|
|
3920
|
-
* The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
|
|
3921
|
-
* Like the `internal` error of the adapters, its message says nothing about the cause, which goes
|
|
3922
|
-
* to the log through `cause`, and its fix names the request id the operator finds the log line by.
|
|
3923
|
-
*/
|
|
3924
|
-
function internalError(context, cause) {
|
|
3925
|
-
return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${context.requestId}.`, { cause: new Error(cause) });
|
|
3926
|
-
}
|
|
3927
|
-
/**
|
|
3928
|
-
* The error for an answer of the store that the call it came from can never get: an entry that
|
|
3929
|
-
* is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
|
|
3930
|
-
* operation, so it is not one of the outcomes of its spec.
|
|
3931
|
-
*/
|
|
3932
|
-
function unexpectedAnswer(context, call, answer) {
|
|
3933
|
-
return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
|
|
3934
|
-
}
|
|
3935
|
-
//#endregion
|
|
3936
|
-
//#region src/ops/actor.ts
|
|
3937
|
-
/**
|
|
3938
|
-
* Who a write is made by: the person behind the token of the call, with what its client said
|
|
3939
|
-
* about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
|
|
3940
|
-
* client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
|
|
3941
|
-
* the scopes of the operation before any handler runs and never route a write for the RPC
|
|
3942
|
-
* binding, so reaching one with it is a bug, answered as `internal` before anything is read or
|
|
3943
|
-
* written.
|
|
3944
|
-
*/
|
|
3945
|
-
function actorOf(context) {
|
|
3946
|
-
const { principal } = context;
|
|
3947
|
-
if (principal.kind === "token") return {
|
|
3948
|
-
kind: "token",
|
|
3949
|
-
userId: principal.userId,
|
|
3950
|
-
tokenId: principal.tokenId,
|
|
3951
|
-
meta: context.client
|
|
3952
|
-
};
|
|
3953
|
-
if (principal.kind === "oauth") return {
|
|
3954
|
-
kind: "oauth_client",
|
|
3955
|
-
userId: principal.userId,
|
|
3956
|
-
tokenId: null,
|
|
3957
|
-
meta: {
|
|
3958
|
-
...context.client,
|
|
3959
|
-
oauthClient: principal.clientId
|
|
3960
|
-
}
|
|
3961
|
-
};
|
|
3962
|
-
throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
|
|
3963
|
-
}
|
|
3964
|
-
//#endregion
|
|
3965
4619
|
//#region src/ops/answers.ts
|
|
3966
4620
|
/** A review as an output. */
|
|
3967
4621
|
function reviewView(review) {
|
|
@@ -4062,6 +4716,117 @@ function publicationView(publication) {
|
|
|
4062
4716
|
at: publication.createdAt
|
|
4063
4717
|
};
|
|
4064
4718
|
}
|
|
4719
|
+
/** A comment on a review as the operations answer it; `client` is the name its client gave. */
|
|
4720
|
+
function reviewCommentView(comment) {
|
|
4721
|
+
const name = comment.authorMeta?.["name"];
|
|
4722
|
+
return {
|
|
4723
|
+
id: comment.id,
|
|
4724
|
+
author: comment.authorId,
|
|
4725
|
+
authorName: comment.authorName,
|
|
4726
|
+
client: typeof name === "string" ? name : null,
|
|
4727
|
+
body: comment.body,
|
|
4728
|
+
at: comment.createdAt
|
|
4729
|
+
};
|
|
4730
|
+
}
|
|
4731
|
+
//#endregion
|
|
4732
|
+
//#region src/ops/content-stats.ts
|
|
4733
|
+
/** A path without its trailing slash, so that `/blog/a` and `/blog/a/` are one page. */
|
|
4734
|
+
function trimmed(path) {
|
|
4735
|
+
return path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
4736
|
+
}
|
|
4737
|
+
/**
|
|
4738
|
+
* The entry that a path of the site shows, by the routes of its collections: the slug is the part
|
|
4739
|
+
* of the path where the route has `:slug`. `null` for a path that no route has.
|
|
4740
|
+
*/
|
|
4741
|
+
function entryRefOf(site, path) {
|
|
4742
|
+
const wanted = trimmed(path);
|
|
4743
|
+
for (const [collection, resolved] of site.collections) for (const [locale, route] of Object.entries(resolved.routes)) {
|
|
4744
|
+
const [before, after, ...rest] = trimmed(route).split(":slug");
|
|
4745
|
+
if (before === void 0 || after === void 0 || rest.length > 0) continue;
|
|
4746
|
+
if (!wanted.startsWith(before) || !wanted.endsWith(after)) continue;
|
|
4747
|
+
const slug = wanted.slice(before.length, wanted.length - after.length);
|
|
4748
|
+
if (slug !== "" && !slug.includes("/")) return {
|
|
4749
|
+
collection,
|
|
4750
|
+
locale,
|
|
4751
|
+
slug
|
|
4752
|
+
};
|
|
4753
|
+
}
|
|
4754
|
+
return null;
|
|
4755
|
+
}
|
|
4756
|
+
/**
|
|
4757
|
+
* The most read pages of the site, from the counts of its beacons (ADR-031), each with the
|
|
4758
|
+
* published entry it shows. `unavailable` when the deployment cannot read the counts.
|
|
4759
|
+
*/
|
|
4760
|
+
const contentStats = async (input, context) => {
|
|
4761
|
+
const analytics = context.analytics ?? null;
|
|
4762
|
+
if (analytics === null) return { outcome: "unavailable" };
|
|
4763
|
+
const site = context.site;
|
|
4764
|
+
const counts = await analytics.topPages(site.key, {
|
|
4765
|
+
days: input.days,
|
|
4766
|
+
limit: input.limit
|
|
4767
|
+
});
|
|
4768
|
+
const pages = await Promise.all(counts.map(async (count) => {
|
|
4769
|
+
const ref = entryRefOf(site, count.path);
|
|
4770
|
+
const found = ref === null ? null : await context.store.findEntry(site.key, ref, "published");
|
|
4771
|
+
return {
|
|
4772
|
+
path: count.path,
|
|
4773
|
+
views: count.views,
|
|
4774
|
+
entry: found === null ? null : entrySummary(found)
|
|
4775
|
+
};
|
|
4776
|
+
}));
|
|
4777
|
+
return {
|
|
4778
|
+
outcome: "ok",
|
|
4779
|
+
since: (/* @__PURE__ */ new Date(context.clock.now().getTime() - input.days * 864e5)).toISOString(),
|
|
4780
|
+
pages
|
|
4781
|
+
};
|
|
4782
|
+
};
|
|
4783
|
+
//#endregion
|
|
4784
|
+
//#region src/ops/errors.ts
|
|
4785
|
+
/**
|
|
4786
|
+
* The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
|
|
4787
|
+
* Like the `internal` error of the adapters, its message says nothing about the cause, which goes
|
|
4788
|
+
* to the log through `cause`, and its fix names the request id the operator finds the log line by.
|
|
4789
|
+
*/
|
|
4790
|
+
function internalError(context, cause) {
|
|
4791
|
+
return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${context.requestId}.`, { cause: new Error(cause) });
|
|
4792
|
+
}
|
|
4793
|
+
/**
|
|
4794
|
+
* The error for an answer of the store that the call it came from can never get: an entry that
|
|
4795
|
+
* is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
|
|
4796
|
+
* operation, so it is not one of the outcomes of its spec.
|
|
4797
|
+
*/
|
|
4798
|
+
function unexpectedAnswer(context, call, answer) {
|
|
4799
|
+
return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
|
|
4800
|
+
}
|
|
4801
|
+
//#endregion
|
|
4802
|
+
//#region src/ops/actor.ts
|
|
4803
|
+
/**
|
|
4804
|
+
* Who a write is made by: the person behind the token of the call, with what its client said
|
|
4805
|
+
* about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
|
|
4806
|
+
* client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
|
|
4807
|
+
* the scopes of the operation before any handler runs and never route a write for the RPC
|
|
4808
|
+
* binding, so reaching one with it is a bug, answered as `internal` before anything is read or
|
|
4809
|
+
* written.
|
|
4810
|
+
*/
|
|
4811
|
+
function actorOf(context) {
|
|
4812
|
+
const { principal } = context;
|
|
4813
|
+
if (principal.kind === "token") return {
|
|
4814
|
+
kind: "token",
|
|
4815
|
+
userId: principal.userId,
|
|
4816
|
+
tokenId: principal.tokenId,
|
|
4817
|
+
meta: context.client
|
|
4818
|
+
};
|
|
4819
|
+
if (principal.kind === "oauth") return {
|
|
4820
|
+
kind: "oauth_client",
|
|
4821
|
+
userId: principal.userId,
|
|
4822
|
+
tokenId: null,
|
|
4823
|
+
meta: {
|
|
4824
|
+
...context.client,
|
|
4825
|
+
oauthClient: principal.clientId
|
|
4826
|
+
}
|
|
4827
|
+
};
|
|
4828
|
+
throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
|
|
4829
|
+
}
|
|
4065
4830
|
//#endregion
|
|
4066
4831
|
//#region src/ops/collections.ts
|
|
4067
4832
|
/**
|
|
@@ -4261,6 +5026,7 @@ const getEntry$1 = async (input, context) => {
|
|
|
4261
5026
|
if (found === null) return { outcome: "not_found" };
|
|
4262
5027
|
const { entry, revision } = found;
|
|
4263
5028
|
const publication = await context.store.lastPublication(context.site.key, entry.id);
|
|
5029
|
+
const related = input.related ? await relatedEntries(context, entry.id) : void 0;
|
|
4264
5030
|
return {
|
|
4265
5031
|
outcome: "ok",
|
|
4266
5032
|
entry: {
|
|
@@ -4271,9 +5037,19 @@ const getEntry$1 = async (input, context) => {
|
|
|
4271
5037
|
...input.formats.includes("markdown") ? { markdown: revision.markdown } : {},
|
|
4272
5038
|
...input.formats.includes("document") ? { document: revision.document } : {},
|
|
4273
5039
|
...input.formats.includes("html") ? { html: await renderHtml(revision.document) } : {}
|
|
4274
|
-
}
|
|
5040
|
+
},
|
|
5041
|
+
...related === void 0 ? {} : { related }
|
|
4275
5042
|
};
|
|
4276
5043
|
};
|
|
5044
|
+
/**
|
|
5045
|
+
* The published entries closest to one, as its vectors found them when it was indexed: none when
|
|
5046
|
+
* the deployment has no semantic search, and none that is no longer published.
|
|
5047
|
+
*/
|
|
5048
|
+
async function relatedEntries(context, entryId) {
|
|
5049
|
+
if ((context.semantic ?? null) === null) return [];
|
|
5050
|
+
const related = await context.store.relatedOf(context.site.key, entryId);
|
|
5051
|
+
return (await context.store.findSummaries(context.site.key, related.map((item) => item.id), "published")).map(entrySummary);
|
|
5052
|
+
}
|
|
4277
5053
|
//#endregion
|
|
4278
5054
|
//#region src/ops/get-translations.ts
|
|
4279
5055
|
/** Reads the entries of a translation group in the view of the call, one per locale. */
|
|
@@ -4308,6 +5084,93 @@ const listEntries$1 = async (input, context) => {
|
|
|
4308
5084
|
nextCursor: page.nextCursor
|
|
4309
5085
|
};
|
|
4310
5086
|
};
|
|
5087
|
+
/** The published entries closest to the meaning of the query, closest first. */
|
|
5088
|
+
async function semanticSearch(semantic, store, site, query) {
|
|
5089
|
+
const [vector] = await semantic.embedder.embed([query.text]);
|
|
5090
|
+
const matches = await semantic.index.query(vector ?? [], {
|
|
5091
|
+
topK: 20,
|
|
5092
|
+
site,
|
|
5093
|
+
...query.collection === void 0 ? {} : { collection: query.collection },
|
|
5094
|
+
...query.locale === void 0 ? {} : { locale: query.locale }
|
|
5095
|
+
});
|
|
5096
|
+
const best = /* @__PURE__ */ new Map();
|
|
5097
|
+
for (const match of matches) {
|
|
5098
|
+
const kept = best.get(match.metadata.entry);
|
|
5099
|
+
if (kept === void 0 || match.score > kept.score) best.set(match.metadata.entry, match);
|
|
5100
|
+
}
|
|
5101
|
+
const ranked = [...best.values()].sort((a, b) => b.score - a.score).slice(0, query.limit);
|
|
5102
|
+
const summaries = await store.findSummaries(site, ranked.map((match) => match.metadata.entry), "published");
|
|
5103
|
+
const byId = new Map(summaries.map((summary) => [summary.entry.id, summary]));
|
|
5104
|
+
return ranked.flatMap((match) => {
|
|
5105
|
+
const entry = byId.get(match.metadata.entry);
|
|
5106
|
+
return entry === void 0 ? [] : [{
|
|
5107
|
+
entry,
|
|
5108
|
+
snippet: match.metadata.excerpt,
|
|
5109
|
+
score: match.score
|
|
5110
|
+
}];
|
|
5111
|
+
});
|
|
5112
|
+
}
|
|
5113
|
+
/**
|
|
5114
|
+
* Merges rankings by reciprocal rank fusion: an entry scores `1 / (FUSION_K + place)` in each list
|
|
5115
|
+
* it is in, places counted from 1. An entry keeps the snippet of the first list that has it, so
|
|
5116
|
+
* the words found by full text win over the excerpt of a match by meaning.
|
|
5117
|
+
*/
|
|
5118
|
+
function fuse(lists, limit) {
|
|
5119
|
+
const merged = /* @__PURE__ */ new Map();
|
|
5120
|
+
for (const list of lists) list.forEach((hit, place) => {
|
|
5121
|
+
const gain = 1 / (60 + place + 1);
|
|
5122
|
+
const kept = merged.get(hit.entry.entry.id);
|
|
5123
|
+
merged.set(hit.entry.entry.id, kept === void 0 ? {
|
|
5124
|
+
...hit,
|
|
5125
|
+
score: gain
|
|
5126
|
+
} : {
|
|
5127
|
+
...kept,
|
|
5128
|
+
score: kept.score + gain
|
|
5129
|
+
});
|
|
5130
|
+
});
|
|
5131
|
+
return [...merged.values()].sort((a, b) => b.score - a.score).slice(0, limit);
|
|
5132
|
+
}
|
|
5133
|
+
//#endregion
|
|
5134
|
+
//#region src/ops/search.ts
|
|
5135
|
+
/**
|
|
5136
|
+
* The published entries of the site that match the query, best first: by its words, by meaning,
|
|
5137
|
+
* or both (ADR-030). Without the semantic search of the deployment, every mode is full text, and
|
|
5138
|
+
* the answer's `mode` says which one ran.
|
|
5139
|
+
*/
|
|
5140
|
+
const search$1 = async (input, context) => {
|
|
5141
|
+
if (input.collection !== void 0) requireCollection(context.site, input.collection);
|
|
5142
|
+
if (input.locale !== void 0) requireLocale(context.site, input.locale);
|
|
5143
|
+
const query = {
|
|
5144
|
+
text: input.query,
|
|
5145
|
+
collection: input.collection,
|
|
5146
|
+
locale: input.locale,
|
|
5147
|
+
limit: input.limit
|
|
5148
|
+
};
|
|
5149
|
+
const semantic = context.semantic ?? null;
|
|
5150
|
+
const asked = input.mode ?? "hybrid";
|
|
5151
|
+
const mode = semantic === null ? "fulltext" : asked;
|
|
5152
|
+
const site = context.site.key;
|
|
5153
|
+
let hits;
|
|
5154
|
+
if (semantic === null || mode === "fulltext") hits = await context.store.searchEntries(site, query);
|
|
5155
|
+
else if (mode === "semantic") hits = await semanticSearch(semantic, context.store, site, query);
|
|
5156
|
+
else {
|
|
5157
|
+
const wide = {
|
|
5158
|
+
...query,
|
|
5159
|
+
limit: Math.max(query.limit, 20)
|
|
5160
|
+
};
|
|
5161
|
+
const [words, meaning] = await Promise.all([context.store.searchEntries(site, wide), semanticSearch(semantic, context.store, site, wide)]);
|
|
5162
|
+
hits = fuse([words, meaning], query.limit);
|
|
5163
|
+
}
|
|
5164
|
+
return {
|
|
5165
|
+
outcome: "ok",
|
|
5166
|
+
mode,
|
|
5167
|
+
results: hits.map((hit) => ({
|
|
5168
|
+
entry: entrySummary(hit.entry),
|
|
5169
|
+
snippet: hit.snippet,
|
|
5170
|
+
score: hit.score
|
|
5171
|
+
}))
|
|
5172
|
+
};
|
|
5173
|
+
};
|
|
4311
5174
|
//#endregion
|
|
4312
5175
|
//#region src/ops/event.ts
|
|
4313
5176
|
/** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
|
|
@@ -4625,13 +5488,15 @@ const listReviews = async (input, context) => {
|
|
|
4625
5488
|
status: input.status,
|
|
4626
5489
|
limit: input.limit
|
|
4627
5490
|
});
|
|
5491
|
+
const comments = await context.store.reviewComments(context.site.key, found.map((review) => review.id));
|
|
4628
5492
|
const reviews = [];
|
|
4629
5493
|
for (const review of found) {
|
|
4630
5494
|
const view = await context.store.findEntryById(context.site.key, review.entryId, "head");
|
|
4631
5495
|
if (view === null) throw unexpectedAnswer(context, "list_reviews", { outcome: "no entry" });
|
|
4632
5496
|
reviews.push({
|
|
4633
5497
|
...reviewView(review),
|
|
4634
|
-
entry: entrySummary(view)
|
|
5498
|
+
entry: entrySummary(view),
|
|
5499
|
+
comments: (comments.get(review.id) ?? []).map(reviewCommentView)
|
|
4635
5500
|
});
|
|
4636
5501
|
}
|
|
4637
5502
|
return {
|
|
@@ -4697,6 +5562,56 @@ const requestChanges = async (input, context) => {
|
|
|
4697
5562
|
review: reviewView(closed.review)
|
|
4698
5563
|
};
|
|
4699
5564
|
};
|
|
5565
|
+
/**
|
|
5566
|
+
* A comment on a pending review, in the name of the caller: for the person who decides and for
|
|
5567
|
+
* the author. It decides nothing; a review that is no longer pending takes none.
|
|
5568
|
+
*/
|
|
5569
|
+
const commentReview = async (input, context) => {
|
|
5570
|
+
const answer = await context.store.commentReview(context.site.key, {
|
|
5571
|
+
reviewId: input.id,
|
|
5572
|
+
actor: actorOf(context),
|
|
5573
|
+
body: input.body
|
|
5574
|
+
});
|
|
5575
|
+
return answer.outcome === "commented" ? {
|
|
5576
|
+
outcome: "commented",
|
|
5577
|
+
comment: reviewCommentView(answer.comment)
|
|
5578
|
+
} : { outcome: answer.outcome };
|
|
5579
|
+
};
|
|
5580
|
+
/**
|
|
5581
|
+
* Asks for a review of the head revision of an entry through `publish`, with `content:publish`
|
|
5582
|
+
* taken from the caller for this call: the same checks run, and the one thing that cannot happen
|
|
5583
|
+
* is a publication. It is how an agent sends its work to a person, whatever its token holds.
|
|
5584
|
+
*/
|
|
5585
|
+
const requestReview = async (input, context) => {
|
|
5586
|
+
const { principal: given } = context;
|
|
5587
|
+
const principal = given.kind === "binding" ? given : {
|
|
5588
|
+
...given,
|
|
5589
|
+
scopes: given.scopes.filter((scope) => scope !== "content:publish")
|
|
5590
|
+
};
|
|
5591
|
+
const answer = await publish$1({
|
|
5592
|
+
id: input.id,
|
|
5593
|
+
version: input.version,
|
|
5594
|
+
acknowledgeWarnings: input.acknowledgeWarnings,
|
|
5595
|
+
group: []
|
|
5596
|
+
}, {
|
|
5597
|
+
...context,
|
|
5598
|
+
principal
|
|
5599
|
+
});
|
|
5600
|
+
switch (answer.outcome) {
|
|
5601
|
+
case "pending_review":
|
|
5602
|
+
case "blocked":
|
|
5603
|
+
case "needs_acknowledgement":
|
|
5604
|
+
case "conflict":
|
|
5605
|
+
case "not_found": return {
|
|
5606
|
+
outcome: answer.outcome,
|
|
5607
|
+
...answer.entry === void 0 ? {} : { entry: answer.entry },
|
|
5608
|
+
...answer.verdict === void 0 ? {} : { verdict: answer.verdict },
|
|
5609
|
+
...answer.conflict === void 0 ? {} : { conflict: answer.conflict },
|
|
5610
|
+
...answer.review === void 0 ? {} : { review: answer.review }
|
|
5611
|
+
};
|
|
5612
|
+
default: throw unexpectedAnswer(context, "request_review", answer);
|
|
5613
|
+
}
|
|
5614
|
+
};
|
|
4700
5615
|
//#endregion
|
|
4701
5616
|
//#region src/ops/unpublish.ts
|
|
4702
5617
|
/**
|
|
@@ -4842,14 +5757,18 @@ function defineHandlers(handlers) {
|
|
|
4842
5757
|
*/
|
|
4843
5758
|
const handlers = defineHandlers({
|
|
4844
5759
|
approve_review: approveReview,
|
|
5760
|
+
comment_review: commentReview,
|
|
5761
|
+
content_stats: contentStats,
|
|
4845
5762
|
create_draft: createDraft,
|
|
4846
5763
|
describe_site: describeSite$1,
|
|
4847
5764
|
get_entry: getEntry$1,
|
|
4848
5765
|
get_translations: getTranslations$1,
|
|
4849
5766
|
list_entries: listEntries$1,
|
|
5767
|
+
search: search$1,
|
|
4850
5768
|
list_reviews: listReviews,
|
|
4851
5769
|
publish: publish$1,
|
|
4852
5770
|
request_changes: requestChanges,
|
|
5771
|
+
request_review: requestReview,
|
|
4853
5772
|
unpublish,
|
|
4854
5773
|
update_draft: updateDraft,
|
|
4855
5774
|
validate
|