@hollware/server 0.4.0 → 0.5.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-BM9G03lZ.mjs} +1 -1
- package/dist/index.d.mts +140 -2
- package/dist/index.mjs +867 -81
- package/dist/{store-CsuER6J7.mjs → store-DiS8UFI2.mjs} +42 -1
- package/migrations/0008_search.sql +47 -0
- package/migrations/0009_embeddings.sql +19 -0
- package/migrations/meta/0008_snapshot.json +2110 -0
- package/migrations/meta/0009_snapshot.json +2257 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +2 -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 ROLE_SCOPES, C as reviews, D as sites, E as schedules, M as isRole, O as tokens, S as publications, T as revisions, _ as entries, b as members, g as deliveries, j as effectiveScopes, k as users, l as membershipsOf, n as createInvitation, s as findSession, v as entryEmbeddings, w as revisionTags, x as outbox, y as invitations } from "./store-DiS8UFI2.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, 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.5.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);
|
|
@@ -1442,6 +1497,9 @@ function createD1Store(db, options = {}) {
|
|
|
1442
1497
|
findEntry: (site, ref, view) => findEntry(database, site, ref, view),
|
|
1443
1498
|
findEntryById: (site, id, view) => findEntryById(database, site, id, view),
|
|
1444
1499
|
listEntries: (site, query) => listEntries$2(database, site, query),
|
|
1500
|
+
searchEntries: (site, query) => searchEntries(database, site, query),
|
|
1501
|
+
findSummaries: (site, ids, view) => findSummaries(database, site, ids, view),
|
|
1502
|
+
relatedOf: (site, entryId) => relatedOf(database, site, entryId),
|
|
1445
1503
|
listTranslations: (site, collection, translationGroup, view) => listTranslations(database, site, collection, translationGroup, view),
|
|
1446
1504
|
lookup: (site) => lookup(database, site),
|
|
1447
1505
|
lastPublication: (site, entryId) => lastPublication(database, site, entryId),
|
|
@@ -1515,6 +1573,191 @@ function sqlIncrement() {
|
|
|
1515
1573
|
return sql`${deliveries.attempts} + 1`;
|
|
1516
1574
|
}
|
|
1517
1575
|
//#endregion
|
|
1576
|
+
//#region src/semantic/index-entry.ts
|
|
1577
|
+
/** The id of a piece's vector. */
|
|
1578
|
+
function vectorId(entryId, piece) {
|
|
1579
|
+
return `${entryId}:${piece}`;
|
|
1580
|
+
}
|
|
1581
|
+
function excerptOf(text) {
|
|
1582
|
+
if (text.length <= 200) return text;
|
|
1583
|
+
const cut = text.slice(0, 200);
|
|
1584
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
1585
|
+
return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trim()}…`;
|
|
1586
|
+
}
|
|
1587
|
+
function stringOf(value) {
|
|
1588
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
1589
|
+
}
|
|
1590
|
+
/** The ids of the vectors of `pieces` pieces of an entry, from `from` on. */
|
|
1591
|
+
function idsOf(entryId, from, pieces) {
|
|
1592
|
+
return Array.from({ length: Math.max(0, pieces - from) }, (_, offset) => vectorId(entryId, from + offset));
|
|
1593
|
+
}
|
|
1594
|
+
/** The entries nearest to an entry's summary, in its collection and locale; itself left out. */
|
|
1595
|
+
async function nearestTo(semantic, input) {
|
|
1596
|
+
return (await semantic.index.query(input.summary, {
|
|
1597
|
+
topK: 6,
|
|
1598
|
+
site: input.site,
|
|
1599
|
+
collection: input.collection,
|
|
1600
|
+
locale: input.locale,
|
|
1601
|
+
summariesOnly: true
|
|
1602
|
+
})).filter((match) => match.metadata.entry !== input.entryId).slice(0, 5).map((match) => ({
|
|
1603
|
+
id: match.metadata.entry,
|
|
1604
|
+
score: match.score
|
|
1605
|
+
}));
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Brings the vectors of one entry in line with what it has published: the pieces of its published
|
|
1609
|
+
* revision, embedded with the model of `semantic`. It reads the entry as it is now, so it is right
|
|
1610
|
+
* whichever event called it and however late: an entry whose vectors already are of its published
|
|
1611
|
+
* revision, model and chunker is `current` and costs no embedding; an entry with nothing published
|
|
1612
|
+
* (unpublished, deleted, gone) loses its vectors and its row.
|
|
1613
|
+
*/
|
|
1614
|
+
async function syncEntryVectors(db, semantic, input) {
|
|
1615
|
+
const { site, entryId, now } = input;
|
|
1616
|
+
const [published] = await db.select({
|
|
1617
|
+
collection: entries.collection,
|
|
1618
|
+
locale: entries.locale,
|
|
1619
|
+
revisionId: revisions.id,
|
|
1620
|
+
frontmatter: revisions.frontmatter,
|
|
1621
|
+
document: revisions.document
|
|
1622
|
+
}).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);
|
|
1623
|
+
const [stored] = await db.select({
|
|
1624
|
+
revisionId: entryEmbeddings.revisionId,
|
|
1625
|
+
model: entryEmbeddings.model,
|
|
1626
|
+
chunkerVersion: entryEmbeddings.chunkerVersion,
|
|
1627
|
+
pieces: entryEmbeddings.pieces
|
|
1628
|
+
}).from(entryEmbeddings).where(and(eq(entryEmbeddings.siteId, site), eq(entryEmbeddings.entryId, entryId))).limit(1);
|
|
1629
|
+
if (published === void 0) {
|
|
1630
|
+
if (stored === void 0) return "absent";
|
|
1631
|
+
await semantic.index.remove(idsOf(entryId, 0, stored.pieces));
|
|
1632
|
+
await db.delete(entryEmbeddings).where(and(eq(entryEmbeddings.siteId, site), eq(entryEmbeddings.entryId, entryId)));
|
|
1633
|
+
return "removed";
|
|
1634
|
+
}
|
|
1635
|
+
if (stored !== void 0 && stored.revisionId === published.revisionId && stored.model === semantic.embedder.model && stored.chunkerVersion === CHUNKER_VERSION) return "current";
|
|
1636
|
+
const frontmatter = JSON.parse(published.frontmatter);
|
|
1637
|
+
const document = JSON.parse(published.document);
|
|
1638
|
+
const description = stringOf(frontmatter["description"]);
|
|
1639
|
+
const chunks = chunksOf({
|
|
1640
|
+
title: stringOf(frontmatter["title"]) ?? "",
|
|
1641
|
+
...description === void 0 ? {} : { description },
|
|
1642
|
+
document
|
|
1643
|
+
});
|
|
1644
|
+
const vectors = await semantic.embedder.embed(chunks.map((chunk) => chunk.text));
|
|
1645
|
+
await semantic.index.upsert(chunks.map((chunk) => ({
|
|
1646
|
+
id: vectorId(entryId, chunk.index),
|
|
1647
|
+
values: vectors[chunk.index] ?? [],
|
|
1648
|
+
metadata: {
|
|
1649
|
+
site,
|
|
1650
|
+
entry: entryId,
|
|
1651
|
+
collection: published.collection,
|
|
1652
|
+
locale: published.locale,
|
|
1653
|
+
piece: chunk.index,
|
|
1654
|
+
excerpt: excerptOf(chunk.text)
|
|
1655
|
+
}
|
|
1656
|
+
})));
|
|
1657
|
+
if (stored !== void 0) await semantic.index.remove(idsOf(entryId, chunks.length, stored.pieces));
|
|
1658
|
+
const related = await nearestTo(semantic, {
|
|
1659
|
+
site,
|
|
1660
|
+
entryId,
|
|
1661
|
+
collection: published.collection,
|
|
1662
|
+
locale: published.locale,
|
|
1663
|
+
summary: vectors[0] ?? []
|
|
1664
|
+
});
|
|
1665
|
+
const row = {
|
|
1666
|
+
related: JSON.stringify(related),
|
|
1667
|
+
revisionId: published.revisionId,
|
|
1668
|
+
model: semantic.embedder.model,
|
|
1669
|
+
chunkerVersion: CHUNKER_VERSION,
|
|
1670
|
+
pieces: chunks.length,
|
|
1671
|
+
updatedAt: now
|
|
1672
|
+
};
|
|
1673
|
+
await db.insert(entryEmbeddings).values({
|
|
1674
|
+
id: newId("emb"),
|
|
1675
|
+
siteId: site,
|
|
1676
|
+
entryId,
|
|
1677
|
+
createdAt: now,
|
|
1678
|
+
...row
|
|
1679
|
+
}).onConflictDoUpdate({
|
|
1680
|
+
target: [entryEmbeddings.siteId, entryEmbeddings.entryId],
|
|
1681
|
+
set: row
|
|
1682
|
+
});
|
|
1683
|
+
return "indexed";
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* The entries whose vectors are not those of what they publish: published with no vectors (before
|
|
1687
|
+
* the Worker had semantic search, or an event that never reached the subscriber), of another
|
|
1688
|
+
* revision, model or chunker; and entries with vectors and nothing published. At most `limit`.
|
|
1689
|
+
*/
|
|
1690
|
+
async function staleEntries(db, input) {
|
|
1691
|
+
const { site, model, limit } = input;
|
|
1692
|
+
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);
|
|
1693
|
+
const left = limit - published.length;
|
|
1694
|
+
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);
|
|
1695
|
+
return [...published, ...orphans].map((row) => row.id);
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* What the cron trigger does for the vectors of a site: brings at most `limit` stale entries in
|
|
1699
|
+
* line, so that a site that turns semantic search on, or changes its model, is indexed a few
|
|
1700
|
+
* entries a minute without a command. An entry that fails is left for the next run.
|
|
1701
|
+
*/
|
|
1702
|
+
async function healVectors(db, semantic, input) {
|
|
1703
|
+
const ids = await staleEntries(db, {
|
|
1704
|
+
site: input.site,
|
|
1705
|
+
model: semantic.embedder.model,
|
|
1706
|
+
limit: input.limit ?? 20
|
|
1707
|
+
});
|
|
1708
|
+
let failed = 0;
|
|
1709
|
+
for (const entryId of ids) await syncEntryVectors(db, semantic, {
|
|
1710
|
+
site: input.site,
|
|
1711
|
+
entryId,
|
|
1712
|
+
now: input.now
|
|
1713
|
+
}).catch(() => {
|
|
1714
|
+
failed += 1;
|
|
1715
|
+
});
|
|
1716
|
+
return {
|
|
1717
|
+
synced: ids.length - failed,
|
|
1718
|
+
failed
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
/** How long an entry with no related entries waits before they are looked for again. */
|
|
1722
|
+
const RELATED_RETRY_MS = 6e5;
|
|
1723
|
+
/** How old the related entries of an entry may grow before they are looked for again. */
|
|
1724
|
+
const RELATED_REFRESH_MS = 864e5;
|
|
1725
|
+
/**
|
|
1726
|
+
* What the cron trigger does for related entries: it finds them again for at most `limit`
|
|
1727
|
+
* entries of the site, oldest first, whose related entries are a day old, or empty for ten
|
|
1728
|
+
* minutes. Vectorize shows a vector seconds after it was written, so entries indexed together
|
|
1729
|
+
* miss each other at first; and an entry published later belongs among the related entries of
|
|
1730
|
+
* those before it. Each keeps its vectors: only its summary is read back and compared.
|
|
1731
|
+
*/
|
|
1732
|
+
async function refreshRelated(db, semantic, input) {
|
|
1733
|
+
const now = Date.parse(input.now);
|
|
1734
|
+
const retryBefore = (/* @__PURE__ */ new Date(now - RELATED_RETRY_MS)).toISOString();
|
|
1735
|
+
const refreshBefore = (/* @__PURE__ */ new Date(now - RELATED_REFRESH_MS)).toISOString();
|
|
1736
|
+
const rows = await db.select({
|
|
1737
|
+
entryId: entryEmbeddings.entryId,
|
|
1738
|
+
collection: entries.collection,
|
|
1739
|
+
locale: entries.locale
|
|
1740
|
+
}).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);
|
|
1741
|
+
if (rows.length === 0) return 0;
|
|
1742
|
+
const summaries = new Map((await semantic.index.get(rows.map((row) => vectorId(row.entryId, 0)))).map((vector) => [vector.id, vector.values]));
|
|
1743
|
+
let refreshed = 0;
|
|
1744
|
+
for (const row of rows) {
|
|
1745
|
+
const summary = summaries.get(vectorId(row.entryId, 0));
|
|
1746
|
+
if (summary === void 0) continue;
|
|
1747
|
+
const related = await nearestTo(semantic, {
|
|
1748
|
+
site: input.site,
|
|
1749
|
+
...row,
|
|
1750
|
+
summary
|
|
1751
|
+
});
|
|
1752
|
+
await db.update(entryEmbeddings).set({
|
|
1753
|
+
related: JSON.stringify(related),
|
|
1754
|
+
updatedAt: input.now
|
|
1755
|
+
}).where(and(eq(entryEmbeddings.siteId, input.site), eq(entryEmbeddings.entryId, row.entryId)));
|
|
1756
|
+
refreshed += 1;
|
|
1757
|
+
}
|
|
1758
|
+
return refreshed;
|
|
1759
|
+
}
|
|
1760
|
+
//#endregion
|
|
1518
1761
|
//#region src/events/deliver.ts
|
|
1519
1762
|
/** Where IndexNow takes the URLs that changed. */
|
|
1520
1763
|
const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
|
|
@@ -1566,6 +1809,17 @@ function subscribersOf(deps) {
|
|
|
1566
1809
|
}
|
|
1567
1810
|
});
|
|
1568
1811
|
}
|
|
1812
|
+
const semantic = deps.semantic;
|
|
1813
|
+
if (semantic !== void 0 && semantic !== null) subscribers.push({
|
|
1814
|
+
name: "vectors",
|
|
1815
|
+
async send(payload) {
|
|
1816
|
+
await syncEntryVectors(deps.db, semantic, {
|
|
1817
|
+
site: site.key,
|
|
1818
|
+
entryId: payload.entry.id,
|
|
1819
|
+
now: deps.clock.now().toISOString()
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
});
|
|
1569
1823
|
return subscribers;
|
|
1570
1824
|
}
|
|
1571
1825
|
/**
|
|
@@ -2040,6 +2294,197 @@ function installAdminApi(app, options) {
|
|
|
2040
2294
|
}));
|
|
2041
2295
|
}
|
|
2042
2296
|
//#endregion
|
|
2297
|
+
//#region src/semantic/cloudflare.ts
|
|
2298
|
+
/** The embedding model when the config names none: multilingual, so Spanish finds English. */
|
|
2299
|
+
const DEFAULT_EMBEDDING_MODEL = "@cf/baai/bge-m3";
|
|
2300
|
+
function isRecord$3(value) {
|
|
2301
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2302
|
+
}
|
|
2303
|
+
/** Workers AI as an embedder: `{ data: number[][] }` for a batch of texts. */
|
|
2304
|
+
function workersAiEmbedder(ai, model) {
|
|
2305
|
+
return {
|
|
2306
|
+
model,
|
|
2307
|
+
async embed(texts) {
|
|
2308
|
+
if (texts.length === 0) return [];
|
|
2309
|
+
const answer = await ai.run(model, { text: [...texts] });
|
|
2310
|
+
const data = isRecord$3(answer) ? answer["data"] : void 0;
|
|
2311
|
+
if (!Array.isArray(data) || data.length !== texts.length) throw new Error(`${model} answered no vector for each of ${texts.length} texts`);
|
|
2312
|
+
return data.map((vector) => {
|
|
2313
|
+
if (!Array.isArray(vector)) throw new Error(`${model} answered a vector that is no list`);
|
|
2314
|
+
return vector.map(Number);
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
function metadataOf(raw) {
|
|
2320
|
+
if (raw === void 0) return null;
|
|
2321
|
+
const { site, entry, collection, locale, piece, excerpt } = raw;
|
|
2322
|
+
if (typeof excerpt !== "string" || typeof site !== "string" || typeof entry !== "string" || typeof collection !== "string" || typeof locale !== "string" || typeof piece !== "number") return null;
|
|
2323
|
+
return {
|
|
2324
|
+
site,
|
|
2325
|
+
entry,
|
|
2326
|
+
collection,
|
|
2327
|
+
locale,
|
|
2328
|
+
piece,
|
|
2329
|
+
excerpt
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
/** Vectorize as the index. Its metadata indexes on site, collection, locale and piece filter. */
|
|
2333
|
+
function vectorizeIndex(binding) {
|
|
2334
|
+
return {
|
|
2335
|
+
async upsert(vectors) {
|
|
2336
|
+
if (vectors.length === 0) return;
|
|
2337
|
+
await binding.upsert(vectors.map((vector) => ({
|
|
2338
|
+
id: vector.id,
|
|
2339
|
+
values: [...vector.values],
|
|
2340
|
+
metadata: { ...vector.metadata }
|
|
2341
|
+
})));
|
|
2342
|
+
},
|
|
2343
|
+
async remove(ids) {
|
|
2344
|
+
if (ids.length > 0) await binding.deleteByIds([...ids]);
|
|
2345
|
+
},
|
|
2346
|
+
async get(ids) {
|
|
2347
|
+
if (ids.length === 0) return [];
|
|
2348
|
+
return (await binding.getByIds([...ids])).map((vector) => ({
|
|
2349
|
+
id: vector.id,
|
|
2350
|
+
values: Array.from(vector.values)
|
|
2351
|
+
}));
|
|
2352
|
+
},
|
|
2353
|
+
async query(values, options) {
|
|
2354
|
+
return (await binding.query([...values], {
|
|
2355
|
+
topK: options.topK,
|
|
2356
|
+
returnMetadata: "all",
|
|
2357
|
+
filter: {
|
|
2358
|
+
site: options.site,
|
|
2359
|
+
...options.collection === void 0 ? {} : { collection: options.collection },
|
|
2360
|
+
...options.locale === void 0 ? {} : { locale: options.locale },
|
|
2361
|
+
...options.summariesOnly === true ? { piece: 0 } : {}
|
|
2362
|
+
}
|
|
2363
|
+
})).matches.flatMap((match) => {
|
|
2364
|
+
const metadata = metadataOf(match.metadata);
|
|
2365
|
+
return metadata === null ? [] : [{
|
|
2366
|
+
id: match.id,
|
|
2367
|
+
score: match.score,
|
|
2368
|
+
metadata
|
|
2369
|
+
}];
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
/** Semantic search over the Worker's bindings, or `null` when it does not have both. */
|
|
2375
|
+
function semanticOf(env, model) {
|
|
2376
|
+
const ai = Reflect.get(env, "AI");
|
|
2377
|
+
const vectorize = Reflect.get(env, "VECTORIZE");
|
|
2378
|
+
if (!isRecord$3(ai) || typeof ai["run"] !== "function") return null;
|
|
2379
|
+
if (!isRecord$3(vectorize) || typeof vectorize["query"] !== "function") return null;
|
|
2380
|
+
return {
|
|
2381
|
+
embedder: workersAiEmbedder(ai, model),
|
|
2382
|
+
index: vectorizeIndex(vectorize)
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Semantic search over the Worker's bindings, or `null` without them. The model is the variable
|
|
2387
|
+
* `HOLLWARE_EMBEDDING_MODEL`, or `DEFAULT_EMBEDDING_MODEL`; the Vectorize index must have been
|
|
2388
|
+
* made for its dimensions, so a new model needs a new index.
|
|
2389
|
+
*/
|
|
2390
|
+
function semanticOfEnv(env) {
|
|
2391
|
+
const model = Reflect.get(env, "HOLLWARE_EMBEDDING_MODEL");
|
|
2392
|
+
return semanticOf(env, typeof model === "string" && model !== "" ? model : DEFAULT_EMBEDDING_MODEL);
|
|
2393
|
+
}
|
|
2394
|
+
//#endregion
|
|
2395
|
+
//#region src/analytics/cloudflare.ts
|
|
2396
|
+
/** The dataset of Analytics Engine when the Worker names none. */
|
|
2397
|
+
const DEFAULT_ANALYTICS_DATASET = "hollware_page_views";
|
|
2398
|
+
/** Where the SQL API of Analytics Engine answers. */
|
|
2399
|
+
function sqlApiUrl(accountId) {
|
|
2400
|
+
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`;
|
|
2401
|
+
}
|
|
2402
|
+
function isRecord$2(value) {
|
|
2403
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2406
|
+
* Analytics Engine as the writer: one data point per view, indexed by the site (its sampling key),
|
|
2407
|
+
* with the path and the country as blobs and a count of 1.
|
|
2408
|
+
*/
|
|
2409
|
+
function analyticsEngineWriter(binding) {
|
|
2410
|
+
return { record(view) {
|
|
2411
|
+
binding.writeDataPoint({
|
|
2412
|
+
indexes: [view.site],
|
|
2413
|
+
blobs: [view.path, view.country],
|
|
2414
|
+
doubles: [1]
|
|
2415
|
+
});
|
|
2416
|
+
} };
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2419
|
+
* The query of the most read paths. The SQL API takes no parameters, so every value in it is
|
|
2420
|
+
* checked first: a site key (`[a-z0-9-]`), a dataset name and two whole numbers.
|
|
2421
|
+
*/
|
|
2422
|
+
function topPagesSql(dataset, site, query) {
|
|
2423
|
+
if (!/^[a-z_][a-z0-9_]{0,63}$/i.test(dataset)) throw new Error(`No dataset is named ${dataset}`);
|
|
2424
|
+
if (!isSiteKey(site)) throw new Error(`No site key is ${String(site)}`);
|
|
2425
|
+
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`);
|
|
2426
|
+
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`);
|
|
2427
|
+
return [
|
|
2428
|
+
"SELECT blob1 AS path, SUM(_sample_interval) AS views",
|
|
2429
|
+
`FROM ${dataset}`,
|
|
2430
|
+
`WHERE index1 = '${site}' AND timestamp > NOW() - INTERVAL '${query.days}' DAY`,
|
|
2431
|
+
"GROUP BY path",
|
|
2432
|
+
"ORDER BY views DESC",
|
|
2433
|
+
`LIMIT ${query.limit}`,
|
|
2434
|
+
"FORMAT JSON"
|
|
2435
|
+
].join(" ");
|
|
2436
|
+
}
|
|
2437
|
+
/** The SQL API of Analytics Engine as the reader, its answers kept for ten minutes. */
|
|
2438
|
+
function sqlApiReader(options) {
|
|
2439
|
+
return { async topPages(site, query) {
|
|
2440
|
+
const sql = topPagesSql(options.dataset, site, query);
|
|
2441
|
+
const key = new Request(`https://hollware.invalid/content-stats/${options.dataset}/${site}?days=${query.days}&limit=${query.limit}`);
|
|
2442
|
+
const cached = await options.cache?.match(key);
|
|
2443
|
+
const answer = cached ?? await options.fetch(new Request(sqlApiUrl(options.accountId), {
|
|
2444
|
+
method: "POST",
|
|
2445
|
+
headers: { authorization: `Bearer ${options.token}` },
|
|
2446
|
+
body: sql
|
|
2447
|
+
}));
|
|
2448
|
+
if (!answer.ok) throw new Error(`The SQL API of Analytics Engine answered ${answer.status}`);
|
|
2449
|
+
const text = await answer.text();
|
|
2450
|
+
if (cached === void 0 && options.cache !== null) await options.cache.put(key, new Response(text, { headers: { "cache-control": `max-age=600` } }));
|
|
2451
|
+
const body = JSON.parse(text);
|
|
2452
|
+
return (isRecord$2(body) && Array.isArray(body["data"]) ? body["data"] : []).flatMap((row) => {
|
|
2453
|
+
if (!isRecord$2(row) || typeof row["path"] !== "string") return [];
|
|
2454
|
+
const views = Number(row["views"]);
|
|
2455
|
+
return Number.isFinite(views) ? [{
|
|
2456
|
+
path: row["path"],
|
|
2457
|
+
views
|
|
2458
|
+
}] : [];
|
|
2459
|
+
});
|
|
2460
|
+
} };
|
|
2461
|
+
}
|
|
2462
|
+
/** The writer over the Worker's `ANALYTICS` binding, or `null` without one. */
|
|
2463
|
+
function analyticsWriterOf(env) {
|
|
2464
|
+
const binding = Reflect.get(env, "ANALYTICS");
|
|
2465
|
+
if (!isRecord$2(binding) || typeof binding["writeDataPoint"] !== "function") return null;
|
|
2466
|
+
return analyticsEngineWriter(binding);
|
|
2467
|
+
}
|
|
2468
|
+
/**
|
|
2469
|
+
* The reader over the Worker's variables: `HOLLWARE_ANALYTICS_ACCOUNT_ID` and the secret
|
|
2470
|
+
* `HOLLWARE_ANALYTICS_TOKEN` (an API token with Account Analytics Read), and the dataset of
|
|
2471
|
+
* `HOLLWARE_ANALYTICS_DATASET` or the default one. `null` without the account or the token.
|
|
2472
|
+
*/
|
|
2473
|
+
function analyticsReaderOf(env) {
|
|
2474
|
+
const accountId = Reflect.get(env, "HOLLWARE_ANALYTICS_ACCOUNT_ID");
|
|
2475
|
+
const token = Reflect.get(env, "HOLLWARE_ANALYTICS_TOKEN");
|
|
2476
|
+
const dataset = Reflect.get(env, "HOLLWARE_ANALYTICS_DATASET");
|
|
2477
|
+
if (typeof accountId !== "string" || accountId === "") return null;
|
|
2478
|
+
if (typeof token !== "string" || token === "") return null;
|
|
2479
|
+
return sqlApiReader({
|
|
2480
|
+
accountId,
|
|
2481
|
+
token,
|
|
2482
|
+
dataset: typeof dataset === "string" && dataset !== "" ? dataset : DEFAULT_ANALYTICS_DATASET,
|
|
2483
|
+
fetch: (request) => fetch(request),
|
|
2484
|
+
cache: typeof caches === "undefined" ? null : caches.default
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
//#endregion
|
|
2043
2488
|
//#region src/http/ops-api.ts
|
|
2044
2489
|
const STATUS$1 = {
|
|
2045
2490
|
invalid_input: 400,
|
|
@@ -2089,7 +2534,9 @@ function installOpsApi(app, options) {
|
|
|
2089
2534
|
clock: options.clock,
|
|
2090
2535
|
client: { name: "hollware-admin" },
|
|
2091
2536
|
requestId,
|
|
2092
|
-
notify: options.notify(c, requestId, site.key)
|
|
2537
|
+
notify: options.notify(c, requestId, site.key),
|
|
2538
|
+
semantic: semanticOfEnv(c.env),
|
|
2539
|
+
analytics: analyticsReaderOf(c.env)
|
|
2093
2540
|
};
|
|
2094
2541
|
try {
|
|
2095
2542
|
return json(spec.output.parse(await invoke$1(options.handlers, spec, parsed.data, context)));
|
|
@@ -2129,6 +2576,31 @@ const STATUS = {
|
|
|
2129
2576
|
unknown_collection: 404,
|
|
2130
2577
|
unknown_locale: 404
|
|
2131
2578
|
};
|
|
2579
|
+
/**
|
|
2580
|
+
* A route of the read API whose `HollwareError` (an unknown collection, a limit out of range) is
|
|
2581
|
+
* the caller's to read, with its status. It is caught here, in the route: Hono 5 hands an error
|
|
2582
|
+
* that a route throws to the app's error handler before any middleware around it sees it, and
|
|
2583
|
+
* that handler answers every error with the same 500. The answer says how long the read took on
|
|
2584
|
+
* the server, after the token was checked (`Server-Timing: read;dur=<ms>`), so that a client can
|
|
2585
|
+
* tell the server's time from the network's.
|
|
2586
|
+
*/
|
|
2587
|
+
function readRoute(handler) {
|
|
2588
|
+
return async (c) => {
|
|
2589
|
+
const started = Date.now();
|
|
2590
|
+
try {
|
|
2591
|
+
const response = await handler(c);
|
|
2592
|
+
response.headers.set("server-timing", `read;dur=${Date.now() - started}`);
|
|
2593
|
+
return response;
|
|
2594
|
+
} catch (error) {
|
|
2595
|
+
if (!(error instanceof HollwareError)) throw error;
|
|
2596
|
+
return c.json({
|
|
2597
|
+
code: error.code,
|
|
2598
|
+
message: error.message,
|
|
2599
|
+
fix: error.fix
|
|
2600
|
+
}, STATUS[error.code] ?? 500);
|
|
2601
|
+
}
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2132
2604
|
/** A weak ETag of a body: the first 128 bits of its SHA-256, in hex. */
|
|
2133
2605
|
async function etagOf(body) {
|
|
2134
2606
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)));
|
|
@@ -2194,19 +2666,8 @@ function installReadApi(app, options) {
|
|
|
2194
2666
|
vary: "Origin",
|
|
2195
2667
|
"access-control-allow-origin": new URL(c.env.HOLLWARE_PUBLIC_URL).origin
|
|
2196
2668
|
});
|
|
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
|
-
})();
|
|
2669
|
+
await next();
|
|
2670
|
+
const answered = c.res;
|
|
2210
2671
|
if (crossOrigin === null) return answered;
|
|
2211
2672
|
const shared = new Response(answered.body, answered);
|
|
2212
2673
|
shared.headers.set("access-control-allow-origin", crossOrigin);
|
|
@@ -2217,8 +2678,18 @@ function installReadApi(app, options) {
|
|
|
2217
2678
|
return c.res;
|
|
2218
2679
|
});
|
|
2219
2680
|
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
|
|
2681
|
+
app.get("/v1/site", readRoute(async (c) => answer(c, await options.read(c.env).describeSite(c.get("site")))));
|
|
2682
|
+
app.get("/v1/search", readRoute(async (c) => {
|
|
2683
|
+
const query = c.req.query();
|
|
2684
|
+
const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
|
|
2685
|
+
return answer(c, await options.read(c.env).search(c.get("site"), query["q"] ?? "", {
|
|
2686
|
+
...query["collection"] === void 0 ? {} : { collection: query["collection"] },
|
|
2687
|
+
...query["locale"] === void 0 ? {} : { locale: query["locale"] },
|
|
2688
|
+
...limit === void 0 ? {} : { limit },
|
|
2689
|
+
...query["mode"] === void 0 ? {} : { mode: searchModeOf(query["mode"]) }
|
|
2690
|
+
}));
|
|
2691
|
+
}));
|
|
2692
|
+
app.get("/v1/:collection", readRoute(async (c) => {
|
|
2222
2693
|
const query = c.req.query();
|
|
2223
2694
|
const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
|
|
2224
2695
|
return answer(c, await options.read(c.env).listEntries(c.get("site"), c.req.param("collection"), {
|
|
@@ -2227,12 +2698,27 @@ function installReadApi(app, options) {
|
|
|
2227
2698
|
...limit === void 0 ? {} : { limit },
|
|
2228
2699
|
...query["cursor"] === void 0 ? {} : { cursor: query["cursor"] }
|
|
2229
2700
|
}));
|
|
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) => {
|
|
2701
|
+
}));
|
|
2702
|
+
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")))));
|
|
2703
|
+
app.get("/v1/:collection/:locale/:slug", readRoute(async (c) => {
|
|
2233
2704
|
const formats = c.req.query("formats");
|
|
2234
|
-
|
|
2235
|
-
|
|
2705
|
+
const related = c.req.query("related") === "true" ? { related: true } : {};
|
|
2706
|
+
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 : {
|
|
2707
|
+
...related,
|
|
2708
|
+
formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : [])
|
|
2709
|
+
}));
|
|
2710
|
+
}));
|
|
2711
|
+
}
|
|
2712
|
+
const SEARCH_MODES = [
|
|
2713
|
+
"fulltext",
|
|
2714
|
+
"semantic",
|
|
2715
|
+
"hybrid"
|
|
2716
|
+
];
|
|
2717
|
+
/** The `mode` of a search from the query string, or the error that names the ones there are. */
|
|
2718
|
+
function searchModeOf(value) {
|
|
2719
|
+
const mode = SEARCH_MODES.find((known) => known === value);
|
|
2720
|
+
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.`);
|
|
2721
|
+
return mode;
|
|
2236
2722
|
}
|
|
2237
2723
|
/** The OpenAPI 3.1 document of the read API. */
|
|
2238
2724
|
function openApiDocument() {
|
|
@@ -2271,6 +2757,20 @@ function openApiDocument() {
|
|
|
2271
2757
|
...common
|
|
2272
2758
|
}
|
|
2273
2759
|
} },
|
|
2760
|
+
"/v1/search": { get: {
|
|
2761
|
+
operationId: "search",
|
|
2762
|
+
parameters: [
|
|
2763
|
+
param("q", "query", true),
|
|
2764
|
+
param("collection", "query", false),
|
|
2765
|
+
param("locale", "query", false),
|
|
2766
|
+
param("limit", "query", false),
|
|
2767
|
+
param("mode", "query", false)
|
|
2768
|
+
],
|
|
2769
|
+
responses: {
|
|
2770
|
+
"200": ok("The published entries that match q, best first"),
|
|
2771
|
+
...common
|
|
2772
|
+
}
|
|
2773
|
+
} },
|
|
2274
2774
|
"/v1/{collection}": { get: {
|
|
2275
2775
|
operationId: "list_entries",
|
|
2276
2776
|
parameters: [
|
|
@@ -2300,7 +2800,8 @@ function openApiDocument() {
|
|
|
2300
2800
|
param("collection", "path", true),
|
|
2301
2801
|
param("locale", "path", true),
|
|
2302
2802
|
param("slug", "path", true),
|
|
2303
|
-
param("formats", "query", false)
|
|
2803
|
+
param("formats", "query", false),
|
|
2804
|
+
param("related", "query", false)
|
|
2304
2805
|
],
|
|
2305
2806
|
responses: {
|
|
2306
2807
|
"200": ok("A published entry"),
|
|
@@ -2495,7 +2996,7 @@ function plainPageHeaders() {
|
|
|
2495
2996
|
* The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
|
|
2496
2997
|
* routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
|
|
2497
2998
|
*/
|
|
2498
|
-
const ceremonies = () => import("./ceremonies-
|
|
2999
|
+
const ceremonies = () => import("./ceremonies-BM9G03lZ.mjs");
|
|
2499
3000
|
/** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
|
|
2500
3001
|
const SESSION_COOKIE = "hollware_session";
|
|
2501
3002
|
function sessionCookie(secret) {
|
|
@@ -2888,6 +3389,66 @@ function build(issuer, options) {
|
|
|
2888
3389
|
};
|
|
2889
3390
|
}
|
|
2890
3391
|
//#endregion
|
|
3392
|
+
//#region src/http/events.ts
|
|
3393
|
+
/** The largest beacon the route reads: a site key and a path, with room to spare. */
|
|
3394
|
+
const MAX_BEACON_BYTES = 2048;
|
|
3395
|
+
/** The origins whose pages may count views of a site: its own URL and its `cors.origins`. */
|
|
3396
|
+
function pageOriginsOf(site) {
|
|
3397
|
+
return [...site.url === null ? [] : [new URL(site.url).origin], ...site.cors.origins];
|
|
3398
|
+
}
|
|
3399
|
+
function refusal(status, code, message, fix) {
|
|
3400
|
+
return Response.json({
|
|
3401
|
+
code,
|
|
3402
|
+
message,
|
|
3403
|
+
fix
|
|
3404
|
+
}, {
|
|
3405
|
+
status,
|
|
3406
|
+
headers: { "cache-control": "no-store" }
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3409
|
+
/** The path of a view without its query and fragment, or `null` when it is no path. */
|
|
3410
|
+
function pathOf(value) {
|
|
3411
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return null;
|
|
3412
|
+
const { pathname } = new URL(value, "https://hollware.invalid");
|
|
3413
|
+
return pathname.length > 512 ? null : pathname;
|
|
3414
|
+
}
|
|
3415
|
+
/**
|
|
3416
|
+
* `POST /events`, the beacon of the sites (ADR-031): `{ "site": "<key>", "path": "/<path>" }`, sent
|
|
3417
|
+
* by a page with `navigator.sendBeacon`, so with no token. It counts a view of the path when the
|
|
3418
|
+
* page is of one of the site's origins (its URL or its `cors.origins`), and keeps the site, the
|
|
3419
|
+
* path and the country Cloudflare gives the request: never a cookie, an IP or a user agent. It
|
|
3420
|
+
* answers 204 whether or not the Worker has an `ANALYTICS` binding, so that a page cannot tell.
|
|
3421
|
+
*/
|
|
3422
|
+
function installEvents(app, options) {
|
|
3423
|
+
app.post("/events", async (c) => {
|
|
3424
|
+
const text = await c.req.text();
|
|
3425
|
+
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.");
|
|
3426
|
+
let body;
|
|
3427
|
+
try {
|
|
3428
|
+
body = JSON.parse(text);
|
|
3429
|
+
} catch {
|
|
3430
|
+
body = null;
|
|
3431
|
+
}
|
|
3432
|
+
const key = typeof body === "object" && body !== null && "site" in body ? body.site : null;
|
|
3433
|
+
const site = typeof key === "string" ? options.sites.get(key) : void 0;
|
|
3434
|
+
const path = typeof body === "object" && body !== null && "path" in body ? pathOf(body.path) : null;
|
|
3435
|
+
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>\" }.");
|
|
3436
|
+
const origin = c.req.header("origin") ?? null;
|
|
3437
|
+
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.`);
|
|
3438
|
+
const cf = Reflect.get(c.req.raw, "cf");
|
|
3439
|
+
const country = typeof cf === "object" && cf !== null && "country" in cf && typeof cf.country === "string" ? cf.country : "XX";
|
|
3440
|
+
analyticsWriterOf(c.env)?.record({
|
|
3441
|
+
site: site.key,
|
|
3442
|
+
path,
|
|
3443
|
+
country
|
|
3444
|
+
});
|
|
3445
|
+
return new Response(null, {
|
|
3446
|
+
status: 204,
|
|
3447
|
+
headers: { "cache-control": "no-store" }
|
|
3448
|
+
});
|
|
3449
|
+
});
|
|
3450
|
+
}
|
|
3451
|
+
//#endregion
|
|
2891
3452
|
//#region src/http/gateway.ts
|
|
2892
3453
|
/**
|
|
2893
3454
|
* A 401 of a resource server, with Hollware's body: the same JSON as every other refusal, and the
|
|
@@ -3006,6 +3567,7 @@ function createGateway(options) {
|
|
|
3006
3567
|
*/
|
|
3007
3568
|
const readsFromAnyCorsOrigin = (request, origin) => {
|
|
3008
3569
|
const { pathname } = new URL(request.url);
|
|
3570
|
+
if (request.method === "POST" && pathname === "/events") return true;
|
|
3009
3571
|
return corsOrigins.has(origin) && [
|
|
3010
3572
|
"GET",
|
|
3011
3573
|
"HEAD",
|
|
@@ -3047,7 +3609,9 @@ function createGateway(options) {
|
|
|
3047
3609
|
site,
|
|
3048
3610
|
store: createD1Store(db, { clock }),
|
|
3049
3611
|
clock,
|
|
3050
|
-
notify: notify(c, requestId, site.key)
|
|
3612
|
+
notify: notify(c, requestId, site.key),
|
|
3613
|
+
semantic: semanticOfEnv(c.env),
|
|
3614
|
+
analytics: analyticsReaderOf(c.env)
|
|
3051
3615
|
}
|
|
3052
3616
|
});
|
|
3053
3617
|
}));
|
|
@@ -3090,6 +3654,7 @@ function createGateway(options) {
|
|
|
3090
3654
|
protect: (c, proceed) => protect(c, "v1", proceed),
|
|
3091
3655
|
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
|
|
3092
3656
|
});
|
|
3657
|
+
installEvents(app, { sites });
|
|
3093
3658
|
installErrorHandling(app, log);
|
|
3094
3659
|
return async (request, env, ctx) => {
|
|
3095
3660
|
contexts.set(request, ctx);
|
|
@@ -3278,6 +3843,7 @@ const encoder = new TextEncoder();
|
|
|
3278
3843
|
*/
|
|
3279
3844
|
const OUTPUT_TOO_LARGE_FIX = {
|
|
3280
3845
|
approve_review: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
|
|
3846
|
+
content_stats: "Ask for less: pass a smaller limit.",
|
|
3281
3847
|
create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3282
3848
|
describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
|
|
3283
3849
|
get_entry: "Ask for one format at a time with formats: markdown, document or html, instead of several together.",
|
|
@@ -3286,6 +3852,7 @@ const OUTPUT_TOO_LARGE_FIX = {
|
|
|
3286
3852
|
list_reviews: "Ask for less: pass a smaller limit.",
|
|
3287
3853
|
publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3288
3854
|
request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
|
|
3855
|
+
search: "Ask for less: pass a smaller limit.",
|
|
3289
3856
|
unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3290
3857
|
update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3291
3858
|
validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
|
|
@@ -3426,6 +3993,33 @@ function createToolTable(operations = OPERATIONS) {
|
|
|
3426
3993
|
});
|
|
3427
3994
|
}
|
|
3428
3995
|
//#endregion
|
|
3996
|
+
//#region src/mcp/prompts.ts
|
|
3997
|
+
/**
|
|
3998
|
+
* The prompts of the MCP endpoint: workflows that an agent's user picks by name, written once
|
|
3999
|
+
* here. A prompt only says what to do; every step is a tool, checked as any call is.
|
|
4000
|
+
*/
|
|
4001
|
+
const REFRESH_CONTENT = {
|
|
4002
|
+
name: "refresh_content",
|
|
4003
|
+
title: "Refresh what readers read",
|
|
4004
|
+
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.",
|
|
4005
|
+
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.") })
|
|
4006
|
+
};
|
|
4007
|
+
/** The text of `refresh_content`: the steps, each with the tool that takes it. */
|
|
4008
|
+
function refreshContentText(days) {
|
|
4009
|
+
return [
|
|
4010
|
+
`Refresh the content that readers read most, from the last ${days} days.`,
|
|
4011
|
+
"",
|
|
4012
|
+
`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.`,
|
|
4013
|
+
"2. Read it with get_entry, with related: true, and read describe_site for its collection: its schema, contracts and style guide.",
|
|
4014
|
+
"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.",
|
|
4015
|
+
"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.",
|
|
4016
|
+
"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.",
|
|
4017
|
+
"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.",
|
|
4018
|
+
"",
|
|
4019
|
+
"Report the entry you picked and why, what you changed, and how the publish ended."
|
|
4020
|
+
].join("\n");
|
|
4021
|
+
}
|
|
4022
|
+
//#endregion
|
|
3429
4023
|
//#region src/mcp/endpoint.ts
|
|
3430
4024
|
/**
|
|
3431
4025
|
* `tools/list` is the same for every client and changes only when the Worker is deployed again:
|
|
@@ -3547,7 +4141,10 @@ function createMcpEndpoint(options) {
|
|
|
3547
4141
|
const call = request.authInfo === void 0 ? void 0 : calls.get(request.authInfo);
|
|
3548
4142
|
if (call === void 0) throw new Error("A request reached the MCP server without the call the gateway made for it");
|
|
3549
4143
|
const server = new McpServer(options.serverInfo, {
|
|
3550
|
-
capabilities: {
|
|
4144
|
+
capabilities: {
|
|
4145
|
+
tools: { listChanged: false },
|
|
4146
|
+
prompts: { listChanged: false }
|
|
4147
|
+
},
|
|
3551
4148
|
cacheHints: { "tools/list": TOOLS_LIST_CACHE_HINT }
|
|
3552
4149
|
});
|
|
3553
4150
|
for (const tool of tools) server.registerTool(tool.spec.name, {
|
|
@@ -3567,6 +4164,17 @@ function createMcpEndpoint(options) {
|
|
|
3567
4164
|
detail: null
|
|
3568
4165
|
}))
|
|
3569
4166
|
}, (input, mcp) => runTool(tool.spec, input, mcp, call, request.era));
|
|
4167
|
+
server.registerPrompt(REFRESH_CONTENT.name, {
|
|
4168
|
+
title: REFRESH_CONTENT.title,
|
|
4169
|
+
description: REFRESH_CONTENT.description,
|
|
4170
|
+
argsSchema: REFRESH_CONTENT.args
|
|
4171
|
+
}, ({ days }) => ({ messages: [{
|
|
4172
|
+
role: "user",
|
|
4173
|
+
content: {
|
|
4174
|
+
type: "text",
|
|
4175
|
+
text: refreshContentText(days === void 0 ? 30 : Number(days))
|
|
4176
|
+
}
|
|
4177
|
+
}] }));
|
|
3570
4178
|
return server;
|
|
3571
4179
|
};
|
|
3572
4180
|
const handler = createMcpHandler(buildServer, { onerror: (error) => emit({
|
|
@@ -3666,7 +4274,8 @@ async function run(deps, env, method, site, schema, build, handler) {
|
|
|
3666
4274
|
store: createD1Store(env.DB.withSession("first-unconstrained")),
|
|
3667
4275
|
clock: deps.clock,
|
|
3668
4276
|
client: null,
|
|
3669
|
-
requestId
|
|
4277
|
+
requestId,
|
|
4278
|
+
semantic: semanticOfEnv(env)
|
|
3670
4279
|
};
|
|
3671
4280
|
try {
|
|
3672
4281
|
return await handler(input, context);
|
|
@@ -3686,7 +4295,7 @@ function createRpc(deps, env) {
|
|
|
3686
4295
|
collection,
|
|
3687
4296
|
locale,
|
|
3688
4297
|
slug,
|
|
3689
|
-
...readOptions(options, ["formats"])
|
|
4298
|
+
...readOptions(options, ["formats", "related"])
|
|
3690
4299
|
}), deps.handlers.get_entry),
|
|
3691
4300
|
listEntries: (site, collection, options) => run(deps, env, "listEntries", site, listEntries.input, () => ({
|
|
3692
4301
|
collection,
|
|
@@ -3701,7 +4310,16 @@ function createRpc(deps, env) {
|
|
|
3701
4310
|
collection,
|
|
3702
4311
|
translationGroup
|
|
3703
4312
|
}), deps.handlers.get_translations),
|
|
3704
|
-
describeSite: (site) => run(deps, env, "describeSite", site, describeSite.input, () => ({}), deps.handlers.describe_site)
|
|
4313
|
+
describeSite: (site) => run(deps, env, "describeSite", site, describeSite.input, () => ({}), deps.handlers.describe_site),
|
|
4314
|
+
search: (site, query, options) => run(deps, env, "search", site, search.input, () => ({
|
|
4315
|
+
query,
|
|
4316
|
+
...readOptions(options, [
|
|
4317
|
+
"collection",
|
|
4318
|
+
"locale",
|
|
4319
|
+
"limit",
|
|
4320
|
+
"mode"
|
|
4321
|
+
])
|
|
4322
|
+
}), deps.handlers.search)
|
|
3705
4323
|
};
|
|
3706
4324
|
}
|
|
3707
4325
|
//#endregion
|
|
@@ -3730,6 +4348,9 @@ function createEntrypoint(deps) {
|
|
|
3730
4348
|
describeSite(...args) {
|
|
3731
4349
|
return createRpc(deps, this.env).describeSite(...args);
|
|
3732
4350
|
}
|
|
4351
|
+
search(...args) {
|
|
4352
|
+
return createRpc(deps, this.env).search(...args);
|
|
4353
|
+
}
|
|
3733
4354
|
};
|
|
3734
4355
|
}
|
|
3735
4356
|
//#endregion
|
|
@@ -3859,7 +4480,8 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
3859
4480
|
secret: (name) => {
|
|
3860
4481
|
const value = Reflect.get(env, name);
|
|
3861
4482
|
return typeof value === "string" ? value : void 0;
|
|
3862
|
-
}
|
|
4483
|
+
},
|
|
4484
|
+
semantic: semanticOfEnv(env)
|
|
3863
4485
|
});
|
|
3864
4486
|
return {
|
|
3865
4487
|
fetch: createGateway({
|
|
@@ -3889,6 +4511,18 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
3889
4511
|
queue: env.EVENTS,
|
|
3890
4512
|
deps: deliveryDeps(env, site)
|
|
3891
4513
|
});
|
|
4514
|
+
const semantic = semanticOfEnv(env);
|
|
4515
|
+
if (semantic !== null) {
|
|
4516
|
+
const now = clock.now().toISOString();
|
|
4517
|
+
await healVectors(connect(env.DB), semantic, {
|
|
4518
|
+
site: site.key,
|
|
4519
|
+
now
|
|
4520
|
+
});
|
|
4521
|
+
await refreshRelated(connect(env.DB), semantic, {
|
|
4522
|
+
site: site.key,
|
|
4523
|
+
now
|
|
4524
|
+
});
|
|
4525
|
+
}
|
|
3892
4526
|
};
|
|
3893
4527
|
ctx.waitUntil((async () => {
|
|
3894
4528
|
for (const site of sites.values()) await each(site).catch((error) => {
|
|
@@ -3915,53 +4549,6 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
3915
4549
|
};
|
|
3916
4550
|
}
|
|
3917
4551
|
//#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
4552
|
//#region src/ops/answers.ts
|
|
3966
4553
|
/** A review as an output. */
|
|
3967
4554
|
function reviewView(review) {
|
|
@@ -4063,6 +4650,105 @@ function publicationView(publication) {
|
|
|
4063
4650
|
};
|
|
4064
4651
|
}
|
|
4065
4652
|
//#endregion
|
|
4653
|
+
//#region src/ops/content-stats.ts
|
|
4654
|
+
/** A path without its trailing slash, so that `/blog/a` and `/blog/a/` are one page. */
|
|
4655
|
+
function trimmed(path) {
|
|
4656
|
+
return path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
4657
|
+
}
|
|
4658
|
+
/**
|
|
4659
|
+
* The entry that a path of the site shows, by the routes of its collections: the slug is the part
|
|
4660
|
+
* of the path where the route has `:slug`. `null` for a path that no route has.
|
|
4661
|
+
*/
|
|
4662
|
+
function entryRefOf(site, path) {
|
|
4663
|
+
const wanted = trimmed(path);
|
|
4664
|
+
for (const [collection, resolved] of site.collections) for (const [locale, route] of Object.entries(resolved.routes)) {
|
|
4665
|
+
const [before, after, ...rest] = trimmed(route).split(":slug");
|
|
4666
|
+
if (before === void 0 || after === void 0 || rest.length > 0) continue;
|
|
4667
|
+
if (!wanted.startsWith(before) || !wanted.endsWith(after)) continue;
|
|
4668
|
+
const slug = wanted.slice(before.length, wanted.length - after.length);
|
|
4669
|
+
if (slug !== "" && !slug.includes("/")) return {
|
|
4670
|
+
collection,
|
|
4671
|
+
locale,
|
|
4672
|
+
slug
|
|
4673
|
+
};
|
|
4674
|
+
}
|
|
4675
|
+
return null;
|
|
4676
|
+
}
|
|
4677
|
+
/**
|
|
4678
|
+
* The most read pages of the site, from the counts of its beacons (ADR-031), each with the
|
|
4679
|
+
* published entry it shows. `unavailable` when the deployment cannot read the counts.
|
|
4680
|
+
*/
|
|
4681
|
+
const contentStats = async (input, context) => {
|
|
4682
|
+
const analytics = context.analytics ?? null;
|
|
4683
|
+
if (analytics === null) return { outcome: "unavailable" };
|
|
4684
|
+
const site = context.site;
|
|
4685
|
+
const counts = await analytics.topPages(site.key, {
|
|
4686
|
+
days: input.days,
|
|
4687
|
+
limit: input.limit
|
|
4688
|
+
});
|
|
4689
|
+
const pages = await Promise.all(counts.map(async (count) => {
|
|
4690
|
+
const ref = entryRefOf(site, count.path);
|
|
4691
|
+
const found = ref === null ? null : await context.store.findEntry(site.key, ref, "published");
|
|
4692
|
+
return {
|
|
4693
|
+
path: count.path,
|
|
4694
|
+
views: count.views,
|
|
4695
|
+
entry: found === null ? null : entrySummary(found)
|
|
4696
|
+
};
|
|
4697
|
+
}));
|
|
4698
|
+
return {
|
|
4699
|
+
outcome: "ok",
|
|
4700
|
+
since: (/* @__PURE__ */ new Date(context.clock.now().getTime() - input.days * 864e5)).toISOString(),
|
|
4701
|
+
pages
|
|
4702
|
+
};
|
|
4703
|
+
};
|
|
4704
|
+
//#endregion
|
|
4705
|
+
//#region src/ops/errors.ts
|
|
4706
|
+
/**
|
|
4707
|
+
* The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
|
|
4708
|
+
* Like the `internal` error of the adapters, its message says nothing about the cause, which goes
|
|
4709
|
+
* to the log through `cause`, and its fix names the request id the operator finds the log line by.
|
|
4710
|
+
*/
|
|
4711
|
+
function internalError(context, cause) {
|
|
4712
|
+
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) });
|
|
4713
|
+
}
|
|
4714
|
+
/**
|
|
4715
|
+
* The error for an answer of the store that the call it came from can never get: an entry that
|
|
4716
|
+
* is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
|
|
4717
|
+
* operation, so it is not one of the outcomes of its spec.
|
|
4718
|
+
*/
|
|
4719
|
+
function unexpectedAnswer(context, call, answer) {
|
|
4720
|
+
return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
|
|
4721
|
+
}
|
|
4722
|
+
//#endregion
|
|
4723
|
+
//#region src/ops/actor.ts
|
|
4724
|
+
/**
|
|
4725
|
+
* Who a write is made by: the person behind the token of the call, with what its client said
|
|
4726
|
+
* about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
|
|
4727
|
+
* client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
|
|
4728
|
+
* the scopes of the operation before any handler runs and never route a write for the RPC
|
|
4729
|
+
* binding, so reaching one with it is a bug, answered as `internal` before anything is read or
|
|
4730
|
+
* written.
|
|
4731
|
+
*/
|
|
4732
|
+
function actorOf(context) {
|
|
4733
|
+
const { principal } = context;
|
|
4734
|
+
if (principal.kind === "token") return {
|
|
4735
|
+
kind: "token",
|
|
4736
|
+
userId: principal.userId,
|
|
4737
|
+
tokenId: principal.tokenId,
|
|
4738
|
+
meta: context.client
|
|
4739
|
+
};
|
|
4740
|
+
if (principal.kind === "oauth") return {
|
|
4741
|
+
kind: "oauth_client",
|
|
4742
|
+
userId: principal.userId,
|
|
4743
|
+
tokenId: null,
|
|
4744
|
+
meta: {
|
|
4745
|
+
...context.client,
|
|
4746
|
+
oauthClient: principal.clientId
|
|
4747
|
+
}
|
|
4748
|
+
};
|
|
4749
|
+
throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
|
|
4750
|
+
}
|
|
4751
|
+
//#endregion
|
|
4066
4752
|
//#region src/ops/collections.ts
|
|
4067
4753
|
/**
|
|
4068
4754
|
* Which collection and locale a call names, checked against the config of the site before
|
|
@@ -4261,6 +4947,7 @@ const getEntry$1 = async (input, context) => {
|
|
|
4261
4947
|
if (found === null) return { outcome: "not_found" };
|
|
4262
4948
|
const { entry, revision } = found;
|
|
4263
4949
|
const publication = await context.store.lastPublication(context.site.key, entry.id);
|
|
4950
|
+
const related = input.related ? await relatedEntries(context, entry.id) : void 0;
|
|
4264
4951
|
return {
|
|
4265
4952
|
outcome: "ok",
|
|
4266
4953
|
entry: {
|
|
@@ -4271,9 +4958,19 @@ const getEntry$1 = async (input, context) => {
|
|
|
4271
4958
|
...input.formats.includes("markdown") ? { markdown: revision.markdown } : {},
|
|
4272
4959
|
...input.formats.includes("document") ? { document: revision.document } : {},
|
|
4273
4960
|
...input.formats.includes("html") ? { html: await renderHtml(revision.document) } : {}
|
|
4274
|
-
}
|
|
4961
|
+
},
|
|
4962
|
+
...related === void 0 ? {} : { related }
|
|
4275
4963
|
};
|
|
4276
4964
|
};
|
|
4965
|
+
/**
|
|
4966
|
+
* The published entries closest to one, as its vectors found them when it was indexed: none when
|
|
4967
|
+
* the deployment has no semantic search, and none that is no longer published.
|
|
4968
|
+
*/
|
|
4969
|
+
async function relatedEntries(context, entryId) {
|
|
4970
|
+
if ((context.semantic ?? null) === null) return [];
|
|
4971
|
+
const related = await context.store.relatedOf(context.site.key, entryId);
|
|
4972
|
+
return (await context.store.findSummaries(context.site.key, related.map((item) => item.id), "published")).map(entrySummary);
|
|
4973
|
+
}
|
|
4277
4974
|
//#endregion
|
|
4278
4975
|
//#region src/ops/get-translations.ts
|
|
4279
4976
|
/** Reads the entries of a translation group in the view of the call, one per locale. */
|
|
@@ -4308,6 +5005,93 @@ const listEntries$1 = async (input, context) => {
|
|
|
4308
5005
|
nextCursor: page.nextCursor
|
|
4309
5006
|
};
|
|
4310
5007
|
};
|
|
5008
|
+
/** The published entries closest to the meaning of the query, closest first. */
|
|
5009
|
+
async function semanticSearch(semantic, store, site, query) {
|
|
5010
|
+
const [vector] = await semantic.embedder.embed([query.text]);
|
|
5011
|
+
const matches = await semantic.index.query(vector ?? [], {
|
|
5012
|
+
topK: 20,
|
|
5013
|
+
site,
|
|
5014
|
+
...query.collection === void 0 ? {} : { collection: query.collection },
|
|
5015
|
+
...query.locale === void 0 ? {} : { locale: query.locale }
|
|
5016
|
+
});
|
|
5017
|
+
const best = /* @__PURE__ */ new Map();
|
|
5018
|
+
for (const match of matches) {
|
|
5019
|
+
const kept = best.get(match.metadata.entry);
|
|
5020
|
+
if (kept === void 0 || match.score > kept.score) best.set(match.metadata.entry, match);
|
|
5021
|
+
}
|
|
5022
|
+
const ranked = [...best.values()].sort((a, b) => b.score - a.score).slice(0, query.limit);
|
|
5023
|
+
const summaries = await store.findSummaries(site, ranked.map((match) => match.metadata.entry), "published");
|
|
5024
|
+
const byId = new Map(summaries.map((summary) => [summary.entry.id, summary]));
|
|
5025
|
+
return ranked.flatMap((match) => {
|
|
5026
|
+
const entry = byId.get(match.metadata.entry);
|
|
5027
|
+
return entry === void 0 ? [] : [{
|
|
5028
|
+
entry,
|
|
5029
|
+
snippet: match.metadata.excerpt,
|
|
5030
|
+
score: match.score
|
|
5031
|
+
}];
|
|
5032
|
+
});
|
|
5033
|
+
}
|
|
5034
|
+
/**
|
|
5035
|
+
* Merges rankings by reciprocal rank fusion: an entry scores `1 / (FUSION_K + place)` in each list
|
|
5036
|
+
* it is in, places counted from 1. An entry keeps the snippet of the first list that has it, so
|
|
5037
|
+
* the words found by full text win over the excerpt of a match by meaning.
|
|
5038
|
+
*/
|
|
5039
|
+
function fuse(lists, limit) {
|
|
5040
|
+
const merged = /* @__PURE__ */ new Map();
|
|
5041
|
+
for (const list of lists) list.forEach((hit, place) => {
|
|
5042
|
+
const gain = 1 / (60 + place + 1);
|
|
5043
|
+
const kept = merged.get(hit.entry.entry.id);
|
|
5044
|
+
merged.set(hit.entry.entry.id, kept === void 0 ? {
|
|
5045
|
+
...hit,
|
|
5046
|
+
score: gain
|
|
5047
|
+
} : {
|
|
5048
|
+
...kept,
|
|
5049
|
+
score: kept.score + gain
|
|
5050
|
+
});
|
|
5051
|
+
});
|
|
5052
|
+
return [...merged.values()].sort((a, b) => b.score - a.score).slice(0, limit);
|
|
5053
|
+
}
|
|
5054
|
+
//#endregion
|
|
5055
|
+
//#region src/ops/search.ts
|
|
5056
|
+
/**
|
|
5057
|
+
* The published entries of the site that match the query, best first: by its words, by meaning,
|
|
5058
|
+
* or both (ADR-030). Without the semantic search of the deployment, every mode is full text, and
|
|
5059
|
+
* the answer's `mode` says which one ran.
|
|
5060
|
+
*/
|
|
5061
|
+
const search$1 = async (input, context) => {
|
|
5062
|
+
if (input.collection !== void 0) requireCollection(context.site, input.collection);
|
|
5063
|
+
if (input.locale !== void 0) requireLocale(context.site, input.locale);
|
|
5064
|
+
const query = {
|
|
5065
|
+
text: input.query,
|
|
5066
|
+
collection: input.collection,
|
|
5067
|
+
locale: input.locale,
|
|
5068
|
+
limit: input.limit
|
|
5069
|
+
};
|
|
5070
|
+
const semantic = context.semantic ?? null;
|
|
5071
|
+
const asked = input.mode ?? "hybrid";
|
|
5072
|
+
const mode = semantic === null ? "fulltext" : asked;
|
|
5073
|
+
const site = context.site.key;
|
|
5074
|
+
let hits;
|
|
5075
|
+
if (semantic === null || mode === "fulltext") hits = await context.store.searchEntries(site, query);
|
|
5076
|
+
else if (mode === "semantic") hits = await semanticSearch(semantic, context.store, site, query);
|
|
5077
|
+
else {
|
|
5078
|
+
const wide = {
|
|
5079
|
+
...query,
|
|
5080
|
+
limit: Math.max(query.limit, 20)
|
|
5081
|
+
};
|
|
5082
|
+
const [words, meaning] = await Promise.all([context.store.searchEntries(site, wide), semanticSearch(semantic, context.store, site, wide)]);
|
|
5083
|
+
hits = fuse([words, meaning], query.limit);
|
|
5084
|
+
}
|
|
5085
|
+
return {
|
|
5086
|
+
outcome: "ok",
|
|
5087
|
+
mode,
|
|
5088
|
+
results: hits.map((hit) => ({
|
|
5089
|
+
entry: entrySummary(hit.entry),
|
|
5090
|
+
snippet: hit.snippet,
|
|
5091
|
+
score: hit.score
|
|
5092
|
+
}))
|
|
5093
|
+
};
|
|
5094
|
+
};
|
|
4311
5095
|
//#endregion
|
|
4312
5096
|
//#region src/ops/event.ts
|
|
4313
5097
|
/** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
|
|
@@ -4842,11 +5626,13 @@ function defineHandlers(handlers) {
|
|
|
4842
5626
|
*/
|
|
4843
5627
|
const handlers = defineHandlers({
|
|
4844
5628
|
approve_review: approveReview,
|
|
5629
|
+
content_stats: contentStats,
|
|
4845
5630
|
create_draft: createDraft,
|
|
4846
5631
|
describe_site: describeSite$1,
|
|
4847
5632
|
get_entry: getEntry$1,
|
|
4848
5633
|
get_translations: getTranslations$1,
|
|
4849
5634
|
list_entries: listEntries$1,
|
|
5635
|
+
search: search$1,
|
|
4850
5636
|
list_reviews: listReviews,
|
|
4851
5637
|
publish: publish$1,
|
|
4852
5638
|
request_changes: requestChanges,
|