diff --git a/Dockerfile b/Dockerfile index d3253b1c..e38f3cb4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,8 @@ COPY federation/package.json /app/federation/package.json COPY graphql/deno.json /app/graphql/deno.json COPY models/deno.json /app/models/deno.json COPY models/package.json /app/models/package.json +COPY runtime/deno.json /app/runtime/deno.json +COPY runtime/package.json /app/runtime/package.json COPY web/deno.json /app/web/deno.json COPY web-next/deno.jsonc /app/web-next/deno.jsonc COPY web-next/package.json /app/web-next/package.json @@ -82,6 +84,8 @@ COPY federation/package.json /app/federation/package.json COPY graphql/deno.json /app/graphql/deno.json COPY models/deno.json /app/models/deno.json COPY models/package.json /app/models/package.json +COPY runtime/deno.json /app/runtime/deno.json +COPY runtime/package.json /app/runtime/package.json COPY web/deno.json /app/web/deno.json COPY web-next/deno.jsonc /app/web-next/deno.jsonc COPY web-next/package.json /app/web-next/package.json diff --git a/ai/moderation.ts b/ai/moderation.ts index 9fd0ab3c..584bfaf4 100644 --- a/ai/moderation.ts +++ b/ai/moderation.ts @@ -1,19 +1,20 @@ -import { generateObject, jsonSchema } from "ai"; +import { generateObject, jsonSchema, type LanguageModel } from "ai"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { CocProvision } from "@hackerspub/models/coc"; import type { ModerationAnalysis, ModerationAnalysisMatch, - ModerationAnalysisOptions, + ModerationAnalysisOptions as ApplicationModerationAnalysisOptions, } from "@hackerspub/models/services"; export type ModerationProvision = CocProvision; -export type { - ModerationAnalysis, - ModerationAnalysisMatch, - ModerationAnalysisOptions, -}; +export type { ModerationAnalysis, ModerationAnalysisMatch }; + +export interface ModerationAnalysisOptions + extends Omit { + model: LanguageModel; +} const ANALYSIS_SCHEMA = jsonSchema({ type: "object", diff --git a/ai/services.ts b/ai/services.ts index 971633fe..421c57d1 100644 --- a/ai/services.ts +++ b/ai/services.ts @@ -1,10 +1,34 @@ import type { AiServices } from "@hackerspub/models/services"; +import type { LanguageModel } from "ai"; import { analyzeFlaggedContent } from "./moderation.ts"; import { summarize } from "./summary.ts"; import { translate } from "./translate.ts"; +function unwrapModel( + model: { readonly implementation: unknown }, +): LanguageModel { + return model != null && "implementation" in model + ? model.implementation as LanguageModel + : model as unknown as LanguageModel; +} + export const aiServices: AiServices = { - analyzeFlaggedContent, - summarize, - translate, + analyzeFlaggedContent: (options) => + analyzeFlaggedContent({ + ...options, + model: unwrapModel(options.model), + }), + summarize: (options) => + summarize({ + ...options, + model: unwrapModel(options.model), + }), + translate: (options) => + translate({ + ...options, + model: unwrapModel(options.model), + summarizationModel: options.summarizationModel == null + ? undefined + : unwrapModel(options.summarizationModel), + }), }; diff --git a/ai/summary.ts b/ai/summary.ts index ba822cf2..97763cac 100644 --- a/ai/summary.ts +++ b/ai/summary.ts @@ -1,4 +1,4 @@ -import { generateText } from "ai"; +import { generateText, type LanguageModel } from "ai"; import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { @@ -6,7 +6,7 @@ import { isLocale, type Locale, } from "@hackerspub/models/i18n"; -import type { SummaryOptions } from "@hackerspub/models/services"; +import type { SummaryOptions as ApplicationSummaryOptions } from "@hackerspub/models/services"; import { removeDetailsFromSummaryInput } from "@hackerspub/models/summary"; const PROMPT_LANGUAGES: Locale[] = ( @@ -16,6 +16,11 @@ const PROMPT_LANGUAGES: Locale[] = ( ) ).map((f) => f.name.replace(/\.md$/, "")).filter(isLocale); +export interface SummaryOptions + extends Omit { + model: LanguageModel; +} + async function getSummaryPrompt( sourceLanguage: string, targetLanguage: string, @@ -39,7 +44,6 @@ async function getSummaryPrompt( } export { removeDetailsFromSummaryInput }; -export type { SummaryOptions }; export async function summarize(options: SummaryOptions): Promise { const system = await getSummaryPrompt( diff --git a/ai/translate.ts b/ai/translate.ts index 688d1cf5..64ebf256 100644 --- a/ai/translate.ts +++ b/ai/translate.ts @@ -1,9 +1,14 @@ import { fetchWebPage } from "@vertana/context-web"; import type { ContextSource, RequiredContextSource } from "@vertana/core"; import { translate as vertanaTranslate } from "@vertana/facade"; -import type { TranslationOptions } from "@hackerspub/models/services"; +import type { LanguageModel } from "ai"; +import type { TranslationOptions as ApplicationTranslationOptions } from "@hackerspub/models/services"; -export type { TranslationOptions }; +export interface TranslationOptions + extends Omit { + model: LanguageModel; + summarizationModel?: LanguageModel; +} /** * Creates a required context source with author information @@ -72,7 +77,10 @@ export async function translate(options: TranslationOptions): Promise { maxCharsPerPage: 10_000, maxTotalChars: 30_000, ...(options.summarizationModel && { - summarize: { model: options.summarizationModel, maxChars: 3_000 }, + summarize: { + model: options.summarizationModel, + maxChars: 3_000, + }, }), })); diff --git a/deno.json b/deno.json index e18610a0..86a47ad8 100644 --- a/deno.json +++ b/deno.json @@ -4,6 +4,7 @@ "./federation", "./graphql", "./models", + "./runtime", "./web", "./web-next" ], @@ -80,9 +81,9 @@ "@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.15", "@types/fluent-ffmpeg": "npm:@types/fluent-ffmpeg@^2.1.27", "@types/mdast": "npm:@types/mdast@^4.0.4", - "@upyo/core": "jsr:@upyo/core@^0.1.1", - "@upyo/mailgun": "jsr:@upyo/mailgun@^0.1.1", - "@upyo/mock": "jsr:@upyo/mock@^0.1.1", + "@upyo/core": "jsr:@upyo/core@^0.5.0", + "@upyo/mailgun": "jsr:@upyo/mailgun@^0.5.0", + "@upyo/mock": "jsr:@upyo/mock@^0.5.0", "@valibot/valibot": "jsr:@valibot/valibot@^1.0.0", "@vertana/context-web": "npm:@vertana/context-web@^0.2.0", "@vertana/core": "jsr:@vertana/core@^0.2.0", diff --git a/deno.lock b/deno.lock index b94a9b60..9895e680 100644 --- a/deno.lock +++ b/deno.lock @@ -21,9 +21,9 @@ "jsr:@logtape/drizzle-orm@2.1.1": "2.1.1", "jsr:@logtape/file@2.1.1": "2.1.1", "jsr:@logtape/logtape@2.1.1": "2.1.1", - "jsr:@logtape/logtape@^2.0.7": "2.2.1", - "jsr:@logtape/logtape@^2.1.1": "2.2.1", - "jsr:@logtape/logtape@^2.2.0": "2.2.1", + "jsr:@logtape/logtape@^2.0.7": "2.1.1", + "jsr:@logtape/logtape@^2.1.1": "2.2.4", + "jsr:@logtape/logtape@^2.2.0": "2.2.4", "jsr:@logtape/logtape@^2.2.0-dev.620+455a47e2": "2.2.1", "jsr:@logtape/redaction@2.1.1": "2.1.1", "jsr:@logtape/sentry@2.2.0-dev.620+455a47e2": "2.2.0-dev.620+455a47e2", @@ -35,21 +35,24 @@ "jsr:@std/assert@0.221": "0.221.0", "jsr:@std/assert@^1.0.12": "1.0.19", "jsr:@std/assert@~0.225.2": "0.225.3", - "jsr:@std/async@^1.0.12": "1.4.0", + "jsr:@std/async@^1.0.12": "1.5.0", "jsr:@std/bytes@^1.0.2": "1.0.6", "jsr:@std/bytes@^1.0.6": "1.0.6", "jsr:@std/cli@^1.0.30": "1.0.30", - "jsr:@std/collections@^1.0.10": "1.2.0", + "jsr:@std/cli@^1.0.31": "1.0.32", + "jsr:@std/collections@^1.0.10": "1.3.0", "jsr:@std/crypto@1": "1.1.0", "jsr:@std/crypto@^1.1.0": "1.1.0", "jsr:@std/data-structures@^1.0.11": "1.0.11", "jsr:@std/data-structures@^1.1.0": "1.1.0", + "jsr:@std/data-structures@^1.1.1": "1.1.1", "jsr:@std/datetime@~0.225.2": "0.225.7", "jsr:@std/dotenv@~0.225.3": "0.225.7", - "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/encoding@1": "1.0.11", "jsr:@std/encoding@^1.0.10": "1.0.10", - "jsr:@std/encoding@^1.0.5": "1.0.10", - "jsr:@std/encoding@^1.0.8": "1.0.10", + "jsr:@std/encoding@^1.0.11": "1.0.11", + "jsr:@std/encoding@^1.0.5": "1.0.11", + "jsr:@std/encoding@^1.0.8": "1.0.11", "jsr:@std/fmt@1": "1.0.10", "jsr:@std/fmt@^1.0.10": "1.0.10", "jsr:@std/fs@1": "1.0.24", @@ -59,8 +62,8 @@ "jsr:@std/html@1": "1.0.7", "jsr:@std/html@^1.0.3": "1.0.7", "jsr:@std/html@^1.0.7": "1.0.7", - "jsr:@std/http@^1.0.13": "1.1.1", - "jsr:@std/http@^1.0.15": "1.1.1", + "jsr:@std/http@^1.0.13": "1.1.2", + "jsr:@std/http@^1.0.15": "1.1.2", "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/json@^1.0.2": "1.1.0", @@ -69,22 +72,22 @@ "jsr:@std/media-types@^1.1.0": "1.1.0", "jsr:@std/net@^1.0.6": "1.0.6", "jsr:@std/path@0.221": "0.221.0", - "jsr:@std/path@1": "1.1.5", - "jsr:@std/path@^1.0.6": "1.1.5", - "jsr:@std/path@^1.0.8": "1.1.5", - "jsr:@std/path@^1.1.0": "1.1.5", - "jsr:@std/path@^1.1.5": "1.1.5", + "jsr:@std/path@1": "1.1.6", + "jsr:@std/path@^1.0.6": "1.1.6", + "jsr:@std/path@^1.0.8": "1.1.6", + "jsr:@std/path@^1.1.0": "1.1.6", + "jsr:@std/path@^1.1.5": "1.1.6", + "jsr:@std/path@^1.1.6": "1.1.6", "jsr:@std/regexp@^1.0.1": "1.0.1", "jsr:@std/regexp@^1.0.2": "1.0.2", "jsr:@std/semver@1": "1.0.8", "jsr:@std/streams@^1.1.1": "1.1.1", "jsr:@std/text@^1.0.12": "1.0.19", "jsr:@std/uuid@^1.1.0": "1.1.1", - "jsr:@upyo/core@~0.1.1": "0.1.2", - "jsr:@upyo/core@~0.1.2": "0.1.2", - "jsr:@upyo/mailgun@~0.1.1": "0.1.2", - "jsr:@upyo/mock@~0.1.1": "0.1.2", - "jsr:@valibot/valibot@1": "1.4.1", + "jsr:@upyo/core@0.5": "0.5.0", + "jsr:@upyo/mailgun@0.5": "0.5.0", + "jsr:@upyo/mock@0.5": "0.5.0", + "jsr:@valibot/valibot@1": "1.4.2", "jsr:@vertana/core@0.2": "0.2.0", "jsr:@vertana/facade@0.2": "0.2.0", "npm:@ai-sdk/anthropic@^3.0.44": "3.0.44_zod@4.2.1", @@ -388,7 +391,7 @@ "jsr:@std/fs@1", "jsr:@std/html@1", "jsr:@std/http@^1.0.15", - "jsr:@std/jsonc@1", + "jsr:@std/jsonc", "jsr:@std/media-types@1", "jsr:@std/path@1", "jsr:@std/semver", @@ -439,6 +442,9 @@ "@logtape/logtape@2.2.1": { "integrity": "eef6a50f472462a639a110c4bf4def43ec2f8e1400c500e8426773ca44555bf4" }, + "@logtape/logtape@2.2.4": { + "integrity": "8ee23b7c8d4b2e28c43a3a16027a7a27ed3d453c395c7d91b9960640399201ec" + }, "@logtape/redaction@2.1.1": { "integrity": "7c19cd623c44e3be7204ade9f7b5cf1901fad3935bcdcbc7c8855941f960f027", "dependencies": [ @@ -576,12 +582,21 @@ "jsr:@std/data-structures@^1.1.0" ] }, + "@std/async@1.5.0": { + "integrity": "4c1f22d287a17ad7d8643b1952bc5a4d21e5d8b68dc8c20d8268eecc69096e82", + "dependencies": [ + "jsr:@std/data-structures@^1.1.1" + ] + }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, "@std/cli@1.0.30": { "integrity": "769446536522d0417d7127ebcabcafac1ab0ce6766d0eb3fca1d36326fe98d13" }, + "@std/cli@1.0.32": { + "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66" + }, "@std/collections@1.1.2": { "integrity": "f1685dd45c3ec27c39d0e8a642ccf810f391ec8a6cb5e7355926e6dacc64c43e" }, @@ -597,6 +612,9 @@ "@std/collections@1.2.0": { "integrity": "47627a21d3a13138b77fd0e4d790ba9d2e603c3510b686cde6b132fe9aa98a88" }, + "@std/collections@1.3.0": { + "integrity": "eb36b43d784477ea0b476483ac034a14bdd182aff921c812ecf662a1fcef9498" + }, "@std/crypto@1.1.0": { "integrity": "b8d6d0a6377a32b213af2661ed7bf1062d94feac0c57def5526a8e74a95c3ec8" }, @@ -606,6 +624,9 @@ "@std/data-structures@1.1.0": { "integrity": "c35ae4ad5d8e41a38573c2fe3e19b18ea2505f63cfea201edcb8720aca1f7f58" }, + "@std/data-structures@1.1.1": { + "integrity": "7e1fcf19771fb9c6dbcd7f65039d6d42ef8dc023cfbc0c4cdc74b24e09229380" + }, "@std/datetime@0.225.7": { "integrity": "e62c6c454a0ecfc2918fe41e7a621e0f334a6f82605e98fd32eb1e6371999328" }, @@ -621,6 +642,9 @@ "@std/encoding@1.0.10": { "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" }, + "@std/encoding@1.0.11": { + "integrity": "e7cef2f0b3153bccc17431e7c864a1de03a4b6d9647389c43e08aaa775d37385" + }, "@std/fmt@1.0.10": { "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" }, @@ -646,7 +670,7 @@ "@std/http@1.1.1": { "integrity": "e343a9a80aea07c716b91be5c79df764144430ad2dd7c9121ed7443f08dc74f7", "dependencies": [ - "jsr:@std/cli", + "jsr:@std/cli@^1.0.30", "jsr:@std/encoding@^1.0.10", "jsr:@std/fmt@^1.0.10", "jsr:@std/fs@^1.0.24", @@ -657,6 +681,20 @@ "jsr:@std/streams" ] }, + "@std/http@1.1.2": { + "integrity": "1c0473590d5e5b75b30b2be4833c08047dd799aeea35624fd34ec58d67fc9240", + "dependencies": [ + "jsr:@std/cli@^1.0.31", + "jsr:@std/encoding@^1.0.11", + "jsr:@std/fmt@^1.0.10", + "jsr:@std/fs@^1.0.24", + "jsr:@std/html@^1.0.7", + "jsr:@std/media-types@^1.1.0", + "jsr:@std/net", + "jsr:@std/path@^1.1.6", + "jsr:@std/streams" + ] + }, "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" }, @@ -687,6 +725,12 @@ "jsr:@std/internal@^1.0.14" ] }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] + }, "@std/regexp@1.0.1": { "integrity": "5179d823465085c5480dafb44438466e83c424fadc61ba31f744050ecc0f596d" }, @@ -736,31 +780,19 @@ "@upyo/core@0.1.1": { "integrity": "1a6a29b263734881a6fb3fa083329a9c956abc48552dcc9cca4f54b89acc8a5d" }, - "@upyo/core@0.1.2": { - "integrity": "d096e0009313206e547892648cb0b82864c28e8d2611e73474c24c13204e90e7" + "@upyo/core@0.5.0": { + "integrity": "bc963a289248558815ee8e9168def2ec62aeb8287bec54580ff8aac6527d587c" }, - "@upyo/mailgun@0.1.1": { - "integrity": "6727e3cb5bed340cc5ab473f7f65e6ac38318e44adfcd4110322f8bc4a52b4fb", + "@upyo/mailgun@0.5.0": { + "integrity": "c90403a12f8226d19db67f0e496e93c87d974781fd7d61317fff33f507284065", "dependencies": [ - "jsr:@upyo/core@~0.1.1" + "jsr:@upyo/core" ] }, - "@upyo/mailgun@0.1.2": { - "integrity": "f785efaf77536e34f832a8e125044e5ff44bea4f042fc25b790e359100781571", + "@upyo/mock@0.5.0": { + "integrity": "d6be2bcc6ea31756a0eecaa52da5c3311ecc6eb5d6c2facc91d26e39ce113e16", "dependencies": [ - "jsr:@upyo/core@~0.1.2" - ] - }, - "@upyo/mock@0.1.1": { - "integrity": "9589150113e11078cbc3af67ec1b3c7a213f223163290bde803dcae52a6c9a08", - "dependencies": [ - "jsr:@upyo/core@~0.1.1" - ] - }, - "@upyo/mock@0.1.2": { - "integrity": "180ed3d9a0967d84e947fcbf7cf8fa3f0caad504303acf18f111eb2114ff6f18", - "dependencies": [ - "jsr:@upyo/core@~0.1.2" + "jsr:@upyo/core" ] }, "@valibot/valibot@1.1.0": { @@ -778,6 +810,9 @@ "@valibot/valibot@1.4.1": { "integrity": "9be12a3f73f3fd00fa44ae189bc1bb3ab4d9088ec2e32a6ab370ae4fca29ee6e" }, + "@valibot/valibot@1.4.2": { + "integrity": "14f7fbb7474a8ade216224062d1e8b23cd17ecbb5176fc23595e846778c4725d" + }, "@vertana/core@0.2.0": { "integrity": "ed9fc31b64d6d3d61f1522b71ca3f2417621d5e764731b8baf09e8646d0b6a72", "dependencies": [ @@ -13186,9 +13221,9 @@ "jsr:@std/path@^1.0.8", "jsr:@std/text@^1.0.12", "jsr:@std/uuid@^1.1.0", - "jsr:@upyo/core@~0.1.1", - "jsr:@upyo/mailgun@~0.1.1", - "jsr:@upyo/mock@~0.1.1", + "jsr:@upyo/core@0.5", + "jsr:@upyo/mailgun@0.5", + "jsr:@upyo/mock@0.5", "jsr:@valibot/valibot@1", "jsr:@vertana/core@0.2", "jsr:@vertana/facade@0.2", diff --git a/federation/context.test.ts b/federation/context.test.ts new file mode 100644 index 00000000..75316414 --- /dev/null +++ b/federation/context.test.ts @@ -0,0 +1,74 @@ +import { assertStrictEquals } from "@std/assert"; +import type { Context } from "@fedify/fedify"; +import type { + AfterCommitTask, + ApplicationContext, + ContextData, +} from "@hackerspub/models/context"; +import { getFedifyContext, toApplicationContext } from "./context.ts"; + +function createFedifyContext(data: ContextData): Context { + const documentLoader = () => Promise.reject(new Error("Not implemented")); + return { + data, + clone: createFedifyContext, + origin: "https://example.com", + canonicalOrigin: "https://example.com", + host: "example.com", + documentLoader, + contextLoader: documentLoader, + getActorUri: () => new URL("https://example.com/actor"), + getInboxUri: () => new URL("https://example.com/inbox"), + getOutboxUri: () => new URL("https://example.com/outbox"), + getFollowersUri: () => new URL("https://example.com/followers"), + getFollowingUri: () => new URL("https://example.com/following"), + getFeaturedUri: () => new URL("https://example.com/featured"), + getObjectUri: () => new URL("https://example.com/object"), + getDocumentLoader: () => documentLoader, + lookupObject: () => Promise.resolve(null), + lookupWebFinger: () => Promise.resolve(null), + sendActivity: () => Promise.resolve(), + } as unknown as Context; +} + +Deno.test("Fedify adapter state survives application context cloning", () => { + const rootDb = {} as ContextData["db"]; + const transactionDb = {} as ContextData["db"]; + const data = { db: rootDb } as ContextData; + const fedifyContext = { + data, + clone(nextData: ContextData) { + return { ...this, data: nextData }; + }, + } as Context; + const context = { + db: transactionDb, + federation: fedifyContext, + } as ApplicationContext; + + const adapted = getFedifyContext({ ...context }); + assertStrictEquals(adapted.data.db, transactionDb); + assertStrictEquals(fedifyContext.data.db, rootDb); +}); + +Deno.test("database rebinding preserves transaction adapter state", () => { + const rootDb = {} as ContextData["db"]; + const transactionDb = {} as ContextData["db"]; + const reboundDb = {} as ContextData["db"]; + const afterCommit: AfterCommitTask[] = []; + const applicationContext = toApplicationContext( + createFedifyContext({ db: rootDb } as ContextData), + ); + const transactionContext = { + ...applicationContext, + db: transactionDb, + rootDb, + afterCommit, + }; + + const rebound = transactionContext.withDatabase(reboundDb); + + assertStrictEquals(rebound.db, reboundDb); + assertStrictEquals(rebound.rootDb, rootDb); + assertStrictEquals(rebound.afterCommit, afterCommit); +}); diff --git a/federation/context.ts b/federation/context.ts new file mode 100644 index 00000000..1743c1c6 --- /dev/null +++ b/federation/context.ts @@ -0,0 +1,109 @@ +import type { Context } from "@fedify/fedify"; +import type { + ApplicationContext, + ContextData, +} from "@hackerspub/models/context"; + +/** Return the adapter-owned Fedify context for a federation implementation. */ +export function getFedifyContext( + context: ApplicationContext, +): Context { + const fedifyContext = context.federation; + if ( + typeof fedifyContext !== "object" || fedifyContext == null || + !("data" in fedifyContext) + ) { + throw new TypeError( + "Application context was not created by Fedify adapter", + ); + } + const raw = fedifyContext as Context; + const cloned = raw.clone({ + ...raw.data, + db: context.db, + rootDb: context.rootDb, + afterCommit: context.afterCommit, + }); + return Object.assign(cloned, { + documentLoader: context.documentLoader, + contextLoader: context.contextLoader, + getActorUri: context.getActorUri, + getInboxUri: context.getInboxUri, + getOutboxUri: context.getOutboxUri, + getFollowersUri: context.getFollowersUri, + getFollowingUri: context.getFollowingUri, + getFeaturedUri: context.getFeaturedUri, + getObjectUri: context.getObjectUri, + getDocumentLoader: context.getDocumentLoader, + lookupObject: context.lookupObject, + lookupWebFinger: context.lookupWebFinger, + sendActivity: context.sendActivity, + }); +} + +/** Translate a Fedify adapter context into the context used by application code. */ +export function toApplicationContext( + context: Context, +): ApplicationContext { + const getActorKeyPairs = "getActorKeyPairs" in context && + typeof context.getActorKeyPairs === "function" + ? (identifier: string) => context.getActorKeyPairs(identifier) + : undefined; + const applicationContext: ApplicationContext = { + db: context.data.db, + withDatabase(db) { + return toApplicationContext(context.clone({ + ...context.data, + db, + rootDb: this.rootDb, + afterCommit: this.afterCommit, + })); + }, + rootDb: context.data.rootDb, + afterCommit: context.data.afterCommit, + kv: context.data.kv, + storage: context.data.disk, + models: context.data.models, + services: context.data.services, + federation: context, + origin: context.origin, + canonicalOrigin: context.canonicalOrigin, + host: context.host, + documentLoader: context.documentLoader, + contextLoader: context.contextLoader, + getActorUri: context.getActorUri.bind(context), + getInboxUri: context.getInboxUri.bind(context), + getOutboxUri: context.getOutboxUri.bind(context), + getFollowersUri: context.getFollowersUri.bind(context), + getFollowingUri: context.getFollowingUri.bind(context), + getFeaturedUri: context.getFeaturedUri.bind(context), + getObjectUri: (type, values) => context.getObjectUri(type as never, values), + getDocumentLoader: (options) => + context.getDocumentLoader( + options as Parameters[0], + ), + lookupObject: (value, options) => + context.lookupObject( + value, + options as Parameters[1], + ), + lookupWebFinger: (resource) => context.lookupWebFinger(resource), + getActor: (identifier) => { + if ( + "getActor" in context && typeof context.getActor === "function" + ) { + return context.getActor(identifier); + } + return Promise.resolve(null); + }, + ...(getActorKeyPairs == null ? {} : { getActorKeyPairs }), + sendActivity: (sender, recipients, activity, options) => + context.sendActivity( + sender as never, + recipients as never, + activity, + options as never, + ), + }; + return applicationContext; +} diff --git a/federation/deno.json b/federation/deno.json index b2801c36..68c5bd73 100644 --- a/federation/deno.json +++ b/federation/deno.json @@ -6,6 +6,7 @@ "./actor": "./actor.ts", "./builder": "./builder.ts", "./collections": "./collections.ts", + "./context": "./context.ts", "./inbox": "./inbox/mod.ts", "./nodeinfo": "./nodeinfo.ts", "./tags-pub": "./tags-pub.ts", diff --git a/federation/inbox/actor.ts b/federation/inbox/actor.ts index f180d046..e7b32c55 100644 --- a/federation/inbox/actor.ts +++ b/federation/inbox/actor.ts @@ -5,6 +5,7 @@ import { persistActor, } from "@hackerspub/models/actor"; import type { ContextData } from "@hackerspub/models/context"; +import { toApplicationContext } from "../context.ts"; import { follow } from "@hackerspub/models/following"; import { ActorSuspendedError, @@ -21,7 +22,10 @@ export async function onActorUpdated( const actor = object ?? await update.getObject({ ...fedCtx, suppressError: true }); if (!isActor(actor) || update.actorId?.href !== actor.id?.href) return; - await persistActor(fedCtx, actor, { ...fedCtx, outbox: false }); + await persistActor(toApplicationContext(fedCtx), actor, { + ...fedCtx, + outbox: false, + }); } export async function onActorDeleted( @@ -88,9 +92,17 @@ export async function onActorMoved( ) { return; } - const oldActor = await persistActor(fedCtx, object, fedCtx); + const oldActor = await persistActor( + toApplicationContext(fedCtx), + object, + fedCtx, + ); if (oldActor == null) return; - const newActor = await persistActor(fedCtx, target, fedCtx); + const newActor = await persistActor( + toApplicationContext(fedCtx), + target, + fedCtx, + ); if (newActor == null) return; if (newActor.id === oldActor.id) return; const { db } = fedCtx.data; @@ -108,7 +120,7 @@ export async function onActorMoved( if (follower.account == null) continue; try { await follow( - fedCtx, + toApplicationContext(fedCtx), { ...follower.account, actor: follower }, newActor, ); diff --git a/federation/inbox/flag.ts b/federation/inbox/flag.ts index 54f72141..d800569a 100644 --- a/federation/inbox/flag.ts +++ b/federation/inbox/flag.ts @@ -6,6 +6,7 @@ import { persistActor, } from "@hackerspub/models/actor"; import type { ContextData } from "@hackerspub/models/context"; +import { toApplicationContext } from "../context.ts"; import { analyzeFlag, createFlag, getFlagByIri } from "@hackerspub/models/flag"; import type { Actor, Post } from "@hackerspub/models/schema"; import { getLogger } from "@logtape/logtape"; @@ -60,7 +61,8 @@ export async function onFlagged( ); return; } - reporter = await persistActor(fedCtx, actorObject) ?? undefined; + reporter = await persistActor(toApplicationContext(fedCtx), actorObject) ?? + undefined; if (reporter == null) return; } // Match the flagged objects against local data. A Flag usually carries diff --git a/federation/inbox/following.ts b/federation/inbox/following.ts index 1b9ebec3..ca9c3a4b 100644 --- a/federation/inbox/following.ts +++ b/federation/inbox/following.ts @@ -6,6 +6,7 @@ import { } from "@hackerspub/models/actor"; import { persistBlocking } from "@hackerspub/models/blocking"; import type { ContextData } from "@hackerspub/models/context"; +import { toApplicationContext } from "../context.ts"; import { acceptFollowing, updateFolloweesCount, @@ -349,10 +350,14 @@ export async function onFollowed( if (await isCachedActorFederationBlocked(db, follow.actorId)) return; const followActor = await follow.getActor(fedCtx); if (followActor == null) return; - const follower = await persistActor(fedCtx, followActor, { - ...fedCtx, - outbox: false, - }); + const follower = await persistActor( + toApplicationContext(fedCtx), + followActor, + { + ...fedCtx, + outbox: false, + }, + ); if (follower == null) return; const rows = await db.insert(followingTable).values({ iri: follow.id.href, @@ -427,7 +432,7 @@ export async function onBlocked( fedCtx: InboxContext, block: Block, ): Promise { - await persistBlocking(fedCtx, block, fedCtx); + await persistBlocking(toApplicationContext(fedCtx), block, fedCtx); } export async function onUnblocked( diff --git a/federation/inbox/quote.ts b/federation/inbox/quote.ts index b31bbb11..d49cedae 100644 --- a/federation/inbox/quote.ts +++ b/federation/inbox/quote.ts @@ -16,6 +16,7 @@ import { persistActor, } from "@hackerspub/models/actor"; import type { ContextData } from "@hackerspub/models/context"; +import { toApplicationContext } from "../context.ts"; import { canActorQuotePost, canActorRequestQuotePost, @@ -81,7 +82,11 @@ export async function onQuoteRequested( suppressError: true, }); if (!isActor(actorObject)) return; - actor = await persistActor(fedCtx, actorObject, fedCtx); + actor = await persistActor( + toApplicationContext(fedCtx), + actorObject, + fedCtx, + ); if (actor == null) return; } const quotedPost = await getQuoteRequestTarget( @@ -114,10 +119,14 @@ export async function onQuoteRequested( } const { instrument, instrumentIri } = validInstrument; if (!canActorQuotePost(quotedPost, actor)) { - const quotePost = await persistPost(fedCtx, instrument, { - actor, - replies: false, - }); + const quotePost = await persistPost( + toApplicationContext(fedCtx), + instrument, + { + actor, + replies: false, + }, + ); if (quotePost == null) return; await fedCtx.data.db.update(postTable) .set({ quoteTargetState: "pending" }) diff --git a/federation/inbox/subscribe.ts b/federation/inbox/subscribe.ts index 032c1662..cd92304f 100644 --- a/federation/inbox/subscribe.ts +++ b/federation/inbox/subscribe.ts @@ -19,6 +19,7 @@ import { persistActor, } from "@hackerspub/models/actor"; import type { ContextData } from "@hackerspub/models/context"; +import { toApplicationContext } from "../context.ts"; import { refreshNewsScoresForPostId } from "@hackerspub/models/news"; import { createMentionNotification, @@ -74,16 +75,20 @@ export async function onPostCreated( object instanceof Note && object.attributionId?.href === create.actorId?.href ) { - const vote = await persistPollVoteResult(fedCtx, object, { - documentLoader: fedCtx.documentLoader, - contextLoader: fedCtx.contextLoader, - }); + const vote = await persistPollVoteResult( + toApplicationContext(fedCtx), + object, + { + documentLoader: fedCtx.documentLoader, + contextLoader: fedCtx.contextLoader, + }, + ); if (vote.attempted) return; } if (!isPostObject(object)) return; if (object.attributionId?.href !== create.actorId?.href) return; const { db } = fedCtx.data; - const post = await persistPost(fedCtx, object, { + const post = await persistPost(toApplicationContext(fedCtx), object, { replies: true, documentLoader: fedCtx.documentLoader, contextLoader: fedCtx.contextLoader, @@ -134,7 +139,7 @@ export async function onPostUpdated( await update.getObject({ ...fedCtx, suppressError: true }); if (!isPostObject(postObject)) return; if (postObject.attributionId?.href !== update.actorId?.href) return; - await persistPost(fedCtx, postObject, { + await persistPost(toApplicationContext(fedCtx), postObject, { replies: true, documentLoader: fedCtx.documentLoader, contextLoader: fedCtx.contextLoader, @@ -191,7 +196,7 @@ export async function onPostShared( }); if (!isPostObject(object)) return; if (isTagsPubHashtagActor(actorId.href)) { - const post = await persistPost(fedCtx, object, { + const post = await persistPost(toApplicationContext(fedCtx), object, { replies: true, documentLoader: boundedLoader, contextLoader: fedCtx.contextLoader, @@ -202,7 +207,7 @@ export async function onPostShared( } return; } - const post = await persistSharedPost(fedCtx, announce, { + const post = await persistSharedPost(toApplicationContext(fedCtx), announce, { ...fedCtx, documentLoader: boundedLoader, signal: overallSignal, @@ -261,7 +266,7 @@ export async function onReactedOnPost( ): Promise { logger.debug("On post reacted: {reaction}", { reaction }); const reactionObject = await persistReaction( - fedCtx, + toApplicationContext(fedCtx), reaction, fedCtx, ); @@ -312,7 +317,11 @@ export async function onPostPinned( if (actor?.featuredUrl == null) { const actorObject = await add.getActor({ ...fedCtx, suppressError: true }); if (actorObject == null) return; - actor = await persistActor(fedCtx, actorObject, fedCtx); + actor = await persistActor( + toApplicationContext(fedCtx), + actorObject, + fedCtx, + ); if (actor?.featuredUrl == null) return; } if (add.targetIds.find((tid) => tid.href === actor.featuredUrl) == null) { @@ -321,7 +330,7 @@ export async function onPostPinned( const pinnedPosts: Post[] = []; for await (const obj of add.getObjects({ ...fedCtx, suppressError: true })) { if (!isPostObject(obj)) continue; - const post = await persistPost(fedCtx, obj, fedCtx); + const post = await persistPost(toApplicationContext(fedCtx), obj, fedCtx); if (post != null) pinnedPosts.push(post); } if (pinnedPosts.length > 0) { @@ -352,7 +361,11 @@ export async function onPostUnpinned( suppressError: true, }); if (actorObject == null) return; - actor = await persistActor(fedCtx, actorObject, fedCtx); + actor = await persistActor( + toApplicationContext(fedCtx), + actorObject, + fedCtx, + ); if (actor?.featuredUrl == null) return; } if (remove.targetIds.find((tid) => tid.href === actor.featuredUrl) == null) { @@ -363,7 +376,7 @@ export async function onPostUnpinned( const obj of remove.getObjects({ ...fedCtx, suppressError: true }) ) { if (!isPostObject(obj)) continue; - const post = await persistPost(fedCtx, obj, fedCtx); + const post = await persistPost(toApplicationContext(fedCtx), obj, fedCtx); if (post != null) unpinnedPosts.push(post); } if (unpinnedPosts.length > 0) { diff --git a/federation/objects.test.ts b/federation/objects.test.ts index 952aaf0c..f7ad05c0 100644 --- a/federation/objects.test.ts +++ b/federation/objects.test.ts @@ -1,9 +1,7 @@ import assert from "node:assert"; import test from "node:test"; -import type { Context } from "@fedify/fedify"; import { MemoryKvStore } from "@fedify/fedify"; import type { ContextData } from "@hackerspub/models/context"; -import type { Transaction } from "@hackerspub/models/db"; import { createQuestion } from "@hackerspub/models/question"; import { actorTable, @@ -437,7 +435,7 @@ test("getQuestion() advertises the emoji reactions collection", async () => { }); const published = new Date("2026-04-15T00:00:00.000Z"); const question = await createQuestion( - createFedCtx(tx) as unknown as Context>, + createFedCtx(tx), { accountId: author.account.id, visibility: "public", @@ -496,7 +494,7 @@ test("source-backed Questions do not resolve through the Note dispatcher", async }); const published = new Date("2026-04-15T00:00:00.000Z"); const question = await createQuestion( - createFedCtx(tx) as unknown as Context>, + createFedCtx(tx), { accountId: author.account.id, visibility: "public", diff --git a/federation/objects.ts b/federation/objects.ts index cfda2756..7253f935 100644 --- a/federation/objects.ts +++ b/federation/objects.ts @@ -2,6 +2,7 @@ import type { Context, RequestContext } from "@fedify/fedify"; import { LanguageString, PUBLIC_COLLECTION } from "@fedify/vocab"; import * as vocab from "@fedify/vocab"; import type { ContextData } from "@hackerspub/models/context"; +import { toApplicationContext } from "./context.ts"; import { DEFAULT_REACTION_EMOJI, isReactionEmoji, @@ -198,12 +199,16 @@ export async function getArticle( const missingMediumLabel = getMissingArticleMediumLabel( content.language, ); - const { hashtags, html } = await renderMarkup(ctx, content.content, { - docId: articleSource.id, - kv: ctx.data.kv, - mediumUrls, - missingMediumLabel, - }); + const { hashtags, html } = await renderMarkup( + toApplicationContext(ctx), + content.content, + { + docId: articleSource.id, + kv: ctx.data.kv, + mediumUrls, + missingMediumLabel, + }, + ); return { ...content, hashtags, @@ -371,7 +376,7 @@ export async function getNote( quoteRequestPolicy?: QuotePolicy | null; } = {}, ): Promise { - const rendered = await renderMarkup(ctx, note.content, { + const rendered = await renderMarkup(toApplicationContext(ctx), note.content, { docId: note.id, kv: ctx.data.kv, }); @@ -496,7 +501,7 @@ export async function getQuestion( quoteRequestPolicy?: QuotePolicy | null; } = {}, ): Promise { - const rendered = await renderMarkup(ctx, note.content, { + const rendered = await renderMarkup(toApplicationContext(ctx), note.content, { docId: note.id, kv: ctx.data.kv, }); diff --git a/federation/person.ts b/federation/person.ts index 39b0f6b8..a814928f 100644 --- a/federation/person.ts +++ b/federation/person.ts @@ -2,6 +2,7 @@ import type { ActorKeyPair, Context } from "@fedify/fedify"; import { Endpoints, Image, Organization, Person } from "@fedify/vocab"; import { getAvatarUrl, renderAccountLinks } from "@hackerspub/models/account"; import type { ContextData } from "@hackerspub/models/context"; +import { toApplicationContext } from "./context.ts"; import { removeHeaderAnchorLinks } from "@hackerspub/models/html"; import { renderMarkup } from "@hackerspub/models/markup"; import { isActorBanned } from "@hackerspub/models/moderation"; @@ -60,7 +61,7 @@ export async function getAccountActor( }); } const bio = await renderMarkup( - ctx, + toApplicationContext(ctx), account.bio, { docId: account.id, diff --git a/federation/services.ts b/federation/services.ts index 902c17f6..8b858ea6 100644 --- a/federation/services.ts +++ b/federation/services.ts @@ -1,6 +1,6 @@ -import type { Context } from "@fedify/fedify"; -import type { ContextData } from "@hackerspub/models/context"; +import type { ApplicationContext } from "@hackerspub/models/context"; import type { FederationServices } from "@hackerspub/models/services"; +import { getFedifyContext } from "./context.ts"; import { getAnnounce, getArticle, @@ -9,14 +9,34 @@ import { getNote, getQuestion, } from "./objects.ts"; -import { sendTagsPubRelayActivity } from "./tags-pub.ts"; - -export const federationServices: FederationServices> = { - getAnnounce, - getArticle, - getEmojiReact, - getEmojiReactId, - getNote, - getQuestion, +import { sendTagsPubRelayActivity, + subscribeTagsPubHashtag, + unsubscribeTagsPubHashtag, +} from "./tags-pub.ts"; + +export const federationServices: FederationServices = { + subscribeTagsPubHashtag: (context, tag) => + subscribeTagsPubHashtag(getFedifyContext(context), tag), + unsubscribeTagsPubHashtag: (context, tag) => + unsubscribeTagsPubHashtag(getFedifyContext(context), tag), + getAnnounce: (context, share) => + getAnnounce(getFedifyContext(context), share), + getArticle: (context, articleSource) => + getArticle(getFedifyContext(context), articleSource), + getEmojiReact: (context, reaction) => + getEmojiReact(getFedifyContext(context), reaction), + getEmojiReactId: (context, accountId, postId, emoji) => + getEmojiReactId(getFedifyContext(context), accountId, postId, emoji), + getNote: (context, note, relations) => + getNote(getFedifyContext(context), note, relations), + getQuestion: (context, note, poll, relations) => + getQuestion(getFedifyContext(context), note, poll, relations), + sendTagsPubRelayActivity: (context, accountId, activity, options) => + sendTagsPubRelayActivity( + getFedifyContext(context), + accountId, + activity, + options, + ), }; diff --git a/graphql/ai.ts b/graphql/ai.ts index a23f2a6a..d461e185 100644 --- a/graphql/ai.ts +++ b/graphql/ai.ts @@ -1,9 +1 @@ -import { anthropic } from "@ai-sdk/anthropic"; -import { google } from "@ai-sdk/google"; - -// TODO: make model IDs configurable via env vars so they can be swapped -// when preview models are promoted or deprecated. -export const altTextGenerator = google("gemini-3.1-flash-lite-preview"); -export const summarizer = google("gemini-3.5-flash"); -export const translator = anthropic("claude-sonnet-4-6"); -export const moderationAnalyzer = anthropic("claude-sonnet-4-6"); +export { createAiModels } from "@hackerspub/runtime/resources"; diff --git a/graphql/builder.ts b/graphql/builder.ts index 10933467..ef3523c0 100644 --- a/graphql/builder.ts +++ b/graphql/builder.ts @@ -1,4 +1,3 @@ -import type { RequestContext } from "@fedify/fedify"; import SchemaBuilder from "@pothos/core"; import ComplexityPlugin from "@pothos/plugin-complexity"; import DataloaderPlugin from "@pothos/plugin-dataloader"; @@ -50,7 +49,7 @@ import { import { createGraphQLError } from "graphql-yoga"; import type Keyv from "keyv"; import { normalizeEmail } from "@hackerspub/models/account"; -import type { ContextData } from "@hackerspub/models/context"; +import type { ApplicationContext } from "@hackerspub/models/context"; import type { Database } from "@hackerspub/models/db"; import type { PostInteractionPolicy } from "@hackerspub/models/post"; import { relations } from "@hackerspub/models/relations"; @@ -73,7 +72,8 @@ export interface ServerContext { kv: Keyv; disk: Disk; email: Transport; - fedCtx: RequestContext; + emailFrom: string; + fedCtx: ApplicationContext; request: Request; connectionInfo?: Deno.ServeHandlerInfo; } diff --git a/graphql/composition.test.ts b/graphql/composition.test.ts new file mode 100644 index 00000000..86d33bbe --- /dev/null +++ b/graphql/composition.test.ts @@ -0,0 +1,18 @@ +import { assert, assertStringIncludes } from "@std/assert"; + +for (const compositionRoot of ["main.ts", "worker.ts"]) { + Deno.test(`${compositionRoot} initializes LogTape after Sentry instrumentation`, async () => { + const source = await Deno.readTextFile( + new URL(compositionRoot, import.meta.url), + ); + const instrumentImport = 'import "./instrument.ts";'; + const loggingImport = 'import "./logging.ts";'; + + assertStringIncludes(source, instrumentImport); + assertStringIncludes(source, loggingImport); + assert( + source.indexOf(instrumentImport) < source.indexOf(loggingImport), + "Sentry instrumentation must run before LogTape configuration", + ); + }); +} diff --git a/graphql/db.ts b/graphql/db.ts index 6d6d7104..bdd1f515 100644 --- a/graphql/db.ts +++ b/graphql/db.ts @@ -1,26 +1 @@ -import type { Database } from "@hackerspub/models/db"; -import { relations } from "@hackerspub/models/relations"; -import { getLogger as getDatabaseLogger } from "@logtape/drizzle-orm"; -import { getLogger } from "@logtape/logtape"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgresJs from "postgres"; -import "./logging.ts"; - -const DATABASE_URL = Deno.env.get("DATABASE_URL"); -if (DATABASE_URL == null) { - throw new Error("Missing DATABASE_URL environment variable."); -} - -export const postgres = postgresJs(DATABASE_URL, { - // The pool size needs to exceed the ParallelMessageQueue concurrency (10) - // to leave headroom for HTTP handlers and KV store queries. The default - // of 10 can cause connection starvation under federation load. - max: 20, -}); -export const db: Database = drizzle({ - relations, - client: postgres, - logger: getDatabaseLogger(), -}); -getLogger(["hackerspub", "db"]) - .debug("The driver is ready: {driver}", { driver: db.constructor }); +export { createDatabaseResources } from "@hackerspub/runtime/resources"; diff --git a/graphql/drive.ts b/graphql/drive.ts index c74482cc..2b422454 100644 --- a/graphql/drive.ts +++ b/graphql/drive.ts @@ -1,72 +1 @@ -import { DriveManager } from "flydrive"; -import { FSDriver } from "flydrive/drivers/fs"; -import { S3Driver } from "flydrive/drivers/s3"; - -const DRIVE_DISK = Deno.env.get("DRIVE_DISK"); -if (DRIVE_DISK == null) { - throw new Error("Missing DRIVE_DISK environment variable."); -} else if (DRIVE_DISK !== "fs" && DRIVE_DISK !== "s3") { - throw new Error("Invalid DRIVE_DISK environment variable; must be fs or s3."); -} - -const ORIGIN = Deno.env.get("ORIGIN"); -if (ORIGIN == null) { - throw new Error("Missing ORIGIN environment variable."); -} - -export const drive = new DriveManager({ - default: DRIVE_DISK, - services: { - fs() { - const FS_LOCATION = Deno.env.get("FS_LOCATION"); - if (FS_LOCATION == null) { - throw new Error("Missing FS_LOCATION environment variable."); - } - return new FSDriver({ - location: new URL(FS_LOCATION, import.meta.url), - visibility: "public", - urlBuilder: { - generateURL(key: string) { - const url = new URL(`/media/${key}`, ORIGIN); - return Promise.resolve(url.href); - }, - generateSignedURL(key: string) { - const url = new URL(`/media/${key}`, ORIGIN); - return Promise.resolve(url.href); - }, - }, - }); - }, - s3() { - const AWS_ACCESS_KEY_ID = Deno.env.get("AWS_ACCESS_KEY_ID"); - if (AWS_ACCESS_KEY_ID == null) { - throw new Error("Missing AWS_ACCESS_KEY_ID environment variable."); - } - const AWS_SECRET_ACCESS_KEY = Deno.env.get("AWS_SECRET_ACCESS_KEY"); - if (AWS_SECRET_ACCESS_KEY == null) { - throw new Error("Missing AWS_SECRET_ACCESS_KEY environment variable."); - } - const AWS_REGION = Deno.env.get("AWS_REGION"); - if (AWS_REGION == null) { - throw new Error("Missing AWS_REGION environment variable."); - } - const S3_BUCKET = Deno.env.get("S3_BUCKET"); - if (S3_BUCKET == null) { - throw new Error("Missing S3_BUCKET environment variable."); - } - const S3_ENDPOINT = Deno.env.get("S3_ENDPOINT"); - const S3_CDN_URL = Deno.env.get("S3_CDN_URL"); - return new S3Driver({ - credentials: { - accessKeyId: AWS_ACCESS_KEY_ID, - secretAccessKey: AWS_SECRET_ACCESS_KEY, - }, - endpoint: S3_ENDPOINT, - cdnUrl: S3_CDN_URL, - region: AWS_REGION, - bucket: S3_BUCKET, - visibility: "public", - }); - }, - }, -}); +export { createDriveResource } from "@hackerspub/runtime/resources"; diff --git a/graphql/email-helpers.ts b/graphql/email-helpers.ts index 6e37c34b..fb8adcd9 100644 --- a/graphql/email-helpers.ts +++ b/graphql/email-helpers.ts @@ -7,7 +7,6 @@ import { join } from "@std/path"; import { createMessage, type Message } from "@upyo/core"; import { createGraphQLError } from "graphql-yoga"; import { parseTemplate } from "url-template"; -import { EMAIL_FROM } from "./email.ts"; const LOCALES_DIR = join(import.meta.dirname!, "locales"); @@ -90,15 +89,17 @@ async function getEmailTemplate( } export async function getEmailMessage( - { locale, inviter, to, verifyUrlTemplate, token, message, expiration }: { - locale: Intl.Locale; - inviter: AccountTable & { actor: Actor }; - to: string; - verifyUrlTemplate: string; - token: SignupToken; - message?: string; - expiration: Temporal.Duration; - }, + { from, locale, inviter, to, verifyUrlTemplate, token, message, expiration }: + { + from: string; + locale: Intl.Locale; + inviter: AccountTable & { actor: Actor }; + to: string; + verifyUrlTemplate: string; + token: SignupToken; + message?: string; + expiration: Temporal.Duration; + }, ): Promise { const verifyUrl = parseTemplate(verifyUrlTemplate).expand({ token: token.token, @@ -134,7 +135,7 @@ export async function getEmailMessage( } const textContent = substitute(template.content); return createMessage({ - from: EMAIL_FROM, + from, to, subject: substitute(template.subject), content: { diff --git a/graphql/email.ts b/graphql/email.ts index bcc32360..db12ff7e 100644 --- a/graphql/email.ts +++ b/graphql/email.ts @@ -1,25 +1 @@ -import type { Transport } from "@upyo/core"; -import { MailgunTransport } from "@upyo/mailgun"; -import { MockTransport } from "@upyo/mock"; - -function getEnv(variable: string): string { - const val = Deno.env.get(variable); - if (val == null) throw new Error(`Missing environment variable: ${variable}`); - return val; -} - -export const EMAIL_FROM = Deno.env.get("EMAIL_FROM") ?? getEnv("MAILGUN_FROM"); - -if (EMAIL_FROM === "") { - throw new Error( - "EMAIL_FROM or MAILGUN_FROM cannot be an empty string. Please set it in your environment variables.", - ); -} - -export const transport: Transport = Deno.env.get("CI") === "true" - ? new MockTransport() - : new MailgunTransport({ - apiKey: getEnv("MAILGUN_KEY"), - domain: getEnv("MAILGUN_DOMAIN"), - region: getEnv("MAILGUN_REGION") === "eu" ? "eu" : "us", - }); +export { createEmailResource } from "@hackerspub/runtime/resources"; diff --git a/graphql/federation.ts b/graphql/federation.ts index bdb31818..e5f1d63d 100644 --- a/graphql/federation.ts +++ b/graphql/federation.ts @@ -1,55 +1 @@ -import { PostgresKvStore, PostgresMessageQueue } from "@fedify/postgres"; -import { RedisKvStore } from "@fedify/redis"; -import { builder } from "@hackerspub/federation"; -import { getLogger } from "@logtape/logtape"; -import { Redis } from "ioredis"; -import { postgres } from "./db.ts"; -import metadata from "./deno.json" with { type: "json" }; -import { kvUrl } from "./kv.ts"; - -const logger = getLogger(["hackerspub", "federation"]); - -const origin = Deno.env.get("ORIGIN"); -if (origin == null) { - throw new Error("Missing ORIGIN environment variable."); -} else if (!origin.startsWith("https://") && !origin.startsWith("http://")) { - throw new Error("ORIGIN must start with http:// or https://"); -} -export const ORIGIN = origin; - -const kv = kvUrl.protocol === "redis:" - ? new RedisKvStore( - new Redis(kvUrl.href, { - family: kvUrl.hostname.endsWith(".upstash.io") ? 6 : 4, - }), - ) - : new PostgresKvStore(postgres); -logger.debug("KV store initialized: {kv}", { kv }); - -// Raise the message handler timeout above the 60-second default: inbox -// handlers may legitimately need several bounded remote fetches, and the -// default tripped on slow federation peers (see GRAPHQL-1H). The real fix is -// bounding each remote fetch (see `withDocumentLoaderTimeout` in -// `@hackerspub/models/post`); this is headroom so transient slowness no longer -// surfaces as a handler timeout. -const queue = new PostgresMessageQueue(postgres, { - handlerTimeout: { seconds: 180 }, -}); -logger.debug("Message queue initialized: {queue}", { queue }); - -export const federation = await builder.build({ - kv, - queue, - // Do NOT auto-consume the queue here. This federation is imported by both - // the public API process (`main.ts`, which serves HTTP and enqueues) and the - // dedicated worker process (`worker.ts`, which calls `startQueue`). Draining - // the inbox/outbox in the API process put queue load (slow remote fetches, - // handler-timeout zombies) on the same event loop and DB pool as user-facing - // GraphQL requests, which is what tipped them into Caddy 504s (WEB-NEXT-1W). - manuallyStartQueue: true, - origin: ORIGIN, - userAgent: { - software: `HackersPub/${metadata.version}`, - url: new URL(ORIGIN), - }, -}); +export { createFederationResource } from "@hackerspub/runtime/resources"; diff --git a/graphql/hashtag.ts b/graphql/hashtag.ts index feb004c9..78cd2741 100644 --- a/graphql/hashtag.ts +++ b/graphql/hashtag.ts @@ -10,10 +10,6 @@ import { unpinHashtag, validateHashtag, } from "@hackerspub/models/hashtag"; -import { - subscribeTagsPubHashtag, - unsubscribeTagsPubHashtag, -} from "../federation/tags-pub.ts"; import { Account } from "./account.ts"; import { builder } from "./builder.ts"; import { InvalidInputError } from "./error.ts"; @@ -74,7 +70,10 @@ builder.relayMutationField( const countBefore = await getHashtagFollowerCount(ctx.db, tag); await followHashtag(ctx.db, ctx.account.id, tag); if (countBefore === 0) { - await subscribeTagsPubHashtag(ctx.fedCtx, tag); + await ctx.fedCtx.services.federation.subscribeTagsPubHashtag( + ctx.fedCtx, + tag, + ); } return { accountId: ctx.account.id, tag }; }, @@ -124,7 +123,10 @@ builder.relayMutationField( await unfollowHashtag(ctx.db, ctx.account.id, tag); const countAfter = await getHashtagFollowerCount(ctx.db, tag); if (countAfter === 0) { - await unsubscribeTagsPubHashtag(ctx.fedCtx, tag); + await ctx.fedCtx.services.federation.unsubscribeTagsPubHashtag( + ctx.fedCtx, + tag, + ); } return { accountId: ctx.account.id, tag }; }, diff --git a/graphql/invitation-link.ts b/graphql/invitation-link.ts index d28a53d2..885961ba 100644 --- a/graphql/invitation-link.ts +++ b/graphql/invitation-link.ts @@ -538,6 +538,7 @@ builder.mutationField("redeemInvitationLink", (t) => expiration: EXPIRATION, }); const message = await getEmailMessage({ + from: ctx.emailFrom, locale: args.locale, inviter: link.inviter, verifyUrlTemplate: args.verifyUrl, diff --git a/graphql/invite.ts b/graphql/invite.ts index 21241ab7..f727667b 100644 --- a/graphql/invite.ts +++ b/graphql/invite.ts @@ -233,6 +233,7 @@ builder.mutationField("invite", (t) => }; try { const message = await getEmailMessage({ + from: ctx.emailFrom, locale: args.locale, inviter, verifyUrlTemplate: args.verifyUrl, diff --git a/graphql/kv.ts b/graphql/kv.ts index dcd1eae3..057d0ef3 100644 --- a/graphql/kv.ts +++ b/graphql/kv.ts @@ -1,22 +1 @@ -import KeyvRedis from "@keyv/redis"; -import { KeyvFile } from "keyv-file"; -import Keyv from "keyv"; - -const KV_URL = Deno.env.get("KV_URL"); -if (KV_URL == null) { - throw new Error("Missing KV_URL environment variable."); -} else if (!URL.canParse(KV_URL)) { - throw new Error("Invalid KV_URL environment variable; must be a valid URL."); -} - -export const kvUrl = new URL(KV_URL); -if (kvUrl.protocol !== "file:" && kvUrl.protocol !== "redis:") { - throw new Error( - "Invalid KV_URL environment variable; must start with file: or redis:", - ); -} - -export const kvAdaptor = kvUrl.protocol === "file:" - ? new KeyvFile({ filename: kvUrl.pathname }) - : new KeyvRedis(KV_URL); -export const kv = new Keyv(kvAdaptor); +export { createKeyValueResource } from "@hackerspub/runtime/resources"; diff --git a/graphql/login.ts b/graphql/login.ts index 43feee88..a7d43833 100644 --- a/graphql/login.ts +++ b/graphql/login.ts @@ -30,7 +30,6 @@ import { createGraphQLError } from "graphql-yoga"; import { parseTemplate } from "url-template"; import { Account } from "./account.ts"; import { builder } from "./builder.ts"; -import { EMAIL_FROM } from "./email.ts"; import { SessionRef } from "./session.ts"; const logger = getLogger(["hackerspub", "graphql", "login"]); @@ -148,6 +147,7 @@ builder.mutationFields((t) => ({ const messages: Message[] = []; for (const { email } of account.emails) { const message = await getEmailMessage({ + from: ctx.emailFrom, locale: args.locale, to: email, verifyUrlTemplate: args.verifyUrl, @@ -235,6 +235,7 @@ builder.mutationFields((t) => ({ const messages: Message[] = []; for (const { email } of account.emails) { const message = await getEmailMessage({ + from: ctx.emailFrom, locale: args.locale, to: email, verifyUrlTemplate: args.verifyUrl, @@ -464,7 +465,8 @@ async function getEmailTemplate( }; } -async function getEmailMessage({ locale, to, verifyUrlTemplate, token }: { +async function getEmailMessage({ from, locale, to, verifyUrlTemplate, token }: { + from: string; locale: Intl.Locale; to: string; verifyUrlTemplate: string; @@ -488,7 +490,7 @@ async function getEmailMessage({ locale, to, verifyUrlTemplate, token }: { : expiration; }); return createMessage({ - from: EMAIL_FROM, + from, to, subject: template.subject, content: { diff --git a/graphql/main.ts b/graphql/main.ts index d848e87e..c201a789 100644 --- a/graphql/main.ts +++ b/graphql/main.ts @@ -1,76 +1,140 @@ // Must be the first import — see instrument.ts for the rationale. import "./instrument.ts"; +import "./logging.ts"; import { getXForwardedRequest } from "@hongminhee/x-forwarded-fetch"; -import * as models from "./ai.ts"; -import { db } from "./db.ts"; -import { drive } from "./drive.ts"; -import { transport as email } from "./email.ts"; -import { federation } from "./federation.ts"; -import { kv } from "./kv.ts"; +import { toApplicationContext } from "@hackerspub/federation/context"; +import { + getDenoEnvironment, + loadServerConfig, +} from "@hackerspub/runtime/config"; +import { createRuntimeResources } from "@hackerspub/runtime/resources"; import { createYogaServer } from "./mod.ts"; import { handleMediumUploadProxy } from "./medium-upload.ts"; import { services } from "./services.ts"; import assetlinks from "./static/.well-known/assetlinks.json" with { type: "json", }; +import metadata from "./deno.json" with { type: "json" }; const appleAppSiteAssociationJson = Deno.readTextFileSync( new URL("./static/.well-known/apple-app-site-association", import.meta.url), ); const yogaServer = createYogaServer(); +const resources = await createRuntimeResources( + loadServerConfig(getDenoEnvironment()), + metadata.version, + { + fileSystemBaseUrl: new URL("./", import.meta.url), + federation: { manuallyStartQueue: true }, + }, +); +const { db, drive, email, federation, kv, models } = resources; // The federation inbox/outbox queue worker and the periodic news-score sweep // run in a separate process (`worker.ts`), not here: keeping that background // work off this event loop and DB pool is what stops it from starving // user-facing GraphQL requests into Caddy 504s (WEB-NEXT-1W). -Deno.serve({ port: 8080 }, async (req, info) => { - try { - req = await getXForwardedRequest(req); - const url = new URL(req.url); - const disk = drive.use(); - const uploadResponse = await handleMediumUploadProxy(req, kv, disk); - if (uploadResponse != null) return uploadResponse; - if (url.pathname === "/.well-known/assetlinks.json") { - return new Response(JSON.stringify(assetlinks), { - headers: { "content-type": "application/json" }, - }); - } - if (url.pathname === "/.well-known/apple-app-site-association") { - return new Response(appleAppSiteAssociationJson, { - headers: { "content-type": "application/json" }, - }); - } - if ( - url.pathname.startsWith("/.well-known/") || - url.pathname.startsWith("/ap/") || - url.pathname.startsWith("/nodeinfo/") - ) { - return await federation.fetch(req, { - contextData: { db, kv, disk, models, services }, - }); - } - return await yogaServer.fetch(req, { - altTextGenerator: models.altTextGenerator, - db, - kv, - disk, - email, - fedCtx: federation.createContext(req, { +const startServer = () => + Deno.serve({ port: 8080 }, async (req, info) => { + try { + req = await getXForwardedRequest(req); + const url = new URL(req.url); + const disk = drive.use(); + const uploadResponse = await handleMediumUploadProxy(req, kv, disk); + if (uploadResponse != null) return uploadResponse; + if (url.pathname === "/.well-known/assetlinks.json") { + return new Response(JSON.stringify(assetlinks), { + headers: { "content-type": "application/json" }, + }); + } + if (url.pathname === "/.well-known/apple-app-site-association") { + return new Response(appleAppSiteAssociationJson, { + headers: { "content-type": "application/json" }, + }); + } + if ( + url.pathname.startsWith("/.well-known/") || + url.pathname.startsWith("/ap/") || + url.pathname.startsWith("/nodeinfo/") + ) { + return await federation.fetch(req, { + contextData: { db, kv, disk, models, services }, + }); + } + return await yogaServer.fetch(req, { + altTextGenerator: models.altTextGenerator, db, kv, disk, - models, - services, - }), - request: req, - connectionInfo: info, - }); - } catch (e) { - // Client disconnected before the server finished — not a server error. - if (e instanceof DOMException && e.name === "AbortError") { - return new Response(null, { status: 499 }); + email, + emailFrom: resources.config.email.from, + fedCtx: toApplicationContext( + federation.createContext(req, { + db, + kv, + disk, + models, + services, + }), + ), + request: req, + connectionInfo: info, + }); + } catch (e) { + // Client disconnected before the server finished — not a server error. + if (e instanceof DOMException && e.name === "AbortError") { + return new Response(null, { status: 499 }); + } + throw e; + } + }); + +let runtimeFailed = false; +let runtimeError: unknown; +try { + const server = startServer(); + const signals = ["SIGINT", "SIGTERM"] as const; + const registeredSignals = new Set<(typeof signals)[number]>(); + const removeSignalListeners = () => { + for (const signal of registeredSignals) { + Deno.removeSignalListener(signal, shutdown); } - throw e; + registeredSignals.clear(); + }; + const shutdown = () => { + removeSignalListeners(); + void server.shutdown(); + }; + try { + for (const signal of signals) { + Deno.addSignalListener(signal, shutdown); + registeredSignals.add(signal); + } + await server.finished; + } finally { + removeSignalListeners(); + } +} catch (error) { + runtimeFailed = true; + runtimeError = error; +} + +let closeFailed = false; +let closeError: unknown; +try { + await resources.close(); +} catch (error) { + closeFailed = true; + closeError = error; +} +if (runtimeFailed) { + if (closeFailed) { + throw new AggregateError( + [runtimeError, closeError], + "The GraphQL server failed and its resources could not be closed.", + ); } -}); + throw runtimeError; +} +if (closeFailed) throw closeError; diff --git a/graphql/moderation-appeal.test.ts b/graphql/moderation-appeal.test.ts index ea7bd813..f03205cf 100644 --- a/graphql/moderation-appeal.test.ts +++ b/graphql/moderation-appeal.test.ts @@ -1,7 +1,5 @@ import assert from "node:assert"; import test from "node:test"; -import type { RequestContext } from "@fedify/fedify"; -import type { ContextData } from "@hackerspub/models/context"; import type { Transaction } from "@hackerspub/models/db"; import { createArticle } from "@hackerspub/models/article"; import { createFlag } from "@hackerspub/models/flag"; @@ -43,7 +41,7 @@ import { const HOUR = 60 * 60 * 1000; const REASON = "This post contains harassment targeting another user."; -function quietFedCtx(tx: Transaction): RequestContext { +function quietFedCtx(tx: Transaction): ReturnType { const fedCtx = createFedCtx(tx); // deno-lint-ignore no-explicit-any (fedCtx as any).sendActivity = () => Promise.resolve(); @@ -1055,6 +1053,7 @@ test("ban emails direct appeals to email replies", async () => { await withRollback(async (tx) => { const { action } = await sanction(tx, "ban"); const message = await getModerationActionEmail({ + from: "moderation@example.com", locale: new Intl.Locale("en"), to: "reported@example.com", action, diff --git a/graphql/moderation-email.ts b/graphql/moderation-email.ts index e5b956bd..9970d99e 100644 --- a/graphql/moderation-email.ts +++ b/graphql/moderation-email.ts @@ -3,7 +3,6 @@ import type { FlagAction, FlagAppeal } from "@hackerspub/models/schema"; import { expandGlob } from "@std/fs"; import { join } from "@std/path"; import { createMessage, type Message } from "@upyo/core"; -import { EMAIL_FROM } from "./email.ts"; const LOCALES_DIR = join(import.meta.dirname!, "locales"); @@ -63,6 +62,7 @@ async function loadTemplates(): Promise> { * collective identity; the acting moderator is never named. */ export async function getModerationActionEmail(options: { + from: string; locale: Intl.Locale; to: string; action: FlagAction; @@ -109,7 +109,7 @@ export async function getModerationActionEmail(options: { ); } return createMessage({ - from: EMAIL_FROM, + from: options.from, to: options.to, subject: substitute(template.subject), content: { @@ -131,6 +131,7 @@ export async function getModerationActionEmail(options: { * identity. */ export async function getAppealResolvedEmail(options: { + from: string; locale: Intl.Locale; to: string; appeal: FlagAppeal; @@ -154,7 +155,7 @@ export async function getAppealResolvedEmail(options: { ); } return createMessage({ - from: EMAIL_FROM, + from: options.from, to: options.to, subject: substitute(template.appealSubject), content: { diff --git a/graphql/moderation-notification.test.ts b/graphql/moderation-notification.test.ts index 3cc959f4..3edb73ae 100644 --- a/graphql/moderation-notification.test.ts +++ b/graphql/moderation-notification.test.ts @@ -1,7 +1,5 @@ import assert from "node:assert"; import test from "node:test"; -import type { RequestContext } from "@fedify/fedify"; -import type { ContextData } from "@hackerspub/models/context"; import type { Transaction } from "@hackerspub/models/db"; import { createFlag } from "@hackerspub/models/flag"; import { @@ -24,7 +22,7 @@ import { const REASON = "This post contains harassment targeting another user."; -function quietFedCtx(tx: Transaction): RequestContext { +function quietFedCtx(tx: Transaction): ReturnType { const fedCtx = createFedCtx(tx); // deno-lint-ignore no-explicit-any (fedCtx as any).sendActivity = () => Promise.resolve(); diff --git a/graphql/moderation.ts b/graphql/moderation.ts index ef9b89df..3fe713b7 100644 --- a/graphql/moderation.ts +++ b/graphql/moderation.ts @@ -358,11 +358,11 @@ builder.mutationField("reportContent", (t) => // Fire-and-forget: the LLM code of conduct matching runs in the // background on the root database handle and stores its result (or // failure) in flag.llmAnalysis; it never blocks report creation. - const analyzer = ctx.fedCtx.data.models.moderationAnalyzer; + const analyzer = ctx.fedCtx.models.moderationAnalyzer; if (analyzer != null) { void analyzeFlag( ctx.db, - ctx.fedCtx.data.services.ai, + ctx.fedCtx.services.ai, analyzer, flag, flag.snapshot, @@ -1266,6 +1266,7 @@ async function sendModerationActionEmail( const messages = await Promise.all( emails.map(({ email }) => getModerationActionEmail({ + from: ctx.emailFrom, locale, to: email, action, @@ -1931,7 +1932,12 @@ builder.mutationField("resolveFlagAppeal", (t) => .filter((email) => email.verified != null); const locale = new Intl.Locale(appellant.locales?.[0] ?? "en"); const messages = await Promise.all(emails.map(({ email }) => - getAppealResolvedEmail({ locale, to: email, appeal }) + getAppealResolvedEmail({ + from: ctx.emailFrom, + locale, + to: email, + appeal, + }) )); for await (const receipt of ctx.email.sendMany(messages)) { if (!receipt.successful) { diff --git a/graphql/poll.test.ts b/graphql/poll.test.ts index 0e6db92d..3ce73a03 100644 --- a/graphql/poll.test.ts +++ b/graphql/poll.test.ts @@ -1,11 +1,8 @@ import assert from "node:assert"; import test from "node:test"; -import type { Context } from "@fedify/fedify"; import * as vocab from "@fedify/vocab"; import { encodeGlobalID } from "@pothos/plugin-relay"; import { execute, parse } from "graphql"; -import type { ContextData } from "@hackerspub/models/context"; -import type { Transaction } from "@hackerspub/models/db"; import { createQuestion } from "@hackerspub/models/question"; import { actorTable, @@ -385,10 +382,10 @@ test("source-backed local Questions expose their Question ActivityPub IRI", asyn } return new URL(`/objects/${values.id}`, "http://localhost/"); }, - } as ReturnType; + }; const published = new Date("2026-04-15T00:00:00.000Z"); const question = await createQuestion( - fedCtx as unknown as Context>, + fedCtx, { accountId: author.account.id, visibility: "public", diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index 91f139ef..50d526bd 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -681,15 +681,6 @@ function makeTransactionalUserContext( const fedCtx = { ...baseFedCtx, request: new Request("http://localhost/graphql"), - federation: { - createContext(request: unknown, data: unknown) { - return { - ...baseFedCtx, - request, - data, - }; - }, - }, } as UserContext["fedCtx"]; return makeUserContext(tx, account, { fedCtx }); } diff --git a/graphql/post.ts b/graphql/post.ts index 323fd971..b5c0458e 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -3309,7 +3309,7 @@ builder.relayMutationField( attachedMedia.map(async (medium, i) => { const alt = medium.alt.trim(); if (alt === "") throw new InvalidInputError(`media.${i}.alt`); - const storedMedium = await context.data.db.query.mediumTable + const storedMedium = await context.db.query.mediumTable .findFirst({ where: { id: medium.mediumId }, }); @@ -3610,7 +3610,7 @@ builder.relayMutationField( attachedMedia.map(async (medium, i) => { const alt = medium.alt.trim(); if (alt === "") throw new InvalidInputError(`media.${i}.alt`); - const storedMedium = await context.data.db.query.mediumTable + const storedMedium = await context.db.query.mediumTable .findFirst({ where: { id: medium.mediumId }, }); @@ -4134,7 +4134,7 @@ builder.relayMutationField( // Create article from draft const article = await withTransaction(ctx.fedCtx, async (context) => { - const media = await context.data.db.query.articleDraftMediumTable + const media = await context.db.query.articleDraftMediumTable .findMany({ where: { articleDraftId: draft.id }, }); diff --git a/graphql/query-tx-plugin.test.ts b/graphql/query-tx-plugin.test.ts index dbba7ec1..245ef357 100644 --- a/graphql/query-tx-plugin.test.ts +++ b/graphql/query-tx-plugin.test.ts @@ -25,12 +25,38 @@ interface StubDb { ): Promise; } +interface StubApplicationContext { + readonly db: unknown; + readonly rootDb?: unknown; + readonly afterCommit?: Array<() => Promise | void>; + withDatabase(db: unknown): StubApplicationContext; + lookupObject(value: URL): Promise; +} + +function makeStubApplicationContext( + db: unknown, + onLookup: (db: unknown) => void = () => {}, +): StubApplicationContext { + return { + db, + withDatabase(nextDb) { + return makeStubApplicationContext(nextDb, onLookup); + }, + lookupObject(_value) { + onLookup(db); + return Promise.resolve(null); + }, + }; +} + interface Harness { readonly stubDb: StubDb; readonly txCalls: Array< { config: { isolationLevel?: string; accessMode?: string } } >; - readonly fedData: { db: unknown }; + readonly fedData: StubApplicationContext; + readonly lookupState: { db?: unknown }; + readonly transactionState: { open: boolean }; readonly payload: OnExecutePayload; registeredExecute: ExecuteFn | undefined; innerExecuteSawCtxDb?: unknown; @@ -42,22 +68,34 @@ function buildHarness( operationName: string | null = null, ): Harness { const txCalls: Harness["txCalls"] = []; + const transactionState = { open: false }; const stubDb: StubDb = { id: "root-db", async transaction(cb, config) { txCalls.push({ config }); - return await cb({ id: "tx" }); + transactionState.open = true; + try { + return await cb({ id: "tx" }); + } finally { + transactionState.open = false; + } }, }; - const fedData = { db: stubDb as unknown }; + const lookupState: { db?: unknown } = {}; + const fedData = makeStubApplicationContext( + stubDb, + (db) => lookupState.db = db, + ); const contextValue = { db: stubDb, - fedCtx: { data: fedData }, + fedCtx: fedData, } as unknown as UserContext; const harness: Harness = { stubDb, txCalls, fedData, + lookupState, + transactionState, registeredExecute: undefined, payload: { args: { @@ -69,10 +107,11 @@ function buildHarness( executeFn: (async (args) => { const innerCtx = args.contextValue as unknown as { db: unknown; - fedCtx: { data: { db: unknown } }; + fedCtx: StubApplicationContext; }; harness.innerExecuteSawCtxDb = innerCtx.db; - harness.innerExecuteSawFedDb = innerCtx.fedCtx.data.db; + harness.innerExecuteSawFedDb = innerCtx.fedCtx.db; + await innerCtx.fedCtx.lookupObject(new URL("https://example.com/")); return { data: { __typename: "Query" } }; }) as ExecuteFn, setExecuteFn(fn: ExecuteFn) { @@ -108,7 +147,7 @@ test("useQuerySnapshotTransaction wraps a query in REPEATABLE READ", async () => // legitimately write (e.g. `addPostToTimeline`, `persistPost` through // `ctx.fedCtx`), and locking them out would regress those flows. assert.deepEqual(h.txCalls[0].config.accessMode, undefined); - // Both ctx.db and ctx.fedCtx.data.db should observe the swap so any + // Both ctx.db and ctx.fedCtx.db should observe the swap so any // model helper invoked via fedCtx (persistActor / persistPost / ...) // shares the transaction snapshot. assert.deepEqual( @@ -119,6 +158,11 @@ test("useQuerySnapshotTransaction wraps a query in REPEATABLE READ", async () => (h.innerExecuteSawFedDb as { id?: string } | undefined)?.id, "tx", ); + assert.deepEqual( + (h.lookupState.db as { id?: string } | undefined)?.id, + "tx", + "Fedify helpers must close over the transaction-scoped context", + ); // The originals are restored once execute returns. assert.deepEqual( (h.payload.args.contextValue as unknown as { db: { id: string } }).db.id, @@ -134,6 +178,60 @@ test("useQuerySnapshotTransaction wraps a query in REPEATABLE READ", async () => ); }); +test("useQuerySnapshotTransaction defers work until after commit", async () => { + const plugin = useQuerySnapshotTransaction(); + const h = buildHarness("query Q { __typename }", "Q"); + let backgroundRanInTransaction: boolean | undefined; + h.payload.executeFn = (async (args) => { + const innerCtx = args.contextValue as unknown as { + fedCtx: StubApplicationContext; + }; + const txCtx = innerCtx.fedCtx; + assert.deepEqual(txCtx.rootDb, h.stubDb); + assert.ok(txCtx.afterCommit != null); + txCtx.afterCommit.push(async () => { + backgroundRanInTransaction = h.transactionState.open; + await txCtx.withDatabase(txCtx.rootDb).lookupObject( + new URL("https://example.com/background"), + ); + }); + return { data: { __typename: "Query" } }; + }) as ExecuteFn; + + await plugin.onExecute!(h.payload); + await h.registeredExecute!(h.payload.args); + + assert.deepEqual(backgroundRanInTransaction, false); + assert.deepEqual(h.lookupState.db, h.stubDb); +}); + +test("useQuerySnapshotTransaction continues after an after-commit failure", async () => { + const plugin = useQuerySnapshotTransaction(); + const h = buildHarness("query Q { __typename }", "Q"); + let subsequentTaskRan = false; + h.payload.executeFn = (async (args) => { + const innerCtx = args.contextValue as unknown as { + fedCtx: StubApplicationContext; + }; + innerCtx.fedCtx.afterCommit?.push( + () => Promise.reject(new Error("background failure")), + () => { + subsequentTaskRan = true; + }, + ); + return { data: { __typename: "Query" } }; + }) as ExecuteFn; + + await plugin.onExecute!(h.payload); + const result = await h.registeredExecute!(h.payload.args); + + assert.deepEqual(subsequentTaskRan, true); + assert.deepEqual( + (result as { data: { __typename: string } }).data.__typename, + "Query", + ); +}); + test("useQuerySnapshotTransaction restores db handles when execute throws", async () => { const plugin = useQuerySnapshotTransaction(); const h = buildHarness("query Q { __typename }", "Q"); @@ -239,10 +337,9 @@ test("useQuerySnapshotTransaction retries on serialization failure (40001)", asy return await cb({ id: "tx" }); }, }; - const fedData = { db: stubDb as unknown }; const contextValue = { db: stubDb, - fedCtx: { data: fedData }, + fedCtx: makeStubApplicationContext(stubDb), } as unknown as UserContext; let registeredExecute: ExecuteFn | undefined; const payload = { @@ -284,10 +381,9 @@ test("useQuerySnapshotTransaction retries on deadlock (40P01)", async () => { return await cb({ id: "tx" }); }, }; - const fedData = { db: stubDb as unknown }; const contextValue = { db: stubDb, - fedCtx: { data: fedData }, + fedCtx: makeStubApplicationContext(stubDb), } as unknown as UserContext; let registeredExecute: ExecuteFn | undefined; const payload = { @@ -325,10 +421,9 @@ test("useQuerySnapshotTransaction gives up after maxRetries", async () => { throw makePgError("40001"); }, }; - const fedData = { db: stubDb as unknown }; const contextValue = { db: stubDb, - fedCtx: { data: fedData }, + fedCtx: makeStubApplicationContext(stubDb), } as unknown as UserContext; let registeredExecute: ExecuteFn | undefined; const payload = { @@ -373,10 +468,9 @@ test("useQuerySnapshotTransaction does not retry non-retryable errors", async () throw new Error("some other error"); }, }; - const fedData = { db: stubDb as unknown }; const contextValue = { db: stubDb, - fedCtx: { data: fedData }, + fedCtx: makeStubApplicationContext(stubDb), } as unknown as UserContext; let registeredExecute: ExecuteFn | undefined; const payload = { diff --git a/graphql/query-tx-plugin.ts b/graphql/query-tx-plugin.ts index aad61235..d05fcce5 100644 --- a/graphql/query-tx-plugin.ts +++ b/graphql/query-tx-plugin.ts @@ -1,4 +1,6 @@ +import type { AfterCommitTask } from "@hackerspub/models/context"; import type { Database } from "@hackerspub/models/db"; +import { getLogger } from "@logtape/logtape"; import { type DocumentNode, Kind, type OperationDefinitionNode } from "graphql"; import type { Plugin as EnvelopPlugin } from "graphql-yoga"; import postgres from "postgres"; @@ -41,6 +43,7 @@ import type { UserContext } from "./builder.ts"; // 40P01 — deadlock_detected: raised when the server breaks a deadlock by // aborting one of the involved transactions. const RETRYABLE_PG_CODES = new Set(["40001", "40P01"]); +const logger = getLogger(["hackerspub", "graphql", "query-tx-plugin"]); export function isRetryableError(err: unknown): boolean { return err instanceof postgres.PostgresError && @@ -65,27 +68,27 @@ export function useQuerySnapshotTransaction( setExecuteFn(async (innerArgs) => { let attempt = 0; while (true) { + const afterCommit: AfterCommitTask[] = []; + let result: Awaited>; try { - return await rootDb.transaction( + result = await rootDb.transaction( async (tx) => { // Swap every database handle reachable through the context so // both direct Drizzle access (`ctx.db` for resolvers and // Pothos drizzle's re-fetches, via the schema's // `drizzle.client: (ctx) => ctx.db` factory) and federation - // helpers (`ctx.fedCtx.data.db` used by `persistActor`, - // `persistPost`, `addPostToTimeline`, …) share the same - // snapshot. Leaving `fedCtx.data.db` pointing at the root - // pool would let a query resolver write outside the - // transaction and then fail to see its own write through - // `ctx.db`. `contextValue` is typed `Readonly` by envelop, - // but at runtime it is the same object resolvers consume, - // and per-request mutation is the standard envelop pattern - // for request-scoped overrides. + // helpers share the same snapshot. Rebinding the complete + // application context is necessary because methods such as + // `lookupObject` and `sendActivity` close over the underlying + // Fedify context and its `data.db`; changing only `fedCtx.db` + // leaves those methods on the root pool. `contextValue` is + // typed `Readonly` by envelop, but at runtime it is the same + // object resolvers consume, and per-request mutation is the + // standard envelop pattern for request-scoped overrides. const liveCtx = innerArgs .contextValue as unknown as UserContext; const originalDb = liveCtx.db; - const fedData = liveCtx.fedCtx.data; - const originalFedDb = fedData.db; + const originalFedCtx = liveCtx.fedCtx; // Cast: PostgresJsTransaction structurally satisfies the // PostgresJsDatabase interface (both extend PgAsyncDatabase), // but Drizzle's nominal class typing makes TypeScript reject @@ -93,12 +96,17 @@ export function useQuerySnapshotTransaction( // every method Pothos and resolvers call. const txAsDb = tx as unknown as Database; liveCtx.db = txAsDb; - fedData.db = txAsDb; + liveCtx.fedCtx = { + ...originalFedCtx.withDatabase(txAsDb), + db: txAsDb, + rootDb, + afterCommit, + }; try { return await wrappedExecute(innerArgs); } finally { liveCtx.db = originalDb; - fedData.db = originalFedDb; + liveCtx.fedCtx = originalFedCtx; } }, { isolationLevel: "repeatable read" }, @@ -110,6 +118,19 @@ export function useQuerySnapshotTransaction( } throw err; } + // A query resolver can schedule work that outlives execution. Run + // it only after the snapshot transaction commits so it can rebind + // itself to rootDb instead of retaining a closed transaction. + for (const task of afterCommit) { + try { + await task(); + } catch (error) { + logger.error("Failed to run after-commit task: {error}", { + error, + }); + } + } + return result; } }); }, diff --git a/graphql/services.ts b/graphql/services.ts index 129d885a..a45ec4f1 100644 --- a/graphql/services.ts +++ b/graphql/services.ts @@ -1,10 +1,9 @@ -import type { Context } from "@fedify/fedify"; import { aiServices } from "@hackerspub/ai/services"; import { federationServices } from "@hackerspub/federation/services"; -import type { ContextData } from "@hackerspub/models/context"; +import type { ApplicationContext } from "@hackerspub/models/context"; import type { ApplicationServices } from "@hackerspub/models/services"; export const services = { ai: aiServices, federation: federationServices, -} satisfies ApplicationServices>; +} satisfies ApplicationServices; diff --git a/graphql/worker.ts b/graphql/worker.ts index eb1e4675..ac8481bb 100644 --- a/graphql/worker.ts +++ b/graphql/worker.ts @@ -1,5 +1,6 @@ // Must be the first import — see instrument.ts for the rationale. import "./instrument.ts"; +import "./logging.ts"; import { sweepExpiredSuspensionRescores } from "@hackerspub/models/moderation"; import { @@ -8,14 +9,25 @@ import { } from "@hackerspub/models/news"; import { notifyEndedPolls } from "@hackerspub/models/poll"; import { getLogger } from "@logtape/logtape"; +import { + getDenoEnvironment, + loadServerConfig, +} from "@hackerspub/runtime/config"; +import { createRuntimeResources } from "@hackerspub/runtime/resources"; import { sql } from "drizzle-orm"; -import * as models from "./ai.ts"; -import { db } from "./db.ts"; -import { drive } from "./drive.ts"; -import { federation, ORIGIN } from "./federation.ts"; -import { kv } from "./kv.ts"; import { sendNotificationDigests } from "./notification-digest.ts"; import { services } from "./services.ts"; +import metadata from "./deno.json" with { type: "json" }; + +const resources = await createRuntimeResources( + loadServerConfig(getDenoEnvironment()), + metadata.version, + { + fileSystemBaseUrl: new URL("./", import.meta.url), + federation: { manuallyStartQueue: true }, + }, +); +const { db, drive, email, federation, kv, models } = resources; const logger = getLogger(["hackerspub", "graphql", "worker"]); @@ -25,14 +37,20 @@ const logger = getLogger(["hackerspub", "graphql", "worker"]); // this signal; otherwise the process would hang on shutdown (or run another // sweep mid-shutdown) instead of draining and exiting. const controller = new AbortController(); +const signalListeners: Array<{ + readonly signal: "SIGINT" | "SIGTERM"; + readonly listener: () => void; +}> = []; for (const signalName of ["SIGINT", "SIGTERM"] as const) { - Deno.addSignalListener(signalName, () => { + const listener = () => { logger.info( "Received {signal}; shutting down the queue worker gracefully.", { signal: signalName }, ); controller.abort(); - }); + }; + Deno.addSignalListener(signalName, listener); + signalListeners.push({ signal: signalName, listener }); } // Periodic news-score sweep. The write hook re-scores a link only when the @@ -148,12 +166,11 @@ const digestLogger = getLogger([ ]); async function sendNotificationDigestJob(frequency: "daily" | "weekly") { - const { EMAIL_FROM, transport: email } = await import("./email.ts"); return await sendNotificationDigests({ db, email, - from: EMAIL_FROM, - origin: ORIGIN, + from: resources.config.email.from, + origin: resources.config.origin.href, frequency, }); } @@ -200,8 +217,36 @@ Deno.cron("send-daily-notification-digests", "5 0 * * *", { // independently). const disk = drive.use(); logger.info("Starting the federation message queue worker."); -await federation.startQueue( - { db, kv, disk, models, services }, - { signal: controller.signal }, -); -logger.info("The federation message queue worker has stopped."); +let queueFailed = false; +let queueError: unknown; +try { + await federation.startQueue( + { db, kv, disk, models, services }, + { signal: controller.signal }, + ); + logger.info("The federation message queue worker has stopped."); +} catch (error) { + queueFailed = true; + queueError = error; +} +for (const { signal, listener } of signalListeners) { + Deno.removeSignalListener(signal, listener); +} +let closeFailed = false; +let closeError: unknown; +try { + await resources.close(); +} catch (error) { + closeFailed = true; + closeError = error; +} +if (queueFailed) { + if (closeFailed) { + throw new AggregateError( + [queueError, closeError], + "The federation queue worker failed and its resources could not be closed.", + ); + } + throw queueError; +} +if (closeFailed) throw closeError; diff --git a/models/account.ts b/models/account.ts index 8eed7268..c53100e4 100644 --- a/models/account.ts +++ b/models/account.ts @@ -1,4 +1,4 @@ -import { getNodeInfo, type RequestContext } from "@fedify/fedify"; +import { getNodeInfo } from "@fedify/fedify"; import { getActorHandle, isActor, @@ -11,9 +11,9 @@ import { zip } from "@std/collections/zip"; import { encodeHex } from "@std/encoding/hex"; import { escape, unescape } from "@std/html/entities"; import { and, eq, inArray, or, sql } from "drizzle-orm"; -import type { Disk } from "flydrive"; +import type { StorageService } from "./context.ts"; import sharp from "sharp"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database, Transaction } from "./db.ts"; import { createMediumFromBlob, @@ -383,22 +383,19 @@ function toDeletedAccountRecipient(actor: Actor): vocab.Recipient | null { } async function ensureAccountKeys( - fedCtx: RequestContext, + fedCtx: ApplicationContext, accountId: Uuid, ): Promise { - const context = fedCtx as RequestContext & { - getActorKeyPairs?: (identifier: string) => Promise; - }; - if (typeof context.getActorKeyPairs !== "function") return; - await context.getActorKeyPairs(accountId); + if (typeof fedCtx.getActorKeyPairs !== "function") return; + await fedCtx.getActorKeyPairs(accountId); } export async function deleteAccount( - fedCtx: RequestContext, + fedCtx: ApplicationContext, accountId: Uuid, options: DeleteAccountOptions = {}, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const accountForKeys = await db.query.accountTable.findFirst({ where: { id: accountId }, with: { actor: true }, @@ -598,7 +595,7 @@ export async function deleteAccount( } export async function getAvatarUrl( - disk: Disk, + disk: StorageService, account: Account & { emails: AccountEmail[]; avatarMedium?: Medium | null; @@ -636,7 +633,7 @@ async function preprocessAvatarMedium( export async function createAvatarMediumFromBlob( db: Database, - disk: Disk, + disk: StorageService, blob: Blob, options: { maxSize?: number } = {}, ): Promise { @@ -648,7 +645,7 @@ export async function createAvatarMediumFromBlob( export async function createAvatarMediumFromUrl( db: Database, - disk: Disk, + disk: StorageService, url: URL, options: { maxSize?: number; userAgentUrl?: URL } = {}, ): Promise { @@ -660,7 +657,7 @@ export async function createAvatarMediumFromUrl( export async function createAvatarMediumFromMedium( db: Database, - disk: Disk, + disk: StorageService, medium: Medium, options: { maxSize?: number } = {}, ): Promise { @@ -710,10 +707,10 @@ export async function getAccountByUsername( } export async function updateAccount( - fedCtx: RequestContext, + fedCtx: ApplicationContext, account: Partial & { id: Uuid; links?: Link[] }, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const result = await updateAccountData(db, account); if (result == null) return undefined; let links: AccountLink[]; @@ -735,7 +732,7 @@ export async function updateAccount( } export async function sendAccountActorUpdate( - fedCtx: RequestContext, + fedCtx: ApplicationContext, accountId: Uuid, updated: Date, ): Promise { diff --git a/models/actor.ts b/models/actor.ts index 0a012e69..cca2ec56 100644 --- a/models/actor.ts +++ b/models/actor.ts @@ -1,4 +1,4 @@ -import type { Context, DocumentLoader } from "@fedify/fedify"; +import type { DocumentLoader } from "@fedify/fedify"; import { getActorHandle, getActorTypeName, @@ -28,7 +28,7 @@ import { getAvatarUrl as getAccountAvatarUrl, renderAccountLinks, } from "./account.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import { isActorSuspended } from "./moderation.ts"; import { toDate } from "./date.ts"; import metadata from "./deno.json" with { type: "json" }; @@ -100,7 +100,7 @@ async function mapWithConcurrencyLimit( } export async function syncActorFromAccount( - fedCtx: Context, + fedCtx: ApplicationContext, account: Account & { avatarMedium: Medium | null; emails: AccountEmail[]; @@ -121,7 +121,7 @@ export async function syncActorFromAccount( software: "hackerspub", softwareVersion: metadata.version, }; - const { db, kv, disk } = fedCtx.data; + const { db, kv, storage: disk } = fedCtx; const instances = await db.insert(instanceTable) .values(instance) .onConflictDoUpdate({ @@ -169,7 +169,7 @@ export async function syncActorFromAccount( } export async function persistActor( - ctx: Context, + ctx: ApplicationContext, actor: vocab.Actor, options: { contextLoader?: DocumentLoader; @@ -189,9 +189,9 @@ export async function persistActor( return undefined; } if (actor.id.origin === ctx.canonicalOrigin) { - return await getPersistedActor(ctx.data.db, actor.id.href); + return await getPersistedActor(ctx.db, actor.id.href); } - const { db } = ctx.data; + const { db } = ctx; // Remote actors under an active moderation suspension are federation- // blocked: their activities are dropped at this single choke point // (nearly every inbox handler bails when the actor fails to persist), @@ -463,7 +463,7 @@ export function getPersistedActor( const KV_UNREACHABLE_HANDLES_NAMESPACE = "unreachable-handles"; export async function persistActorsByHandles( - ctx: Context, + ctx: ApplicationContext, handles: string[], ): Promise> { const filter: RelationsFilter<"actorTable">[] = []; @@ -485,7 +485,7 @@ export async function persistActorsByHandles( }); } if (filter.length < 1) return {}; - const { db } = ctx.data; + const { db } = ctx; const existingActors = await db.query.actorTable.findMany({ with: { instance: true }, where: { OR: [...filter] }, @@ -501,7 +501,7 @@ export async function persistActorsByHandles( } } const handlesToFetchArray = [...handlesToFetch]; - const unreachableHandles = await ctx.data.kv.getMany( + const unreachableHandles = await ctx.kv.getMany( handlesToFetchArray.map((handle) => `${KV_UNREACHABLE_HANDLES_NAMESPACE}/${handle}` ), @@ -530,7 +530,7 @@ export async function persistActorsByHandles( "Timeout while looking up actor {handle}, skipping.", { handle }, ); - await ctx.data.kv.set( + await ctx.kv.set( `${KV_UNREACHABLE_HANDLES_NAMESPACE}/${handle}`, "1", 300_000, diff --git a/models/appeal.test.ts b/models/appeal.test.ts index 6b6b3f54..faa1f26b 100644 --- a/models/appeal.test.ts +++ b/models/appeal.test.ts @@ -1,7 +1,5 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import type { RequestContext } from "@fedify/fedify"; -import type { ContextData } from "@hackerspub/models/context"; import type { Transaction } from "@hackerspub/models/db"; import { createFlag } from "@hackerspub/models/flag"; import { @@ -27,7 +25,7 @@ import { const HOUR = 60 * 60 * 1000; const REASON = "This post contains harassment targeting another user."; -function recordingFedCtx(tx: Transaction): RequestContext { +function recordingFedCtx(tx: Transaction): ReturnType { const fedCtx = createFedCtx(tx); // deno-lint-ignore no-explicit-any (fedCtx as any).sendActivity = () => Promise.resolve(); @@ -55,7 +53,7 @@ async function makeModerator(tx: Transaction, username = "moderator") { async function makeSanctionedCase( tx: Transaction, - fedCtx: RequestContext, + fedCtx: ReturnType, actionType: FlagActionType, ) { const moderator = await makeModerator(tx); diff --git a/models/article.background.test.ts b/models/article.background.test.ts index 946acd90..3ed6d353 100644 --- a/models/article.background.test.ts +++ b/models/article.background.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert"; import test from "node:test"; import { MockLanguageModelV3 } from "ai/test"; import { eq } from "drizzle-orm"; +import { defineApplicationModel } from "./context.ts"; import { accountTable, articleContentTable, @@ -23,7 +24,7 @@ import { } from "../test/postgres.ts"; import { waitFor } from "../test/wait.ts"; import { generateUuidV7, type Uuid } from "./uuid.ts"; -import { db } from "../graphql/db.ts"; +import { db } from "../test/database.ts"; test("startArticleContentSummary() resets summaryStarted when summarization fails", async () => { await withRollback(async (tx) => { @@ -129,19 +130,11 @@ test("createArticle() starts summaries after enclosing transaction commit", asyn const fedCtx = createFedCtx( db as unknown as Parameters[0], ); - Object.assign(fedCtx, { - federation: { - createContext(request: Request, data: typeof fedCtx.data) { - return { ...fedCtx, request, data }; - }, - } as typeof fedCtx.federation, - request: new Request("http://localhost/"), - }); - fedCtx.data.models = { - summarizer, + fedCtx.models = { + summarizer: defineApplicationModel(summarizer), translator: {} as never, moderationAnalyzer: {} as never, - } as typeof fedCtx.data.models; + } as typeof fedCtx.models; const published = new Date("2026-04-15T00:00:00.000Z"); let sourceId: Uuid | undefined; @@ -188,11 +181,11 @@ test("createArticle() starts summaries after enclosing transaction commit", asyn test("startArticleContentTranslation() deletes queued rows when translation fails", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = { + fedCtx.models = { summarizer: {} as never, translator: {} as never, moderationAnalyzer: {} as never, - } as typeof fedCtx.data.models; + } as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "translationbackground", name: "Translation Background", @@ -259,11 +252,11 @@ test( // branch, which is enough to confirm // restartArticleContentTranslations actually re-fired the // translation pipeline against the reset row. - fedCtx.data.models = { + fedCtx.models = { summarizer: {} as never, translator: {} as never, moderationAnalyzer: {} as never, - } as typeof fedCtx.data.models; + } as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "restarttranslator", name: "Restart Translator", @@ -351,11 +344,11 @@ test( async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = { + fedCtx.models = { summarizer: {} as never, translator: {} as never, moderationAnalyzer: {} as never, - } as typeof fedCtx.data.models; + } as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "restartnotrans", name: "Restart No Translations", diff --git a/models/article.lifecycle.test.ts b/models/article.lifecycle.test.ts index 027af475..f23840e0 100644 --- a/models/article.lifecycle.test.ts +++ b/models/article.lifecycle.test.ts @@ -1,9 +1,8 @@ import assert from "node:assert"; import test from "node:test"; -import type { Context } from "@fedify/fedify"; import { Article as ActivityPubArticle, Create } from "@fedify/vocab"; import { createArticle, updateArticle } from "./article.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Transaction } from "./db.ts"; import { articleContentTable, @@ -28,7 +27,7 @@ const fakeModels = { test("createArticle() creates a post and timeline entry for the author", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + fedCtx.models = fakeModels as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "createarticleauthor", name: "Create Article Author", @@ -102,8 +101,8 @@ test("createArticle() applies post-created hooks before federation", async () => sent.push(args); return Promise.resolve(undefined); }, - } as unknown as Context>; - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + } as unknown as ApplicationContext; + fedCtx.models = fakeModels as typeof fedCtx.models; const article = await createArticle( fedCtx, @@ -153,7 +152,7 @@ test("createArticle() applies post-created hooks before federation", async () => test("createArticle() copies source media before rendering the post", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + fedCtx.models = fakeModels as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "createarticlemediaauthor", name: "Create Article Media Author", @@ -213,7 +212,7 @@ test("createArticle() copies source media before rendering the post", async () = test("createArticle() rejects content with missing source media", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + fedCtx.models = fakeModels as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "missingarticlemediaauthor", name: "Missing Article Media Author", @@ -243,7 +242,7 @@ test("createArticle() rejects content with missing source media", async () => { test("updateArticle() rewrites the persisted article post", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + fedCtx.models = fakeModels as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "updatearticleauthor", name: "Update Article Author", @@ -310,7 +309,7 @@ test("updateArticle() rewrites the persisted article post", async () => { test("updateArticle() notifies local sharers when the title changes", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + fedCtx.models = fakeModels as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "updatearticleshareauthor", name: "Update Article Share Author", @@ -357,7 +356,7 @@ test("updateArticle() notifies local sharers when the title changes", async () = test("updateArticle() attaches source media before rendering the post", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + fedCtx.models = fakeModels as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "updatearticlemediaauthor", name: "Update Article Media Author", @@ -408,7 +407,7 @@ test("updateArticle() attaches source media before rendering the post", async () test("updateArticle() rejects missing source media without saving content", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + fedCtx.models = fakeModels as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "updatearticlemissingmedia", name: "Update Article Missing Media", @@ -448,7 +447,7 @@ test("updateArticle() rejects missing source media without saving content", asyn test("updateArticle() resets existing translation rows when the body changes", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + fedCtx.models = fakeModels as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "retranslateauthor", name: "Retranslate Author", @@ -512,7 +511,7 @@ test("updateArticle() resets existing translation rows when the body changes", a test("updateArticle() leaves existing translations alone on title-only edits", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + fedCtx.models = fakeModels as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "retranslatetitleauthor", name: "Retranslate Title Author", @@ -571,7 +570,7 @@ test("updateArticle() leaves existing translations alone on title-only edits", a test("updateArticle() does not retranslate when allowLlmTranslation is false", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + fedCtx.models = fakeModels as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "noretranslateauthor", name: "No Retranslate Author", @@ -638,7 +637,7 @@ test("updateArticle() does not retranslate when allowLlmTranslation is false", a test("updateArticle() does not retranslate human-curated translation rows", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); - fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + fedCtx.models = fakeModels as typeof fedCtx.models; const author = await insertAccountWithActor(tx, { username: "humantranslateauthor", name: "Human Translate Author", diff --git a/models/article.ts b/models/article.ts index 5f039355..9c4b04b1 100644 --- a/models/article.ts +++ b/models/article.ts @@ -1,8 +1,7 @@ -import type { Context } from "@fedify/fedify"; import * as vocab from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; import { minBy } from "@std/collections/min-by"; -import type { LanguageModel } from "ai"; +import type { ApplicationModel } from "./context.ts"; import { and, eq, @@ -13,9 +12,9 @@ import { or, sql, } from "drizzle-orm"; -import type { Disk } from "flydrive"; +import type { StorageService } from "./context.ts"; import postgres from "postgres"; -import type { ContextData, Models } from "./context.ts"; +import type { ApplicationContext, Models } from "./context.ts"; import type { Database, Transaction } from "./db.ts"; import { assertAccountActorNotSuspended } from "./moderation.ts"; import { syncPostFromArticleSource } from "./post.ts"; @@ -145,7 +144,7 @@ async function updateArticleSourceMedia( export async function getArticleDraftMediumUrls( db: Database, - disk: Disk, + disk: StorageService, draftId: Uuid, ): Promise> { const media = await db.query.articleDraftMediumTable.findMany({ @@ -164,7 +163,7 @@ export async function getArticleDraftMediumUrls( export async function getArticleSourceMediumUrls( db: Database, - disk: Disk, + disk: StorageService, sourceId: Uuid, ): Promise> { const media = await db.query.articleSourceMediumTable.findMany({ @@ -375,20 +374,20 @@ export async function createArticleSource( } async function queueArticleContentSummary( - fedCtx: Context, + fedCtx: ApplicationContext, content: ArticleContent, ): Promise { await queueAfterCommit(fedCtx, () => startArticleContentSummary( - fedCtx.data.rootDb ?? fedCtx.data.db, - fedCtx.data.models.summarizer, + fedCtx.rootDb ?? fedCtx.db, + fedCtx.models.summarizer, content, - fedCtx.data.services.ai.summarize, + fedCtx.services.ai.summarize, )); } export async function createArticle( - fedCtx: Context, + fedCtx: ApplicationContext, source: Omit & { id?: Uuid; title: string; @@ -412,7 +411,7 @@ export async function createArticle( }; } | undefined > { - const { db } = fedCtx.data; + const { db } = fedCtx; // Check the suspension before any insert: when this function runs // without an enclosing transaction (the legacy draft-publish route), // a guard placed after createArticleSource would leave already- @@ -428,8 +427,8 @@ export async function createArticle( } const articleSource = await createArticleSource( db, - fedCtx.data.models, - fedCtx.data.services.ai, + fedCtx.models, + fedCtx.services.ai, articleSourceInput, { summarize: false }, ); @@ -456,7 +455,7 @@ export async function createArticle( }); await addPostToTimeline(db, post); await options.afterPostCreated?.(post, db); - const articleObject = await fedCtx.data.services.federation.getArticle( + const articleObject = await fedCtx.services.federation.getArticle( fedCtx, { ...articleSource, account }, ); @@ -477,7 +476,7 @@ export async function createArticle( excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], }, ); - const relayedTags = await fedCtx.data.services.federation + const relayedTags = await fedCtx.services.federation .sendTagsPubRelayActivity( fedCtx, source.accountId, @@ -653,7 +652,7 @@ export async function updateArticleSource( } export async function updateArticle( - fedCtx: Context, + fedCtx: ApplicationContext, articleSourceId: Uuid, source: Partial & { title?: string; @@ -672,7 +671,7 @@ export async function updateArticle( }; } | undefined > { - const { db, models } = fedCtx.data; + const { db, models } = fedCtx; const previousPost = await db.query.postTable.findFirst({ where: { articleSourceId }, }); @@ -681,7 +680,7 @@ export async function updateArticle( articleSourceId, source, models, - fedCtx.data.services.ai, + fedCtx.services.ai, ); if (updateResult == null) return undefined; const { source: articleSource, originalContentChanged } = updateResult; @@ -699,7 +698,7 @@ export async function updateArticle( // tag relays, and translation restarts (each of which fires its own Update) // are skipped, while it remains censored. if (post.censored != null) return post; - const articleObject = await fedCtx.data.services.federation.getArticle( + const articleObject = await fedCtx.services.federation.getArticle( fedCtx, { ...articleSource, account }, ); @@ -726,7 +725,7 @@ export async function updateArticle( ], }, ); - const relayedTags = await fedCtx.data.services.federation + const relayedTags = await fedCtx.services.federation .sendTagsPubRelayActivity( fedCtx, articleSource.accountId, @@ -800,7 +799,7 @@ export function getOriginalArticleContent( export async function startArticleContentSummary( db: Database, - model: LanguageModel, + model: ApplicationModel, content: ArticleContent, summarize: AiServices["summarize"], ): Promise { @@ -1008,10 +1007,10 @@ export interface ArticleContentTranslationOptions { } export async function startArticleContentTranslation( - fedCtx: Context, + fedCtx: ApplicationContext, { content, targetLanguage, requester }: ArticleContentTranslationOptions, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; // Stamp `updated` with a JS-side Date rather than letting it // default to PG's `CURRENT_TIMESTAMP`. See the long comment on // the CAS in `runArticleContentTranslation` for why this matters: @@ -1129,10 +1128,10 @@ export async function startArticleContentTranslation( * . */ export async function restartArticleContentTranslations( - fedCtx: Context, + fedCtx: ApplicationContext, articleSource: ArticleSource, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; // Serialize the read-original-then-reset-translations sequence // against any other writer to this article's source row. Two // concurrent restartArticleContentTranslations calls (driven by @@ -1295,10 +1294,10 @@ export function splitTranslationTitleAndContent( * the row into the placeholder state first. */ async function runArticleContentTranslation( - fedCtx: Context, + fedCtx: ApplicationContext, queued: ArticleContent, ): Promise { - const { db, models: { translator: model, summarizer } } = fedCtx.data; + const { db, models: { translator: model, summarizer } } = fedCtx; logger.debug( "Starting translation for content: {sourceId} {language}", queued, @@ -1395,7 +1394,7 @@ async function runArticleContentTranslation( // `article_content_being_translated_check` makes that imply // `originalLanguage IS NOT NULL`. Drizzle types it as nullable // because the column is nullable in general, so assert. - fedCtx.data.services.ai.translate({ + fedCtx.services.ai.translate({ model, summarizationModel: summarizer, sourceLanguage: claimed.originalLanguage!, @@ -1471,11 +1470,11 @@ async function runArticleContentTranslation( db, summarizer, updated[0], - fedCtx.data.services.ai.summarize, + fedCtx.services.ai.summarize, ); return; } - const articleObject = await fedCtx.data.services.federation.getArticle( + const articleObject = await fedCtx.services.federation.getArticle( fedCtx, article, ); @@ -1515,7 +1514,7 @@ async function runArticleContentTranslation( }, ); if (post != null) { - const relayedTags = await fedCtx.data.services.federation + const relayedTags = await fedCtx.services.federation .sendTagsPubRelayActivity( fedCtx, article.accountId, @@ -1538,7 +1537,7 @@ async function runArticleContentTranslation( db, summarizer, updated[0], - fedCtx.data.services.ai.summarize, + fedCtx.services.ai.summarize, ); }).catch(async (error) => { logger.error("Translation failed ({sourceId} {language}): {error}", { diff --git a/models/blocking.ts b/models/blocking.ts index 4c36c532..4f3b273e 100644 --- a/models/blocking.ts +++ b/models/blocking.ts @@ -1,4 +1,4 @@ -import type { Context, DocumentLoader } from "@fedify/fedify"; +import type { DocumentLoader } from "@fedify/fedify"; import { isActor } from "@fedify/vocab"; import * as vocab from "@fedify/vocab"; import { and, eq, inArray } from "drizzle-orm"; @@ -8,7 +8,7 @@ import { persistActor, toRecipient, } from "./actor.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database } from "./db.ts"; import { removeFollower, unfollow } from "./following.ts"; import { @@ -20,7 +20,7 @@ import { import { generateUuidV7, type Uuid } from "./uuid.ts"; export async function persistBlocking( - fedCtx: Context, + fedCtx: ApplicationContext, block: vocab.Block, options: { contextLoader?: DocumentLoader; @@ -31,7 +31,7 @@ export async function persistBlocking( return undefined; } const getterOpts = { ...options, suppressError: true }; - const { db } = fedCtx.data; + const { db } = fedCtx; let blocker = await getPersistedActor(db, block.actorId); if (blocker == null) { const actor = await block.getActor(getterOpts); @@ -72,12 +72,12 @@ export async function persistBlocking( } export async function block( - fedCtx: Context, + fedCtx: ApplicationContext, blocker: Account & { actor: Actor }, blockee: Actor, ): Promise { const id = generateUuidV7(); - const { db } = fedCtx.data; + const { db } = fedCtx; const removeLocalFollowRelationships = async () => { await removeFollower(fedCtx, blocker, blockee); await unfollow(fedCtx, blocker, blockee); @@ -125,11 +125,11 @@ export async function block( } export async function unblock( - fedCtx: Context, + fedCtx: ApplicationContext, blocker: Account & { actor: Actor }, blockee: Actor, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const rows = await db.delete(blockingTable).where( and( eq(blockingTable.blockerId, blocker.actor.id), diff --git a/models/context.ts b/models/context.ts index e6b1ad78..22b6a5fc 100644 --- a/models/context.ts +++ b/models/context.ts @@ -1,5 +1,5 @@ -import type { Context } from "@fedify/fedify"; -import type { LanguageModel } from "ai"; +import type * as vocab from "@fedify/vocab"; +import type { DocumentLoader } from "@fedify/vocab-runtime"; import type { Disk } from "flydrive"; import type Keyv from "keyv"; import type { Database } from "./db.ts"; @@ -7,14 +7,45 @@ import type { ApplicationServices } from "./services.ts"; export type AfterCommitTask = () => Promise | void; +export interface KeyValueStore { + get(key: string): Promise; + getMany(keys: string[]): Promise>; + set(key: string, value: Value, ttl?: number): Promise; +} + +export interface StorageService { + put( + key: string, + contents: string | Uint8Array, + options?: { readonly contentType?: string; readonly visibility?: string }, + ): Promise; + getUrl(key: string): Promise; + getBytes(key: string): Promise; +} + +/** Opaque AI model handle interpreted only by the configured AI adapter. */ +export interface ApplicationModel { + readonly id: string; + readonly implementation: unknown; +} + +export function defineApplicationModel( + implementation: unknown, + id = typeof implementation === "string" + ? implementation + : (implementation as { modelId?: string })?.modelId ?? "unknown", +): ApplicationModel { + return { id, implementation }; +} + export interface Models { - translator: LanguageModel; - summarizer: LanguageModel; + translator: ApplicationModel; + summarizer: ApplicationModel; /** * Matches reports against the code of conduct (a reference tool for * moderators, never an automated decision system). */ - moderationAnalyzer: LanguageModel; + moderationAnalyzer: ApplicationModel; } export interface ContextData { @@ -24,5 +55,61 @@ export interface ContextData { kv: Keyv; disk: Disk; models: Models; - services: ApplicationServices>; + services: ApplicationServices; +} + +/** + * Runtime-neutral dependencies used by application operations. + * + * Fedify adapters translate their request context into this plain object; + * transaction helpers can therefore replace the database handle without + * manufacturing a new HTTP or federation request context. + */ +export interface ApplicationContext { + db: D; + /** Rebind every adapter capability to a different database handle. */ + withDatabase(db: Database): ApplicationContext; + rootDb?: Database; + afterCommit?: AfterCommitTask[]; + kv: KeyValueStore; + storage: StorageService; + models: Models; + services: ApplicationServices; + /** Request-scoped federation capability supplied by an adapter. */ + federation: object; + readonly origin: string; + readonly canonicalOrigin: string; + readonly host: string; + readonly documentLoader: DocumentLoader; + readonly contextLoader: DocumentLoader; + getActorUri(identifier: string): URL; + getInboxUri(identifier?: string): URL; + getOutboxUri(identifier: string): URL; + getFollowersUri(identifier: string): URL; + getFollowingUri(identifier: string): URL; + getFeaturedUri(identifier: string): URL; + getObjectUri(type: unknown, values: Record): URL; + getDocumentLoader(options?: unknown): DocumentLoader; + lookupObject( + value: string | URL, + options?: object, + ): Promise; + lookupWebFinger(resource: string | URL): Promise< + { + links?: readonly { + rel: string; + type?: string; + href?: string; + template?: string; + }[]; + } | null + >; + getActor(identifier: string): Promise; + getActorKeyPairs?(identifier: string): Promise; + sendActivity( + sender: unknown, + recipients: unknown, + activity: vocab.Activity, + options?: unknown, + ): Promise; } diff --git a/models/flag.test.ts b/models/flag.test.ts index c2aaec55..00cb2798 100644 --- a/models/flag.test.ts +++ b/models/flag.test.ts @@ -8,7 +8,6 @@ import { postTable, } from "@hackerspub/models/schema"; import { generateUuidV7 } from "@hackerspub/models/uuid"; -import type { LanguageModel } from "ai"; import { eq, sql } from "drizzle-orm"; import { insertAccountWithActor, @@ -18,6 +17,7 @@ import { withRollback, } from "../test/postgres.ts"; import type { Transaction } from "./db.ts"; +import { defineApplicationModel } from "./context.ts"; import { analyzeFlag, createFlag, @@ -592,7 +592,7 @@ describe("analyzeFlag()", () => { }); }, }, - { modelId: "test-analyzer" } as LanguageModel, + defineApplicationModel({}, "test-analyzer"), flag, flag.snapshot, ); @@ -621,7 +621,7 @@ describe("analyzeFlag()", () => { return Promise.reject(new Error("analyzer unavailable")); }, }, - { modelId: "failing-analyzer" } as LanguageModel, + defineApplicationModel({}, "failing-analyzer"), flag, flag.snapshot, ); diff --git a/models/flag.ts b/models/flag.ts index 89249e01..b6f5aaec 100644 --- a/models/flag.ts +++ b/models/flag.ts @@ -1,5 +1,5 @@ import { getLogger } from "@logtape/logtape"; -import type { LanguageModel } from "ai"; +import type { ApplicationModel } from "./context.ts"; import { and, eq, inArray, isNull } from "drizzle-orm"; import { getCocProvisions, getCocVersion } from "./coc.ts"; import type { Database, Transaction } from "./db.ts"; @@ -288,11 +288,11 @@ async function getPostSourceContent( export async function analyzeFlag( db: Database, aiServices: Pick, - model: LanguageModel, + model: ApplicationModel, flag: Flag, snapshot: ContentSnapshot, ): Promise { - const modelId = typeof model === "string" ? model : model.modelId; + const modelId = model.id; let analysis: FlagLlmAnalysis; try { const provisions = await getCocProvisions("en"); diff --git a/models/following.ts b/models/following.ts index 71b391bb..3aa8ec09 100644 --- a/models/following.ts +++ b/models/following.ts @@ -1,4 +1,3 @@ -import type { Context } from "@fedify/fedify"; import { assertAccountActorNotSuspended } from "./moderation.ts"; import { Follow, Reject, Undo } from "@fedify/vocab"; import { @@ -12,7 +11,7 @@ import { sql, } from "drizzle-orm"; import { toRecipient } from "./actor.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database } from "./db.ts"; import { createFollowNotification, @@ -28,7 +27,7 @@ import { import type { Uuid } from "./uuid.ts"; export function createFollowingIri( - fedCtx: Context, + fedCtx: ApplicationContext, follower: Account, ): URL { return new URL( @@ -38,11 +37,11 @@ export function createFollowingIri( } export async function follow( - fedCtx: Context, + fedCtx: ApplicationContext, follower: Account & { actor: Actor }, followee: Actor, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; await assertAccountActorNotSuspended(db, follower.id); const rows = await db.insert(followingTable).values({ iri: createFollowingIri(fedCtx, follower).href, @@ -122,11 +121,11 @@ export async function acceptFollowing( } export async function unfollow( - fedCtx: Context, + fedCtx: ApplicationContext, follower: Account & { actor: Actor }, followee: Actor, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const rows = await db.delete(followingTable).where( and( eq(followingTable.followerId, follower.actor.id), @@ -166,11 +165,11 @@ export async function unfollow( } export async function removeFollower( - fedCtx: Context, + fedCtx: ApplicationContext, followee: Account & { actor: Actor }, follower: Actor, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const rows = await db.delete(followingTable).where( and( eq(followingTable.followerId, follower.id), diff --git a/models/markup.ts b/models/markup.ts index 9e98b443..d358fe46 100644 --- a/models/markup.ts +++ b/models/markup.ts @@ -1,4 +1,4 @@ -import type { Context, DocumentLoader } from "@fedify/fedify"; +import type { DocumentLoader } from "@fedify/fedify"; import { isActor } from "@fedify/vocab"; import type * as vocab from "@fedify/vocab"; import { hashtag, spanHashAndTag } from "@fedify/markdown-it-hashtag"; @@ -24,7 +24,7 @@ import { ASCII_DIACRITICS_REGEXP, slugify } from "@std/text/unstable-slugify"; import { load } from "cheerio"; import { arrayOverlaps, eq } from "drizzle-orm"; import katex from "katex"; -import type Keyv from "keyv"; +import type { KeyValueStore } from "./context.ts"; import abbr from "markdown-it-abbr"; import anchor from "markdown-it-anchor"; import MarkdownItAsync from "markdown-it-async"; @@ -35,7 +35,7 @@ import graphviz from "markdown-it-graphviz"; import toc from "markdown-it-toc-done-right"; import { codeToHtml } from "shiki"; import { persistActor, persistActorsByHandles } from "./actor.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import { sanitizeExcerptHtml, sanitizeHtml, stripHtml } from "./html.ts"; import { negotiateLocale } from "./i18n.ts"; import { type Actor, actorTable } from "./schema.ts"; @@ -174,7 +174,7 @@ interface Env { } export interface RenderMarkupOptions { - kv?: Keyv | null; + kv?: KeyValueStore | null; docId?: string | null; refresh?: boolean; mediumUrls?: Record; @@ -190,7 +190,7 @@ function canonicalizeMediumUrls(mediumUrls: Record): string { } export async function renderMarkup( - fedCtx: Context | null | undefined, + fedCtx: ApplicationContext | null | undefined, markup: string, options: RenderMarkupOptions = {}, ): Promise { @@ -419,11 +419,11 @@ function toToc(toc: InternalToc, docId?: string | null): Toc { export interface ExtractMentionsFromHtmlOptions { contextLoader?: DocumentLoader; documentLoader?: DocumentLoader; - kv?: Keyv; + kv?: KeyValueStore; } export async function extractMentionsFromHtml( - fedCtx: Context, + fedCtx: ApplicationContext, html: string, options: ExtractMentionsFromHtmlOptions = {}, ): Promise<{ actor: Actor }[]> { @@ -446,7 +446,7 @@ export async function extractMentionsFromHtml( if (href != null) mentionHrefs.add(href); }); if (mentionHrefs.size < 1) return []; - const { db } = fedCtx.data; + const { db } = fedCtx; const actors = await db.query.actorTable.findMany({ where: { OR: [ diff --git a/models/medium.ts b/models/medium.ts index c76a0a26..d8b17120 100644 --- a/models/medium.ts +++ b/models/medium.ts @@ -1,14 +1,14 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { type Context, getUserAgent } from "@fedify/fedify"; +import { getUserAgent } from "@fedify/fedify"; import * as vocab from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; import ffmpeg from "fluent-ffmpeg"; -import type { Disk } from "flydrive"; +import type { StorageService } from "./context.ts"; import sharp from "sharp"; import { isSSRFSafeURL } from "ssrfcheck"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database } from "./db.ts"; import metadata from "./deno.json" with { type: "json" }; import { @@ -175,7 +175,7 @@ async function readResponseBytes( export async function createMediumFromBytes( db: Database, - disk: Disk, + disk: StorageService, bytes: Uint8Array | ArrayBuffer, options: { maxSize?: number; @@ -253,7 +253,7 @@ export async function createMediumFromBytes( export async function createMediumFromBlob( db: Database, - disk: Disk, + disk: StorageService, blob: Blob, options: { maxSize?: number; preprocess?: MediumPreprocess } = {}, ): Promise { @@ -266,7 +266,7 @@ export async function createMediumFromBlob( export async function createMediumFromUrl( db: Database, - disk: Disk, + disk: StorageService, url: URL, options: { maxSize?: number; @@ -343,14 +343,14 @@ export async function createMediumForExistingKey( } export async function getMediumUrl( - disk: Disk, + disk: StorageService, medium: Pick, ): Promise { return await disk.getUrl(medium.key); } export async function persistPostMedium( - fedCtx: Context, + fedCtx: ApplicationContext, document: vocab.Document, postId: Uuid, index: number, @@ -438,7 +438,7 @@ export async function persistPostMedium( ); if (!screenshotCreated) return undefined; const screenshot = join(tmpDir, "screenshot.png"); - await fedCtx.data.disk.put( + await fedCtx.storage.put( thumbnailKey = `videos/${crypto.randomUUID()}.png`, await readFile(screenshot), ); @@ -457,7 +457,7 @@ export async function persistPostMedium( thumbnailKey, sensitive: document.sensitive ?? false, } satisfies NewPostMedium; - const result = await fedCtx.data.db.insert(postMediumTable) + const result = await fedCtx.db.insert(postMediumTable) .values(values) .onConflictDoUpdate({ target: [postMediumTable.postId, postMediumTable.index], diff --git a/models/moderation-action.test.ts b/models/moderation-action.test.ts index fbf1eb0f..dd433a15 100644 --- a/models/moderation-action.test.ts +++ b/models/moderation-action.test.ts @@ -1,8 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import type { RequestContext } from "@fedify/fedify"; import * as vocab from "@fedify/vocab"; -import type { ContextData } from "@hackerspub/models/context"; import type { Transaction } from "@hackerspub/models/db"; import { createFlag } from "@hackerspub/models/flag"; import { @@ -43,7 +41,7 @@ interface SentActivity { function recordingFedCtx( tx: Transaction, -): { fedCtx: RequestContext; sent: SentActivity[] } { +): { fedCtx: ReturnType; sent: SentActivity[] } { const fedCtx = createFedCtx(tx); const sent: SentActivity[] = []; // deno-lint-ignore no-explicit-any diff --git a/models/moderation-notification.test.ts b/models/moderation-notification.test.ts index 3a825213..51b44c8e 100644 --- a/models/moderation-notification.test.ts +++ b/models/moderation-notification.test.ts @@ -1,7 +1,5 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import type { RequestContext } from "@fedify/fedify"; -import type { ContextData } from "@hackerspub/models/context"; import type { Transaction } from "@hackerspub/models/db"; import { createFlag } from "@hackerspub/models/flag"; import { @@ -28,7 +26,7 @@ import { const HOUR = 60 * 60 * 1000; const REASON = "This post contains harassment targeting another user."; -function quietFedCtx(tx: Transaction): RequestContext { +function quietFedCtx(tx: Transaction): ReturnType { const fedCtx = createFedCtx(tx); // deno-lint-ignore no-explicit-any (fedCtx as any).sendActivity = () => Promise.resolve(); @@ -54,7 +52,7 @@ async function makeModerator(tx: Transaction, username = "moderator") { async function suspendedAccountWithAction( tx: Transaction, - fedCtx: RequestContext, + fedCtx: ReturnType, suspensionEnds: Date, ) { const moderator = await makeModerator(tx); diff --git a/models/moderation.test.ts b/models/moderation.test.ts index 66aa7566..fad0787c 100644 --- a/models/moderation.test.ts +++ b/models/moderation.test.ts @@ -1,7 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import type { Context } from "@fedify/fedify"; -import type { ContextData } from "@hackerspub/models/context"; +import type { ApplicationContext } from "@hackerspub/models/context"; import type { Transaction } from "@hackerspub/models/db"; import { follow } from "@hackerspub/models/following"; import { @@ -139,7 +138,7 @@ describe("write-path suspension guards", () => { }); await suspend(tx, author.actor.id); await assert.rejects( - createNote(fedCtx as unknown as Context>, { + createNote(fedCtx as unknown as ApplicationContext, { accountId: author.account.id, visibility: "public", content: "Hello", @@ -166,7 +165,7 @@ describe("write-path suspension guards", () => { }) .where(eq(actorTable.id, author.actor.id)); const note = await createNote( - fedCtx as unknown as Context>, + fedCtx as unknown as ApplicationContext, { accountId: author.account.id, visibility: "public", diff --git a/models/moderation.ts b/models/moderation.ts index 4a844288..e47935bf 100644 --- a/models/moderation.ts +++ b/models/moderation.ts @@ -1,9 +1,8 @@ -import type { Context } from "@fedify/fedify"; import * as vocab from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; import { and, eq, inArray, sql } from "drizzle-orm"; import { toRecipient } from "./actor.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database, Transaction } from "./db.ts"; import { createActionTakenNotification, @@ -557,10 +556,10 @@ export interface TakeModerationActionOptions { * instance without a `forwardSummary`) or the case is not open. */ export async function takeModerationAction( - fedCtx: Context, + fedCtx: ApplicationContext, options: TakeModerationActionOptions, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const provisions = options.violatedProvisions ?? []; if (!options.moderator.moderator) { logger.warn( diff --git a/models/note.lifecycle.test.ts b/models/note.lifecycle.test.ts index cd1ebe2a..5e61d809 100644 --- a/models/note.lifecycle.test.ts +++ b/models/note.lifecycle.test.ts @@ -1,9 +1,8 @@ import assert from "node:assert"; import test from "node:test"; -import type { Context } from "@fedify/fedify"; import { Create, Note as ActivityPubNote, QuoteRequest } from "@fedify/vocab"; import { eq } from "drizzle-orm"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Transaction } from "./db.ts"; import { createNote, QuotePolicyDeniedError, updateNote } from "./note.ts"; import { createQuestion } from "./question.ts"; @@ -33,7 +32,7 @@ test("createNote() creates a post and timeline entry for the author", async () = const published = new Date("2026-04-15T00:00:00.000Z"); const note = await createNote( - fedCtx as unknown as Context>, + fedCtx as unknown as ApplicationContext, { accountId: author.account.id, visibility: "public", @@ -87,7 +86,7 @@ test("createNote() applies post-created hooks before federation", async () => { sent.push(args); return Promise.resolve(undefined); }, - } as unknown as Context>; + } as unknown as ApplicationContext; const note = await createNote( fedCtx, @@ -304,7 +303,7 @@ test("updateNote() does not notify sharers when only quote policy changes", asyn email: "updatenotepolicysharer@example.com", }); const post = await createNote( - fedCtx as unknown as Context>, + fedCtx as unknown as ApplicationContext, { accountId: author.account.id, visibility: "public", @@ -352,7 +351,7 @@ test("createNote() allows the same medium at multiple indexes", async () => { }).returning(); const note = await createNote( - fedCtx as unknown as Context>, + fedCtx as unknown as ApplicationContext, { accountId: author.account.id, visibility: "public", @@ -387,7 +386,7 @@ test("createNote() fails when a requested medium cannot be attached", async () = }); const note = await createNote( - fedCtx as unknown as Context>, + fedCtx as unknown as ApplicationContext, { accountId: author.account.id, visibility: "public", @@ -415,7 +414,7 @@ test("createNote() stores tags relayed to tags.pub only for public posts", async const published = new Date("2026-04-15T00:00:00.000Z"); const publicNote = await createNote( - fedCtx as unknown as Context>, + fedCtx as unknown as ApplicationContext, { accountId: author.account.id, visibility: "public", @@ -427,7 +426,7 @@ test("createNote() stores tags relayed to tags.pub only for public posts", async }, ); const followersNote = await createNote( - fedCtx as unknown as Context>, + fedCtx as unknown as ApplicationContext, { accountId: author.account.id, visibility: "followers", @@ -476,7 +475,7 @@ test("createNote() enforces quote policy for legacy callers", async () => { await assert.rejects( () => createNote( - fedCtx as unknown as Context>, + fedCtx as unknown as ApplicationContext, { accountId: follower.account.id, visibility: "public", @@ -522,7 +521,7 @@ test("createNote() rejects direct quote targets for legacy callers", async () => await assert.rejects( () => createNote( - fedCtx as unknown as Context>, + fedCtx as unknown as ApplicationContext, { accountId: author.account.id, visibility: "public", @@ -579,7 +578,7 @@ test("createNote() federates the normalized quote target for shares", async () = sent.push(args); return Promise.resolve(undefined); }, - } as unknown as Context>; + } as unknown as ApplicationContext; const quote = await createNote(fedCtx, { accountId: quoter.account.id, @@ -633,7 +632,7 @@ test("createNote() keeps pending quote requests out of confirmed quote state", a sent.push(args); return Promise.resolve(undefined); }, - } as unknown as Context>; + } as unknown as ApplicationContext; const quote = await createNote(fedCtx, { accountId: quoter.account.id, @@ -679,7 +678,7 @@ test("updateNote() updates the persisted post for an existing note source", asyn email: "updatenoteauthor@example.com", }); const original = await createNote( - fedCtx as unknown as Context>, + fedCtx as unknown as ApplicationContext, { accountId: author.account.id, visibility: "public", @@ -724,7 +723,7 @@ test("updateNote() rejects existing Question sources before changing content", a }); const published = new Date("2026-04-15T00:00:00.000Z"); const question = await createQuestion( - fedCtx as unknown as Context>, + fedCtx as unknown as ApplicationContext, { accountId: author.account.id, visibility: "public", diff --git a/models/note.ts b/models/note.ts index 04c1fde9..db389d1c 100644 --- a/models/note.ts +++ b/models/note.ts @@ -1,10 +1,9 @@ -import type { Context } from "@fedify/fedify"; import type { Recipient } from "@fedify/vocab"; import * as vocab from "@fedify/vocab"; import { eq, sql } from "drizzle-orm"; -import type { Disk } from "flydrive"; +import type { StorageService } from "./context.ts"; import { syncActorFromAccount } from "./actor.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database, Transaction } from "./db.ts"; import { assertAccountActorNotSuspended } from "./moderation.ts"; import { @@ -299,7 +298,7 @@ export async function getNoteSource( export async function createNoteSourceMedium( db: Database, - disk: Disk, + disk: StorageService, sourceId: Uuid, index: number, input: { blob: Blob; alt: string } | { mediumId: Uuid; alt: string }, @@ -318,7 +317,7 @@ export async function createNoteSourceMedium( } export async function createNote( - fedCtx: Context>, + fedCtx: ApplicationContext, source: Omit & { id?: Uuid; media: ({ blob: Blob; alt: string } | { mediumId: Uuid; alt: string })[]; @@ -341,7 +340,7 @@ export async function createNote( media: PostMedium[]; } | undefined > { - const { db, disk } = fedCtx.data; + const { db, storage: disk } = fedCtx; const account = await db.query.accountTable.findFirst({ where: { id: source.accountId }, with: { avatarMedium: true, emails: true, links: true }, @@ -395,7 +394,7 @@ export async function createNote( } await addPostToTimeline(db, post); await options.afterPostCreated?.(post, db); - const noteObject = await fedCtx.data.services.federation.getNote( + const noteObject = await fedCtx.services.federation.getNote( fedCtx, { ...noteSource, media, account }, { @@ -420,7 +419,7 @@ export async function createNote( const quoteRequestTarget = post.quoteRequestTarget; if (post.quoteRequestRequired && quoteRequestTarget != null) { const requestId = new URL("#quote-request", noteObject.id ?? fedCtx.origin); - const instrument = await fedCtx.data.services.federation.getNote( + const instrument = await fedCtx.services.federation.getNote( fedCtx, { ...noteSource, media, account }, { @@ -499,7 +498,7 @@ export async function createNote( }, ); } - const relayedTags = await fedCtx.data.services.federation + const relayedTags = await fedCtx.services.federation .sendTagsPubRelayActivity( fedCtx, source.accountId, @@ -566,7 +565,7 @@ export async function updateNoteSource( } export async function updateNote( - fedCtx: Context, + fedCtx: ApplicationContext, noteSourceId: Uuid, source: Partial, ): Promise< @@ -583,7 +582,7 @@ export async function updateNote( media: PostMedium[]; } | undefined > { - const { db } = fedCtx.data; + const { db } = fedCtx; const previousPost = await db.query.postTable.findFirst({ where: { noteSourceId }, }); @@ -619,7 +618,7 @@ export async function updateNote( // but no Update(Note) is delivered to mentions, followers, or tag relays // while it remains censored. if (post.censored != null) return post; - const noteObject = await fedCtx.data.services.federation.getNote( + const noteObject = await fedCtx.services.federation.getNote( fedCtx, { ...noteSource, media, account }, { @@ -691,7 +690,7 @@ export async function updateNote( }, ); } - const relayedTags = await fedCtx.data.services.federation + const relayedTags = await fedCtx.services.federation .sendTagsPubRelayActivity( fedCtx, noteSource.accountId, diff --git a/models/notification.test.ts b/models/notification.test.ts index 11251de0..c31581e7 100644 --- a/models/notification.test.ts +++ b/models/notification.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert"; import test from "node:test"; import { sql } from "drizzle-orm"; -import { db } from "../graphql/db.ts"; +import { db } from "../test/database.ts"; import { createFollowNotification, createOrganizationInvitationNotification, diff --git a/models/organization.test.ts b/models/organization.test.ts index 01c2d82d..915dbe02 100644 --- a/models/organization.test.ts +++ b/models/organization.test.ts @@ -7,6 +7,7 @@ import { type MessageQueue, } from "@fedify/fedify"; import { Organization, Update } from "@fedify/vocab"; +import { toApplicationContext } from "@hackerspub/federation/context"; import { eq, sql } from "drizzle-orm"; import { registerPushNotificationTarget } from "./push.ts"; import type { ContextData } from "./context.ts"; @@ -1031,7 +1032,11 @@ test("acceptOrganizationConversion() sends Update(Organization) to followers", a }, } as typeof baseFedCtx; - await acceptOrganizationConversion(fedCtx, admin.account, request.id); + await acceptOrganizationConversion( + toApplicationContext(fedCtx), + admin.account, + request.id, + ); assert.equal(sent.length, 1); assert.equal(sent[0].recipient, "followers"); @@ -1112,7 +1117,11 @@ test("acceptOrganizationConversion() enqueues Update(Organization) through Fedif }, ); - await acceptOrganizationConversion(fedCtx, admin.account, request.id); + await acceptOrganizationConversion( + toApplicationContext(fedCtx), + admin.account, + request.id, + ); assert.equal(queued.length, 1); }); diff --git a/models/organization.ts b/models/organization.ts index d0559a10..de4585e9 100644 --- a/models/organization.ts +++ b/models/organization.ts @@ -1,8 +1,7 @@ -import type { Context, RequestContext } from "@fedify/fedify"; import { and, count, eq, gt, isNotNull, isNull, sql } from "drizzle-orm"; import { isUsernameReserved, sendAccountActorUpdate } from "./account.ts"; import { syncActorFromAccount } from "./actor.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import { type Database, runInTransaction, type Transaction } from "./db.ts"; import { createOrganizationConversionRequestNotification @@ -111,13 +110,13 @@ type AccountForSync = Account & { }; function asTransactionalFedCtx( - fedCtx: Context, + fedCtx: ApplicationContext, tx: Transaction, -): Context { - return fedCtx.clone({ - ...fedCtx.data, +): ApplicationContext { + return { + ...fedCtx.withDatabase(tx), db: tx, - }); + }; } async function lockOrganizationMembershipSet( @@ -318,7 +317,7 @@ export async function assertPersonalAccountDeletionPreservesOrganizations( } export async function createOrganization( - fedCtx: Context, + fedCtx: ApplicationContext, creator: Pick, input: CreateOrganizationInput, ): Promise< @@ -338,7 +337,7 @@ export async function createOrganization( "The organization username is invalid.", ); } - const db = fedCtx.data.db; + const db = fedCtx.db; if (await isUsernameReserved(db, username)) { throw new OrganizationMembershipError( "The organization username is already in use.", @@ -827,7 +826,7 @@ async function createOrganizationConversionRequestNotification( } export async function acceptOrganizationConversion( - fedCtx: RequestContext, + fedCtx: ApplicationContext, adminAccount: Pick, requestId: Uuid, ): Promise< @@ -841,7 +840,7 @@ export async function acceptOrganizationConversion( if (adminAccount.kind !== "personal") { throw new OrganizationConversionError("Only personal accounts can accept."); } - const db = fedCtx.data.db; + const db = fedCtx.db; const organization = await runInTransaction(db, async (tx) => { const request = await tx.query.organizationConversionRequestTable.findFirst( { diff --git a/models/package.json b/models/package.json index b74de11a..9b7683b7 100644 --- a/models/package.json +++ b/models/package.json @@ -9,6 +9,7 @@ "@fedify/markdown-it-hashtag": "catalog:", "@fedify/markdown-it-mention": "catalog:", "@fedify/vocab": "catalog:", + "@fedify/vocab-runtime": "catalog:", "@hackerspub/markdown-it-texmath": "catalog:", "@logtape/logtape": "catalog:", "@mdit-vue/plugin-title": "catalog:", @@ -23,7 +24,6 @@ "@std/html": "catalog:", "@std/text": "catalog:", "@std/uuid": "catalog:", - "ai": "catalog:", "apns2": "catalog:", "arcsecond": "catalog:", "cheerio": "catalog:", diff --git a/models/pin.ts b/models/pin.ts index 6f794eb3..6ae637ed 100644 --- a/models/pin.ts +++ b/models/pin.ts @@ -1,7 +1,6 @@ -import type { Context } from "@fedify/fedify"; import * as vocab from "@fedify/vocab"; import { and, count, eq, inArray } from "drizzle-orm"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database, Transaction } from "./db.ts"; import { type Actor, @@ -24,11 +23,11 @@ export function canPinPost( } export async function pinPost( - fedCtx: Context, + fedCtx: ApplicationContext, actor: Actor, post: Post, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; if (!canPinPost(actor, post)) return null; const pinInTransaction = async (tx: Transaction) => { @@ -85,11 +84,11 @@ function isTransaction(db: Database): db is Transaction { } export async function unpinPost( - fedCtx: Context, + fedCtx: ApplicationContext, actor: Actor, post: Post, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const [pin] = await db .delete(pinTable) .where( @@ -104,7 +103,7 @@ export async function unpinPost( } async function sendPinActivity( - fedCtx: Context, + fedCtx: ApplicationContext, actor: Actor, post: Post, ): Promise { @@ -132,7 +131,7 @@ async function sendPinActivity( } async function sendUnpinActivity( - fedCtx: Context, + fedCtx: ApplicationContext, actor: Actor, post: Post, ): Promise { diff --git a/models/poll.ts b/models/poll.ts index 39bedc9b..cd8a8db3 100644 --- a/models/poll.ts +++ b/models/poll.ts @@ -1,4 +1,4 @@ -import type { Context, DocumentLoader } from "@fedify/fedify"; +import type { DocumentLoader } from "@fedify/fedify"; import { assertAccountActorNotSuspended } from "./moderation.ts"; import * as vocab from "@fedify/vocab"; import { and, eq, inArray, sql } from "drizzle-orm"; @@ -9,7 +9,7 @@ import { persistActor, toRecipient, } from "./actor.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import { toDate } from "./date.ts"; import type { Database, Transaction } from "./db.ts"; import { createPollEndedNotification } from "./notification.ts"; @@ -252,7 +252,7 @@ export interface PersistPollVoteResult { } export async function persistPollVote( - ctx: Context, + ctx: ApplicationContext, note: vocab.Note, options: { contextLoader?: DocumentLoader; @@ -263,7 +263,7 @@ export async function persistPollVote( } export async function persistPollVoteResult( - ctx: Context, + ctx: ApplicationContext, note: vocab.Note, options: { contextLoader?: DocumentLoader; @@ -279,7 +279,7 @@ export async function persistPollVoteResult( const voteName = note.name.toString(); const hasReplyContent = note.content != null && note.content.toString().trim() !== ""; - const { db } = ctx.data; + const { db } = ctx; // Check the cached voter first, before any remote dereference or // Question persistence a federation-blocked actor could trigger. if (await isCachedActorFederationBlocked(db, note.attributionId)) { @@ -541,12 +541,12 @@ async function notifyClaimedEndedPolls( } export async function vote( - fedCtx: Context, + fedCtx: ApplicationContext, voter: Account & { actor: Actor }, poll: Poll & { options: PollOption[] }, optionIndices: Set, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; await assertAccountActorNotSuspended(db, voter.id); const voteInTransaction = async (tx: Transaction) => { if ( diff --git a/models/post.remote.test.ts b/models/post.remote.test.ts index 9717f009..c7650ab1 100644 --- a/models/post.remote.test.ts +++ b/models/post.remote.test.ts @@ -44,7 +44,7 @@ import { insertRemotePost, withRollback, } from "../test/postgres.ts"; -import { db } from "../graphql/db.ts"; +import { db } from "../test/database.ts"; async function waitFor( read: () => Promise, @@ -464,14 +464,6 @@ test("persistPost() starts emojiReactions backfill after transaction commit", as const fedCtx = createFedCtx( db as unknown as Parameters[0], ); - Object.assign(fedCtx, { - federation: { - createContext(request: Request, data: typeof fedCtx.data) { - return { ...fedCtx, request, data }; - }, - } as typeof fedCtx.federation, - request: new Request("http://localhost/"), - }); let transactionOpen = true; let backfillStartedDuringTransaction = false; @@ -480,12 +472,12 @@ test("persistPost() starts emojiReactions backfill after transaction commit", as ); await withTransaction(fedCtx, async (context) => { - const remoteActor = await insertRemoteActor(context.data.db, { + const remoteActor = await insertRemoteActor(context.db, { username: `emojiafterauthor${suffix}`, name: "Emoji After Commit Author", host: remoteHost, }); - const reactor = await insertRemoteActor(context.data.db, { + const reactor = await insertRemoteActor(context.db, { username: `emojiafterreactor${suffix}`, name: "Emoji After Commit Reactor", host: remoteHost, @@ -518,7 +510,7 @@ test("persistPost() starts emojiReactions backfill after transaction commit", as return reactionCollection; }; - context.data.afterCommit?.push(() => { + context.afterCommit?.push(() => { transactionOpen = false; }); const persisted = await persistPost(context, post); diff --git a/models/post.sync.test.ts b/models/post.sync.test.ts index 6eb409b1..d964e146 100644 --- a/models/post.sync.test.ts +++ b/models/post.sync.test.ts @@ -1,9 +1,6 @@ import assert from "node:assert"; import test from "node:test"; -import type { Context } from "@fedify/fedify"; import { eq } from "drizzle-orm"; -import type { ContextData } from "./context.ts"; -import type { Transaction } from "./db.ts"; import { accountTable, articleContentTable, @@ -96,9 +93,7 @@ test("syncPostFromArticleSource() upserts the post when source content changes", test("syncPostFromNoteSource() preserves existing Question type", async () => { await withRollback(async (tx) => { - const fedCtx = createFedCtx(tx) as unknown as Context< - ContextData - >; + const fedCtx = createFedCtx(tx); const author = await insertAccountWithActor(tx, { username: "syncquestionowner", name: "Sync Question Owner", diff --git a/models/post.ts b/models/post.ts index bf2a1bd8..320d3a4c 100644 --- a/models/post.ts +++ b/models/post.ts @@ -1,9 +1,5 @@ import { assertAccountActorNotSuspended } from "./moderation.ts"; -import { - type Context, - type DocumentLoader, - getUserAgent, -} from "@fedify/fedify"; +import { type DocumentLoader, getUserAgent } from "@fedify/fedify"; import { isActor, LanguageString, @@ -44,7 +40,7 @@ import { getArticleSourceMediumUrls, getOriginalArticleContent, } from "./article.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import { toDate } from "./date.ts"; import { type Database, @@ -105,7 +101,7 @@ const logger = getLogger(["hackerspub", "models", "post"]); const DEFAULT_MAX_PERSIST_POST_DEPTH = 3; const DEFAULT_MAX_INLINE_REPLIES = 50; const DEFAULT_INLINE_REPLIES_THRESHOLD = 10; -const REPLIES_BACKFILL_LOCK_TTL_SECONDS = 300; +const REPLIES_BACKFILL_LOCK_TTL_MS = 300_000; const REPLIES_BACKFILL_RETRY_DELAY_MS = 30_000; const EMOJI_REACTIONS_BACKFILL_LOCK_TTL_MS = 300_000; const EMOJI_REACTIONS_BACKFILL_RETRY_DELAY_MS = 30_000; @@ -180,7 +176,7 @@ export function withDocumentLoaderTimeout( } async function backfillEmojiReactions( - ctx: Context, + ctx: ApplicationContext, post: PostObject, persistedPost: Pick, options: { @@ -215,13 +211,13 @@ async function backfillEmojiReactions( } } finally { if (shouldUpdateCounts) { - await updateReactionsCounts(ctx.data.db, persistedPost.id); + await updateReactionsCounts(ctx.db, persistedPost.id); } } } async function enqueueEmojiReactionsBackfill( - ctx: Context, + ctx: ApplicationContext, post: PostObject, persistedPost: Pick, options: { @@ -230,9 +226,9 @@ async function enqueueEmojiReactionsBackfill( }, ): Promise { const lockKey = `emoji-reactions-backfill/${persistedPost.iri}`; - const [locked] = await ctx.data.kv.getMany([lockKey]); + const [locked] = await ctx.kv.getMany([lockKey]); if (locked === "1") return; - await ctx.data.kv.set( + await ctx.kv.set( lockKey, "1", EMOJI_REACTIONS_BACKFILL_LOCK_TTL_MS, @@ -336,7 +332,7 @@ function truncatePlainText(value: string): string { } async function persistArticleNewsLink( - fedCtx: Context, + fedCtx: ApplicationContext, article: { readonly url: string | null | undefined; readonly iri: string; @@ -367,7 +363,7 @@ async function persistArticleNewsLink( author, creatorId: actor.id, }; - const rows = await fedCtx.data.db + const rows = await fedCtx.db .insert(postLinkTable) .values(values) .onConflictDoUpdate({ @@ -388,7 +384,7 @@ async function persistArticleNewsLink( } async function resolveMentionedActors( - ctx: Context, + ctx: ApplicationContext, mentionHrefs: ReadonlySet, options: { contextLoader?: DocumentLoader; @@ -396,7 +392,7 @@ async function resolveMentionedActors( }, ): Promise { if (mentionHrefs.size < 1) return []; - const { db } = ctx.data; + const { db } = ctx; const unresolvedHrefs = new Set(mentionHrefs); const actorsById = new Map(); const actorRows = await db.query.actorTable.findMany({ @@ -719,7 +715,7 @@ async function readResponseBytesAtMost( } export async function syncPostFromArticleSource( - fedCtx: Context, + fedCtx: ApplicationContext, articleSource: ArticleSource & { account: Account & { avatarMedium: Medium | null; @@ -749,7 +745,7 @@ export async function syncPostFromArticleSource( mentions: Mention[]; } > { - const { db, kv, disk } = fedCtx.data; + const { db, kv, storage: disk } = fedCtx; const actor = await syncActorFromAccount(fedCtx, articleSource.account); const content = getOriginalArticleContent(articleSource); if (content == null) { @@ -826,7 +822,7 @@ export async function syncPostFromArticleSource( } export async function syncPostFromNoteSource( - fedCtx: Context, + fedCtx: ApplicationContext, noteSource: NoteSource & { account: Account & { avatarMedium: Medium | null; @@ -871,7 +867,7 @@ export async function syncPostFromNoteSource( } | undefined > { - const { db, kv, disk } = fedCtx.data; + const { db, kv, storage: disk } = fedCtx; const existingPost = await db.query.postTable.findFirst({ columns: { id: true, @@ -1102,7 +1098,7 @@ export async function syncPostFromNoteSource( } export async function persistPost( - ctx: Context, + ctx: ApplicationContext, post: PostObject, options: { actor?: Actor & { instance: Instance }; @@ -1142,7 +1138,7 @@ export async function persistPost( ); return; } - const { db } = ctx.data; + const { db } = ctx; const depth = options.depth ?? 0; const maxDepth = options.maxDepth ?? DEFAULT_MAX_PERSIST_POST_DEPTH; const maxReplies = options.maxReplies ?? DEFAULT_MAX_INLINE_REPLIES; @@ -1647,82 +1643,94 @@ export async function persistPost( } } else if (deferLargeReplies) { const lockKey = `reply-backfill/${persistedPost.iri}`; - const [locked] = await ctx.data.kv.getMany([lockKey]); + const [locked] = await ctx.kv.getMany([lockKey]); if (locked !== "1") { // Best-effort dedupe lock: avoid spawning multiple backfills for // the same post during bursty inbox traffic. - await ctx.data.kv.set(lockKey, "1", REPLIES_BACKFILL_LOCK_TTL_SECONDS); - void (async () => { - // This runs in the background after the handler returns, so it must - // NOT inherit the handler's overall deadline (`opts` is bound to - // `overallSignal`). Give it a loader with only the per-fetch timeout - // so a long backfill is not truncated at the synchronous handler's - // budget; each backfilled reply still gets its own fresh overall - // budget via the `persistPost` call below (which omits `signal`). - const backfillOpts = { - contextLoader: options.contextLoader, - documentLoader: withDocumentLoaderTimeout( - options.documentLoader ?? ctx.documentLoader, - ), - suppressError: true, + await ctx.kv.set(lockKey, "1", REPLIES_BACKFILL_LOCK_TTL_MS); + await queueAfterCommit(ctx, () => { + const backgroundDb = ctx.rootDb ?? ctx.db; + const backgroundCtx: ApplicationContext = { + ...ctx.withDatabase(backgroundDb), + db: backgroundDb, + rootDb: backgroundDb, + afterCommit: undefined, }; - const persistReply = async ( - attempt: number, - ): Promise => { - try { - let count = 0; - for await ( - const reply of traverseCollection(replies, backfillOpts) - ) { - if (count >= maxReplies) break; - if (!isPostObject(reply)) continue; - await persistPost(ctx, reply, { - ...options, - actor, - replyTarget: persistedPost, - replies: false, - depth: depth + 1, - // Don't inherit a caller's top-level deadline (`...options` - // may carry one); each backfilled reply mints its own fresh - // budget so the background batch isn't cut off by the - // originating handler's deadline. - signal: undefined, - }); - count++; - } - if (persistedPost.repliesCount < count) { - await db.update(postTable) - .set({ - repliesCount: - sql`GREATEST(${postTable.repliesCount}, ${count})`, - }) - .where(eq(postTable.id, persistedPost.id)); - persistedPost.repliesCount = Math.max( - persistedPost.repliesCount, - count, - ); - } - } catch (error) { - if (attempt < 1) { - // Single delayed retry to absorb transient federation failures - // without introducing a durable queue. - await new Promise((resolve) => - setTimeout(resolve, REPLIES_BACKFILL_RETRY_DELAY_MS) + void (async () => { + // This runs in the background after the handler returns, so it must + // NOT inherit the handler's overall deadline (`opts` is bound to + // `overallSignal`). Give it a loader with only the per-fetch timeout + // so a long backfill is not truncated at the synchronous handler's + // budget; each backfilled reply still gets its own fresh overall + // budget via the `persistPost` call below (which omits `signal`). + const backfillOpts = { + contextLoader: options.contextLoader, + documentLoader: withDocumentLoaderTimeout( + options.documentLoader ?? backgroundCtx.documentLoader, + ), + suppressError: true, + }; + const persistReply = async ( + attempt: number, + ): Promise => { + try { + let count = 0; + for await ( + const reply of traverseCollection(replies, backfillOpts) + ) { + if (count >= maxReplies) break; + if (!isPostObject(reply)) continue; + await persistPost(backgroundCtx, reply, { + ...options, + actor, + replyTarget: persistedPost, + replies: false, + depth: depth + 1, + // Don't inherit a caller's top-level deadline (`...options` + // may carry one); each backfilled reply mints its own fresh + // budget so the background batch isn't cut off by the + // originating handler's deadline. + signal: undefined, + }); + count++; + } + if (persistedPost.repliesCount < count) { + await backgroundDb.update(postTable) + .set({ + repliesCount: + sql`GREATEST(${postTable.repliesCount}, ${count})`, + }) + .where(eq(postTable.id, persistedPost.id)); + persistedPost.repliesCount = Math.max( + persistedPost.repliesCount, + count, + ); + } + } catch (error) { + if (attempt < 1) { + // Single delayed retry to absorb transient federation failures + // without introducing a durable queue. + await new Promise((resolve) => + setTimeout(resolve, REPLIES_BACKFILL_RETRY_DELAY_MS) + ); + await persistReply(attempt + 1); + return; + } + logger.warn( + "Failed to backfill replies for {postIri} after retry: {error}", + { postIri: persistedPost.iri, error }, ); - await persistReply(attempt + 1); - return; } - logger.warn( - "Failed to backfill replies for {postIri} after retry: {error}", - { postIri: persistedPost.iri, error }, - ); - } - }; - await persistReply(0); - })().catch((error) => { - logger.warn("Replies backfill task failed for {postIri}: {error}", { - postIri: persistedPost.iri, - error, + }; + await persistReply(0); + })().catch((error) => { + logger.warn( + "Replies backfill task failed for {postIri}: {error}", + { + postIri: persistedPost.iri, + error, + }, + ); }); }); } @@ -1734,9 +1742,14 @@ export async function persistPost( } if (depth === 0) { await queueAfterCommit(ctx, () => { - const db = ctx.data.rootDb ?? ctx.data.db; + const db = ctx.rootDb ?? ctx.db; return enqueueEmojiReactionsBackfill( - ctx.clone({ ...ctx.data, db, rootDb: db, afterCommit: undefined }), + { + ...ctx.withDatabase(db), + db, + rootDb: db, + afterCommit: undefined, + }, post, persistedPost, { @@ -1761,7 +1774,7 @@ export async function persistPost( } export async function persistSharedPost( - ctx: Context, + ctx: ApplicationContext, announce: vocab.Announce, options: { actor?: Actor & { instance: Instance }; @@ -1790,7 +1803,7 @@ export async function persistSharedPost( return; } const announceId = announce.id.href; - const { db } = ctx.data; + const { db } = ctx; // One deadline for this entire operation. Reuse the caller's signal when // available so pre-persistPost fetches (getActor, getObject) and the // persistPost subtree are all capped by the same wall-clock budget. @@ -2049,7 +2062,7 @@ async function getOriginalQuoteTarget( } export async function sharePost( - fedCtx: Context, + fedCtx: ApplicationContext, account: Account & { avatarMedium: Medium | null; emails: AccountEmail[]; @@ -2058,7 +2071,7 @@ export async function sharePost( post: Post & { actor: Actor }, visibility?: PostVisibility, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; await assertAccountActorNotSuspended(db, account.id); const sharedPost = await getOriginalSharedPost(db, post); // Callers reject censored targets with a proper response; this is a @@ -2115,7 +2128,7 @@ export async function sharePost( notification, }); } - const announce = fedCtx.data.services.federation.getAnnounce(fedCtx, { + const announce = fedCtx.services.federation.getAnnounce(fedCtx, { ...share, sharedPost, actor: { ...actor, account }, @@ -2144,7 +2157,7 @@ export async function sharePost( } export async function unsharePost( - fedCtx: Context, + fedCtx: ApplicationContext, account: Account & { avatarMedium: Medium | null; emails: AccountEmail[]; @@ -2152,7 +2165,7 @@ export async function unsharePost( }, sharedPost: Post & { actor: Actor }, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const originalPost = await getOriginalSharedPost(db, sharedPost); if (originalPost.sharedPostId != null) return; const actor = await syncActorFromAccount(fedCtx, account); @@ -2176,7 +2189,7 @@ export async function unsharePost( actor, ); } - const announce = fedCtx.data.services.federation.getAnnounce(fedCtx, { + const announce = fedCtx.services.federation.getAnnounce(fedCtx, { ...unshared[0], actor: { ...actor, account }, sharedPost: originalPost, @@ -2995,12 +3008,12 @@ export async function updateQuotesCount( } export async function revokeQuote( - fedCtx: Context, + fedCtx: ApplicationContext, account: Account, quotePost: Post & { actor: Actor }, quotedPost: Post, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const revoked = new Date(); let updatedQuote: QuoteUpdatePost | undefined; const rows = await db.update(postTable) @@ -3074,7 +3087,7 @@ export async function revokeQuote( } async function sendLocalQuoteAuthorizationDelete( - fedCtx: Context, + fedCtx: ApplicationContext, account: Account, quote: QuoteUpdatePost, quoteAuthorizationIri: string, @@ -3107,7 +3120,7 @@ async function sendLocalQuoteAuthorizationDelete( ) { return; } - const followers = await fedCtx.data.db.query.followingTable.findMany({ + const followers = await fedCtx.db.query.followingTable.findMany({ with: { follower: true }, where: { followeeId: quote.actorId, @@ -3128,13 +3141,13 @@ async function sendLocalQuoteAuthorizationDelete( } async function sendLocalQuoteUpdate( - fedCtx: Context, + fedCtx: ApplicationContext, quote: QuoteUpdatePost, quoteAuthorizationIri: string | null, updated: Date, ): Promise { if (quote.actor.accountId == null || quote.noteSourceId == null) return; - const noteSource = await fedCtx.data.db.query.noteSourceTable.findFirst({ + const noteSource = await fedCtx.db.query.noteSourceTable.findFirst({ where: { id: quote.noteSourceId }, with: { account: true, @@ -3142,7 +3155,7 @@ async function sendLocalQuoteUpdate( }, }); if (noteSource == null) return; - const noteObject = await fedCtx.data.services.federation.getNote( + const noteObject = await fedCtx.services.federation.getNote( fedCtx, noteSource, { @@ -3195,7 +3208,7 @@ async function sendLocalQuoteUpdate( }, ); } - const relayedTags = await fedCtx.data.services.federation + const relayedTags = await fedCtx.services.federation .sendTagsPubRelayActivity( fedCtx, quote.actor.accountId, @@ -3208,7 +3221,7 @@ async function sendLocalQuoteUpdate( }, ); if (relayedTags != null) { - await fedCtx.data.db.update(postTable) + await fedCtx.db.update(postTable) .set({ relayedTags: [...relayedTags] }) .where(eq(postTable.id, quote.id)); quote.relayedTags = [...relayedTags]; @@ -3216,10 +3229,10 @@ async function sendLocalQuoteUpdate( } export async function deletePost( - fedCtx: Context, + fedCtx: ApplicationContext, post: Post & { actor: Actor; replyTarget: Post | null }, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const replies = await db.query.postTable.findMany({ with: { actor: true }, where: { @@ -3369,7 +3382,7 @@ export async function deletePost( excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], }, ); - await fedCtx.data.services.federation.sendTagsPubRelayActivity( + await fedCtx.services.federation.sendTagsPubRelayActivity( fedCtx, post.actor.accountId, activity, @@ -3392,8 +3405,8 @@ export async function deletePost( ); } -export async function scrapePostLink( - fedCtx: Context, +export async function scrapePostLink( + fedCtx: Pick, url: string | URL, handleToActorId: (handle: string) => Promise, options: { signal?: AbortSignal } = {}, @@ -3694,7 +3707,7 @@ export async function scrapePostLink( const POST_LINK_CACHE_TTL = Temporal.Duration.from({ hours: 24 }); export async function persistPostLink( - ctx: Context, + ctx: ApplicationContext, url: string | URL, options: { signal?: AbortSignal } = {}, ): Promise { @@ -3705,7 +3718,7 @@ export async function persistPostLink( } const scrapeUrl = new URL(url); scrapeUrl.hash = ""; - const { db } = ctx.data; + const { db } = ctx; const link = await db.query.postLinkTable.findFirst({ where: { url: scrapeUrl.href }, }); diff --git a/models/question.lifecycle.test.ts b/models/question.lifecycle.test.ts index df42fc05..d4dc3f51 100644 --- a/models/question.lifecycle.test.ts +++ b/models/question.lifecycle.test.ts @@ -1,8 +1,7 @@ import assert from "node:assert"; import test from "node:test"; -import type { Context } from "@fedify/fedify"; import { Create, Person, Question as ActivityPubQuestion } from "@fedify/vocab"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Transaction } from "./db.ts"; import { createQuestion } from "./question.ts"; import { organizationPostAuthorTable } from "./schema.ts"; @@ -28,7 +27,7 @@ test("createQuestion() creates a source-backed Question with a poll", async () = sent.push(args); return Promise.resolve(undefined); }, - } as unknown as Context>; + } as unknown as ApplicationContext; const published = new Date("2026-04-15T00:00:00.000Z"); const question = await createQuestion(fedCtx, { @@ -125,7 +124,7 @@ test("createQuestion() applies post-created hooks before federation", async () = sent.push(args); return Promise.resolve(undefined); }, - } as unknown as Context>; + } as unknown as ApplicationContext; const now = new Date("2026-04-15T00:00:00.000Z"); const question = await createQuestion( @@ -195,7 +194,7 @@ test("createQuestion() does not federate none visibility polls to followers", as sent.push(args); return Promise.resolve(undefined); }, - } as unknown as Context>; + } as unknown as ApplicationContext; const now = new Date("2026-04-15T00:00:00.000Z"); const question = await createQuestion(fedCtx, { @@ -231,9 +230,7 @@ test("createQuestion() throws when a requested medium cannot be attached", async name: "Missing Question Media", email: "missingquestionmedia@example.com", }); - const fedCtx = createFedCtx(tx) as unknown as Context< - ContextData - >; + const fedCtx = createFedCtx(tx); const now = new Date("2026-04-15T00:00:00.000Z"); await assert.rejects( @@ -283,9 +280,7 @@ test("createQuestion() creates reply notifications without duplicate mention not account: targetAuthor.account, content: "Original note", }); - const fedCtx = createFedCtx(tx) as unknown as Context< - ContextData - >; + const fedCtx = createFedCtx(tx); fedCtx.lookupObject = (handle: string) => { if (handle !== "@questionreplytarget@localhost") { return Promise.resolve(null); @@ -345,9 +340,7 @@ test("createQuestion() rolls back the poll when source-backed Question creation name: "Invalid Question Author", email: "invalidquestionauthor@example.com", }); - const fedCtx = createFedCtx(tx) as unknown as Context< - ContextData - >; + const fedCtx = createFedCtx(tx); const now = new Date("2026-04-15T00:00:00.000Z"); await assert.rejects( diff --git a/models/question.ts b/models/question.ts index c18e75fc..e30374be 100644 --- a/models/question.ts +++ b/models/question.ts @@ -1,4 +1,3 @@ -import type { Context } from "@fedify/fedify"; import type { Recipient } from "@fedify/vocab"; import * as vocab from "@fedify/vocab"; import { eq, sql } from "drizzle-orm"; @@ -37,7 +36,7 @@ import { quoteRequestTable, } from "./schema.ts"; import { syncActorFromAccount } from "./actor.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database, Transaction } from "./db.ts"; import { addPostToTimeline } from "./timeline.ts"; import { generateUuidV7, type Uuid } from "./uuid.ts"; @@ -54,7 +53,7 @@ interface CreatePostOptions { } export async function createQuestion( - fedCtx: Context>, + fedCtx: ApplicationContext, source: Omit & { id?: Uuid; media: ({ blob: Blob; alt: string } | { mediumId: Uuid; alt: string })[]; @@ -81,7 +80,7 @@ export async function createQuestion( } | undefined > { const normalizedPoll = normalizePollInput(source.poll); - const { db, disk } = fedCtx.data; + const { db, storage: disk } = fedCtx; const account = await db.query.accountTable.findFirst({ where: { id: source.accountId }, with: { avatarMedium: true, emails: true, links: true }, @@ -99,9 +98,10 @@ export async function createQuestion( } const result = await db.transaction(async (tx) => { - const txCtx = Object.create(fedCtx, { - data: { value: { ...fedCtx.data, db: tx } }, - }) as Context>; + const txCtx: ApplicationContext = { + ...fedCtx.withDatabase(tx), + db: tx, + }; const noteSource = await createNoteSource(tx, source); if (noteSource == null) return undefined; @@ -150,7 +150,7 @@ export async function createQuestion( if (result == null) return undefined; const { noteSource, media, post } = result; - const questionObject = await fedCtx.data.services.federation.getQuestion( + const questionObject = await fedCtx.services.federation.getQuestion( fedCtx, { ...noteSource, media, account }, { ...post.poll, post, options: post.poll.options }, @@ -179,7 +179,7 @@ export async function createQuestion( "#quote-request", questionObject.id ?? fedCtx.origin, ); - const instrument = await fedCtx.data.services.federation.getQuestion( + const instrument = await fedCtx.services.federation.getQuestion( fedCtx, { ...noteSource, media, account }, { ...post.poll, post, options: post.poll.options }, @@ -259,7 +259,7 @@ export async function createQuestion( }, ); } - const relayedTags = await fedCtx.data.services.federation + const relayedTags = await fedCtx.services.federation .sendTagsPubRelayActivity( fedCtx, source.accountId, diff --git a/models/reaction.ts b/models/reaction.ts index 54e17777..cb4aa189 100644 --- a/models/reaction.ts +++ b/models/reaction.ts @@ -1,4 +1,4 @@ -import type { Context, DocumentLoader } from "@fedify/fedify"; +import type { DocumentLoader } from "@fedify/fedify"; import { isActor } from "@fedify/vocab"; import * as vocab from "@fedify/vocab"; import { and, eq, inArray, sql } from "drizzle-orm"; @@ -7,7 +7,7 @@ import { isFederationBlocked, persistActor, } from "./actor.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database } from "./db.ts"; import { DEFAULT_REACTION_EMOJI, type ReactionEmoji } from "./emoji.ts"; import { assertAccountActorNotSuspended } from "./moderation.ts"; @@ -70,7 +70,7 @@ export async function persistCustomEmoji( } export async function persistReaction( - ctx: Context, + ctx: ApplicationContext, reaction: vocab.Like | vocab.EmojiReact, options: { contextLoader?: DocumentLoader; @@ -82,7 +82,7 @@ export async function persistReaction( ) { return undefined; } - const { db } = ctx.data; + const { db } = ctx; let actor = await getPersistedActor(db, reaction.actorId); const opts = { ...options, suppressError: true }; if (actor == null) { @@ -211,17 +211,17 @@ export async function deleteReaction( } export async function react( - ctx: Context, + ctx: ApplicationContext, account: Account & { actor: Actor }, post: Post & { actor: Actor }, emoji: ReactionEmoji | null, customEmojiId?: Uuid, ): Promise { - const { db } = ctx.data; + const { db } = ctx; await assertAccountActorNotSuspended(db, account.id); let iri: string; if (emoji != null) { - iri = ctx.data.services.federation.getEmojiReactId( + iri = ctx.services.federation.getEmojiReactId( ctx, account.id, post.id, @@ -262,7 +262,7 @@ export async function react( ); } if (emoji == null) return rows[0]; - const activity = ctx.data.services.federation.getEmojiReact(ctx, { + const activity = ctx.services.federation.getEmojiReact(ctx, { ...rows[0], actor: account.actor, post, @@ -299,13 +299,13 @@ export async function react( } export async function undoReaction( - ctx: Context, + ctx: ApplicationContext, account: Account & { actor: Actor }, post: Post & { actor: Actor }, emoji: ReactionEmoji | null, customEmojiId?: Uuid, ): Promise { - const { db } = ctx.data; + const { db } = ctx; const whereClause = emoji != null ? and( eq(reactionTable.postId, post.id), @@ -342,7 +342,7 @@ export async function undoReaction( ); } if (emoji == null) return rows[0]; - const activity = ctx.data.services.federation.getEmojiReact(ctx, { + const activity = ctx.services.federation.getEmojiReact(ctx, { ...rows[0], actor: account.actor, post, diff --git a/models/relay.test.ts b/models/relay.test.ts index 0733ef6f..c0fd780b 100644 --- a/models/relay.test.ts +++ b/models/relay.test.ts @@ -1,14 +1,12 @@ import assert from "node:assert"; import test from "node:test"; import { Follow, Undo } from "@fedify/vocab"; -import type { RequestContext } from "@fedify/fedify"; import { eq } from "drizzle-orm"; import { createFedCtx, insertRemoteActor, withRollback, } from "../test/postgres.ts"; -import type { ContextData } from "./context.ts"; import { relaySubscriptionTable } from "./schema.ts"; import { getRelayFollowIri, @@ -28,7 +26,7 @@ interface SentActivity { function withCapturingFedCtx( tx: Parameters[0], ): { - fedCtx: RequestContext; + fedCtx: ReturnType; sent: SentActivity[]; } { const fedCtx = createFedCtx(tx); diff --git a/models/relay.ts b/models/relay.ts index b6fb7185..603536a9 100644 --- a/models/relay.ts +++ b/models/relay.ts @@ -1,8 +1,7 @@ -import type { Context } from "@fedify/fedify"; import { Follow, Undo } from "@fedify/vocab"; import { and, eq, isNull, sql } from "drizzle-orm"; import { toRecipient } from "./actor.ts"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database } from "./db.ts"; import { type Actor, @@ -26,7 +25,7 @@ export type RelaySubscriptionWithActor = RelaySubscription & { * origin's hostname (e.g. `hackers.pub`). The instance actor has no `Account` * or `actorTable` row; it is dispatched purely from this identifier. */ -function getInstanceActorIdentifier(fedCtx: Context): string { +function getInstanceActorIdentifier(fedCtx: ApplicationContext): string { return new URL(fedCtx.canonicalOrigin).hostname; } @@ -36,7 +35,7 @@ function getInstanceActorIdentifier(fedCtx: Context): string { * `Accept`/`Reject`, letting us reconcile the response to the exact row. */ export function getRelayFollowIri( - fedCtx: Context, + fedCtx: ApplicationContext, subscriptionId: Uuid, ): URL { const identifier = getInstanceActorIdentifier(fedCtx); @@ -77,10 +76,10 @@ export function getRelaySubscription( * `undefined`. */ export async function subscribeRelay( - fedCtx: Context, + fedCtx: ApplicationContext, relayActor: Actor, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const id = generateUuidV7(); const followIri = getRelayFollowIri(fedCtx, id); const rows = await db.insert(relaySubscriptionTable).values({ @@ -132,10 +131,10 @@ export async function subscribeRelay( * was already gone. */ export async function unsubscribeRelay( - fedCtx: Context, + fedCtx: ApplicationContext, subscription: RelaySubscription & { actor: Actor }, ): Promise { - const { db } = fedCtx.data; + const { db } = fedCtx; const identifier = getInstanceActorIdentifier(fedCtx); const followIri = new URL(subscription.followIri); await fedCtx.sendActivity( diff --git a/models/services.ts b/models/services.ts index 2bc2227d..1c2cd7a2 100644 --- a/models/services.ts +++ b/models/services.ts @@ -1,5 +1,5 @@ import type * as vocab from "@fedify/vocab"; -import type { LanguageModel } from "ai"; +import type { ApplicationModel } from "./context.ts"; import type { CocProvision } from "./coc.ts"; import type { ReactionEmoji } from "./emoji.ts"; import type { @@ -22,15 +22,15 @@ import type { import type { Uuid } from "./uuid.ts"; export interface SummaryOptions { - model: LanguageModel; + model: ApplicationModel; sourceLanguage: string; targetLanguage: string; text: string; } export interface TranslationOptions { - model: LanguageModel; - summarizationModel?: LanguageModel; + model: ApplicationModel; + summarizationModel?: ApplicationModel; sourceLanguage: string; targetLanguage: string; text: string; @@ -51,7 +51,7 @@ export interface ModerationAnalysis { } export interface ModerationAnalysisOptions { - model: LanguageModel; + model: ApplicationModel; provisions: readonly CocProvision[]; reason: string; contentHtml: string; @@ -74,6 +74,14 @@ export interface SendTagsPubRelayOptions { } export interface FederationServices { + readonly subscribeTagsPubHashtag: ( + context: TContext, + tag: string, + ) => Promise; + readonly unsubscribeTagsPubHashtag: ( + context: TContext, + tag: string, + ) => Promise; readonly getAnnounce: ( context: TContext, share: Post & { diff --git a/models/tx.test.ts b/models/tx.test.ts index 8d8ee421..db554b9e 100644 --- a/models/tx.test.ts +++ b/models/tx.test.ts @@ -1,7 +1,6 @@ import assert from "node:assert"; import test from "node:test"; -import type { RequestContext } from "@fedify/fedify"; -import type { ContextData } from "./context.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database } from "./db.ts"; import { queueAfterCommit, withTransaction } from "./tx.ts"; @@ -13,27 +12,43 @@ function createFakeDb(): Database { return db; } -function createContext(db: Database): RequestContext { - const request = new Request("http://localhost/"); - const federation = { - createContext( - nextRequest: Request, - data: ContextData, - ): RequestContext { - return { - data, - federation, - request: nextRequest, - } as RequestContext; - }, - } as RequestContext["federation"]; - return federation.createContext(request, { +function createContext( + db: Database, + capabilityDbs: Database[] = [], + services: ApplicationContext["services"] = {} as never, +): ApplicationContext { + const never = () => { + throw new Error("Unexpected federation capability call."); + }; + return { db, - disk: {} as never, + withDatabase: (nextDb) => createContext(nextDb, capabilityDbs, services), + storage: {} as never, kv: {} as never, models: {} as never, - services: {} as never, - }); + services, + federation: {}, + origin: "http://localhost/", + canonicalOrigin: "http://localhost/", + host: "localhost", + documentLoader: never, + contextLoader: never, + getActorUri: never, + getInboxUri: never, + getOutboxUri: never, + getFollowersUri: never, + getFollowingUri: never, + getFeaturedUri: never, + getObjectUri: never, + getDocumentLoader: never, + lookupObject: async () => { + capabilityDbs.push(db); + return null; + }, + lookupWebFinger: never, + getActor: never, + sendActivity: never, + }; } test("withTransaction() discards after-commit tasks from rolled back nested transactions", async () => { @@ -65,3 +80,17 @@ test("withTransaction() discards after-commit tasks from rolled back nested tran assert.deepEqual(completedTasks, ["outer"]); }); + +test("withTransaction() rebinds adapter capabilities to the transaction", async () => { + const rootDb = createFakeDb(); + const capabilityDbs: Database[] = []; + const context = createContext(rootDb, capabilityDbs); + + await withTransaction(context, async (transactionContext) => { + assert.notEqual(transactionContext.db, rootDb); + assert.equal(transactionContext.rootDb, rootDb); + assert.equal(transactionContext.services, context.services); + await transactionContext.lookupObject("https://example.com/object"); + assert.deepEqual(capabilityDbs, [transactionContext.db]); + }); +}); diff --git a/models/tx.ts b/models/tx.ts index 6f6c9b49..d08fb8d5 100644 --- a/models/tx.ts +++ b/models/tx.ts @@ -1,12 +1,11 @@ -import type { RequestContext } from "@fedify/fedify"; -import type { AfterCommitTask, ContextData } from "./context.ts"; +import type { AfterCommitTask, ApplicationContext } from "./context.ts"; import type { Database, Transaction } from "./db.ts"; export async function queueAfterCommit( - context: { data: ContextData }, + context: Pick, "afterCommit">, task: AfterCommitTask, ): Promise { - const afterCommit = context.data.afterCommit; + const afterCommit = context.afterCommit; if (afterCommit != null) { afterCommit.push(task); return; @@ -15,19 +14,19 @@ export async function queueAfterCommit( } export async function withTransaction( - context: RequestContext, - callback: (context: RequestContext>) => Promise, + context: ApplicationContext, + callback: (context: ApplicationContext) => Promise, ) { - const parentAfterCommit = context.data.afterCommit; + const parentAfterCommit = context.afterCommit; const afterCommit: AfterCommitTask[] = []; - const rootDb = context.data.rootDb ?? context.data.db; - const result = await context.data.db.transaction(async (transaction) => { - const nextContext = context.federation.createContext(context.request, { - ...context.data, + const rootDb = context.rootDb ?? context.db; + const result = await context.db.transaction(async (transaction) => { + const nextContext: ApplicationContext = { + ...context.withDatabase(transaction), db: transaction, rootDb, afterCommit, - }) as RequestContext>; + }; return await callback(nextContext); }); if (parentAfterCommit != null) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53ef39ea..42538156 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,12 @@ settings: catalogs: default: + '@ai-sdk/anthropic': + specifier: ^3.0.44 + version: 3.0.96 + '@ai-sdk/google': + specifier: ^3.0.29 + version: 3.0.91 '@c4spar/mock-fetch': specifier: jsr:^1.0.0 version: 1.0.0 @@ -18,12 +24,27 @@ catalogs: '@fedify/markdown-it-mention': specifier: jsr:^0.3.0 version: 0.3.0 + '@fedify/postgres': + specifier: jsr:2.3.1 + version: 2.3.1 + '@fedify/redis': + specifier: jsr:2.3.1 + version: 2.3.1 '@fedify/vocab': specifier: jsr:2.3.1 version: 2.3.1 + '@fedify/vocab-runtime': + specifier: jsr:2.3.1 + version: 2.3.1 '@hackerspub/markdown-it-texmath': specifier: ^1.0.1 version: 1.0.1 + '@keyv/redis': + specifier: ^4.6.0 + version: 4.6.0 + '@logtape/drizzle-orm': + specifier: jsr:2.1.1 + version: 2.1.1 '@logtape/logtape': specifier: jsr:2.0.6 version: 2.0.6 @@ -66,6 +87,15 @@ catalogs: '@std/uuid': specifier: jsr:^1.1.0 version: 1.1.0 + '@upyo/core': + specifier: jsr:^0.5.0 + version: 0.5.0 + '@upyo/mailgun': + specifier: jsr:^0.5.0 + version: 0.5.0 + '@upyo/mock': + specifier: jsr:^0.5.0 + version: 0.5.0 '@vertana/context-web': specifier: ^0.2.0 version: 0.2.0 @@ -102,12 +132,18 @@ catalogs: iconv-lite: specifier: ^0.6.3 version: 0.6.3 + ioredis: + specifier: ^5.4.1 + version: 5.10.1 katex: specifier: ^0.16.44 version: 0.16.44 keyv: specifier: ^5.6.0 version: 5.6.0 + keyv-file: + specifier: ^5.1.3 + version: 5.3.5 markdown-it-abbr: specifier: ^2.0.0 version: 2.0.0 @@ -243,6 +279,9 @@ importers: '@fedify/vocab': specifier: 'catalog:' version: '@jsr/fedify__vocab@2.3.1' + '@fedify/vocab-runtime': + specifier: 'catalog:' + version: '@jsr/fedify__vocab-runtime@2.3.1' '@hackerspub/markdown-it-texmath': specifier: 'catalog:' version: 1.0.1 @@ -285,9 +324,6 @@ importers: '@std/uuid': specifier: 'catalog:' version: '@jsr/std__uuid@1.1.0' - ai: - specifier: 'catalog:' - version: 6.0.146(zod@4.3.6) apns2: specifier: 'catalog:' version: 12.2.0 @@ -374,6 +410,66 @@ importers: specifier: 'catalog:' version: '@jsr/c4spar__mock-fetch@1.0.0' + runtime: + dependencies: + '@ai-sdk/anthropic': + specifier: 'catalog:' + version: 3.0.96(zod@4.3.6) + '@ai-sdk/google': + specifier: 'catalog:' + version: 3.0.91(zod@4.3.6) + '@fedify/fedify': + specifier: 'catalog:' + version: '@jsr/fedify__fedify@2.3.1' + '@fedify/postgres': + specifier: 'catalog:' + version: '@jsr/fedify__postgres@2.3.1' + '@fedify/redis': + specifier: 'catalog:' + version: '@jsr/fedify__redis@2.3.1' + '@hackerspub/federation': + specifier: workspace:* + version: link:../federation + '@hackerspub/models': + specifier: workspace:* + version: link:../models + '@keyv/redis': + specifier: 'catalog:' + version: 4.6.0(keyv@5.6.0) + '@logtape/drizzle-orm': + specifier: 'catalog:' + version: '@jsr/logtape__drizzle-orm@2.1.1' + '@logtape/logtape': + specifier: 'catalog:' + version: '@jsr/logtape__logtape@2.0.6' + '@upyo/core': + specifier: 'catalog:' + version: '@jsr/upyo__core@0.5.0' + '@upyo/mailgun': + specifier: 'catalog:' + version: '@jsr/upyo__mailgun@0.5.0' + '@upyo/mock': + specifier: 'catalog:' + version: '@jsr/upyo__mock@0.5.0' + drizzle-orm: + specifier: 'catalog:' + version: 1.0.0-beta.1-5e64efc(@cloudflare/workers-types@4.20260529.1)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(postgres@3.4.8) + flydrive: + specifier: 'catalog:' + version: 1.3.0 + ioredis: + specifier: 'catalog:' + version: 5.10.1 + keyv: + specifier: 'catalog:' + version: 5.6.0 + keyv-file: + specifier: 'catalog:' + version: 5.3.5 + postgres: + specifier: 'catalog:' + version: 3.4.8 + web-next: dependencies: '@codemirror/commands': @@ -548,18 +644,40 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/anthropic@3.0.96': + resolution: {integrity: sha512-6VQzaXQdm5FkX6NWOyKzV5GB11C8IqkgsKZE91lg/bdwyvnQJLDwal2qkE0+fC8CCGeW5d+VV8Mw/+H+OcDC1A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.88': resolution: {integrity: sha512-AFoj7xdWAtCQcy0jJ235ENSakYM8D28qBX+rB+/rX4r8qe/LXgl0e5UivOqxAlIM5E9jnQdYxIPuj3XFtGk/yg==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/google@3.0.91': + resolution: {integrity: sha512-d/ho+sDjArFjreE2002t9jE4LXX3wde97dN2HCLCX1l41gaJV3wf/c/19axjUNfIf/4uUq+nJmhBO0lW/dg3yw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.22': resolution: {integrity: sha512-B2OTFcRw/Pdka9ZTjpXv6T6qZ6RruRuLokyb8HwW+aoW9ndJ3YasA3/mVswyJw7VMBF8ofXgqvcrCt9KYvFifg==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.38': + resolution: {integrity: sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.14': + resolution: {integrity: sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==} + engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} @@ -1585,6 +1703,12 @@ packages: '@jsr/fedify__markdown-it-mention@0.3.0': resolution: {integrity: sha512-bxAgJuhq95rg6jFUAgtkZU5QrYOX7jqUHGBZm3ynW7ju3Hyv/AebB4TV7pQ0WKwP/ODVPtD5MsmaLs0ReIQs5Q==, tarball: https://npm.jsr.io/~/11/@jsr/fedify__markdown-it-mention/0.3.0.tgz} + '@jsr/fedify__postgres@2.3.1': + resolution: {integrity: sha512-j39qcQB57YpnaDFgfdanK8okNxgWQcF6sK7sBLipJImYGDc3ey+IGUqKr0iT3qZ9MWKuy0ahGgGg9HzPA9J7rg==, tarball: https://npm.jsr.io/~/11/@jsr/fedify__postgres/2.3.1.tgz} + + '@jsr/fedify__redis@2.3.1': + resolution: {integrity: sha512-Z8yAmAeOBQzPsx3CUKk8RyMh8/S/Snu3iHBDASGLH7BvtIKDJTytx8EGh7XW+QBkLf0mRTqtXBr6pel/cnHk5g==, tarball: https://npm.jsr.io/~/11/@jsr/fedify__redis/2.3.1.tgz} + '@jsr/fedify__uri-template@2.3.1': resolution: {integrity: sha512-XUHuytS5cPMwzpEk55RJyes0N9xhvIVX9Z2hKUvrRroDmfV2mDvjLlonrlz1tOurnAMRbNtsfrXjvD/yt24Pvw==, tarball: https://npm.jsr.io/~/11/@jsr/fedify__uri-template/2.3.1.tgz} @@ -1597,6 +1721,9 @@ packages: '@jsr/fedify__webfinger@2.3.1': resolution: {integrity: sha512-XiMpyDomlazqwnRTwowubeJvmG9k75mVZC5WYjyHuvCen7RQPhkdnpe55lNTuy47un3PWbhPVYaOzefwo4BjYA==, tarball: https://npm.jsr.io/~/11/@jsr/fedify__webfinger/2.3.1.tgz} + '@jsr/logtape__drizzle-orm@2.1.1': + resolution: {integrity: sha512-IDW8BaI12Qzkv6dkQ4EMbyqgecLabbgdwSCq8N4pY7Uwj7BQq2wI7w2/W9ArG56JHG+YWW7CpdLzET6ScrgArw==, tarball: https://npm.jsr.io/~/11/@jsr/logtape__drizzle-orm/2.1.1.tgz} + '@jsr/logtape__logtape@2.0.6': resolution: {integrity: sha512-Lw0+sFlpD9U+WzE/77diiDzEoYkhTuhrE53Fl5sXcNqWnlOJNi2x3K2elF+EhqdrZub2mHGSYznPRttlf/ah7Q==, tarball: https://npm.jsr.io/~/11/@jsr/logtape__logtape/2.0.6.tgz} @@ -1651,12 +1778,27 @@ packages: '@jsr/std__uuid@1.1.0': resolution: {integrity: sha512-V5tUE+zEFoOLNqC3W4c/ki0ikE5qzGfvaY3mZpxWd4BczhVjHZYlLLrbTkMkCsWM9quQSf3+eP0kHzEn+e5LvA==, tarball: https://npm.jsr.io/~/11/@jsr/std__uuid/1.1.0.tgz} + '@jsr/upyo__core@0.5.0': + resolution: {integrity: sha512-Auj86dYOY+3ayfYHYSdSjF8SR8pg6nQ8PHoau8GTTdEKZe+d8qactka2JY/kHSAdJd+ookE5Zf3TJKLAqljl0g==, tarball: https://npm.jsr.io/~/11/@jsr/upyo__core/0.5.0.tgz} + + '@jsr/upyo__mailgun@0.5.0': + resolution: {integrity: sha512-aHAxVCqTSoTfev+Lqorv1/m9n/z3aetM72JA8LV2L99TextqJl7IY3ccqx6TPpCzLPTiBeMzOjPtrvabxJOgdw==, tarball: https://npm.jsr.io/~/11/@jsr/upyo__mailgun/0.5.0.tgz} + + '@jsr/upyo__mock@0.5.0': + resolution: {integrity: sha512-q9nTJgqW8FiPY/UtGfaVuHoXsjN2HV1poO+WoafTNvP2dVllzFKm8BC9Kk6Vd2jDKW+LcrmSk9DtAuQKytdF/A==, tarball: https://npm.jsr.io/~/11/@jsr/upyo__mock/0.5.0.tgz} + '@jsr/vertana__core@0.2.0': resolution: {integrity: sha512-mb6aaUmwpX4WDIuwNF+qCKR8+4/Gg06lc6x2kVhcsWXlsOXu8nHA6kmj2O0ZYLErqa3UCL8ZYutECKrlzUYNvQ==, tarball: https://npm.jsr.io/~/11/@jsr/vertana__core/0.2.0.tgz} '@jsr/vertana__facade@0.2.0': resolution: {integrity: sha512-GlQu432hPXBft263oqPt4Nr1dD9k34V344pze6KuTuLL9ofl5AjfaF4DFPy4OSIqZfR+K48Bp2WMGqjQlIWo0g==, tarball: https://npm.jsr.io/~/11/@jsr/vertana__facade/0.2.0.tgz} + '@keyv/redis@4.6.0': + resolution: {integrity: sha512-FP3FP42RiQ3j0UC6f4Maf7ISTLAIivm37/SdfG5xvhqceMMq3kabtC6T4a2h5byMnh4S8PjP51DY/9CpyrcfsQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.3.4 + '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} @@ -2371,6 +2513,10 @@ packages: peerDependencies: '@opentelemetry/api': ^1.8 + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + '@rollup/plugin-alias@6.0.0': resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} engines: {node: '>=20.19.0'} @@ -4268,6 +4414,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + exit-hook@2.2.1: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} @@ -4380,6 +4530,10 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -4505,6 +4659,9 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + html-entities@2.3.3: resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} @@ -4834,6 +4991,9 @@ packages: resolution: {integrity: sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==} hasBin: true + keyv-file@5.3.5: + resolution: {integrity: sha512-0JFTTi55d1HdhIrSOnPngUw0fyHLn3BHqoLJ8TyGKM/fQfuZsz8HkcFxpl6YzU2mj1ZfRF/6BXlSqOpyfXYAmw==} + keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} @@ -6650,6 +6810,12 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@ai-sdk/anthropic@3.0.96(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.38(zod@4.3.6) + zod: 4.3.6 + '@ai-sdk/gateway@3.0.88(zod@4.2.1)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -6664,6 +6830,12 @@ snapshots: '@vercel/oidc': 3.1.0 zod: 4.3.6 + '@ai-sdk/google@3.0.91(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.38(zod@4.3.6) + zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.22(zod@4.2.1)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -6678,6 +6850,17 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.38(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.14': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 @@ -7722,6 +7905,20 @@ snapshots: '@jsr/std__html': 0.224.2 '@types/markdown-it': 14.1.2 + '@jsr/fedify__postgres@2.3.1': + dependencies: + '@jsr/fedify__fedify': 2.3.1 + '@jsr/logtape__logtape': 2.2.1 + postgres: 3.4.8 + + '@jsr/fedify__redis@2.3.1': + dependencies: + '@jsr/fedify__fedify': 2.3.1 + '@jsr/logtape__logtape': 2.2.1 + ioredis: 5.10.1 + transitivePeerDependencies: + - supports-color + '@jsr/fedify__uri-template@2.3.1': {} '@jsr/fedify__vocab-runtime@2.3.1': @@ -7748,6 +7945,10 @@ snapshots: '@jsr/logtape__logtape': 2.2.1 '@opentelemetry/api': 1.9.1 + '@jsr/logtape__drizzle-orm@2.1.1': + dependencies: + '@jsr/logtape__logtape': 2.2.1 + '@jsr/logtape__logtape@2.0.6': {} '@jsr/logtape__logtape@2.0.7': {} @@ -7802,6 +8003,16 @@ snapshots: '@jsr/std__bytes': 1.0.6 '@jsr/std__crypto': 1.0.5 + '@jsr/upyo__core@0.5.0': {} + + '@jsr/upyo__mailgun@0.5.0': + dependencies: + '@jsr/upyo__core': 0.5.0 + + '@jsr/upyo__mock@0.5.0': + dependencies: + '@jsr/upyo__core': 0.5.0 + '@jsr/vertana__core@0.2.0(@types/json-schema@7.0.15)(quansync@0.2.11)': dependencies: '@jsr/logtape__logtape': 2.0.7 @@ -7841,6 +8052,13 @@ snapshots: - zod - zod-to-json-schema + '@keyv/redis@4.6.0(keyv@5.6.0)': + dependencies: + '@redis/client': 1.6.1 + cluster-key-slot: 1.1.2 + hookified: 1.15.1 + keyv: 5.6.0 + '@keyv/serialize@1.1.1': {} '@kobalte/core@0.13.11(patch_hash=0e9ebaa19196c640af5fb0d7dc0d8bb5c8243b5315d53897219526bab4fd8be4)(solid-js@1.9.12(patch_hash=e1d4b15462056babc6bc3de4094e36ef15bb7673147815a0b1bd7408d7ff5fcc))': @@ -8578,14 +8796,14 @@ snapshots: '@peculiar/asn1-pkcs8': 2.6.1 '@peculiar/asn1-rsa': 2.6.1 '@peculiar/asn1-schema': 2.6.0 - asn1js: 3.0.7 + asn1js: 3.0.10 tslib: 2.8.1 '@peculiar/asn1-pkcs8@2.6.1': dependencies: '@peculiar/asn1-schema': 2.6.0 '@peculiar/asn1-x509': 2.6.1 - asn1js: 3.0.7 + asn1js: 3.0.10 tslib: 2.8.1 '@peculiar/asn1-pkcs9@2.6.1': @@ -8616,7 +8834,7 @@ snapshots: dependencies: '@peculiar/asn1-schema': 2.6.0 '@peculiar/asn1-x509': 2.6.1 - asn1js: 3.0.7 + asn1js: 3.0.10 tslib: 2.8.1 '@peculiar/asn1-x509@2.6.1': @@ -8682,6 +8900,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + '@rollup/plugin-alias@6.0.0(rollup@4.60.1)': optionalDependencies: rollup: 4.60.1 @@ -10713,6 +10937,8 @@ snapshots: eventsource-parser@3.0.6: {} + eventsource-parser@3.1.0: {} + exit-hook@2.2.1: {} exsolve@1.0.8: {} @@ -10824,6 +11050,8 @@ snapshots: generator-function@2.0.1: {} + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -10975,6 +11203,8 @@ snapshots: hookable@5.5.3: {} + hookified@1.15.1: {} + html-entities@2.3.3: {} html-escaper@3.0.3: {} @@ -11311,6 +11541,11 @@ snapshots: dependencies: commander: 8.3.0 + keyv-file@5.3.5: + dependencies: + '@keyv/serialize': 1.1.1 + tslib: 1.14.1 + keyv@5.6.0: dependencies: '@keyv/serialize': 1.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7926b058..5290c9e4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,16 +2,24 @@ packages: - ai - federation - models + - runtime - web-next - asset-cdn catalog: + '@ai-sdk/anthropic': ^3.0.44 + '@ai-sdk/google': ^3.0.29 '@c4spar/mock-fetch': jsr:^1.0.0 '@fedify/fedify': jsr:2.3.1 '@fedify/markdown-it-hashtag': jsr:^0.3.0 '@fedify/markdown-it-mention': jsr:^0.3.0 + '@fedify/postgres': jsr:2.3.1 + '@fedify/redis': jsr:2.3.1 '@fedify/vocab': jsr:2.3.1 + '@fedify/vocab-runtime': jsr:2.3.1 '@hackerspub/markdown-it-texmath': ^1.0.1 + '@keyv/redis': ^4.6.0 + '@logtape/drizzle-orm': jsr:2.1.1 '@logtape/logtape': jsr:2.0.6 '@mdit-vue/plugin-title': ^2.1.4 '@opentelemetry/api': ^1.0.15 @@ -27,6 +35,9 @@ catalog: '@std/html': jsr:^1.0.5 '@std/text': jsr:^1.0.17 '@std/uuid': jsr:^1.1.0 + '@upyo/core': jsr:^0.5.0 + '@upyo/mailgun': jsr:^0.5.0 + '@upyo/mock': jsr:^0.5.0 '@vertana/context-web': ^0.2.0 '@vertana/core': jsr:^0.2.0 '@vertana/facade': jsr:^0.2.0 @@ -39,8 +50,10 @@ catalog: fluent-ffmpeg: ^2.1.3 flydrive: ^1.3.0 iconv-lite: ^0.6.3 + ioredis: ^5.4.1 katex: ^0.16.44 keyv: ^5.6.0 + keyv-file: ^5.1.3 markdown-it-abbr: ^2.0.0 markdown-it-anchor: ^9.2.0 markdown-it-async: ^2.2.0 diff --git a/runtime/config.test.ts b/runtime/config.test.ts new file mode 100644 index 00000000..5051195b --- /dev/null +++ b/runtime/config.test.ts @@ -0,0 +1,133 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + ConfigurationError, + loadAccountCreationConfig, + loadDatabaseConfig, + loadServerConfig, +} from "./config.ts"; + +const required = { + DATABASE_URL: "postgres://localhost/hackerspub", + DRIVE_DISK: "fs", + FS_LOCATION: "./data", + KV_URL: "file:///tmp/hackerspub-kv.json", + ORIGIN: "https://hackers.pub", + CI: "true", + EMAIL_FROM: "noreply@hackers.pub", +}; + +Deno.test("loadDatabaseConfig requires only DATABASE_URL", () => { + assertEquals( + loadDatabaseConfig({ DATABASE_URL: required.DATABASE_URL }), + { url: required.DATABASE_URL }, + ); +}); + +Deno.test("loadAccountCreationConfig requires only ORIGIN and KV_URL", () => { + const config = loadAccountCreationConfig({ + ORIGIN: required.ORIGIN, + KV_URL: required.KV_URL, + }); + + assertEquals(config.origin.href, "https://hackers.pub/"); + assertEquals(config.kv.url.href, required.KV_URL); +}); + +Deno.test("loadServerConfig parses runtime configuration without reading Deno.env", () => { + const config = loadServerConfig(required); + + assertEquals(config.database.url, required.DATABASE_URL); + assertEquals(config.origin.href, "https://hackers.pub/"); + assertEquals(config.kv.url.href, required.KV_URL); + assertEquals(config.storage, { driver: "fs", location: "./data" }); + assertEquals(config.email, { + transport: "mock", + from: "noreply@hackers.pub", + reason: "ci", + }); +}); + +Deno.test("loadServerConfig uses mock email in development without Mailgun", () => { + const { CI: _ci, EMAIL_FROM: _emailFrom, ...withoutEmail } = required; + const config = loadServerConfig({ ...withoutEmail, MODE: "development" }); + + assertEquals(config.email, { + transport: "mock", + from: "noreply@hackers.pub", + reason: "mailgun-unconfigured", + }); +}); + +Deno.test("loadServerConfig ignores sender and region without Mailgun credentials", () => { + const { CI: _ci, ...withoutCi } = required; + const config = loadServerConfig({ + ...withoutCi, + MODE: "development", + MAILGUN_FROM: "legacy@hackers.pub", + MAILGUN_REGION: "us", + }); + + assertEquals(config.email, { + transport: "mock", + from: "noreply@hackers.pub", + reason: "mailgun-unconfigured", + }); +}); + +Deno.test("loadServerConfig requires Mailgun in the default production mode", () => { + const { CI: _ci, ...withoutCi } = required; + const error = assertThrows( + () => loadServerConfig({ ...withoutCi, MAILGUN_REGION: "us" }), + ConfigurationError, + ) as ConfigurationError; + + assertEquals( + error.issues.map((issue) => issue.variable).toSorted(), + ["MAILGUN_DOMAIN", "MAILGUN_KEY"], + ); +}); + +Deno.test("loadServerConfig rejects partial Mailgun configuration", () => { + const { CI: _ci, EMAIL_FROM: _emailFrom, ...withoutEmail } = required; + const error = assertThrows( + () => loadServerConfig({ ...withoutEmail, MAILGUN_KEY: "key" }), + ConfigurationError, + ) as ConfigurationError; + + assertEquals( + error.issues.map((issue) => issue.variable).toSorted(), + ["EMAIL_FROM", "MAILGUN_DOMAIN", "MAILGUN_REGION"], + ); +}); + +Deno.test("loadServerConfig reports missing and invalid values as typed issues", () => { + const error = assertThrows( + () => loadServerConfig({ ORIGIN: "ftp://example.com", KV_URL: "nope" }), + ConfigurationError, + ) as ConfigurationError; + + assertEquals( + error.issues.some((issue) => issue.variable === "DATABASE_URL"), + true, + ); + assertEquals(error.issues.some((issue) => issue.variable === "ORIGIN"), true); + assertEquals(error.issues.some((issue) => issue.variable === "KV_URL"), true); +}); + +Deno.test("loadServerConfig requires the complete selected storage configuration", () => { + const error = assertThrows( + () => + loadServerConfig({ + ...required, + DRIVE_DISK: "s3", + FS_LOCATION: undefined, + AWS_ACCESS_KEY_ID: "key", + }), + ConfigurationError, + ) as ConfigurationError; + + assertEquals( + error.issues.map((issue) => issue.variable).toSorted(), + ["AWS_REGION", "AWS_SECRET_ACCESS_KEY", "S3_BUCKET"], + ); +}); diff --git a/runtime/config.ts b/runtime/config.ts new file mode 100644 index 00000000..d7802c54 --- /dev/null +++ b/runtime/config.ts @@ -0,0 +1,278 @@ +export interface Environment { + readonly [variable: string]: string | undefined; +} + +export interface ConfigurationIssue { + readonly variable: string; + readonly message: string; +} + +export class ConfigurationError extends Error { + constructor(readonly issues: readonly ConfigurationIssue[]) { + super( + `Invalid server configuration:\n${ + issues.map(({ variable, message }) => `- ${variable}: ${message}`).join( + "\n", + ) + }`, + ); + this.name = "ConfigurationError"; + } +} + +export type StorageConfig = + | { readonly driver: "fs"; readonly location: string } + | { + readonly driver: "s3"; + readonly accessKeyId: string; + readonly secretAccessKey: string; + readonly region: string; + readonly bucket: string; + readonly endpoint?: string; + readonly cdnUrl?: string; + }; + +export type EmailConfig = + | { + readonly transport: "mock"; + readonly from: string; + readonly reason: "ci" | "mailgun-unconfigured"; + } + | { + readonly transport: "mailgun"; + readonly from: string; + readonly apiKey: string; + readonly domain: string; + readonly region: "eu" | "us"; + }; + +export interface DatabaseConfig { + readonly url: string; +} + +export interface KeyValueConfig { + readonly url: URL; +} + +export interface AccountCreationConfig { + readonly origin: URL; + readonly kv: KeyValueConfig; +} + +export interface ServerConfig { + readonly database: DatabaseConfig; + readonly origin: URL; + readonly kv: KeyValueConfig; + readonly storage: StorageConfig; + readonly email: EmailConfig; + readonly ai: { + readonly altTextModel: string; + readonly summarizerModel: string; + readonly translatorModel: string; + readonly moderationModel: string; + }; + readonly behindProxy: boolean; + readonly mode: string; +} + +function nonEmpty( + env: Environment, + variable: string, + issues: ConfigurationIssue[], +): string | undefined { + const value = env[variable]?.trim(); + if (value == null || value === "") { + issues.push({ variable, message: "is required" }); + return undefined; + } + return value; +} + +function parseDatabaseConfig( + env: Environment, + issues: ConfigurationIssue[], +): DatabaseConfig | undefined { + const url = nonEmpty(env, "DATABASE_URL", issues); + return url == null ? undefined : { url }; +} + +export function loadDatabaseConfig(env: Environment): DatabaseConfig { + const issues: ConfigurationIssue[] = []; + const config = parseDatabaseConfig(env, issues); + if (config == null) throw new ConfigurationError(issues); + return config; +} + +function parseUrl( + value: string | undefined, + variable: string, + issues: ConfigurationIssue[], + protocols?: readonly string[], +): URL | undefined { + if (value == null) return undefined; + let url: URL; + try { + url = new URL(value); + } catch { + issues.push({ variable, message: "must be a valid URL" }); + return undefined; + } + if (protocols != null && !protocols.includes(url.protocol)) { + issues.push({ + variable, + message: `must use ${protocols.join(" or ")}`, + }); + return undefined; + } + return url; +} + +function parseAccountCreationConfig( + env: Environment, + issues: ConfigurationIssue[], +): Partial { + const origin = parseUrl( + nonEmpty(env, "ORIGIN", issues), + "ORIGIN", + issues, + ["http:", "https:"], + ); + const kvUrl = parseUrl( + nonEmpty(env, "KV_URL", issues), + "KV_URL", + issues, + ["file:", "redis:"], + ); + return { + ...(origin == null ? {} : { origin }), + ...(kvUrl == null ? {} : { kv: { url: kvUrl } }), + }; +} + +export function loadAccountCreationConfig( + env: Environment, +): AccountCreationConfig { + const issues: ConfigurationIssue[] = []; + const config = parseAccountCreationConfig(env, issues); + if (issues.length > 0 || config.origin == null || config.kv == null) { + throw new ConfigurationError(issues); + } + return { origin: config.origin, kv: config.kv }; +} + +export function loadServerConfig(env: Environment): ServerConfig { + const issues: ConfigurationIssue[] = []; + const database = parseDatabaseConfig(env, issues); + const { origin, kv } = parseAccountCreationConfig(env, issues); + + const driver = nonEmpty(env, "DRIVE_DISK", issues); + let storage: StorageConfig | undefined; + if (driver === "fs") { + const location = nonEmpty(env, "FS_LOCATION", issues); + if (location != null) storage = { driver, location }; + } else if (driver === "s3") { + const accessKeyId = nonEmpty(env, "AWS_ACCESS_KEY_ID", issues); + const secretAccessKey = nonEmpty(env, "AWS_SECRET_ACCESS_KEY", issues); + const region = nonEmpty(env, "AWS_REGION", issues); + const bucket = nonEmpty(env, "S3_BUCKET", issues); + if ( + accessKeyId != null && secretAccessKey != null && region != null && + bucket != null + ) { + storage = { + driver, + accessKeyId, + secretAccessKey, + region, + bucket, + ...(env.S3_ENDPOINT == null ? {} : { endpoint: env.S3_ENDPOINT }), + ...(env.S3_CDN_URL == null ? {} : { cdnUrl: env.S3_CDN_URL }), + }; + } + } else if (driver != null) { + issues.push({ variable: "DRIVE_DISK", message: "must be fs or s3" }); + } + + const configuredFrom = (env.EMAIL_FROM ?? env.MAILGUN_FROM)?.trim(); + const defaultFrom = `noreply@${origin?.hostname ?? "localhost"}`; + const mode = env.MODE ?? "production"; + const mailgunSelected = [ + env.MAILGUN_KEY, + env.MAILGUN_DOMAIN, + ].some((value) => value != null && value.trim() !== ""); + let email: EmailConfig | undefined; + if (env.CI?.toLowerCase() === "true") { + email = { + transport: "mock", + from: configuredFrom || defaultFrom, + reason: "ci", + }; + } else if ( + !mailgunSelected && + (mode === "development" || mode === "test" || mode === "build") + ) { + email = { + transport: "mock", + from: configuredFrom || defaultFrom, + reason: "mailgun-unconfigured", + }; + } else { + const from = configuredFrom; + if (from == null || from === "") { + issues.push({ + variable: "EMAIL_FROM", + message: "EMAIL_FROM or MAILGUN_FROM is required", + }); + } + const apiKey = nonEmpty(env, "MAILGUN_KEY", issues); + const domain = nonEmpty(env, "MAILGUN_DOMAIN", issues); + const regionValue = nonEmpty(env, "MAILGUN_REGION", issues); + const region = regionValue === "eu" || regionValue === "us" + ? regionValue + : undefined; + if (regionValue != null && region == null) { + issues.push({ + variable: "MAILGUN_REGION", + message: "must be eu or us", + }); + } + if ( + from != null && from !== "" && apiKey != null && domain != null && + region != null + ) { + email = { + transport: "mailgun", + from, + apiKey, + domain, + region, + }; + } + } + + if ( + issues.length > 0 || database == null || origin == null || + kv == null || storage == null || email == null + ) { + throw new ConfigurationError(issues); + } + return { + database, + origin, + kv, + storage, + email, + ai: { + altTextModel: env.AI_ALT_TEXT_MODEL ?? "gemini-3.1-flash-lite-preview", + summarizerModel: env.AI_SUMMARIZER_MODEL ?? "gemini-3.5-flash", + translatorModel: env.AI_TRANSLATOR_MODEL ?? "claude-sonnet-4-6", + moderationModel: env.AI_MODERATION_MODEL ?? "claude-sonnet-4-6", + }, + behindProxy: env.BEHIND_PROXY?.toLowerCase() === "true", + mode, + }; +} + +export function getDenoEnvironment(): Environment { + return Deno.env.toObject(); +} diff --git a/runtime/deno.json b/runtime/deno.json new file mode 100644 index 00000000..2a134b6e --- /dev/null +++ b/runtime/deno.json @@ -0,0 +1,8 @@ +{ + "name": "@hackerspub/runtime", + "version": "0.2.0", + "exports": { + "./config": "./config.ts", + "./resources": "./resources.ts" + } +} diff --git a/runtime/imports.test.ts b/runtime/imports.test.ts new file mode 100644 index 00000000..c8c354d6 --- /dev/null +++ b/runtime/imports.test.ts @@ -0,0 +1,20 @@ +import { assertEquals } from "@std/assert"; + +Deno.test("resource modules can be imported without server configuration", async () => { + const modules = await Promise.all([ + import("../graphql/ai.ts"), + import("../graphql/db.ts"), + import("../graphql/drive.ts"), + import("../graphql/email.ts"), + import("../graphql/federation.ts"), + import("../graphql/kv.ts"), + import("../web/ai.ts"), + import("../web/db.ts"), + import("../web/drive.ts"), + import("../web/email.ts"), + import("../web/federation.ts"), + import("../web/kv.ts"), + ]); + + assertEquals(modules.length, 12); +}); diff --git a/runtime/package.json b/runtime/package.json new file mode 100644 index 00000000..ed9fcd0c --- /dev/null +++ b/runtime/package.json @@ -0,0 +1,28 @@ +{ + "name": "@hackerspub/runtime", + "type": "module", + "exports": { + "./*": "./*.ts" + }, + "dependencies": { + "@ai-sdk/anthropic": "catalog:", + "@ai-sdk/google": "catalog:", + "@fedify/fedify": "catalog:", + "@fedify/postgres": "catalog:", + "@fedify/redis": "catalog:", + "@hackerspub/federation": "workspace:*", + "@hackerspub/models": "workspace:*", + "@keyv/redis": "catalog:", + "@logtape/drizzle-orm": "catalog:", + "@logtape/logtape": "catalog:", + "@upyo/core": "catalog:", + "@upyo/mailgun": "catalog:", + "@upyo/mock": "catalog:", + "drizzle-orm": "catalog:", + "flydrive": "catalog:", + "ioredis": "catalog:", + "keyv": "catalog:", + "keyv-file": "catalog:", + "postgres": "catalog:" + } +} diff --git a/runtime/resources.test.ts b/runtime/resources.test.ts new file mode 100644 index 00000000..f1c65653 --- /dev/null +++ b/runtime/resources.test.ts @@ -0,0 +1,172 @@ +import { assertEquals, assertInstanceOf } from "@std/assert"; +import { MockTransport } from "@upyo/mock"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + createEmailResource, + createKeyValueResource, + getFederationBehaviorOptions, + resolveFileSystemStorageLocation, + runWithFederationQueue, +} from "./resources.ts"; + +Deno.test("resolveFileSystemStorageLocation resolves relative paths from the composition root", () => { + assertEquals( + resolveFileSystemStorageLocation( + "./media", + new URL("file:///app/web/"), + ).href, + "file:///app/web/media", + ); +}); + +Deno.test("createKeyValueResource decodes file URL paths", async () => { + const directory = await Deno.makeTempDir(); + const filename = join(directory, "cache file.json"); + const kv = createKeyValueResource({ url: pathToFileURL(filename) }); + try { + await kv.set("key", "value"); + assertEquals(await kv.get("key"), "value"); + await Deno.stat(filename); + } finally { + await kv.disconnect(); + await Deno.remove(directory, { recursive: true }); + } +}); + +Deno.test("getFederationBehaviorOptions supports an explicitly managed legacy queue", () => { + assertEquals( + getFederationBehaviorOptions({ + manuallyStartQueue: true, + firstKnock: "draft-cavage-http-signatures-12", + }), + { + manuallyStartQueue: true, + firstKnock: "draft-cavage-http-signatures-12", + }, + ); +}); + +Deno.test("runWithFederationQueue aborts and awaits the queue before returning", async () => { + const events: string[] = []; + const federation = { + startQueue( + _contextData: undefined, + options?: { signal?: AbortSignal }, + ): Promise { + events.push("queue-started"); + return new Promise((resolve) => { + options?.signal?.addEventListener("abort", () => { + events.push("queue-stopped"); + resolve(); + }); + }); + }, + }; + + await runWithFederationQueue(federation, undefined, async (signal) => { + assertEquals(signal.aborted, false); + events.push("server-stopped"); + }); + + assertEquals(events, [ + "queue-started", + "server-stopped", + "queue-stopped", + ]); +}); + +Deno.test("runWithFederationQueue accepts Error-shaped abort failures", async () => { + const federation = { + startQueue( + _contextData: undefined, + options?: { signal?: AbortSignal }, + ): Promise { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => { + const error = new Error("Queue aborted"); + error.name = "AbortError"; + reject(error); + }); + }); + }, + }; + + await runWithFederationQueue(federation, undefined, () => Promise.resolve()); +}); + +Deno.test("runWithFederationQueue stops both tasks on an external signal", async () => { + const controller = new AbortController(); + const queueStarted = Promise.withResolvers(); + const serverStarted = Promise.withResolvers(); + const federation = { + startQueue( + _contextData: undefined, + options?: { signal?: AbortSignal }, + ): Promise { + queueStarted.resolve(); + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => { + const error = new Error("Queue aborted"); + error.name = "AbortError"; + reject(error); + }); + }); + }, + }; + + const running = runWithFederationQueue( + federation, + undefined, + (signal) => { + serverStarted.resolve(); + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve()); + }); + }, + { signal: controller.signal }, + ); + await Promise.all([queueStarted.promise, serverStarted.promise]); + controller.abort(); + + await running; +}); + +Deno.test("createEmailResource warns when Mailgun is unconfigured", () => { + const warnings: string[] = []; + const transport = createEmailResource( + { + transport: "mock", + from: "noreply@hackers.pub", + reason: "mailgun-unconfigured", + }, + { + warning(message) { + warnings.push(message); + }, + }, + ); + + assertInstanceOf(transport, MockTransport); + assertEquals(warnings, [ + "MAILGUN_* environment variables are not configured; using MockTransport. Emails will not be delivered.", + ]); +}); + +Deno.test("createEmailResource does not warn when CI selects the mock transport", () => { + const warnings: string[] = []; + createEmailResource( + { + transport: "mock", + from: "noreply@hackers.pub", + reason: "ci", + }, + { + warning(message) { + warnings.push(message); + }, + }, + ); + + assertEquals(warnings, []); +}); diff --git a/runtime/resources.ts b/runtime/resources.ts new file mode 100644 index 00000000..250d1f90 --- /dev/null +++ b/runtime/resources.ts @@ -0,0 +1,389 @@ +import { anthropic } from "@ai-sdk/anthropic"; +import { google } from "@ai-sdk/google"; +import type { Federation, FederationOptions } from "@fedify/fedify"; +import { PostgresKvStore, PostgresMessageQueue } from "@fedify/postgres"; +import { RedisKvStore } from "@fedify/redis"; +import { + type ContextData, + defineApplicationModel, + type Models, +} from "@hackerspub/models/context"; +import type { Database } from "@hackerspub/models/db"; +import { relations } from "@hackerspub/models/relations"; +import { getLogger as getDatabaseLogger } from "@logtape/drizzle-orm"; +import { getLogger } from "@logtape/logtape"; +import type { Transport } from "@upyo/core"; +import { MailgunTransport } from "@upyo/mailgun"; +import { MockTransport } from "@upyo/mock"; +import KeyvRedis from "@keyv/redis"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { type Disk, DriveManager } from "flydrive"; +import { FSDriver } from "flydrive/drivers/fs"; +import { S3Driver } from "flydrive/drivers/s3"; +import { Redis } from "ioredis"; +import Keyv from "keyv"; +import { KeyvFile } from "keyv-file"; +import { resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import postgresJs, { type Sql } from "postgres"; +import type { KeyValueConfig, ServerConfig } from "./config.ts"; + +export interface DatabaseResources { + readonly postgres: Sql; + readonly db: Database; +} + +export function createDatabaseResources( + config: ServerConfig["database"], +): DatabaseResources { + const postgres = postgresJs(config.url, { max: 20 }); + const db: Database = drizzle({ + relations, + client: postgres, + logger: getDatabaseLogger(), + }); + getLogger(["hackerspub", "db"]).debug("The driver is ready: {driver}", { + driver: db.constructor, + }); + return { postgres, db }; +} + +export function createKeyValueResource(config: KeyValueConfig): Keyv { + const adapter = config.url.protocol === "file:" + ? new KeyvFile({ filename: fileURLToPath(config.url) }) + : new KeyvRedis(config.url.href); + return new Keyv(adapter); +} + +export interface DriveResource { + readonly fileSystemRoot?: URL; + use(): Disk; +} + +export function resolveFileSystemStorageLocation( + location: string, + baseUrl: URL, +): URL { + return pathToFileURL(resolve(fileURLToPath(baseUrl), location)); +} + +export function createDriveResource( + config: ServerConfig["storage"], + origin: URL, + fileSystemBaseUrl: URL, +): DriveResource { + if (config.driver === "fs") { + const fileSystemRoot = resolveFileSystemStorageLocation( + config.location, + fileSystemBaseUrl, + ); + const drive = new DriveManager({ + default: "fs", + services: { + fs: () => + new FSDriver({ + location: fileSystemRoot, + visibility: "public", + urlBuilder: { + generateURL: (key) => + Promise.resolve(new URL(`/media/${key}`, origin).href), + generateSignedURL: (key) => + Promise.resolve(new URL(`/media/${key}`, origin).href), + }, + }), + }, + }); + return Object.assign(drive, { fileSystemRoot }); + } + return new DriveManager({ + default: "s3", + services: { + s3: () => + new S3Driver({ + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + endpoint: config.endpoint, + cdnUrl: config.cdnUrl, + region: config.region, + bucket: config.bucket, + visibility: "public", + }), + }, + }); +} + +export interface FederationResourceOptions { + readonly manuallyStartQueue: boolean; + readonly firstKnock?: NonNullable< + FederationOptions["firstKnock"] + >; +} + +export function getFederationBehaviorOptions( + options: FederationResourceOptions, +): Pick< + FederationOptions, + "firstKnock" | "manuallyStartQueue" +> { + return { + manuallyStartQueue: options.manuallyStartQueue, + ...(options.firstKnock == null ? {} : { firstKnock: options.firstKnock }), + }; +} + +interface FederationQueueRunner { + startQueue( + contextData: TContextData, + options?: { readonly signal?: AbortSignal }, + ): Promise; +} + +type LifecycleCompletion = + | { readonly source: "queue" | "server"; readonly successful: true } + | { + readonly source: "queue" | "server"; + readonly successful: false; + readonly error: unknown; + }; + +function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError") + ); +} + +export async function runWithFederationQueue( + federation: FederationQueueRunner, + contextData: TContextData, + runServer: (signal: AbortSignal) => Promise, + options: { readonly signal?: AbortSignal } = {}, +): Promise { + const controller = new AbortController(); + let shutdownRequested = options.signal?.aborted ?? false; + const requestShutdown = () => { + shutdownRequested = true; + controller.abort(); + }; + if (options.signal?.aborted) { + requestShutdown(); + } else { + options.signal?.addEventListener("abort", requestShutdown, { once: true }); + } + const settle = ( + source: "queue" | "server", + promise: Promise, + ): Promise => + promise.then( + () => ({ source, successful: true }), + (error: unknown) => ({ source, successful: false, error }), + ); + const queueCompletion = settle( + "queue", + federation.startQueue(contextData, { signal: controller.signal }), + ); + const serverCompletion = settle( + "server", + Promise.resolve().then(() => runServer(controller.signal)), + ); + + try { + const first = await Promise.race([queueCompletion, serverCompletion]); + controller.abort(); + const [queue, server] = await Promise.all([ + queueCompletion, + serverCompletion, + ]); + + if ( + !first.successful && + !(shutdownRequested && isAbortError(first.error)) + ) { + throw first.error; + } + if (first.source === "queue" && first.successful && !shutdownRequested) { + throw new Error("The federation queue stopped before the server."); + } + if ( + !server.successful && + !(shutdownRequested && isAbortError(server.error)) + ) { + throw server.error; + } + if (!queue.successful && !isAbortError(queue.error)) throw queue.error; + } finally { + options.signal?.removeEventListener("abort", requestShutdown); + } +} + +export interface WarningLogger { + warning(message: string): void; +} + +export function createEmailResource( + config: ServerConfig["email"], + logger: WarningLogger = getLogger(["hackerspub", "email"]), +): Transport { + if (config.transport === "mock") { + if (config.reason === "mailgun-unconfigured") { + logger.warning( + "MAILGUN_* environment variables are not configured; using MockTransport. Emails will not be delivered.", + ); + } + return new MockTransport(); + } + return new MailgunTransport({ + apiKey: config.apiKey, + domain: config.domain, + region: config.region, + }); +} + +export function createAiModels(config: ServerConfig["ai"]): Models & { + altTextGenerator: ReturnType; +} { + return { + altTextGenerator: google(config.altTextModel), + summarizer: defineApplicationModel(google(config.summarizerModel)), + translator: defineApplicationModel(anthropic(config.translatorModel)), + moderationAnalyzer: defineApplicationModel( + anthropic(config.moderationModel), + ), + }; +} + +export async function createFederationResource( + config: Pick, + postgres: Sql, + softwareVersion: string, + options: FederationResourceOptions, +): Promise<{ + readonly federation: Federation; + close(): Promise; +}> { + const { builder } = await import("@hackerspub/federation"); + const redis = config.kv.url.protocol === "redis:" + ? new Redis(config.kv.url.href, { + family: config.kv.url.hostname.endsWith(".upstash.io") ? 6 : 4, + }) + : undefined; + const federationKv = redis == null + ? new PostgresKvStore(postgres) + : new RedisKvStore(redis); + const queue = new PostgresMessageQueue(postgres, { + handlerTimeout: { seconds: 180 }, + }); + let federation: Federation; + try { + federation = await builder.build({ + kv: federationKv, + queue, + ...getFederationBehaviorOptions(options), + origin: config.origin.href, + userAgent: { + software: `HackersPub/${softwareVersion}`, + url: config.origin, + }, + }); + } catch (error) { + if (redis != null) await redis.quit(); + throw error; + } + return { + federation, + async close() { + if (redis != null) await redis.quit(); + }, + }; +} + +export interface RuntimeResources { + readonly config: ServerConfig; + readonly postgres: Sql; + readonly db: Database; + readonly kv: Keyv; + readonly drive: ReturnType; + readonly email: Transport; + readonly models: ReturnType; + readonly federation: Awaited< + ReturnType + >["federation"]; + close(): Promise; +} + +export interface RuntimeResourceOptions { + readonly fileSystemBaseUrl: URL; + readonly federation: FederationResourceOptions; +} + +type ResourceCleanup = () => Promise; + +async function closeResourceHandles( + cleanups: readonly ResourceCleanup[], +): Promise { + const errors: unknown[] = []; + for (const cleanup of cleanups) { + try { + await cleanup(); + } catch (error) { + errors.push(error); + } + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError(errors, "Failed to close runtime resources."); + } +} + +export async function createRuntimeResources( + config: ServerConfig, + softwareVersion: string, + options: RuntimeResourceOptions, +): Promise { + const { postgres, db } = createDatabaseResources(config.database); + const cleanups: ResourceCleanup[] = [() => postgres.end()]; + try { + const kv = createKeyValueResource(config.kv); + cleanups.unshift(() => kv.disconnect()); + const drive = createDriveResource( + config.storage, + config.origin, + options.fileSystemBaseUrl, + ); + const email = createEmailResource(config.email); + const models = createAiModels(config.ai); + const federationResources = await createFederationResource( + config, + postgres, + softwareVersion, + options.federation, + ); + cleanups.unshift(() => federationResources.close()); + const { federation } = federationResources; + return { + config, + postgres, + db, + kv, + drive, + email, + models, + federation, + async close() { + await closeResourceHandles(cleanups); + }, + }; + } catch (error) { + try { + await closeResourceHandles(cleanups); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Failed to create and clean up runtime resources.", + ); + } + throw error; + } +} diff --git a/scripts/addaccount.test.ts b/scripts/addaccount.test.ts new file mode 100644 index 00000000..3add3ad4 --- /dev/null +++ b/scripts/addaccount.test.ts @@ -0,0 +1,40 @@ +import { assert, assertEquals, assertMatch } from "@std/assert"; + +Deno.test("addaccount initializes only its origin and key-value resources", async () => { + const directory = await Deno.makeTempDir(); + try { + const output = await new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-all", + "scripts/addaccount.ts", + "alice@example.com", + ], + cwd: new URL("../", import.meta.url), + clearEnv: true, + env: { + ORIGIN: "https://hackers.pub", + KV_URL: new URL("signup.json", `file://${directory}/`).href, + }, + stdout: "piped", + stderr: "piped", + }).output(); + + assertEquals( + output.success, + true, + new TextDecoder().decode(output.stderr), + ); + const stdout = new TextDecoder().decode(output.stdout).trim(); + assertMatch( + stdout, + /^https:\/\/hackers\.pub\/sign\/up\/[0-9a-f-]+\?code=.+$/, + ); + assert( + !new TextDecoder().decode(output.stderr).includes("Error creating"), + "the operational command should create a sign-up link", + ); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); diff --git a/scripts/addaccount.ts b/scripts/addaccount.ts index af8338a8..356fd7c2 100644 --- a/scripts/addaccount.ts +++ b/scripts/addaccount.ts @@ -1,10 +1,18 @@ import { createSignupToken } from "@hackerspub/models/signup"; -import { kv } from "@hackerspub/web/kv"; -import { ORIGIN } from "../web/federation.ts"; +import { + getDenoEnvironment, + loadAccountCreationConfig, +} from "@hackerspub/runtime/config"; +import { createKeyValueResource } from "@hackerspub/runtime/resources"; +import type Keyv from "keyv"; -export async function createSignupLink(email: string): Promise { +export async function createSignupLink( + kv: Keyv, + origin: URL, + email: string, +): Promise { const token = await createSignupToken(kv, email); - const verifyUrl = new URL(`/sign/up/${token.token}`, ORIGIN); + const verifyUrl = new URL(`/sign/up/${token.token}`, origin); verifyUrl.searchParams.set("code", token.code); return verifyUrl; } @@ -16,12 +24,17 @@ export async function main() { console.error("Usage: mise run addaccount EMAIL"); Deno.exit(1); } + const config = loadAccountCreationConfig(getDenoEnvironment()); + const kv = createKeyValueResource(config.kv); try { - const signupLink = await createSignupLink(email); + const signupLink = await createSignupLink(kv, config.origin, email); console.error(`Signup link for ${email}:\n`); console.log(signupLink.href); } catch (error) { console.error("Error creating signup link:", error); + Deno.exitCode = 1; + } finally { + await kv.disconnect(); } } diff --git a/scripts/repair-link-previews.ts b/scripts/repair-link-previews.ts index 7fa37a19..8658a44f 100644 --- a/scripts/repair-link-previews.ts +++ b/scripts/repair-link-previews.ts @@ -1,5 +1,12 @@ import { repairBrokenLinkPreviews } from "@hackerspub/models/link-preview"; -import { db, postgres } from "../graphql/db.ts"; +import { + getDenoEnvironment, + loadDatabaseConfig, +} from "@hackerspub/runtime/config"; +import { createDatabaseResources } from "@hackerspub/runtime/resources"; + +const database = loadDatabaseConfig(getDenoEnvironment()); +const { db, postgres } = createDatabaseResources(database); try { const result = await db.transaction((tx) => repairBrokenLinkPreviews(tx)); diff --git a/test/database.ts b/test/database.ts new file mode 100644 index 00000000..31515f22 --- /dev/null +++ b/test/database.ts @@ -0,0 +1,8 @@ +import { createDatabaseResources } from "@hackerspub/runtime/resources"; + +const url = Deno.env.get("DATABASE_URL"); +if (url == null || url.trim() === "") { + throw new Error("DATABASE_URL is required by the PostgreSQL test fixture"); +} + +export const { db, postgres } = createDatabaseResources({ url }); diff --git a/test/package-boundaries.test.ts b/test/package-boundaries.test.ts index 96c29717..3d55b675 100644 --- a/test/package-boundaries.test.ts +++ b/test/package-boundaries.test.ts @@ -22,9 +22,15 @@ const workspaceDirectories = [ "asset-cdn", "federation", "models", + "runtime", "web-next", ] as const; -const corePackageDirectories = ["ai", "federation", "models"] as const; +const corePackageDirectories = [ + "ai", + "federation", + "models", + "runtime", +] as const; async function readJson(url: URL): Promise { return JSON.parse(await Deno.readTextFile(url)) as T; diff --git a/test/postgres.ts b/test/postgres.ts index 88a9fdb6..89c666b3 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -2,8 +2,12 @@ import { assert } from "@std/assert/assert"; import type { RequestContext } from "@fedify/fedify"; import { Organization, Person } from "@fedify/vocab"; import { sql } from "drizzle-orm"; -import type { ContextData } from "@hackerspub/models/context"; -import type { Transaction } from "@hackerspub/models/db"; +import { + type ApplicationContext, + type ContextData, + defineApplicationModel, +} from "@hackerspub/models/context"; +import type { Database, Transaction } from "@hackerspub/models/db"; import type { Transport } from "@upyo/core"; import { MockLanguageModelV3 } from "ai/test"; import { @@ -22,7 +26,7 @@ import { } from "@hackerspub/models/schema"; import { generateUuidV7 } from "@hackerspub/models/uuid"; import type { Uuid } from "@hackerspub/models/uuid"; -import { db } from "../graphql/db.ts"; +import { db } from "./database.ts"; import type { UserContext } from "../graphql/builder.ts"; import { services } from "./services.ts"; @@ -512,13 +516,13 @@ const stubAuthenticatedDocumentLoader = () => ), ); -export function createFedCtx( - tx: Transaction, +export function createFedCtx( + tx: D, options: { kv?: UserContext["kv"]; lookupObject?: FedCtxLookupObject; } = {}, -): RequestContext { +): RequestContext> & ApplicationContext { const kv = options.kv ?? createTestKv().kv; const lookupObject: FedCtxLookupObject = options.lookupObject ?? (() => { throw new Error( @@ -528,17 +532,60 @@ export function createFedCtx( ); }); + const disk = createTestDisk(); + const unavailableModel = defineApplicationModel(undefined, "unavailable"); + const models: ContextData["models"] = { + summarizer: unavailableModel, + translator: unavailableModel, + moderationAnalyzer: unavailableModel, + }; + const data: ContextData = { + db: tx, + kv: kv as unknown as ContextData["kv"], + disk, + models, + services, + }; const fedCtx = { host: "localhost", origin: "http://localhost/", canonicalOrigin: "http://localhost/", - data: { - db: tx, - kv: kv as unknown as ContextData["kv"], - disk: createTestDisk(), - models: {} as ContextData["models"], - services, + data, + get db() { + return data.db; + }, + set db(value) { + data.db = value; + }, + withDatabase( + this: RequestContext & ApplicationContext, + db: Database, + ) { + const cloned = this.clone({ ...this.data, db }) as + & RequestContext + & ApplicationContext; + cloned.db = db; + (cloned as unknown as { federation: object }).federation = cloned; + return cloned; }, + get kv() { + return data.kv; + }, + get storage() { + return data.disk; + }, + get models() { + return data.models; + }, + set models(value) { + data.models = value; + }, + get services() { + return data.services; + }, + federation: {}, + documentLoader: undefined as never, + contextLoader: undefined as never, getActorUri(identifier: string) { return new URL(`/actors/${identifier}`, "http://localhost/"); }, @@ -559,8 +606,11 @@ export function createFedCtx( getFeaturedUri(identifier: string) { return new URL(`/actors/${identifier}/featured`, "http://localhost/"); }, - async getActor(identifier: string) { - const account = await tx.query.accountTable.findFirst({ + async getActor( + this: RequestContext>, + identifier: string, + ) { + const account = await this.data.db.query.accountTable.findFirst({ where: { id: identifier as Uuid }, columns: { id: true, kind: true, username: true }, }); @@ -583,19 +633,24 @@ export function createFedCtx( ); }, getDocumentLoader() { - return Promise.resolve(stubAuthenticatedDocumentLoader); + return stubAuthenticatedDocumentLoader; }, lookupObject, + lookupWebFinger() { + return Promise.resolve(null); + }, sendActivity() { return Promise.resolve(undefined); }, clone(this: RequestContext, data: ContextData) { return { ...this, + db: data.db, data, }; }, - } as unknown as RequestContext; + } as unknown as RequestContext> & ApplicationContext; + (fedCtx as unknown as { federation: object }).federation = fedCtx; return fedCtx; } @@ -625,6 +680,7 @@ export function makeUserContext( kv, disk: createTestDisk() as UserContext["disk"], email, + emailFrom: "noreply@example.com", fedCtx, request: new Request("http://localhost/graphql"), session: { @@ -651,6 +707,7 @@ export function makeGuestContext( kv, disk: createTestDisk() as UserContext["disk"], email, + emailFrom: "noreply@example.com", fedCtx, request: new Request("http://localhost/graphql"), session: undefined, diff --git a/test/services.ts b/test/services.ts index 129d885a..a45ec4f1 100644 --- a/test/services.ts +++ b/test/services.ts @@ -1,10 +1,9 @@ -import type { Context } from "@fedify/fedify"; import { aiServices } from "@hackerspub/ai/services"; import { federationServices } from "@hackerspub/federation/services"; -import type { ContextData } from "@hackerspub/models/context"; +import type { ApplicationContext } from "@hackerspub/models/context"; import type { ApplicationServices } from "@hackerspub/models/services"; export const services = { ai: aiServices, federation: federationServices, -} satisfies ApplicationServices>; +} satisfies ApplicationServices; diff --git a/web/ai.ts b/web/ai.ts index 4ee252bf..8d86e658 100644 --- a/web/ai.ts +++ b/web/ai.ts @@ -1,9 +1,15 @@ -import { anthropic } from "@ai-sdk/anthropic"; -import { google } from "@ai-sdk/google"; +import type { createAiModels } from "@hackerspub/runtime/resources"; -// TODO: make model IDs configurable via env vars so they can be swapped -// when preview models are promoted or deprecated. -export const altTextGenerator = google("gemini-3.1-flash-lite-preview"); -export const summarizer = google("gemini-3-flash-preview"); -export const translator = anthropic("claude-sonnet-4-6"); -export const moderationAnalyzer = anthropic("claude-sonnet-4-6"); +type AiModels = ReturnType; + +export let altTextGenerator: AiModels["altTextGenerator"]; +export let summarizer: AiModels["summarizer"]; +export let translator: AiModels["translator"]; +export let moderationAnalyzer: AiModels["moderationAnalyzer"]; + +export function configureAiModels(models: AiModels): void { + altTextGenerator = models.altTextGenerator; + summarizer = models.summarizer; + translator = models.translator; + moderationAnalyzer = models.moderationAnalyzer; +} diff --git a/web/composition.test.ts b/web/composition.test.ts new file mode 100644 index 00000000..1b2ceb96 --- /dev/null +++ b/web/composition.test.ts @@ -0,0 +1,36 @@ +import { assert, assertStringIncludes } from "@std/assert"; + +Deno.test("legacy web owns the federation queue lifecycle", async () => { + const source = await Deno.readTextFile(new URL("main.ts", import.meta.url)); + + assertStringIncludes(source, "manuallyStartQueue: true"); + assertStringIncludes(source, "await runWithFederationQueue("); + assertStringIncludes(source, "(signal) => app.listen({ signal })"); + assertStringIncludes(source, "Deno.addSignalListener(signalName, listener)"); + assertStringIncludes(source, "removeSignalListeners()"); + assertStringIncludes(source, "{ signal: controller.signal }"); + assert( + source.indexOf("await runWithFederationQueue(") < + source.indexOf("await resources.close()"), + "the federation queue must stop before its resources close", + ); +}); + +Deno.test("Fresh development starts the federation queue but static builds do not", async () => { + const source = await Deno.readTextFile(new URL("dev.ts", import.meta.url)); + const buildBranch = source.slice( + source.indexOf('if (Deno.args.includes("build"))'), + source.indexOf("} else {"), + ); + const listenBranch = source.slice(source.indexOf("} else {")); + + assertStringIncludes(buildBranch, "await closeWebResources()"); + assert( + !buildBranch.includes("runWebServer"), + "static builds must not start the federation queue", + ); + assertStringIncludes( + listenBranch, + "await runWebServer((signal) => builder.listen(app, { signal }))", + ); +}); diff --git a/web/db.ts b/web/db.ts index 6d6d7104..4fdda4fb 100644 --- a/web/db.ts +++ b/web/db.ts @@ -1,26 +1,13 @@ import type { Database } from "@hackerspub/models/db"; -import { relations } from "@hackerspub/models/relations"; -import { getLogger as getDatabaseLogger } from "@logtape/drizzle-orm"; -import { getLogger } from "@logtape/logtape"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgresJs from "postgres"; -import "./logging.ts"; +import type { Sql } from "postgres"; -const DATABASE_URL = Deno.env.get("DATABASE_URL"); -if (DATABASE_URL == null) { - throw new Error("Missing DATABASE_URL environment variable."); -} +export let db: Database; +export let postgres: Sql; -export const postgres = postgresJs(DATABASE_URL, { - // The pool size needs to exceed the ParallelMessageQueue concurrency (10) - // to leave headroom for HTTP handlers and KV store queries. The default - // of 10 can cause connection starvation under federation load. - max: 20, -}); -export const db: Database = drizzle({ - relations, - client: postgres, - logger: getDatabaseLogger(), -}); -getLogger(["hackerspub", "db"]) - .debug("The driver is ready: {driver}", { driver: db.constructor }); +export function configureDatabase(resources: { + readonly db: Database; + readonly postgres: Sql; +}): void { + db = resources.db; + postgres = resources.postgres; +} diff --git a/web/dev.ts b/web/dev.ts index 8ecc600e..db0a87a4 100644 --- a/web/dev.ts +++ b/web/dev.ts @@ -2,12 +2,16 @@ import { tailwind } from "@fresh/plugin-tailwind"; import { Builder } from "@fresh/core/dev"; -import { app } from "./main.ts"; +import { app, closeWebResources, runWebServer } from "./main.ts"; const builder = new Builder(); tailwind(builder, app, {}); if (Deno.args.includes("build")) { - await builder.build(app); + try { + await builder.build(app); + } finally { + await closeWebResources(); + } } else { - await builder.listen(app); + await runWebServer((signal) => builder.listen(app, { signal })); } diff --git a/web/drive.ts b/web/drive.ts index c74482cc..c3a356f7 100644 --- a/web/drive.ts +++ b/web/drive.ts @@ -1,72 +1,7 @@ -import { DriveManager } from "flydrive"; -import { FSDriver } from "flydrive/drivers/fs"; -import { S3Driver } from "flydrive/drivers/s3"; +import type { DriveResource } from "@hackerspub/runtime/resources"; -const DRIVE_DISK = Deno.env.get("DRIVE_DISK"); -if (DRIVE_DISK == null) { - throw new Error("Missing DRIVE_DISK environment variable."); -} else if (DRIVE_DISK !== "fs" && DRIVE_DISK !== "s3") { - throw new Error("Invalid DRIVE_DISK environment variable; must be fs or s3."); -} +export let drive: DriveResource; -const ORIGIN = Deno.env.get("ORIGIN"); -if (ORIGIN == null) { - throw new Error("Missing ORIGIN environment variable."); +export function configureDrive(resource: DriveResource): void { + drive = resource; } - -export const drive = new DriveManager({ - default: DRIVE_DISK, - services: { - fs() { - const FS_LOCATION = Deno.env.get("FS_LOCATION"); - if (FS_LOCATION == null) { - throw new Error("Missing FS_LOCATION environment variable."); - } - return new FSDriver({ - location: new URL(FS_LOCATION, import.meta.url), - visibility: "public", - urlBuilder: { - generateURL(key: string) { - const url = new URL(`/media/${key}`, ORIGIN); - return Promise.resolve(url.href); - }, - generateSignedURL(key: string) { - const url = new URL(`/media/${key}`, ORIGIN); - return Promise.resolve(url.href); - }, - }, - }); - }, - s3() { - const AWS_ACCESS_KEY_ID = Deno.env.get("AWS_ACCESS_KEY_ID"); - if (AWS_ACCESS_KEY_ID == null) { - throw new Error("Missing AWS_ACCESS_KEY_ID environment variable."); - } - const AWS_SECRET_ACCESS_KEY = Deno.env.get("AWS_SECRET_ACCESS_KEY"); - if (AWS_SECRET_ACCESS_KEY == null) { - throw new Error("Missing AWS_SECRET_ACCESS_KEY environment variable."); - } - const AWS_REGION = Deno.env.get("AWS_REGION"); - if (AWS_REGION == null) { - throw new Error("Missing AWS_REGION environment variable."); - } - const S3_BUCKET = Deno.env.get("S3_BUCKET"); - if (S3_BUCKET == null) { - throw new Error("Missing S3_BUCKET environment variable."); - } - const S3_ENDPOINT = Deno.env.get("S3_ENDPOINT"); - const S3_CDN_URL = Deno.env.get("S3_CDN_URL"); - return new S3Driver({ - credentials: { - accessKeyId: AWS_ACCESS_KEY_ID, - secretAccessKey: AWS_SECRET_ACCESS_KEY, - }, - endpoint: S3_ENDPOINT, - cdnUrl: S3_CDN_URL, - region: AWS_REGION, - bucket: S3_BUCKET, - visibility: "public", - }); - }, - }, -}); diff --git a/web/email.ts b/web/email.ts index c9a0e66f..a9225d91 100644 --- a/web/email.ts +++ b/web/email.ts @@ -1,26 +1,4 @@ -import { getLogger } from "@logtape/logtape"; -import Mailgun from "@schotsl/mailgun"; -import type { Transport } from "@upyo/core"; -import { MailgunTransport } from "@upyo/mailgun"; - -const logger = getLogger(["hackerspub", "email"]); - -function getEnv(variable: string): string { - const val = Deno.env.get(variable); - if (val == null) throw new Error(`Missing environment variable: ${variable}`); - return val; -} - -const MAILGUN_KEY = getEnv("MAILGUN_KEY"); -const MAILGUN_REGION = getEnv("MAILGUN_REGION"); -const MAILGUN_DOMAIN = getEnv("MAILGUN_DOMAIN"); -const MAILGUN_FROM = Deno.env.get("EMAIL_FROM") ?? getEnv("MAILGUN_FROM"); - -const mailgun = new Mailgun({ - key: MAILGUN_KEY, - region: MAILGUN_REGION === "eu" ? "eu" : "us", - domain: MAILGUN_DOMAIN, -}); +import { createMessage, type Transport } from "@upyo/core"; export interface Email { to: string; @@ -28,14 +6,22 @@ export interface Email { text: string; } -export async function sendEmail(email: Email): Promise { - const params = { from: MAILGUN_FROM, ...email }; - logger.debug("Sending email... {*}", params); - await mailgun.send(params); +export let transport: Transport; +let emailFrom: string; + +export function configureEmail(resource: Transport, from: string): void { + transport = resource; + emailFrom = from; } -export const transport: Transport = new MailgunTransport({ - apiKey: MAILGUN_KEY, - domain: MAILGUN_DOMAIN, - region: MAILGUN_REGION === "eu" ? "eu" : "us", -}); +export async function sendEmail(email: Email): Promise { + const receipt = await transport.send(createMessage({ + from: emailFrom, + to: email.to, + subject: email.subject, + content: { text: email.text }, + })); + if (!receipt.successful) { + throw new Error(receipt.errorMessages.join("; ")); + } +} diff --git a/web/federation.ts b/web/federation.ts index 9a702546..c34d0cbd 100644 --- a/web/federation.ts +++ b/web/federation.ts @@ -1,52 +1,16 @@ -import { PostgresKvStore, PostgresMessageQueue } from "@fedify/postgres"; -import { RedisKvStore } from "@fedify/redis"; -import { builder } from "@hackerspub/federation"; -import { getLogger } from "@logtape/logtape"; -import { Redis } from "ioredis"; -import { postgres } from "./db.ts"; -import metadata from "./deno.json" with { type: "json" }; -import { kvUrl } from "./kv.ts"; +import type { createFederationResource } from "@hackerspub/runtime/resources"; -const logger = getLogger(["hackerspub", "federation"]); +type Federation = Awaited< + ReturnType +>["federation"]; -const origin = Deno.env.get("ORIGIN"); -if (origin == null) { - throw new Error("Missing ORIGIN environment variable."); -} else if (!origin.startsWith("https://") && !origin.startsWith("http://")) { - throw new Error("ORIGIN must start with http:// or https://"); -} -export const ORIGIN = origin; - -const kv = kvUrl.protocol === "redis:" - ? new RedisKvStore( - new Redis(kvUrl.href, { - family: kvUrl.hostname.endsWith(".upstash.io") ? 6 : 4, - }), - ) - : new PostgresKvStore(postgres); -logger.debug("KV store initialized: {kv}", { kv }); +export let federation: Federation; +export let ORIGIN: string; -// Raise the message handler timeout above the 60-second default: inbox -// handlers may legitimately need several bounded remote fetches, and the -// default tripped on slow federation peers (see GRAPHQL-1H). The real fix is -// bounding each remote fetch (see `withDocumentLoaderTimeout` in -// `@hackerspub/models/post`); this is headroom so transient slowness no longer -// surfaces as a handler timeout. -const queue = new PostgresMessageQueue(postgres, { - handlerTimeout: { seconds: 180 }, -}); -logger.debug("Message queue initialized: {queue}", { queue }); - -export const federation = await builder.build({ - kv, - queue, - origin: ORIGIN, - // TODO: Revert to Fedify's default RFC 9421-first behavior once - // https://github.com/bonfire-networks/activity_pub/issues/8 is fixed and - // released. - firstKnock: "draft-cavage-http-signatures-12", - userAgent: { - software: `HackersPub/${metadata.version}`, - url: new URL(ORIGIN), - }, -}); +export function configureFederation( + resource: Federation, + origin: URL, +): void { + federation = resource; + ORIGIN = origin.href; +} diff --git a/web/kv.ts b/web/kv.ts index dcd1eae3..381ade8b 100644 --- a/web/kv.ts +++ b/web/kv.ts @@ -1,22 +1,7 @@ -import KeyvRedis from "@keyv/redis"; -import { KeyvFile } from "keyv-file"; -import Keyv from "keyv"; +import type Keyv from "keyv"; -const KV_URL = Deno.env.get("KV_URL"); -if (KV_URL == null) { - throw new Error("Missing KV_URL environment variable."); -} else if (!URL.canParse(KV_URL)) { - throw new Error("Invalid KV_URL environment variable; must be a valid URL."); -} +export let kv: Keyv; -export const kvUrl = new URL(KV_URL); -if (kvUrl.protocol !== "file:" && kvUrl.protocol !== "redis:") { - throw new Error( - "Invalid KV_URL environment variable; must start with file: or redis:", - ); +export function configureKeyValue(resource: Keyv): void { + kv = resource; } - -export const kvAdaptor = kvUrl.protocol === "file:" - ? new KeyvFile({ filename: kvUrl.pathname }) - : new KeyvRedis(KV_URL); -export const kv = new Keyv(kvAdaptor); diff --git a/web/main.ts b/web/main.ts index 2665bb51..3b1fc306 100644 --- a/web/main.ts +++ b/web/main.ts @@ -7,6 +7,7 @@ import { trailingSlashes, } from "@fresh/core"; import { type Context, createYogaServer } from "@hackerspub/graphql"; +import { toApplicationContext } from "@hackerspub/federation/context"; import { handleMediumUploadProxy } from "@hackerspub/graphql/medium-upload"; import { ActorSuspendedError, @@ -26,19 +27,29 @@ import { import "@std/dotenv/load"; import { getCookies } from "@std/http/cookie"; import { serveDir } from "@std/http/file-server"; -import * as models from "./ai.ts"; -import { db } from "./db.ts"; -import { drive } from "./drive.ts"; -import { transport as email } from "./email.ts"; -import { federation } from "./federation.ts"; +import { fromFileUrl } from "@std/path/from-file-url"; +import { + getDenoEnvironment, + loadServerConfig, +} from "@hackerspub/runtime/config"; +import { + createRuntimeResources, + runWithFederationQueue, +} from "@hackerspub/runtime/resources"; +import { configureAiModels } from "./ai.ts"; +import { configureDatabase } from "./db.ts"; +import { configureDrive } from "./drive.ts"; +import { configureEmail } from "./email.ts"; +import { configureFederation } from "./federation.ts"; import { makeQueryGraphQL } from "./graphql/gql.ts"; -import { kv } from "./kv.ts"; +import { configureKeyValue } from "./kv.ts"; import "./logging.ts"; import { services } from "./services.ts"; import type { State } from "./utils.ts"; import assetlinks from "../graphql/static/.well-known/assetlinks.json" with { type: "json", }; +import metadata from "./deno.json" with { type: "json" }; const appleAppSiteAssociationJson = Deno.readTextFileSync( new URL( "../graphql/static/.well-known/apple-app-site-association", @@ -46,6 +57,28 @@ const appleAppSiteAssociationJson = Deno.readTextFileSync( ), ); +const resources = await createRuntimeResources( + loadServerConfig(getDenoEnvironment()), + metadata.version, + { + fileSystemBaseUrl: new URL("./", import.meta.url), + federation: { + manuallyStartQueue: true, + // TODO: Revert to Fedify's default RFC 9421-first behavior once + // https://github.com/bonfire-networks/activity_pub/issues/8 is fixed + // and released. + firstKnock: "draft-cavage-http-signatures-12", + }, + }, +); +const { db, drive, email, federation, kv, models } = resources; +configureDatabase(resources); +configureDrive(drive); +configureEmail(email, resources.config.email.from); +configureFederation(federation, resources.config.origin); +configureKeyValue(kv); +configureAiModels(models); + export const app = new App(); const staticHandler = staticFiles(); const yogaServer = createYogaServer(); @@ -75,22 +108,21 @@ app.use(async (ctx) => { return await staticHandler(ctx); }); -if (Deno.env.get("DRIVE_DISK") === "fs") { - const FS_LOCATION = Deno.env.get("FS_LOCATION"); - if (FS_LOCATION == null) { - throw new Error("Missing FS_LOCATION environment variable."); +if (resources.config.storage.driver === "fs") { + const fileSystemRoot = drive.fileSystemRoot; + if (fileSystemRoot == null) { + throw new TypeError("The filesystem drive has no resolved root path."); } - app.use((ctx) => { if (!ctx.url.pathname.startsWith("/media/")) return ctx.next(); return serveDir(ctx.req, { urlRoot: "media", - fsRoot: FS_LOCATION, + fsRoot: fromFileUrl(fileSystemRoot), }); }); } -if (Deno.env.get("BEHIND_PROXY") === "true") { +if (resources.config.behindProxy) { app.use(async (ctx) => { // @ts-ignore: Fresh will fix https://github.com/denoland/fresh/pull/2751 ctx.req = await getXForwardedRequest(ctx.req); @@ -226,13 +258,16 @@ app.use(async (ctx) => { kv, disk, email, - fedCtx: federation.createContext(ctx.req, { - db, - kv, - disk, - models, - services, - }), + emailFrom: resources.config.email.from, + fedCtx: toApplicationContext( + federation.createContext(ctx.req, { + db, + kv, + disk, + models, + services, + }), + ), session: await ctx.state.sessionPromise?.then(({ session }) => session), account: await ctx.state.sessionPromise?.then(({ account }) => account), request: ctx.req, @@ -257,6 +292,50 @@ await fsRoutes(app, { loadRoute: (path) => import(`./routes/${path}`), }); +export async function runWebServer( + runServer: (signal: AbortSignal) => Promise, + options: { readonly signal?: AbortSignal } = {}, +): Promise { + const disk = drive.use(); + try { + await runWithFederationQueue( + federation, + { db, kv, disk, models, services }, + runServer, + options, + ); + } finally { + await resources.close(); + } +} + +export function closeWebResources(): Promise { + return resources.close(); +} + if (import.meta.main) { - await app.listen(); + const controller = new AbortController(); + const signalListeners = new Map<"SIGINT" | "SIGTERM", () => void>(); + const removeSignalListeners = () => { + for (const [signalName, listener] of signalListeners) { + Deno.removeSignalListener(signalName, listener); + } + signalListeners.clear(); + }; + try { + for (const signalName of ["SIGINT", "SIGTERM"] as const) { + const listener = () => { + removeSignalListeners(); + controller.abort(); + }; + Deno.addSignalListener(signalName, listener); + signalListeners.set(signalName, listener); + } + await runWebServer( + (signal) => app.listen({ signal }), + { signal: controller.signal }, + ); + } finally { + removeSignalListeners(); + } } diff --git a/web/routes/@[username]/[idOrYear]/[slug]/index.tsx b/web/routes/@[username]/[idOrYear]/[slug]/index.tsx index a51f90ae..3faf3dc7 100644 --- a/web/routes/@[username]/[idOrYear]/[slug]/index.tsx +++ b/web/routes/@[username]/[idOrYear]/[slug]/index.tsx @@ -197,7 +197,7 @@ export async function handleArticle( db, summarizer, content, - ctx.state.fedCtx.data.services.ai.summarize, + ctx.state.fedCtx.services.ai.summarize, ); } ctx.state.metas.push( diff --git a/web/routes/_middleware.ts b/web/routes/_middleware.ts index 9309b3b7..6587c9b6 100644 --- a/web/routes/_middleware.ts +++ b/web/routes/_middleware.ts @@ -1,3 +1,4 @@ +import { toApplicationContext } from "@hackerspub/federation/context"; import { isLocale, type Locale, @@ -21,13 +22,15 @@ import { define } from "../utils.ts"; export const handler = define.middleware([ (ctx) => { - const fedCtx = federation.createContext(ctx.req, { - db, - kv, - disk: drive.use(), - models, - services, - }); + const fedCtx = toApplicationContext( + federation.createContext(ctx.req, { + db, + kv, + disk: drive.use(), + models, + services, + }), + ); ctx.state.fedCtx = fedCtx; ctx.state.canonicalOrigin = fedCtx.canonicalOrigin; return ctx.next(); diff --git a/web/routes/search.tsx b/web/routes/search.tsx index b316345e..17cd527c 100644 --- a/web/routes/search.tsx +++ b/web/routes/search.tsx @@ -1,9 +1,8 @@ -import type { Context } from "@fedify/fedify"; import { isActor } from "@fedify/vocab"; import type * as vocab from "@fedify/vocab"; import { page } from "@fresh/core"; import { persistActor } from "@hackerspub/models/actor"; -import type { ContextData } from "@hackerspub/models/context"; +import type { ApplicationContext } from "@hackerspub/models/context"; import type { RelationsFilter } from "@hackerspub/models/db"; import { getCensoredPostExclusionFilter, @@ -40,7 +39,7 @@ import { db } from "../db.ts"; import { define } from "../utils.ts"; async function searchHandle( - fedCtx: Context, + fedCtx: ApplicationContext, account?: Account, keyword?: string | null, ): Promise { @@ -90,7 +89,7 @@ async function searchHandle( } async function searchUrl( - fedCtx: Context, + fedCtx: ApplicationContext, account?: Account, keyword?: string | null, ): Promise { diff --git a/web/services.ts b/web/services.ts index 129d885a..a45ec4f1 100644 --- a/web/services.ts +++ b/web/services.ts @@ -1,10 +1,9 @@ -import type { Context } from "@fedify/fedify"; import { aiServices } from "@hackerspub/ai/services"; import { federationServices } from "@hackerspub/federation/services"; -import type { ContextData } from "@hackerspub/models/context"; +import type { ApplicationContext } from "@hackerspub/models/context"; import type { ApplicationServices } from "@hackerspub/models/services"; export const services = { ai: aiServices, federation: federationServices, -} satisfies ApplicationServices>; +} satisfies ApplicationServices; diff --git a/web/utils.ts b/web/utils.ts index 488e821b..e9a69d52 100644 --- a/web/utils.ts +++ b/web/utils.ts @@ -1,7 +1,6 @@ /// -import type { RequestContext } from "@fedify/fedify"; import { createDefine } from "@fresh/core"; -import type { ContextData } from "@hackerspub/models/context"; +import type { ApplicationContext } from "@hackerspub/models/context"; import type { Locale } from "@hackerspub/models/i18n"; import type { Account, @@ -53,7 +52,7 @@ export interface State { links: AccountLink[]; }; canonicalOrigin: string; - fedCtx: RequestContext; + fedCtx: ApplicationContext; language: Language; locales: Locale[]; t: ReturnType;