From 2506518d765add6518abe44da64063f6809e6018 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 16:58:05 +0900 Subject: [PATCH 01/12] Decouple application services from runtime Move server configuration and resource construction into a shared runtime package, and expose runtime-neutral application contexts to the model layer. Recompose the legacy web and GraphQL entry points around those resources, preserve federation queue lifecycles and transactional context binding, and register the package in Deno, pnpm, and container build manifests. Assisted-by: Codex:gpt-5.6-sol --- Dockerfile | 4 + ai/moderation.ts | 15 +- ai/services.ts | 30 +- ai/summary.ts | 10 +- ai/translate.ts | 14 +- deno.json | 7 +- deno.lock | 125 ++++--- federation/context.test.ts | 27 ++ federation/context.ts | 103 ++++++ federation/deno.json | 1 + federation/inbox/actor.ts | 20 +- federation/inbox/flag.ts | 4 +- federation/inbox/following.ts | 15 +- federation/inbox/quote.ts | 19 +- federation/inbox/subscribe.ts | 39 +- federation/objects.test.ts | 6 +- federation/objects.ts | 21 +- federation/person.ts | 3 +- federation/services.ts | 42 ++- graphql/ai.ts | 10 +- graphql/builder.ts | 6 +- graphql/composition.test.ts | 17 + graphql/db.ts | 27 +- graphql/drive.ts | 73 +--- graphql/email-helpers.ts | 23 +- graphql/email.ts | 26 +- graphql/federation.ts | 56 +-- graphql/hashtag.ts | 14 +- graphql/invitation-link.ts | 1 + graphql/invite.ts | 1 + graphql/kv.ts | 23 +- graphql/login.ts | 8 +- graphql/main.ts | 48 ++- graphql/moderation-appeal.test.ts | 5 +- graphql/moderation-email.ts | 7 +- graphql/moderation-notification.test.ts | 4 +- graphql/moderation.ts | 12 +- graphql/poll.test.ts | 7 +- graphql/post.more.test.ts | 9 - graphql/post.ts | 6 +- graphql/query-tx-plugin.test.ts | 58 ++- graphql/query-tx-plugin.ts | 24 +- graphql/services.ts | 5 +- graphql/worker.ts | 41 ++- models/account.ts | 28 +- models/actor.ts | 22 +- models/appeal.test.ts | 6 +- models/article.background.test.ts | 29 +- models/article.lifecycle.test.ts | 29 +- models/article.ts | 65 ++-- models/blocking.ts | 16 +- models/context.ts | 99 +++++- models/flag.test.ts | 6 +- models/flag.ts | 6 +- models/following.ts | 17 +- models/markup.ts | 16 +- models/medium.ts | 20 +- models/moderation-action.test.ts | 4 +- models/moderation-notification.test.ts | 6 +- models/moderation.test.ts | 7 +- models/moderation.ts | 7 +- models/note.lifecycle.test.ts | 29 +- models/note.ts | 25 +- models/notification.test.ts | 2 +- models/organization.test.ts | 13 +- models/organization.ts | 21 +- models/package.json | 2 +- models/pin.ts | 15 +- models/poll.ts | 14 +- models/post.remote.test.ts | 16 +- models/post.sync.test.ts | 7 +- models/post.ts | 99 +++--- models/question.lifecycle.test.ts | 21 +- models/question.ts | 20 +- models/reaction.ts | 22 +- models/relay.test.ts | 4 +- models/relay.ts | 15 +- models/services.ts | 18 +- models/tx.test.ts | 69 ++-- models/tx.ts | 23 +- pnpm-lock.yaml | 247 ++++++++++++- pnpm-workspace.yaml | 13 + runtime/config.test.ts | 119 +++++++ runtime/config.ts | 274 ++++++++++++++ runtime/deno.json | 8 + runtime/imports.test.ts | 20 ++ runtime/package.json | 28 ++ runtime/resources.test.ts | 99 ++++++ runtime/resources.ts | 333 ++++++++++++++++++ scripts/addaccount.test.ts | 40 +++ scripts/addaccount.ts | 23 +- scripts/repair-link-previews.ts | 9 +- test/database.ts | 8 + test/package-boundaries.test.ts | 8 +- test/postgres.ts | 89 ++++- test/services.ts | 5 +- web/ai.ts | 22 +- web/composition.test.ts | 33 ++ web/db.ts | 33 +- web/dev.ts | 10 +- web/drive.ts | 73 +--- web/email.ts | 50 +-- web/federation.ts | 62 +--- web/kv.ts | 23 +- web/main.ts | 96 +++-- .../@[username]/[idOrYear]/[slug]/index.tsx | 2 +- web/routes/_middleware.ts | 17 +- web/routes/search.tsx | 7 +- web/services.ts | 5 +- web/utils.ts | 5 +- 110 files changed, 2522 insertions(+), 1073 deletions(-) create mode 100644 federation/context.test.ts create mode 100644 federation/context.ts create mode 100644 graphql/composition.test.ts create mode 100644 runtime/config.test.ts create mode 100644 runtime/config.ts create mode 100644 runtime/deno.json create mode 100644 runtime/imports.test.ts create mode 100644 runtime/package.json create mode 100644 runtime/resources.test.ts create mode 100644 runtime/resources.ts create mode 100644 scripts/addaccount.test.ts create mode 100644 test/database.ts create mode 100644 web/composition.test.ts diff --git a/Dockerfile b/Dockerfile index d3253b1c8..e38f3cb4f 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 9fd0ab3c5..584bfaf44 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 971633fe7..421c57d19 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 ba822cf2b..97763cac9 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 688d1cf5f..64ebf256b 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 e18610a0a..86a47ad8f 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 b94a9b603..9895e680c 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 000000000..520d0cbe1 --- /dev/null +++ b/federation/context.test.ts @@ -0,0 +1,27 @@ +import { assertStrictEquals } from "@std/assert"; +import type { Context } from "@fedify/fedify"; +import type { + ApplicationContext, + ContextData, +} from "@hackerspub/models/context"; +import { getFedifyContext } from "./context.ts"; + +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); +}); diff --git a/federation/context.ts b/federation/context.ts new file mode 100644 index 000000000..c565ec3d5 --- /dev/null +++ b/federation/context.ts @@ -0,0 +1,103 @@ +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) => + toApplicationContext(context.clone({ ...context.data, db })), + 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 b2801c362..68c5bd737 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 f180d0461..e7b32c55a 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 54f721415..d800569ae 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 1b9ebec34..ca9c3a4b2 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 b31bbb117..d49cedaeb 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 032c16622..cd92304f4 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 952aaf0c0..f7ad05c0d 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 cfda27566..7253f9358 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 39b0f6b88..a814928ff 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 902c17f60..8b858ea62 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 a23f2a6ac..d461e1852 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 109334672..ef3523c05 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 000000000..8cd9a6140 --- /dev/null +++ b/graphql/composition.test.ts @@ -0,0 +1,17 @@ +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, 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 6d6d71049..bdd1f5154 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 c74482cc6..2b422454b 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 6e37c34b5..fb8adcd90 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 bcc32360d..db12ff7ee 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 bdb318182..e5f1d63d5 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 feb004c97..78cd27413 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 d28a53d25..885961ba6 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 21241ab7c..f727667bb 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 dcd1eae3f..057d0ef3b 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 43feee88d..a7d43833f 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 d848e87ee..7e07e1bf3 100644 --- a/graphql/main.ts +++ b/graphql/main.ts @@ -1,30 +1,41 @@ // 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) => { +const server = Deno.serve({ port: 8080 }, async (req, info) => { try { req = await getXForwardedRequest(req); const url = new URL(req.url); @@ -56,13 +67,16 @@ Deno.serve({ port: 8080 }, async (req, info) => { kv, disk, email, - fedCtx: federation.createContext(req, { - db, - kv, - disk, - models, - services, - }), + emailFrom: resources.config.email.from, + fedCtx: toApplicationContext( + federation.createContext(req, { + db, + kv, + disk, + models, + services, + }), + ), request: req, connectionInfo: info, }); @@ -74,3 +88,9 @@ Deno.serve({ port: 8080 }, async (req, info) => { throw e; } }); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + Deno.addSignalListener(signal, () => server.shutdown()); +} +await server.finished; +await resources.close(); diff --git a/graphql/moderation-appeal.test.ts b/graphql/moderation-appeal.test.ts index ea7bd8134..f03205cf1 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 e5b956bd9..9970d99e4 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 3cc959f4a..3edb73aec 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 ef9b89dfc..3fe713b78 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 0e6db92df..3ce73a035 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 91f139efe..50d526bd0 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 323fd971c..b5c0458e2 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 dbba7ec12..1d8856a1b 100644 --- a/graphql/query-tx-plugin.test.ts +++ b/graphql/query-tx-plugin.test.ts @@ -25,12 +25,35 @@ interface StubDb { ): Promise; } +interface StubApplicationContext { + readonly db: unknown; + 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 payload: OnExecutePayload; registeredExecute: ExecuteFn | undefined; innerExecuteSawCtxDb?: unknown; @@ -49,15 +72,20 @@ function buildHarness( return await cb({ id: "tx" }); }, }; - 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, registeredExecute: undefined, payload: { args: { @@ -69,10 +97,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 +137,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 +148,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, @@ -239,10 +273,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 +317,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 +357,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 +404,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 aad61235c..02878f3a8 100644 --- a/graphql/query-tx-plugin.ts +++ b/graphql/query-tx-plugin.ts @@ -72,20 +72,18 @@ export function useQuerySnapshotTransaction( // 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 +91,12 @@ 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); try { return await wrappedExecute(innerArgs); } finally { liveCtx.db = originalDb; - fedData.db = originalFedDb; + liveCtx.fedCtx = originalFedCtx; } }, { isolationLevel: "repeatable read" }, diff --git a/graphql/services.ts b/graphql/services.ts index 129d885ab..a45ec4f13 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 eb1e46753..866b73094 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"]); @@ -148,12 +160,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 +211,12 @@ 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."); +try { + await federation.startQueue( + { db, kv, disk, models, services }, + { signal: controller.signal }, + ); + logger.info("The federation message queue worker has stopped."); +} finally { + await resources.close(); +} diff --git a/models/account.ts b/models/account.ts index 8eed7268c..0b7c3ba44 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,10 +383,10 @@ function toDeletedAccountRecipient(actor: Actor): vocab.Recipient | null { } async function ensureAccountKeys( - fedCtx: RequestContext, + fedCtx: ApplicationContext, accountId: Uuid, ): Promise { - const context = fedCtx as RequestContext & { + const context = fedCtx as ApplicationContext & { getActorKeyPairs?: (identifier: string) => Promise; }; if (typeof context.getActorKeyPairs !== "function") return; @@ -394,11 +394,11 @@ async function ensureAccountKeys( } 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 +598,7 @@ export async function deleteAccount( } export async function getAvatarUrl( - disk: Disk, + disk: StorageService, account: Account & { emails: AccountEmail[]; avatarMedium?: Medium | null; @@ -636,7 +636,7 @@ async function preprocessAvatarMedium( export async function createAvatarMediumFromBlob( db: Database, - disk: Disk, + disk: StorageService, blob: Blob, options: { maxSize?: number } = {}, ): Promise { @@ -648,7 +648,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 +660,7 @@ export async function createAvatarMediumFromUrl( export async function createAvatarMediumFromMedium( db: Database, - disk: Disk, + disk: StorageService, medium: Medium, options: { maxSize?: number } = {}, ): Promise { @@ -710,10 +710,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 +735,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 0a012e69d..cca2ec56e 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 6b6b3f54e..faa1f26bf 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 946acd90a..3ed6d3537 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 027af475d..f23840e0b 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 5f0393558..9c4b04b17 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 4c36c532e..4f3b273ea 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 e6b1ad784..22b6a5fcd 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 c2aaec550..00cb2798d 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 89249e01e..b6f5aaecd 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 71b391bb9..3aa8ec097 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 9e98b443d..d358fe46e 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 c76a0a265..d8b171203 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 fbf1eb0fd..dd433a156 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 3a8252130..51b44c8e8 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 66aa75669..fad0787c1 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 4a8442882..e47935bf0 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 cd1ebe2a2..5e61d8092 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 04c1fde9c..db389d1c9 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 11251de08..c31581e7c 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 01c2d82db..915dbe027 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 d0559a108..de4585e97 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 b74de11a4..9b7683b78 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 6f794eb33..6ae637edf 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 39bedc9bb..cd8a8db36 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 9717f009d..c7650ab19 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 6eb409b1e..d964e1469 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 bf2a1bd80..0231e1b55 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, @@ -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,11 +1643,11 @@ 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); + await ctx.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 @@ -1734,9 +1730,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 +1762,7 @@ export async function persistPost( } export async function persistSharedPost( - ctx: Context, + ctx: ApplicationContext, announce: vocab.Announce, options: { actor?: Actor & { instance: Instance }; @@ -1790,7 +1791,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 +2050,7 @@ async function getOriginalQuoteTarget( } export async function sharePost( - fedCtx: Context, + fedCtx: ApplicationContext, account: Account & { avatarMedium: Medium | null; emails: AccountEmail[]; @@ -2058,7 +2059,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 +2116,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 +2145,7 @@ export async function sharePost( } export async function unsharePost( - fedCtx: Context, + fedCtx: ApplicationContext, account: Account & { avatarMedium: Medium | null; emails: AccountEmail[]; @@ -2152,7 +2153,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 +2177,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 +2996,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 +3075,7 @@ export async function revokeQuote( } async function sendLocalQuoteAuthorizationDelete( - fedCtx: Context, + fedCtx: ApplicationContext, account: Account, quote: QuoteUpdatePost, quoteAuthorizationIri: string, @@ -3107,7 +3108,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 +3129,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 +3143,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 +3196,7 @@ async function sendLocalQuoteUpdate( }, ); } - const relayedTags = await fedCtx.data.services.federation + const relayedTags = await fedCtx.services.federation .sendTagsPubRelayActivity( fedCtx, quote.actor.accountId, @@ -3208,7 +3209,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 +3217,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 +3370,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 +3393,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 +3695,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 +3706,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 df42fc052..d4dc3f518 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 c18e75fc3..e30374be7 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 54e177774..cb4aa189a 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 0733ef6ff..c0fd780bc 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 b6fb71852..603536a9f 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 2bc2227d0..1c2cd7a2a 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 8d8ee421a..db554b9eb 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 6f6c9b496..d08fb8d50 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 53ef39ea7..425381567 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 7926b058d..5290c9e43 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 000000000..d16325d76 --- /dev/null +++ b/runtime/config.test.ts @@ -0,0 +1,119 @@ +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 falls back to mock email when Mailgun is unconfigured", () => { + const { CI: _ci, EMAIL_FROM: _emailFrom, ...withoutEmail } = required; + const config = loadServerConfig(withoutEmail); + + 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, + MAILGUN_FROM: "legacy@hackers.pub", + MAILGUN_REGION: "us", + }); + + assertEquals(config.email, { + transport: "mock", + from: "noreply@hackers.pub", + reason: "mailgun-unconfigured", + }); +}); + +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 000000000..66ffddaf9 --- /dev/null +++ b/runtime/config.ts @@ -0,0 +1,274 @@ +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 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) { + 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: env.MODE ?? "production", + }; +} + +export function getDenoEnvironment(): Environment { + return Deno.env.toObject(); +} diff --git a/runtime/deno.json b/runtime/deno.json new file mode 100644 index 000000000..2a134b6e5 --- /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 000000000..c8c354d66 --- /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 000000000..ed9fcd0cb --- /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 000000000..b929e5c0e --- /dev/null +++ b/runtime/resources.test.ts @@ -0,0 +1,99 @@ +import { assertEquals, assertInstanceOf } from "@std/assert"; +import { MockTransport } from "@upyo/mock"; +import { + createEmailResource, + 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("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("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 000000000..74b33d510 --- /dev/null +++ b/runtime/resources.ts @@ -0,0 +1,333 @@ +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 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: config.url.pathname }) + : 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 new URL(location, baseUrl); +} + +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"; +} + +export async function runWithFederationQueue( + federation: FederationQueueRunner, + contextData: TContextData, + runServer: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + 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)), + ); + + const first = await Promise.race([queueCompletion, serverCompletion]); + controller.abort(); + const [queue, server] = await Promise.all([ + queueCompletion, + serverCompletion, + ]); + + if (!first.successful) throw first.error; + if (first.source === "queue") { + throw new Error("The federation queue stopped before the server."); + } + if (!server.successful) throw server.error; + if (!queue.successful && !isAbortError(queue.error)) throw queue.error; +} + +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; +} + +export async function createRuntimeResources( + config: ServerConfig, + softwareVersion: string, + options: RuntimeResourceOptions, +): Promise { + const { postgres, db } = createDatabaseResources(config.database); + const kv = createKeyValueResource(config.kv); + const drive = createDriveResource( + config.storage, + config.origin, + options.fileSystemBaseUrl, + ); + const email = createEmailResource(config.email); + const models = createAiModels(config.ai); + try { + const federationResources = await createFederationResource( + config, + postgres, + softwareVersion, + options.federation, + ); + const { federation } = federationResources; + return { + config, + postgres, + db, + kv, + drive, + email, + models, + federation, + async close() { + await federationResources.close(); + await kv.disconnect(); + await postgres.end(); + }, + }; + } catch (error) { + await kv.disconnect(); + await postgres.end(); + throw error; + } +} diff --git a/scripts/addaccount.test.ts b/scripts/addaccount.test.ts new file mode 100644 index 000000000..3add3ad4d --- /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 af8338a81..356fd7c2e 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 7fa37a198..8658a44f2 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 000000000..31515f226 --- /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 96c297174..3d55b675f 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 88a9fdb68..89c666b37 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 129d885ab..a45ec4f13 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 4ee252bf6..8d86e6588 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 000000000..b60714028 --- /dev/null +++ b/web/composition.test.ts @@ -0,0 +1,33 @@ +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 })"); + 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 6d6d71049..4fdda4fb0 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 8ecc600ee..db0a87a4c 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 c74482cc6..c3a356f7c 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 c9a0e66f6..a9225d911 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 9a702546e..c34d0cbde 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 dcd1eae3f..381ade8b2 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 2665bb51e..57a564b94 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,25 @@ await fsRoutes(app, { loadRoute: (path) => import(`./routes/${path}`), }); +export async function runWebServer( + runServer: (signal: AbortSignal) => Promise, +): Promise { + const disk = drive.use(); + try { + await runWithFederationQueue( + federation, + { db, kv, disk, models, services }, + runServer, + ); + } finally { + await resources.close(); + } +} + +export function closeWebResources(): Promise { + return resources.close(); +} + if (import.meta.main) { - await app.listen(); + await runWebServer((signal) => app.listen({ signal })); } diff --git a/web/routes/@[username]/[idOrYear]/[slug]/index.tsx b/web/routes/@[username]/[idOrYear]/[slug]/index.tsx index a51f90ae6..3faf3dc73 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 9309b3b7a..486b60542 100644 --- a/web/routes/_middleware.ts +++ b/web/routes/_middleware.ts @@ -3,6 +3,7 @@ import { type Locale, normalizeLocale, } from "@hackerspub/models/i18n"; +import { toApplicationContext } from "@hackerspub/federation/context"; import { acceptsLanguages } from "@std/http/negotiation"; import * as models from "../ai.ts"; import { db } from "../db.ts"; @@ -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 b316345ee..17cd527c7 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 129d885ab..a45ec4f13 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 488e821b8..e9a69d522 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; From 05dc976c890845764cb0670aa5455196dcaf9771 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 17:25:23 +0900 Subject: [PATCH 02/12] Order middleware imports consistently Place the federation context import before the models import to keep the external package group in alphabetical order. https://github.com/hackers-pub/hackerspub/pull/353#pullrequestreview-4682933854 Assisted-by: Codex:gpt-5.6-sol --- web/routes/_middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/routes/_middleware.ts b/web/routes/_middleware.ts index 486b60542..6587c9b6c 100644 --- a/web/routes/_middleware.ts +++ b/web/routes/_middleware.ts @@ -1,9 +1,9 @@ +import { toApplicationContext } from "@hackerspub/federation/context"; import { isLocale, type Locale, normalizeLocale, } from "@hackerspub/models/i18n"; -import { toApplicationContext } from "@hackerspub/federation/context"; import { acceptsLanguages } from "@std/http/negotiation"; import * as models from "../ai.ts"; import { db } from "../db.ts"; From 4364dab6cc835d0286ba8c62a8052dc37ecdef2c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 17:35:48 +0900 Subject: [PATCH 03/12] Harden runtime resource handling Convert file URLs through platform path helpers, recognize both DOM and Error abort failures, and make construction and shutdown attempt every cleanup without losing the triggering error. Remove signal listeners when GraphQL processes stop. https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569059718 https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569059745 https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569059748 https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569059754 https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569059761 https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569072339 https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569072355 Assisted-by: Codex:gpt-5.6-sol --- graphql/main.ts | 148 ++++++++++++++++++++++++-------------- graphql/worker.ts | 36 +++++++++- runtime/resources.test.ts | 36 ++++++++++ runtime/resources.ts | 63 +++++++++++----- 4 files changed, 212 insertions(+), 71 deletions(-) diff --git a/graphql/main.ts b/graphql/main.ts index 7e07e1bf3..c201a789f 100644 --- a/graphql/main.ts +++ b/graphql/main.ts @@ -35,62 +35,106 @@ const { db, drive, email, federation, kv, models } = resources; // 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). -const server = 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" }, +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, + 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; } - 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 }, - }); + }); + +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); } - return await yogaServer.fetch(req, { - altTextGenerator: models.altTextGenerator, - db, - kv, - disk, - 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 }); + registeredSignals.clear(); + }; + const shutdown = () => { + removeSignalListeners(); + void server.shutdown(); + }; + try { + for (const signal of signals) { + Deno.addSignalListener(signal, shutdown); + registeredSignals.add(signal); } - throw e; + await server.finished; + } finally { + removeSignalListeners(); } -}); +} catch (error) { + runtimeFailed = true; + runtimeError = error; +} -for (const signal of ["SIGINT", "SIGTERM"] as const) { - Deno.addSignalListener(signal, () => server.shutdown()); +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; } -await server.finished; -await resources.close(); +if (closeFailed) throw closeError; diff --git a/graphql/worker.ts b/graphql/worker.ts index 866b73094..9ad25889b 100644 --- a/graphql/worker.ts +++ b/graphql/worker.ts @@ -37,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 @@ -211,12 +217,36 @@ Deno.cron("send-daily-notification-digests", "5 0 * * *", { // independently). const disk = drive.use(); logger.info("Starting the federation message queue worker."); +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."); -} finally { +} 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) { + logger.error( + "Failed to close runtime resources after the queue failed: {error}", + { error: closeError }, + ); + } + throw queueError; } +if (closeFailed) throw closeError; diff --git a/runtime/resources.test.ts b/runtime/resources.test.ts index b929e5c0e..e17bb6609 100644 --- a/runtime/resources.test.ts +++ b/runtime/resources.test.ts @@ -1,7 +1,10 @@ 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, @@ -17,6 +20,20 @@ Deno.test("resolveFileSystemStorageLocation resolves relative paths from the com ); }); +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({ @@ -59,6 +76,25 @@ Deno.test("runWithFederationQueue aborts and awaits the queue before returning", ]); }); +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("createEmailResource warns when Mailgun is unconfigured", () => { const warnings: string[] = []; const transport = createEmailResource( diff --git a/runtime/resources.ts b/runtime/resources.ts index 74b33d510..8fca4373c 100644 --- a/runtime/resources.ts +++ b/runtime/resources.ts @@ -23,6 +23,8 @@ 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"; @@ -48,7 +50,7 @@ export function createDatabaseResources( export function createKeyValueResource(config: KeyValueConfig): Keyv { const adapter = config.url.protocol === "file:" - ? new KeyvFile({ filename: config.url.pathname }) + ? new KeyvFile({ filename: fileURLToPath(config.url) }) : new KeyvRedis(config.url.href); return new Keyv(adapter); } @@ -62,7 +64,7 @@ export function resolveFileSystemStorageLocation( location: string, baseUrl: URL, ): URL { - return new URL(location, baseUrl); + return pathToFileURL(resolve(fileURLToPath(baseUrl), location)); } export function createDriveResource( @@ -147,7 +149,10 @@ type LifecycleCompletion = }; function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === "AbortError"; + return ( + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError") + ); } export async function runWithFederationQueue( @@ -288,27 +293,49 @@ export interface RuntimeResourceOptions { 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 kv = createKeyValueResource(config.kv); - const drive = createDriveResource( - config.storage, - config.origin, - options.fileSystemBaseUrl, - ); - const email = createEmailResource(config.email); - const models = createAiModels(config.ai); + 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, @@ -320,14 +347,18 @@ export async function createRuntimeResources( models, federation, async close() { - await federationResources.close(); - await kv.disconnect(); - await postgres.end(); + await closeResourceHandles(cleanups); }, }; } catch (error) { - await kv.disconnect(); - await postgres.end(); + try { + await closeResourceHandles(cleanups); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Failed to create and clean up runtime resources.", + ); + } throw error; } } From 1a10d881aa6a54211a842082d37af70974af722a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 17:36:17 +0900 Subject: [PATCH 04/12] Fix reply backfill lock expiry Express the reply backfill lock timeout in milliseconds so Keyv keeps the lock for five minutes instead of expiring it almost immediately. https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569059728 Assisted-by: Codex:gpt-5.6-sol --- models/post.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models/post.ts b/models/post.ts index 0231e1b55..783173749 100644 --- a/models/post.ts +++ b/models/post.ts @@ -101,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; @@ -1647,7 +1647,7 @@ export async function persistPost( if (locked !== "1") { // Best-effort dedupe lock: avoid spawning multiple backfills for // the same post during bursty inbox traffic. - await ctx.kv.set(lockKey, "1", REPLIES_BACKFILL_LOCK_TTL_SECONDS); + await ctx.kv.set(lockKey, "1", REPLIES_BACKFILL_LOCK_TTL_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 From 3e2e4b013260dae86cec4a68d98d542b603e7599 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 17:36:29 +0900 Subject: [PATCH 05/12] Simplify account key initialization Use the optional key-pair capability already declared by ApplicationContext instead of widening the same type with a redundant cast. https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569059763 Assisted-by: Codex:gpt-5.6-sol --- models/account.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/models/account.ts b/models/account.ts index 0b7c3ba44..c53100e41 100644 --- a/models/account.ts +++ b/models/account.ts @@ -386,11 +386,8 @@ async function ensureAccountKeys( fedCtx: ApplicationContext, accountId: Uuid, ): Promise { - const context = fedCtx as ApplicationContext & { - 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( From 522c6843c4c5ca46f3bf1bc538b655d09811265a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 17:36:39 +0900 Subject: [PATCH 06/12] Verify instrumentation import presence Assert that each GraphQL composition root imports the Sentry setup before checking its order relative to LogTape initialization. https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569072318 Assisted-by: Codex:gpt-5.6-sol --- graphql/composition.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/graphql/composition.test.ts b/graphql/composition.test.ts index 8cd9a6140..86d33bbef 100644 --- a/graphql/composition.test.ts +++ b/graphql/composition.test.ts @@ -8,6 +8,7 @@ for (const compositionRoot of ["main.ts", "worker.ts"]) { const instrumentImport = 'import "./instrument.ts";'; const loggingImport = 'import "./logging.ts";'; + assertStringIncludes(source, instrumentImport); assertStringIncludes(source, loggingImport); assert( source.indexOf(instrumentImport) < source.indexOf(loggingImport), From 89e2647c528875d5b08fd31e5911ad48998de12a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 18:26:53 +0900 Subject: [PATCH 07/12] Defer query background work until commit Expose the root database and an after-commit task queue while GraphQL queries use their snapshot transaction. Deferred federation work can then rebind its capabilities to the root database after the transaction has closed. Route reply backfills through the same queue so they cannot retain a completed transaction context. https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569383799 Assisted-by: Codex:gpt-5.6-sol --- graphql/query-tx-plugin.test.ts | 39 ++++++++- graphql/query-tx-plugin.ts | 17 +++- models/post.ts | 148 +++++++++++++++++--------------- 3 files changed, 133 insertions(+), 71 deletions(-) diff --git a/graphql/query-tx-plugin.test.ts b/graphql/query-tx-plugin.test.ts index 1d8856a1b..fe728e88a 100644 --- a/graphql/query-tx-plugin.test.ts +++ b/graphql/query-tx-plugin.test.ts @@ -27,6 +27,8 @@ interface StubDb { interface StubApplicationContext { readonly db: unknown; + readonly rootDb?: unknown; + readonly afterCommit?: Array<() => Promise | void>; withDatabase(db: unknown): StubApplicationContext; lookupObject(value: URL): Promise; } @@ -54,6 +56,7 @@ interface Harness { >; readonly fedData: StubApplicationContext; readonly lookupState: { db?: unknown }; + readonly transactionState: { open: boolean }; readonly payload: OnExecutePayload; registeredExecute: ExecuteFn | undefined; innerExecuteSawCtxDb?: unknown; @@ -65,11 +68,17 @@ 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 lookupState: { db?: unknown } = {}; @@ -86,6 +95,7 @@ function buildHarness( txCalls, fedData, lookupState, + transactionState, registeredExecute: undefined, payload: { args: { @@ -168,6 +178,33 @@ 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 restores db handles when execute throws", async () => { const plugin = useQuerySnapshotTransaction(); const h = buildHarness("query Q { __typename }", "Q"); diff --git a/graphql/query-tx-plugin.ts b/graphql/query-tx-plugin.ts index 02878f3a8..658aa28c0 100644 --- a/graphql/query-tx-plugin.ts +++ b/graphql/query-tx-plugin.ts @@ -1,3 +1,4 @@ +import type { AfterCommitTask } from "@hackerspub/models/context"; import type { Database } from "@hackerspub/models/db"; import { type DocumentNode, Kind, type OperationDefinitionNode } from "graphql"; import type { Plugin as EnvelopPlugin } from "graphql-yoga"; @@ -65,8 +66,10 @@ 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 @@ -91,7 +94,12 @@ export function useQuerySnapshotTransaction( // every method Pothos and resolvers call. const txAsDb = tx as unknown as Database; liveCtx.db = txAsDb; - liveCtx.fedCtx = originalFedCtx.withDatabase(txAsDb); + liveCtx.fedCtx = { + ...originalFedCtx.withDatabase(txAsDb), + db: txAsDb, + rootDb, + afterCommit, + }; try { return await wrappedExecute(innerArgs); } finally { @@ -108,6 +116,11 @@ 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) await task(); + return result; } }); }, diff --git a/models/post.ts b/models/post.ts index 783173749..320d3a4c2 100644 --- a/models/post.ts +++ b/models/post.ts @@ -1648,77 +1648,89 @@ export async function persistPost( // Best-effort dedupe lock: avoid spawning multiple backfills for // the same post during bursty inbox traffic. await ctx.kv.set(lockKey, "1", REPLIES_BACKFILL_LOCK_TTL_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 ?? ctx.documentLoader, - ), - suppressError: true, + 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, + }, + ); }); }); } From 38d2da4bc41cf60255650aec2fed9fb1ddf4a449 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 18:54:16 +0900 Subject: [PATCH 08/12] Gracefully stop the legacy web server Forward process shutdown signals through the shared lifecycle so the HTTP server and federation queue stop together before their database and Redis resources close. Remove signal listeners after shutdown so a second signal retains its normal termination behavior. Cover externally initiated shutdown when the queue reports an abort error and the server finishes normally. https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569552002 https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569552010 https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569552014 Assisted-by: Codex:gpt-5.6-sol --- runtime/resources.test.ts | 37 ++++++++++++++++++++++++++++++ runtime/resources.ts | 47 ++++++++++++++++++++++++++++++--------- web/composition.test.ts | 3 +++ web/main.ts | 27 +++++++++++++++++++++- 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/runtime/resources.test.ts b/runtime/resources.test.ts index e17bb6609..f1c656530 100644 --- a/runtime/resources.test.ts +++ b/runtime/resources.test.ts @@ -95,6 +95,43 @@ Deno.test("runWithFederationQueue accepts Error-shaped abort failures", async () 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( diff --git a/runtime/resources.ts b/runtime/resources.ts index 8fca4373c..250d1f906 100644 --- a/runtime/resources.ts +++ b/runtime/resources.ts @@ -159,8 +159,19 @@ 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, @@ -178,19 +189,33 @@ export async function runWithFederationQueue( Promise.resolve().then(() => runServer(controller.signal)), ); - const first = await Promise.race([queueCompletion, serverCompletion]); - controller.abort(); - const [queue, server] = await Promise.all([ - queueCompletion, - serverCompletion, - ]); + try { + const first = await Promise.race([queueCompletion, serverCompletion]); + controller.abort(); + const [queue, server] = await Promise.all([ + queueCompletion, + serverCompletion, + ]); - if (!first.successful) throw first.error; - if (first.source === "queue") { - throw new Error("The federation queue stopped before the server."); + 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); } - if (!server.successful) throw server.error; - if (!queue.successful && !isAbortError(queue.error)) throw queue.error; } export interface WarningLogger { diff --git a/web/composition.test.ts b/web/composition.test.ts index b60714028..1b2ceb964 100644 --- a/web/composition.test.ts +++ b/web/composition.test.ts @@ -6,6 +6,9 @@ Deno.test("legacy web owns the federation queue lifecycle", async () => { 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()"), diff --git a/web/main.ts b/web/main.ts index 57a564b94..3b1fc306f 100644 --- a/web/main.ts +++ b/web/main.ts @@ -294,6 +294,7 @@ await fsRoutes(app, { export async function runWebServer( runServer: (signal: AbortSignal) => Promise, + options: { readonly signal?: AbortSignal } = {}, ): Promise { const disk = drive.use(); try { @@ -301,6 +302,7 @@ export async function runWebServer( federation, { db, kv, disk, models, services }, runServer, + options, ); } finally { await resources.close(); @@ -312,5 +314,28 @@ export function closeWebResources(): Promise { } if (import.meta.main) { - await runWebServer((signal) => app.listen({ signal })); + 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(); + } } From 858ba4934a4caf2d226390629a94ec92c418953b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 19:48:04 +0900 Subject: [PATCH 09/12] Preserve transaction state when rebinding Carry the active root database and after-commit queue into cloned Fedify contexts. This keeps nested database rebinding inside the transaction boundary instead of falling back to adapter construction-time state. https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569721371 Assisted-by: Codex:gpt-5.6-sol --- federation/context.test.ts | 49 +++++++++++++++++++++++++++++++++++++- federation/context.ts | 10 ++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/federation/context.test.ts b/federation/context.test.ts index 520d0cbe1..75316414c 100644 --- a/federation/context.test.ts +++ b/federation/context.test.ts @@ -1,10 +1,35 @@ import { assertStrictEquals } from "@std/assert"; import type { Context } from "@fedify/fedify"; import type { + AfterCommitTask, ApplicationContext, ContextData, } from "@hackerspub/models/context"; -import { getFedifyContext } from "./context.ts"; +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"]; @@ -25,3 +50,25 @@ Deno.test("Fedify adapter state survives application context cloning", () => { 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 index c565ec3d5..1743c1c68 100644 --- a/federation/context.ts +++ b/federation/context.ts @@ -51,8 +51,14 @@ export function toApplicationContext( : undefined; const applicationContext: ApplicationContext = { db: context.data.db, - withDatabase: (db) => - toApplicationContext(context.clone({ ...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, From 0d1f5e4f78314e405870f618cfe2ece864243f87 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 19:48:39 +0900 Subject: [PATCH 10/12] Require Mailgun in production Allow the mock email transport only in CI and explicit non-production modes. Production startup now rejects missing Mailgun credentials instead of accepting messages that will never be delivered. https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3569822766 Assisted-by: Codex:gpt-5.6-sol --- runtime/config.test.ts | 18 ++++++++++++++++-- runtime/config.ts | 8 ++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/runtime/config.test.ts b/runtime/config.test.ts index d16325d76..5051195b8 100644 --- a/runtime/config.test.ts +++ b/runtime/config.test.ts @@ -47,9 +47,9 @@ Deno.test("loadServerConfig parses runtime configuration without reading Deno.en }); }); -Deno.test("loadServerConfig falls back to mock email when Mailgun is unconfigured", () => { +Deno.test("loadServerConfig uses mock email in development without Mailgun", () => { const { CI: _ci, EMAIL_FROM: _emailFrom, ...withoutEmail } = required; - const config = loadServerConfig(withoutEmail); + const config = loadServerConfig({ ...withoutEmail, MODE: "development" }); assertEquals(config.email, { transport: "mock", @@ -62,6 +62,7 @@ Deno.test("loadServerConfig ignores sender and region without Mailgun credential const { CI: _ci, ...withoutCi } = required; const config = loadServerConfig({ ...withoutCi, + MODE: "development", MAILGUN_FROM: "legacy@hackers.pub", MAILGUN_REGION: "us", }); @@ -73,6 +74,19 @@ Deno.test("loadServerConfig ignores sender and region without Mailgun credential }); }); +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( diff --git a/runtime/config.ts b/runtime/config.ts index 66ffddaf9..d7802c54f 100644 --- a/runtime/config.ts +++ b/runtime/config.ts @@ -195,6 +195,7 @@ export function loadServerConfig(env: Environment): ServerConfig { 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, @@ -206,7 +207,10 @@ export function loadServerConfig(env: Environment): ServerConfig { from: configuredFrom || defaultFrom, reason: "ci", }; - } else if (!mailgunSelected) { + } else if ( + !mailgunSelected && + (mode === "development" || mode === "test" || mode === "build") + ) { email = { transport: "mock", from: configuredFrom || defaultFrom, @@ -265,7 +269,7 @@ export function loadServerConfig(env: Environment): ServerConfig { moderationModel: env.AI_MODERATION_MODEL ?? "claude-sonnet-4-6", }, behindProxy: env.BEHIND_PROXY?.toLowerCase() === "true", - mode: env.MODE ?? "production", + mode, }; } From 7405583f874f5d7d1a0cb50109749874047d0e06 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 20:10:30 +0900 Subject: [PATCH 11/12] Preserve worker shutdown failures Report queue and resource cleanup failures together so neither cause is lost when both operations fail during worker shutdown. https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3570078943 Assisted-by: Codex:gpt-5.6-sol --- graphql/worker.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphql/worker.ts b/graphql/worker.ts index 9ad25889b..ac8481bb0 100644 --- a/graphql/worker.ts +++ b/graphql/worker.ts @@ -242,9 +242,9 @@ try { } if (queueFailed) { if (closeFailed) { - logger.error( - "Failed to close runtime resources after the queue failed: {error}", - { error: closeError }, + throw new AggregateError( + [queueError, closeError], + "The federation queue worker failed and its resources could not be closed.", ); } throw queueError; From a39a57ae1af6a65ac626bec112988c3c3f506905 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 13 Jul 2026 20:11:19 +0900 Subject: [PATCH 12/12] Isolate query after-commit failures Log each deferred task failure and continue draining the queue after the query transaction commits. A background failure can no longer replace a successful GraphQL result or prevent later tasks from running. https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3570078947 https://github.com/hackers-pub/hackerspub/pull/353#discussion_r3570078955 Assisted-by: Codex:gpt-5.6-sol --- graphql/query-tx-plugin.test.ts | 27 +++++++++++++++++++++++++++ graphql/query-tx-plugin.ts | 12 +++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/graphql/query-tx-plugin.test.ts b/graphql/query-tx-plugin.test.ts index fe728e88a..245ef3577 100644 --- a/graphql/query-tx-plugin.test.ts +++ b/graphql/query-tx-plugin.test.ts @@ -205,6 +205,33 @@ test("useQuerySnapshotTransaction defers work until after commit", async () => { 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"); diff --git a/graphql/query-tx-plugin.ts b/graphql/query-tx-plugin.ts index 658aa28c0..d05fcce5f 100644 --- a/graphql/query-tx-plugin.ts +++ b/graphql/query-tx-plugin.ts @@ -1,5 +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"; @@ -42,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 && @@ -119,7 +121,15 @@ export function useQuerySnapshotTransaction( // 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) await task(); + for (const task of afterCommit) { + try { + await task(); + } catch (error) { + logger.error("Failed to run after-commit task: {error}", { + error, + }); + } + } return result; } });