diff --git a/AGENTS.md b/AGENTS.md index f777891c9..826b175de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -748,6 +748,9 @@ example, `storage.bunnycdn.com` or `uk.storage.bunnycdn.com`). - `STORAGE_ZONE_KEY` - Bunny CDN storage zone access key (required for image uploads) - `BACKUP_PAGE_SIZE` - Rows read per keyset page when dumping a table for backup (default 500). Each page is one libsql response, so this bounds the response size to stay under libsqld's "Response is too large" payload cap. Used by `deno task backup` and the admin Backups page; migrations no longer back up inline (the edge subrequest budget can't fit a full dump), so backups are taken out-of-band. - `MAIN_INSTANCE_KEY` - Shared secret authorizing the inter-instance site-credentials endpoint (`POST /instance/site-credentials`). When set on a builder/main instance, that endpoint returns built sites' DB URL + token to a caller presenting this key as a bearer token, so the upgrade workflow can back each site up to the builder's storage before deploying. The returned token is each site's own full-access credential (the same one the site runs with) — callers only read, but must treat the response as write-capable production secrets. The caller passes the release tier it is publishing as `?tier=alpha|beta|release` (a tier-less call defaults to `release` ⇒ the whole fleet, which is what the single-site `backup-site` action relies on); each site carries an `updates` channel and only the sites at that tier or more eager are returned (a `release` deploy reaches every site, `beta` reaches beta + alpha sites, `alpha` only alpha sites — an unknown tier is a 400). The response echoes the applied `tier` so a caller can confirm the server actually filtered: a pre-tier build ignores the query string and omits it, letting the canary workflow fail closed instead of fanning a non-release deploy out to the whole fleet. Unset `MAIN_INSTANCE_KEY` ⇒ the endpoint is disabled (404). The upgrade workflow receives the key as a run-time input, not a stored GitHub secret. +- `DENO_DEPLOY_TOKEN` - Deno Deploy organization access token. Required with `DENO_DEPLOY_ORG_ID` and `DENO_DEPLOY_ORG_SLUG` to build sites on Deno Deploy. +- `DENO_DEPLOY_ORG_ID` - Deno Deploy organization ID used by the app creation API. +- `DENO_DEPLOY_ORG_SLUG` - Deno Deploy organization slug used in each app's managed `..deno.net` production domain. - `BUNNY_DNS_ZONE_ID` - Bunny DNS zone ID for subdomain registration (enables subdomain feature when set with `BUNNY_API_KEY`) - `BUNNY_DNS_SUBDOMAIN_SUFFIX` - Suffix appended to user-chosen subdomain (e.g. `.tickets`) - `NTFY_URL` - Ntfy endpoint URL for error notifications (e.g. `https://ntfy.sh/your-topic`). Sends domain and error code only, no personal or encrypted data. diff --git a/scripts/mutation/equivalent-mutants.txt b/scripts/mutation/equivalent-mutants.txt index 0ce680129..995606147 100644 --- a/scripts/mutation/equivalent-mutants.txt +++ b/scripts/mutation/equivalent-mutants.txt @@ -46,10 +46,11 @@ src/ui/templates/admin/holidays.tsx:79:45 ?? → || # values is a render-valu src/shared/checkout-pricing.ts:85:35 ?? → || # Map sum: 0 ?? 0 === 0 || 0 src/shared/checkout-pricing.ts:334:41 ?? → || # intent.modifiers: an array is always truthy src/shared/logistics-filter.ts:22:35 ?? → || # raw: string|null, only falsy string "" === fallback "" -src/shared/config.ts:150:39 ?? → || # getEnv(): string|undefined, only falsy string "" === fallback "" -src/shared/config.ts:163:61 ?? → || # getEnv(): string|undefined, only falsy string "" === fallback "" -src/shared/config.ts:170:33 ?? → || # getEnv(): string|undefined, only falsy string "" === fallback "" -src/shared/config.ts:177:33 ?? → || # getEnv(): string|undefined, only falsy string "" === fallback "" +src/shared/config.ts:166:39 ?? → || # getEnv(): string|undefined, only falsy string "" === fallback "" +src/shared/config.ts:36:5 ?? → || # providerValue returns boolean or null, and false stays false with either fallback operator +src/shared/config.ts:179:61 ?? → || # getEnv(): string|undefined, only falsy string "" === fallback "" +src/shared/config.ts:186:33 ?? → || # getEnv(): string|undefined, only falsy string "" === fallback "" +src/shared/config.ts:193:33 ?? → || # getEnv(): string|undefined, only falsy string "" === fallback "" src/shared/qr-token.ts:60:18 ?? → || # input.date?: string, only falsy string "" === fallback "" src/shared/qr-token.ts:62:18 ?? → || # input.name?: string, only falsy string "" === fallback "" src/shared/app-forms.ts:71:18 ?? → || # config.auth is an AuthPolicy object when present, so it is always truthy @@ -79,7 +80,7 @@ src/shared/accounting/manual-entries.ts:209:29 ?? → || # guard error messag src/shared/checkout-ledger.ts:35:72 ?? → || # find(...)?.amount: number|undefined; the only falsy-non-null amount is 0 and 0 ?? 0 === 0 || 0 src/features/settings-bundles.ts:247:40 ?? → || # bundle is a readonly string[] or undefined; every present array, including [], is truthy src/shared/scheduled-access.ts:16:20 ?? → || # the optional regex capture is non-empty because the capture uses +, so only undefined reaches the null fallback -src/shared/maintenance/runner.ts:113:40 ?? → || # wakePolicy is a non-empty MaintenanceWakePolicy value or undefined, so only undefined reaches the fallback +src/shared/maintenance/runner.ts:181:23 ?? → || # wakePolicy is a non-empty MaintenanceWakePolicy value or undefined, so only undefined reaches the fallback src/shared/builder.ts:201:44 ?? → || # dbToken is string|undefined and the fallback is "", so its only falsy string already equals the fallback src/shared/builder.ts:202:35 ?? → || # dbProvider is a non-empty provider value or undefined, so only undefined reaches the fallback src/shared/builder.ts:207:36 ?? → || # dbProvider is a non-empty provider value or undefined, so only undefined reaches the fallback @@ -88,7 +89,7 @@ src/features/url.ts:58:35 ?? → || # URLSearchParams.get returns null or a s src/shared/storage.ts:376:18 application/octet-stream → "" # the pinned Bunny SDK normalizes an empty contentType to application/octet-stream, as the request-level upload test proves src/shared/site-build.ts:39:21 = → += # retainedId starts at 0 and buildSite invokes retain exactly once, so both assignments store row.id src/shared/db/built-site-scheduler.ts:11:36 ?? → || # scheduledTaskKey is null or a non-empty canonical key, so both operators select the candidate only when no key exists -src/shared/deno-deploy-api.ts:85:34 ?? → || # env_vars is a record object when present, and every object is truthy +src/shared/deno-deploy-api.ts:139:30 ?? → || # failure_reason is null or a non-empty picklist value, so both use the status only for null src/shared/bunny-cdn.ts:477:42 ?? → || # DefaultHostname is string|undefined; its only falsy string is already the same empty fallback src/shared/bunny-cdn.ts:529:50 ?? → || # Secrets is an array|null, and every present array is truthy while null takes the fallback either way src/shared/subrequest-budget.ts:38:40 ?? → || # scoped counts is an object when present, and every object is truthy diff --git a/src/shared/config.ts b/src/shared/config.ts index 303ab41e5..12a357fec 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -212,9 +212,11 @@ export const isInstanceApiEnabled = (): boolean => /** The shared secret authorizing the inter-instance site-credentials endpoint. */ export const getMainInstanceKey = (): string => requireEnv("MAIN_INSTANCE_KEY"); -/** Check if Deno Deploy hosting is enabled (requires DENO_DEPLOY_TOKEN and DENO_DEPLOY_ORG_ID). */ +/** Check if Deno Deploy hosting has its token, organization ID, and domain slug. */ export const isDenoDeployEnabled = (): boolean => - !!getEnv("DENO_DEPLOY_TOKEN") && !!getEnv("DENO_DEPLOY_ORG_ID"); + !!getEnv("DENO_DEPLOY_TOKEN") && + !!getEnv("DENO_DEPLOY_ORG_ID") && + !!getEnv("DENO_DEPLOY_ORG_SLUG"); /** Get the Deno Deploy API token from environment. */ export const getDenoDeployToken = (): string => requireEnv("DENO_DEPLOY_TOKEN"); @@ -223,6 +225,10 @@ export const getDenoDeployToken = (): string => requireEnv("DENO_DEPLOY_TOKEN"); export const getDenoDeployOrgId = (): string => requireEnv("DENO_DEPLOY_ORG_ID"); +/** Get the Deno Deploy organization slug used in managed production domains. */ +export const getDenoDeployOrgSlug = (): string => + requireEnv("DENO_DEPLOY_ORG_SLUG"); + /** Get the default database provider from DEFAULT_DB_HOST env var. Returns "turso" when set to "turso", "bunny" otherwise. */ export const getDefaultDbProvider = (): "bunny" | "turso" => getEnv("DEFAULT_DB_HOST") === "turso" ? "turso" : "bunny"; diff --git a/src/shared/db/built-sites/blob.ts b/src/shared/db/built-sites/blob.ts index 1bfb561e4..7f0313d23 100644 --- a/src/shared/db/built-sites/blob.ts +++ b/src/shared/db/built-sites/blob.ts @@ -35,8 +35,11 @@ const siteDataFields = { u: v.string(), }; -const SiteDataBlobSchema = v.variant("v", [ - v.strictObject({ ...siteDataFields, v: v.literal(1) }), +const SiteDataBlobSchema = v.union([ + v.strictObject({ + ...siteDataFields, + v: v.optional(v.literal(1), 1), + }), v.strictObject({ ...siteDataFields, sk: v.optional(v.pipe(v.string(), v.check(isScheduledTaskKey))), diff --git a/src/shared/deno-deploy-api.ts b/src/shared/deno-deploy-api.ts index 59d192361..47780a3a0 100644 --- a/src/shared/deno-deploy-api.ts +++ b/src/shared/deno-deploy-api.ts @@ -6,40 +6,54 @@ * Auth: Authorization: Bearer {DENO_DEPLOY_TOKEN} */ +import * as v from "valibot"; /* jscpd:ignore-start */ import { getDenoDeployOrgId, + getDenoDeployOrgSlug, getDenoDeployToken, slugifyForProvider, } from "#shared/config.ts"; +import { + DenoAppEnvVarsSchema, + DenoAppIdentitySchema, + type DenoRevision, + DenoRevisionSchema, +} from "#shared/deno-deploy-schema.ts"; +import { errorMessage } from "#shared/error-message.ts"; import { fetchText, parseApiError } from "#shared/fetch.ts"; import type { HostingProviderApi, PrepareSiteFn, } from "#shared/provider-types.ts"; import { errorResult, okResult, type Result } from "#shared/result.ts"; +import { retryWithBackoff } from "#shared/retry.ts"; /* jscpd:ignore-end */ const DENO_API_BASE = "https://api.deno.com/v2"; -interface CreateAppResponse { - id: string; - slug: string; -} - -interface GetAppResponse { - env_vars?: Record; - id: string; - slug: string; -} - /** Headers for all Deno Deploy API requests. */ const denoApiHeaders = (): Record => ({ Authorization: `Bearer ${getDenoDeployToken()}`, "Content-Type": "application/json", }); +const parseDenoRevision = (text: string): DenoRevision => + v.parse(DenoRevisionSchema, JSON.parse(text)); + +const getDenoApi = async ( + path: string, + label: string, + parse: (text: string) => T, +): Promise> => { + const res = await fetchText(`${DENO_API_BASE}/${path}`, { + headers: denoApiHeaders(), + }); + if (!res.ok) return parseApiError(res, label); + return okResult(parse(res.text)); +}; + /** * Sanitize a site name into a valid Deno Deploy slug. * Rules: 3–32 chars, lowercase letters/numbers/hyphens, no leading/trailing hyphens. @@ -66,21 +80,21 @@ const createAppImpl = async ( if (!res.ok) return parseApiError(res, "Create app"); - const data: CreateAppResponse = JSON.parse(res.text); + const data = v.parse(DenoAppIdentitySchema, JSON.parse(res.text)); return okResult({ appId: data.id, slug: data.slug }); }; /** Fetch the current env vars for a Deno Deploy app. */ -const fetchAppEnvVars = async ( +const fetchAppEnvVarNames = async ( appId: string, -): Promise>> => { - const res = await fetchText( - `${DENO_API_BASE}/apps/${encodeURIComponent(appId)}`, - { headers: denoApiHeaders() }, +): Promise> => { + const result = await getDenoApi( + `apps/${encodeURIComponent(appId)}`, + "Get app", + (text) => v.parse(DenoAppEnvVarsSchema, JSON.parse(text)), ); - if (!res.ok) return parseApiError(res, "Get app"); - const data: GetAppResponse = JSON.parse(res.text); - return okResult(data.env_vars ?? {}); + if (!result.ok) return result; + return okResult(result.value.env_vars.map(({ key }) => key)); }; /** @@ -111,6 +125,68 @@ const setEnvVarsImpl = async (appId: string, secrets: [string, string][]) => { return okResult(undefined); }; +const REVISION_POLL_BACKOFF_MS = Array(20).fill(1_000); + +class RevisionPendingError extends Error {} + +const revisionOutcome = (revision: DenoRevision): Result | null => { + if (revision.status === "succeeded") return okResult(undefined); + if (revision.status === "queued" || revision.status === "building") { + return null; + } + return errorResult( + `Deno revision ${revision.id} ${revision.status}: ${ + revision.failure_reason ?? revision.status + }`, + ); +}; + +const fetchRevision = async ( + revisionId: string, +): Promise> => + getDenoApi( + `revisions/${encodeURIComponent(revisionId)}`, + "Get revision", + parseDenoRevision, + ); + +const waitForRevision = async ( + initial: DenoRevision, +): Promise> => { + let current = initial; + let fetchNext = false; + try { + return await retryWithBackoff( + async () => { + if (fetchNext) { + const fetched = await fetchRevision(initial.id); + if (!fetched.ok) throw new Error(fetched.error); + current = fetched.value; + } + const outcome = revisionOutcome(current); + if (outcome) return outcome; + fetchNext = true; + throw new RevisionPendingError(); + }, + REVISION_POLL_BACKOFF_MS, + (error, { willRetry }) => { + if (willRetry) return; + if (error instanceof RevisionPendingError) { + throw new Error( + `Deno revision ${initial.id} did not finish within ${REVISION_POLL_BACKOFF_MS.length} seconds`, + ); + } + throw new Error( + `Deno revision ${initial.id} could not be read: ${errorMessage(error)}`, + { cause: error }, + ); + }, + ); + } catch (error) { + return errorResult(errorMessage(error)); + } +}; + /** * Deploy code to a Deno Deploy app (production deployment). * Returns the primary hostname for the deployment. @@ -135,18 +211,15 @@ const deployCodeImpl: HostingProviderApi["publishSite"] = async ( ); if (!res.ok) return parseApiError(res, "Deploy code"); - return okResult(undefined); + return waitForRevision(parseDenoRevision(res.text)); }; /** * Get the names of environment variables currently set on a Deno Deploy app. * Used by the secrets backfill UI to diff against the expected set. */ -const getEnvVarNamesImpl = async (appId: string): Promise> => { - const appResult = await fetchAppEnvVars(appId); - if (!appResult.ok) return appResult; - return okResult(Object.keys(appResult.value)); -}; +const getEnvVarNamesImpl = async (appId: string): Promise> => + fetchAppEnvVarNames(appId); /** Stubbable API for testing */ export const denoDeployApi = { @@ -167,7 +240,7 @@ const prepareDenoSiteImpl: PrepareSiteFn = async (name, _code, secrets) => { return errorResult(`Failed to set secrets: ${setResult.error}`); } return okResult({ - defaultHostname: `https://${createResult.value.slug}.deno.dev`, + defaultHostname: `https://${createResult.value.slug}.${getDenoDeployOrgSlug()}.deno.net`, hostingId: createResult.value.appId, }); }; diff --git a/src/shared/deno-deploy-schema.ts b/src/shared/deno-deploy-schema.ts new file mode 100644 index 000000000..ede98deba --- /dev/null +++ b/src/shared/deno-deploy-schema.ts @@ -0,0 +1,31 @@ +import * as v from "valibot"; + +export const DenoAppIdentitySchema = v.object({ + id: v.string(), + slug: v.string(), +}); + +export const DenoAppEnvVarsSchema = v.object({ + env_vars: v.array(v.object({ key: v.string() })), +}); + +export const DenoRevisionStatusSchema = v.picklist([ + "skipped", + "queued", + "building", + "succeeded", + "failed", +]); + +export type DenoRevisionStatus = v.InferOutput; + +export const DenoRevisionSchema = v.object({ + failure_reason: v.optional( + v.nullable(v.picklist(["error", "cancelled", "timed_out", "skipped"])), + null, + ), + id: v.string(), + status: DenoRevisionStatusSchema, +}); + +export type DenoRevision = v.InferOutput; diff --git a/src/shared/maintenance/definition.ts b/src/shared/maintenance/definition.ts index 4d4b7c569..aef380584 100644 --- a/src/shared/maintenance/definition.ts +++ b/src/shared/maintenance/definition.ts @@ -20,19 +20,43 @@ export type MaintenanceTaskContext = { setCheckpoint: (checkpoint: string | null) => void; }; +export interface MaintenanceTaskCheck { + enabled: () => boolean | Promise; + maxDatabaseCalls: number; + maxExternalCalls: number; + settingsKeys: readonly string[]; +} + export interface MaintenanceTaskDeclaration { + check: MaintenanceTaskCheck; deadlineMs: number; - enabled: () => boolean | Promise; failureRetryIntervalMs: number; intervalMs: number; maxDatabaseCalls: number; maxExternalCalls: number; name: string; run: (context: MaintenanceTaskContext) => void | Promise; - settingsKeys: readonly string[]; wakePolicy: MaintenanceWakePolicy; } +export const maintenanceStartupCalls = ( + tasks: readonly MaintenanceTaskDeclaration[], +): { database: number; external: number; total: number } => { + if (tasks.length === 0) return { database: 0, external: 0, total: 0 }; + const settingsRead = tasks.some((task) => task.check.settingsKeys.length > 0) + ? 1 + : 0; + const database = + 1 + + settingsRead + + tasks.reduce((sum, task) => sum + task.check.maxDatabaseCalls, 0); + const external = tasks.reduce( + (sum, task) => sum + task.check.maxExternalCalls, + 0, + ); + return { database, external, total: database + external }; +}; + const assertNonNegativeInteger = ( task: MaintenanceTaskDeclaration, value: number, @@ -72,6 +96,16 @@ const validateTask = (task: MaintenanceTaskDeclaration): void => { } assertNonNegativeInteger(task, task.maxDatabaseCalls, "database"); assertNonNegativeInteger(task, task.maxExternalCalls, "external"); + assertNonNegativeInteger( + task, + task.check.maxDatabaseCalls, + "enabled-check database", + ); + assertNonNegativeInteger( + task, + task.check.maxExternalCalls, + "enabled-check external", + ); const calls = task.maxDatabaseCalls + task.maxExternalCalls; if (calls > MAINTENANCE_TASK_CALL_LIMIT) { throw new Error( @@ -93,6 +127,12 @@ export const defineMaintenanceTasks = < names.add(task.name); validateTask(task); } + const startup = maintenanceStartupCalls(tasks); + if (startup.total > MAINTENANCE_REQUEST_CALL_LIMIT) { + throw new Error( + `Maintenance checks declare ${startup.total} startup calls; maximum is ${MAINTENANCE_REQUEST_CALL_LIMIT}`, + ); + } return tasks; }; diff --git a/src/shared/maintenance/registry.ts b/src/shared/maintenance/registry.ts index 83cfd72a1..3a0dcac56 100644 --- a/src/shared/maintenance/registry.ts +++ b/src/shared/maintenance/registry.ts @@ -11,8 +11,16 @@ const FAILURE_RETRY_MS = 5 * 60 * 1000; export const MAINTENANCE_TASKS = defineMaintenanceTasks([ { + check: { + enabled: () => true, + maxDatabaseCalls: 0, + maxExternalCalls: 0, + settingsKeys: [ + CONFIG_KEYS.AUTO_PURGE_ORPHANS, + CONFIG_KEYS.ORPHAN_PURGE_RETENTION, + ], + }, deadlineMs: 15_000, - enabled: () => true, failureRetryIntervalMs: FAILURE_RETRY_MS, intervalMs: PRUNE_INTERVAL_MS, maxDatabaseCalls: 2, @@ -24,15 +32,17 @@ export const MAINTENANCE_TASKS = defineMaintenanceTasks([ setCheckpoint(result.checkpoint); if (result.fullBatch) requestFollowUp(); }, - settingsKeys: [ - CONFIG_KEYS.AUTO_PURGE_ORPHANS, - CONFIG_KEYS.ORPHAN_PURGE_RETENTION, - ], wakePolicy: "organic_safe", }, { + check: { + enabled: async () => + Boolean(settings.publicKey) && hasLegacyActivityLog(), + maxDatabaseCalls: 1, + maxExternalCalls: 0, + settingsKeys: [CONFIG_KEYS.PUBLIC_KEY], + }, deadlineMs: 10_000, - enabled: async () => Boolean(settings.publicKey) && hasLegacyActivityLog(), failureRetryIntervalMs: ACTIVITY_LOG_BACKFILL_INTERVAL_MS, intervalMs: ACTIVITY_LOG_BACKFILL_INTERVAL_MS, maxDatabaseCalls: 2, @@ -44,7 +54,6 @@ export const MAINTENANCE_TASKS = defineMaintenanceTasks([ ); await runActivityLogBackfill(settings.publicKey); }, - settingsKeys: [CONFIG_KEYS.PUBLIC_KEY], wakePolicy: "organic_safe", }, ]); diff --git a/src/shared/maintenance/runner.ts b/src/shared/maintenance/runner.ts index 63a01c89e..1ca933347 100644 --- a/src/shared/maintenance/runner.ts +++ b/src/shared/maintenance/runner.ts @@ -18,6 +18,7 @@ import { MAINTENANCE_REQUEST_DEADLINE_MS, type MaintenanceTaskDeclaration, type MaintenanceWakePolicy, + maintenanceStartupCalls, maintenanceTaskByName, } from "./definition.ts"; @@ -31,6 +32,15 @@ export type RunMaintenanceOptions = { const taskCalls = (task: MaintenanceTaskDeclaration): number => task.maxDatabaseCalls + task.maxExternalCalls; +const tasksForWake = ( + declarations: readonly MaintenanceTaskDeclaration[], + wakePolicy: MaintenanceWakePolicy, +): readonly MaintenanceTaskDeclaration[] => + declarations.filter( + (task) => + wakePolicy === "scheduled_only" || task.wakePolicy === "organic_safe", + ); + // Claim, finish, and the final no-work claim remain outside the task allowance. const TASK_RUNNER_CALL_RESERVE = 3; @@ -48,7 +58,10 @@ const enabledTasks = async ( enabled: MaintenanceTaskDeclaration[]; }> => { const states = await Promise.all( - tasks.map(async (task) => ({ enabled: await task.enabled(), task })), + tasks.map(async (task) => ({ + enabled: await task.check.enabled(), + task, + })), ); return { disabledNames: states @@ -106,17 +119,11 @@ const runClaimedTask = async ( }; const runWithinAllowance = async ( - declarations: readonly MaintenanceTaskDeclaration[], - options: RunMaintenanceOptions, + candidates: readonly MaintenanceTaskDeclaration[], requestDeadline: number, ): Promise => { - const wakePolicy = options.wakePolicy ?? "scheduled_only"; - const candidates = declarations.filter( - (task) => - wakePolicy === "scheduled_only" || task.wakePolicy === "organic_safe", - ); await settings.loadKeys( - unique(candidates.flatMap((task) => task.settingsKeys)), + unique(candidates.flatMap((task) => task.check.settingsKeys)), ); const { enabled, disabledNames } = await enabledTasks(candidates); await syncMaintenanceTaskRows( @@ -165,17 +172,29 @@ const runMaintenance = ( MAINTENANCE_REQUEST_CALL_LIMIT - used, ), ); - if (combinedAllowance === 0) return Promise.resolve(); + const externalAllowance = Math.min( + options.externalAllowance ?? combinedAllowance, + combinedAllowance, + ); + const candidates = tasksForWake( + declarations, + options.wakePolicy ?? "scheduled_only", + ); + const startup = maintenanceStartupCalls(candidates); + if ( + startup.database > combinedAllowance || + startup.external > externalAllowance || + startup.total > combinedAllowance + ) { + return Promise.resolve(); + } return withSubrequestAllowance( { database: combinedAllowance, - external: Math.min( - options.externalAllowance ?? combinedAllowance, - combinedAllowance, - ), + external: externalAllowance, total: combinedAllowance, }, - () => runWithinAllowance(declarations, options, requestDeadline), + () => runWithinAllowance(candidates, requestDeadline), ); }; diff --git a/src/shared/site-db.ts b/src/shared/site-db.ts index 551ae70dc..b5eaa6657 100644 --- a/src/shared/site-db.ts +++ b/src/shared/site-db.ts @@ -14,10 +14,20 @@ import { type Client, createClient } from "@libsql/client"; import type { BuiltSite } from "#shared/db/built-sites/types.ts"; +import { wrapExecute } from "#shared/db/libsql-call.ts"; +import { countDatabaseRoundTrip } from "#shared/db/query-log.ts"; import type { Result } from "#shared/result.ts"; /** Credentials needed to open a read-only connection to a site's database. */ export type SiteDbCredentials = Pick; +export type SiteDbReader = Pick; + +const childSiteReader = (client: Client): SiteDbReader => ({ + execute: wrapExecute(client, (_statement, execute) => { + countDatabaseRoundTrip("child site statement"); + return execute(); + }), +}); /** Stubbable client factory so tests can inject an in-memory database. */ export const siteDbApi = { @@ -37,7 +47,7 @@ export const hasSiteDbCredentials = (creds: SiteDbCredentials): boolean => */ export const withSiteDb = async ( creds: SiteDbCredentials, - fn: (client: Client) => Promise, + fn: (client: SiteDbReader) => Promise, ): Promise> => { if (!creds.dbUrl) return { error: "No database URL for this site", ok: false }; @@ -48,7 +58,7 @@ export const withSiteDb = async ( let client: Client | undefined; try { client = siteDbApi.createClient(creds.dbUrl, creds.dbToken); - const value = await fn(client); + const value = await fn(childSiteReader(client)); client.close(); return { ok: true, value }; } catch (e) { diff --git a/test/features/admin/builder/server.test.ts b/test/features/admin/builder/server.test.ts index 03376b59e..2a0089bdc 100644 --- a/test/features/admin/builder/server.test.ts +++ b/test/features/admin/builder/server.test.ts @@ -223,6 +223,7 @@ describeWithEnv( test("POST /admin/builder returns error when Deno Deploy is not configured", async () => { using _env = withEnv({ DENO_DEPLOY_ORG_ID: undefined, + DENO_DEPLOY_ORG_SLUG: undefined, DENO_DEPLOY_TOKEN: undefined, }); const { response } = await adminFormPost("/admin/builder", { @@ -491,6 +492,7 @@ describeWithEnv( test("POST /admin/builder passes deno hosting_provider to buildSite", async () => { using _env = withEnv({ DENO_DEPLOY_ORG_ID: "test-org", + DENO_DEPLOY_ORG_SLUG: "test-org", DENO_DEPLOY_TOKEN: "test-token", }); await withMocks( diff --git a/test/shared/builder.test.ts b/test/shared/builder.test.ts index 970d188bc..33c57636c 100644 --- a/test/shared/builder.test.ts +++ b/test/shared/builder.test.ts @@ -125,6 +125,7 @@ describeWithEnv( db: true, env: { ADMIN_EMAIL_ADDRESS: "admin@example.com", + DENO_DEPLOY_ORG_SLUG: "test-org", NTFY_URL: "https://ntfy.example.com/test", SENTRY_URL: "https://k@bugs.example.com/2", }, @@ -277,7 +278,7 @@ describeWithEnv( expect(result.hostingProvider).toBe("deno"); expect(result.hostingId).toBe("app_abc123"); expect(result.defaultHostname).toBe( - "https://tickets-test.deno.dev", + "https://tickets-test.test-org.deno.net", ); } expect(deployStub.calls).toHaveLength(1); diff --git a/test/shared/config/env.test.ts b/test/shared/config/env.test.ts index 7aa17d3e9..17487e2f6 100644 --- a/test/shared/config/env.test.ts +++ b/test/shared/config/env.test.ts @@ -10,6 +10,7 @@ import { getDebugKey, getDefaultDbProvider, getDenoDeployOrgId, + getDenoDeployOrgSlug, getDenoDeployToken, getMainInstanceKey, getTursoApiToken, @@ -213,6 +214,7 @@ expectRequiredEnvGetter( expectEnabledByAllKeys("isDenoDeployEnabled", isDenoDeployEnabled, [ "DENO_DEPLOY_TOKEN", "DENO_DEPLOY_ORG_ID", + "DENO_DEPLOY_ORG_SLUG", ]); expectRequiredEnvGetter( "getDenoDeployToken", @@ -226,6 +228,12 @@ expectRequiredEnvGetter( "DENO_DEPLOY_ORG_ID", "deploy_org", ); +expectRequiredEnvGetter( + "getDenoDeployOrgSlug", + getDenoDeployOrgSlug, + "DENO_DEPLOY_ORG_SLUG", + "deploy-org", +); describe("getDefaultDbProvider", () => { test("returns turso only when DEFAULT_DB_HOST is turso", () => { diff --git a/test/shared/db/built-sites/blob.test.ts b/test/shared/db/built-sites/blob.test.ts index 7e3ab1aa2..2284b9aeb 100644 --- a/test/shared/db/built-sites/blob.test.ts +++ b/test/shared/db/built-sites/blob.test.ts @@ -54,6 +54,12 @@ test("parses legacy blobs without current optional fields", () => { ).toEqual({ n: "Legacy", u: "legacy.example", v: 1 }); }); +test("treats an unversioned legacy blob as version 1", () => { + expect( + parseSiteDataBlob(JSON.stringify({ n: "Legacy", u: "legacy.example" })), + ).toEqual({ n: "Legacy", u: "legacy.example", v: 1 }); +}); + test("turns a legacy blob into every site field default", () => { expect( blobToSiteFields({ diff --git a/test/shared/deno-deploy-api.test.ts b/test/shared/deno-deploy-api.test.ts index 0d58bdfb0..004c55081 100644 --- a/test/shared/deno-deploy-api.test.ts +++ b/test/shared/deno-deploy-api.test.ts @@ -1,15 +1,19 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; import { denoDeployApi, denoHostingProvider, slugifyForDeno, } from "#shared/deno-deploy-api.ts"; +import { okResult } from "#shared/result.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { stubFetch } from "#test-utils/fetch-stub.ts"; +import { withVirtualBackoff } from "#test-utils/virtual-time.ts"; const DENO_ENV = { DENO_DEPLOY_ORG_ID: "test-org-id", + DENO_DEPLOY_ORG_SLUG: "test-org", DENO_DEPLOY_TOKEN: "test-deno-token", }; @@ -162,7 +166,7 @@ describeWithEnv("deno-deploy-api", { env: DENO_ENV }, () => { test("deployCode POSTs assets and config to the revision endpoint", async () => { const captured: CapturedRequest = { body: undefined, url: undefined }; using _fetch = stubFetch( - captureRequest({ domains: ["my-app.deno.dev"], id: "dep_123" }, captured), + captureRequest({ id: "dep_123", status: "succeeded" }, captured), ); const result = await denoDeployApi.deployCode( "app_dc", @@ -184,6 +188,98 @@ describeWithEnv("deno-deploy-api", { env: DENO_ENV }, () => { }); }); + test("deployCode waits for an accepted revision to succeed", async () => { + const requests: string[] = []; + const requestTimes: number[] = []; + const responses = [ + { id: "revision-1", status: "queued" }, + { id: "revision-1", status: "building" }, + { id: "revision-1", status: "succeeded" }, + ]; + using _fetch = stubFetch((url) => { + requests.push(String(url)); + requestTimes.push(Date.now()); + return Response.json(responses.shift()); + }); + + expect( + await withVirtualBackoff(() => denoDeployApi.deployCode("app_1", "code")), + ).toEqual({ ok: true, value: undefined }); + expect(requests).toEqual([ + "https://api.deno.com/v2/apps/app_1/deploy", + "https://api.deno.com/v2/revisions/revision-1", + "https://api.deno.com/v2/revisions/revision-1", + ]); + expect(requestTimes.map((time) => time - requestTimes[0]!)).toEqual([ + 0, 1_000, 2_000, + ]); + }); + + test("deployCode reports a failed accepted revision", async () => { + using _fetch = stubFetch( + Response.json({ + failure_reason: "error", + id: "revision-failed", + status: "failed", + }), + ); + + expect(await denoDeployApi.deployCode("app_1", "code")).toEqual({ + error: "Deno revision revision-failed failed: error", + ok: false, + }); + }); + + test("deployCode reports a skipped accepted revision", async () => { + using _fetch = stubFetch( + Response.json({ id: "revision-skipped", status: "skipped" }), + ); + + expect(await denoDeployApi.deployCode("app_1", "code")).toEqual({ + error: "Deno revision revision-skipped skipped: skipped", + ok: false, + }); + }); + + test("deployCode reports a revision that never finishes", async () => { + using _fetch = stubFetch(() => + Response.json({ id: "revision-pending", status: "queued" }), + ); + + expect( + await withVirtualBackoff(() => denoDeployApi.deployCode("app_1", "code")), + ).toEqual({ + error: "Deno revision revision-pending did not finish within 20 seconds", + ok: false, + }); + }); + + test("deployCode reports repeated revision read failures", async () => { + const calls = { value: 0 }; + using _fetch = stubFetch(() => { + calls.value += 1; + return calls.value === 1 + ? Response.json({ id: "revision-unreadable", status: "queued" }) + : new Response("unavailable", { status: 503 }); + }); + + expect( + await withVirtualBackoff(() => denoDeployApi.deployCode("app_1", "code")), + ).toEqual({ + error: + "Deno revision revision-unreadable could not be read: Get revision failed (503): unavailable", + ok: false, + }); + }); + + test("deployCode rejects an undocumented revision response", async () => { + using _fetch = stubFetch( + Response.json({ id: "revision-unknown", status: "running" }), + ); + + await expect(denoDeployApi.deployCode("app_1", "code")).rejects.toThrow(); + }); + test("deployCode returns error when API responds with failure", async () => { using _fetch = stubFetch( new Response(JSON.stringify({ message: "app not found" }), { @@ -204,10 +300,7 @@ describeWithEnv("deno-deploy-api", { env: DENO_ENV }, () => { using _fetch = stubFetch( new Response( JSON.stringify({ - env_vars: { - DB_TOKEN: { is_secret: true, value: "" }, - DB_URL: { is_secret: true, value: "" }, - }, + env_vars: [{ key: "DB_TOKEN" }, { key: "DB_URL" }], id: "app_gn", slug: "gn-app", }), @@ -225,7 +318,7 @@ describeWithEnv("deno-deploy-api", { env: DENO_ENV }, () => { test("getEnvVarNames returns empty array when no env vars are set", async () => { using _fetch = stubFetch( new Response( - JSON.stringify({ env_vars: {}, id: "app_empty", slug: "empty" }), + JSON.stringify({ env_vars: [], id: "app_empty", slug: "empty" }), ), ); const result = await denoDeployApi.getEnvVarNames("app_empty"); @@ -235,15 +328,11 @@ describeWithEnv("deno-deploy-api", { env: DENO_ENV }, () => { } }); - test("getEnvVarNames returns empty when env_vars is omitted", async () => { + test("getEnvVarNames rejects an app response without env vars", async () => { using _fetch = stubFetch( new Response(JSON.stringify({ id: "app_no_ev", slug: "no-ev" })), ); - const result = await denoDeployApi.getEnvVarNames("app_no_ev"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value).toEqual([]); - } + await expect(denoDeployApi.getEnvVarNames("app_no_ev")).rejects.toThrow(); }); test("getEnvVarNames returns error when API fails", async () => { @@ -262,11 +351,26 @@ describeWithEnv("deno-deploy-api", { env: DENO_ENV }, () => { expect(denoHostingProvider.configEnvVar).toBe("DENO_DEPLOY_TOKEN"); }); - test("hosting publish returns only provider success", async () => { - using _fetch = stubFetch( - new Response(JSON.stringify({ domains: ["child.deno.dev"], id: "dep" })), + test("hosting provider builds the Deno v2 production domain", async () => { + using _create = stub(denoDeployApi, "createApp", () => + Promise.resolve(okResult({ appId: "app_1", slug: "child" })), + ); + using _secrets = stub(denoDeployApi, "setEnvVars", () => + Promise.resolve(okResult(undefined)), ); + expect(await denoHostingProvider.prepareSite("Child", "code", [])).toEqual({ + ok: true, + value: { + defaultHostname: "https://child.test-org.deno.net", + hostingId: "app_1", + }, + }); + }); + + test("hosting publish returns only provider success", async () => { + using _fetch = stubFetch(Response.json({ id: "dep", status: "succeeded" })); + expect(await denoHostingProvider.publishSite("app_1", "code")).toEqual({ ok: true, }); diff --git a/test/shared/deno-deploy-schema.test.ts b/test/shared/deno-deploy-schema.test.ts new file mode 100644 index 000000000..692d80360 --- /dev/null +++ b/test/shared/deno-deploy-schema.test.ts @@ -0,0 +1,58 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import * as v from "valibot"; +import { + DenoAppEnvVarsSchema, + DenoAppIdentitySchema, + DenoRevisionSchema, + DenoRevisionStatusSchema, +} from "#shared/deno-deploy-schema.ts"; + +test("accepts every documented revision status", () => { + expect( + DenoRevisionStatusSchema.options.map((status) => + v.parse(DenoRevisionSchema, { id: "revision-1", status }), + ), + ).toEqual([ + { failure_reason: null, id: "revision-1", status: "skipped" }, + { failure_reason: null, id: "revision-1", status: "queued" }, + { failure_reason: null, id: "revision-1", status: "building" }, + { failure_reason: null, id: "revision-1", status: "succeeded" }, + { failure_reason: null, id: "revision-1", status: "failed" }, + ]); +}); + +test("accepts every documented revision failure reason", () => { + expect( + ["error", "cancelled", "timed_out", "skipped"].map((failure_reason) => + v.parse(DenoRevisionSchema, { + failure_reason, + id: "revision-1", + status: "failed", + }), + ), + ).toEqual([ + { failure_reason: "error", id: "revision-1", status: "failed" }, + { failure_reason: "cancelled", id: "revision-1", status: "failed" }, + { failure_reason: "timed_out", id: "revision-1", status: "failed" }, + { failure_reason: "skipped", id: "revision-1", status: "failed" }, + ]); +}); + +test("rejects unknown revision states and missing identities", () => { + expect(() => + v.parse(DenoRevisionSchema, { id: "revision-1", status: "running" }), + ).toThrow(); + expect(() => v.parse(DenoRevisionSchema, { status: "succeeded" })).toThrow(); + expect(() => v.parse(DenoAppIdentitySchema, { slug: "child" })).toThrow(); + expect(() => v.parse(DenoAppIdentitySchema, { id: "app-1" })).toThrow(); +}); + +test("reads app environment variable names from the documented list", () => { + expect( + v.parse(DenoAppEnvVarsSchema, { + env_vars: [{ key: "DB_URL", secret: true }], + }), + ).toEqual({ env_vars: [{ key: "DB_URL" }] }); + expect(() => v.parse(DenoAppEnvVarsSchema, {})).toThrow(); +}); diff --git a/test/shared/maintenance/definition.test.ts b/test/shared/maintenance/definition.test.ts index 26e2633c9..1bfb5064a 100644 --- a/test/shared/maintenance/definition.test.ts +++ b/test/shared/maintenance/definition.test.ts @@ -6,26 +6,44 @@ import { MAINTENANCE_REQUEST_CALL_LIMIT, MAINTENANCE_TASK_CALL_LIMIT, type MaintenanceTaskDeclaration, + maintenanceStartupCalls, maintenanceTaskByName, } from "#shared/maintenance/definition.ts"; +import { maintenanceDeclaration } from "./fixtures.ts"; const task = ( - overrides: Partial = {}, -): MaintenanceTaskDeclaration => ({ - deadlineMs: 10_000, - enabled: () => true, - failureRetryIntervalMs: 60_000, - intervalMs: 60_000, - maxDatabaseCalls: 1, - maxExternalCalls: 0, - name: "test_task", - run: () => Promise.resolve(), - settingsKeys: [], - wakePolicy: "organic_safe", - ...overrides, -}); + overrides: Parameters[2] = {}, +): MaintenanceTaskDeclaration => + maintenanceDeclaration("test_task", () => Promise.resolve(), { + maxDatabaseCalls: 1, + ...overrides, + }); describe("maintenance task declarations", () => { + test("totals every startup call and the shared settings and sync reads", () => { + expect(maintenanceStartupCalls([])).toEqual({ + database: 0, + external: 0, + total: 0, + }); + expect( + maintenanceStartupCalls([ + task({ + check: { + maxDatabaseCalls: 2, + maxExternalCalls: 4, + settingsKeys: ["first"], + }, + name: "first", + }), + task({ + check: { maxDatabaseCalls: 3, maxExternalCalls: 5 }, + name: "second", + }), + ]), + ).toEqual({ database: 7, external: 9, total: 16 }); + }); + test("reserves seven calls and one second for scheduler bookkeeping", () => { expect(MAINTENANCE_REQUEST_CALL_LIMIT - MAINTENANCE_TASK_CALL_LIMIT).toBe( 7, @@ -91,6 +109,18 @@ describe("maintenance task declarations", () => { ).not.toThrow(); }); + test("rejects activation checks that cannot fit the request", () => { + expect(() => + defineMaintenanceTasks([ + task({ + check: { maxDatabaseCalls: MAINTENANCE_REQUEST_CALL_LIMIT }, + }), + ]), + ).toThrow( + `Maintenance checks declare ${MAINTENANCE_REQUEST_CALL_LIMIT + 1} startup calls; maximum is ${MAINTENANCE_REQUEST_CALL_LIMIT}`, + ); + }); + test("rejects invalid call counts and deadlines", () => { expect(() => defineMaintenanceTasks([task({ maxDatabaseCalls: -1 })]), @@ -98,6 +128,16 @@ describe("maintenance task declarations", () => { expect(() => defineMaintenanceTasks([task({ maxExternalCalls: -1 })]), ).toThrow("test_task external calls must be a non-negative integer"); + expect(() => + defineMaintenanceTasks([task({ check: { maxDatabaseCalls: -1 } })]), + ).toThrow( + "test_task enabled-check database calls must be a non-negative integer", + ); + expect(() => + defineMaintenanceTasks([task({ check: { maxExternalCalls: -1 } })]), + ).toThrow( + "test_task enabled-check external calls must be a non-negative integer", + ); expect(() => defineMaintenanceTasks([task({ deadlineMs: 0 })])).toThrow( "test_task deadline must be greater than 0ms", ); diff --git a/test/shared/maintenance/fixtures.ts b/test/shared/maintenance/fixtures.ts new file mode 100644 index 000000000..cbfcdaea3 --- /dev/null +++ b/test/shared/maintenance/fixtures.ts @@ -0,0 +1,34 @@ +import type { MaintenanceTaskDeclaration } from "#shared/maintenance/definition.ts"; + +type MaintenanceTaskOverrides = Omit< + Partial, + "check" +> & { + check?: Partial; +}; + +export const maintenanceDeclaration = ( + name: string, + run: MaintenanceTaskDeclaration["run"], + overrides: MaintenanceTaskOverrides = {}, +): MaintenanceTaskDeclaration => { + const { check, ...taskOverrides } = overrides; + return { + check: { + enabled: () => true, + maxDatabaseCalls: 0, + maxExternalCalls: 0, + settingsKeys: [], + ...check, + }, + deadlineMs: 10_000, + failureRetryIntervalMs: 60_000, + intervalMs: 60_000, + maxDatabaseCalls: 0, + maxExternalCalls: 0, + name, + run, + wakePolicy: "organic_safe", + ...taskOverrides, + }; +}; diff --git a/test/shared/maintenance/registry.test.ts b/test/shared/maintenance/registry.test.ts index a3ec3339c..bb1884b25 100644 --- a/test/shared/maintenance/registry.test.ts +++ b/test/shared/maintenance/registry.test.ts @@ -40,35 +40,46 @@ const runTask = ( describeWithEnv("maintenance registry", { db: true }, () => { test("declares only bounded local pruning and activity backfill", () => { expect( - MAINTENANCE_TASKS.map( - ({ enabled: _enabled, run: _run, ...task }) => task, - ), + MAINTENANCE_TASKS.map(({ check, run: _run, ...task }) => ({ + ...task, + check: { ...check, enabled: undefined }, + })), ).toEqual([ { + check: { + enabled: undefined, + maxDatabaseCalls: 0, + maxExternalCalls: 0, + settingsKeys: ["auto_purge_orphans", "orphan_purge_retention"], + }, deadlineMs: 15_000, failureRetryIntervalMs: 300_000, intervalMs: 86_400_000, maxDatabaseCalls: 2, maxExternalCalls: 0, name: "database_pruning", - settingsKeys: ["auto_purge_orphans", "orphan_purge_retention"], wakePolicy: "organic_safe", }, { + check: { + enabled: undefined, + maxDatabaseCalls: 1, + maxExternalCalls: 0, + settingsKeys: ["public_key"], + }, deadlineMs: 10_000, failureRetryIntervalMs: 60_000, intervalMs: 60_000, maxDatabaseCalls: 2, maxExternalCalls: 0, name: "activity_log_backfill", - settingsKeys: ["public_key"], wakePolicy: "organic_safe", }, ]); }); test("the pruning task runs one bounded database batch", async () => { - expect(await taskNamed("database_pruning").enabled()).toBe(true); + expect(await taskNamed("database_pruning").check.enabled()).toBe(true); const ipHash = await insertLoginAttempt("192.0.2.10", 1, 0); expect(await loginAttemptExists(ipHash)).toBe(true); @@ -96,20 +107,24 @@ describeWithEnv("maintenance registry", { db: true }, () => { await insertLegacyActivity("registry legacy"); const task = taskNamed("activity_log_backfill"); - expect(await task.enabled()).toBe(true); + expect(await task.check.enabled()).toBe(true); await runTask(task); expect(await hasLegacyActivityLog()).toBe(false); }); test("the activity task stays disabled when no legacy rows remain", async () => { - expect(await taskNamed("activity_log_backfill").enabled()).toBe(false); + expect(await taskNamed("activity_log_backfill").check.enabled()).toBe( + false, + ); }); test("the activity task stays disabled without an owner public key", async () => { await insertLegacyActivity("legacy without owner key"); settings.setForTest({ public_key: "" }); - expect(await taskNamed("activity_log_backfill").enabled()).toBe(false); + expect(await taskNamed("activity_log_backfill").check.enabled()).toBe( + false, + ); }); }); diff --git a/test/shared/maintenance/runner.test.ts b/test/shared/maintenance/runner.test.ts index 5324e1fd3..e572e6fb3 100644 --- a/test/shared/maintenance/runner.test.ts +++ b/test/shared/maintenance/runner.test.ts @@ -13,24 +13,7 @@ import { runWithSubrequestBudget, } from "#shared/subrequest-budget.ts"; import { describeWithEnv } from "#test-utils/db.ts"; - -const declaration = ( - name: string, - run: MaintenanceTaskDeclaration["run"], - overrides: Partial = {}, -): MaintenanceTaskDeclaration => ({ - deadlineMs: 10_000, - enabled: () => true, - failureRetryIntervalMs: 60_000, - intervalMs: 60_000, - maxDatabaseCalls: 0, - maxExternalCalls: 0, - name, - run, - settingsKeys: [], - wakePolicy: "organic_safe", - ...overrides, -}); +import { maintenanceDeclaration as declaration } from "./fixtures.ts"; const forceDue = (names: string[]): Promise => execute( @@ -338,6 +321,49 @@ describeWithEnv("maintenance runner", { db: true }, () => { expect(calls).toEqual([]); }); + test("an allowance below startup cost skips all bookkeeping", async () => { + const tasks = defineMaintenanceTasks([ + declaration("startup_cost", () => {}, { + check: { + enabled: async () => { + await queryOne("SELECT 1"); + return true; + }, + maxDatabaseCalls: 1, + }, + }), + ]); + + await runWithSubrequestBudget(async () => { + await maintenance.run(tasks, { combinedAllowance: 1 }); + expect(getSubrequestUsage()).toEqual({ + database: 0, + external: 0, + total: 0, + }); + }); + }); + + test("an external activation check needs external startup allowance", async () => { + const checks: string[] = []; + const tasks = defineMaintenanceTasks([ + declaration("external_startup", () => {}, { + check: { + enabled: () => { + checks.push("checked"); + return true; + }, + maxExternalCalls: 1, + }, + }), + ]); + + await runWithSubrequestBudget(() => + maintenance.run(tasks, { externalAllowance: 0 }), + ); + expect(checks).toEqual([]); + }); + test("subtracts request calls already used from the maintenance envelope", async () => { const calls: string[] = []; const tasks = defineMaintenanceTasks([ diff --git a/test/shared/site-db.test.ts b/test/shared/site-db.test.ts index 15c2fd6c0..21220a210 100644 --- a/test/shared/site-db.test.ts +++ b/test/shared/site-db.test.ts @@ -8,6 +8,11 @@ import { siteDbApi, withSiteDb, } from "#shared/site-db.ts"; +import { + getSubrequestUsage, + runWithSubrequestBudget, + withSubrequestAllowance, +} from "#shared/subrequest-budget.ts"; /** Build an in-memory libsql client seeded with a settings table. */ const seededClient = async (rows: [string, string][]): Promise => { @@ -116,6 +121,36 @@ describe("readSiteSetting", () => { expect(result).toEqual({ ok: true, value: "2026-06-19T12:00:00Z" }); }); + test("counts a child database read against the shared request budget", async () => { + await runWithSubrequestBudget(async () => { + await readSiteSetting( + { dbToken: "t", dbUrl: "libsql://u" }, + "current_script_version", + ); + expect(getSubrequestUsage()).toEqual({ + database: 1, + external: 0, + total: 1, + }); + }); + }); + + test("names a child database read blocked by the request budget", async () => { + const result = await runWithSubrequestBudget(() => + withSubrequestAllowance({ database: 0, external: 0, total: 0 }, () => + readSiteSetting( + { dbToken: "t", dbUrl: "libsql://u" }, + "current_script_version", + ), + ), + ); + expect(result).toEqual({ + error: + "Subrequest allowance exceeded: 1 database + 0 external calls. Blocked database operation: child site statement", + ok: false, + }); + }); + test("connects with the site's read-only URL and token", async () => { await readSiteSetting( { dbToken: "ro-token", dbUrl: "libsql://site.example" },