diff --git a/.changeset/steady-cats-retry.md b/.changeset/steady-cats-retry.md new file mode 100644 index 0000000000..abffcef6c7 --- /dev/null +++ b/.changeset/steady-cats-retry.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Adds bounded controls for listing and retrying durable Media Usage indexing work. diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx index dd195a67da..7f819dd9b0 100644 --- a/docs/src/content/docs/reference/rest-api.mdx +++ b/docs/src/content/docs/reference/rest-api.mdx @@ -195,6 +195,7 @@ GET /_emdash/api/media?includeUsage=1 ```json { + "success": true, "data": { "items": [ { @@ -363,6 +364,74 @@ Content-Type: application/json DELETE /_emdash/api/media/:id ``` +### List Media Usage Work + +```http +GET /_emdash/api/admin/media-usage/work?collection=posts&state=failed&limit=50&cursor=... +``` + +Returns a bounded page of durable entry-indexing work for one current collection. The endpoint +requires `schema:manage`; bearer tokens also require the `admin` scope. + +`collection` is required. `state` optionally filters `pending`, `retry`, `leased`, or `failed` +work. `limit` defaults to 50 and is capped at 100. `cursor` is opaque and comes from the previous +page's `nextCursor`. The endpoint does not calculate an exact backlog count. + +```json +{ + "success": true, + "data": { + "items": [ + { + "collectionId": "01COLLECTION...", + "collectionSlug": "posts", + "contentId": "01CONTENT...", + "state": "failed", + "attemptCount": 5, + "nextAttemptAt": "2026-08-07T12:00:00.000Z", + "leaseExpiresAt": null, + "lastAttemptedAt": "2026-08-07T11:45:00.000Z", + "lastErrorCode": "MEDIA_USAGE_PROCESSING_FAILED", + "updatedAt": "2026-08-07T11:45:00.000Z" + } + ], + "nextCursor": "eyJvcmRlclZhbHVlIjoiLi4uIn0" + } +} +``` + +Responses omit work versions, lease tokens, raw database errors, indexed content, media +references, and exact counts. + +### Retry Media Usage Work + +```http +POST /_emdash/api/admin/media-usage/work/retry +Content-Type: application/json +X-EmDash-Request: 1 +``` + +Idempotently reopens or creates one durable entry job. It has the same authorization requirements +as the list endpoint. + +```json +{ + "collectionId": "01COLLECTION...", + "contentId": "01CONTENT..." +} +``` + +A successful response returns `changed` and the current pending item. `changed: false` means the +job was already pending. A non-expired worker lease returns `409 WORK_LEASE_ACTIVE` with +`details.leaseExpiresAt`; a concurrent mutation returns `409 WORK_CHANGED`. Neither conflict +replaces newer work or exposes its lease token. + +The list returns only known durable work. Retry can create work for the supplied identity in an +active collection even when no work row exists, but it does not scan for historical gaps. Use +collection-scoped Media Usage repair after imports or direct database writes. +When scheduled maintenance is disabled, failed jobs remain visible and manually retryable, but no +automatic freshness deadline is promised. + ### Repair Media Usage ```http diff --git a/packages/cloudflare/src/sandbox/bridge.ts b/packages/cloudflare/src/sandbox/bridge.ts index 3e7ae6a5a5..b431aca202 100644 --- a/packages/cloudflare/src/sandbox/bridge.ts +++ b/packages/cloudflare/src/sandbox/bridge.ts @@ -10,7 +10,12 @@ import type { D1Database } from "@cloudflare/workers-types"; import { WorkerEntrypoint } from "cloudflare:workers"; import type { SandboxEmailSendCallback } from "emdash"; -import { ulid, PluginStorageRepository } from "emdash"; +import { + createSandboxRouteError, + getSandboxRouteErrorDetails, + ulid, + PluginStorageRepository, +} from "emdash"; import { Kysely } from "kysely"; import { D1Dialect } from "kysely-d1"; @@ -18,6 +23,7 @@ import { sandboxHttpFetch } from "./bridge-http.js"; /** Regex to validate collection names (prevent SQL injection) */ const COLLECTION_NAME_REGEX = /^[a-z][a-z0-9_]*$/; +const MISSING_MEDIA_USAGE_ACTIVATION_TABLE_REGEX = /no such table.*_emdash_media_usage_activation/i; /** Regex to validate file extensions (simple alphanumeric, 1-10 chars) */ const FILE_EXT_REGEX = /^\.[a-z0-9]{1,10}$/i; @@ -214,6 +220,25 @@ export interface PluginBridgeProps { * 3. Plugins call bridge methods which validate and proxy to the database */ export class PluginBridge extends WorkerEntrypoint { + private async assertMediaUsageActivationWriteAllowed(): Promise { + try { + const activation = await this.env.DB.prepare( + "SELECT state FROM _emdash_media_usage_activation WHERE task_key = ? LIMIT 1", + ) + .bind("incremental_capture") + .first<{ state: string }>(); + if (activation?.state === "activating") { + throw createSandboxRouteError("MEDIA_USAGE_ACTIVATION_IN_PROGRESS"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (MISSING_MEDIA_USAGE_ACTIVATION_TABLE_REGEX.test(message)) return; + if (getSandboxRouteErrorDetails(error)) throw error; + console.error("[media-usage] Failed to check the sandbox write fence:", error); + throw createSandboxRouteError("MEDIA_USAGE_ACTIVATION_CHECK_FAILED"); + } + } + /** * Construct a PluginStorageRepository for the requested collection. * Uses the indexes from the plugin's storage config (if provided) so @@ -549,6 +574,7 @@ export class PluginBridge extends WorkerEntrypoint { - return this.withWallTimeLimit(`route:${routeName}`, () => { + return this.withWallTimeLimit(`route:${routeName}`, async () => { const worker = this.createWorker(); const entrypoint = worker.getEntrypoint("default"); - return entrypoint.invokeRoute(routeName, input, request); + const result = await entrypoint.invokeRoute(routeName, input, request); + const envelope = getSandboxRouteErrorEnvelope(result); + if (envelope) throw createSandboxRouteError(envelope.error.code); + return result; }); } diff --git a/packages/cloudflare/src/sandbox/wrapper.ts b/packages/cloudflare/src/sandbox/wrapper.ts index 320c3752e7..280f8e4337 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -60,6 +60,27 @@ import pluginModule from "sandbox-plugin.js"; const hooks = pluginModule?.hooks || pluginModule?.default?.hooks || {}; const routes = pluginModule?.routes || pluginModule?.default?.routes || {}; +function sandboxRouteErrorDetails(value) { + if (!value || typeof value !== "object") return null; + const code = + value.code === "MEDIA_USAGE_ACTIVATION_IN_PROGRESS" || + value.code === "MEDIA_USAGE_ACTIVATION_CHECK_FAILED" + ? value.code + : value.name === "MEDIA_USAGE_ACTIVATION_IN_PROGRESS" || + value.name === "MEDIA_USAGE_ACTIVATION_CHECK_FAILED" + ? value.name + : null; + if (!code || (value.status !== undefined && value.status !== 503)) return null; + return { + code, + message: + code === "MEDIA_USAGE_ACTIVATION_IN_PROGRESS" + ? "Media usage activation is in progress" + : "Unable to verify media usage activation state", + status: 503, + }; +} + // ----------------------------------------------------------------------------- // Context Factory - creates ctx that proxies to BRIDGE // ----------------------------------------------------------------------------- @@ -238,7 +259,18 @@ export default class PluginEntrypoint extends WorkerEntrypoint { } // Execute the route handler with input, request metadata, and context - return handler({ input, request: serializedRequest, requestMeta: serializedRequest.meta }, ctx); + try { + return await handler( + { input, request: serializedRequest, requestMeta: serializedRequest.meta }, + ctx, + ); + } catch (error) { + const details = sandboxRouteErrorDetails(error); + if (details) { + return { __emdashSandboxRouteError: true, error: details }; + } + throw error; + } } } `; diff --git a/packages/cloudflare/tests/sandbox/bridge-content-write-fence.test.ts b/packages/cloudflare/tests/sandbox/bridge-content-write-fence.test.ts new file mode 100644 index 0000000000..74b186331a --- /dev/null +++ b/packages/cloudflare/tests/sandbox/bridge-content-write-fence.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("cloudflare:workers", () => ({ + WorkerEntrypoint: class { + ctx: unknown; + env: unknown; + constructor(ctx: unknown, env: unknown) { + this.ctx = ctx; + this.env = env; + } + }, +})); + +import { PluginBridge } from "../../src/sandbox/bridge.js"; + +const bridgeContext = { + props: { + pluginId: "test-plugin", + pluginVersion: "1.0.0", + capabilities: ["content:write"], + allowedHosts: [], + storageCollections: [], + }, +}; + +function makeBridge(db: unknown) { + return new PluginBridge(bridgeContext as never, { DB: db } as never); +} + +describe("PluginBridge content write fence", () => { + it("rejects content mutations while media usage activation is incomplete", async () => { + const queries: string[] = []; + const db = { + prepare(sql: string) { + queries.push(sql); + return { + bind() { + return this; + }, + async first() { + return { state: "activating" }; + }, + async run() { + return { meta: { changes: 1 } }; + }, + }; + }, + }; + const bridge = makeBridge(db); + + await expect(bridge.contentCreate("posts", { slug: "blocked" })).rejects.toMatchObject({ + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + message: "Media usage activation is in progress", + status: 503, + }); + expect(queries).toHaveLength(1); + expect(queries[0]).toContain("_emdash_media_usage_activation"); + }); + + it("preserves content writes before the activation table is migrated", async () => { + const queries: string[] = []; + const db = { + prepare(sql: string) { + queries.push(sql); + const statement = { + bind() { + return statement; + }, + async first() { + if (sql.includes("_emdash_media_usage_activation")) { + throw new Error("D1_ERROR: no such table: _emdash_media_usage_activation"); + } + return { + id: "created-id", + created_at: "2026-08-09T00:00:00.000Z", + updated_at: "2026-08-09T00:00:00.000Z", + }; + }, + async run() { + return { meta: { changes: 1 } }; + }, + }; + return statement; + }, + }; + + await expect(makeBridge(db).contentCreate("posts", { slug: "created" })).resolves.toEqual( + expect.objectContaining({ id: "created-id", type: "posts" }), + ); + expect(queries).toHaveLength(3); + }); + + it("fails closed without exposing unexpected database errors", async () => { + const db = { + prepare() { + return { + bind() { + return this; + }, + async first() { + throw new Error("private database failure"); + }, + }; + }, + }; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect( + makeBridge(db).contentCreate("posts", { slug: "blocked" }), + ).rejects.toMatchObject({ + code: "MEDIA_USAGE_ACTIVATION_CHECK_FAILED", + message: "Unable to verify media usage activation state", + status: 503, + }); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/packages/cloudflare/tests/sandbox/runner-route-error.test.ts b/packages/cloudflare/tests/sandbox/runner-route-error.test.ts new file mode 100644 index 0000000000..65f4723491 --- /dev/null +++ b/packages/cloudflare/tests/sandbox/runner-route-error.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const invokeRoute = vi.fn(); + const invokeHook = vi.fn(); + const bridge = vi.fn(() => ({})); + const loader = { + get: vi.fn(() => ({ + getEntrypoint: () => ({ invokeHook, invokeRoute }), + })), + }; + return { bridge, invokeHook, invokeRoute, loader }; +}); + +vi.mock("cloudflare:workers", () => ({ + WorkerEntrypoint: class { + ctx: unknown; + env: unknown; + constructor(ctx: unknown, env: unknown) { + this.ctx = ctx; + this.env = env; + } + }, + env: { LOADER: mocks.loader }, + exports: { PluginBridge: mocks.bridge }, +})); + +import { CloudflareSandboxRunner } from "../../src/sandbox/runner.js"; + +describe("Cloudflare sandbox route errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("turns a structured worker result into a retryable host error", async () => { + mocks.invokeRoute.mockResolvedValue({ + __emdashSandboxRouteError: true, + error: { + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + message: "Media usage activation is in progress", + status: 503, + }, + }); + const runner = new CloudflareSandboxRunner({ db: null as never }); + const plugin = await runner.load( + { + id: "content-writer", + version: "1.0.0", + capabilities: ["content:write"], + allowedHosts: [], + storage: {}, + hooks: [], + routes: [], + admin: {}, + }, + "export default {}", + ); + + await expect( + plugin.invokeRoute( + "write", + {}, + { + url: "https://example.com/_emdash/api/plugins/content-writer/write", + method: "POST", + headers: {}, + meta: { ip: null, userAgent: null, referer: null, geo: null }, + }, + ), + ).rejects.toMatchObject({ + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + message: "Media usage activation is in progress", + status: 503, + }); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 8083fa808e..644565052f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -37,6 +37,10 @@ "types": "./dist/astro/middleware/auth.d.mts", "default": "./dist/astro/middleware/auth.mjs" }, + "./middleware/media-usage-write-fence": { + "types": "./dist/astro/middleware/media-usage-write-fence.d.mts", + "default": "./dist/astro/middleware/media-usage-write-fence.mjs" + }, "./middleware/redirect": { "types": "./dist/astro/middleware/redirect.d.mts", "default": "./dist/astro/middleware/redirect.mjs" diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts index 9d35c56c3a..92790f774c 100644 --- a/packages/core/src/api/errors.ts +++ b/packages/core/src/api/errors.ts @@ -88,6 +88,10 @@ export const ErrorCode = { MEDIA_DELETE_ERROR: "MEDIA_DELETE_ERROR", MEDIA_USAGE_READ_ERROR: "MEDIA_USAGE_READ_ERROR", MEDIA_USAGE_REPAIR_ERROR: "MEDIA_USAGE_REPAIR_ERROR", + MEDIA_USAGE_WORK_LIST_ERROR: "MEDIA_USAGE_WORK_LIST_ERROR", + MEDIA_USAGE_WORK_RETRY_ERROR: "MEDIA_USAGE_WORK_RETRY_ERROR", + WORK_LEASE_ACTIVE: "WORK_LEASE_ACTIVE", + WORK_CHANGED: "WORK_CHANGED", NO_STORAGE: "NO_STORAGE", NO_FILE: "NO_FILE", INVALID_TYPE: "INVALID_TYPE", @@ -449,6 +453,8 @@ export function mapErrorStatus(code: string | undefined): number { case ErrorCode.ALREADY_UP_TO_DATE: case ErrorCode.TRANSLATABLE_LOCKED: case ErrorCode.ENV_INCOMPATIBLE: + case ErrorCode.WORK_LEASE_ACTIVE: + case ErrorCode.WORK_CHANGED: return 409; // 410 Gone diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 2d0fadf48c..4e3b90c7f0 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -77,6 +77,16 @@ export { type MediaUsageSummary, } from "./media-usage.js"; +export { + handleMediaUsageWorkList, + handleMediaUsageWorkRetry, + type MediaUsageWorkItem, + type MediaUsageWorkListQuery, + type MediaUsageWorkListResponse, + type MediaUsageWorkRetryRequest, + type MediaUsageWorkRetryResponse, +} from "./media-usage-work.js"; + // Schema handlers export { handleSchemaCollectionList, diff --git a/packages/core/src/api/handlers/media-usage-work.ts b/packages/core/src/api/handlers/media-usage-work.ts new file mode 100644 index 0000000000..95381c8b8e --- /dev/null +++ b/packages/core/src/api/handlers/media-usage-work.ts @@ -0,0 +1,113 @@ +import type { Kysely } from "kysely"; + +import { MediaUsageWorkRepository } from "../../database/repositories/media-usage-work.js"; +import { InvalidCursorError } from "../../database/repositories/types.js"; +import type { Database } from "../../database/types.js"; +import { ErrorCode } from "../errors.js"; +import type { + MediaUsageWorkListQuery, + MediaUsageWorkListResponse, + MediaUsageWorkRetryRequest, + MediaUsageWorkRetryResponse, +} from "../schemas/media-usage.js"; +import type { ApiResult } from "../types.js"; + +export type { + MediaUsageWorkItem, + MediaUsageWorkListQuery, + MediaUsageWorkListResponse, + MediaUsageWorkRetryRequest, + MediaUsageWorkRetryResponse, +} from "../schemas/media-usage.js"; + +export async function handleMediaUsageWorkList( + db: Kysely, + query: MediaUsageWorkListQuery, +): Promise> { + try { + const page = await new MediaUsageWorkRepository(db).findOperatorPage({ + collectionSlug: query.collection, + state: query.state, + cursor: query.cursor, + limit: query.limit, + }); + if (!page) { + return { + success: false, + error: { + code: ErrorCode.COLLECTION_NOT_FOUND, + message: "Collection not found", + }, + }; + } + return { success: true, data: page }; + } catch (error) { + if (error instanceof InvalidCursorError) { + return { + success: false, + error: { + code: ErrorCode.INVALID_CURSOR, + message: "Invalid media usage work cursor", + }, + }; + } + console.error("[media-usage-work] list failed:", error); + return { + success: false, + error: { + code: ErrorCode.MEDIA_USAGE_WORK_LIST_ERROR, + message: "Failed to list media usage work", + }, + }; + } +} + +export async function handleMediaUsageWorkRetry( + db: Kysely, + input: MediaUsageWorkRetryRequest, +): Promise> { + try { + const result = await new MediaUsageWorkRepository(db).retryOperatorWork(input); + switch (result.outcome) { + case "pending": + return { + success: true, + data: { changed: result.changed, item: result.work }, + }; + case "lease_active": + return { + success: false, + error: { + code: ErrorCode.WORK_LEASE_ACTIVE, + message: "Media usage work is currently leased", + details: { leaseExpiresAt: result.leaseExpiresAt }, + }, + }; + case "collection_not_found": + return { + success: false, + error: { + code: ErrorCode.COLLECTION_NOT_FOUND, + message: "Collection not found", + }, + }; + case "conflict": + return { + success: false, + error: { + code: ErrorCode.WORK_CHANGED, + message: "Media usage work changed; retry the request", + }, + }; + } + } catch (error) { + console.error("[media-usage-work] retry failed:", error); + return { + success: false, + error: { + code: ErrorCode.MEDIA_USAGE_WORK_RETRY_ERROR, + message: "Failed to retry media usage work", + }, + }; + } +} diff --git a/packages/core/src/api/handlers/media-usage.ts b/packages/core/src/api/handlers/media-usage.ts index 6b7d33c6aa..7e2bd98547 100644 --- a/packages/core/src/api/handlers/media-usage.ts +++ b/packages/core/src/api/handlers/media-usage.ts @@ -74,9 +74,10 @@ export async function handleMediaUsageSummaries( try { const repository = new MediaUsageRepository(db); const coverage = await loadMediaUsageCoverage(repository); - const counts = options.includeCount - ? await repository.findActiveEntryCountsByMediaIds(mediaIds) - : null; + const counts = + options.includeCount && coverage.status === "complete" + ? await repository.findActiveEntryCountsByMediaIds(mediaIds) + : null; const summaries: Record = {}; for (const mediaId of new Set(mediaIds)) { @@ -212,6 +213,7 @@ function normalizeMediaUsageCoverageStatus( ): MediaUsageCoverageStatus { if (scope.status === null) return "never"; if (scope.status === "complete") { + if (scope.reconciliationRequired) return "stale"; return scope.schemaVersion === CONTENT_SOURCE_SCHEMA_VERSION ? "complete" : "stale"; } if ( diff --git a/packages/core/src/api/media-usage-write-fence.ts b/packages/core/src/api/media-usage-write-fence.ts new file mode 100644 index 0000000000..2e44ff6ec2 --- /dev/null +++ b/packages/core/src/api/media-usage-write-fence.ts @@ -0,0 +1,64 @@ +import type { Kysely } from "kysely"; + +import { tableExists } from "../database/dialect-helpers.js"; +import type { Database } from "../database/types.js"; +import { apiError } from "./error.js"; + +export interface MediaUsageActivationWriteFenceError { + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS" | "MEDIA_USAGE_ACTIVATION_CHECK_FAILED"; + message: string; + status: 503; +} + +export class MediaUsageActivationWriteBlockedError extends Error { + constructor( + readonly code: MediaUsageActivationWriteFenceError["code"], + message: string, + readonly status: 503, + ) { + super(message); + this.name = code; + } +} + +export async function checkMediaUsageActivationWriteFence( + db: Kysely, +): Promise { + const error = await findMediaUsageActivationWriteFenceError(db); + return error ? apiError(error.code, error.message, error.status) : null; +} + +export async function assertMediaUsageActivationWriteAllowed(db: Kysely): Promise { + const error = await findMediaUsageActivationWriteFenceError(db); + if (error) { + throw new MediaUsageActivationWriteBlockedError(error.code, error.message, error.status); + } +} + +export async function findMediaUsageActivationWriteFenceError( + db: Kysely, +): Promise { + if (!(await tableExists(db, "_emdash_media_usage_activation"))) return null; + try { + const row = await db + .selectFrom("_emdash_media_usage_activation") + .select("state") + .where("task_key", "=", "incremental_capture") + .executeTakeFirst(); + if (row?.state === "activating") { + return { + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + message: "Media usage activation is in progress", + status: 503, + }; + } + } catch (error) { + console.error("[media-usage] Failed to check the activation write fence:", error); + return { + code: "MEDIA_USAGE_ACTIVATION_CHECK_FAILED", + message: "Unable to verify media usage activation state", + status: 503, + }; + } + return null; +} diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts index 43649e9ad8..8fe7221c8a 100644 --- a/packages/core/src/api/openapi/document.ts +++ b/packages/core/src/api/openapi/document.ts @@ -42,6 +42,11 @@ import { mediaUsageDetailsResponseSchema, mediaUsageRepairBody, mediaUsageRepairResponseSchema, + mediaUsageWorkListQuery, + mediaUsageWorkListResponseSchema, + mediaUsageWorkRetryBody, + mediaUsageWorkRetryConflictSchema, + mediaUsageWorkRetryResponseSchema, } from "../schemas/media-usage.js"; import { DEFAULT_MAX_UPLOAD_SIZE, @@ -790,6 +795,53 @@ function buildMediaPaths(maxUploadSize: number) { }, }, }, + "/_emdash/api/admin/media-usage/work": { + get: { + operationId: "listMediaUsageWork", + summary: "List durable media usage work", + description: + "Returns one bounded cursor page of durable entry-indexing work for a current collection. Requires `schema:manage`; bearer tokens also require the `admin` scope. The response omits lease tokens, work versions, indexed content, media references, raw errors, and an exact backlog count.", + tags: ["Media"], + requestParams: { query: mediaUsageWorkListQuery }, + responses: { + "200": { + description: "Bounded media usage work page", + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaUsageWorkListResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(400, 404, 500), + }, + }, + }, + "/_emdash/api/admin/media-usage/work/retry": { + post: { + operationId: "retryMediaUsageWork", + summary: "Retry one durable media usage job", + description: + "Idempotently reopens or creates one entry-indexing job for a current immutable collection identity. Requires `schema:manage`; bearer tokens also require the `admin` scope. A live worker lease or a concurrent work change returns a stable conflict without exposing ownership tokens.", + tags: ["Media"], + requestBody: { + required: true, + content: { [JSON_CONTENT]: { schema: mediaUsageWorkRetryBody } }, + }, + responses: { + "200": { + description: "Current pending work state", + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaUsageWorkRetryResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(400, 404, 500), + "409": { + description: "The job has a live lease or changed concurrently", + content: { [JSON_CONTENT]: { schema: mediaUsageWorkRetryConflictSchema } }, + }, + }, + }, + }, "/_emdash/api/media/upload-url": { post: { operationId: "getMediaUploadUrl", diff --git a/packages/core/src/api/schemas/media-usage.ts b/packages/core/src/api/schemas/media-usage.ts index cfed0cd28b..cfec6256a8 100644 --- a/packages/core/src/api/schemas/media-usage.ts +++ b/packages/core/src/api/schemas/media-usage.ts @@ -109,8 +109,86 @@ export const mediaUsageRepairResponseSchema = z }) .meta({ id: "MediaUsageRepairResponse" }); +export const mediaUsageWorkStateSchema = z + .enum(["pending", "retry", "leased", "failed"]) + .meta({ id: "MediaUsageWorkState" }); + +export const mediaUsageWorkListQuery = z.object({ + collection: z.string().min(1).max(63).regex(slugPattern, "Invalid collection slug"), + state: mediaUsageWorkStateSchema.optional(), + cursor: z.string().min(1).max(2048).optional().meta({ + description: "Opaque work-page cursor", + }), + limit: z.coerce.number().int().min(1).max(100).optional().default(50).meta({ + description: "Maximum number of work items to return (1-100, default 50)", + }), +}); + +export const mediaUsageWorkItemSchema = z + .object({ + collectionId: z.string(), + collectionSlug: z.string(), + contentId: z.string(), + state: mediaUsageWorkStateSchema, + attemptCount: z.number().int().min(0), + nextAttemptAt: z.string(), + leaseExpiresAt: z.string().nullable(), + lastAttemptedAt: z.string().nullable(), + lastErrorCode: z.string().nullable(), + updatedAt: z.string(), + }) + .meta({ id: "MediaUsageWorkItem" }); + +export const mediaUsageWorkListResponseSchema = z + .object({ + items: z.array(mediaUsageWorkItemSchema), + nextCursor: z.string().optional(), + }) + .meta({ id: "MediaUsageWorkListResponse" }); + +const boundedOpaqueMediaUsageId = z.string().min(1).max(2048); + +export const mediaUsageWorkRetryBody = z + .object({ + collectionId: boundedOpaqueMediaUsageId.meta({ + description: "Current immutable collection identity", + }), + contentId: boundedOpaqueMediaUsageId.meta({ + description: "Content entry identity, including a deleted entry", + }), + }) + .strict() + .meta({ id: "MediaUsageWorkRetryBody" }); + +export const mediaUsageWorkRetryResponseSchema = z + .object({ + changed: z.boolean(), + item: mediaUsageWorkItemSchema, + }) + .meta({ id: "MediaUsageWorkRetryResponse" }); + +export const mediaUsageWorkRetryConflictSchema = z.object({ + success: z.literal(false), + error: z.discriminatedUnion("code", [ + z.object({ + code: z.literal("WORK_LEASE_ACTIVE"), + message: z.string(), + details: z.object({ leaseExpiresAt: z.string() }), + }), + z.object({ + code: z.literal("WORK_CHANGED"), + message: z.string(), + }), + ]), +}); + export type MediaUsageRepairRequest = z.infer; export type MediaUsageRepairResponse = z.infer; +export type MediaUsageWorkListQuery = z.infer; +export type MediaUsageWorkItem = z.infer; +export type MediaUsageWorkListResponse = z.infer; +export type MediaUsageWorkRetryRequest = z.infer; +export type MediaUsageWorkRetryResponse = z.infer; export type MediaUsageCoverageStatus = z.infer; export type MediaUsageCoverage = z.infer; export type MediaUsageSummary = z.infer; diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 8e392735ae..f2d2540d2d 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -579,6 +579,11 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { }); } + addMiddleware({ + entrypoint: "emdash/middleware/media-usage-write-fence", + order: "pre", + }); + // Add request context middleware (runs after auth, on ALL routes) // Sets up ALS-based context for query functions (edit mode, preview) addMiddleware({ diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 2ab37de39a..f7516d5984 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -249,6 +249,16 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/admin/media-usage/repair.ts"), }); + injectRoute({ + pattern: "/_emdash/api/admin/media-usage/work", + entrypoint: resolveRoute("api/admin/media-usage/work/index.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/admin/media-usage/work/retry", + entrypoint: resolveRoute("api/admin/media-usage/work/retry.ts"), + }); + // Import API routes injectRoute({ pattern: "/_emdash/api/import/probe", diff --git a/packages/core/src/astro/middleware/media-usage-write-fence.ts b/packages/core/src/astro/middleware/media-usage-write-fence.ts new file mode 100644 index 0000000000..c370c0bac5 --- /dev/null +++ b/packages/core/src/astro/middleware/media-usage-write-fence.ts @@ -0,0 +1,30 @@ +import { defineMiddleware } from "astro:middleware"; + +import { checkMediaUsageActivationWriteFence } from "#api/media-usage-write-fence.js"; + +const FENCED_WRITE_PATHS = [ + "/_emdash/api/content", + "/_emdash/api/schema", + "/_emdash/api/admin/media-usage/repair", + "/_emdash/api/revisions", + "/_emdash/api/import", + "/_emdash/api/mcp", +] as const; + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +export const onRequest = defineMiddleware(async (context, next) => { + if (!isFencedWriteRequest(context.request.method, context.url.pathname)) return next(); + const db = context.locals.emdash?.db; + if (!db) return next(); + return (await checkMediaUsageActivationWriteFence(db)) ?? next(); +}); + +function isFencedWriteRequest(method: string, pathname: string): boolean { + if (SAFE_METHODS.has(method.toUpperCase())) return false; + return FENCED_WRITE_PATHS.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ); +} + +export default onRequest; diff --git a/packages/core/src/astro/routes/api/admin/media-usage/work/index.ts b/packages/core/src/astro/routes/api/admin/media-usage/work/index.ts new file mode 100644 index 0000000000..6215e3c7ef --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/media-usage/work/index.ts @@ -0,0 +1,26 @@ +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { requireDb, unwrapResult } from "#api/error.js"; +import { handleMediaUsageWorkList } from "#api/handlers/media-usage-work.js"; +import { isParseError, parseQuery } from "#api/parse.js"; +import { mediaUsageWorkListQuery } from "#api/schemas.js"; +import { requireScope } from "#auth/scopes.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request, locals }) => { + const { emdash, user } = locals; + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:manage"); + if (denied) return denied; + const scopeDenied = requireScope(locals, "admin"); + if (scopeDenied) return scopeDenied; + + const query = parseQuery(new URL(request.url), mediaUsageWorkListQuery); + if (isParseError(query)) return query; + + return unwrapResult(await handleMediaUsageWorkList(emdash.db, query)); +}; diff --git a/packages/core/src/astro/routes/api/admin/media-usage/work/retry.ts b/packages/core/src/astro/routes/api/admin/media-usage/work/retry.ts new file mode 100644 index 0000000000..a3aee6b7ff --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/media-usage/work/retry.ts @@ -0,0 +1,26 @@ +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { requireDb, unwrapResult } from "#api/error.js"; +import { handleMediaUsageWorkRetry } from "#api/handlers/media-usage-work.js"; +import { isParseError, parseBody } from "#api/parse.js"; +import { mediaUsageWorkRetryBody } from "#api/schemas.js"; +import { requireScope } from "#auth/scopes.js"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash, user } = locals; + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:manage"); + if (denied) return denied; + const scopeDenied = requireScope(locals, "admin"); + if (scopeDenied) return scopeDenied; + + const body = await parseBody(request, mediaUsageWorkRetryBody); + if (isParseError(body)) return body; + + return unwrapResult(await handleMediaUsageWorkRetry(emdash.db, body)); +}; diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/disable.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/disable.ts index 9013c7f1ad..48e0ccf0a3 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/disable.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/disable.ts @@ -9,6 +9,7 @@ import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; import { apiError, unwrapResult } from "#api/error.js"; import { handlePluginDisable } from "#api/index.js"; +import { checkMediaUsageActivationWriteFence } from "#api/media-usage-write-fence.js"; import { setCronTasksEnabled } from "#plugins/cron.js"; export const prerender = false; @@ -24,6 +25,9 @@ export const POST: APIRoute = async ({ params, locals }) => { const denied = requirePerm(user, "plugins:manage"); if (denied) return denied; + const activationFence = await checkMediaUsageActivationWriteFence(emdash.db); + if (activationFence) return activationFence; + if (!id) { return apiError("INVALID_REQUEST", "Plugin ID required", 400); } diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts index 957b919110..789d35ea56 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts @@ -9,6 +9,7 @@ import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; import { apiError, unwrapResult } from "#api/error.js"; import { handlePluginEnable } from "#api/index.js"; +import { checkMediaUsageActivationWriteFence } from "#api/media-usage-write-fence.js"; import { setCronTasksEnabled } from "#plugins/cron.js"; export const prerender = false; @@ -24,6 +25,9 @@ export const POST: APIRoute = async ({ params, locals }) => { const denied = requirePerm(user, "plugins:manage"); if (denied) return denied; + const activationFence = await checkMediaUsageActivationWriteFence(emdash.db); + if (activationFence) return activationFence; + if (!id) { return apiError("INVALID_REQUEST", "Plugin ID required", 400); } diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/uninstall.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/uninstall.ts index d0e474d499..9ed141fe45 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/uninstall.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/uninstall.ts @@ -10,6 +10,7 @@ import { z } from "zod"; import { requirePerm } from "#api/authorize.js"; import { apiError, unwrapResult } from "#api/error.js"; import { handleMarketplaceUninstall } from "#api/index.js"; +import { checkMediaUsageActivationWriteFence } from "#api/media-usage-write-fence.js"; import { isParseError, parseOptionalBody } from "#api/parse.js"; export const prerender = false; @@ -29,6 +30,9 @@ export const POST: APIRoute = async ({ params, request, locals }) => { const denied = requirePerm(user, "plugins:manage"); if (denied) return denied; + const activationFence = await checkMediaUsageActivationWriteFence(emdash.db); + if (activationFence) return activationFence; + if (!id) { return apiError("INVALID_REQUEST", "Plugin ID required", 400); } diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts index 3129a2d538..ef812e118a 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts @@ -10,6 +10,7 @@ import { z } from "zod"; import { requirePerm } from "#api/authorize.js"; import { apiError, unwrapResult } from "#api/error.js"; import { handleMarketplaceUpdate } from "#api/index.js"; +import { checkMediaUsageActivationWriteFence } from "#api/media-usage-write-fence.js"; import { isParseError, parseOptionalBody } from "#api/parse.js"; export const prerender = false; @@ -32,6 +33,9 @@ export const POST: APIRoute = async ({ params, request, locals }) => { const denied = requirePerm(user, "plugins:manage"); if (denied) return denied; + const activationFence = await checkMediaUsageActivationWriteFence(emdash.db); + if (activationFence) return activationFence; + if (!id) { return apiError("INVALID_REQUEST", "Plugin ID required", 400); } diff --git a/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts b/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts index d8d3b7c5c3..e4d0a98787 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts @@ -10,6 +10,7 @@ import { z } from "zod"; import { requirePerm } from "#api/authorize.js"; import { apiError, handleError, unwrapResult } from "#api/error.js"; import { handleMarketplaceInstall } from "#api/index.js"; +import { checkMediaUsageActivationWriteFence } from "#api/media-usage-write-fence.js"; import { isParseError, parseOptionalBody } from "#api/parse.js"; export const prerender = false; @@ -31,6 +32,9 @@ export const POST: APIRoute = async ({ params, request, locals }) => { const denied = requirePerm(user, "plugins:manage"); if (denied) return denied; + const activationFence = await checkMediaUsageActivationWriteFence(emdash.db); + if (activationFence) return activationFence; + if (!id) { return apiError("INVALID_REQUEST", "Plugin ID required", 400); } diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/uninstall.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/uninstall.ts index 64b5d60c56..8e87afb6e5 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/uninstall.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/uninstall.ts @@ -13,6 +13,7 @@ import { z } from "zod"; import { requirePerm } from "#api/authorize.js"; import { apiError, unwrapResult } from "#api/error.js"; import { handleRegistryUninstall } from "#api/index.js"; +import { checkMediaUsageActivationWriteFence } from "#api/media-usage-write-fence.js"; import { isParseError, parseOptionalBody } from "#api/parse.js"; export const prerender = false; @@ -32,6 +33,9 @@ export const POST: APIRoute = async ({ params, request, locals }) => { const denied = requirePerm(user, "plugins:manage"); if (denied) return denied; + const activationFence = await checkMediaUsageActivationWriteFence(emdash.db); + if (activationFence) return activationFence; + if (!id) { return apiError("INVALID_REQUEST", "Plugin ID required", 400); } diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts index 8f4f163197..c6f1745b9b 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts @@ -16,6 +16,7 @@ import { z } from "zod"; import { requirePerm } from "#api/authorize.js"; import { apiError, handleError, unwrapResult } from "#api/error.js"; import { handleRegistryUpdate } from "#api/index.js"; +import { checkMediaUsageActivationWriteFence } from "#api/media-usage-write-fence.js"; import { isParseError, parseOptionalBody } from "#api/parse.js"; import { VERSION } from "../../../../../../../version.js"; @@ -51,6 +52,9 @@ export const POST: APIRoute = async ({ params, request, locals }) => { const denied = requirePerm(user, "plugins:manage"); if (denied) return denied; + const activationFence = await checkMediaUsageActivationWriteFence(emdash.db); + if (activationFence) return activationFence; + if (!id) { return apiError("INVALID_REQUEST", "Plugin ID required", 400); } diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts index 137536cd35..c6b223a92b 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts @@ -19,6 +19,7 @@ import { z } from "zod"; import { requirePerm } from "#api/authorize.js"; import { apiError, handleError, unwrapResult } from "#api/error.js"; import { handleRegistryInstall } from "#api/index.js"; +import { checkMediaUsageActivationWriteFence } from "#api/media-usage-write-fence.js"; import { isParseError, parseBody } from "#api/parse.js"; import { VERSION } from "../../../../../../version.js"; @@ -71,6 +72,9 @@ export const POST: APIRoute = async ({ request, locals }) => { const denied = requirePerm(user, "plugins:manage"); if (denied) return denied; + const activationFence = await checkMediaUsageActivationWriteFence(emdash.db); + if (activationFence) return activationFence; + const body = await parseBody(request, installBodySchema); if (isParseError(body)) return body; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 47c6134eaf..c226413c53 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -228,6 +228,49 @@ export interface MediaUsageRepairResponse { collections: MediaUsageRepairCollectionSummary[]; } +/** Durable media usage entry-work state */ +export type MediaUsageWorkState = "pending" | "retry" | "leased" | "failed"; + +/** Redacted operator view of one durable media usage job */ +export interface MediaUsageWorkItem { + collectionId: string; + collectionSlug: string; + contentId: string; + state: MediaUsageWorkState; + attemptCount: number; + nextAttemptAt: string; + leaseExpiresAt: string | null; + lastAttemptedAt: string | null; + lastErrorCode: string | null; + updatedAt: string; +} + +/** Filters and pagination for durable media usage work */ +export interface MediaUsageWorkListOptions { + collection: string; + state?: MediaUsageWorkState; + limit?: number; + cursor?: string; +} + +/** One bounded page of durable media usage work */ +export interface MediaUsageWorkListResponse { + items: MediaUsageWorkItem[]; + nextCursor?: string; +} + +/** Identity for explicitly retrying one durable media usage job */ +export interface MediaUsageWorkRetryInput { + collectionId: string; + contentId: string; +} + +/** Result of explicitly retrying one durable media usage job */ +export interface MediaUsageWorkRetryResponse { + changed: boolean; + item: MediaUsageWorkItem; +} + /** Search result */ export interface SearchResult { id: string; @@ -818,6 +861,26 @@ export class EmDashClient { return this.request("POST", "/admin/media-usage/repair", input); } + /** List a bounded page of durable media usage entry work */ + async mediaListUsageWork( + options: MediaUsageWorkListOptions, + ): Promise { + const params = new URLSearchParams({ collection: options.collection }); + if (options.state) params.set("state", options.state); + if (options.limit !== undefined) params.set("limit", String(options.limit)); + if (options.cursor) params.set("cursor", options.cursor); + return this.request("GET", `/admin/media-usage/work?${params}`); + } + + /** Explicitly retry one durable media usage entry job */ + async mediaRetryUsageWork(input: MediaUsageWorkRetryInput): Promise { + return this.request( + "POST", + "/admin/media-usage/work/retry", + input, + ); + } + // ----------------------------------------------------------------------- // Search // ----------------------------------------------------------------------- diff --git a/packages/core/src/database/migrations/063_media_usage_incremental_work.ts b/packages/core/src/database/migrations/063_media_usage_incremental_work.ts new file mode 100644 index 0000000000..3086207e48 --- /dev/null +++ b/packages/core/src/database/migrations/063_media_usage_incremental_work.ts @@ -0,0 +1,397 @@ +import { sql, type Kysely, type RawBuilder } from "kysely"; + +import { columnExists, isPostgres, tableExists } from "../dialect-helpers.js"; + +const ACTIVATION_KEY = "incremental_capture"; +const CONTENT_ADAPTER_ID = "content-media"; +const COLLECTION_SCOPE = "collection"; +const DUPLICATE_COLUMN_RE = /(?:duplicate column|column .* already exists|already exists.*column)/i; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("_emdash_media_usage_activation") + .ifNotExists() + .addColumn("task_key", "text", (column) => column.primaryKey()) + .addColumn("state", "text", (column) => column.notNull().defaultTo("expanded")) + .addColumn("runtime_generation", "integer", (column) => column.notNull().defaultTo(1)) + .addColumn("collection_cursor", "text") + .addColumn("drain_confirmed_at", "text") + .addColumn("lease_token", "text") + .addColumn("lease_expires_at", "text") + .addColumn("attempt_count", "integer", (column) => column.notNull().defaultTo(0)) + .addColumn("last_attempted_at", "text") + .addColumn("last_error_code", "text") + .addColumn("activated_at", "text") + .addColumn("created_at", "text", (column) => + column.notNull().defaultTo(sortableUtcTimestamp(db)), + ) + .addColumn("updated_at", "text", (column) => + column.notNull().defaultTo(sortableUtcTimestamp(db)), + ) + .execute(); + + await sql` + INSERT INTO _emdash_media_usage_activation (task_key, state) + VALUES (${ACTIVATION_KEY}, 'expanded') + ON CONFLICT (task_key) DO NOTHING + `.execute(db); + + await db.schema + .createTable("_emdash_media_usage_work") + .ifNotExists() + .addColumn("collection_id", "text", (column) => column.notNull()) + .addColumn("collection_slug", "text", (column) => column.notNull()) + .addColumn("content_id", "text", (column) => column.notNull()) + .addColumn("change_epoch", "bigint", (column) => column.notNull()) + .addColumn("work_version", "bigint", (column) => column.notNull().defaultTo(1)) + .addColumn("state", "text", (column) => column.notNull().defaultTo("pending")) + .addColumn("attempt_count", "integer", (column) => column.notNull().defaultTo(0)) + .addColumn("next_attempt_at", "text", (column) => column.notNull()) + .addColumn("lease_token", "text") + .addColumn("lease_expires_at", "text") + .addColumn("last_attempted_at", "text") + .addColumn("last_error_code", "text") + .addColumn("created_at", "text", (column) => + column.notNull().defaultTo(sortableUtcTimestamp(db)), + ) + .addColumn("updated_at", "text", (column) => + column.notNull().defaultTo(sortableUtcTimestamp(db)), + ) + .addPrimaryKeyConstraint("_emdash_media_usage_work_pk", ["collection_id", "content_id"]) + .execute(); + + await addStatusColumns(db); + await addSourceColumns(db); + + await db.schema + .createIndex("idx__emdash_media_usage_work_due") + .ifNotExists() + .on("_emdash_media_usage_work") + .columns(["state", "next_attempt_at", "updated_at", "collection_id", "content_id"]) + .execute(); + await db.schema + .createIndex("idx__emdash_media_usage_work_lease") + .ifNotExists() + .on("_emdash_media_usage_work") + .columns(["state", "lease_expires_at", "collection_id", "content_id"]) + .execute(); + await db.schema + .createIndex("idx__emdash_media_usage_work_operator") + .ifNotExists() + .on("_emdash_media_usage_work") + .columns(["collection_id", "state", "updated_at", "content_id"]) + .execute(); + await db.schema + .createIndex("idx__emdash_media_usage_status_collection") + .ifNotExists() + .unique() + .on("_emdash_media_usage_index_status") + .columns(["adapter_id", "scope_type", "collection_id"]) + .execute(); + await db.schema + .createIndex("idx__emdash_media_usage_sources_identity") + .ifNotExists() + .on("_emdash_media_usage_sources") + .columns(["source_type", "collection_id", "content_id", "source_variant"]) + .execute(); + + await sql` + DELETE FROM _emdash_media_usage_index_status + WHERE adapter_id = ${CONTENT_ADAPTER_ID} + AND scope_type = ${COLLECTION_SCOPE} + AND ( + ( + collection_id IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.slug = _emdash_media_usage_index_status.scope_key + ) + ) + OR ( + collection_id IS NOT NULL + AND capture_state = 'installing' + AND EXISTS ( + SELECT 1 + FROM _emdash_media_usage_activation AS activation + WHERE activation.task_key = ${ACTIVATION_KEY} + AND activation.state = 'expanded' + ) + AND NOT EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.id = _emdash_media_usage_index_status.collection_id + AND collection.slug = _emdash_media_usage_index_status.scope_key + ) + ) + ) + `.execute(db); + + await sql` + UPDATE _emdash_media_usage_index_status + SET collection_id = ( + SELECT collection.id + FROM _emdash_collections AS collection + WHERE collection.slug = _emdash_media_usage_index_status.scope_key + ), + reconciliation_required = 1, + capture_state = COALESCE(capture_state, 'installing') + WHERE adapter_id = ${CONTENT_ADAPTER_ID} + AND scope_type = ${COLLECTION_SCOPE} + AND ( + collection_id IS NULL + OR capture_state IS NULL + ) + AND EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.id = COALESCE( + _emdash_media_usage_index_status.collection_id, + collection.id + ) + AND collection.slug = _emdash_media_usage_index_status.scope_key + ) + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await assertRollbackIsEmptyAndInactive(db); + + await db.schema.dropIndex("idx__emdash_media_usage_sources_identity").ifExists().execute(); + await db.schema.dropIndex("idx__emdash_media_usage_status_collection").ifExists().execute(); + await db.schema.dropIndex("idx__emdash_media_usage_work_operator").ifExists().execute(); + await db.schema.dropIndex("idx__emdash_media_usage_work_lease").ifExists().execute(); + await db.schema.dropIndex("idx__emdash_media_usage_work_due").ifExists().execute(); + await db.schema.dropTable("_emdash_media_usage_work").ifExists().execute(); + await db.schema.dropTable("_emdash_media_usage_activation").ifExists().execute(); + if (isPostgres(db)) { + await sql`DROP FUNCTION IF EXISTS emdash_media_usage_capture_work()`.execute(db); + } + + for (const columnName of ["identity_version", "collection_id"] as const) { + if (await columnExists(db, "_emdash_media_usage_sources", columnName)) { + await db.schema.alterTable("_emdash_media_usage_sources").dropColumn(columnName).execute(); + } + } + + for (const columnName of [ + "capture_state", + "last_incremental_success_at", + "reconciliation_required", + "change_epoch", + "collection_id", + ] as const) { + if (await columnExists(db, "_emdash_media_usage_index_status", columnName)) { + await db.schema + .alterTable("_emdash_media_usage_index_status") + .dropColumn(columnName) + .execute(); + } + } +} + +async function addStatusColumns(db: Kysely): Promise { + await addColumnIfMissing(db, "_emdash_media_usage_index_status", "collection_id", () => + db.schema + .alterTable("_emdash_media_usage_index_status") + .addColumn("collection_id", "text") + .execute(), + ); + await addColumnIfMissing(db, "_emdash_media_usage_index_status", "change_epoch", () => + db.schema + .alterTable("_emdash_media_usage_index_status") + .addColumn("change_epoch", "bigint", (column) => column.notNull().defaultTo(0)) + .execute(), + ); + await addColumnIfMissing(db, "_emdash_media_usage_index_status", "reconciliation_required", () => + db.schema + .alterTable("_emdash_media_usage_index_status") + .addColumn("reconciliation_required", "integer", (column) => column.notNull().defaultTo(0)) + .execute(), + ); + await addStatusTextColumn(db, "last_incremental_success_at"); + await addStatusTextColumn(db, "capture_state"); +} + +async function assertRollbackIsEmptyAndInactive(db: Kysely): Promise { + if (await hasCaptureTriggers(db)) { + throw new Error("Cannot roll back media usage capture while capture triggers are installed"); + } + + if (await tableExists(db, "_emdash_media_usage_activation")) { + const activation = await sql<{ state: string }>` + SELECT state + FROM _emdash_media_usage_activation + WHERE task_key = ${ACTIVATION_KEY} + `.execute(db); + if (activation.rows[0] && activation.rows[0].state !== "expanded") { + throw new Error("Cannot roll back media usage capture after activation has started"); + } + } + + if (await tableExists(db, "_emdash_media_usage_work")) { + const work = await sql<{ present: number }>` + SELECT 1 AS present FROM _emdash_media_usage_work LIMIT 1 + `.execute(db); + if (work.rows.length > 0) { + throw new Error("Cannot roll back media usage capture while durable work exists"); + } + } + + const sourceHasCollectionId = await columnExists( + db, + "_emdash_media_usage_sources", + "collection_id", + ); + const sourceHasIdentityVersion = await columnExists( + db, + "_emdash_media_usage_sources", + "identity_version", + ); + if (sourceHasCollectionId || sourceHasIdentityVersion) { + const canonicalSources = sourceHasCollectionId + ? await sql<{ present: number }>` + SELECT 1 AS present + FROM _emdash_media_usage_sources + WHERE collection_id IS NOT NULL + LIMIT 1 + `.execute(db) + : await sql<{ present: number }>` + SELECT 1 AS present + FROM _emdash_media_usage_sources + WHERE identity_version IS NOT NULL + LIMIT 1 + `.execute(db); + if (canonicalSources.rows.length > 0) { + throw new Error("Cannot roll back media usage capture after canonical sources exist"); + } + if (sourceHasCollectionId && sourceHasIdentityVersion) { + const versionedSources = await sql<{ present: number }>` + SELECT 1 AS present + FROM _emdash_media_usage_sources + WHERE identity_version IS NOT NULL + LIMIT 1 + `.execute(db); + if (versionedSources.rows.length > 0) { + throw new Error("Cannot roll back media usage capture after canonical sources exist"); + } + } + } + + if (await columnExists(db, "_emdash_media_usage_index_status", "capture_state")) { + const lifecycle = await sql<{ present: number }>` + SELECT 1 AS present FROM _emdash_media_usage_index_status + WHERE capture_state IN ('active', 'deleting') LIMIT 1 + `.execute(db); + if (lifecycle.rows.length > 0) throwRollbackLifecycleError(); + } + if (await columnExists(db, "_emdash_media_usage_index_status", "change_epoch")) { + const epoch = await sql<{ present: number }>` + SELECT 1 AS present FROM _emdash_media_usage_index_status + WHERE change_epoch <> 0 LIMIT 1 + `.execute(db); + if (epoch.rows.length > 0) throwRollbackLifecycleError(); + } + if (await columnExists(db, "_emdash_media_usage_index_status", "last_incremental_success_at")) { + const success = await sql<{ present: number }>` + SELECT 1 AS present FROM _emdash_media_usage_index_status + WHERE last_incremental_success_at IS NOT NULL LIMIT 1 + `.execute(db); + if (success.rows.length > 0) throwRollbackLifecycleError(); + } +} + +function throwRollbackLifecycleError(): never { + throw new Error("Cannot roll back media usage capture while capture lifecycle state exists"); +} + +async function hasCaptureTriggers(db: Kysely): Promise { + if (isPostgres(db)) { + const result = await sql<{ present: boolean }>` + SELECT EXISTS ( + SELECT 1 + FROM pg_trigger AS trigger + INNER JOIN pg_class AS relation ON relation.oid = trigger.tgrelid + INNER JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = current_schema() + AND NOT trigger.tgisinternal + AND left(trigger.tgname, 10) = 'emdash_mu_' + ) AS present + `.execute(db); + return result.rows[0]?.present === true; + } + + const result = await sql<{ present: number }>` + SELECT 1 AS present + FROM sqlite_master + WHERE type = 'trigger' AND substr(name, 1, 10) = 'emdash_mu_' + LIMIT 1 + `.execute(db); + return result.rows.length > 0; +} + +async function addStatusTextColumn(db: Kysely, columnName: string): Promise { + await addColumnIfMissing(db, "_emdash_media_usage_index_status", columnName, () => + db.schema + .alterTable("_emdash_media_usage_index_status") + .addColumn(columnName, "text") + .execute(), + ); +} + +async function addSourceColumns(db: Kysely): Promise { + await addColumnIfMissing(db, "_emdash_media_usage_sources", "collection_id", () => + db.schema + .alterTable("_emdash_media_usage_sources") + .addColumn("collection_id", "text") + .execute(), + ); + await addColumnIfMissing(db, "_emdash_media_usage_sources", "identity_version", () => + db.schema + .alterTable("_emdash_media_usage_sources") + .addColumn("identity_version", "integer") + .execute(), + ); +} + +async function addColumnIfMissing( + db: Kysely, + tableName: string, + columnName: string, + addColumn: () => Promise, +): Promise { + if (await columnExists(db, tableName, columnName)) return; + + try { + await addColumn(); + } catch (error) { + if (DUPLICATE_COLUMN_RE.test(deepErrorMessage(error))) { + if (await columnExists(db, tableName, columnName)) return; + } + throw error; + } +} + +function sortableUtcTimestamp(db: Kysely): RawBuilder { + if (isPostgres(db)) { + return sql`to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`; + } + return sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`; +} + +function deepErrorMessage(error: unknown): string { + if (error instanceof Error) { + const own = error.message ?? ""; + if (error.cause) { + const causeMessage = deepErrorMessage(error.cause); + return own ? `${own}: ${causeMessage}` : causeMessage; + } + return own; + } + if (typeof error === "string") return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 52dc2ea86b..a823eaa57b 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -65,6 +65,7 @@ import * as m059 from "./059_revision_prune_queue.js"; import * as m060 from "./060_collection_admin_config.js"; import * as m061 from "./061_media_usage_cleanup.js"; import * as m062 from "./062_media_usage_cleanup_fence.js"; +import * as m063 from "./063_media_usage_incremental_work.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -128,6 +129,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "060_collection_admin_config": m060, "061_media_usage_cleanup": m061, "062_media_usage_cleanup_fence": m062, + "063_media_usage_incremental_work": m063, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/repositories/media-usage-work.ts b/packages/core/src/database/repositories/media-usage-work.ts new file mode 100644 index 0000000000..ffa48e1474 --- /dev/null +++ b/packages/core/src/database/repositories/media-usage-work.ts @@ -0,0 +1,754 @@ +import { sql, type Kysely, type RawBuilder, type Selectable } from "kysely"; +import { ulid } from "ulidx"; + +import { isPostgres } from "../dialect-helpers.js"; +import type { Database, MediaUsageWorkTable } from "../types.js"; +import { decodeCursor, encodeCursor, type FindManyResult } from "./types.js"; + +export type MediaUsageWorkState = "pending" | "retry" | "leased" | "failed"; +export type MediaUsageWorkVersion = number | string; +const MAX_PORTABLE_DURATION_SECONDS = 365 * 24 * 60 * 60; +const MAX_WORK_SELECTION_LIMIT = 100; +const NON_NEGATIVE_DECIMAL_PATTERN = /^(?:0|[1-9][0-9]*)$/; +const POSITIVE_DECIMAL_PATTERN = /^[1-9][0-9]*$/; +const STABLE_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; + +export const MEDIA_USAGE_WORK_OPERATOR_DEFAULT_LIMIT = 50; +export const MEDIA_USAGE_WORK_OPERATOR_MAX_LIMIT = 100; +const MEDIA_USAGE_WORK_STATES = ["pending", "retry", "leased", "failed"] as const; + +export interface MediaUsageWorkIdentity { + collectionId: string; + contentId: string; + workVersion: MediaUsageWorkVersion; +} + +export interface MediaUsageWorkLease extends MediaUsageWorkIdentity { + leaseToken: string; +} + +export interface MediaUsageWorkRecord extends MediaUsageWorkIdentity { + collectionSlug: string; + changeEpoch: number | string; + state: MediaUsageWorkState; + attemptCount: number; + nextAttemptAt: string; + leaseToken: string | null; + leaseExpiresAt: string | null; + lastAttemptedAt: string | null; + lastErrorCode: string | null; + createdAt: string; + updatedAt: string; +} + +export interface MediaUsageOperatorWorkItem { + collectionId: string; + collectionSlug: string; + contentId: string; + state: MediaUsageWorkState; + attemptCount: number; + nextAttemptAt: string; + leaseExpiresAt: string | null; + lastAttemptedAt: string | null; + lastErrorCode: string | null; + updatedAt: string; +} + +export type MediaUsageOperatorRetryResult = + | { outcome: "pending"; changed: boolean; work: MediaUsageOperatorWorkItem } + | { outcome: "lease_active"; leaseExpiresAt: string } + | { outcome: "collection_not_found" } + | { outcome: "conflict" }; + +export class MediaUsageWorkRepository { + constructor(private db: Kysely) {} + + async findOperatorPage(options: { + collectionSlug: string; + state?: MediaUsageWorkState; + limit?: number; + cursor?: string; + }): Promise | null> { + if (!options.collectionSlug) { + throw new Error("Media usage work listing requires a collection slug"); + } + if (options.state !== undefined && !isMediaUsageWorkState(options.state)) { + throw new Error("Media usage work listing requires a valid state"); + } + const limit = operatorLimit(options.limit); + const cursor = options.cursor ? decodeCursor(options.cursor) : null; + const collection = await this.db + .selectFrom("_emdash_collections") + .select(["id", "slug"]) + .where("slug", "=", options.collectionSlug) + .executeTakeFirst(); + if (!collection) return null; + + const states = options.state ? [options.state] : MEDIA_USAGE_WORK_STATES; + const candidates: MediaUsageOperatorWorkItem[] = []; + for (const state of states) { + const rows = await this.findOperatorRows({ + collectionId: collection.id, + collectionSlug: collection.slug, + state, + limit: limit + 1, + cursor, + }); + candidates.push(...rows.map(rowToOperatorWork)); + } + + const ordered = candidates.toSorted(compareOperatorWork); + const items = ordered.slice(0, limit); + const result: FindManyResult = { items }; + if (ordered.length > limit && items.length > 0) { + const last = items.at(-1)!; + result.nextCursor = encodeCursor(last.updatedAt, last.contentId); + } + return result; + } + + private async findOperatorRows(input: { + collectionId: string; + collectionSlug: string; + state: MediaUsageWorkState; + limit: number; + cursor: { orderValue: string; id: string } | null; + }): Promise[]> { + let query = this.db + .selectFrom("_emdash_media_usage_work as work") + .innerJoin("_emdash_collections as current_collection", (join) => + join + .onRef("current_collection.id", "=", "work.collection_id") + .onRef("current_collection.slug", "=", "work.collection_slug"), + ) + .selectAll("work") + .where("work.collection_id", "=", input.collectionId) + .where("work.collection_slug", "=", input.collectionSlug) + .where("work.state", "=", input.state); + if (input.cursor) { + query = query.where((eb) => + eb.or([ + eb("work.updated_at", "<", input.cursor!.orderValue), + eb.and([ + eb("work.updated_at", "=", input.cursor!.orderValue), + eb("work.content_id", "<", input.cursor!.id), + ]), + ]), + ); + } + return query + .orderBy("work.updated_at", "desc") + .orderBy("work.content_id", "desc") + .limit(input.limit) + .execute(); + } + + async retryOperatorWork(input: { + collectionId: string; + contentId: string; + }): Promise { + if (!input.collectionId || !input.contentId) { + throw new Error("Media usage operator retry requires collection and content IDs"); + } + const collection = await this.findActiveOperatorCollection(input.collectionId); + if (!collection) return { outcome: "collection_not_found" }; + + const observed = await this.findWorkByIdentity(input.collectionId, input.contentId); + if (observed?.state === "pending" && observed.collection_slug === collection.slug) { + return { outcome: "pending", changed: false, work: rowToOperatorWork(observed) }; + } + + const invalidated = await this.invalidateCoverageForOperatorRetry({ + ...input, + collectionSlug: collection.slug, + observed, + }); + if (!invalidated) return this.operatorRetryLost(input); + + const reopened = observed + ? await this.reopenObservedWork(observed, collection.slug, invalidated.change_epoch) + : await this.createOperatorWork({ + ...input, + collectionSlug: collection.slug, + changeEpoch: invalidated.change_epoch, + }); + if (reopened) { + return { outcome: "pending", changed: true, work: rowToOperatorWork(reopened) }; + } + return this.operatorRetryLost(input); + } + + private async findActiveOperatorCollection( + collectionId: string, + ): Promise<{ id: string; slug: string } | null> { + const row = await this.db + .selectFrom("_emdash_collections as collection") + .innerJoin("_emdash_media_usage_index_status as status", (join) => + join + .onRef("status.collection_id", "=", "collection.id") + .onRef("status.scope_key", "=", "collection.slug"), + ) + .select(["collection.id", "collection.slug"]) + .where("collection.id", "=", collectionId) + .where("status.adapter_id", "=", "content-media") + .where("status.scope_type", "=", "collection") + .where("status.capture_state", "=", "active") + .executeTakeFirst(); + return row ?? null; + } + + private async findWorkByIdentity( + collectionId: string, + contentId: string, + ): Promise | null> { + return ( + (await this.db + .selectFrom("_emdash_media_usage_work") + .selectAll() + .where("collection_id", "=", collectionId) + .where("content_id", "=", contentId) + .executeTakeFirst()) ?? null + ); + } + + private async invalidateCoverageForOperatorRetry(input: { + collectionId: string; + collectionSlug: string; + contentId: string; + observed: Selectable | null; + }): Promise<{ change_epoch: number | string } | null> { + let query = this.db + .updateTable("_emdash_media_usage_index_status as status") + .set({ + change_epoch: sql`change_epoch + 1`, + status: sql`CASE WHEN status = 'complete' THEN 'stale' ELSE status END`, + completed_at: sql< + string | null + >`CASE WHEN status = 'complete' THEN NULL ELSE completed_at END`, + updated_at: this.timestampOffset(0), + }) + .where("status.adapter_id", "=", "content-media") + .where("status.scope_type", "=", "collection") + .where("status.scope_key", "=", input.collectionSlug) + .where("status.collection_id", "=", input.collectionId) + .where("status.capture_state", "=", "active") + .where((eb) => + eb.exists( + eb + .selectFrom("_emdash_collections as collection") + .select("collection.id") + .whereRef("collection.id", "=", "status.collection_id") + .whereRef("collection.slug", "=", "status.scope_key"), + ), + ); + + const observed = input.observed; + if (observed) { + query = query.where((eb) => { + let work = eb + .selectFrom("_emdash_media_usage_work as work") + .select("work.content_id") + .whereRef("work.collection_id", "=", "status.collection_id") + .where("work.content_id", "=", input.contentId) + .where("work.work_version", "=", observed.work_version) + .where("work.state", "=", observed.state); + if (observed.state === "leased") { + work = work + .where("work.lease_expires_at", "is not", null) + .where(this.timestampIsDue("work.lease_expires_at")); + } + return eb.exists(work); + }); + } else { + query = query.where((eb) => + eb.not( + eb.exists( + eb + .selectFrom("_emdash_media_usage_work as work") + .select("work.content_id") + .whereRef("work.collection_id", "=", "status.collection_id") + .where("work.content_id", "=", input.contentId), + ), + ), + ); + } + + return (await query.returning("change_epoch").executeTakeFirst()) ?? null; + } + + private async reopenObservedWork( + observed: Selectable, + collectionSlug: string, + changeEpoch: number | string, + ): Promise | null> { + let query = this.db + .updateTable("_emdash_media_usage_work") + .set({ + collection_slug: collectionSlug, + change_epoch: changeEpoch, + work_version: sql`work_version + 1`, + state: "pending", + attempt_count: 0, + next_attempt_at: this.timestampOffset(0), + lease_token: null, + lease_expires_at: null, + last_attempted_at: null, + last_error_code: null, + updated_at: this.timestampOffset(0), + }) + .where("collection_id", "=", observed.collection_id) + .where("content_id", "=", observed.content_id) + .where("work_version", "=", observed.work_version) + .where("state", "=", observed.state) + .where((eb) => + eb.exists( + eb + .selectFrom("_emdash_collections as collection") + .innerJoin("_emdash_media_usage_index_status as status", (join) => + join + .onRef("status.collection_id", "=", "collection.id") + .onRef("status.scope_key", "=", "collection.slug"), + ) + .select("collection.id") + .where("collection.id", "=", observed.collection_id) + .where("collection.slug", "=", collectionSlug) + .where("status.adapter_id", "=", "content-media") + .where("status.scope_type", "=", "collection") + .where("status.capture_state", "=", "active") + .where("status.change_epoch", "=", changeEpoch), + ), + ); + if (observed.state === "leased") { + query = query + .where("lease_expires_at", "is not", null) + .where(this.timestampIsDue("lease_expires_at")); + } + return (await query.returningAll().executeTakeFirst()) ?? null; + } + + private async createOperatorWork(input: { + collectionId: string; + collectionSlug: string; + contentId: string; + changeEpoch: number | string; + }): Promise | null> { + const now = this.timestampOffset(0); + return ( + (await this.db + .insertInto("_emdash_media_usage_work") + .columns([ + "collection_id", + "collection_slug", + "content_id", + "change_epoch", + "work_version", + "state", + "attempt_count", + "next_attempt_at", + "lease_token", + "lease_expires_at", + "last_attempted_at", + "last_error_code", + "created_at", + "updated_at", + ]) + .expression((insert) => + insert + .selectFrom("_emdash_media_usage_index_status as status") + .innerJoin("_emdash_collections as collection", (join) => + join + .onRef("collection.id", "=", "status.collection_id") + .onRef("collection.slug", "=", "status.scope_key"), + ) + .select((select) => [ + select.val(input.collectionId).as("collection_id"), + "collection.slug as collection_slug", + select.val(input.contentId).as("content_id"), + "status.change_epoch as change_epoch", + select.val(1).as("work_version"), + select.val("pending").as("state"), + select.val(0).as("attempt_count"), + now.as("next_attempt_at"), + sql`NULL`.as("lease_token"), + sql`NULL`.as("lease_expires_at"), + sql`NULL`.as("last_attempted_at"), + sql`NULL`.as("last_error_code"), + now.as("created_at"), + now.as("updated_at"), + ]) + .where("status.adapter_id", "=", "content-media") + .where("status.scope_type", "=", "collection") + .where("status.scope_key", "=", input.collectionSlug) + .where("status.collection_id", "=", input.collectionId) + .where("status.capture_state", "=", "active") + .where("status.change_epoch", "=", input.changeEpoch), + ) + .onConflict((conflict) => conflict.columns(["collection_id", "content_id"]).doNothing()) + .returningAll() + .executeTakeFirst()) ?? null + ); + } + + private async operatorRetryLost(input: { + collectionId: string; + contentId: string; + }): Promise { + if (!(await this.findActiveOperatorCollection(input.collectionId))) { + return { outcome: "collection_not_found" }; + } + const current = await this.findWorkByIdentity(input.collectionId, input.contentId); + if (current?.state === "pending") { + return { outcome: "pending", changed: false, work: rowToOperatorWork(current) }; + } + const liveLease = await this.findLiveLeaseExpiry(input.collectionId, input.contentId); + if (liveLease) return { outcome: "lease_active", leaseExpiresAt: liveLease }; + return { outcome: "conflict" }; + } + + private async findLiveLeaseExpiry( + collectionId: string, + contentId: string, + ): Promise { + const row = await this.db + .selectFrom("_emdash_media_usage_work") + .select("lease_expires_at") + .where("collection_id", "=", collectionId) + .where("content_id", "=", contentId) + .where("state", "=", "leased") + .where("lease_expires_at", "is not", null) + .where(this.leaseIsLive()) + .executeTakeFirst(); + return row?.lease_expires_at ?? null; + } + + async findDueWork(limit: number): Promise { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_WORK_SELECTION_LIMIT) { + throw new Error( + `Media usage due-work limit must be a whole number from 1 to ${MAX_WORK_SELECTION_LIMIT}`, + ); + } + + const pendingRows = await this.findDueRows("pending", "next_attempt_at", limit); + const retryRows = await this.findDueRows("retry", "next_attempt_at", limit); + const leasedRows = await this.findDueRows("leased", "lease_expires_at", limit); + + return [...pendingRows, ...retryRows, ...leasedRows] + .map(rowToWork) + .toSorted(compareDueWork) + .slice(0, limit); + } + + private async findDueRows( + state: "pending" | "retry" | "leased", + timestampColumn: "next_attempt_at" | "lease_expires_at", + limit: number, + ): Promise[]> { + let query = this.db + .selectFrom("_emdash_media_usage_work") + .selectAll() + .where("state", "=", state) + .where(this.timestampIsDue(timestampColumn)) + .orderBy(timestampColumn, "asc"); + if (timestampColumn === "next_attempt_at") { + query = query.orderBy("updated_at", "asc"); + } + return query + .orderBy("collection_id", "asc") + .orderBy("content_id", "asc") + .limit(limit) + .execute(); + } + + async findWorkForContent( + collectionSlug: string, + contentId: string, + ): Promise { + if (!collectionSlug || !contentId) { + throw new Error("Media usage work lookup requires collection and content identity"); + } + const row = await this.db + .selectFrom("_emdash_media_usage_work") + .innerJoin("_emdash_collections as current_collection", (join) => + join + .onRef("current_collection.id", "=", "_emdash_media_usage_work.collection_id") + .onRef("current_collection.slug", "=", "_emdash_media_usage_work.collection_slug"), + ) + .selectAll("_emdash_media_usage_work") + .where("_emdash_media_usage_work.collection_slug", "=", collectionSlug) + .where("_emdash_media_usage_work.content_id", "=", contentId) + .executeTakeFirst(); + return row ? rowToWork(row) : null; + } + + async claimWork( + input: MediaUsageWorkIdentity & { + leaseDurationSeconds: number; + }, + ): Promise { + assertIdentity(input); + const leaseDurationSeconds = durationSeconds( + input.leaseDurationSeconds, + "lease duration", + false, + ); + const leaseToken = ulid(); + const now = this.timestampOffset(0); + const row = await this.db + .updateTable("_emdash_media_usage_work") + .set({ + state: "leased", + lease_token: leaseToken, + lease_expires_at: this.timestampOffset(leaseDurationSeconds), + last_attempted_at: now, + updated_at: now, + }) + .where("collection_id", "=", input.collectionId) + .where("content_id", "=", input.contentId) + .where("work_version", "=", input.workVersion) + .where((eb) => + eb.or([ + eb.and([eb("state", "in", ["pending", "retry"]), this.timestampIsDue("next_attempt_at")]), + eb.and([ + eb("state", "=", "leased"), + eb("lease_expires_at", "is not", null), + this.timestampIsDue("lease_expires_at"), + ]), + ]), + ) + .returningAll() + .executeTakeFirst(); + + return row ? rowToWork(row) : null; + } + + async completeWork(input: MediaUsageWorkLease): Promise { + assertLease(input); + const result = await this.db + .deleteFrom("_emdash_media_usage_work") + .where("collection_id", "=", input.collectionId) + .where("content_id", "=", input.contentId) + .where("work_version", "=", input.workVersion) + .where("state", "=", "leased") + .where("lease_token", "=", input.leaseToken) + .where(this.leaseIsLive()) + .executeTakeFirst(); + return Number(result.numDeletedRows ?? 0) > 0; + } + + async deleteWorkThroughEpoch( + collectionId: string, + maxChangeEpoch: number | string, + ): Promise { + if (!collectionId) throw new Error("Media usage work cleanup requires a collection ID"); + assertNonNegativeDecimal(maxChangeEpoch, "change epoch"); + const result = await this.db + .deleteFrom("_emdash_media_usage_work") + .where("collection_id", "=", collectionId) + .where("change_epoch", "<=", maxChangeEpoch) + .executeTakeFirst(); + return Number(result.numDeletedRows ?? 0); + } + + async retryWork( + input: MediaUsageWorkLease & { + retryDelaySeconds: number; + errorCode: string; + }, + ): Promise { + const retryDelaySeconds = durationSeconds(input.retryDelaySeconds, "retry delay", true); + assertErrorCode(input.errorCode); + return this.transitionFailure(input, "retry", { + next_attempt_at: this.timestampOffset(retryDelaySeconds), + }); + } + + async failWork( + input: MediaUsageWorkLease & { + errorCode: string; + }, + ): Promise { + assertErrorCode(input.errorCode); + return this.transitionFailure(input, "failed"); + } + + private async transitionFailure( + input: MediaUsageWorkLease & { errorCode: string }, + state: "retry" | "failed", + extra: { next_attempt_at?: RawBuilder } = {}, + ): Promise { + assertLease(input); + const result = await this.db + .updateTable("_emdash_media_usage_work") + .set({ + state, + attempt_count: sql`attempt_count + 1`, + lease_token: null, + lease_expires_at: null, + last_error_code: input.errorCode, + updated_at: this.timestampOffset(0), + ...extra, + }) + .where("collection_id", "=", input.collectionId) + .where("content_id", "=", input.contentId) + .where("work_version", "=", input.workVersion) + .where("state", "=", "leased") + .where("lease_token", "=", input.leaseToken) + .where(this.leaseIsLive()) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; + } + + private leaseIsLive(): RawBuilder { + return isPostgres(this.db) + ? sql`lease_expires_at::timestamptz > clock_timestamp()` + : sql`lease_expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`; + } + + private timestampIsDue( + column: "next_attempt_at" | "lease_expires_at" | "work.lease_expires_at", + ): RawBuilder { + return isPostgres(this.db) + ? sql`${sql.ref(column)} <= to_char( + statement_timestamp() AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + )` + : sql`${sql.ref(column)} <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`; + } + + private timestampOffset(offsetSeconds: number): RawBuilder { + if (isPostgres(this.db)) { + return sql`to_char( + (clock_timestamp() AT TIME ZONE 'UTC') + (${offsetSeconds} * INTERVAL '1 second'), + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + )`; + } + return sql`strftime( + '%Y-%m-%dT%H:%M:%fZ', + 'now', + ${`${offsetSeconds >= 0 ? "+" : ""}${offsetSeconds} seconds`} + )`; + } +} + +function operatorLimit(value: number | undefined): number { + if (value === undefined) return MEDIA_USAGE_WORK_OPERATOR_DEFAULT_LIMIT; + if (!Number.isSafeInteger(value) || value < 1 || value > MEDIA_USAGE_WORK_OPERATOR_MAX_LIMIT) { + throw new Error( + `Media usage operator limit must be a whole number from 1 to ${MEDIA_USAGE_WORK_OPERATOR_MAX_LIMIT}`, + ); + } + return value; +} + +function durationSeconds(value: number, label: string, allowZero: boolean): number { + if ( + !Number.isSafeInteger(value) || + value < (allowZero ? 0 : 1) || + value > MAX_PORTABLE_DURATION_SECONDS + ) { + throw new Error( + `Media usage work ${label} must be ${allowZero ? "a non-negative" : "a positive"} whole number of seconds no greater than one year`, + ); + } + return value; +} + +function assertIdentity(input: MediaUsageWorkIdentity): void { + if (!input.collectionId || !input.contentId) { + throw new Error("Media usage work identity must include collection and content IDs"); + } + const validVersion = + (typeof input.workVersion === "number" && + Number.isSafeInteger(input.workVersion) && + input.workVersion > 0) || + (typeof input.workVersion === "string" && POSITIVE_DECIMAL_PATTERN.test(input.workVersion)); + if (!validVersion) { + throw new Error("Media usage work identity must include a work version"); + } +} + +function assertNonNegativeDecimal(value: number | string, label: string): void { + const valid = + (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) || + (typeof value === "string" && NON_NEGATIVE_DECIMAL_PATTERN.test(value)); + if (!valid) throw new Error(`Media usage work ${label} must be a non-negative whole number`); +} + +function assertToken(value: string): void { + if (!value) throw new Error("Media usage work lease token must not be empty"); +} + +function assertLease(input: MediaUsageWorkLease): void { + assertIdentity(input); + assertToken(input.leaseToken); +} + +function assertErrorCode(value: string): void { + if (!STABLE_ERROR_CODE_PATTERN.test(value)) { + throw new Error("Media usage work error code must use a stable SCREAMING_SNAKE_CASE value"); + } +} + +function rowToWork(row: Selectable): MediaUsageWorkRecord { + if (!isMediaUsageWorkState(row.state)) { + throw new Error(`Invalid media usage work state: ${row.state}`); + } + return { + collectionId: row.collection_id, + collectionSlug: row.collection_slug, + contentId: row.content_id, + changeEpoch: row.change_epoch, + workVersion: row.work_version, + state: row.state, + attemptCount: row.attempt_count, + nextAttemptAt: row.next_attempt_at, + leaseToken: row.lease_token, + leaseExpiresAt: row.lease_expires_at, + lastAttemptedAt: row.last_attempted_at, + lastErrorCode: row.last_error_code, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function rowToOperatorWork( + row: Selectable | MediaUsageWorkRecord, +): MediaUsageOperatorWorkItem { + const work = "collection_id" in row ? rowToWork(row) : row; + return { + collectionId: work.collectionId, + collectionSlug: work.collectionSlug, + contentId: work.contentId, + state: work.state, + attemptCount: work.attemptCount, + nextAttemptAt: work.nextAttemptAt, + leaseExpiresAt: work.leaseExpiresAt, + lastAttemptedAt: work.lastAttemptedAt, + lastErrorCode: work.lastErrorCode, + updatedAt: work.updatedAt, + }; +} + +function isMediaUsageWorkState(value: string): value is MediaUsageWorkState { + return value === "pending" || value === "retry" || value === "leased" || value === "failed"; +} + +function compareDueWork(a: MediaUsageWorkRecord, b: MediaUsageWorkRecord): number { + const eligibility = dueTimestamp(a).localeCompare(dueTimestamp(b)); + if (eligibility !== 0) return eligibility; + const updated = a.updatedAt.localeCompare(b.updatedAt); + if (updated !== 0) return updated; + const collection = a.collectionId.localeCompare(b.collectionId); + return collection !== 0 ? collection : a.contentId.localeCompare(b.contentId); +} + +function compareOperatorWork(a: MediaUsageOperatorWorkItem, b: MediaUsageOperatorWorkItem): number { + const updated = b.updatedAt.localeCompare(a.updatedAt); + return updated !== 0 ? updated : b.contentId.localeCompare(a.contentId); +} + +function dueTimestamp(work: MediaUsageWorkRecord): string { + if (work.state !== "leased") return work.nextAttemptAt; + if (!work.leaseExpiresAt) throw new Error("Due leased media usage work must have a lease expiry"); + return work.leaseExpiresAt; +} diff --git a/packages/core/src/database/repositories/media-usage.ts b/packages/core/src/database/repositories/media-usage.ts index 5f02e4b9ac..f958cc2c45 100644 --- a/packages/core/src/database/repositories/media-usage.ts +++ b/packages/core/src/database/repositories/media-usage.ts @@ -9,6 +9,7 @@ import { } from "kysely"; import { ulid } from "ulidx"; +import { isMediaUsageProjectionFingerprint } from "../../media/usage/projection-fingerprint.js"; import type { MediaUsageContentSourceVariant } from "../../media/usage/source-key.js"; import type { MediaKind, MediaUsageReferenceType } from "../../media/usage/types.js"; import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; @@ -25,6 +26,7 @@ import { decodeCursor, encodeCursor, InvalidCursorError, type FindManyResult } f type DatabaseExecutor = Kysely | Transaction; type MediaUsageSourceNullableStringColumn = + | "collection_id" | "source_fingerprint" | "source_updated_at" | "revision_id" @@ -67,14 +69,37 @@ const CONTENT_SOURCE_ELIGIBILITY = sql`( AND overlay.collection_slug = s.collection_slug AND overlay.content_id = s.content_id AND overlay.source_variant = 'draft_overlay' + AND ${contentSourceMatchesActiveCollection("overlay", "s.collection_id")} ) ) ) )`; +type ContentSourceAlias = "deleted_source" | "overlay" | "s" | "state"; +type CurrentCollectionIdReference = "collection.id" | "page.collection_id" | "s.collection_id"; + +function contentSourceMatchesActiveCollection( + source: ContentSourceAlias, + currentCollectionId: CurrentCollectionIdReference, +): RawBuilder { + return sql`( + NOT EXISTS ( + SELECT 1 + FROM _emdash_media_usage_activation AS activation + WHERE activation.task_key = 'incremental_capture' + AND activation.state = 'active' + ) + OR ( + ${sql.ref(`${source}.collection_id`)} = ${sql.ref(currentCollectionId)} + AND ${sql.ref(`${source}.identity_version`)} = 1 + ) + )`; +} + export interface MediaUsageSourceInput { sourceKey: string; sourceType: string; + collectionId?: string | null; collectionSlug?: string | null; contentId?: string | null; sourceVariant: MediaUsageContentSourceVariant; @@ -90,6 +115,7 @@ export interface MediaUsageSourceInput { sourceUpdatedAt?: string | null; sourceVersion?: number | null; sourceFingerprint?: string | null; + identityVersion?: number | null; sourceCompleteness?: MediaUsageSourceCompleteness; lastAttemptedAt?: string | null; lastErrorCode?: string | null; @@ -110,6 +136,7 @@ export interface MediaUsageOccurrenceInput { export interface MediaUsageSource { sourceKey: string; sourceType: string; + collectionId: string | null; collectionSlug: string | null; contentId: string | null; sourceVariant: string; @@ -126,6 +153,7 @@ export interface MediaUsageSource { sourceUpdatedAt: string | null; sourceVersion: number | null; sourceFingerprint: string | null; + identityVersion: number | null; sourceCompleteness: string; lastAttemptedAt: string | null; lastErrorCode: string | null; @@ -136,6 +164,7 @@ export interface MediaUsageSource { export interface MediaUsageGuardedReplaceResult { replaced: boolean; + unchanged: boolean; /** Populated only when a guarded replacement did not win the current source row. */ source: MediaUsageSource | null; } @@ -220,6 +249,30 @@ export interface MediaUsageIndexStatusFinalizeInput extends MediaUsageIndexStatu updatedAt?: string; } +export interface MediaUsageIndexStatusEpochRepairInput extends MediaUsageIndexStatusIdentity { + collectionId: string; + runToken: string; + schemaVersion: number; +} + +export interface MediaUsageIndexStatusEpochRepairRun { + changeEpoch: number | string; + startedAt: string; +} + +export interface MediaUsageIndexStatusEpochFinalizeInput extends MediaUsageIndexStatusEpochRepairInput { + startingEpoch: number | string; + status: Exclude; + indexedSourceCount: number; + failedSourceCount: number; + lastErrorCode: string | null; +} + +export interface MediaUsageIncrementalStatusIdentity { + collectionId: string; + collectionSlug: string; +} + export interface MediaUsageGuardedIndexStatusResult { finalized: boolean; status: MediaUsageIndexStatus | null; @@ -279,6 +332,7 @@ export interface MediaUsageCollectionIndexStatusScope { collectionSlug: string; status: string | null; schemaVersion: number | null; + reconciliationRequired: boolean; } export interface MediaUsageEntrySource { @@ -296,6 +350,7 @@ export interface MediaUsageEntryGroup { interface MediaUsageSourceRow { source_key: string; source_type: string; + collection_id: string | null; collection_slug: string | null; content_id: string | null; source_variant: string; @@ -312,6 +367,7 @@ interface MediaUsageSourceRow { source_updated_at: string | null; source_version: number | null; source_fingerprint: string | null; + identity_version: number | null; source_completeness: string; last_attempted_at: string | null; last_error_code: string | null; @@ -344,6 +400,7 @@ export interface MediaUsageRecord { interface JoinedUsageRow { source_key: string; source_type: string; + collection_id: string | null; collection_slug: string | null; content_id: string | null; source_variant: string; @@ -360,6 +417,7 @@ interface JoinedUsageRow { source_updated_at: string | null; source_version: number | null; source_fingerprint: string | null; + identity_version: number | null; source_completeness: string; last_attempted_at: string | null; last_error_code: string | null; @@ -416,6 +474,12 @@ export class MediaUsageRepository { occurrences: readonly MediaUsageOccurrenceInput[], expectedCurrentGeneration: string | null, ): Promise { + if ( + expectedCurrentGeneration !== null && + (await this.projectionMatchesCurrentGeneration(source, expectedCurrentGeneration)) + ) { + return { replaced: false, unchanged: true, source: null }; + } const generation = ulid(); let replaced = false; @@ -438,6 +502,7 @@ export class MediaUsageRepository { return { replaced, + unchanged: false, source: replaced ? null : await this.findSource(source.sourceKey), }; } @@ -477,6 +542,12 @@ export class MediaUsageRepository { occurrences: readonly MediaUsageOccurrenceInput[], expectedSource: MediaUsageSource | null, ): Promise { + if ( + expectedSource !== null && + (await this.projectionMatchesExpectedSource(source, expectedSource)) + ) { + return { replaced: false, unchanged: true, source: null }; + } const generation = ulid(); let replaced = false; @@ -494,11 +565,25 @@ export class MediaUsageRepository { return { replaced, + unchanged: false, source: replaced ? null : await this.findSource(source.sourceKey), }; } async markSourceAttempted(source: MediaUsageSourceInput): Promise { + if (source.collectionId !== undefined && source.collectionId !== null) { + const expectedSource = await this.findSource(source.sourceKey); + const result = await this.markSourceAttemptedIfMatching(source, expectedSource); + if (!result.attempted) { + throw new Error(`Canonical media usage source ${source.sourceKey} is no longer current`); + } + const attempted = await this.findSource(source.sourceKey); + if (!attempted) { + throw new Error(`Media usage source ${source.sourceKey} was not persisted`); + } + return attempted; + } + const generation = ulid(); await this.withGenerationWriteLease(source.sourceKey, generation, async (leaseToken, now) => { const row = this.buildAttemptedSourceRow(source, generation, now); @@ -530,12 +615,12 @@ export class MediaUsageRepository { if (expectedSource === null) { await this.withGenerationWriteLease(source.sourceKey, generation, async (leaseToken, now) => { const row = this.buildAttemptedSourceRow(source, generation, now); - const result = await this.db - .insertInto("_emdash_media_usage_sources") - .values(row) - .onConflict((oc) => oc.column("source_key").doNothing()) - .executeTakeFirst(); - attempted = (result.numInsertedOrUpdatedRows ?? 0n) > 0n; + attempted = await this.persistSourceIfWriteLease( + this.db, + row, + leaseToken, + sql`ON CONFLICT (source_key) DO NOTHING`, + ); }); } else { const row = this.buildAttemptedSourceRow(source, generation, new Date().toISOString()); @@ -570,6 +655,7 @@ export class MediaUsageRepository { .whereRef("deleted_source.collection_slug", "=", "s.collection_slug") .whereRef("deleted_source.content_id", "=", "s.content_id") .where("deleted_source.source_variant", "in", ["columns", "draft_overlay"]) + .where(contentSourceMatchesActiveCollection("deleted_source", "collection.id")) .where("deleted_source.content_deleted_at", "is not", null), ), ), @@ -607,6 +693,7 @@ export class MediaUsageRepository { "collection.slug as collection_slug", "status.status as status", "status.schema_version as schema_version", + "status.reconciliation_required as reconciliation_required", ]) .orderBy("collection.slug", "asc") .execute(); @@ -615,6 +702,8 @@ export class MediaUsageRepository { collectionSlug: row.collection_slug, status: row.status, schemaVersion: row.schema_version === null ? null : Number(row.schema_version), + reconciliationRequired: + row.reconciliation_required !== null && Number(row.reconciliation_required) !== 0, })); } @@ -629,7 +718,11 @@ export class MediaUsageRepository { throw new InvalidCursorError(options.cursor ?? ""); } let matchedGroups = this.currentContentMediaUsageBaseQuery() - .select(["s.collection_slug as collection_slug", "s.content_id as content_id"]) + .select([ + "collection.id as collection_id", + "s.collection_slug as collection_slug", + "s.content_id as content_id", + ]) .where("u.media_id", "=", mediaId) .distinct(); if (cursor) { @@ -662,7 +755,7 @@ export class MediaUsageRepository { db .selectFrom("page_groups as page") .crossJoin("_emdash_media_usage_sources as state") - .select(["page.collection_slug", "page.content_id"]) + .select(["page.collection_id", "page.collection_slug", "page.content_id"]) .select((eb) => eb.fn.max("state.content_deleted_at").as("entry_deleted_at"), ) @@ -670,13 +763,15 @@ export class MediaUsageRepository { .whereRef("page.content_id", "=", "state.content_id") .where("state.source_type", "=", "content") .where("state.source_variant", "in", ["columns", "draft_overlay"]) - .groupBy(["page.collection_slug", "page.content_id"]), + .where(contentSourceMatchesActiveCollection("state", "page.collection_id")) + .groupBy(["page.collection_id", "page.collection_slug", "page.content_id"]), ) .selectFrom("entry_state as page") .crossJoin("_emdash_media_usage_sources as s") .crossJoin("_emdash_media_usage as u") .whereRef("page.collection_slug", "=", "s.collection_slug") .whereRef("page.content_id", "=", "s.content_id") + .where(contentSourceMatchesActiveCollection("s", "page.collection_id")) .whereRef("s.source_key", "=", "u.source_key") .whereRef("s.current_generation", "=", "u.generation") .select(currentUsageSelect) @@ -808,6 +903,9 @@ export class MediaUsageRepository { .deleteFrom("_emdash_media_usage_sources") .where("source_key", "=", sourceKey) .where(this.sourceMatchExpression(expectedSource)) + .where( + this.currentCollectionExists(expectedSource.collectionId, expectedSource.collectionSlug), + ) .executeTakeFirst(); deleted = Number(result.numDeletedRows ?? 0) > 0; if (!deleted) return; @@ -839,6 +937,9 @@ export class MediaUsageRepository { .deleteFrom("_emdash_media_usage_sources") .where("source_key", "=", sourceKey) .where(this.sourceMatchExpression(expectedSource)) + .where( + this.currentCollectionExists(expectedSource.collectionId, expectedSource.collectionSlug), + ) .where( sql`NOT EXISTS (SELECT 1 FROM ${sql.ref(tableName)} WHERE id = ${contentId})`, ) @@ -894,14 +995,18 @@ export class MediaUsageRepository { return deleted; } - async findCollectionContentSources(collectionSlug: string): Promise { - const rows = await this.db + async findCollectionContentSources( + collectionSlug: string, + collectionId?: string, + ): Promise { + let query = this.db .selectFrom("_emdash_media_usage_sources") .selectAll() .where("source_type", "=", "content") .where("collection_slug", "=", collectionSlug) - .orderBy("source_key", "asc") - .execute(); + .orderBy("source_key", "asc"); + if (collectionId !== undefined) query = query.where("collection_id", "=", collectionId); + const rows = await query.execute(); return rows.map((row) => rowToSource(row)); } @@ -1290,6 +1395,43 @@ export class MediaUsageRepository { return status; } + async invalidateIndexStatusForSchemaChange(collectionSlug: string): Promise { + const result = await this.db + .updateTable("_emdash_media_usage_index_status as status") + .set({ + change_epoch: sql`change_epoch + 1`, + status: "stale", + completed_at: null, + cursor: null, + last_error_code: "CONTENT_USAGE_STALE", + reconciliation_required: 1, + updated_at: this.sortableUtcTimestamp(), + }) + .where("status.adapter_id", "=", "content-media") + .where("status.scope_type", "=", "collection") + .where("status.scope_key", "=", collectionSlug) + .where("status.capture_state", "=", "active") + .where((eb) => + eb.exists( + eb + .selectFrom("_emdash_collections as collection") + .select("collection.id") + .whereRef("collection.id", "=", "status.collection_id") + .whereRef("collection.slug", "=", "status.scope_key"), + ), + ) + .where( + sql`EXISTS ( + SELECT 1 + FROM _emdash_media_usage_activation AS activation + WHERE activation.task_key = 'incremental_capture' + AND activation.state = 'active' + )`, + ) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) === 1; + } + async beginIndexStatusRepair( input: MediaUsageIndexStatusRepairInput, ): Promise { @@ -1340,6 +1482,235 @@ export class MediaUsageRepository { }; } + async beginIndexStatusRepairAtCurrentEpoch( + input: MediaUsageIndexStatusEpochRepairInput, + ): Promise { + const now = this.sortableUtcTimestamp(); + const row = await this.db + .updateTable("_emdash_media_usage_index_status") + .set({ + status: "running", + schema_version: input.schemaVersion, + started_at: now, + completed_at: null, + cursor: input.runToken, + indexed_source_count: 0, + failed_source_count: 0, + last_error_code: null, + reconciliation_required: 1, + updated_at: now, + }) + .where("adapter_id", "=", input.adapterId) + .where("scope_type", "=", input.scopeType) + .where("scope_key", "=", input.scopeKey) + .where("collection_id", "=", input.collectionId) + .where("capture_state", "=", "active") + .where( + sql`EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.id = ${input.collectionId} + AND collection.slug = ${input.scopeKey} + )`, + ) + .where( + sql`EXISTS ( + SELECT 1 + FROM _emdash_media_usage_activation AS activation + WHERE activation.task_key = 'incremental_capture' + AND activation.state = 'active' + )`, + ) + .returning(["change_epoch", "started_at"]) + .executeTakeFirst(); + if (!row?.started_at) return null; + return { changeEpoch: row.change_epoch, startedAt: row.started_at }; + } + + async finalizeIndexStatusRepairAtEpoch( + input: MediaUsageIndexStatusEpochFinalizeInput, + ): Promise { + const now = this.sortableUtcTimestamp(); + const updates = { + status: input.status, + schema_version: input.schemaVersion, + completed_at: now, + cursor: null, + indexed_source_count: input.indexedSourceCount, + failed_source_count: input.failedSourceCount, + last_error_code: input.lastErrorCode, + reconciliation_required: input.status === "complete" ? 0 : 1, + updated_at: now, + }; + + let query = this.db + .updateTable("_emdash_media_usage_index_status") + .set(updates) + .where("adapter_id", "=", input.adapterId) + .where("scope_type", "=", input.scopeType) + .where("scope_key", "=", input.scopeKey) + .where("collection_id", "=", input.collectionId) + .where("status", "=", "running") + .where("cursor", "=", input.runToken) + .where("change_epoch", "=", input.startingEpoch) + .where( + sql`EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.id = ${input.collectionId} + AND collection.slug = ${input.scopeKey} + )`, + ); + if (input.status === "complete") { + query = query.where( + sql`NOT EXISTS ( + SELECT 1 + FROM _emdash_media_usage_work AS work + WHERE work.collection_id = ${input.collectionId} + )`, + ); + } + const result = await query.executeTakeFirst(); + const finalized = Number(result.numUpdatedRows ?? 0) > 0; + + if (!finalized) { + await this.db + .updateTable("_emdash_media_usage_index_status") + .set({ + status: "stale", + completed_at: null, + cursor: null, + last_error_code: "CONTENT_USAGE_REPAIR_CONFLICT", + reconciliation_required: 1, + updated_at: this.sortableUtcTimestamp(), + }) + .where("adapter_id", "=", input.adapterId) + .where("scope_type", "=", input.scopeType) + .where("scope_key", "=", input.scopeKey) + .where("collection_id", "=", input.collectionId) + .where("status", "=", "running") + .where("cursor", "=", input.runToken) + .execute(); + } + + return { + finalized, + status: await this.findIndexStatusForCollection(input, input.collectionId), + }; + } + + async recordIncrementalSuccess(input: MediaUsageIncrementalStatusIdentity): Promise { + const observed = await this.db + .selectFrom("_emdash_media_usage_index_status") + .select("change_epoch") + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", input.collectionSlug) + .where("collection_id", "=", input.collectionId) + .where("capture_state", "=", "active") + .where( + sql`EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.id = ${input.collectionId} + AND collection.slug = ${input.collectionSlug} + )`, + ) + .executeTakeFirst(); + if (!observed) return false; + + const canComplete = sql`( + reconciliation_required = 0 + AND status IN ('complete', 'stale', 'partial') + AND NOT EXISTS ( + SELECT 1 + FROM _emdash_media_usage_work AS work + WHERE work.collection_id = ${input.collectionId} + ) + )`; + const now = this.sortableUtcTimestamp(); + const result = await this.db + .updateTable("_emdash_media_usage_index_status") + .set({ + status: sql`CASE WHEN ${canComplete} THEN 'complete' ELSE status END`, + completed_at: sql< + string | null + >`CASE WHEN ${canComplete} THEN ${now} ELSE completed_at END`, + last_error_code: sql< + string | null + >`CASE WHEN ${canComplete} THEN NULL ELSE last_error_code END`, + last_incremental_success_at: now, + updated_at: now, + }) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", input.collectionSlug) + .where("collection_id", "=", input.collectionId) + .where("change_epoch", "=", observed.change_epoch) + .where("capture_state", "=", "active") + .where( + sql`EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.id = ${input.collectionId} + AND collection.slug = ${input.collectionSlug} + )`, + ) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; + } + + async recordIncrementalFailure( + input: MediaUsageIncrementalStatusIdentity & { + contentId: string; + workVersion: number | string; + errorCode: string; + }, + ): Promise { + const now = this.sortableUtcTimestamp(); + const result = await this.db + .updateTable("_emdash_media_usage_index_status") + .set({ + status: sql`CASE + WHEN reconciliation_required = 0 THEN 'partial' + WHEN status = 'running' THEN 'stale' + ELSE status + END`, + completed_at: sql`CASE + WHEN reconciliation_required = 0 OR status = 'running' THEN NULL + ELSE completed_at + END`, + cursor: sql`CASE WHEN status = 'running' THEN NULL ELSE cursor END`, + last_error_code: input.errorCode, + updated_at: now, + }) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", input.collectionSlug) + .where("collection_id", "=", input.collectionId) + .where( + sql`EXISTS ( + SELECT 1 + FROM _emdash_media_usage_work AS work + WHERE work.collection_id = ${input.collectionId} + AND work.content_id = ${input.contentId} + AND work.work_version = ${input.workVersion} + AND work.state = 'failed' + AND work.last_error_code = ${input.errorCode} + )`, + ) + .where( + sql`EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.id = ${input.collectionId} + AND collection.slug = ${input.collectionSlug} + )`, + ) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; + } + async findIndexStatus( identity: MediaUsageIndexStatusIdentity, ): Promise { @@ -1354,16 +1725,41 @@ export class MediaUsageRepository { return row ? rowToIndexStatus(row) : null; } - async deleteIndexStatus(identity: MediaUsageIndexStatusIdentity): Promise { - const result = await this.db - .deleteFrom("_emdash_media_usage_index_status") + private async findIndexStatusForCollection( + identity: MediaUsageIndexStatusIdentity, + collectionId: string, + ): Promise { + const row = await this.db + .selectFrom("_emdash_media_usage_index_status") + .selectAll() .where("adapter_id", "=", identity.adapterId) .where("scope_type", "=", identity.scopeType) .where("scope_key", "=", identity.scopeKey) + .where("collection_id", "=", collectionId) .executeTakeFirst(); + return row ? rowToIndexStatus(row) : null; + } + + async deleteIndexStatus( + identity: MediaUsageIndexStatusIdentity, + collectionId?: string, + ): Promise { + let query = this.db + .deleteFrom("_emdash_media_usage_index_status") + .where("adapter_id", "=", identity.adapterId) + .where("scope_type", "=", identity.scopeType) + .where("scope_key", "=", identity.scopeKey); + if (collectionId !== undefined) query = query.where("collection_id", "=", collectionId); + const result = await query.executeTakeFirst(); return Number(result.numDeletedRows ?? 0); } + private sortableUtcTimestamp(): RawBuilder { + return isPostgres(this.db) + ? sql`to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')` + : sql`strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`; + } + private async findCurrentUsagePage( applyFilter: ( query: ReturnType, @@ -1414,6 +1810,7 @@ export class MediaUsageRepository { .where("s.collection_slug", "is not", null) .where("s.content_id", "is not", null) .where("s.source_variant", "in", ["columns", "draft_overlay"]) + .where(contentSourceMatchesActiveCollection("s", "collection.id")) .where(CONTENT_SOURCE_ELIGIBILITY); } @@ -1755,6 +2152,7 @@ export class MediaUsageRepository { sql` ON CONFLICT (source_key) DO UPDATE SET source_type = excluded.source_type, + collection_id = excluded.collection_id, collection_slug = excluded.collection_slug, content_id = excluded.content_id, source_variant = excluded.source_variant, @@ -1771,6 +2169,7 @@ export class MediaUsageRepository { source_updated_at = excluded.source_updated_at, source_version = excluded.source_version, source_fingerprint = excluded.source_fingerprint, + identity_version = excluded.identity_version, source_completeness = excluded.source_completeness, last_attempted_at = excluded.last_attempted_at, last_error_code = excluded.last_error_code, @@ -1795,7 +2194,9 @@ export class MediaUsageRepository { private async persistSourceIfWriteLease( db: DatabaseExecutor, - row: ReturnType, + row: + | ReturnType + | ReturnType, leaseToken: string, conflict: RawBuilder, ): Promise { @@ -1803,6 +2204,7 @@ export class MediaUsageRepository { INSERT INTO _emdash_media_usage_sources ( source_key, source_type, + collection_id, collection_slug, content_id, source_variant, @@ -1819,6 +2221,7 @@ export class MediaUsageRepository { source_updated_at, source_version, source_fingerprint, + identity_version, source_completeness, last_attempted_at, last_error_code, @@ -1828,6 +2231,7 @@ export class MediaUsageRepository { SELECT ${row.source_key}, ${row.source_type}, + ${row.collection_id}, ${row.collection_slug}, ${row.content_id}, ${row.source_variant}, @@ -1844,6 +2248,7 @@ export class MediaUsageRepository { ${row.source_updated_at}, ${row.source_version}, ${row.source_fingerprint}, + ${row.identity_version}, ${row.source_completeness}, ${row.last_attempted_at}, ${row.last_error_code}, @@ -1857,6 +2262,7 @@ export class MediaUsageRepository { AND lease_token = ${leaseToken} AND ${this.generationWriteLeaseExpiryIsInFuture("expires_at")} ) + AND ${this.currentCollectionExists(row.collection_id, row.collection_slug)} ${conflict} `.execute(db); return Number(result.numAffectedRows ?? 0) > 0; @@ -1951,6 +2357,7 @@ export class MediaUsageRepository { .where("source_key", "=", row.source_key) .where("current_generation", "=", expectedCurrentGeneration) .where(this.generationWriteLeaseExpression(row, leaseToken)) + .where(this.currentCollectionExists(row.collection_id, row.collection_slug)) .executeTakeFirst(); return Number(result.numUpdatedRows ?? 0) > 0; } @@ -1967,6 +2374,7 @@ export class MediaUsageRepository { .where("source_key", "=", row.source_key) .where(this.sourceMatchExpression(expectedSource)) .where(this.generationWriteLeaseExpression(row, leaseToken)) + .where(this.currentCollectionExists(row.collection_id, row.collection_slug)) .executeTakeFirst(); return Number(result.numUpdatedRows ?? 0) > 0; } @@ -1982,6 +2390,7 @@ export class MediaUsageRepository { .set(this.attemptedSourceUpdateSet(source, row)) .where("source_key", "=", row.source_key) .where(this.sourceMatchExpression(expectedSource)) + .where(this.currentCollectionExists(row.collection_id, row.collection_slug)) .executeTakeFirst(); return Number(result.numUpdatedRows ?? 0) > 0; } @@ -1991,16 +2400,64 @@ export class MediaUsageRepository { eb.and([ eb("current_generation", "=", expectedSource.currentGeneration), eb("source_completeness", "=", expectedSource.sourceCompleteness), + this.nullableStringExpression(eb, "collection_id", expectedSource.collectionId), this.nullableStringExpression(eb, "updated_at", expectedSource.updatedAt), this.nullableStringExpression(eb, "source_fingerprint", expectedSource.sourceFingerprint), this.nullableStringExpression(eb, "source_updated_at", expectedSource.sourceUpdatedAt), this.nullableNumberExpression(eb, "source_version", expectedSource.sourceVersion), + this.nullableNumberExpression(eb, "identity_version", expectedSource.identityVersion), this.nullableStringExpression(eb, "revision_id", expectedSource.revisionId), this.nullableStringExpression(eb, "last_attempted_at", expectedSource.lastAttemptedAt), this.nullableStringExpression(eb, "last_error_code", expectedSource.lastErrorCode), ]); } + private async projectionMatchesCurrentGeneration( + source: MediaUsageSourceInput, + expectedCurrentGeneration: string, + ): Promise { + const fingerprint = source.sourceFingerprint; + if (!isMediaUsageProjectionFingerprint(fingerprint)) return false; + const row = await this.db + .selectFrom("_emdash_media_usage_sources") + .select("source_key") + .where("source_key", "=", source.sourceKey) + .where("current_generation", "=", expectedCurrentGeneration) + .where("source_fingerprint", "=", fingerprint!) + .where("source_completeness", "=", source.sourceCompleteness ?? "complete") + .where("last_error_code", "is", null) + .where( + this.currentCollectionExists(source.collectionId ?? null, source.collectionSlug ?? null), + ) + .executeTakeFirst(); + return row !== undefined; + } + + async projectionMatchesExpectedSource( + source: MediaUsageSourceInput, + expectedSource: MediaUsageSource, + ): Promise { + const fingerprint = source.sourceFingerprint; + if ( + !isMediaUsageProjectionFingerprint(fingerprint) || + expectedSource.sourceFingerprint !== fingerprint || + expectedSource.sourceCompleteness !== (source.sourceCompleteness ?? "complete") || + expectedSource.lastErrorCode !== null + ) { + return false; + } + const row = await this.db + .selectFrom("_emdash_media_usage_sources") + .select("source_key") + .where("source_key", "=", source.sourceKey) + .where(this.sourceMatchExpression(expectedSource)) + .where( + this.currentCollectionExists(source.collectionId ?? null, source.collectionSlug ?? null), + ) + .executeTakeFirst(); + return row !== undefined; + } + private nullableStringExpression( eb: ExpressionBuilder, column: MediaUsageSourceNullableStringColumn, @@ -2009,9 +2466,22 @@ export class MediaUsageRepository { return value === null ? eb(column, "is", null) : eb(column, "=", value); } + private currentCollectionExists( + collectionId: string | null, + collectionSlug: string | null, + ): RawBuilder { + if (collectionId === null) return sql`1 = 1`; + return sql`EXISTS ( + SELECT 1 + FROM _emdash_collections + WHERE id = ${collectionId} + AND slug = ${collectionSlug} + )`; + } + private nullableNumberExpression( eb: ExpressionBuilder, - column: "source_version", + column: "source_version" | "identity_version", value: number | null, ) { return value === null ? eb(column, "is", null) : eb(column, "=", value); @@ -2031,6 +2501,7 @@ export class MediaUsageRepository { return { source_key: source.sourceKey, source_type: source.sourceType, + collection_id: source.collectionId ?? null, collection_slug: source.collectionSlug ?? null, content_id: source.contentId ?? null, source_variant: source.sourceVariant, @@ -2047,6 +2518,7 @@ export class MediaUsageRepository { source_updated_at: source.sourceUpdatedAt ?? null, source_version: source.sourceVersion ?? null, source_fingerprint: source.sourceFingerprint ?? null, + identity_version: source.identityVersion ?? null, // Complete means this source was fully refreshed for the extractor's current // schema/version coverage, not that every possible reference shape is known. source_completeness: source.sourceCompleteness ?? "complete", @@ -2061,6 +2533,7 @@ export class MediaUsageRepository { return { source_key: source.sourceKey, source_type: source.sourceType, + collection_id: source.collectionId ?? null, collection_slug: source.collectionSlug ?? null, content_id: source.contentId ?? null, source_variant: source.sourceVariant, @@ -2077,6 +2550,7 @@ export class MediaUsageRepository { source_updated_at: source.sourceUpdatedAt ?? null, source_version: source.sourceVersion ?? null, source_fingerprint: source.sourceFingerprint ?? null, + identity_version: source.identityVersion ?? null, source_completeness: source.sourceCompleteness ?? (source.lastErrorCode ? "failed" : "unknown"), last_attempted_at: source.lastAttemptedAt ?? now, @@ -2100,6 +2574,7 @@ export class MediaUsageRepository { }; if (source.collectionSlug !== undefined) updates.collection_slug = row.collection_slug; + if (source.collectionId !== undefined) updates.collection_id = row.collection_id; if (source.contentId !== undefined) updates.content_id = row.content_id; if (source.locale !== undefined) updates.locale = row.locale; if (source.translationGroup !== undefined) updates.translation_group = row.translation_group; @@ -2117,6 +2592,7 @@ export class MediaUsageRepository { if (source.sourceFingerprint !== undefined) { updates.source_fingerprint = row.source_fingerprint; } + if (source.identityVersion !== undefined) updates.identity_version = row.identity_version; return updates; } @@ -2126,6 +2602,7 @@ export class MediaUsageRepository { ): Updateable { return { source_type: row.source_type, + collection_id: row.collection_id, collection_slug: row.collection_slug, content_id: row.content_id, source_variant: row.source_variant, @@ -2142,6 +2619,7 @@ export class MediaUsageRepository { source_updated_at: row.source_updated_at, source_version: row.source_version, source_fingerprint: row.source_fingerprint, + identity_version: row.identity_version, source_completeness: row.source_completeness, last_attempted_at: row.last_attempted_at, last_error_code: row.last_error_code, @@ -2154,6 +2632,7 @@ export class MediaUsageRepository { const currentUsageSelect = [ "s.source_key as source_key", "s.source_type as source_type", + "s.collection_id as collection_id", "s.collection_slug as collection_slug", "s.content_id as content_id", "s.source_variant as source_variant", @@ -2170,6 +2649,7 @@ const currentUsageSelect = [ "s.source_updated_at as source_updated_at", "s.source_version as source_version", "s.source_fingerprint as source_fingerprint", + "s.identity_version as identity_version", "s.source_completeness as source_completeness", "s.last_attempted_at as last_attempted_at", "s.last_error_code as last_error_code", @@ -2226,6 +2706,7 @@ function rowToSource(row: MediaUsageSourceRow): MediaUsageSource { return { sourceKey: row.source_key, sourceType: row.source_type, + collectionId: row.collection_id, collectionSlug: row.collection_slug, contentId: row.content_id, sourceVariant: row.source_variant, @@ -2242,6 +2723,7 @@ function rowToSource(row: MediaUsageSourceRow): MediaUsageSource { sourceUpdatedAt: row.source_updated_at, sourceVersion: row.source_version === null ? null : Number(row.source_version), sourceFingerprint: row.source_fingerprint, + identityVersion: row.identity_version === null ? null : Number(row.identity_version), sourceCompleteness: row.source_completeness, lastAttemptedAt: row.last_attempted_at, lastErrorCode: row.last_error_code, @@ -2274,6 +2756,7 @@ function rowToUsageRecord(row: JoinedUsageRow): MediaUsageRecord { source: rowToSource({ source_key: row.source_key, source_type: row.source_type, + collection_id: row.collection_id, collection_slug: row.collection_slug, content_id: row.content_id, source_variant: row.source_variant, @@ -2290,6 +2773,7 @@ function rowToUsageRecord(row: JoinedUsageRow): MediaUsageRecord { source_updated_at: row.source_updated_at, source_version: row.source_version, source_fingerprint: row.source_fingerprint, + identity_version: row.identity_version, source_completeness: row.source_completeness, last_attempted_at: row.last_attempted_at, last_error_code: row.last_error_code, diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index eef16d0dbb..fc797fc640 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -102,6 +102,7 @@ export interface MediaUploadAttemptTable { export interface MediaUsageSourceTable { source_key: string; source_type: string; + collection_id: Generated; collection_slug: string | null; content_id: string | null; source_variant: string; @@ -118,6 +119,7 @@ export interface MediaUsageSourceTable { source_updated_at: Generated; source_version: Generated; source_fingerprint: Generated; + identity_version: Generated; source_completeness: Generated; last_attempted_at: Generated; last_error_code: Generated; @@ -193,6 +195,44 @@ export interface MediaUsageIndexStatusTable { failed_source_count: Generated; last_error_code: Generated; updated_at: Generated; + collection_id: Generated; + change_epoch: Generated; + reconciliation_required: Generated; + last_incremental_success_at: Generated; + capture_state: Generated; +} + +export interface MediaUsageActivationTable { + task_key: string; + state: Generated; + runtime_generation: Generated; + collection_cursor: Generated; + drain_confirmed_at: Generated; + lease_token: Generated; + lease_expires_at: Generated; + attempt_count: Generated; + last_attempted_at: Generated; + last_error_code: Generated; + activated_at: Generated; + created_at: Generated; + updated_at: Generated; +} + +export interface MediaUsageWorkTable { + collection_id: string; + collection_slug: string; + content_id: string; + change_epoch: number | string; + work_version: Generated; + state: Generated; + attempt_count: Generated; + next_attempt_at: string; + lease_token: Generated; + lease_expires_at: Generated; + last_attempted_at: Generated; + last_error_code: Generated; + created_at: Generated; + updated_at: Generated; } export interface UserTable { @@ -562,6 +602,8 @@ export interface Database { _emdash_media_usage_generation_writes: MediaUsageGenerationWriteTable; _emdash_media_usage_cleanup_fence: MediaUsageGenerationFenceTable; _emdash_media_usage_index_status: MediaUsageIndexStatusTable; + _emdash_media_usage_activation: MediaUsageActivationTable; + _emdash_media_usage_work: MediaUsageWorkTable; users: UserTable; credentials: CredentialTable; auth_tokens: AuthTokenTable; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 5cba3c64ea..3dc7f6789e 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -13,6 +13,7 @@ import { Kysely, type Dialect } from "kysely"; import virtualConfig from "virtual:emdash/config"; import { z } from "zod"; +import { assertMediaUsageActivationWriteAllowed } from "./api/media-usage-write-fence.js"; import { validateRev } from "./api/rev.js"; import type { EmDashConfig, @@ -47,7 +48,12 @@ import { markContentMediaUsageCollectionStale, refreshContentMediaUsageAfterWrite, } from "./media/usage/content-refresh.js"; +import { + processDueMediaUsageWork, + processMediaUsageWorkAfterWrite, +} from "./media/usage/work-processor.js"; import { createSandboxRunnerOptions } from "./plugins/sandbox/runner-options.js"; +import { getSandboxRouteErrorDetails } from "./plugins/sandbox/types.js"; import type { SandboxedPluginInstance, SandboxRunner, @@ -393,6 +399,7 @@ export interface EmDashRuntimeParts { pipelineFactoryOptions: { db: Kysely; getDb?: () => Kysely; + beforeContentWrite?: () => Promise; storage?: Storage; siteInfo?: { siteName?: string; @@ -524,6 +531,17 @@ const marketplaceManifestCache = new Map< const sandboxedRouteMetaCache = new Map>(); let sandboxRunner: SandboxRunner | null = null; +async function runScheduledMediaUsageWork(db: Kysely): Promise { + try { + const result = await processDueMediaUsageWork(db); + if (result.candidateCount > 0) { + console.info("[media-usage:work] Scheduled processing", result); + } + } catch (error) { + console.error("[media-usage:work] Scheduled processing failed:", error); + } +} + /** * EmDashRuntime - singleton per worker */ @@ -585,6 +603,7 @@ export class EmDashRuntime { private pipelineFactoryOptions: { db: Kysely; getDb?: () => Kysely; + beforeContentWrite?: () => Promise; storage?: Storage; siteInfo?: { siteName?: string; @@ -657,8 +676,16 @@ export class EmDashRuntime { * Returns the items promoted so callers can invalidate their cache tags. */ async publishScheduled(): Promise { + return this.publishScheduledWithFence(); + } + + private async publishScheduledWithFence( + onPublished?: (refs: PublishedRef[]) => Promise, + ): Promise { + await assertMediaUsageActivationWriteAllowed(this.db); return publishDueContent(this.db, { publish: (collection, id, options) => this.handleContentPublish(collection, id, options), + onPublished, }); } @@ -696,11 +723,7 @@ export class EmDashRuntime { let published: PublishedRef[] = []; try { - // Route through the runtime wrapper so content:afterPublish hooks fire. - published = await publishDueContent(this.db, { - publish: (collection, id, opts) => this.handleContentPublish(collection, id, opts), - onPublished: options.onPublished, - }); + published = await this.publishScheduledWithFence(options.onPublished); } catch (error) { console.error("[scheduled-publish] Sweep failed:", error); } @@ -711,6 +734,7 @@ export class EmDashRuntime { console.error("[cleanup] System cleanup failed:", error); } + await runScheduledMediaUsageWork(this.db); try { await this.syncPluginStorageIndexesOnce(); } catch (error) { @@ -1496,6 +1520,7 @@ export class EmDashRuntime { const pipelineFactoryOptions = { db, getDb: resolveDb, + beforeContentWrite: () => assertMediaUsageActivationWriteAllowed(resolveDb()), storage: storage ?? undefined, siteInfo, }; @@ -1650,12 +1675,12 @@ export class EmDashRuntime { // Falls back to the raw handler if (improbably) the tick beats // the post-construction ref assignment. const runtime = runtimeRef.current; - await publishDueContent(db, { - publish: runtime - ? (collection, id, options) => - runtime.handleContentPublish(collection, id, options) - : undefined, - }); + if (runtime) { + await runtime.publishScheduled(); + } else { + await assertMediaUsageActivationWriteAllowed(db); + await publishDueContent(db); + } } catch (error) { console.error("[scheduled-publish] Sweep failed:", error); } @@ -1666,6 +1691,7 @@ export class EmDashRuntime { // by runSystemCleanup. This catches unexpected errors. console.error("[cleanup] System cleanup failed:", error); } + await runScheduledMediaUsageWork(db); try { await runtimeRef.current?.syncPluginStorageIndexesOnce(); } catch (error) { @@ -1977,6 +2003,7 @@ export class EmDashRuntime { createSandboxRunnerOptions( { db, + beforeContentWrite: () => assertMediaUsageActivationWriteAllowed(db), mediaStorage: mediaStorage ? { upload: (opts) => @@ -2100,6 +2127,7 @@ export class EmDashRuntime { createSandboxRunnerOptions( { db, + beforeContentWrite: () => assertMediaUsageActivationWriteAllowed(db), mediaStorage: { upload: (opts) => storage.upload({ @@ -3522,12 +3550,15 @@ export class EmDashRuntime { ): Promise { for (const contentId of new Set(contentIds)) { try { + const work = await processMediaUsageWorkAfterWrite(this.db, collection, contentId); + if (work.outcome !== "inactive") return; await refreshContentMediaUsageAfterWrite(this.db, collection, contentId); } catch (error) { console.error( `[media-usage] Failed after content write ${collection}/${contentId}:`, error, ); + return; } } } @@ -3537,6 +3568,8 @@ export class EmDashRuntime { contentId: string, ): Promise { try { + const work = await processMediaUsageWorkAfterWrite(this.db, collection, contentId); + if (work.outcome !== "inactive") return; const result = await deleteContentMediaUsage(this.db, collection, contentId); if (!result.success) { console.error( @@ -4115,6 +4148,7 @@ export class EmDashRuntime { success: boolean; data?: unknown; error?: { code: string; message: string }; + status?: number; }> { const routeName = path.replace(LEADING_SLASH_PATTERN, ""); @@ -4133,6 +4167,17 @@ export class EmDashRuntime { return { success: true, data: result }; } catch (error) { console.error(`EmDash: Sandboxed plugin route error:`, error); + const sandboxRouteError = getSandboxRouteErrorDetails(error); + if (sandboxRouteError) { + return { + success: false, + status: sandboxRouteError.status, + error: { + code: sandboxRouteError.code, + message: sandboxRouteError.message, + }, + }; + } return { success: false, error: { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 32cf43156c..88df2216cf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -231,6 +231,10 @@ export { NoopSandboxRunner, SandboxNotAvailableError, SandboxUnavailableError, + createSandboxRouteError, + createSandboxRouteErrorEnvelope, + getSandboxRouteErrorDetails, + getSandboxRouteErrorEnvelope, createNoopSandboxRunner, // HTTP access for plugins (shared between in-process, Cloudflare, and workerd runners) createHttpAccess, @@ -300,6 +304,9 @@ export type { PluginManifest, ValidatedPluginManifest, SerializedRequest, + SandboxRouteErrorCode, + SandboxRouteErrorDetails, + SandboxRouteErrorEnvelope, } from "./plugins/index.js"; // Capability normalization (legacy → canonical alias layer) diff --git a/packages/core/src/media/usage/activation.ts b/packages/core/src/media/usage/activation.ts new file mode 100644 index 0000000000..749263c44f --- /dev/null +++ b/packages/core/src/media/usage/activation.ts @@ -0,0 +1,626 @@ +import { sql, type Kysely, type RawBuilder, type Selectable } from "kysely"; +import { ulid } from "ulidx"; + +import { isPostgres, tableExists } from "../../database/dialect-helpers.js"; +import type { Database, MediaUsageActivationTable } from "../../database/types.js"; +import { + installMediaUsageCaptureTriggers, + verifyMediaUsageCaptureTriggers, +} from "./capture-triggers.js"; + +const ACTIVATION_KEY = "incremental_capture"; +const ACTIVATION_ERROR_CODE = "MEDIA_USAGE_ACTIVATION_FAILED"; +export const MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION = 1; + +export const MEDIA_USAGE_ACTIVATION_LIMITS = Object.freeze({ + collectionsPerCall: 1, + leaseDurationSeconds: 5 * 60, +}); + +export type MediaUsageActivationResult = + | { outcome: "active"; processedCollections: number } + | { + outcome: "activating"; + processedCollections: number; + collectionCursor: string | null; + } + | { outcome: "lease_active"; leaseExpiresAt: string } + | { outcome: "conflict"; processedCollections: number }; + +export interface MediaUsageCollectionCapturePreparation { + captureRequired: boolean; + collectionId: string; + registrationExists: boolean; + resuming: boolean; +} + +export async function canResumeMediaUsageCollectionCapture( + db: Kysely, + identity: { collectionId: string; collectionSlug: string; creationFingerprint?: string }, +): Promise { + const activation = await findActivationIfAvailable(db); + if (!activation) return false; + assertRuntimeGeneration(activation); + if (activation.state !== "active") return false; + + const lifecycle = await db + .selectFrom("_emdash_media_usage_index_status") + .select(["collection_id", "cursor"]) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", identity.collectionSlug) + .where("collection_id", "=", identity.collectionId) + .where("capture_state", "in", ["installing", "ready"]) + .executeTakeFirst(); + return ( + lifecycle?.collection_id === identity.collectionId && + (identity.creationFingerprint + ? lifecycle.cursor === identity.creationFingerprint + : lifecycle.cursor === null) + ); +} + +export async function prepareMediaUsageCollectionCapture( + db: Kysely, + input: { + collectionId: string; + collectionSlug: string; + creationFingerprint?: string; + registeredCollectionId?: string; + }, +): Promise { + const activation = await findActivationIfAvailable(db); + if (!activation) { + return { + captureRequired: false, + collectionId: input.collectionId, + registrationExists: input.registeredCollectionId !== undefined, + resuming: false, + }; + } + assertRuntimeGeneration(activation); + if (activation.state !== "active") { + return { + captureRequired: false, + collectionId: input.collectionId, + registrationExists: input.registeredCollectionId !== undefined, + resuming: false, + }; + } + + const existing = await db + .selectFrom("_emdash_media_usage_index_status") + .select(["collection_id", "capture_state", "cursor"]) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", input.collectionSlug) + .executeTakeFirst(); + if (existing) { + if ( + existing.collection_id && + (existing.capture_state === "installing" || existing.capture_state === "ready") && + (input.creationFingerprint + ? existing.cursor === input.creationFingerprint + : existing.cursor === null) && + (input.registeredCollectionId === undefined || + input.registeredCollectionId === existing.collection_id) + ) { + return { + captureRequired: true, + collectionId: existing.collection_id, + registrationExists: input.registeredCollectionId !== undefined, + resuming: true, + }; + } + throw new Error("Media usage collection lifecycle identity conflict"); + } + if (input.registeredCollectionId !== undefined) { + throw new Error("Media usage collection lifecycle is missing"); + } + + await db + .insertInto("_emdash_media_usage_index_status") + .values({ + adapter_id: "content-media", + scope_type: "collection", + scope_key: input.collectionSlug, + status: "never", + collection_id: input.collectionId, + reconciliation_required: 1, + capture_state: "installing", + cursor: input.creationFingerprint ?? null, + updated_at: timestampOffset(db, 0), + }) + .execute(); + return { + captureRequired: true, + collectionId: input.collectionId, + registrationExists: false, + resuming: false, + }; +} + +export async function installPreparedMediaUsageCollectionCapture( + db: Kysely, + identity: { collectionId: string; collectionSlug: string }, +): Promise { + await installMediaUsageCaptureTriggers(db, identity, { replaceExisting: false }); + if (!(await verifyMediaUsageCaptureTriggers(db, identity))) { + throw new Error("Media usage capture trigger verification failed"); + } +} + +export async function markMediaUsageCollectionCaptureReady( + db: Kysely, + identity: { collectionId: string; collectionSlug: string }, +): Promise { + const result = await db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "ready", updated_at: timestampOffset(db, 0) }) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", identity.collectionSlug) + .where("collection_id", "=", identity.collectionId) + .where("capture_state", "in", ["installing", "ready"]) + .where( + sql`EXISTS ( + SELECT 1 FROM _emdash_media_usage_activation AS activation + WHERE activation.task_key = ${ACTIVATION_KEY} + AND activation.state = 'active' + AND activation.runtime_generation = ${MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION} + )`, + ) + .executeTakeFirst(); + if (Number(result.numUpdatedRows ?? 0) === 0) { + throw new Error("Media usage collection readiness lost its fence"); + } +} + +export async function finalizeMediaUsageCollectionCapture( + db: Kysely, + identity: { collectionId: string; collectionSlug: string }, +): Promise { + const result = await db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active", cursor: null, updated_at: timestampOffset(db, 0) }) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", identity.collectionSlug) + .where("collection_id", "=", identity.collectionId) + .where("capture_state", "=", "ready") + .where( + sql`EXISTS ( + SELECT 1 FROM _emdash_collections AS collection + WHERE collection.id = ${identity.collectionId} + AND collection.slug = ${identity.collectionSlug} + )`, + ) + .where( + sql`EXISTS ( + SELECT 1 FROM _emdash_media_usage_activation AS activation + WHERE activation.task_key = ${ACTIVATION_KEY} + AND activation.state = 'active' + AND activation.runtime_generation = ${MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION} + )`, + ) + .executeTakeFirst(); + if (Number(result.numUpdatedRows ?? 0) === 0) { + throw new Error("Media usage collection activation lost its fence"); + } +} + +export async function activateMediaUsageCapture( + db: Kysely, + input: { writersDrained: true }, +): Promise { + if (input.writersDrained !== true) { + throw new Error("Media usage activation requires confirmation that writers are drained"); + } + + const before = await findActivation(db); + assertRuntimeGeneration(before); + if (before.state === "active") { + return { outcome: "active", processedCollections: 0 }; + } + + const leaseToken = ulid(); + const lease = await claimActivation(db, leaseToken); + if (!lease) return activationClaimLoss(db); + + let processedCollections = 0; + try { + const candidates = await findActivationCandidates(db, lease.collection_cursor); + for (const collection of candidates.slice( + 0, + MEDIA_USAGE_ACTIVATION_LIMITS.collectionsPerCall, + )) { + await requireActivationLease(db, leaseToken); + await prepareCollectionForActivation(db, collection, leaseToken); + await requireActivationLease(db, leaseToken); + await installMediaUsageCaptureTriggers( + db, + { + collectionId: collection.id, + collectionSlug: collection.slug, + }, + { replaceExisting: false }, + ); + await requireActivationLease(db, leaseToken); + if ( + !(await verifyMediaUsageCaptureTriggers(db, { + collectionId: collection.id, + collectionSlug: collection.slug, + })) + ) { + throw new Error("Media usage capture trigger verification failed"); + } + if (!(await activateCollectionLifecycle(db, collection, leaseToken))) { + throw new Error("Media usage collection activation lost its fence"); + } + processedCollections++; + } + + const collectionCursor = + candidates[Math.min(processedCollections, candidates.length) - 1]?.slug ?? + lease.collection_cursor; + if (candidates.length > MEDIA_USAGE_ACTIVATION_LIMITS.collectionsPerCall) { + if (!(await releaseActivationBatch(db, leaseToken, collectionCursor))) { + throw new Error("Media usage activation cursor lost its fence"); + } + return { outcome: "activating", processedCollections, collectionCursor }; + } + + const incomplete = await findIncompleteCollection(db); + if (incomplete) { + if (!(await releaseActivationBatch(db, leaseToken, null))) { + throw new Error("Media usage activation restart lost its fence"); + } + return { outcome: "activating", processedCollections, collectionCursor: null }; + } + + if (!(await finalizeActivation(db, leaseToken, collectionCursor))) { + throw new Error("Media usage activation finalization lost its fence"); + } + return { outcome: "active", processedCollections }; + } catch (error) { + if (!(await activationLeaseIsLive(db, leaseToken))) { + return { outcome: "conflict", processedCollections }; + } + await recordActivationFailure(db, leaseToken); + throw new Error("Media usage activation failed", { cause: error }); + } +} + +async function findActivation( + db: Kysely, +): Promise> { + return db + .selectFrom("_emdash_media_usage_activation") + .selectAll() + .where("task_key", "=", ACTIVATION_KEY) + .executeTakeFirstOrThrow(); +} + +async function findActivationIfAvailable( + db: Kysely, +): Promise | null> { + if (!(await tableExists(db, "_emdash_media_usage_activation"))) return null; + return findActivation(db); +} + +function assertRuntimeGeneration(activation: Selectable): void { + if (activation.runtime_generation !== MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION) { + throw new Error("Media usage activation runtime generation mismatch"); + } +} + +async function claimActivation( + db: Kysely, + leaseToken: string, +): Promise | null> { + const now = timestampOffset(db, 0); + return ( + (await db + .updateTable("_emdash_media_usage_activation") + .set({ + state: "activating", + drain_confirmed_at: now, + lease_token: leaseToken, + lease_expires_at: timestampOffset(db, MEDIA_USAGE_ACTIVATION_LIMITS.leaseDurationSeconds), + attempt_count: sql`attempt_count + 1`, + last_attempted_at: now, + last_error_code: null, + updated_at: now, + }) + .where("task_key", "=", ACTIVATION_KEY) + .where("runtime_generation", "=", MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION) + .where((eb) => + eb.or([ + eb("state", "=", "expanded"), + eb.and([ + eb("state", "=", "activating"), + eb.or([ + eb("lease_token", "is", null), + eb.and([ + eb("lease_expires_at", "is not", null), + timestampIsDue(db, "lease_expires_at"), + ]), + ]), + ]), + ]), + ) + .returningAll() + .executeTakeFirst()) ?? null + ); +} + +async function activationClaimLoss(db: Kysely): Promise { + const current = await findActivation(db); + assertRuntimeGeneration(current); + if (current.state === "active") return { outcome: "active", processedCollections: 0 }; + if ( + current.state === "activating" && + current.lease_token && + current.lease_expires_at && + (await activationLeaseIsLive(db, current.lease_token)) + ) { + return { outcome: "lease_active", leaseExpiresAt: current.lease_expires_at }; + } + throw new Error("Media usage activation state is not claimable"); +} + +async function activationLeaseIsLive(db: Kysely, leaseToken: string): Promise { + const row = await db + .selectFrom("_emdash_media_usage_activation") + .select("task_key") + .where("task_key", "=", ACTIVATION_KEY) + .where("state", "=", "activating") + .where("runtime_generation", "=", MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION) + .where("lease_token", "=", leaseToken) + .where("lease_expires_at", "is not", null) + .where(timestampIsLive(db, "lease_expires_at")) + .executeTakeFirst(); + return row !== undefined; +} + +async function findActivationCandidates( + db: Kysely, + collectionCursor: string | null, +): Promise> { + let query = db.selectFrom("_emdash_collections").select(["id", "slug"]); + if (collectionCursor) query = query.where("slug", ">", collectionCursor); + return query + .orderBy("slug", "asc") + .limit(MEDIA_USAGE_ACTIVATION_LIMITS.collectionsPerCall + 1) + .execute(); +} + +async function prepareCollectionForActivation( + db: Kysely, + collection: { id: string; slug: string }, + leaseToken: string, +): Promise { + const now = timestampOffset(db, 0); + const existingStatus = sql.ref("_emdash_media_usage_index_status.status"); + const existingCompletedAt = sql.ref("_emdash_media_usage_index_status.completed_at"); + const existingCollectionId = sql.ref("_emdash_media_usage_index_status.collection_id"); + const existingCaptureState = sql.ref("_emdash_media_usage_index_status.capture_state"); + const row = await db + .insertInto("_emdash_media_usage_index_status") + .values({ + adapter_id: "content-media", + scope_type: "collection", + scope_key: collection.slug, + status: "never", + collection_id: collection.id, + reconciliation_required: 1, + capture_state: "installing", + updated_at: now, + }) + .onConflict((conflict) => + conflict + .columns(["adapter_id", "scope_type", "scope_key"]) + .doUpdateSet({ + status: sql`CASE WHEN ${existingStatus} IN ('complete', 'running') THEN 'stale' ELSE ${existingStatus} END`, + completed_at: sql< + string | null + >`CASE WHEN ${existingStatus} IN ('complete', 'running') THEN NULL ELSE ${existingCompletedAt} END`, + cursor: null, + collection_id: collection.id, + reconciliation_required: 1, + capture_state: "installing", + updated_at: now, + }) + .where((eb) => + eb.and([ + eb.or([ + eb(existingCollectionId, "is", null), + eb(existingCollectionId, "=", collection.id), + ]), + eb.or([ + eb(existingCaptureState, "is", null), + eb(existingCaptureState, "!=", "deleting"), + ]), + activeActivationLease(db, leaseToken), + ]), + ), + ) + .returning("collection_id") + .executeTakeFirst(); + if (row?.collection_id !== collection.id) { + throw new Error("Media usage collection lifecycle identity conflict"); + } +} + +async function requireActivationLease(db: Kysely, leaseToken: string): Promise { + if (!(await activationLeaseIsLive(db, leaseToken))) { + throw new Error("Media usage activation lease is no longer live"); + } +} + +async function activateCollectionLifecycle( + db: Kysely, + collection: { id: string; slug: string }, + leaseToken: string, +): Promise { + const result = await db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active", updated_at: timestampOffset(db, 0) }) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", collection.slug) + .where("collection_id", "=", collection.id) + .where("capture_state", "=", "installing") + .where( + sql`EXISTS ( + SELECT 1 FROM _emdash_collections AS collection + WHERE collection.id = ${collection.id} + AND collection.slug = ${collection.slug} + )`, + ) + .where(activeActivationLease(db, leaseToken)) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; +} + +async function releaseActivationBatch( + db: Kysely, + leaseToken: string, + collectionCursor: string | null, +): Promise { + const result = await db + .updateTable("_emdash_media_usage_activation") + .set({ + collection_cursor: collectionCursor, + lease_token: null, + lease_expires_at: null, + updated_at: timestampOffset(db, 0), + }) + .where("task_key", "=", ACTIVATION_KEY) + .where("state", "=", "activating") + .where("runtime_generation", "=", MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION) + .where("lease_token", "=", leaseToken) + .where("lease_expires_at", "is not", null) + .where(timestampIsLive(db, "lease_expires_at")) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; +} + +async function findIncompleteCollection( + db: Kysely, +): Promise<{ id: string; slug: string } | null> { + const row = await db + .selectFrom("_emdash_collections as collection") + .select(["collection.id", "collection.slug"]) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom("_emdash_media_usage_index_status as status") + .select("status.scope_key") + .where("status.adapter_id", "=", "content-media") + .where("status.scope_type", "=", "collection") + .whereRef("status.scope_key", "=", "collection.slug") + .whereRef("status.collection_id", "=", "collection.id") + .where("status.capture_state", "=", "active"), + ), + ), + ) + .orderBy("collection.slug", "asc") + .limit(1) + .executeTakeFirst(); + return row ?? null; +} + +async function finalizeActivation( + db: Kysely, + leaseToken: string, + collectionCursor: string | null, +): Promise { + const now = timestampOffset(db, 0); + const result = await db + .updateTable("_emdash_media_usage_activation") + .set({ + state: "active", + collection_cursor: collectionCursor, + lease_token: null, + lease_expires_at: null, + last_error_code: null, + activated_at: now, + updated_at: now, + }) + .where("task_key", "=", ACTIVATION_KEY) + .where("state", "=", "activating") + .where("runtime_generation", "=", MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION) + .where("lease_token", "=", leaseToken) + .where("lease_expires_at", "is not", null) + .where(timestampIsLive(db, "lease_expires_at")) + .where( + sql`NOT EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE NOT EXISTS ( + SELECT 1 + FROM _emdash_media_usage_index_status AS status + WHERE status.adapter_id = 'content-media' + AND status.scope_type = 'collection' + AND status.scope_key = collection.slug + AND status.collection_id = collection.id + AND status.capture_state = 'active' + ) + )`, + ) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; +} + +async function recordActivationFailure(db: Kysely, leaseToken: string): Promise { + await db + .updateTable("_emdash_media_usage_activation") + .set({ + lease_token: null, + lease_expires_at: null, + last_error_code: ACTIVATION_ERROR_CODE, + updated_at: timestampOffset(db, 0), + }) + .where("task_key", "=", ACTIVATION_KEY) + .where("state", "=", "activating") + .where("runtime_generation", "=", MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION) + .where("lease_token", "=", leaseToken) + .execute(); +} + +function activeActivationLease(db: Kysely, leaseToken: string): RawBuilder { + return sql`EXISTS ( + SELECT 1 + FROM _emdash_media_usage_activation AS activation + WHERE activation.task_key = ${ACTIVATION_KEY} + AND activation.state = 'activating' + AND activation.runtime_generation = ${MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION} + AND activation.lease_token = ${leaseToken} + AND activation.lease_expires_at IS NOT NULL + AND ${timestampIsLive(db, "activation.lease_expires_at")} + )`; +} + +function timestampIsDue(db: Kysely, column: string): RawBuilder { + return isPostgres(db) + ? sql`${sql.ref(column)}::timestamptz <= clock_timestamp()` + : sql`${sql.ref(column)} <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`; +} + +function timestampIsLive(db: Kysely, column: string): RawBuilder { + return isPostgres(db) + ? sql`${sql.ref(column)}::timestamptz > clock_timestamp()` + : sql`${sql.ref(column)} > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`; +} + +function timestampOffset(db: Kysely, seconds: number): RawBuilder { + if (isPostgres(db)) { + return sql`to_char( + clock_timestamp() AT TIME ZONE 'UTC' + (${seconds} * interval '1 second'), + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + )`; + } + return sql`strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ${`+${seconds} seconds`})`; +} diff --git a/packages/core/src/media/usage/capture-triggers.ts b/packages/core/src/media/usage/capture-triggers.ts new file mode 100644 index 0000000000..0eb557fc89 --- /dev/null +++ b/packages/core/src/media/usage/capture-triggers.ts @@ -0,0 +1,543 @@ +import { sql, type Kysely, type RawBuilder } from "kysely"; + +import { isPostgres, tableExists } from "../../database/dialect-helpers.js"; +import type { Database } from "../../database/types.js"; +import { validateIdentifier } from "../../database/validate.js"; + +const POSTGRES_TRIGGER_FUNCTION = "emdash_media_usage_capture_work"; +const POSTGRES_IDENTIFIER_LIMIT = 63; +const CAPTURE_TRIGGER_VERSION = 1; +const OWNED_TRIGGER_PREFIX = "emdash_mu_"; +const WHITESPACE_PATTERN = /\s+/g; +const TRAILING_SEMICOLON_PATTERN = /;$/; + +export interface MediaUsageCaptureIdentity { + collectionId: string; + collectionSlug: string; +} + +type CaptureOperation = "insert" | "update" | "delete"; + +export async function installMediaUsageCaptureTriggers( + db: Kysely, + identity: MediaUsageCaptureIdentity, + options: { replaceExisting?: boolean } = {}, +): Promise { + const identifiers = await captureIdentifiers(identity); + if (isPostgres(db)) await installPostgresFunction(db); + const installedNames = await listOwnedCaptureTriggers(db, identifiers.tableName); + if (await hasExactCaptureTriggers(db, identifiers, identity, installedNames)) { + return; + } + + await assertCaptureLifecycle(db, identity, ["installing"]); + const expectedNames = new Set(Object.values(identifiers.triggerNames)); + const retainedNames = new Set( + options.replaceExisting === false + ? installedNames.filter((name) => expectedNames.has(name)) + : [], + ); + await removeCaptureTriggers( + db, + identifiers.tableName, + installedNames.filter((name) => !retainedNames.has(name)), + ); + + for (const operation of captureOperations) { + if (retainedNames.has(identifiers.triggerNames[operation])) continue; + if (isPostgres(db)) { + await postgresCreateTrigger( + db, + identifiers.tableName, + identifiers.triggerNames[operation], + operation, + identity, + ); + } else { + await sqliteCreateTrigger( + db, + identifiers.tableName, + identifiers.triggerNames[operation], + operation, + identity, + ); + } + } + + await assertExpectedTriggerSet(db, identifiers, identity); +} + +export async function verifyMediaUsageCaptureTriggers( + db: Kysely, + identity: MediaUsageCaptureIdentity, +): Promise { + const identifiers = await captureIdentifiers(identity); + if (!(await tableExists(db, identifiers.tableName))) return false; + return hasExactCaptureTriggers( + db, + identifiers, + identity, + await listOwnedCaptureTriggers(db, identifiers.tableName), + ); +} + +export async function removeMediaUsageCaptureTriggers( + db: Kysely, + identity: MediaUsageCaptureIdentity, +): Promise { + const identifiers = await captureIdentifiers(identity); + if (!(await tableExists(db, identifiers.tableName))) return; + await assertCaptureLifecycle(db, identity, ["installing", "deleting"]); + await removeCaptureTriggers( + db, + identifiers.tableName, + await listOwnedCaptureTriggers(db, identifiers.tableName), + ); +} + +const captureOperations: readonly CaptureOperation[] = ["insert", "update", "delete"]; + +async function captureIdentifiers(identity: MediaUsageCaptureIdentity): Promise<{ + tableName: string; + triggerNames: Record; +}> { + validateIdentifier(identity.collectionSlug, "collection slug"); + const tableName = `ec_${identity.collectionSlug}`; + validateIdentifier(tableName, "content table"); + + const digest = await identityDigest( + `${CAPTURE_TRIGGER_VERSION}:${identity.collectionId}:${identity.collectionSlug}`, + ); + const triggerNames = { + insert: `emdash_mu_${digest}_ai`, + update: `emdash_mu_${digest}_au`, + delete: `emdash_mu_${digest}_ad`, + }; + for (const triggerName of Object.values(triggerNames)) { + validateIdentifier(triggerName, "media usage trigger"); + if (triggerName.length > POSTGRES_IDENTIFIER_LIMIT) { + throw new Error(`Media usage trigger name exceeds ${POSTGRES_IDENTIFIER_LIMIT} bytes`); + } + } + + return { tableName, triggerNames }; +} + +async function identityDigest(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest).slice(0, 16), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +async function removeCaptureTriggers( + db: Kysely, + tableName: string, + triggerNames: readonly string[], +): Promise { + for (const triggerName of triggerNames) { + validateIdentifier(triggerName, "media usage trigger"); + if (isPostgres(db)) { + await sql` + DROP TRIGGER IF EXISTS ${sql.ref(triggerName)} ON ${sql.ref(tableName)} + `.execute(db); + } else { + await sql`DROP TRIGGER IF EXISTS ${sql.ref(triggerName)}`.execute(db); + } + } +} + +async function listOwnedCaptureTriggers( + db: Kysely, + tableName: string, +): Promise { + if (isPostgres(db)) { + const result = await sql<{ name: string }>` + SELECT trigger.tgname AS name + FROM pg_trigger AS trigger + INNER JOIN pg_class AS relation ON relation.oid = trigger.tgrelid + INNER JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = current_schema() + AND relation.relname = ${tableName} + AND NOT trigger.tgisinternal + AND left(trigger.tgname, 10) = ${OWNED_TRIGGER_PREFIX} + `.execute(db); + return result.rows.map((row) => row.name); + } + + const result = await sql<{ name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'trigger' + AND tbl_name = ${tableName} + AND substr(name, 1, 10) = ${OWNED_TRIGGER_PREFIX} + `.execute(db); + return result.rows.map((row) => row.name); +} + +async function hasExactCaptureTriggers( + db: Kysely, + identifiers: { + tableName: string; + triggerNames: Record; + }, + identity: MediaUsageCaptureIdentity, + installedNames: readonly string[], +): Promise { + const expectedNames = new Set(Object.values(identifiers.triggerNames)); + if ( + installedNames.length !== expectedNames.size || + installedNames.some((name) => !expectedNames.has(name)) + ) { + return false; + } + + if (isPostgres(db)) { + const result = await sql<{ + name: string; + trigger_type: number; + function_name: string; + arguments_hex: string; + enabled: string; + has_when: boolean; + }>` + SELECT + trigger.tgname AS name, + trigger.tgtype AS trigger_type, + procedure.proname AS function_name, + encode(trigger.tgargs, 'hex') AS arguments_hex, + trigger.tgenabled AS enabled, + trigger.tgqual IS NOT NULL AS has_when + FROM pg_trigger AS trigger + INNER JOIN pg_class AS relation ON relation.oid = trigger.tgrelid + INNER JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace + INNER JOIN pg_proc AS procedure ON procedure.oid = trigger.tgfoid + INNER JOIN pg_namespace AS function_namespace ON function_namespace.oid = procedure.pronamespace + WHERE namespace.nspname = current_schema() + AND function_namespace.nspname = current_schema() + AND relation.relname = ${identifiers.tableName} + AND NOT trigger.tgisinternal + AND trigger.tgconstraint = 0 + AND NOT trigger.tgdeferrable + AND NOT trigger.tginitdeferred + AND trigger.tgattr = ''::int2vector + AND left(trigger.tgname, 10) = ${OWNED_TRIGGER_PREFIX} + `.execute(db); + const expectedArguments = triggerArgumentsHex(identity); + return captureOperations.every((operation) => { + const row = result.rows.find( + (candidate) => candidate.name === identifiers.triggerNames[operation], + ); + return ( + row?.function_name === POSTGRES_TRIGGER_FUNCTION && + Number(row.trigger_type) === postgresTriggerType(operation) && + row.arguments_hex === expectedArguments && + row.enabled === "O" && + row.has_when === false + ); + }); + } + + const result = await sql<{ name: string; definition: string }>` + SELECT name, sql AS definition + FROM sqlite_master + WHERE type = 'trigger' + AND tbl_name = ${identifiers.tableName} + AND substr(name, 1, 10) = ${OWNED_TRIGGER_PREFIX} + `.execute(db); + return captureOperations.every((operation) => { + const row = result.rows.find( + (candidate) => candidate.name === identifiers.triggerNames[operation], + ); + const contentId = operation === "delete" ? sql`OLD.id` : sql`NEW.id`; + const expected = sqliteTriggerSql( + identifiers.tableName, + identifiers.triggerNames[operation], + operationSql(operation), + contentId, + identity, + ).compile(db).sql; + return row ? normalizeDdl(row.definition) === normalizeDdl(expected) : false; + }); +} + +function triggerArgumentsHex(identity: MediaUsageCaptureIdentity): string { + const bytes = new TextEncoder().encode(`${identity.collectionId}\0${identity.collectionSlug}\0`); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function postgresTriggerType(operation: CaptureOperation): number { + switch (operation) { + case "insert": + return 5; + case "update": + return 17; + case "delete": + return 9; + } +} + +function normalizeDdl(definition: string): string { + return definition.replace(WHITESPACE_PATTERN, " ").trim().replace(TRAILING_SEMICOLON_PATTERN, ""); +} + +async function assertCaptureLifecycle( + db: Kysely, + identity: MediaUsageCaptureIdentity, + allowedStates: readonly string[], +): Promise { + const lifecycle = await db + .selectFrom("_emdash_media_usage_index_status") + .select("capture_state") + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", identity.collectionSlug) + .where("collection_id", "=", identity.collectionId) + .executeTakeFirst(); + if (!lifecycle?.capture_state || !allowedStates.includes(lifecycle.capture_state)) { + throw new Error("Media usage capture trigger changes require a fenced collection lifecycle"); + } +} + +async function assertExpectedTriggerSet( + db: Kysely, + identifiers: { + tableName: string; + triggerNames: Record; + }, + identity: MediaUsageCaptureIdentity, +): Promise { + const actual = await listOwnedCaptureTriggers(db, identifiers.tableName); + if (!(await hasExactCaptureTriggers(db, identifiers, identity, actual))) { + throw new Error("Media usage capture trigger installation is incomplete"); + } +} + +async function installPostgresFunction(db: Kysely): Promise { + await sql` + CREATE OR REPLACE FUNCTION ${sql.ref(POSTGRES_TRIGGER_FUNCTION)}() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + v_collection_id text := TG_ARGV[0]; + v_collection_slug text := TG_ARGV[1]; + v_content_id text; + v_epoch bigint; + v_now text := to_char( + clock_timestamp() AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ); + BEGIN + IF TG_OP = 'DELETE' THEN + v_content_id := OLD.id; + ELSE + v_content_id := NEW.id; + END IF; + + UPDATE _emdash_media_usage_index_status + SET change_epoch = change_epoch + 1, + status = CASE WHEN status = 'complete' THEN 'stale' ELSE status END, + completed_at = CASE WHEN status = 'complete' THEN NULL ELSE completed_at END, + updated_at = v_now + WHERE adapter_id = 'content-media' + AND scope_type = 'collection' + AND scope_key = v_collection_slug + AND collection_id = v_collection_id + AND capture_state = 'active' + AND EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.id = v_collection_id + AND collection.slug = v_collection_slug + ) + RETURNING change_epoch INTO v_epoch; + + IF NOT FOUND THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'media usage capture inactive'; + END IF; + + INSERT INTO _emdash_media_usage_work ( + collection_id, + collection_slug, + content_id, + change_epoch, + work_version, + state, + attempt_count, + next_attempt_at, + lease_token, + lease_expires_at, + last_attempted_at, + last_error_code, + created_at, + updated_at + ) + VALUES ( + v_collection_id, + v_collection_slug, + v_content_id, + v_epoch, + 1, + 'pending', + 0, + v_now, + NULL, + NULL, + NULL, + NULL, + v_now, + v_now + ) + ON CONFLICT (collection_id, content_id) DO UPDATE SET + collection_slug = EXCLUDED.collection_slug, + change_epoch = EXCLUDED.change_epoch, + work_version = _emdash_media_usage_work.work_version + 1, + state = 'pending', + attempt_count = 0, + next_attempt_at = EXCLUDED.next_attempt_at, + lease_token = NULL, + lease_expires_at = NULL, + last_attempted_at = NULL, + last_error_code = NULL, + updated_at = EXCLUDED.updated_at; + + RETURN NULL; + END; + $function$ + `.execute(db); +} + +async function postgresCreateTrigger( + db: Kysely, + tableName: string, + triggerName: string, + operation: CaptureOperation, + identity: MediaUsageCaptureIdentity, +): Promise { + await sql` + CREATE TRIGGER ${sql.ref(triggerName)} + AFTER ${operationSql(operation)} ON ${sql.ref(tableName)} + FOR EACH ROW + EXECUTE FUNCTION ${sql.ref(POSTGRES_TRIGGER_FUNCTION)}( + ${sql.lit(identity.collectionId)}, + ${sql.lit(identity.collectionSlug)} + ) + `.execute(db); +} + +async function sqliteCreateTrigger( + db: Kysely, + tableName: string, + triggerName: string, + operation: CaptureOperation, + identity: MediaUsageCaptureIdentity, +): Promise { + const contentId = operation === "delete" ? sql`OLD.id` : sql`NEW.id`; + await sqliteTriggerSql( + tableName, + triggerName, + operationSql(operation), + contentId, + identity, + ).execute(db); +} + +function sqliteTriggerSql( + tableName: string, + triggerName: string, + operation: RawBuilder, + contentId: RawBuilder, + identity: MediaUsageCaptureIdentity, +): RawBuilder { + return sql` + CREATE TRIGGER ${sql.ref(triggerName)} + AFTER ${operation} ON ${sql.ref(tableName)} + FOR EACH ROW + BEGIN + UPDATE _emdash_media_usage_index_status + SET change_epoch = change_epoch + 1, + status = CASE WHEN status = 'complete' THEN 'stale' ELSE status END, + completed_at = CASE WHEN status = 'complete' THEN NULL ELSE completed_at END, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE adapter_id = 'content-media' + AND scope_type = 'collection' + AND scope_key = ${sql.lit(identity.collectionSlug)} + AND collection_id = ${sql.lit(identity.collectionId)} + AND capture_state = 'active' + AND EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.id = ${sql.lit(identity.collectionId)} + AND collection.slug = ${sql.lit(identity.collectionSlug)} + ); + + SELECT CASE + WHEN changes() <> 1 THEN RAISE(ABORT, 'media usage capture inactive') + END; + + INSERT INTO _emdash_media_usage_work ( + collection_id, + collection_slug, + content_id, + change_epoch, + work_version, + state, + attempt_count, + next_attempt_at, + lease_token, + lease_expires_at, + last_attempted_at, + last_error_code, + created_at, + updated_at + ) + SELECT + ${sql.lit(identity.collectionId)}, + ${sql.lit(identity.collectionSlug)}, + ${contentId}, + change_epoch, + 1, + 'pending', + 0, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + NULL, + NULL, + NULL, + NULL, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + FROM _emdash_media_usage_index_status + WHERE adapter_id = 'content-media' + AND scope_type = 'collection' + AND scope_key = ${sql.lit(identity.collectionSlug)} + AND collection_id = ${sql.lit(identity.collectionId)} + AND capture_state = 'active' + ON CONFLICT (collection_id, content_id) DO UPDATE SET + collection_slug = excluded.collection_slug, + change_epoch = excluded.change_epoch, + work_version = _emdash_media_usage_work.work_version + 1, + state = 'pending', + attempt_count = 0, + next_attempt_at = excluded.next_attempt_at, + lease_token = NULL, + lease_expires_at = NULL, + last_attempted_at = NULL, + last_error_code = NULL, + updated_at = excluded.updated_at; + END + `; +} + +function operationSql(operation: CaptureOperation): RawBuilder { + switch (operation) { + case "insert": + return sql`INSERT`; + case "update": + return sql`UPDATE`; + case "delete": + return sql`DELETE`; + } +} diff --git a/packages/core/src/media/usage/content-fields.ts b/packages/core/src/media/usage/content-fields.ts index e139918423..9dc5146a74 100644 --- a/packages/core/src/media/usage/content-fields.ts +++ b/packages/core/src/media/usage/content-fields.ts @@ -35,15 +35,17 @@ type SupportedTopLevelType = (typeof SUPPORTED_TOP_LEVEL_TYPES)[number]; export async function loadContentMediaUsageFields( db: Kysely, collectionSlug: string, + collectionId?: string, ): Promise { validateIdentifier(collectionSlug, "collection slug"); - const rows = await db + let query = db .selectFrom("_emdash_fields") .innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id") .select(["_emdash_fields.slug", "_emdash_fields.type", "_emdash_fields.validation"]) - .where("_emdash_collections.slug", "=", collectionSlug) - .execute(); + .where("_emdash_collections.slug", "=", collectionSlug); + if (collectionId !== undefined) query = query.where("_emdash_collections.id", "=", collectionId); + const rows = await query.execute(); const extractionFields: ContentMediaUsageField[] = []; const rowBySlug = new Map(); diff --git a/packages/core/src/media/usage/content-refresh.ts b/packages/core/src/media/usage/content-refresh.ts index ffd705dc5d..e0d93cf4c3 100644 --- a/packages/core/src/media/usage/content-refresh.ts +++ b/packages/core/src/media/usage/content-refresh.ts @@ -1,5 +1,6 @@ import { sql, type Kysely } from "kysely"; +import { tableExists } from "../../database/dialect-helpers.js"; import { MediaUsageRepository } from "../../database/repositories/media-usage.js"; import type { Database } from "../../database/types.js"; import { validateIdentifier } from "../../database/validate.js"; @@ -34,6 +35,11 @@ export type ContentMediaUsageRefreshErrorCode = | "CONTENT_USAGE_GENERATION_CONFLICT" | "CONTENT_USAGE_STALE"; +interface ContentMediaUsageRefreshOptions { + collectionId?: string; + durableWork?: boolean; +} + export interface ContentMediaUsageRefreshResult { success: boolean; refreshedSourceCount: number; @@ -57,7 +63,25 @@ export async function refreshContentMediaUsage( validateIdentifier(collectionSlug, "collection slug"); return withContentUsageCollectionLock(collectionSlug, () => withContentUsageLock(collectionSlug, contentId, () => - refreshContentMediaUsageUnlocked(db, collectionSlug, contentId), + refreshContentMediaUsageUnlocked(db, collectionSlug, contentId, {}), + ), + ); +} + +export async function refreshContentMediaUsageForWork( + db: Kysely, + collectionId: string, + collectionSlug: string, + contentId: string, +): Promise { + validateIdentifier(collectionSlug, "collection slug"); + if (!collectionId) throw new Error("Durable media usage work requires a collection identity"); + return withContentUsageCollectionLock(collectionSlug, () => + withContentUsageLock(collectionSlug, contentId, () => + refreshContentMediaUsageUnlocked(db, collectionSlug, contentId, { + collectionId, + durableWork: true, + }), ), ); } @@ -66,26 +90,35 @@ async function refreshContentMediaUsageUnlocked( db: Kysely, collectionSlug: string, contentId: string, + options: ContentMediaUsageRefreshOptions, ): Promise { try { let conflictResult: ContentMediaUsageRefreshResult | null = null; for (let attempt = 0; attempt < CONTENT_USAGE_REFRESH_MAX_ATTEMPTS; attempt++) { - const result = await refreshContentMediaUsageAttempt(db, collectionSlug, contentId); + const result = await refreshContentMediaUsageAttempt(db, collectionSlug, contentId, options); if (result.errorCode !== "CONTENT_USAGE_GENERATION_CONFLICT") return result; conflictResult = result; } + if (options.durableWork) { + return generationConflictResult({ + refreshedSourceCount: conflictResult?.refreshedSourceCount ?? 0, + deletedSourceCount: conflictResult?.deletedSourceCount ?? 0, + }); + } return markGenerationConflict(db, collectionSlug, { refreshedSourceCount: conflictResult?.refreshedSourceCount ?? 0, deletedSourceCount: conflictResult?.deletedSourceCount ?? 0, }); } catch (error) { console.error(`[media-usage] Failed to refresh ${collectionSlug}/${contentId}:`, error); - await markContentMediaUsageCollectionStaleSafely( - db, - collectionSlug, - "CONTENT_USAGE_REFRESH_ERROR", - ); + if (!options.durableWork) { + await markContentMediaUsageCollectionStaleSafely( + db, + collectionSlug, + "CONTENT_USAGE_REFRESH_ERROR", + ); + } return { success: false, refreshedSourceCount: 0, @@ -100,30 +133,64 @@ async function refreshContentMediaUsageAttempt( db: Kysely, collectionSlug: string, contentId: string, + options: ContentMediaUsageRefreshOptions, ): Promise { const repo = new MediaUsageRepository(db); - const observedGenerations = await loadObservedContentSourceGenerations( + const observedSources = await loadObservedContentSources( + repo, + collectionSlug, + contentId, + options.collectionId, + ); + const snapshotsResult = await loadContentMediaUsageSnapshots( db, collectionSlug, contentId, + undefined, + options.collectionId ? { collectionId: options.collectionId, identityVersion: 1 } : undefined, ); - const snapshotsResult = await loadContentMediaUsageSnapshots(db, collectionSlug, contentId); if (!snapshotsResult.success) { - return markSnapshotFailure(db, collectionSlug, snapshotsResult); + if (snapshotsResult.error === "CONTENT_NOT_FOUND" && options.collectionId) { + if (!(await contentCollectionExists(db, collectionSlug, options.collectionId))) { + return generationConflictResult({ refreshedSourceCount: 0, deletedSourceCount: 0 }); + } + return deleteCanonicalContentSourcesIfAbsent( + repo, + observedSources, + collectionSlug, + contentId, + ); + } + if ( + snapshotsResult.error === "CONTENT_NOT_FOUND" && + !(await contentCollectionExists(db, collectionSlug)) + ) { + const deletedSourceCount = await repo.deleteContentSources(collectionSlug, contentId); + return { ...ZERO_RESULT, deletedSourceCount }; + } + return options.durableWork + ? snapshotFailureResult(snapshotsResult) + : markSnapshotFailure(db, collectionSlug, snapshotsResult); } - if (!(await contentCollectionExists(db, collectionSlug))) { + if (!(await contentCollectionExists(db, collectionSlug, options.collectionId))) { + if (options.collectionId) { + return generationConflictResult({ refreshedSourceCount: 0, deletedSourceCount: 0 }); + } const deletedSourceCount = await repo.deleteContentSources(collectionSlug, contentId); return { ...ZERO_RESULT, deletedSourceCount }; } - let refreshedSourceCount = 0; for (const snapshot of snapshotsResult.snapshots) { - const result = await repo.replaceSourceIfCurrent( + const result = await repo.replaceSourceIfMatching( snapshot.source, snapshot.occurrences, - observedGenerations.get(snapshot.source.sourceKey) ?? null, + observedSources.get(snapshot.source.sourceKey) ?? null, ); + if (result.unchanged) { + refreshedSourceCount++; + continue; + } if (!result.replaced) { return generationConflictResult({ refreshedSourceCount, @@ -132,7 +199,10 @@ async function refreshContentMediaUsageAttempt( } refreshedSourceCount++; } - if (!(await contentCollectionExists(db, collectionSlug))) { + if (!(await contentCollectionExists(db, collectionSlug, options.collectionId))) { + if (options.collectionId) { + return generationConflictResult({ refreshedSourceCount, deletedSourceCount: 0 }); + } const deletedSourceCount = await repo.deleteContentSources(collectionSlug, contentId); return { ...ZERO_RESULT, deletedSourceCount }; } @@ -141,14 +211,19 @@ async function refreshContentMediaUsageAttempt( snapshotsResult.snapshots.map((snapshot) => snapshot.source.sourceKey), ); const absentSourceKeys = MEDIA_USAGE_CONTENT_SOURCE_VARIANTS.map((sourceVariant) => - buildContentMediaUsageSourceKey({ collectionSlug, contentId, sourceVariant }), + buildContentMediaUsageSourceKey({ + collectionId: options.collectionId, + collectionSlug, + contentId, + sourceVariant, + }), ).filter((sourceKey) => !expectedSourceKeys.has(sourceKey)); let deletedSourceCount = 0; for (const sourceKey of absentSourceKeys) { - const expectedGeneration = observedGenerations.get(sourceKey) ?? null; - if (expectedGeneration === null) continue; + const expectedSource = observedSources.get(sourceKey); + if (!expectedSource) continue; - const result = await repo.deleteSourceIfCurrent(sourceKey, expectedGeneration); + const result = await repo.deleteSourceIfMatching(sourceKey, expectedSource); if (result.deleted) { deletedSourceCount++; continue; @@ -169,33 +244,21 @@ async function refreshContentMediaUsageAttempt( }; } -async function loadObservedContentSourceGenerations( - db: Kysely, +async function loadObservedContentSources( + repo: MediaUsageRepository, collectionSlug: string, contentId: string, -): Promise> { - const generations = new Map(); + collectionId?: string, +): Promise>> { const sourceKeys = MEDIA_USAGE_CONTENT_SOURCE_VARIANTS.map((sourceVariant) => buildContentMediaUsageSourceKey({ + collectionId, collectionSlug, contentId, sourceVariant, }), ); - for (const sourceKey of sourceKeys) { - generations.set(sourceKey, null); - } - - const rows = await db - .selectFrom("_emdash_media_usage_sources") - .select(["source_key", "current_generation"]) - .where("source_key", "in", sourceKeys) - .execute(); - for (const row of rows) { - generations.set(row.source_key, row.current_generation); - } - - return generations; + return repo.findSources(sourceKeys); } async function markGenerationConflict( @@ -232,12 +295,11 @@ function generationConflictResult( async function contentCollectionExists( db: Kysely, collectionSlug: string, + collectionId?: string, ): Promise { - const row = await db - .selectFrom("_emdash_collections") - .select("id") - .where("slug", "=", collectionSlug) - .executeTakeFirst(); + let query = db.selectFrom("_emdash_collections").select("id").where("slug", "=", collectionSlug); + if (collectionId) query = query.where("id", "=", collectionId); + const row = await query.executeTakeFirst(); return row !== undefined; } @@ -371,6 +433,28 @@ export async function markContentMediaUsageCollectionStale( }); } +export async function invalidateContentMediaUsageSchemaChange( + db: Kysely, + collectionSlug: string, +): Promise { + validateIdentifier(collectionSlug, "collection slug"); + if (!(await tableExists(db, "_emdash_media_usage_activation"))) return false; + const activation = await db + .selectFrom("_emdash_media_usage_activation") + .select("state") + .where("task_key", "=", "incremental_capture") + .executeTakeFirst(); + if (activation?.state !== "active") return false; + + const invalidated = await new MediaUsageRepository(db).invalidateIndexStatusForSchemaChange( + collectionSlug, + ); + if (!invalidated) { + throw new Error(`Cannot invalidate media usage coverage for collection ${collectionSlug}`); + } + return true; +} + export async function findNonTranslatableSiblingContentIds( db: Kysely, collectionSlug: string, @@ -442,6 +526,43 @@ async function markSnapshotFailure( }; } +function snapshotFailureResult( + result: Exclude>, { success: true }>, +): ContentMediaUsageRefreshResult { + return { + success: false, + refreshedSourceCount: 0, + deletedSourceCount: 0, + failedSourceCount: result.source ? 1 : 0, + errorCode: result.error, + }; +} + +async function deleteCanonicalContentSourcesIfAbsent( + repo: MediaUsageRepository, + observedSources: Awaited>, + collectionSlug: string, + contentId: string, +): Promise { + let deletedSourceCount = 0; + for (const source of observedSources.values()) { + const result = await repo.deleteSourceIfMatchingContentAbsent( + source.sourceKey, + source, + collectionSlug, + contentId, + ); + if (result.deleted) { + deletedSourceCount++; + continue; + } + if (result.contentPresent || result.source) { + return generationConflictResult({ refreshedSourceCount: 0, deletedSourceCount }); + } + } + return { ...ZERO_RESULT, deletedSourceCount }; +} + export async function markContentMediaUsageCollectionStaleSafely( db: Kysely, collectionSlug: string, diff --git a/packages/core/src/media/usage/content-repair.ts b/packages/core/src/media/usage/content-repair.ts index bff130547a..540ef633e9 100644 --- a/packages/core/src/media/usage/content-repair.ts +++ b/packages/core/src/media/usage/content-repair.ts @@ -1,6 +1,7 @@ import { sql, type Kysely } from "kysely"; import { ulid } from "ulidx"; +import { MediaUsageWorkRepository } from "../../database/repositories/media-usage-work.js"; import { MediaUsageRepository, type MediaUsageSource, @@ -90,6 +91,11 @@ interface ContentMediaUsageInitialCollectionResult { result: ContentMediaUsageRepairCollectionResult; } +interface ContentMediaUsageRepairExecution { + collectionId: string; + startingEpoch: number | string; +} + export async function repairContentMediaUsageAll( db: Kysely, ): Promise { @@ -99,7 +105,7 @@ export async function repairContentMediaUsageAll( for (const collection of collections) { results.push({ collection, - result: await repairContentMediaUsageCollectionSafely(db, collection.slug), + result: await repairContentMediaUsageCollectionSafely(db, collection), }); } @@ -111,20 +117,30 @@ export async function repairContentMediaUsageAll( export async function scanContentMediaUsageCollection( db: Kysely, collectionSlug: string, + expectedCollectionId?: string, ): Promise { validateIdentifier(collectionSlug, "collection slug"); - const collection = await db + let collectionQuery = db .selectFrom("_emdash_collections") .select("id") - .where("slug", "=", collectionSlug) - .executeTakeFirst(); + .where("slug", "=", collectionSlug); + if (expectedCollectionId !== undefined) { + collectionQuery = collectionQuery.where("id", "=", expectedCollectionId); + } + const collection = await collectionQuery.executeTakeFirst(); if (!collection) return null; const tableName = getContentTableName(collectionSlug); const rows = await sql<{ id: string }>` - SELECT id - FROM ${sql.ref(tableName)} - ORDER BY id ASC + SELECT content.id + FROM ${sql.ref(tableName)} AS content + WHERE EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.id = ${collection.id} + AND collection.slug = ${collectionSlug} + ) + ORDER BY content.id ASC `.execute(db); return { @@ -155,15 +171,17 @@ async function loadContentMediaUsageCollectionRecords( async function repairContentMediaUsageCollectionSafely( db: Kysely, - collectionSlug: string, + collection: ContentMediaUsageCollectionRecord, ): Promise { try { - return await repairContentMediaUsageCollection(db, { collectionSlug }); + return await withContentUsageCollectionLock(collection.slug, () => + repairContentMediaUsageCollectionUnlocked(db, collection.slug, collection.id), + ); } catch (error) { - console.error(`[media-usage] Failed to repair collection ${collectionSlug}:`, error); + console.error(`[media-usage] Failed to repair collection ${collection.slug}:`, error); const now = new Date().toISOString(); return { - scope: contentMediaUsageCollectionScope(collectionSlug), + scope: contentMediaUsageCollectionScope(collection.slug), status: "failed", indexedSourceCount: 0, failedSourceCount: 0, @@ -180,25 +198,29 @@ async function filterExistingContentMediaUsageCollectionResults( db: Kysely, results: readonly ContentMediaUsageInitialCollectionResult[], ): Promise { + const identityBound = await isIncrementalCaptureActive(db); const currentCollections = await loadContentMediaUsageCollectionRecordsSafely(db); const currentIdsBySlug = new Map( currentCollections.map((collection) => [collection.slug, collection.id]), ); const includedResults: ContentMediaUsageRepairCollectionResult[] = []; - const excludedResults: ContentMediaUsageRepairCollectionResult[] = []; + const excludedResults: ContentMediaUsageInitialCollectionResult[] = []; for (const { collection, result } of results) { if (currentIdsBySlug.get(collection.slug) === collection.id) { includedResults.push(result); } else { - excludedResults.push(result); + excludedResults.push({ collection, result }); } } if (excludedResults.length > 0) { const repo = new MediaUsageRepository(db); - for (const result of excludedResults) { - await repo.deleteIndexStatus(result.scope); + for (const excluded of excludedResults) { + await repo.deleteIndexStatus( + excluded.result.scope, + identityBound ? excluded.collection.id : undefined, + ); } } @@ -256,10 +278,16 @@ function sumCollectionRepairCount( async function repairContentMediaUsageCollectionUnlocked( db: Kysely, collectionSlug: string, + expectedCollectionId?: string, ): Promise { - const startedAt = new Date().toISOString(); + const requestedAt = new Date().toISOString(); const scope = contentMediaUsageCollectionScope(collectionSlug); - if (!(await contentCollectionExists(db, collectionSlug))) { + const identityBound = await isIncrementalCaptureActive(db); + const collection = await loadContentMediaUsageCollectionRecord(db, collectionSlug); + if ( + !collection || + (identityBound && expectedCollectionId && collection.id !== expectedCollectionId) + ) { return { scope, status: "failed", @@ -268,25 +296,51 @@ async function repairContentMediaUsageCollectionUnlocked( skippedSourceCount: 0, deletedSourceCount: 0, lastErrorCode: CONTENT_MEDIA_USAGE_REPAIR_ERROR.COLLECTION_NOT_FOUND, - startedAt, - completedAt: startedAt, + startedAt: requestedAt, + completedAt: requestedAt, }; } const repo = new MediaUsageRepository(db); const runToken = ulid(); - await repo.beginIndexStatusRepair({ - ...scope, - runToken, - schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, - startedAt, - }); + let startedAt = requestedAt; + let execution: ContentMediaUsageRepairExecution | undefined; + if (identityBound) { + const run = await repo.beginIndexStatusRepairAtCurrentEpoch({ + ...scope, + collectionId: collection.id, + runToken, + schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, + }); + if (!run) { + return { + scope, + status: "failed", + indexedSourceCount: 0, + failedSourceCount: 0, + skippedSourceCount: 0, + deletedSourceCount: 0, + lastErrorCode: CONTENT_MEDIA_USAGE_REPAIR_ERROR.CONTENT_USAGE_REPAIR_ERROR, + startedAt, + completedAt: requestedAt, + }; + } + startedAt = run.startedAt; + execution = { collectionId: collection.id, startingEpoch: run.changeEpoch }; + } else { + await repo.beginIndexStatusRepair({ + ...scope, + runToken, + schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, + startedAt, + }); + } try { - const scan = await scanContentMediaUsageCollection(db, collectionSlug); + const scan = await scanContentMediaUsageCollection(db, collectionSlug, execution?.collectionId); if (!scan) { const completedAt = new Date().toISOString(); - return await finalizeRepairStatus(repo, { + return await finalizeRepairStatus(db, repo, { ...scope, runToken, counts: { @@ -300,10 +354,15 @@ async function repairContentMediaUsageCollectionUnlocked( status: "failed", startedAt, completedAt, + execution, }); } - const counts = await repairScannedContentSources(db, repo, scan); - const finalScan = await scanContentMediaUsageCollection(db, collectionSlug); + const counts = await repairScannedContentSources(db, repo, scan, execution?.collectionId); + const finalScan = await scanContentMediaUsageCollection( + db, + collectionSlug, + execution?.collectionId, + ); if (!finalScan) { counts.failedSourceCount++; counts.lastErrorCode = CONTENT_MEDIA_USAGE_REPAIR_ERROR.COLLECTION_NOT_FOUND; @@ -312,13 +371,14 @@ async function repairContentMediaUsageCollectionUnlocked( } const completedAt = new Date().toISOString(); const status = determineRepairStatus(counts); - return await finalizeRepairStatus(repo, { + return await finalizeRepairStatus(db, repo, { ...scope, runToken, counts, status, startedAt, completedAt, + execution, }); } catch (error) { if (!(error instanceof MediaUsageFieldDiscoveryError)) { @@ -329,7 +389,7 @@ async function repairContentMediaUsageCollectionUnlocked( error instanceof MediaUsageFieldDiscoveryError ? error.code : CONTENT_MEDIA_USAGE_REPAIR_ERROR.CONTENT_USAGE_REPAIR_ERROR; - return finalizeRepairStatus(repo, { + return finalizeRepairStatus(db, repo, { ...scope, runToken, counts: { @@ -343,6 +403,7 @@ async function repairContentMediaUsageCollectionUnlocked( status: "failed", startedAt, completedAt, + execution, }); } } @@ -360,6 +421,7 @@ async function repairScannedContentSources( db: Kysely, repo: MediaUsageRepository, scan: ContentMediaUsageCollectionScan, + collectionId?: string, ): Promise { const counts: RepairCounts = { indexedSourceCount: 0, @@ -370,8 +432,8 @@ async function repairScannedContentSources( missingContentIds: new Set(), }; - const fieldDiscovery = await loadContentMediaUsageFields(db, scan.collectionSlug); - const observedSources = await repo.findSources(buildContentSourceKeysForScan(scan)); + const fieldDiscovery = await loadContentMediaUsageFields(db, scan.collectionSlug, collectionId); + const observedSources = await repo.findSources(buildContentSourceKeysForScan(scan, collectionId)); for (const contentId of scan.contentIds) { await repairContentSource( @@ -382,10 +444,11 @@ async function repairScannedContentSources( fieldDiscovery, observedSources, counts, + collectionId, ); } - await reconcileOrphanedContentSources(db, repo, scan.collectionSlug, counts); + await reconcileOrphanedContentSources(db, repo, scan.collectionSlug, counts, collectionId); return counts; } @@ -397,13 +460,15 @@ async function repairContentSource( fieldDiscovery: ContentMediaUsageFieldDiscovery, observedSources: Map, counts: RepairCounts, + collectionId?: string, ): Promise { - const sourceKeys = buildContentSourceKeys(collectionSlug, contentId); + const sourceKeys = buildContentSourceKeys(collectionSlug, contentId, collectionId); const snapshotsResult = await loadContentMediaUsageSnapshots( db, collectionSlug, contentId, fieldDiscovery, + collectionId ? { collectionId, identityVersion: 1 } : undefined, ); if (!snapshotsResult.success) { if (snapshotsResult.error === CONTENT_MEDIA_USAGE_REPAIR_ERROR.CONTENT_NOT_FOUND) { @@ -466,7 +531,7 @@ async function repairSnapshotSources( snapshot.occurrences, observedSources.get(snapshot.source.sourceKey) ?? null, ); - if (result.replaced) { + if (result.replaced || result.unchanged) { counts.indexedSourceCount++; } else { markRepairConflict(counts); @@ -485,12 +550,14 @@ async function reconcileOrphanedContentSources( repo: MediaUsageRepository, collectionSlug: string, counts: RepairCounts, + collectionId?: string, ): Promise { - const sources = await repo.findCollectionContentSources(collectionSlug); + const sources = await repo.findCollectionContentSources(collectionSlug, collectionId); const existingContentIds = await findExistingContentIds( db, collectionSlug, sources.flatMap((source) => (source.contentId ? [source.contentId] : [])), + collectionId, ); for (const source of sources) { @@ -507,6 +574,7 @@ async function findExistingContentIds( db: Kysely, collectionSlug: string, contentIds: readonly string[], + collectionId?: string, ): Promise> { validateIdentifier(collectionSlug, "collection slug"); const existingContentIds = new Set(); @@ -516,9 +584,19 @@ async function findExistingContentIds( const tableName = getContentTableName(collectionSlug); for (const contentIdBatch of chunks(uniqueContentIds, SQL_BATCH_SIZE)) { const result = await sql<{ id: string }>` - SELECT id - FROM ${sql.ref(tableName)} - WHERE id IN (${sql.join(contentIdBatch)}) + SELECT content.id + FROM ${sql.ref(tableName)} AS content + WHERE content.id IN (${sql.join(contentIdBatch)}) + ${ + collectionId + ? sql`AND EXISTS ( + SELECT 1 + FROM _emdash_collections AS collection + WHERE collection.id = ${collectionId} + AND collection.slug = ${collectionSlug} + )` + : sql`` + } `.execute(db); for (const row of result.rows) { existingContentIds.add(row.id); @@ -573,24 +651,49 @@ interface FinalizeInput extends ContentMediaUsageRepairScope { status: Exclude; startedAt: string; completedAt: string; + execution?: ContentMediaUsageRepairExecution; } async function finalizeRepairStatus( + db: Kysely, repo: MediaUsageRepository, input: FinalizeInput, ): Promise { - const result = await repo.finalizeIndexStatusRepairIfRunning({ - adapterId: input.adapterId, - scopeType: input.scopeType, - scopeKey: input.scopeKey, - runToken: input.runToken, - status: input.status, - schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, - completedAt: input.completedAt, - indexedSourceCount: input.counts.indexedSourceCount, - failedSourceCount: input.counts.failedSourceCount, - lastErrorCode: input.counts.lastErrorCode, - }); + let result; + if (input.execution) { + if (input.status === "complete") { + await new MediaUsageWorkRepository(db).deleteWorkThroughEpoch( + input.execution.collectionId, + input.execution.startingEpoch, + ); + } + result = await repo.finalizeIndexStatusRepairAtEpoch({ + adapterId: input.adapterId, + scopeType: input.scopeType, + scopeKey: input.scopeKey, + collectionId: input.execution.collectionId, + runToken: input.runToken, + startingEpoch: input.execution.startingEpoch, + status: input.status, + schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, + indexedSourceCount: input.counts.indexedSourceCount, + failedSourceCount: input.counts.failedSourceCount, + lastErrorCode: input.counts.lastErrorCode, + }); + } else { + result = await repo.finalizeIndexStatusRepairIfRunning({ + adapterId: input.adapterId, + scopeType: input.scopeType, + scopeKey: input.scopeKey, + runToken: input.runToken, + status: input.status, + schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, + completedAt: input.completedAt, + indexedSourceCount: input.counts.indexedSourceCount, + failedSourceCount: input.counts.failedSourceCount, + lastErrorCode: input.counts.lastErrorCode, + }); + } return { scope: { @@ -608,7 +711,7 @@ async function finalizeRepairStatus( : (result.status?.lastErrorCode ?? CONTENT_MEDIA_USAGE_REPAIR_ERROR.CONTENT_USAGE_REPAIR_CONFLICT), startedAt: input.startedAt, - completedAt: result.finalized ? input.completedAt : null, + completedAt: result.finalized ? (result.status?.completedAt ?? input.completedAt) : null, }; } @@ -623,28 +726,44 @@ function determineRepairStatus( return "partial"; } -function buildContentSourceKeys(collectionSlug: string, contentId: string): string[] { +function buildContentSourceKeys( + collectionSlug: string, + contentId: string, + collectionId?: string, +): string[] { return MEDIA_USAGE_CONTENT_SOURCE_VARIANTS.map((sourceVariant) => - buildContentMediaUsageSourceKey({ collectionSlug, contentId, sourceVariant }), + buildContentMediaUsageSourceKey({ collectionId, collectionSlug, contentId, sourceVariant }), ); } -function buildContentSourceKeysForScan(scan: ContentMediaUsageCollectionScan): string[] { +function buildContentSourceKeysForScan( + scan: ContentMediaUsageCollectionScan, + collectionId?: string, +): string[] { return scan.contentIds.flatMap((contentId) => - buildContentSourceKeys(scan.collectionSlug, contentId), + buildContentSourceKeys(scan.collectionSlug, contentId, collectionId), ); } -async function contentCollectionExists( +async function loadContentMediaUsageCollectionRecord( db: Kysely, collectionSlug: string, -): Promise { +): Promise { const row = await db .selectFrom("_emdash_collections") - .select("id") + .select(["id", "slug"]) .where("slug", "=", collectionSlug) .executeTakeFirst(); - return row !== undefined; + return row ?? null; +} + +async function isIncrementalCaptureActive(db: Kysely): Promise { + const row = await db + .selectFrom("_emdash_media_usage_activation") + .select("state") + .where("task_key", "=", "incremental_capture") + .executeTakeFirst(); + return row?.state === "active"; } function sameContentIds(left: readonly string[], right: readonly string[]): boolean { diff --git a/packages/core/src/media/usage/content-snapshots.ts b/packages/core/src/media/usage/content-snapshots.ts index 707332c0d3..1e8d76b19f 100644 --- a/packages/core/src/media/usage/content-snapshots.ts +++ b/packages/core/src/media/usage/content-snapshots.ts @@ -6,19 +6,20 @@ import type { } from "../../database/repositories/media-usage.js"; import type { Database } from "../../database/types.js"; import { validateIdentifier } from "../../database/validate.js"; -import { hashString } from "../../utils/hash.js"; import { loadContentMediaUsageFields, type ContentMediaUsageField, type ContentMediaUsageFieldDiscovery, } from "./content-fields.js"; import { extractMediaUsageOccurrences } from "./extractor.js"; +import { buildMediaUsageProjectionFingerprint } from "./projection-fingerprint.js"; import { buildContentMediaUsageSourceKey, type MediaUsageContentSourceVariant, } from "./source-key.js"; export const CONTENT_SOURCE_SCHEMA_VERSION = 1; +const CONTENT_COLLECTION_ID_RESULT = "__emdash_media_usage_collection_id"; const CONTENT_SYSTEM_COLUMNS = [ "id", @@ -55,20 +56,36 @@ export interface ContentMediaUsageSnapshot { fields: readonly ContentMediaUsageField[]; } +export interface LoadContentMediaUsageSnapshotsOptions { + collectionId?: string; + identityVersion?: number; +} + export async function loadContentMediaUsageSnapshots( db: Kysely, collectionSlug: string, contentId: string, fieldDiscovery?: ContentMediaUsageFieldDiscovery, + options: LoadContentMediaUsageSnapshotsOptions = {}, ): Promise { validateIdentifier(collectionSlug, "collection slug"); + if (options.identityVersion !== undefined && !options.collectionId) { + throw new Error("Canonical media usage snapshots require a collection identity"); + } const discovery = fieldDiscovery ?? (await loadContentMediaUsageFields(db, collectionSlug)); - const row = await loadContentRow(db, collectionSlug, contentId, [ - ...discovery.extractionFields.map((field) => field.slug), - ...discovery.displayFieldSlugs, - ]); + const row = await loadContentRow( + db, + collectionSlug, + contentId, + [...discovery.extractionFields.map((field) => field.slug), ...discovery.displayFieldSlugs], + options.collectionId, + ); if (!row) return { success: false, error: "CONTENT_NOT_FOUND" }; + const collectionId = readString(row[CONTENT_COLLECTION_ID_RESULT]); + if (!collectionId) { + throw new Error("Media usage snapshot query did not return a collection identity"); + } const columnsData = projectData( row, @@ -80,23 +97,24 @@ export async function loadContentMediaUsageSnapshots( data: columnsData, }); const columnsRevisionId = readNullableString(row.live_revision_id); - const columnsFingerprint = await buildSourceFingerprint({ + const columnsSource = buildContentSource({ + collectionId: options.collectionId, collectionSlug, + identityVersion: options.identityVersion, + row, + displayData, sourceVariant: "columns", revisionId: columnsRevisionId, - fields: discovery.extractionFields, - data: columnsData, + }); + columnsSource.sourceFingerprint = await buildMediaUsageProjectionFingerprint({ + collectionId, + source: columnsSource, + occurrences, + extractionFields: discovery.extractionFields, }); const snapshots: ContentMediaUsageSnapshot[] = [ { - source: buildContentSource({ - collectionSlug, - row, - displayData, - sourceVariant: "columns", - revisionId: columnsRevisionId, - sourceFingerprint: columnsFingerprint, - }), + source: columnsSource, occurrences, fields: discovery.extractionFields, }, @@ -105,7 +123,9 @@ export async function loadContentMediaUsageSnapshots( const draftRevisionId = readNullableString(row.draft_revision_id); if (draftRevisionId) { const attemptedDraftSource = buildContentSource({ + collectionId: options.collectionId, collectionSlug, + identityVersion: options.identityVersion, row, displayData, sourceVariant: "draft_overlay", @@ -146,27 +166,29 @@ export async function loadContentMediaUsageSnapshots( }; const draftContentSlug = readNullableString(revision.data._slug) ?? readNullableString(row.slug); - const draftFingerprint = await buildSourceFingerprint({ + const draftOccurrences = extractMediaUsageOccurrences({ + fields: discovery.extractionFields, + data: draftOverlayData, + }); + const draftSource = buildContentSource({ + collectionId: options.collectionId, collectionSlug, + identityVersion: options.identityVersion, + row, + displayData: draftDisplayData, sourceVariant: "draft_overlay", revisionId: draftRevisionId, - fields: discovery.extractionFields, - data: draftOverlayData, + contentSlug: draftContentSlug, + }); + draftSource.sourceFingerprint = await buildMediaUsageProjectionFingerprint({ + collectionId, + source: draftSource, + occurrences: draftOccurrences, + extractionFields: discovery.extractionFields, }); snapshots.push({ - source: buildContentSource({ - collectionSlug, - row, - displayData: draftDisplayData, - sourceVariant: "draft_overlay", - revisionId: draftRevisionId, - contentSlug: draftContentSlug, - sourceFingerprint: draftFingerprint, - }), - occurrences: extractMediaUsageOccurrences({ - fields: discovery.extractionFields, - data: draftOverlayData, - }), + source: draftSource, + occurrences: draftOccurrences, fields: discovery.extractionFields, }); } @@ -189,14 +211,20 @@ async function loadContentRow( collectionSlug: string, contentId: string, fieldSlugs: readonly string[], + expectedCollectionId?: string, ): Promise | null> { const tableName = getContentTableName(collectionSlug); const columns = uniqueColumns([...CONTENT_SYSTEM_COLUMNS, ...fieldSlugs]); - const columnRefs = columns.map((column) => sql.ref(column)); + const columnRefs = columns.map((column) => sql.ref(`content.${column}`)); const result = await sql>` - SELECT ${sql.join(columnRefs, sql`, `)} - FROM ${sql.ref(tableName)} - WHERE id = ${contentId} + SELECT + ${sql.join(columnRefs, sql`, `)}, + collection.id AS __emdash_media_usage_collection_id + FROM ${sql.ref(tableName)} AS content + INNER JOIN _emdash_collections AS collection + ON collection.slug = ${collectionSlug} + ${expectedCollectionId ? sql`AND collection.id = ${expectedCollectionId}` : sql``} + WHERE content.id = ${contentId} LIMIT 1 `.execute(db); @@ -227,24 +255,35 @@ async function loadRevisionRow( } function buildContentSource(input: { + collectionId?: string; collectionSlug: string; + identityVersion?: number; row: Record; displayData: Record; sourceVariant: MediaUsageContentSourceVariant; revisionId: string | null; contentSlug?: string | null; - sourceFingerprint?: string | null; }): MediaUsageSourceInput { - const { collectionSlug, row, displayData, sourceVariant, revisionId } = input; + const { + collectionId, + collectionSlug, + identityVersion, + row, + displayData, + sourceVariant, + revisionId, + } = input; const contentId = readString(row.id) ?? ""; const contentSlug = input.contentSlug ?? readNullableString(row.slug); const source: MediaUsageSourceInput = { sourceKey: buildContentMediaUsageSourceKey({ + collectionId, collectionSlug, contentId, sourceVariant, }), sourceType: "content", + collectionId, collectionSlug, contentId, sourceVariant, @@ -259,76 +298,11 @@ function buildContentSource(input: { schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, sourceUpdatedAt: readNullableString(row.updated_at), sourceVersion: readNumber(row.version), + identityVersion, }; - if (input.sourceFingerprint !== undefined) source.sourceFingerprint = input.sourceFingerprint; return source; } -async function buildSourceFingerprint(input: { - collectionSlug: string; - sourceVariant: MediaUsageContentSourceVariant; - revisionId: string | null; - fields: readonly ContentMediaUsageField[]; - data: Record; -}): Promise { - return hashString( - canonicalJson({ - schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, - collectionSlug: input.collectionSlug, - sourceVariant: input.sourceVariant, - fields: normalizeFingerprintFields(input.fields), - values: projectFingerprintData(input.data, input.fields), - revisionId: input.sourceVariant === "draft_overlay" ? input.revisionId : null, - }), - ); -} - -function normalizeFingerprintFields( - fields: readonly ContentMediaUsageField[], -): Record[] { - return fields - .map((field) => { - if (field.type !== "repeater") return { slug: field.slug, type: field.type }; - return { - slug: field.slug, - type: field.type, - subFields: (field.validation?.subFields ?? []) - .map((subField) => ({ slug: subField.slug, type: subField.type })) - .toSorted((a, b) => a.slug.localeCompare(b.slug)), - }; - }) - .toSorted((a, b) => String(a.slug).localeCompare(String(b.slug))); -} - -function projectFingerprintData( - data: Record, - fields: readonly ContentMediaUsageField[], -): Record { - const projected: Record = {}; - for (const field of fields) { - projected[field.slug] = Object.hasOwn(data, field.slug) ? data[field.slug] : null; - } - return projected; -} - -function canonicalJson(value: unknown): string { - return JSON.stringify(canonicalize(value)); -} - -function canonicalize(value: unknown): unknown { - if (value === undefined) return null; - if (typeof value === "bigint") return value.toString(); - if (typeof value === "number") return Number.isFinite(value) ? value : null; - if (Array.isArray(value)) return value.map((item) => canonicalize(item)); - if (!isRecord(value)) return value; - - const canonical: Record = {}; - for (const key of Object.keys(value).toSorted()) { - canonical[key] = canonicalize(value[key]); - } - return canonical; -} - function projectData( row: Record, fieldSlugs: readonly string[], diff --git a/packages/core/src/media/usage/projection-fingerprint.ts b/packages/core/src/media/usage/projection-fingerprint.ts new file mode 100644 index 0000000000..e609593dbf --- /dev/null +++ b/packages/core/src/media/usage/projection-fingerprint.ts @@ -0,0 +1,114 @@ +import type { + MediaUsageOccurrenceInput, + MediaUsageSourceInput, +} from "../../database/repositories/media-usage.js"; +import type { MediaUsageExtractionField } from "./types.js"; + +export const MEDIA_USAGE_PROJECTION_FINGERPRINT_VERSION = 1; +const FINGERPRINT_PREFIX = `media-usage-projection:v${MEDIA_USAGE_PROJECTION_FINGERPRINT_VERSION}:sha256:`; +const FINGERPRINT_PATTERN = new RegExp(`^${FINGERPRINT_PREFIX}[a-f0-9]{64}$`); + +export interface MediaUsageProjectionFingerprintInput { + collectionId: string; + source: MediaUsageSourceInput; + occurrences: readonly MediaUsageOccurrenceInput[]; + extractionFields: readonly MediaUsageExtractionField[]; +} + +export async function buildMediaUsageProjectionFingerprint( + input: MediaUsageProjectionFingerprintInput, +): Promise { + if (!input.collectionId) { + throw new Error("Media usage projection fingerprints require a collection identity"); + } + const canonicalOccurrences = input.occurrences + .map((occurrence) => ({ + fieldSlug: occurrence.fieldSlug, + fieldPath: occurrence.fieldPath, + occurrenceIndex: occurrence.occurrenceIndex ?? 0, + referenceType: occurrence.referenceType, + mediaId: occurrence.mediaId, + provider: occurrence.provider, + providerAssetId: occurrence.providerAssetId, + mediaKind: occurrence.mediaKind ?? null, + mimeType: occurrence.mimeType ?? null, + })) + .map((occurrence) => ({ occurrence, key: canonicalJson(occurrence) })) + .toSorted((a, b) => compareCanonicalStrings(a.key, b.key)) + .map(({ occurrence }) => occurrence); + const payload = canonicalJson({ + fingerprintVersion: MEDIA_USAGE_PROJECTION_FINGERPRINT_VERSION, + collectionId: input.collectionId, + extractionSchema: normalizeExtractionFields(input.extractionFields), + source: { + sourceKey: input.source.sourceKey, + sourceType: input.source.sourceType, + collectionSlug: input.source.collectionSlug ?? null, + contentId: input.source.contentId ?? null, + sourceVariant: input.source.sourceVariant, + locale: input.source.locale ?? null, + translationGroup: input.source.translationGroup ?? null, + contentSlug: input.source.contentSlug ?? null, + contentTitle: input.source.contentTitle ?? null, + contentStatus: input.source.contentStatus ?? null, + contentScheduledAt: input.source.contentScheduledAt ?? null, + contentDeletedAt: input.source.contentDeletedAt ?? null, + revisionId: input.source.revisionId ?? null, + schemaVersion: input.source.schemaVersion ?? 1, + sourceCompleteness: input.source.sourceCompleteness ?? "complete", + }, + occurrences: canonicalOccurrences, + }); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload)); + const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); + return `${FINGERPRINT_PREFIX}${hex}`; +} + +function normalizeExtractionFields( + fields: readonly MediaUsageExtractionField[], +): Record[] { + return fields + .map((field) => { + if (field.type !== "repeater") return { slug: field.slug, type: field.type }; + return { + slug: field.slug, + type: field.type, + subFields: (field.validation?.subFields ?? []) + .map((subField) => ({ slug: subField.slug, type: subField.type })) + .toSorted((a, b) => compareCanonicalStrings(a.slug, b.slug)), + }; + }) + .toSorted((a, b) => compareCanonicalStrings(String(a.slug), String(b.slug))); +} + +function compareCanonicalStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +export function isMediaUsageProjectionFingerprint(value: string | null | undefined): boolean { + return typeof value === "string" && FINGERPRINT_PATTERN.test(value); +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} + +function canonicalize(value: unknown): unknown { + if (value === undefined) return null; + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (Array.isArray(value)) return value.map((item) => canonicalize(item)); + if (!isRecord(value)) return value; + + const canonical: Record = {}; + for (const key of Object.keys(value).toSorted()) { + canonical[key] = canonicalize(value[key]); + } + return canonical; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/media/usage/source-key.ts b/packages/core/src/media/usage/source-key.ts index e685476c5a..f046027036 100644 --- a/packages/core/src/media/usage/source-key.ts +++ b/packages/core/src/media/usage/source-key.ts @@ -3,6 +3,7 @@ export const MEDIA_USAGE_CONTENT_SOURCE_VARIANTS = ["columns", "draft_overlay"] export type MediaUsageContentSourceVariant = (typeof MEDIA_USAGE_CONTENT_SOURCE_VARIANTS)[number]; export interface ContentMediaUsageSourceKeyInput { + collectionId?: string; collectionSlug: string; contentId: string; sourceVariant: MediaUsageContentSourceVariant; @@ -18,5 +19,8 @@ export function isMediaUsageContentSourceVariant( } export function buildContentMediaUsageSourceKey(input: ContentMediaUsageSourceKeyInput): string { + if (input.collectionId) { + return `content:${input.collectionId}:${input.contentId}:${input.sourceVariant}`; + } return `content:${input.collectionSlug}:${input.contentId}:${input.sourceVariant}`; } diff --git a/packages/core/src/media/usage/work-processor.ts b/packages/core/src/media/usage/work-processor.ts new file mode 100644 index 0000000000..ceefbfe52d --- /dev/null +++ b/packages/core/src/media/usage/work-processor.ts @@ -0,0 +1,244 @@ +import type { Kysely } from "kysely"; + +import { + MediaUsageWorkRepository, + type MediaUsageWorkRecord, +} from "../../database/repositories/media-usage-work.js"; +import { MediaUsageRepository } from "../../database/repositories/media-usage.js"; +import type { Database } from "../../database/types.js"; +import { + refreshContentMediaUsageForWork, + type ContentMediaUsageRefreshErrorCode, +} from "./content-refresh.js"; + +export const MEDIA_USAGE_WORK_PROCESSING_LIMITS = Object.freeze({ + candidatesPerTick: 4, + jobsPerTick: 1, + maxTickDurationMs: 5_000, + leaseDurationSeconds: 60, + maxAttempts: 5, + retryBaseSeconds: 30, + retryMaxSeconds: 15 * 60, + retryJitterRatio: 0.25, + ordinaryStatementsPerJob: 20, +}); + +export type MediaUsageWorkProcessingOutcome = + | "inactive" + | "not_due" + | "claim_lost" + | "completed" + | "retry" + | "failed" + | "superseded" + | "obsolete"; + +export interface MediaUsageWorkProcessingResult { + outcome: MediaUsageWorkProcessingOutcome; + claimed: boolean; +} + +export interface MediaUsageWorkTickResult { + candidateCount: number; + claimedCount: number; + completedCount: number; + retryCount: number; + failedCount: number; + supersededCount: number; + obsoleteCount: number; + durationMs: number; + admissionClosed: boolean; +} + +export async function processMediaUsageWorkAfterWrite( + db: Kysely, + collectionSlug: string, + contentId: string, +): Promise { + if (!(await isIncrementalCaptureActive(db))) { + return { outcome: "inactive", claimed: false }; + } + + const repo = new MediaUsageWorkRepository(db); + const work = await repo.findWorkForContent(collectionSlug, contentId); + if (!work) return { outcome: "not_due", claimed: false }; + return processCandidate(db, repo, work); +} + +export async function processDueMediaUsageWork( + db: Kysely, +): Promise { + const startedAt = Date.now(); + const result: MediaUsageWorkTickResult = { + candidateCount: 0, + claimedCount: 0, + completedCount: 0, + retryCount: 0, + failedCount: 0, + supersededCount: 0, + obsoleteCount: 0, + durationMs: 0, + admissionClosed: false, + }; + + if (!(await isIncrementalCaptureActive(db))) { + result.durationMs = Date.now() - startedAt; + return result; + } + + const repo = new MediaUsageWorkRepository(db); + const candidates = await repo.findDueWork(MEDIA_USAGE_WORK_PROCESSING_LIMITS.candidatesPerTick); + result.candidateCount = candidates.length; + + for (const candidate of candidates) { + if ( + result.claimedCount >= MEDIA_USAGE_WORK_PROCESSING_LIMITS.jobsPerTick || + Date.now() - startedAt >= MEDIA_USAGE_WORK_PROCESSING_LIMITS.maxTickDurationMs + ) { + result.admissionClosed = true; + break; + } + + const processed = await processCandidate(db, repo, candidate); + if (processed.claimed) result.claimedCount++; + if (processed.outcome === "completed") result.completedCount++; + if (processed.outcome === "retry") result.retryCount++; + if (processed.outcome === "failed") result.failedCount++; + if (processed.outcome === "superseded") result.supersededCount++; + if (processed.outcome === "obsolete") result.obsoleteCount++; + } + + result.durationMs = Date.now() - startedAt; + return result; +} + +async function processCandidate( + db: Kysely, + repo: MediaUsageWorkRepository, + candidate: MediaUsageWorkRecord, +): Promise { + const claimed = await repo.claimWork({ + collectionId: candidate.collectionId, + contentId: candidate.contentId, + workVersion: candidate.workVersion, + leaseDurationSeconds: MEDIA_USAGE_WORK_PROCESSING_LIMITS.leaseDurationSeconds, + }); + if (!claimed?.leaseToken) return { outcome: "claim_lost", claimed: false }; + + const lease = { + collectionId: claimed.collectionId, + contentId: claimed.contentId, + workVersion: claimed.workVersion, + leaseToken: claimed.leaseToken, + }; + if (!(await collectionIdentityIsCurrent(db, claimed.collectionId, claimed.collectionSlug))) { + return { + outcome: (await repo.completeWork(lease)) ? "obsolete" : "superseded", + claimed: true, + }; + } + + const refresh = await refreshContentMediaUsageForWork( + db, + claimed.collectionId, + claimed.collectionSlug, + claimed.contentId, + ); + if (refresh.success) { + const completed = await repo.completeWork(lease); + if (completed) { + await new MediaUsageRepository(db).recordIncrementalSuccess({ + collectionId: claimed.collectionId, + collectionSlug: claimed.collectionSlug, + }); + } + return { + outcome: completed ? "completed" : "superseded", + claimed: true, + }; + } + + if (!(await collectionIdentityIsCurrent(db, claimed.collectionId, claimed.collectionSlug))) { + return { + outcome: (await repo.completeWork(lease)) ? "obsolete" : "superseded", + claimed: true, + }; + } + + const errorCode = processingErrorCode(refresh.errorCode); + if (claimed.attemptCount + 1 >= MEDIA_USAGE_WORK_PROCESSING_LIMITS.maxAttempts) { + const failed = await repo.failWork({ ...lease, errorCode }); + if (failed) { + await new MediaUsageRepository(db).recordIncrementalFailure({ + collectionId: claimed.collectionId, + collectionSlug: claimed.collectionSlug, + contentId: claimed.contentId, + workVersion: claimed.workVersion, + errorCode, + }); + } + return { + outcome: failed ? "failed" : "superseded", + claimed: true, + }; + } + + return { + outcome: (await repo.retryWork({ + ...lease, + errorCode, + retryDelaySeconds: retryDelaySeconds(claimed.attemptCount), + })) + ? "retry" + : "superseded", + claimed: true, + }; +} + +async function isIncrementalCaptureActive(db: Kysely): Promise { + const row = await db + .selectFrom("_emdash_media_usage_activation") + .select("state") + .where("task_key", "=", "incremental_capture") + .executeTakeFirst(); + return row?.state === "active"; +} + +async function collectionIdentityIsCurrent( + db: Kysely, + collectionId: string, + collectionSlug: string, +): Promise { + const row = await db + .selectFrom("_emdash_collections") + .select("id") + .where("id", "=", collectionId) + .where("slug", "=", collectionSlug) + .executeTakeFirst(); + return row !== undefined; +} + +function retryDelaySeconds(attemptCount: number): number { + const exponential = Math.min( + MEDIA_USAGE_WORK_PROCESSING_LIMITS.retryMaxSeconds, + MEDIA_USAGE_WORK_PROCESSING_LIMITS.retryBaseSeconds * 2 ** attemptCount, + ); + const jitter = Math.floor( + exponential * MEDIA_USAGE_WORK_PROCESSING_LIMITS.retryJitterRatio * Math.random(), + ); + return Math.min(MEDIA_USAGE_WORK_PROCESSING_LIMITS.retryMaxSeconds, exponential + jitter); +} + +function processingErrorCode(errorCode: ContentMediaUsageRefreshErrorCode | undefined): string { + if ( + errorCode === "DRAFT_REVISION_NOT_FOUND" || + errorCode === "DRAFT_REVISION_MISMATCH" || + errorCode === "DRAFT_REVISION_INVALID" + ) { + return "MEDIA_USAGE_SNAPSHOT_FAILED"; + } + if (errorCode === "CONTENT_USAGE_GENERATION_CONFLICT") { + return "MEDIA_USAGE_GENERATION_CONFLICT"; + } + return "MEDIA_USAGE_PROCESSING_FAILED"; +} diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index 233a6e16a5..a0012b321f 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -367,13 +367,17 @@ export function createTaxonomyAccess(db: Kysely): TaxonomyAccess { * the content write. The returned `ContentItem.seo` reflects the resulting * SEO state for SEO-enabled collections. */ -export function createContentAccessWithWrite(db: Kysely): ContentAccessWithWrite { +export function createContentAccessWithWrite( + db: Kysely, + beforeContentWrite?: () => Promise, +): ContentAccessWithWrite { const readAccess = createContentAccess(db); return { ...readAccess, async create(collection: string, data: ContentWriteInput): Promise { + await beforeContentWrite?.(); const { fields, seo } = splitSeoFromInput(data); let contentMutated = false; @@ -423,6 +427,7 @@ export function createContentAccessWithWrite(db: Kysely): ContentAcces }, async update(collection: string, id: string, data: ContentWriteInput): Promise { + await beforeContentWrite?.(); const { fields, seo } = splitSeoFromInput(data); const hasFieldUpdates = Object.keys(fields).length > 0; let contentMutated = false; @@ -483,6 +488,7 @@ export function createContentAccessWithWrite(db: Kysely): ContentAcces }, async delete(collection: string, id: string): Promise { + await beforeContentWrite?.(); const contentRepo = new ContentRepository(db); const deleted = await contentRepo.delete(collection, id); if (deleted) { @@ -990,6 +996,7 @@ export function createUserAccess(db: Kysely): UserAccess { export interface PluginContextFactoryOptions { db: Kysely; + beforeContentWrite?: () => Promise; /** * Resolver for the database connection, preferred over `db` when present. * Called per `createContext()` so connection-backed adapters (e.g. Postgres @@ -1044,6 +1051,7 @@ export interface PluginContextFactoryOptions { */ export class PluginContextFactory { private resolveDb: () => Kysely; + private beforeContentWrite?: () => Promise; private storage?: Storage; private getUploadUrl?: ( filename: string, @@ -1063,6 +1071,7 @@ export class PluginContextFactory { constructor(options: PluginContextFactoryOptions) { const fixedDb = options.db; this.resolveDb = options.getDb ?? (() => fixedDb); + this.beforeContentWrite = options.beforeContentWrite; this.storage = options.storage; this.getUploadUrl = options.getUploadUrl; this.site = createSiteInfo(options.siteInfo ?? {}); @@ -1095,7 +1104,7 @@ export class PluginContextFactory { // names ("read:content", "write:content") never appear here. let content: ContentAccess | ContentAccessWithWrite | undefined; if (capabilities.has("content:write")) { - content = createContentAccessWithWrite(db); + content = createContentAccessWithWrite(db, this.beforeContentWrite); } else if (capabilities.has("content:read")) { content = createContentAccess(db); } diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 75b5879a4d..c58624916a 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -73,6 +73,10 @@ export { NoopSandboxRunner, SandboxNotAvailableError, SandboxUnavailableError, + createSandboxRouteError, + createSandboxRouteErrorEnvelope, + getSandboxRouteErrorDetails, + getSandboxRouteErrorEnvelope, createNoopSandboxRunner, } from "./sandbox/index.js"; export type { @@ -85,6 +89,9 @@ export type { ResourceLimits, PluginCodeStorage, SerializedRequest, + SandboxRouteErrorCode, + SandboxRouteErrorDetails, + SandboxRouteErrorEnvelope, } from "./sandbox/index.js"; // Types diff --git a/packages/core/src/plugins/routes.ts b/packages/core/src/plugins/routes.ts index 43cd70e4a0..538dbb9883 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -8,6 +8,7 @@ * */ +import { MediaUsageActivationWriteBlockedError } from "../api/media-usage-write-fence.js"; import { PluginContextFactory, type PluginContextFactoryOptions } from "./context.js"; import { extractRequestMeta } from "./request-meta.js"; import type { ResolvedPlugin, RouteContext, PluginRoute } from "./types.js"; @@ -212,6 +213,13 @@ export class PluginRouteHandler { status: 200, }; } catch (error) { + if (error instanceof MediaUsageActivationWriteBlockedError) { + return { + success: false, + error: { code: error.code, message: error.message }, + status: error.status, + }; + } // Handle known error types if (error instanceof PluginRouteError) { return { diff --git a/packages/core/src/plugins/sandbox/index.ts b/packages/core/src/plugins/sandbox/index.ts index 2b03e8cc0e..1f5752ff8a 100644 --- a/packages/core/src/plugins/sandbox/index.ts +++ b/packages/core/src/plugins/sandbox/index.ts @@ -4,7 +4,13 @@ */ export { NoopSandboxRunner, SandboxNotAvailableError, createNoopSandboxRunner } from "./noop.js"; -export { SandboxUnavailableError } from "./types.js"; +export { + SandboxUnavailableError, + createSandboxRouteError, + createSandboxRouteErrorEnvelope, + getSandboxRouteErrorDetails, + getSandboxRouteErrorEnvelope, +} from "./types.js"; export type { SandboxRunner, @@ -16,4 +22,7 @@ export type { ResourceLimits, PluginCodeStorage, SerializedRequest, + SandboxRouteErrorCode, + SandboxRouteErrorDetails, + SandboxRouteErrorEnvelope, } from "./types.js"; diff --git a/packages/core/src/plugins/sandbox/types.ts b/packages/core/src/plugins/sandbox/types.ts index 3dfb344d59..1a02518104 100644 --- a/packages/core/src/plugins/sandbox/types.ts +++ b/packages/core/src/plugins/sandbox/types.ts @@ -69,6 +69,8 @@ export interface SandboxOptions { storage?: PluginCodeStorage; /** Database for bridge operations */ db: Kysely; + /** Called immediately before a sandboxed plugin content mutation. */ + beforeContentWrite?: () => Promise; /** Default resource limits */ limits?: ResourceLimits; /** Site info for plugin context (injected into wrapper at generation time) */ @@ -140,6 +142,76 @@ export interface SerializedRequest { meta: RequestMeta; } +const SANDBOX_ROUTE_ERROR_DEFINITIONS = { + MEDIA_USAGE_ACTIVATION_IN_PROGRESS: { + message: "Media usage activation is in progress", + status: 503, + }, + MEDIA_USAGE_ACTIVATION_CHECK_FAILED: { + message: "Unable to verify media usage activation state", + status: 503, + }, +} as const; + +export type SandboxRouteErrorCode = keyof typeof SANDBOX_ROUTE_ERROR_DEFINITIONS; + +export interface SandboxRouteErrorDetails { + code: SandboxRouteErrorCode; + message: string; + status: 503; +} + +export interface SandboxRouteErrorEnvelope { + __emdashSandboxRouteError: true; + error: SandboxRouteErrorDetails; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isSandboxRouteErrorCode(value: unknown): value is SandboxRouteErrorCode { + return typeof value === "string" && value in SANDBOX_ROUTE_ERROR_DEFINITIONS; +} + +export function getSandboxRouteErrorDetails(error: unknown): SandboxRouteErrorDetails | null { + if (!isRecord(error)) return null; + + const propertyCode = isSandboxRouteErrorCode(error.code) ? error.code : null; + const nameCode = + error instanceof Error && isSandboxRouteErrorCode(error.name) ? error.name : null; + if (propertyCode && nameCode && propertyCode !== nameCode) return null; + + const code = propertyCode ?? nameCode; + if (!code || (error.status !== undefined && error.status !== 503)) return null; + + return { + code, + ...SANDBOX_ROUTE_ERROR_DEFINITIONS[code], + }; +} + +export function createSandboxRouteError( + code: SandboxRouteErrorCode, +): Error & SandboxRouteErrorDetails { + const details: SandboxRouteErrorDetails = { + code, + ...SANDBOX_ROUTE_ERROR_DEFINITIONS[code], + }; + return Object.assign(new Error(details.message), details, { name: code }); +} + +export function createSandboxRouteErrorEnvelope(error: unknown): SandboxRouteErrorEnvelope | null { + const details = getSandboxRouteErrorDetails(error); + return details ? { __emdashSandboxRouteError: true, error: details } : null; +} + +export function getSandboxRouteErrorEnvelope(value: unknown): SandboxRouteErrorEnvelope | null { + if (!isRecord(value) || value.__emdashSandboxRouteError !== true) return null; + const details = getSandboxRouteErrorDetails(value.error); + return details ? { __emdashSandboxRouteError: true, error: details } : null; +} + /** * Sandbox runner interface. * Platform adapters implement this to provide plugin isolation. diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index d44b3d2bd8..0fe03b2b34 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -6,8 +6,16 @@ import { currentTimestamp, listTablesLike, tableExists } from "../database/diale import { withTransaction } from "../database/transaction.js"; import type { CollectionTable, Database, FieldTable } from "../database/types.js"; import { validateIdentifier } from "../database/validate.js"; +import { + canResumeMediaUsageCollectionCapture, + finalizeMediaUsageCollectionCapture, + installPreparedMediaUsageCollectionCapture, + markMediaUsageCollectionCaptureReady, + prepareMediaUsageCollectionCapture, +} from "../media/usage/activation.js"; import { deleteContentMediaUsageCollection, + invalidateContentMediaUsageSchemaChange, markContentMediaUsageCollectionStaleSafely, } from "../media/usage/content-refresh.js"; import { FTSManager } from "../search/fts-manager.js"; @@ -122,6 +130,70 @@ function parseCollectionAdmin(raw: string | null | undefined): CollectionAdminCo }; } +export async function buildSeedCollectionCaptureFingerprint( + input: Omit, + fields: readonly CreateFieldInput[], +): Promise { + const supports = input.supports ?? ["drafts", "revisions"]; + const hasSeo = input.hasSeo ?? supports.includes("seo") ?? false; + let maxSortOrder = -1; + const definitions = fields.map((field) => { + const sortOrder = field.sortOrder ?? maxSortOrder + 1; + maxSortOrder = Math.max(maxSortOrder, sortOrder); + return { + slug: field.slug, + label: field.label, + type: field.type, + required: field.required ?? false, + unique: field.unique ?? false, + defaultValue: field.defaultValue === undefined ? null : JSON.stringify(field.defaultValue), + validation: field.validation ? JSON.stringify(field.validation) : null, + widget: field.widget ?? null, + options: field.options ? JSON.stringify(field.options) : null, + sortOrder, + searchable: field.searchable ?? false, + translatable: field.translatable ?? true, + }; + }); + const payload = JSON.stringify( + canonicalizeFingerprintValue({ + version: 1, + collection: { + slug: input.slug, + label: input.label, + labelSingular: input.labelSingular ?? null, + description: input.description ?? null, + icon: input.icon ?? null, + admin: input.admin ?? null, + supports, + hasSeo, + hidden: input.hidden ?? false, + sortOrder: input.sortOrder ?? null, + commentsEnabled: input.commentsEnabled ?? false, + urlPattern: input.urlPattern ?? null, + }, + fields: definitions, + }), + ); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload)); + const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); + return `media-usage-seed:v1:sha256:${hex}`; +} + +function canonicalizeFingerprintValue(value: unknown): unknown { + if (value === undefined) return { __emdashUndefined: true }; + if (Array.isArray(value)) return value.map(canonicalizeFingerprintValue); + if (typeof value !== "object" || value === null) return value; + + const canonical: Record = {}; + for (const [key, entry] of Object.entries(value).toSorted(([a], [b]) => a.localeCompare(b))) { + canonical[key] = canonicalizeFingerprintValue(entry); + } + return canonical; +} + /** * Error thrown when a schema operation fails */ @@ -256,11 +328,17 @@ export class SchemaRegistry { // Check if collection already exists const existing = await this.getCollection(input.slug); - if (existing) { + if ( + existing && + !(await canResumeMediaUsageCollectionCapture(this.db, { + collectionId: existing.id, + collectionSlug: existing.slug, + })) + ) { throw new SchemaError(`Collection "${input.slug}" already exists`, "COLLECTION_EXISTS"); } - const id = ulid(); + const proposedId = existing?.id ?? ulid(); // Default `supports` to drafts + revisions when the caller didn't // specify it. Explicit empty array (`[]`) is preserved as an opt-out @@ -276,27 +354,51 @@ export class SchemaRegistry { const hasSeo = input.hasSeo ?? supports.includes("seo") ?? false; await withTransaction(this.db, async (trx) => { - await trx - .insertInto("_emdash_collections") - .values({ - id, - slug: input.slug, - label: input.label, - label_singular: input.labelSingular ?? null, - description: input.description ?? null, - icon: input.icon ?? null, - admin_config: input.admin ? JSON.stringify(input.admin) : null, - supports: JSON.stringify(supports), - source: input.source ?? "manual", - has_seo: hasSeo ? 1 : 0, - hidden: input.hidden ? 1 : 0, - sort_order: input.sortOrder ?? null, - comments_enabled: input.commentsEnabled ? 1 : 0, - url_pattern: input.urlPattern ?? null, - }) - .execute(); + const capture = await prepareMediaUsageCollectionCapture(trx, { + collectionId: proposedId, + collectionSlug: input.slug, + registeredCollectionId: existing?.id, + }); + const values: Insertable = { + id: capture.collectionId, + slug: input.slug, + label: input.label, + label_singular: input.labelSingular ?? null, + description: input.description ?? null, + icon: input.icon ?? null, + admin_config: input.admin ? JSON.stringify(input.admin) : null, + supports: JSON.stringify(supports), + source: input.source ?? "manual", + has_seo: hasSeo ? 1 : 0, + hidden: input.hidden ? 1 : 0, + sort_order: input.sortOrder ?? null, + comments_enabled: input.commentsEnabled ? 1 : 0, + url_pattern: input.urlPattern ?? null, + }; - // Create the content table for this collection + if (capture.captureRequired) { + await this.createContentTable(input.slug, trx, [], { + ifNotExists: capture.resuming, + }); + await installPreparedMediaUsageCollectionCapture(trx, { + collectionId: capture.collectionId, + collectionSlug: input.slug, + }); + await markMediaUsageCollectionCaptureReady(trx, { + collectionId: capture.collectionId, + collectionSlug: input.slug, + }); + if (!capture.registrationExists) { + await trx.insertInto("_emdash_collections").values(values).execute(); + } + await finalizeMediaUsageCollectionCapture(trx, { + collectionId: capture.collectionId, + collectionSlug: input.slug, + }); + return; + } + + await trx.insertInto("_emdash_collections").values(values).execute(); await this.createContentTable(input.slug, trx); }); @@ -327,11 +429,6 @@ export class SchemaRegistry { throw new SchemaError(`Collection slug "${input.slug}" is reserved`, "RESERVED_SLUG"); } - const existing = await this.getCollection(input.slug); - if (existing) { - throw new SchemaError(`Collection "${input.slug}" already exists`, "COLLECTION_EXISTS"); - } - const fieldSlugs = new Set(); for (const field of fields) { this.validateSlug(field.slug, "field"); @@ -347,9 +444,22 @@ export class SchemaRegistry { fieldSlugs.add(field.slug); } - const collectionId = ulid(); const supports = input.supports ?? ["drafts", "revisions"]; const hasSeo = input.hasSeo ?? supports.includes("seo") ?? false; + const creationFingerprint = await buildSeedCollectionCaptureFingerprint(input, fields); + const existing = await this.getCollection(input.slug); + if ( + existing && + !(await canResumeMediaUsageCollectionCapture(this.db, { + collectionId: existing.id, + collectionSlug: existing.slug, + creationFingerprint, + })) + ) { + throw new SchemaError(`Collection "${input.slug}" already exists`, "COLLECTION_EXISTS"); + } + + const proposedCollectionId = existing?.id ?? ulid(); let maxSortOrder = -1; const fieldRows: Insertable[] = fields.map((field) => { const sortOrder = field.sortOrder ?? maxSortOrder + 1; @@ -357,7 +467,7 @@ export class SchemaRegistry { return { id: ulid(), - collection_id: collectionId, + collection_id: proposedCollectionId, slug: field.slug, label: field.label, type: field.type, @@ -377,31 +487,72 @@ export class SchemaRegistry { let schemaMutated = false; try { await withTransaction(this.db, async (trx) => { - await trx - .insertInto("_emdash_collections") - .values({ - id: collectionId, - slug: input.slug, - label: input.label, - label_singular: input.labelSingular ?? null, - description: input.description ?? null, - icon: input.icon ?? null, - admin_config: input.admin ? JSON.stringify(input.admin) : null, - supports: JSON.stringify(supports), - source: "seed", - has_seo: hasSeo ? 1 : 0, - hidden: input.hidden ? 1 : 0, - sort_order: input.sortOrder ?? null, - comments_enabled: input.commentsEnabled ? 1 : 0, - url_pattern: input.urlPattern ?? null, - }) - .execute(); - schemaMutated = true; - - await this.createContentTable(input.slug, trx, fields); + const capture = await prepareMediaUsageCollectionCapture(trx, { + collectionId: proposedCollectionId, + collectionSlug: input.slug, + creationFingerprint, + registeredCollectionId: existing?.id, + }); + const collectionValues: Insertable = { + id: capture.collectionId, + slug: input.slug, + label: input.label, + label_singular: input.labelSingular ?? null, + description: input.description ?? null, + icon: input.icon ?? null, + admin_config: input.admin ? JSON.stringify(input.admin) : null, + supports: JSON.stringify(supports), + source: "seed", + has_seo: hasSeo ? 1 : 0, + hidden: input.hidden ? 1 : 0, + sort_order: input.sortOrder ?? null, + comments_enabled: input.commentsEnabled ? 1 : 0, + url_pattern: input.urlPattern ?? null, + }; + const rows = fieldRows.map((row) => ({ + ...row, + collection_id: capture.collectionId, + })); + + if (capture.captureRequired) { + await this.createContentTable(input.slug, trx, fields, { + ifNotExists: capture.resuming, + }); + await installPreparedMediaUsageCollectionCapture(trx, { + collectionId: capture.collectionId, + collectionSlug: input.slug, + }); + await markMediaUsageCollectionCaptureReady(trx, { + collectionId: capture.collectionId, + collectionSlug: input.slug, + }); + if (!capture.registrationExists) { + await trx.insertInto("_emdash_collections").values(collectionValues).execute(); + schemaMutated = true; + } + } else { + await trx.insertInto("_emdash_collections").values(collectionValues).execute(); + schemaMutated = true; + await this.createContentTable(input.slug, trx, fields); + } - for (const fieldBatch of chunks(fieldRows, SEED_FIELD_INSERT_BATCH_SIZE)) { - await trx.insertInto("_emdash_fields").values(fieldBatch).execute(); + for (const fieldBatch of chunks(rows, SEED_FIELD_INSERT_BATCH_SIZE)) { + let insert = trx.insertInto("_emdash_fields").values(fieldBatch); + if (capture.resuming) { + insert = insert.onConflict((conflict) => + conflict.columns(["collection_id", "slug"]).doNothing(), + ); + } + await insert.execute(); + } + if (capture.resuming) { + await this.assertSeedFieldDefinitions(capture.collectionId, fields, trx); + } + if (capture.captureRequired) { + await finalizeMediaUsageCollectionCapture(trx, { + collectionId: capture.collectionId, + collectionSlug: input.slug, + }); } }); @@ -418,6 +569,46 @@ export class SchemaRegistry { } } + private async assertSeedFieldDefinitions( + collectionId: string, + fields: readonly CreateFieldInput[], + db: Kysely, + ): Promise { + const stored = await db + .selectFrom("_emdash_fields") + .selectAll() + .where("collection_id", "=", collectionId) + .execute(); + if (stored.length !== fields.length) { + throw new SchemaError("Interrupted seed collection fields do not match", "CREATE_FAILED"); + } + + let maxSortOrder = -1; + for (const field of fields) { + const sortOrder = field.sortOrder ?? maxSortOrder + 1; + maxSortOrder = Math.max(maxSortOrder, sortOrder); + const row = stored.find((candidate) => candidate.slug === field.slug); + if ( + !row || + row.label !== field.label || + row.type !== field.type || + row.column_type !== FIELD_TYPE_TO_COLUMN[field.type] || + row.required !== (field.required ? 1 : 0) || + row.unique !== (field.unique ? 1 : 0) || + row.default_value !== + (field.defaultValue !== undefined ? JSON.stringify(field.defaultValue) : null) || + row.validation !== (field.validation ? JSON.stringify(field.validation) : null) || + row.widget !== (field.widget ?? null) || + row.options !== (field.options ? JSON.stringify(field.options) : null) || + row.sort_order !== sortOrder || + row.searchable !== (field.searchable ? 1 : 0) || + row.translatable !== (field.translatable === false ? 0 : 1) + ) { + throw new SchemaError("Interrupted seed collection fields do not match", "CREATE_FAILED"); + } + } + } + /** * Update a collection */ @@ -627,6 +818,10 @@ export class SchemaRegistry { .executeTakeFirst(); const sortOrder = input.sortOrder ?? (maxSort?.max ?? -1) + 1; + const activeCoverageInvalidated = await invalidateContentMediaUsageSchemaChange( + this.db, + collectionSlug, + ); let schemaMutated = false; try { @@ -688,20 +883,28 @@ export class SchemaRegistry { return field; }); - await markContentMediaUsageCollectionStaleSafely( - this.db, - collectionSlug, - "CONTENT_USAGE_STALE", - ); - return created; - } catch (error) { - if (schemaMutated) { + if (activeCoverageInvalidated) { + await invalidateContentMediaUsageSchemaChange(this.db, collectionSlug); + } else { await markContentMediaUsageCollectionStaleSafely( this.db, collectionSlug, "CONTENT_USAGE_STALE", ); } + return created; + } catch (error) { + if (schemaMutated) { + if (activeCoverageInvalidated) { + await invalidateContentMediaUsageSchemaChange(this.db, collectionSlug); + } else { + await markContentMediaUsageCollectionStaleSafely( + this.db, + collectionSlug, + "CONTENT_USAGE_STALE", + ); + } + } throw error; } } @@ -748,6 +951,10 @@ export class SchemaRegistry { nextType = input.type; nextColumnType = newColumnType; } + const activeCoverageInvalidated = await invalidateContentMediaUsageSchemaChange( + this.db, + collectionSlug, + ); let schemaMutated = false; try { @@ -819,20 +1026,28 @@ export class SchemaRegistry { return updated; }); - await markContentMediaUsageCollectionStaleSafely( - this.db, - collectionSlug, - "CONTENT_USAGE_STALE", - ); - return updatedField; - } catch (error) { - if (schemaMutated) { + if (activeCoverageInvalidated) { + await invalidateContentMediaUsageSchemaChange(this.db, collectionSlug); + } else { await markContentMediaUsageCollectionStaleSafely( this.db, collectionSlug, "CONTENT_USAGE_STALE", ); } + return updatedField; + } catch (error) { + if (schemaMutated) { + if (activeCoverageInvalidated) { + await invalidateContentMediaUsageSchemaChange(this.db, collectionSlug); + } else { + await markContentMediaUsageCollectionStaleSafely( + this.db, + collectionSlug, + "CONTENT_USAGE_STALE", + ); + } + } throw error; } } @@ -891,6 +1106,10 @@ export class SchemaRegistry { "FIELD_NOT_FOUND", ); } + const activeCoverageInvalidated = await invalidateContentMediaUsageSchemaChange( + this.db, + collectionSlug, + ); let schemaMutated = false; try { @@ -911,19 +1130,27 @@ export class SchemaRegistry { // Drop column from content table — safe now because FTS triggers are gone await this.dropColumn(collectionSlug, fieldSlug, trx); }); - await markContentMediaUsageCollectionStaleSafely( - this.db, - collectionSlug, - "CONTENT_USAGE_STALE", - ); - } catch (error) { - if (schemaMutated) { + if (activeCoverageInvalidated) { + await invalidateContentMediaUsageSchemaChange(this.db, collectionSlug); + } else { await markContentMediaUsageCollectionStaleSafely( this.db, collectionSlug, "CONTENT_USAGE_STALE", ); } + } catch (error) { + if (schemaMutated) { + if (activeCoverageInvalidated) { + await invalidateContentMediaUsageSchemaChange(this.db, collectionSlug); + } else { + await markContentMediaUsageCollectionStaleSafely( + this.db, + collectionSlug, + "CONTENT_USAGE_STALE", + ); + } + } throw error; } } @@ -1013,6 +1240,7 @@ export class SchemaRegistry { slug: string, db?: Kysely, fields: readonly CreateFieldInput[] = [], + options: { ifNotExists?: boolean } = {}, ): Promise { const conn = db ?? this.db; const tableName = this.getTableName(slug); @@ -1034,6 +1262,7 @@ export class SchemaRegistry { .addColumn("draft_revision_id", "text", (col) => col.references("revisions.id")) .addColumn("locale", "text", (col) => col.notNull().defaultTo("en")) .addColumn("translation_group", "text"); + if (options.ifNotExists) table = table.ifNotExists(); for (const field of fields) { const columnName = this.getColumnName(field.slug); @@ -1053,40 +1282,42 @@ export class SchemaRegistry { .addUniqueConstraint(`${tableName}_slug_locale_unique`, ["slug", "locale"]) .execute(); + const createIndex = options.ifNotExists ? sql`CREATE INDEX IF NOT EXISTS` : sql`CREATE INDEX`; + // Create standard indexes await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_slug`)} + ${createIndex} ${sql.ref(`idx_${tableName}_slug`)} ON ${sql.ref(tableName)} (slug) `.execute(conn); await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_scheduled`)} + ${createIndex} ${sql.ref(`idx_${tableName}_scheduled`)} ON ${sql.ref(tableName)} (scheduled_at) WHERE scheduled_at IS NOT NULL `.execute(conn); await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_live_revision`)} + ${createIndex} ${sql.ref(`idx_${tableName}_live_revision`)} ON ${sql.ref(tableName)} (live_revision_id) `.execute(conn); await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_draft_revision`)} + ${createIndex} ${sql.ref(`idx_${tableName}_draft_revision`)} ON ${sql.ref(tableName)} (draft_revision_id) `.execute(conn); await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_author`)} + ${createIndex} ${sql.ref(`idx_${tableName}_author`)} ON ${sql.ref(tableName)} (author_id) `.execute(conn); await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_primary_byline`)} + ${createIndex} ${sql.ref(`idx_${tableName}_primary_byline`)} ON ${sql.ref(tableName)} (primary_byline_id) `.execute(conn); await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_locale`)} + ${createIndex} ${sql.ref(`idx_${tableName}_locale`)} ON ${sql.ref(tableName)} (locale) `.execute(conn); @@ -1095,33 +1326,33 @@ export class SchemaRegistry { // (menu and reference resolution) need the first; reads that do need the // second. await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_tg_locale`)} + ${createIndex} ${sql.ref(`idx_${tableName}_tg_locale`)} ON ${sql.ref(tableName)} (translation_group, locale) `.execute(conn); await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_del_tg_locale`)} + ${createIndex} ${sql.ref(`idx_${tableName}_del_tg_locale`)} ON ${sql.ref(tableName)} (deleted_at, translation_group, locale) `.execute(conn); // Composite indexes for optimized query performance (see migration 033) await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_updated_id`)} + ${createIndex} ${sql.ref(`idx_${tableName}_deleted_updated_id`)} ON ${sql.ref(tableName)} (deleted_at, updated_at DESC, id DESC) `.execute(conn); await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_status`)} + ${createIndex} ${sql.ref(`idx_${tableName}_deleted_status`)} ON ${sql.ref(tableName)} (deleted_at, status) `.execute(conn); await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_created_id`)} + ${createIndex} ${sql.ref(`idx_${tableName}_deleted_created_id`)} ON ${sql.ref(tableName)} (deleted_at, created_at DESC, id DESC) `.execute(conn); await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_published_id`)} + ${createIndex} ${sql.ref(`idx_${tableName}_deleted_published_id`)} ON ${sql.ref(tableName)} (deleted_at, published_at DESC, id DESC) `.execute(conn); @@ -1130,12 +1361,12 @@ export class SchemaRegistry { // inside Postgres's 63-byte identifier limit for long slugs; keep these // names identical to migration 041. await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_loc_upd`)} + ${createIndex} ${sql.ref(`idx_${tableName}_loc_upd`)} ON ${sql.ref(tableName)} (deleted_at, locale, updated_at DESC, id DESC) `.execute(conn); await sql` - CREATE INDEX ${sql.ref(`idx_${tableName}_loc_crt`)} + ${createIndex} ${sql.ref(`idx_${tableName}_loc_crt`)} ON ${sql.ref(tableName)} (deleted_at, locale, created_at DESC, id DESC) `.execute(conn); } @@ -1464,32 +1695,61 @@ export class SchemaRegistry { // Check if already registered const existing = await this.getCollection(slug); - if (existing) { + if ( + existing && + !(await canResumeMediaUsageCollectionCapture(this.db, { + collectionId: existing.id, + collectionSlug: existing.slug, + })) + ) { throw new SchemaError(`Collection "${slug}" is already registered`, "COLLECTION_EXISTS"); } // Create collection entry - const id = ulid(); + const proposedId = existing?.id ?? ulid(); const label = options?.label || this.slugToLabel(slug); let collectionRegistered = false; try { - await this.db - .insertInto("_emdash_collections") - .values({ - id, - slug, - label, - label_singular: options?.labelSingular ?? null, - description: options?.description ?? null, - icon: null, - supports: JSON.stringify([]), - source: "discovered", - has_seo: 0, - url_pattern: null, - }) - .execute(); - collectionRegistered = true; + const capture = await prepareMediaUsageCollectionCapture(this.db, { + collectionId: proposedId, + collectionSlug: slug, + registeredCollectionId: existing?.id, + }); + if (capture.captureRequired) { + await installPreparedMediaUsageCollectionCapture(this.db, { + collectionId: capture.collectionId, + collectionSlug: slug, + }); + await markMediaUsageCollectionCaptureReady(this.db, { + collectionId: capture.collectionId, + collectionSlug: slug, + }); + } + if (!capture.registrationExists) { + await this.db + .insertInto("_emdash_collections") + .values({ + id: capture.collectionId, + slug, + label, + label_singular: options?.labelSingular ?? null, + description: options?.description ?? null, + icon: null, + supports: JSON.stringify([]), + source: "discovered", + has_seo: 0, + url_pattern: null, + }) + .execute(); + collectionRegistered = true; + } + if (capture.captureRequired) { + await finalizeMediaUsageCollectionCapture(this.db, { + collectionId: capture.collectionId, + collectionSlug: slug, + }); + } const collection = await this.getCollection(slug); if (!collection) { diff --git a/packages/core/tests/integration/astro/admin-plugins-sandboxed.test.ts b/packages/core/tests/integration/astro/admin-plugins-sandboxed.test.ts index 08da877151..acc5e42fbe 100644 --- a/packages/core/tests/integration/astro/admin-plugins-sandboxed.test.ts +++ b/packages/core/tests/integration/astro/admin-plugins-sandboxed.test.ts @@ -15,9 +15,14 @@ import { GET as listPlugins } from "../../../src/astro/routes/api/admin/plugins/ import type { Database } from "../../../src/database/types.js"; import { EmDashRuntime, type SandboxedPluginEntry } from "../../../src/emdash-runtime.js"; import { createHookPipeline } from "../../../src/plugins/hooks.js"; +import type { SandboxedPluginInstance } from "../../../src/plugins/sandbox/types.js"; import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; -function buildRuntime(db: Kysely, entries: SandboxedPluginEntry[]): EmDashRuntime { +function buildRuntime( + db: Kysely, + entries: SandboxedPluginEntry[], + sandboxedPlugins: Map = new Map(), +): EmDashRuntime { const config: EmDashConfig = {}; const pipelineFactoryOptions = { db } as const; const hooks = createHookPipeline([], pipelineFactoryOptions); @@ -39,7 +44,7 @@ function buildRuntime(db: Kysely, entries: SandboxedPluginEntry[]): Em db, storage: null, configuredPlugins: [], - sandboxedPlugins: new Map(), + sandboxedPlugins, sandboxedPluginEntries: entries, hooks, enabledPlugins: new Set(), @@ -136,4 +141,60 @@ describe("admin plugin routes: statically-sandboxed plugins (real runtime)", () body = await listIds(runtime); expect(body.items.find((p) => p.id === "webhook-notifier")?.enabled).toBe(true); }); + + it("rejects plugin lifecycle changes while media usage activation is incomplete", async () => { + await db + .updateTable("_emdash_media_usage_activation") + .set({ state: "activating" }) + .where("task_key", "=", "incremental_capture") + .execute(); + + const response = await enablePlugin(ctx(runtime, { id: "webhook-notifier" })); + + expect(response.status).toBe(503); + expect((await response.json()) as unknown).toEqual({ + success: false, + error: { + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + message: "Media usage activation is in progress", + }, + }); + }); + + it("preserves a sandboxed content-write fence as a retryable route error", async () => { + const routeError = Object.assign(new Error("Media usage activation is in progress"), { + name: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + status: 503, + }); + const plugin: SandboxedPluginInstance = { + id: "content-writer:1.0.0", + invokeHook: async () => undefined, + invokeRoute: async () => { + throw routeError; + }, + terminate: async () => undefined, + }; + const sandboxedRuntime = buildRuntime( + db, + [sandboxedEntry({ id: "content-writer", version: "1.0.0" })], + new Map([[plugin.id, plugin]]), + ); + + const result = await sandboxedRuntime.handlePluginApiRoute( + "content-writer", + "POST", + "/write", + new Request("http://test.local/_emdash/api/plugins/content-writer/write"), + ); + + expect(result).toEqual({ + success: false, + status: 503, + error: { + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + message: "Media usage activation is in progress", + }, + }); + }); }); diff --git a/packages/core/tests/integration/database/media-usage-activation.test.ts b/packages/core/tests/integration/database/media-usage-activation.test.ts new file mode 100644 index 0000000000..b8cbafaa02 --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-activation.test.ts @@ -0,0 +1,728 @@ +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { findMediaUsageActivationWriteFenceError } from "../../../src/api/media-usage-write-fence.js"; +import { + activateMediaUsageCapture, + canResumeMediaUsageCollectionCapture, + MEDIA_USAGE_ACTIVATION_LIMITS, +} from "../../../src/media/usage/activation.js"; +import { installMediaUsageCaptureTriggers } from "../../../src/media/usage/capture-triggers.js"; +import { invalidateContentMediaUsageSchemaChange } from "../../../src/media/usage/content-refresh.js"; +import { + buildSeedCollectionCaptureFingerprint, + SchemaRegistry, +} from "../../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("media usage production activation", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("requires explicit writer-drain confirmation before reading or changing activation state", async () => { + const before = await activationRow(); + + await expect( + activateMediaUsageCapture(ctx.db, { + // Exercise the JavaScript boundary rather than the compile-time literal. + writersDrained: false as true, + }), + ).rejects.toThrow(/writers.*drained/i); + + expect(await activationRow()).toEqual(before); + }); + + it("treats a missing activation table as inactive without aborting the transaction", async () => { + await ctx.db.schema.dropTable("_emdash_media_usage_activation").execute(); + + const result = await ctx.db.transaction().execute(async (trx) => { + const resumable = await canResumeMediaUsageCollectionCapture(trx, { + collectionId: "missing-collection", + collectionSlug: "missing_collection", + }); + const writeFence = await findMediaUsageActivationWriteFenceError(trx); + const schemaInvalidated = await invalidateContentMediaUsageSchemaChange( + trx, + "missing_collection", + ); + const probe = await sql<{ value: number | string }>`SELECT 1 AS value`.execute(trx); + return { + resumable, + schemaInvalidated, + transactionValue: Number(probe.rows[0]?.value), + writeFence, + }; + }); + + expect(result).toEqual({ + resumable: false, + schemaInvalidated: false, + transactionValue: 1, + writeFence: null, + }); + }); + + it("activates an empty installation explicitly and is then an idempotent no-op", async () => { + const first = await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + expect(first).toEqual({ outcome: "active", processedCollections: 0 }); + + const activated = await activationRow(); + expect(activated).toEqual( + expect.objectContaining({ + state: "active", + lease_token: null, + lease_expires_at: null, + last_error_code: null, + activated_at: expect.any(String), + drain_confirmed_at: expect.any(String), + }), + ); + + const second = await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + expect(second).toEqual({ outcome: "active", processedCollections: 0 }); + expect(await activationRow()).toEqual(activated); + }); + + it("activates durable capture for collections created after global activation", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + const registry = new SchemaRegistry(ctx.db); + + const collection = await registry.createCollection({ slug: "posts", label: "Posts" }); + + expect(await statusRow(collection.id)).toEqual( + expect.objectContaining({ + collection_id: collection.id, + capture_state: "active", + reconciliation_required: 1, + }), + ); + await sql`INSERT INTO ${sql.ref("ec_posts")} (id, slug) VALUES ('post-1', 'post-1')`.execute( + ctx.db, + ); + expect(await workRows()).toEqual([ + expect.objectContaining({ collection_id: collection.id, content_id: "post-1" }), + ]); + }); + + it("activates durable capture for seed collections created after global activation", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + const registry = new SchemaRegistry(ctx.db); + + await registry.createSeedCollection({ slug: "posts", label: "Posts" }, []); + const collection = await registry.getCollection("posts"); + if (!collection) throw new Error("Expected seed collection"); + + expect(await statusRow(collection.id)).toEqual( + expect.objectContaining({ + collection_id: collection.id, + capture_state: "active", + reconciliation_required: 1, + }), + ); + await sql`INSERT INTO ${sql.ref("ec_posts")} (id, slug) VALUES ('post-1', 'post-1')`.execute( + ctx.db, + ); + expect(await workRows()).toEqual([ + expect.objectContaining({ collection_id: collection.id, content_id: "post-1" }), + ]); + }); + + it("rejects seed writes until field metadata and capture publication are complete", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + const registry = new SchemaRegistry(ctx.db); + const input = { slug: "posts", label: "Posts" }; + const fields = [{ slug: "hero", label: "Hero", type: "image" as const }]; + await registry.createSeedCollection(input, fields); + const collection = await registry.getCollection("posts"); + if (!collection) throw new Error("Expected seed collection"); + + await ctx.db.deleteFrom("_emdash_fields").where("collection_id", "=", collection.id).execute(); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ + capture_state: "ready", + cursor: await buildSeedCollectionCaptureFingerprint(input, fields), + }) + .where("collection_id", "=", collection.id) + .execute(); + + await expect( + sql` + INSERT INTO ${sql.ref("ec_posts")} (id, slug, hero) + VALUES ( + 'post-during-publication', + 'post-during-publication', + ${JSON.stringify({ id: "media-hero", provider: "local" })} + ) + `.execute(ctx.db), + ).rejects.toThrow(/media usage capture inactive/i); + expect(await workRows()).toEqual([]); + + await registry.createSeedCollection(input, fields); + await sql` + INSERT INTO ${sql.ref("ec_posts")} (id, slug, hero) + VALUES ( + 'post-after-publication', + 'post-after-publication', + ${JSON.stringify({ id: "media-hero", provider: "local" })} + ) + `.execute(ctx.db); + expect(await workRows()).toEqual([ + expect.objectContaining({ + collection_id: collection.id, + content_id: "post-after-publication", + }), + ]); + }); + + it("activates durable capture when registering an orphan after global activation", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + await sql`CREATE TABLE ${sql.ref("ec_orphan_posts")} (id text primary key)`.execute(ctx.db); + const registry = new SchemaRegistry(ctx.db); + + const collection = await registry.registerOrphanedTable("orphan_posts"); + + expect(await statusRow(collection.id)).toEqual( + expect.objectContaining({ + collection_id: collection.id, + capture_state: "active", + reconciliation_required: 1, + }), + ); + await sql`INSERT INTO ${sql.ref("ec_orphan_posts")} (id) VALUES ('post-1')`.execute(ctx.db); + expect(await workRows()).toEqual([ + expect.objectContaining({ collection_id: collection.id, content_id: "post-1" }), + ]); + }); + + it("rejects orphan writes until collection publication is active and resumes registration", async () => { + if (dialect !== "sqlite") return; + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + await sql`CREATE TABLE ${sql.ref("ec_orphan_posts")} (id text primary key)`.execute(ctx.db); + await sql` + CREATE TRIGGER write_after_collection_publication + AFTER INSERT ON _emdash_collections + WHEN NEW.slug = 'orphan_posts' + BEGIN + INSERT INTO ec_orphan_posts (id) VALUES ('post-during-publication'); + END + `.execute(ctx.db); + const registry = new SchemaRegistry(ctx.db); + + await expect(registry.registerOrphanedTable("orphan_posts")).rejects.toThrow( + /media usage capture inactive/i, + ); + expect(await registry.getCollection("orphan_posts")).toBeNull(); + expect(await workRows()).toEqual([]); + const orphanContent = await sql<{ id: string }>` + SELECT id FROM ${sql.ref("ec_orphan_posts")} + `.execute(ctx.db); + expect(orphanContent.rows).toEqual([]); + expect( + await ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select("capture_state") + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", "orphan_posts") + .executeTakeFirst(), + ).toEqual({ capture_state: "ready" }); + + await sql`DROP TRIGGER write_after_collection_publication`.execute(ctx.db); + const collection = await registry.registerOrphanedTable("orphan_posts"); + + expect(await statusRow(collection.id)).toEqual( + expect.objectContaining({ capture_state: "active" }), + ); + await sql`INSERT INTO ${sql.ref("ec_orphan_posts")} (id) VALUES ('post-after-publication')`.execute( + ctx.db, + ); + expect(await workRows()).toEqual([ + expect.objectContaining({ + collection_id: collection.id, + content_id: "post-after-publication", + }), + ]); + }); + + it("resumes collection capture after the content table commits before registration", async () => { + const registry = new SchemaRegistry(ctx.db); + const interrupted = await registry.createCollection({ slug: "posts", label: "Posts" }); + await ctx.db.deleteFrom("_emdash_collections").where("id", "=", interrupted.id).execute(); + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + await ctx.db + .insertInto("_emdash_media_usage_index_status") + .values({ + adapter_id: "content-media", + scope_type: "collection", + scope_key: "posts", + status: "never", + collection_id: interrupted.id, + reconciliation_required: 1, + capture_state: "installing", + }) + .execute(); + + const resumed = await registry.createCollection({ slug: "posts", label: "Posts" }); + + expect(resumed.id).toBe(interrupted.id); + expect(await statusRow(resumed.id)).toEqual( + expect.objectContaining({ capture_state: "active" }), + ); + }); + + it("resumes collection publication after verified capture reaches ready", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + const registry = new SchemaRegistry(ctx.db); + const interrupted = await registry.createCollection({ slug: "posts", label: "Posts" }); + await ctx.db.deleteFrom("_emdash_collections").where("id", "=", interrupted.id).execute(); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "ready" }) + .where("collection_id", "=", interrupted.id) + .execute(); + + const resumed = await registry.createCollection({ slug: "posts", label: "Posts" }); + + expect(resumed.id).toBe(interrupted.id); + expect(await statusRow(resumed.id)).toEqual( + expect.objectContaining({ capture_state: "active" }), + ); + }); + + it("resumes collection capture after registration commits before finalization", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + const registry = new SchemaRegistry(ctx.db); + const interrupted = await registry.createCollection({ slug: "posts", label: "Posts" }); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "installing" }) + .where("collection_id", "=", interrupted.id) + .execute(); + + const resumed = await registry.createCollection({ slug: "posts", label: "Posts" }); + + expect(resumed.id).toBe(interrupted.id); + expect(await statusRow(resumed.id)).toEqual( + expect.objectContaining({ capture_state: "active" }), + ); + }); + + it("resumes seed capture and restores missing field metadata before finalization", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + const registry = new SchemaRegistry(ctx.db); + const input = { slug: "posts", label: "Posts" }; + const fields = [{ slug: "hero", label: "Hero", type: "image" as const }]; + await registry.createSeedCollection(input, fields); + const interrupted = await registry.getCollection("posts"); + if (!interrupted) throw new Error("Expected seed collection"); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ + capture_state: "installing", + cursor: await buildSeedCollectionCaptureFingerprint(input, fields), + }) + .where("collection_id", "=", interrupted.id) + .execute(); + await ctx.db + .deleteFrom("_emdash_fields") + .where("collection_id", "=", interrupted.id) + .where("slug", "=", "hero") + .execute(); + + await registry.createSeedCollection(input, fields); + + expect((await registry.listFields(interrupted.id)).map((field) => field.slug)).toEqual([ + "hero", + ]); + expect(await statusRow(interrupted.id)).toEqual( + expect.objectContaining({ capture_state: "active" }), + ); + }); + + it("rejects a conflicting seed definition while capture installation is incomplete", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + const registry = new SchemaRegistry(ctx.db); + const input = { slug: "posts", label: "Posts" }; + const fields = [{ slug: "hero", label: "Hero", type: "image" as const }]; + await registry.createSeedCollection(input, fields); + const interrupted = await registry.getCollection("posts"); + if (!interrupted) throw new Error("Expected seed collection"); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ + capture_state: "installing", + cursor: await buildSeedCollectionCaptureFingerprint(input, fields), + }) + .where("collection_id", "=", interrupted.id) + .execute(); + await ctx.db.deleteFrom("_emdash_fields").where("collection_id", "=", interrupted.id).execute(); + + await expect( + registry.createSeedCollection({ slug: "posts", label: "Posts" }, [ + { slug: "title", label: "Title", type: "string" }, + ]), + ).rejects.toThrow(); + expect(await registry.listFields(interrupted.id)).toEqual([]); + expect(await statusRow(interrupted.id)).toEqual( + expect.objectContaining({ capture_state: "installing" }), + ); + }); + + it("does not resume a cursorless collection lifecycle as seed creation", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + const registry = new SchemaRegistry(ctx.db); + const interrupted = await registry.createCollection({ slug: "posts", label: "Posts" }); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "installing", cursor: null }) + .where("collection_id", "=", interrupted.id) + .execute(); + + await expect( + registry.createSeedCollection({ slug: "posts", label: "Posts" }, [ + { slug: "hero", label: "Hero", type: "image" }, + ]), + ).rejects.toThrow(); + expect(await registry.listFields(interrupted.id)).toEqual([]); + expect(await statusRow(interrupted.id)).toEqual( + expect.objectContaining({ capture_state: "installing", cursor: null }), + ); + }); + + it("does not resume a fingerprinted seed lifecycle as ordinary collection creation", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + const registry = new SchemaRegistry(ctx.db); + const input = { slug: "posts", label: "Posts" }; + const fields = [{ slug: "hero", label: "Hero", type: "image" as const }]; + await registry.createSeedCollection(input, fields); + const interrupted = await registry.getCollection("posts"); + if (!interrupted) throw new Error("Expected seed collection"); + const fingerprint = await buildSeedCollectionCaptureFingerprint(input, fields); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "installing", cursor: fingerprint }) + .where("collection_id", "=", interrupted.id) + .execute(); + + await expect(registry.createCollection(input)).rejects.toThrow(); + expect(await statusRow(interrupted.id)).toEqual( + expect.objectContaining({ capture_state: "installing", cursor: fingerprint }), + ); + }); + + it("distinguishes an omitted seed default from an explicit null default", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + const registry = new SchemaRegistry(ctx.db); + const input = { slug: "posts", label: "Posts" }; + const originalFields = [ + { slug: "settings", label: "Settings", type: "json" as const, required: true }, + ]; + await registry.createSeedCollection(input, originalFields); + const interrupted = await registry.getCollection("posts"); + if (!interrupted) throw new Error("Expected seed collection"); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ + capture_state: "installing", + cursor: await buildSeedCollectionCaptureFingerprint(input, originalFields), + }) + .where("collection_id", "=", interrupted.id) + .execute(); + await ctx.db.deleteFrom("_emdash_fields").where("collection_id", "=", interrupted.id).execute(); + + await expect( + registry.createSeedCollection(input, [ + { + slug: "settings", + label: "Settings", + type: "json", + required: true, + defaultValue: null, + }, + ]), + ).rejects.toThrow(); + expect(await registry.listFields(interrupted.id)).toEqual([]); + expect(await statusRow(interrupted.id)).toEqual( + expect.objectContaining({ capture_state: "installing" }), + ); + }); + + it("resumes orphan registration after publication commits before finalization", async () => { + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + await sql`CREATE TABLE ${sql.ref("ec_orphan_posts")} (id text primary key)`.execute(ctx.db); + const registry = new SchemaRegistry(ctx.db); + const interrupted = await registry.registerOrphanedTable("orphan_posts"); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "installing" }) + .where("collection_id", "=", interrupted.id) + .execute(); + + const resumed = await registry.registerOrphanedTable("orphan_posts"); + + expect(resumed.id).toBe(interrupted.id); + expect(await statusRow(resumed.id)).toEqual( + expect.objectContaining({ capture_state: "active" }), + ); + }); + + it("activates one bounded collection per call and captures writes only after each exact lifecycle", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "alpha", label: "Alpha" }); + await registry.createCollection({ slug: "beta", label: "Beta" }); + const alpha = await registry.getCollection("alpha"); + const beta = await registry.getCollection("beta"); + if (!alpha || !beta) throw new Error("Expected activation collections"); + + const first = await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + expect(first).toEqual({ + outcome: "activating", + processedCollections: MEDIA_USAGE_ACTIVATION_LIMITS.collectionsPerCall, + collectionCursor: "alpha", + }); + expect(await statusRow(alpha.id)).toEqual( + expect.objectContaining({ + status: "never", + collection_id: alpha.id, + capture_state: "active", + reconciliation_required: 1, + }), + ); + expect(await statusRow(beta.id)).toBeUndefined(); + + await sql`INSERT INTO ${sql.ref("ec_alpha")} (id, slug) VALUES ('alpha-1', 'alpha-1')`.execute( + ctx.db, + ); + expect(await workRows()).toEqual([ + expect.objectContaining({ collection_id: alpha.id, content_id: "alpha-1" }), + ]); + + const second = await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + expect(second).toEqual({ outcome: "active", processedCollections: 1 }); + expect(await statusRow(beta.id)).toEqual( + expect.objectContaining({ + collection_id: beta.id, + capture_state: "active", + reconciliation_required: 1, + }), + ); + }); + + it("conservatively invalidates trusted coverage before activating capture", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts" }); + const collection = await registry.getCollection("posts"); + if (!collection) throw new Error("Expected posts collection"); + await ctx.db + .insertInto("_emdash_media_usage_index_status") + .values({ + adapter_id: "content-media", + scope_type: "collection", + scope_key: "posts", + status: "complete", + completed_at: "2026-08-01T00:00:00.000Z", + cursor: "old-repair", + }) + .execute(); + + await activateMediaUsageCapture(ctx.db, { writersDrained: true }); + + expect(await statusRow(collection.id)).toEqual( + expect.objectContaining({ + status: "stale", + completed_at: null, + cursor: null, + collection_id: collection.id, + capture_state: "active", + reconciliation_required: 1, + }), + ); + }); + + it("does not steal a live activation lease and takes over an expired lease", async () => { + await ctx.db + .updateTable("_emdash_media_usage_activation") + .set({ + state: "activating", + lease_token: "current-owner", + lease_expires_at: "2100-01-01T00:00:00.000Z", + drain_confirmed_at: "2026-08-01T00:00:00.000Z", + }) + .execute(); + + expect(await activateMediaUsageCapture(ctx.db, { writersDrained: true })).toEqual({ + outcome: "lease_active", + leaseExpiresAt: "2100-01-01T00:00:00.000Z", + }); + expect(await activationRow()).toEqual( + expect.objectContaining({ lease_token: "current-owner", attempt_count: 0 }), + ); + + await ctx.db + .updateTable("_emdash_media_usage_activation") + .set({ lease_expires_at: "2000-01-01T00:00:00.000Z" }) + .execute(); + expect(await activateMediaUsageCapture(ctx.db, { writersDrained: true })).toEqual({ + outcome: "active", + processedCollections: 0, + }); + expect(await activationRow()).toEqual( + expect.objectContaining({ state: "active", attempt_count: 1, lease_token: null }), + ); + }); + + it("fails closed with durable diagnostics when trigger installation cannot finish", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "broken", label: "Broken" }); + const collection = await registry.getCollection("broken"); + if (!collection) throw new Error("Expected broken collection"); + await sql`DROP TABLE ${sql.ref("ec_broken")}`.execute(ctx.db); + + await expect(activateMediaUsageCapture(ctx.db, { writersDrained: true })).rejects.toThrow( + /activation failed/i, + ); + + expect(await activationRow()).toEqual( + expect.objectContaining({ + state: "activating", + lease_token: null, + lease_expires_at: null, + last_error_code: "MEDIA_USAGE_ACTIVATION_FAILED", + activated_at: null, + }), + ); + expect(await statusRow(collection.id)).toEqual( + expect.objectContaining({ capture_state: "installing", reconciliation_required: 1 }), + ); + }); + + it("refuses a runtime generation mismatch without changing activation state", async () => { + await ctx.db + .updateTable("_emdash_media_usage_activation") + .set({ runtime_generation: 2 }) + .execute(); + const before = await activationRow(); + + await expect(activateMediaUsageCapture(ctx.db, { writersDrained: true })).rejects.toThrow( + /runtime generation/i, + ); + expect(await activationRow()).toEqual(before); + }); + + it("cannot finalize after losing its exact lease during collection activation", async () => { + if (dialect !== "sqlite") return; + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "lease_loss", label: "Lease loss" }); + await sql` + CREATE TRIGGER steal_media_usage_activation_lease + AFTER UPDATE OF capture_state ON _emdash_media_usage_index_status + WHEN NEW.capture_state = 'active' + BEGIN + UPDATE _emdash_media_usage_activation + SET lease_token = 'new-owner', + lease_expires_at = '2100-01-01T00:00:00.000Z' + WHERE task_key = 'incremental_capture'; + END + `.execute(ctx.db); + + expect(await activateMediaUsageCapture(ctx.db, { writersDrained: true })).toEqual({ + outcome: "conflict", + processedCollections: 1, + }); + expect(await activationRow()).toEqual( + expect.objectContaining({ + state: "activating", + lease_token: "new-owner", + activated_at: null, + }), + ); + }); + + it("cannot downgrade an active collection after its activation lease is taken over", async () => { + if (dialect !== "sqlite") return; + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "takeover", label: "Takeover" }); + const collection = await registry.getCollection("takeover"); + if (!collection) throw new Error("Expected takeover collection"); + await ctx.db + .insertInto("_emdash_media_usage_index_status") + .values({ + adapter_id: "content-media", + scope_type: "collection", + scope_key: collection.slug, + collection_id: collection.id, + status: "never", + reconciliation_required: 1, + capture_state: "installing", + }) + .execute(); + await installMediaUsageCaptureTriggers(ctx.db, { + collectionId: collection.id, + collectionSlug: collection.slug, + }); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", collection.id) + .execute(); + await sql` + CREATE TRIGGER steal_media_usage_activation_claim + AFTER UPDATE OF lease_token ON _emdash_media_usage_activation + WHEN NEW.lease_token IS NOT NULL AND NEW.lease_token <> 'new-owner' + BEGIN + UPDATE _emdash_media_usage_activation + SET lease_token = 'new-owner', + lease_expires_at = '2100-01-01T00:00:00.000Z' + WHERE task_key = 'incremental_capture'; + END + `.execute(ctx.db); + + expect(await activateMediaUsageCapture(ctx.db, { writersDrained: true })).toEqual({ + outcome: "conflict", + processedCollections: 0, + }); + expect(await statusRow(collection.id)).toEqual( + expect.objectContaining({ capture_state: "active" }), + ); + }); + + async function activationRow() { + return ctx.db + .selectFrom("_emdash_media_usage_activation") + .selectAll() + .where("task_key", "=", "incremental_capture") + .executeTakeFirstOrThrow(); + } + + async function statusRow(collectionId: string) { + return ctx.db + .selectFrom("_emdash_media_usage_index_status") + .selectAll() + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("collection_id", "=", collectionId) + .executeTakeFirst(); + } + + function workRows() { + return ctx.db + .selectFrom("_emdash_media_usage_work") + .select(["collection_id", "content_id"]) + .orderBy("collection_id") + .orderBy("content_id") + .execute(); + } +}); diff --git a/packages/core/tests/integration/database/media-usage-capture-trigger.test.ts b/packages/core/tests/integration/database/media-usage-capture-trigger.test.ts new file mode 100644 index 0000000000..e27f537952 --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-capture-trigger.test.ts @@ -0,0 +1,426 @@ +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + installMediaUsageCaptureTriggers, + removeMediaUsageCaptureTriggers, +} from "../../../src/media/usage/capture-triggers.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +const ADAPTER_ID = "content-media"; + +describeEachDialect("media usage capture triggers", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("coalesces insert, update, and delete into one newest pending job", async () => { + const fixture = await createActiveFixture(ctx, "posts"); + + await insertEntry(ctx, fixture.tableName, "entry-1", "first"); + await ctx.db + .updateTable("_emdash_media_usage_work") + .set({ + state: "leased", + attempt_count: 4, + next_attempt_at: "2099-01-01T00:00:00.000Z", + lease_token: "old-owner", + lease_expires_at: "2099-01-01T00:01:00.000Z", + last_attempted_at: "2026-08-01T00:00:00.000Z", + last_error_code: "OLD_ERROR", + }) + .execute(); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ status: "complete", completed_at: "2026-08-01T00:00:00.000Z" }) + .where("collection_id", "=", fixture.collectionId) + .execute(); + + await sql`UPDATE ${sql.ref(fixture.tableName)} SET slug = 'second' WHERE id = 'entry-1'`.execute( + ctx.db, + ); + await sql`DELETE FROM ${sql.ref(fixture.tableName)} WHERE id = 'entry-1'`.execute(ctx.db); + + const status = await ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select(["change_epoch", "status", "completed_at"]) + .where("collection_id", "=", fixture.collectionId) + .executeTakeFirstOrThrow(); + expect({ ...status, change_epoch: Number(status.change_epoch) }).toEqual({ + change_epoch: 3, + status: "stale", + completed_at: null, + }); + + const jobs = await ctx.db + .selectFrom("_emdash_media_usage_work") + .select([ + "collection_id", + "content_id", + "change_epoch", + "work_version", + "state", + "attempt_count", + "lease_token", + "lease_expires_at", + "last_attempted_at", + "last_error_code", + "next_attempt_at", + "updated_at", + ]) + .execute(); + expect( + jobs.map((job) => ({ + ...job, + change_epoch: Number(job.change_epoch), + work_version: Number(job.work_version), + })), + ).toEqual([ + { + collection_id: fixture.collectionId, + content_id: "entry-1", + change_epoch: 3, + work_version: 3, + state: "pending", + attempt_count: 0, + lease_token: null, + lease_expires_at: null, + last_attempted_at: null, + last_error_code: null, + next_attempt_at: jobs[0]?.updated_at, + updated_at: jobs[0]?.updated_at, + }, + ]); + expect(jobs[0]?.next_attempt_at).not.toBe("2099-01-01T00:00:00.000Z"); + }); + + it("rejects writes unless the exact registry and lifecycle identity is active", async () => { + const fixture = await createActiveFixture(ctx, "articles"); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "installing" }) + .where("collection_id", "=", fixture.collectionId) + .execute(); + + await expect(insertEntry(ctx, fixture.tableName, "entry-1", "entry-1")).rejects.toThrow(); + expect(await countEntries(ctx, fixture.tableName)).toBe(0); + expect(await countWork(ctx)).toBe(0); + + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", fixture.collectionId) + .execute(); + await ctx.db + .updateTable("_emdash_collections") + .set({ id: "replacement-collection-id" }) + .where("id", "=", fixture.collectionId) + .execute(); + + await expect(insertEntry(ctx, fixture.tableName, "entry-2", "entry-2")).rejects.toThrow(); + expect(await countEntries(ctx, fixture.tableName)).toBe(0); + expect(await countWork(ctx)).toBe(0); + }); + + it("rejects a status identity mismatch while the registry identity remains current", async () => { + const fixture = await createActiveFixture(ctx, "status_mismatch"); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ collection_id: "wrong-status-identity" }) + .where("collection_id", "=", fixture.collectionId) + .execute(); + + await expect(insertEntry(ctx, fixture.tableName, "entry-1", "entry-1")).rejects.toThrow(); + expect(await countEntries(ctx, fixture.tableName)).toBe(0); + expect(await countWork(ctx)).toBe(0); + }); + + it("is behaviorally idempotent when installed repeatedly", async () => { + const fixture = await createActiveFixture(ctx, "notes"); + await installMediaUsageCaptureTriggers(ctx.db, { + collectionId: fixture.collectionId, + collectionSlug: fixture.collectionSlug, + }); + + await insertEntry(ctx, fixture.tableName, "entry-1", "entry-1"); + + const status = await ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select("change_epoch") + .where("collection_id", "=", fixture.collectionId) + .executeTakeFirstOrThrow(); + const work = await ctx.db + .selectFrom("_emdash_media_usage_work") + .select("work_version") + .executeTakeFirstOrThrow(); + expect(Number(status.change_epoch)).toBe(1); + expect(Number(work.work_version)).toBe(1); + }); + + it("replaces stale triggers from an interrupted earlier collection identity", async () => { + const fixture = await createActiveFixture(ctx, "recreated_posts"); + const replacementId = "replacement-collection-id"; + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ collection_id: replacementId, capture_state: "installing" }) + .where("collection_id", "=", fixture.collectionId) + .execute(); + await ctx.db + .updateTable("_emdash_collections") + .set({ id: replacementId }) + .where("id", "=", fixture.collectionId) + .execute(); + + await installMediaUsageCaptureTriggers(ctx.db, { + collectionId: replacementId, + collectionSlug: fixture.collectionSlug, + }); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", replacementId) + .execute(); + await insertEntry(ctx, fixture.tableName, "entry-1", "entry-1"); + + const work = await ctx.db + .selectFrom("_emdash_media_usage_work") + .select(["collection_id", "content_id"]) + .execute(); + expect(work).toEqual([{ collection_id: replacementId, content_id: "entry-1" }]); + }); + + it("repairs a same-named disabled or incorrect trigger while fenced", async () => { + const fixture = await createActiveFixture(ctx, "corrupt_trigger"); + await replaceInsertCaptureWithNoOp(ctx, fixture.tableName); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "installing" }) + .where("collection_id", "=", fixture.collectionId) + .execute(); + + await installMediaUsageCaptureTriggers(ctx.db, { + collectionId: fixture.collectionId, + collectionSlug: fixture.collectionSlug, + }); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", fixture.collectionId) + .execute(); + await insertEntry(ctx, fixture.tableName, "entry-1", "entry-1"); + expect(await countWork(ctx)).toBe(1); + }); + + it("rolls back every row, job, and epoch when one row cannot be captured", async () => { + const fixture = await createActiveFixture(ctx, "bulk_posts"); + await installRejectingWorkTrigger(ctx, "entry-2"); + + await expect( + sql` + INSERT INTO ${sql.ref(fixture.tableName)} (id, slug) + VALUES ('entry-1', 'entry-1'), ('entry-2', 'entry-2') + `.execute(ctx.db), + ).rejects.toThrow(); + + expect(await countEntries(ctx, fixture.tableName)).toBe(0); + expect(await countWork(ctx)).toBe(0); + const status = await ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select(["change_epoch", "status", "completed_at"]) + .where("collection_id", "=", fixture.collectionId) + .executeTakeFirstOrThrow(); + expect({ ...status, change_epoch: Number(status.change_epoch) }).toEqual({ + change_epoch: 0, + status: "complete", + completed_at: "2026-08-01T00:00:00.000Z", + }); + }); + + it("removes the capture boundary explicitly", async () => { + const fixture = await createActiveFixture(ctx, "scratch"); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "deleting" }) + .where("collection_id", "=", fixture.collectionId) + .execute(); + await removeMediaUsageCaptureTriggers(ctx.db, { + collectionId: fixture.collectionId, + collectionSlug: fixture.collectionSlug, + }); + + await insertEntry(ctx, fixture.tableName, "entry-1", "entry-1"); + expect(await countEntries(ctx, fixture.tableName)).toBe(1); + expect(await countWork(ctx)).toBe(0); + }); + + it("refuses rollback after capture is active and leaves writes protected", async () => { + const fixture = await createActiveFixture(ctx, "protected_posts"); + const migration = + await import("../../../src/database/migrations/063_media_usage_incremental_work.js"); + + await expect(migration.down(ctx.db)).rejects.toThrow(/cannot roll back media usage capture/i); + await insertEntry(ctx, fixture.tableName, "entry-1", "entry-1"); + expect(await countEntries(ctx, fixture.tableName)).toBe(1); + expect(await countWork(ctx)).toBe(1); + }); +}); + +async function createActiveFixture(ctx: DialectTestContext, collectionSlug: string) { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: collectionSlug, label: collectionSlug }); + const collection = await registry.getCollection(collectionSlug); + if (!collection) throw new Error(`Expected ${collectionSlug} collection`); + + await ctx.db + .insertInto("_emdash_media_usage_index_status") + .values({ + adapter_id: ADAPTER_ID, + scope_type: "collection", + scope_key: collectionSlug, + collection_id: collection.id, + status: "complete", + completed_at: "2026-08-01T00:00:00.000Z", + reconciliation_required: 0, + capture_state: "installing", + }) + .execute(); + await installMediaUsageCaptureTriggers(ctx.db, { + collectionId: collection.id, + collectionSlug, + }); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", collection.id) + .execute(); + + return { + collectionId: collection.id, + collectionSlug, + tableName: `ec_${collectionSlug}`, + }; +} + +async function insertEntry( + ctx: DialectTestContext, + tableName: string, + id: string, + slug: string, +): Promise { + await sql`INSERT INTO ${sql.ref(tableName)} (id, slug) VALUES (${id}, ${slug})`.execute(ctx.db); +} + +async function countEntries(ctx: DialectTestContext, tableName: string): Promise { + const result = await sql<{ count: number }>` + SELECT COUNT(*) AS count FROM ${sql.ref(tableName)} + `.execute(ctx.db); + return Number(result.rows[0]?.count ?? 0); +} + +async function countWork(ctx: DialectTestContext): Promise { + const result = await ctx.db + .selectFrom("_emdash_media_usage_work") + .select((eb) => eb.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + return Number(result.count); +} + +async function installRejectingWorkTrigger( + ctx: DialectTestContext, + rejectedContentId: string, +): Promise { + if (ctx.dialect === "postgres") { + await sql` + CREATE OR REPLACE FUNCTION emdash_test_reject_media_usage_work() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF NEW.content_id = ${sql.lit(rejectedContentId)} THEN + RAISE EXCEPTION 'forced work failure'; + END IF; + RETURN NEW; + END; + $$ + `.execute(ctx.db); + await sql` + CREATE TRIGGER emdash_test_reject_media_usage_work + BEFORE INSERT OR UPDATE ON _emdash_media_usage_work + FOR EACH ROW EXECUTE FUNCTION emdash_test_reject_media_usage_work() + `.execute(ctx.db); + return; + } + + await sql` + CREATE TRIGGER emdash_test_reject_media_usage_work + BEFORE INSERT ON _emdash_media_usage_work + FOR EACH ROW + WHEN NEW.content_id = ${sql.lit(rejectedContentId)} + BEGIN + SELECT RAISE(ABORT, 'forced work failure'); + END + `.execute(ctx.db); +} + +async function replaceInsertCaptureWithNoOp( + ctx: DialectTestContext, + tableName: string, +): Promise { + const triggerName = await findInsertCaptureTrigger(ctx, tableName); + if (ctx.dialect === "postgres") { + await sql` + ALTER TABLE ${sql.ref(tableName)} DISABLE TRIGGER ${sql.ref(triggerName)} + `.execute(ctx.db); + return; + } + + await sql`DROP TRIGGER ${sql.ref(triggerName)}`.execute(ctx.db); + await sql` + CREATE TRIGGER ${sql.ref(triggerName)} + AFTER INSERT ON ${sql.ref(tableName)} + FOR EACH ROW BEGIN SELECT 1; END + `.execute(ctx.db); +} + +async function findInsertCaptureTrigger( + ctx: DialectTestContext, + tableName: string, +): Promise { + if (ctx.dialect === "postgres") { + const result = await sql<{ name: string }>` + SELECT trigger.tgname AS name + FROM pg_trigger AS trigger + INNER JOIN pg_class AS relation ON relation.oid = trigger.tgrelid + INNER JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = current_schema() + AND relation.relname = ${tableName} + AND trigger.tgtype = 5 + AND NOT trigger.tgisinternal + `.execute(ctx.db); + const name = result.rows[0]?.name; + if (!name) throw new Error("Expected PostgreSQL insert capture trigger"); + return name; + } + + const result = await sql<{ name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'trigger' + AND tbl_name = ${tableName} + AND sql LIKE '%AFTER INSERT%' + `.execute(ctx.db); + const name = result.rows[0]?.name; + if (!name) throw new Error("Expected SQLite insert capture trigger"); + return name; +} diff --git a/packages/core/tests/integration/database/media-usage-content-refresh.test.ts b/packages/core/tests/integration/database/media-usage-content-refresh.test.ts index 0c59dc58d2..d2e03d6f4f 100644 --- a/packages/core/tests/integration/database/media-usage-content-refresh.test.ts +++ b/packages/core/tests/integration/database/media-usage-content-refresh.test.ts @@ -66,7 +66,7 @@ describeEachDialect("content media usage refresh", (dialect) => { sourceKey: columnsKey, sourceCompleteness: "complete", contentTitle: "Hello World", - sourceFingerprint: expect.stringMatching(/^[a-f0-9]{16}$/), + sourceFingerprint: expect.stringMatching(/^media-usage-projection:v1:sha256:[a-f0-9]{64}$/), }), ); expect(await usageRepo.findCurrentUsageByMediaId("media-old")).toEqual([ @@ -113,6 +113,34 @@ describeEachDialect("content media usage refresh", (dialect) => { ]); }); + it("does not create another generation when the full projection is unchanged", async () => { + const item = await insertPost(ctx, { + slug: "unchanged-post", + status: "published", + data: { + title: "Unchanged Post", + hero: { id: "media-stable", provider: "local", mimeType: "image/webp" }, + }, + }); + const columnsKey = sourceKey(item.id, "columns"); + await refreshContentMediaUsage(ctx.db, "posts", item.id); + const before = await usageRepo.findSource(columnsKey); + const occurrenceCountBefore = await countOccurrences(ctx, columnsKey); + + const repeated = await refreshContentMediaUsage(ctx.db, "posts", item.id); + + expect(repeated).toEqual({ + success: true, + refreshedSourceCount: 1, + deletedSourceCount: 0, + failedSourceCount: 0, + }); + expect((await usageRepo.findSource(columnsKey))?.currentGeneration).toBe( + before?.currentGeneration, + ); + expect(await countOccurrences(ctx, columnsKey)).toBe(occurrenceCountBefore); + }); + it("refreshes columns and draft overlay sources when a draft exists", async () => { const item = await insertPost(ctx, { slug: "live-post", @@ -274,7 +302,7 @@ describeEachDialect("content media usage refresh", (dialect) => { ).not.toEqual(expect.objectContaining({ lastErrorCode: "CONTENT_USAGE_GENERATION_CONFLICT" })); }); - it("does not delete a draft overlay source that changed after observation", async () => { + it("retries a draft overlay delete after a concurrent source change", async () => { const item = await insertPost(ctx, { slug: "guarded-delete-post", status: "published", @@ -290,37 +318,29 @@ describeEachDialect("content media usage refresh", (dialect) => { }); await setDraftRevision(ctx, item.id, draft.id); await refreshContentMediaUsage(ctx.db, "posts", item.id); + const observedColumnsGeneration = (await usageRepo.findSource(sourceKey(item.id, "columns"))) + ?.currentGeneration; await clearDraftRevision(ctx, item.id); + await updatePostHero(ctx, item.id, { + id: "media-live-changed", + provider: "local", + mimeType: "image/webp", + }); await installDraftOverlayDeletionConflictTrigger(ctx); const result = await refreshContentMediaUsage(ctx.db, "posts", item.id); - + expect((await usageRepo.findSource(sourceKey(item.id, "columns")))?.currentGeneration).not.toBe( + observedColumnsGeneration, + ); expect(result).toEqual({ - success: false, + success: true, refreshedSourceCount: 1, - deletedSourceCount: 0, + deletedSourceCount: 1, failedSourceCount: 0, - errorCode: "CONTENT_USAGE_GENERATION_CONFLICT", }); - expect(await usageRepo.findSource(sourceKey(item.id, "draft_overlay"))).toEqual( - expect.objectContaining({ - currentGeneration: expect.stringMatching(/^concurrent-draft-generation-/), - }), - ); - expect(await usageRepo.findCurrentUsageByMediaId("media-concurrent-draft-generation")).toEqual([ - expect.objectContaining({ source: expect.objectContaining({ contentId: item.id }) }), - ]); - expect( - await usageRepo.findIndexStatus({ - adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, - scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, - scopeKey: "posts", - }), - ).toEqual( - expect.objectContaining({ - status: "stale", - lastErrorCode: "CONTENT_USAGE_GENERATION_CONFLICT", - }), + expect(await usageRepo.findSource(sourceKey(item.id, "draft_overlay"))).toBeNull(); + expect(await usageRepo.findCurrentUsageByMediaId("media-concurrent-draft-generation")).toEqual( + [], ); }); @@ -355,6 +375,31 @@ describeEachDialect("content media usage refresh", (dialect) => { expect(await usageRepo.findCurrentUsageByMediaId("media-draft")).toEqual([]); }); + it("removes the old projection when its collection registry row disappears", async () => { + const item = await insertPost(ctx, { + slug: "deleted-collection-post", + status: "published", + data: { + title: "Deleted Collection Post", + hero: { id: "media-old-collection", provider: "local", mimeType: "image/webp" }, + }, + }); + await refreshContentMediaUsage(ctx.db, "posts", item.id); + await ctx.db.deleteFrom("_emdash_fields").where("collection_id", "is not", null).execute(); + await ctx.db.deleteFrom("_emdash_collections").where("slug", "=", "posts").execute(); + + const result = await refreshContentMediaUsage(ctx.db, "posts", item.id); + + expect(result).toEqual({ + success: true, + refreshedSourceCount: 0, + deletedSourceCount: 1, + failedSourceCount: 0, + }); + expect(await usageRepo.findSource(sourceKey(item.id, "columns"))).toBeNull(); + expect(await usageRepo.findCurrentUsageByMediaId("media-old-collection")).toEqual([]); + }); + it("marks draft snapshot failures without replacing current usage", async () => { const item = await insertPost(ctx, { slug: "live-post", @@ -497,6 +542,15 @@ async function insertPost(ctx: DialectTestContext, input: TestPostInput): Promis }; } +async function countOccurrences(ctx: DialectTestContext, sourceKeyValue: string): Promise { + const row = await ctx.db + .selectFrom("_emdash_media_usage") + .select((eb) => eb.fn.countAll().as("count")) + .where("source_key", "=", sourceKeyValue) + .executeTakeFirstOrThrow(); + return Number(row.count); +} + async function updatePostHero( ctx: DialectTestContext, contentId: string, diff --git a/packages/core/tests/integration/database/media-usage-content-repair.test.ts b/packages/core/tests/integration/database/media-usage-content-repair.test.ts index c745c41507..b523a65d32 100644 --- a/packages/core/tests/integration/database/media-usage-content-repair.test.ts +++ b/packages/core/tests/integration/database/media-usage-content-repair.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; import { validateIdentifier } from "../../../src/database/validate.js"; +import { installMediaUsageCaptureTriggers } from "../../../src/media/usage/capture-triggers.js"; import { CONTENT_MEDIA_USAGE_ADAPTER_ID, CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, @@ -124,6 +125,140 @@ describeEachDialect("content media usage repair", (dialect) => { ); }); + it("uses canonical identity and clears only proven work after activation", async () => { + const item = await insertPost(ctx, { + id: "post_canonical", + slug: "canonical-post", + status: "published", + data: { + title: "Before activation", + hero: { id: "media-canonical", provider: "local", mimeType: "image/webp" }, + }, + }); + const legacySource = await usageRepo.replaceSource(contentSource(item.id, "columns"), [ + occurrence("hero", "media-legacy"), + ]); + const collectionId = await activateIncrementalRepair(ctx, "posts"); + await sql`UPDATE ec_posts SET title = 'After activation' WHERE id = ${item.id}`.execute(ctx.db); + + const result = await repairContentMediaUsageCollection(ctx.db, { collectionSlug: "posts" }); + + expect(result.status).toBe("complete"); + expect(await usageRepo.findSource(sourceKey(item.id, "columns"))).toEqual( + expect.objectContaining({ currentGeneration: legacySource.currentGeneration }), + ); + expect( + await usageRepo.findSource(canonicalContentSourceKey(collectionId, item.id, "columns")), + ).toEqual( + expect.objectContaining({ + collectionId, + identityVersion: 1, + contentTitle: "After activation", + }), + ); + expect( + Number( + ( + await ctx.db + .selectFrom("_emdash_media_usage_work") + .select((eb) => eb.fn.countAll().as("count")) + .where("collection_id", "=", collectionId) + .executeTakeFirstOrThrow() + ).count, + ), + ).toBe(0); + expect( + await ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select(["status", "reconciliation_required", "collection_id"]) + .where("collection_id", "=", collectionId) + .executeTakeFirstOrThrow(), + ).toEqual({ status: "complete", reconciliation_required: 0, collection_id: collectionId }); + }); + + it("retains newer work and refuses complete coverage when repair loses its epoch", async () => { + const collectionId = await activateIncrementalRepair(ctx, "posts"); + await insertPost(ctx, { + id: "post_epoch_race", + slug: "epoch-race", + status: "published", + data: { + title: "Before concurrent update", + hero: { id: "media-first", provider: "local", mimeType: "image/webp" }, + }, + }); + await installConcurrentPostUpdateTrigger(ctx); + + const result = await repairContentMediaUsageCollection(ctx.db, { collectionSlug: "posts" }); + + expect(result).toEqual( + expect.objectContaining({ + status: "stale", + lastErrorCode: "CONTENT_USAGE_REPAIR_CONFLICT", + completedAt: null, + }), + ); + expect( + await ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select(["status", "reconciliation_required", "change_epoch", "cursor"]) + .where("collection_id", "=", collectionId) + .executeTakeFirstOrThrow(), + ).toEqual( + expect.objectContaining({ + status: "stale", + reconciliation_required: 1, + change_epoch: expect.toSatisfy((value) => Number(value) === 2), + cursor: null, + }), + ); + expect( + await ctx.db + .selectFrom("_emdash_media_usage_work") + .select(["content_id", "change_epoch"]) + .where("collection_id", "=", collectionId) + .execute(), + ).toEqual([ + expect.objectContaining({ + content_id: "post_epoch_race", + change_epoch: expect.toSatisfy((value) => Number(value) === 2), + }), + ]); + }); + + it("requires reconciliation after a failed active repair of trusted coverage", async () => { + await registry.createField("posts", { + slug: "sections", + label: "Sections", + type: "repeater", + }); + await ctx.db + .updateTable("_emdash_fields") + .set({ validation: "{" }) + .where("slug", "=", "sections") + .execute(); + const collectionId = await activateIncrementalRepair(ctx, "posts", { + status: "complete", + reconciliationRequired: 0, + }); + + const result = await repairContentMediaUsageCollection(ctx.db, { collectionSlug: "posts" }); + + expect(result).toEqual( + expect.objectContaining({ + status: "failed", + lastErrorCode: "INVALID_REPEATER_VALIDATION", + }), + ); + expect( + await ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select(["status", "reconciliation_required"]) + .where("collection_id", "=", collectionId) + .executeTakeFirstOrThrow(), + ).toEqual({ status: "failed", reconciliation_required: 1 }); + }); + it("repairs empty collection scopes as complete", async () => { const result = await repairContentMediaUsageCollection(ctx.db, { collectionSlug: "posts" }); @@ -1403,6 +1538,89 @@ function collectionSourceKey( }); } +function canonicalContentSourceKey( + collectionId: string, + contentId: string, + sourceVariant: MediaUsageContentSourceVariant, +): string { + return buildContentMediaUsageSourceKey({ + collectionId, + collectionSlug: "posts", + contentId, + sourceVariant, + }); +} + +async function activateIncrementalRepair( + ctx: DialectTestContext, + collectionSlug: string, + options: { status?: string; reconciliationRequired?: number } = {}, +): Promise { + const collectionId = await getCollectionId(ctx, collectionSlug); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ + collection_id: collectionId, + status: options.status ?? "stale", + reconciliation_required: options.reconciliationRequired ?? 1, + capture_state: "installing", + }) + .where("adapter_id", "=", CONTENT_MEDIA_USAGE_ADAPTER_ID) + .where("scope_type", "=", CONTENT_MEDIA_USAGE_COLLECTION_SCOPE) + .where("scope_key", "=", collectionSlug) + .execute(); + await installMediaUsageCaptureTriggers(ctx.db, { collectionId, collectionSlug }); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", collectionId) + .execute(); + await ctx.db + .updateTable("_emdash_media_usage_activation") + .set({ state: "active", activated_at: "2026-08-06T00:00:00.000Z" }) + .where("task_key", "=", "incremental_capture") + .execute(); + return collectionId; +} + +async function installConcurrentPostUpdateTrigger(ctx: DialectTestContext): Promise { + if (ctx.dialect === "postgres") { + await sql` + CREATE FUNCTION media_usage_update_concurrent_post() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF NEW.media_id = 'media-first' THEN + UPDATE ec_posts + SET title = 'Concurrent update' + WHERE id = 'post_epoch_race'; + END IF; + RETURN NEW; + END; + $$ + `.execute(ctx.db); + await sql` + CREATE TRIGGER media_usage_update_concurrent_post + AFTER INSERT ON _emdash_media_usage + FOR EACH ROW + EXECUTE FUNCTION media_usage_update_concurrent_post() + `.execute(ctx.db); + return; + } + + await sql` + CREATE TRIGGER media_usage_update_concurrent_post + AFTER INSERT ON _emdash_media_usage + WHEN NEW.media_id = 'media-first' + BEGIN + UPDATE ec_posts + SET title = 'Concurrent update' + WHERE id = 'post_epoch_race'; + END + `.execute(ctx.db); +} + async function installConcurrentPostInsertTrigger(ctx: DialectTestContext): Promise { if (ctx.dialect === "postgres") { await sql` diff --git a/packages/core/tests/integration/database/media-usage-content-snapshots.test.ts b/packages/core/tests/integration/database/media-usage-content-snapshots.test.ts index b1e21973e6..86c27b1e68 100644 --- a/packages/core/tests/integration/database/media-usage-content-snapshots.test.ts +++ b/packages/core/tests/integration/database/media-usage-content-snapshots.test.ts @@ -431,13 +431,44 @@ describeEachDialect("content media usage snapshots", (dialect) => { expect(firstColumns.source.schemaVersion).toBe(1); expect(firstOverlay.source.schemaVersion).toBe(1); - expect(firstColumns.source.sourceFingerprint).toEqual(expect.stringMatching(/^[a-f0-9]{16}$/)); - expect(firstOverlay.source.sourceFingerprint).toEqual(expect.stringMatching(/^[a-f0-9]{16}$/)); + expect(firstColumns.source.sourceFingerprint).toEqual( + expect.stringMatching(/^media-usage-projection:v1:sha256:[a-f0-9]{64}$/), + ); + expect(firstOverlay.source.sourceFingerprint).toEqual( + expect.stringMatching(/^media-usage-projection:v1:sha256:[a-f0-9]{64}$/), + ); expect(firstOverlay.source.sourceFingerprint).not.toBe(firstColumns.source.sourceFingerprint); expect(secondColumns.source.sourceFingerprint).toBe(firstColumns.source.sourceFingerprint); expect(secondOverlay.source.sourceFingerprint).toBe(firstOverlay.source.sourceFingerprint); }); + it("changes fingerprints when V1-visible source metadata changes", async () => { + const item = await insertPost(ctx, { + slug: "metadata-post", + status: "published", + locale: "en", + data: { + title: "Initial title", + hero: { id: "media-stable", provider: "local", mimeType: "image/webp" }, + }, + }); + const initial = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id); + expect(initial.success).toBe(true); + if (!initial.success) throw new Error(initial.error); + const initialFingerprint = getSnapshot(initial, "columns").source.sourceFingerprint; + + await sql` + UPDATE ${sql.ref("ec_posts")} + SET title = 'Changed title', status = 'draft', locale = 'fr' + WHERE id = ${item.id} + `.execute(ctx.db); + const changed = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id); + expect(changed.success).toBe(true); + if (!changed.success) throw new Error(changed.error); + + expect(getSnapshot(changed, "columns").source.sourceFingerprint).not.toBe(initialFingerprint); + }); + it("changes fingerprints when extraction-relevant values or fields change", async () => { const item = await insertPost(ctx, { slug: "live-post", diff --git a/packages/core/tests/integration/database/media-usage-incremental-work-migration.test.ts b/packages/core/tests/integration/database/media-usage-incremental-work-migration.test.ts new file mode 100644 index 0000000000..5c220cf729 --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-incremental-work-migration.test.ts @@ -0,0 +1,237 @@ +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("media usage incremental work migration", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("registers expansion without activating capture or backfilling work", async () => { + const migration = await ctx.db + .selectFrom("_emdash_migrations") + .select("name") + .where("name", "=", "063_media_usage_incremental_work") + .executeTakeFirst(); + expect(migration).toBeDefined(); + + const activation = await ctx.db + .selectFrom("_emdash_media_usage_activation") + .select(["state", "collection_cursor", "activated_at"]) + .executeTakeFirstOrThrow(); + expect(activation).toEqual({ + state: "expanded", + collection_cursor: null, + activated_at: null, + }); + + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts" }); + await sql`INSERT INTO ${sql.ref("ec_posts")} (id, slug) VALUES ('entry-1', 'entry-1')`.execute( + ctx.db, + ); + + const work = await ctx.db.selectFrom("_emdash_media_usage_work").selectAll().execute(); + expect(work).toEqual([]); + }); + + it("upgrades and reruns without rewriting legacy evidence or inventing work", async () => { + const migration = + await import("../../../src/database/migrations/063_media_usage_incremental_work.js"); + await migration.down(ctx.db); + + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts" }); + const collection = await registry.getCollection("posts"); + if (!collection) throw new Error("Expected posts collection"); + + await ctx.db + .insertInto("_emdash_media_usage_index_status") + .values([ + { + adapter_id: "content-media", + scope_type: "collection", + scope_key: "posts", + status: "complete", + completed_at: "2026-08-01T12:00:00.000Z", + }, + { + adapter_id: "content-media", + scope_type: "collection", + scope_key: "deleted_collection", + status: "complete", + }, + ]) + .execute(); + + await ctx.db + .insertInto("_emdash_media_usage_sources") + .values({ + source_key: "content:posts:entry-1:columns", + source_type: "content", + collection_slug: "posts", + content_id: "entry-1", + source_variant: "columns", + locale: "en", + translation_group: "translation-1", + content_slug: "entry-1", + content_title: "Legacy title", + content_status: "published", + content_scheduled_at: null, + content_deleted_at: null, + revision_id: null, + current_generation: "generation-1", + source_fingerprint: "legacy-fingerprint", + }) + .execute(); + await ctx.db + .insertInto("_emdash_media_usage") + .values({ + id: "usage-1", + source_key: "content:posts:entry-1:columns", + generation: "generation-1", + field_slug: "body", + field_path: "body[0]", + reference_type: "local", + media_id: "media-1", + provider_asset_id: "media-1", + }) + .execute(); + + await migration.up(ctx.db); + + const status = await ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select([ + "collection_id", + "status", + "completed_at", + "reconciliation_required", + "capture_state", + ]) + .where("scope_key", "=", "posts") + .executeTakeFirstOrThrow(); + expect(status).toEqual({ + collection_id: collection.id, + status: "complete", + completed_at: "2026-08-01T12:00:00.000Z", + reconciliation_required: 1, + capture_state: "installing", + }); + + const unmatched = await ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select("scope_key") + .where("scope_key", "=", "deleted_collection") + .executeTakeFirst(); + expect(unmatched).toBeUndefined(); + + const source = await ctx.db + .selectFrom("_emdash_media_usage_sources") + .select(["collection_id", "identity_version", "source_fingerprint", "content_title"]) + .where("source_key", "=", "content:posts:entry-1:columns") + .executeTakeFirstOrThrow(); + expect(source).toEqual({ + collection_id: null, + identity_version: null, + source_fingerprint: "legacy-fingerprint", + content_title: "Legacy title", + }); + const occurrence = await ctx.db + .selectFrom("_emdash_media_usage") + .select(["source_key", "generation", "reference_type", "media_id", "provider_asset_id"]) + .where("id", "=", "usage-1") + .executeTakeFirstOrThrow(); + expect(occurrence).toEqual({ + source_key: "content:posts:entry-1:columns", + generation: "generation-1", + reference_type: "local", + media_id: "media-1", + provider_asset_id: "media-1", + }); + expect( + await ctx.db.selectFrom("_emdash_media_usage_work").select("content_id").execute(), + ).toEqual([]); + + await ctx.db + .updateTable("_emdash_media_usage_activation") + .set({ collection_cursor: "posts", attempt_count: 2 }) + .execute(); + await migration.up(ctx.db); + + const activation = await ctx.db + .selectFrom("_emdash_media_usage_activation") + .select(["state", "collection_cursor", "attempt_count"]) + .executeTakeFirstOrThrow(); + expect(activation).toEqual({ + state: "expanded", + collection_cursor: "posts", + attempt_count: 2, + }); + expect( + await ctx.db + .selectFrom("_emdash_media_usage") + .select("id") + .where("id", "=", "usage-1") + .executeTakeFirst(), + ).toEqual({ id: "usage-1" }); + }); + + it("purges a partially bound status if its collection is deleted or recreated before retry", async () => { + const migration = + await import("../../../src/database/migrations/063_media_usage_incremental_work.js"); + await migration.down(ctx.db); + + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "recreated", label: "Recreated" }); + await registry.createCollection({ slug: "deleted", label: "Deleted" }); + await ctx.db + .insertInto("_emdash_media_usage_index_status") + .values([ + { + adapter_id: "content-media", + scope_type: "collection", + scope_key: "recreated", + status: "never", + }, + { + adapter_id: "content-media", + scope_type: "collection", + scope_key: "deleted", + status: "never", + }, + ]) + .execute(); + await migration.up(ctx.db); + + await ctx.db + .updateTable("_emdash_collections") + .set({ id: "replacement-collection-id" }) + .where("slug", "=", "recreated") + .execute(); + await ctx.db.deleteFrom("_emdash_collections").where("slug", "=", "deleted").execute(); + await migration.up(ctx.db); + + const statuses = await ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select("scope_key") + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "in", ["recreated", "deleted"]) + .execute(); + expect(statuses).toEqual([]); + }); +}); diff --git a/packages/core/tests/integration/database/media-usage-read-repository.test.ts b/packages/core/tests/integration/database/media-usage-read-repository.test.ts index b93ab6759e..7112a1834f 100644 --- a/packages/core/tests/integration/database/media-usage-read-repository.test.ts +++ b/packages/core/tests/integration/database/media-usage-read-repository.test.ts @@ -185,6 +185,109 @@ describeEachDialect("MediaUsageRepository reads", (dialect) => { }); }); + it("quarantines legacy and replaced-collection sources after activation", async () => { + await ctx.db + .insertInto("_emdash_collections") + .values({ id: "collection-posts-old", slug: "posts", label: "Old posts" }) + .execute(); + await repo.replaceSource( + contentSource("old-only", "columns", { + sourceKey: buildContentMediaUsageSourceKey({ + collectionId: "collection-posts-old", + collectionSlug: "posts", + contentId: "old-only", + sourceVariant: "columns", + }), + collectionId: "collection-posts-old", + identityVersion: 1, + }), + [occurrence("hero", "media-shared")], + ); + await ctx.db + .deleteFrom("_emdash_collections") + .where("id", "=", "collection-posts-old") + .execute(); + await ctx.db + .insertInto("_emdash_collections") + .values({ id: "collection-posts", slug: "posts", label: "Posts" }) + .execute(); + await repo.replaceSource(contentSource("legacy-only", "columns"), [ + occurrence("hero", "media-shared"), + ]); + const legacyDeletedAt = "2026-01-01T00:00:00.000Z"; + await repo.replaceSource( + contentSource("current", "columns", { + contentDeletedAt: legacyDeletedAt, + }), + [occurrence("legacy", "media-shared")], + ); + await repo.replaceSource( + contentSource("current", "draft_overlay", { + contentDeletedAt: legacyDeletedAt, + }), + [], + ); + await repo.replaceSource( + contentSource("unversioned", "columns", { + sourceKey: buildContentMediaUsageSourceKey({ + collectionId: "collection-posts", + collectionSlug: "posts", + contentId: "unversioned", + sourceVariant: "columns", + }), + collectionId: "collection-posts", + }), + [occurrence("unversioned", "media-shared")], + ); + await repo.replaceSource( + contentSource("current", "columns", { + sourceKey: buildContentMediaUsageSourceKey({ + collectionId: "collection-posts", + collectionSlug: "posts", + contentId: "current", + sourceVariant: "columns", + }), + collectionId: "collection-posts", + contentStatus: "draft", + identityVersion: 1, + }), + [occurrence("canonical", "media-shared")], + ); + + const legacyCounts = await repo.findActiveEntryCountsByMediaIds(["media-shared"]); + const legacyPage = await repo.findCurrentEntryUsagePageByMediaId("media-shared"); + + expect(legacyCounts.get("media-shared")).toBe(3); + expect(legacyPage.items.map(entryIdentity)).toEqual([ + ["posts", "current"], + ["posts", "legacy-only"], + ["posts", "old-only"], + ["posts", "unversioned"], + ]); + expect(legacyPage.items[0]?.contentDeletedAt).toBe(legacyDeletedAt); + expect( + legacyPage.items[0]?.sources.flatMap((source) => + source.occurrences.map((item) => item.fieldSlug), + ), + ).toEqual(["legacy"]); + + await ctx.db + .updateTable("_emdash_media_usage_activation") + .set({ state: "active" }) + .where("task_key", "=", "incremental_capture") + .execute(); + + const counts = await repo.findActiveEntryCountsByMediaIds(["media-shared"]); + const page = await repo.findCurrentEntryUsagePageByMediaId("media-shared"); + + expect(counts.get("media-shared")).toBe(1); + expect(page.items.map(entryIdentity)).toEqual([["posts", "current"]]); + expect(page.items[0]?.contentDeletedAt).toBeNull(); + expect( + page.items[0]?.sources.flatMap((source) => source.occurrences.map((item) => item.fieldSlug)), + ).toEqual(["canonical"]); + }); + it("returns trashed entries in details while excluding them from active counts", async () => { await registerCollection(ctx, "posts"); const deletedAt = "2026-01-01T00:00:00.000Z"; @@ -232,8 +335,18 @@ describeEachDialect("MediaUsageRepository reads", (dialect) => { }); expect(scopes).toEqual([ - { collectionSlug: "pages", status: null, schemaVersion: null }, - { collectionSlug: "posts", status: "complete", schemaVersion: 2 }, + { + collectionSlug: "pages", + status: null, + schemaVersion: null, + reconciliationRequired: false, + }, + { + collectionSlug: "posts", + status: "complete", + schemaVersion: 2, + reconciliationRequired: false, + }, ]); }); diff --git a/packages/core/tests/integration/database/media-usage-repository.test.ts b/packages/core/tests/integration/database/media-usage-repository.test.ts index 60c9650b57..e132923f39 100644 --- a/packages/core/tests/integration/database/media-usage-repository.test.ts +++ b/packages/core/tests/integration/database/media-usage-repository.test.ts @@ -92,6 +92,88 @@ describeEachDialect("MediaUsageRepository", (dialect) => { expect(rows).toContainEqual({ generation: second.currentGeneration, media_id: "media-new" }); }); + it("updates canonical identity metadata when replacing a source", async () => { + const collectionId = "collection-posts"; + await ctx.db + .insertInto("_emdash_collections") + .values({ id: collectionId, slug: "posts", label: "Posts" }) + .execute(); + const sourceKey = buildContentMediaUsageSourceKey({ + collectionId, + collectionSlug: "posts", + contentId: "entry1", + sourceVariant: "columns", + }); + + await repo.replaceSource( + contentSource("entry1", "columns", { + sourceKey, + collectionId, + identityVersion: 1, + }), + [occurrence("hero", "media-old")], + ); + const replaced = await repo.replaceSource( + contentSource("entry1", "columns", { + sourceKey, + collectionId, + identityVersion: 2, + }), + [occurrence("hero", "media-new")], + ); + + expect(replaced).toEqual(expect.objectContaining({ collectionId, identityVersion: 2 })); + }); + + it("refuses canonical attempted writes after collection identity disappears", async () => { + const collectionId = "collection-posts"; + await ctx.db + .insertInto("_emdash_collections") + .values({ id: collectionId, slug: "posts", label: "Posts" }) + .execute(); + const source = contentSource("entry1", "columns", { + sourceKey: buildContentMediaUsageSourceKey({ + collectionId, + collectionSlug: "posts", + contentId: "entry1", + sourceVariant: "columns", + }), + collectionId, + identityVersion: 1, + }); + const observed = await repo.replaceSource(source, [occurrence("hero", "media-old")]); + await ctx.db.deleteFrom("_emdash_collections").where("id", "=", collectionId).execute(); + + const update = await repo.markSourceAttemptedIfMatching( + { ...source, sourceCompleteness: "failed", lastErrorCode: "SNAPSHOT_FAILED" }, + observed, + ); + const absentSource = contentSource("entry2", "columns", { + sourceKey: buildContentMediaUsageSourceKey({ + collectionId, + collectionSlug: "posts", + contentId: "entry2", + sourceVariant: "columns", + }), + collectionId, + identityVersion: 1, + sourceCompleteness: "failed", + lastErrorCode: "SNAPSHOT_FAILED", + }); + const insert = await repo.markSourceAttemptedIfMatching(absentSource, null); + + expect(update.attempted).toBe(false); + expect((await repo.findSource(source.sourceKey))?.sourceCompleteness).toBe("complete"); + expect(insert).toEqual({ attempted: false, source: null }); + await expect( + repo.markSourceAttempted({ + ...source, + sourceCompleteness: "failed", + lastErrorCode: "SNAPSHOT_FAILED", + }), + ).rejects.toThrow(/no longer current/i); + }); + it("does not replace a source when the expected generation is stale", async () => { const first = await repo.replaceSource(contentSource("entry1", "columns"), [ occurrence("hero", "media-old"), @@ -1221,6 +1303,57 @@ describeEachDialect("MediaUsageRepository", (dialect) => { ); }); + it("makes an active repair require reconciliation until guarded completion", async () => { + await ctx.db + .insertInto("_emdash_collections") + .values({ id: "active-posts-id", slug: "active_posts", label: "Active posts" }) + .execute(); + await repo.upsertIndexStatus({ + adapterId: "content-media", + scopeType: "collection", + scopeKey: "active_posts", + status: "complete", + }); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ + collection_id: "active-posts-id", + capture_state: "active", + reconciliation_required: 0, + }) + .where("scope_key", "=", "active_posts") + .execute(); + await ctx.db + .updateTable("_emdash_media_usage_activation") + .set({ state: "active" }) + .where("task_key", "=", "incremental_capture") + .execute(); + + const run = await repo.beginIndexStatusRepairAtCurrentEpoch({ + adapterId: "content-media", + scopeType: "collection", + scopeKey: "active_posts", + collectionId: "active-posts-id", + runToken: "active-repair-run", + schemaVersion: 1, + }); + + expect(run).toEqual( + expect.objectContaining({ changeEpoch: expect.toSatisfy((value) => Number(value) === 0) }), + ); + expect( + await ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select(["status", "cursor", "reconciliation_required"]) + .where("collection_id", "=", "active-posts-id") + .executeTakeFirstOrThrow(), + ).toEqual({ + status: "running", + cursor: "active-repair-run", + reconciliation_required: 1, + }); + }); + it("finalizes repair status only when status and run token still match", async () => { await repo.beginIndexStatusRepair({ adapterId: "content-media", @@ -1334,6 +1467,25 @@ describeEachDialect("MediaUsageRepository", (dialect) => { ); }); + it("does not delete a replacement collection's status through an old identity", async () => { + const identity = { + adapterId: "content-media", + scopeType: "collection", + scopeKey: "recreated", + }; + await repo.upsertIndexStatus({ ...identity, status: "stale" }); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ collection_id: "replacement-id" }) + .where("scope_key", "=", "recreated") + .execute(); + + expect(await repo.deleteIndexStatus(identity, "old-id")).toBe(0); + expect(await repo.findIndexStatus(identity)).toEqual( + expect.objectContaining({ status: "stale" }), + ); + }); + it("replaces more occurrences than one D1 insert batch", async () => { const occurrences = Array.from({ length: SQL_BATCH_SIZE + 7 }, (_, index) => occurrence(`gallery-${index}`, `media-${index}`, { diff --git a/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts b/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts index 6936572361..ce44eaa688 100644 --- a/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts +++ b/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts @@ -352,7 +352,7 @@ describeEachDialect("runtime content media usage refresh", (dialect) => { success: true, data: { [mediaId]: { - count: 0, + count: null, coverage: { scope: "all_content_collections", status: "stale" }, }, }, diff --git a/packages/core/tests/integration/database/media-usage-stale-bypass.test.ts b/packages/core/tests/integration/database/media-usage-stale-bypass.test.ts index 039050b8bf..75c1f14058 100644 --- a/packages/core/tests/integration/database/media-usage-stale-bypass.test.ts +++ b/packages/core/tests/integration/database/media-usage-stale-bypass.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, expect, it } from "vitest"; import { rewriteUrls } from "../../../src/astro/routes/api/import/wordpress/rewrite-urls.js"; import { ContentRepository } from "../../../src/database/repositories/content.js"; import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import { installMediaUsageCaptureTriggers } from "../../../src/media/usage/capture-triggers.js"; import { CONTENT_MEDIA_USAGE_ADAPTER_ID, CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, @@ -138,6 +139,84 @@ describeEachDialect("media usage stale marking for bypass writes", (dialect) => await expectCollectionStatus("posts", "stale"); }); + it("requires reconciliation after active schema field mutations", async () => { + const collectionId = await activateCollectionCapture("posts"); + + await registry.createField("posts", { slug: "deck", label: "Deck", type: "string" }); + await expectSchemaReconciliation(collectionId, 2); + + await trustCurrentSchema(collectionId); + await registry.updateField("posts", "hero", { type: "file" }); + await expectSchemaReconciliation(collectionId, 4); + + await trustCurrentSchema(collectionId); + await registry.deleteField("posts", "deck"); + await expectSchemaReconciliation(collectionId, 6); + + expect( + await usageRepo.recordIncrementalSuccess({ collectionId, collectionSlug: "posts" }), + ).toBe(true); + await expectSchemaReconciliation(collectionId, 6); + }); + + it("does not mutate schema when active coverage cannot be invalidated", async () => { + const collectionId = await activateCollectionCapture("posts"); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "installing" }) + .where("collection_id", "=", collectionId) + .execute(); + + await expect( + registry.createField("posts", { slug: "blocked", label: "Blocked", type: "image" }), + ).rejects.toThrow(); + await expect(registry.getField("posts", "blocked")).resolves.toBeNull(); + }); + + it.runIf(dialect === "sqlite")( + "fences a repair that starts during an active schema mutation", + async () => { + const collectionId = await activateCollectionCapture("posts"); + const runToken = "schema-race-repair"; + await sql` + CREATE TRIGGER begin_media_usage_repair_during_schema_change + AFTER INSERT ON _emdash_fields + WHEN NEW.slug = 'race_field' + BEGIN + UPDATE _emdash_media_usage_index_status + SET status = 'running', + started_at = '2026-08-09T00:00:00.000Z', + completed_at = NULL, + cursor = ${sql.lit(runToken)}, + reconciliation_required = 1 + WHERE collection_id = ${sql.lit(collectionId)}; + END + `.execute(ctx.db); + + await registry.createField("posts", { + slug: "race_field", + label: "Race field", + type: "image", + }); + const finalized = await usageRepo.finalizeIndexStatusRepairAtEpoch({ + adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, + scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, + scopeKey: "posts", + collectionId, + runToken, + startingEpoch: 1, + status: "complete", + schemaVersion: 1, + indexedSourceCount: 0, + failedSourceCount: 0, + lastErrorCode: null, + }); + + expect(finalized.finalized).toBe(false); + await expectSchemaReconciliation(collectionId, 2); + }, + ); + it("marks registered orphaned tables stale", async () => { await sql`CREATE TABLE ec_orphan_posts (id text primary key)`.execute(ctx.db); @@ -289,6 +368,64 @@ describeEachDialect("media usage stale marking for bypass writes", (dialect) => }); } + async function activateCollectionCapture(collectionSlug: string): Promise { + const collection = await registry.getCollection(collectionSlug); + if (!collection) throw new Error(`Expected ${collectionSlug} collection`); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ + collection_id: collection.id, + status: "complete", + completed_at: "2026-08-01T00:00:00.000Z", + reconciliation_required: 0, + capture_state: "installing", + }) + .where("adapter_id", "=", CONTENT_MEDIA_USAGE_ADAPTER_ID) + .where("scope_type", "=", CONTENT_MEDIA_USAGE_COLLECTION_SCOPE) + .where("scope_key", "=", collectionSlug) + .execute(); + await installMediaUsageCaptureTriggers(ctx.db, { + collectionId: collection.id, + collectionSlug, + }); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", collection.id) + .execute(); + await ctx.db + .updateTable("_emdash_media_usage_activation") + .set({ state: "active", activated_at: "2026-08-05T00:00:00.000Z" }) + .where("task_key", "=", "incremental_capture") + .execute(); + return collection.id; + } + + async function trustCurrentSchema(collectionId: string): Promise { + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ status: "complete", reconciliation_required: 0 }) + .where("collection_id", "=", collectionId) + .execute(); + } + + async function expectSchemaReconciliation( + collectionId: string, + changeEpoch: number, + ): Promise { + await expect( + ctx.db + .selectFrom("_emdash_media_usage_index_status") + .select(["status", "reconciliation_required", "change_epoch"]) + .where("collection_id", "=", collectionId) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ + status: "stale", + reconciliation_required: 1, + change_epoch: expect.toSatisfy((value) => Number(value) === changeEpoch), + }); + } + async function expectCollectionStatus(collectionSlug: string, status: string) { await expect(findCollectionStatus(collectionSlug)).resolves.toEqual( expect.objectContaining({ status }), diff --git a/packages/core/tests/integration/database/media-usage-work-operator.test.ts b/packages/core/tests/integration/database/media-usage-work-operator.test.ts new file mode 100644 index 0000000000..bdf250b9ac --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-work-operator.test.ts @@ -0,0 +1,495 @@ +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + MediaUsageWorkRepository, + type MediaUsageWorkState, +} from "../../../src/database/repositories/media-usage-work.js"; +import { InvalidCursorError } from "../../../src/database/repositories/types.js"; +import { + describeEachDialect, + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("media usage work operator repository", (dialect) => { + let ctx: DialectTestContext; + let collectionId: string; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + const collection = await ctx.db + .selectFrom("_emdash_collections") + .select(["id", "slug"]) + .where("slug", "=", "post") + .executeTakeFirstOrThrow(); + collectionId = collection.id; + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ + collection_id: collectionId, + capture_state: "active", + status: "complete", + completed_at: "2026-08-01T00:00:00.000Z", + reconciliation_required: 0, + }) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", "post") + .execute(); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("returns deterministic bounded cursor pages without exposing internal ownership", async () => { + for (let index = 0; index < 27; index++) { + await insertWork({ + contentId: `entry-${String(index).padStart(3, "0")}`, + state: STATES[index % STATES.length]!, + updatedAt: `2026-08-06T12:${String(Math.floor(index / 3)).padStart(2, "0")}:00.000Z`, + }); + } + + const repo = new MediaUsageWorkRepository(ctx.db); + const first = await repo.findOperatorPage({ collectionSlug: "post", limit: 10 }); + expect(first?.items).toHaveLength(10); + expect(first?.nextCursor).toEqual(expect.any(String)); + expect(first?.items[0]).toEqual( + expect.objectContaining({ + collectionId, + collectionSlug: "post", + contentId: "entry-026", + }), + ); + for (const item of first!.items) { + expect(item).not.toHaveProperty("leaseToken"); + expect(item).not.toHaveProperty("workVersion"); + expect(item).not.toHaveProperty("changeEpoch"); + } + + const second = await repo.findOperatorPage({ + collectionSlug: "post", + limit: 10, + cursor: first!.nextCursor, + }); + expect(second?.items).toHaveLength(10); + expect(new Set([...first!.items, ...second!.items].map((item) => item.contentId)).size).toBe( + 20, + ); + }); + + it("distinguishes an exact full page from a page with more work", async () => { + for (let index = 0; index < 100; index++) { + await insertWork({ + contentId: `entry-boundary-${String(index).padStart(3, "0")}`, + state: "pending", + updatedAt: `2026-08-06T12:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`, + }); + } + + const repo = new MediaUsageWorkRepository(ctx.db); + const exact = await repo.findOperatorPage({ collectionSlug: "post", limit: 100 }); + expect(exact?.items).toHaveLength(100); + expect(exact?.nextCursor).toBeUndefined(); + + await insertWork({ + contentId: "entry-boundary-100", + state: "pending", + updatedAt: "2026-08-06T12:02:00.000Z", + }); + const first = await repo.findOperatorPage({ collectionSlug: "post", limit: 100 }); + expect(first?.items).toHaveLength(100); + expect(first?.nextCursor).toEqual(expect.any(String)); + const second = await repo.findOperatorPage({ + collectionSlug: "post", + limit: 100, + cursor: first?.nextCursor, + }); + expect(second?.items).toHaveLength(1); + expect(second?.nextCursor).toBeUndefined(); + }); + + it("rejects malformed cursors instead of silently restarting pagination", async () => { + await expect( + new MediaUsageWorkRepository(ctx.db).findOperatorPage({ + collectionSlug: "post", + cursor: "not-a-cursor", + }), + ).rejects.toBeInstanceOf(InvalidCursorError); + }); + + it("filters one state without crossing collection identity", async () => { + await insertWork({ contentId: "failed-current", state: "failed" }); + await insertWork({ contentId: "pending-current", state: "pending" }); + await ctx.db + .insertInto("_emdash_media_usage_work") + .values(workRow("other-collection", "post", "failed-old", "failed")) + .execute(); + + const page = await new MediaUsageWorkRepository(ctx.db).findOperatorPage({ + collectionSlug: "post", + state: "failed", + limit: 50, + }); + + expect(page?.items.map((item) => item.contentId)).toEqual(["failed-current"]); + expect(page?.nextCursor).toBeUndefined(); + }); + + it("returns null for a missing current collection", async () => { + const page = await new MediaUsageWorkRepository(ctx.db).findOperatorPage({ + collectionSlug: "missing", + }); + + expect(page).toBeNull(); + }); + + it("leaves pending work and its coverage epoch unchanged", async () => { + await insertWork({ contentId: "entry-pending", state: "pending", workVersion: 4 }); + const beforeStatus = await statusRow(); + + const result = await new MediaUsageWorkRepository(ctx.db).retryOperatorWork({ + collectionId, + contentId: "entry-pending", + }); + + expect(result).toEqual( + expect.objectContaining({ + outcome: "pending", + changed: false, + work: expect.objectContaining({ contentId: "entry-pending", state: "pending" }), + }), + ); + expect(await statusRow()).toEqual(beforeStatus); + expect(await rawWork("entry-pending")).toEqual( + expect.objectContaining({ work_version: expect.toSatisfy((value) => Number(value) === 4) }), + ); + }); + + it.each(["retry", "failed"] as const)( + "reopens %s work and invalidates complete coverage", + async (state) => { + await insertWork({ + contentId: `entry-${state}`, + state, + workVersion: 7, + attemptCount: 3, + lastErrorCode: "MEDIA_USAGE_PROCESSING_FAILED", + }); + + const result = await new MediaUsageWorkRepository(ctx.db).retryOperatorWork({ + collectionId, + contentId: `entry-${state}`, + }); + + expect(result).toEqual( + expect.objectContaining({ + outcome: "pending", + changed: true, + work: expect.objectContaining({ + state: "pending", + attemptCount: 0, + lastErrorCode: null, + }), + }), + ); + expect(await rawWork(`entry-${state}`)).toEqual( + expect.objectContaining({ + work_version: expect.toSatisfy((value) => Number(value) === 8), + lease_token: null, + lease_expires_at: null, + last_attempted_at: null, + }), + ); + expect(await statusRow()).toEqual( + expect.objectContaining({ + status: "stale", + completed_at: null, + change_epoch: expect.toSatisfy((value) => Number(value) === 1), + }), + ); + }, + ); + + it("takes over an expired lease without retaining its owner", async () => { + await insertWork({ + contentId: "entry-expired", + state: "leased", + workVersion: 2, + leaseToken: "expired-owner", + leaseExpiresAt: "2000-01-01T00:00:00.000Z", + }); + + const result = await new MediaUsageWorkRepository(ctx.db).retryOperatorWork({ + collectionId, + contentId: "entry-expired", + }); + + expect(result).toEqual(expect.objectContaining({ outcome: "pending", changed: true })); + expect(await rawWork("entry-expired")).toEqual( + expect.objectContaining({ state: "pending", lease_token: null, lease_expires_at: null }), + ); + }); + + it("does not steal a live lease or expose its token", async () => { + await insertWork({ + contentId: "entry-live", + state: "leased", + workVersion: 3, + leaseToken: "private-owner-token", + leaseExpiresAt: "2100-01-01T00:00:00.000Z", + }); + + const result = await new MediaUsageWorkRepository(ctx.db).retryOperatorWork({ + collectionId, + contentId: "entry-live", + }); + + expect(result).toEqual({ + outcome: "lease_active", + leaseExpiresAt: "2100-01-01T00:00:00.000Z", + }); + expect(JSON.stringify(result)).not.toContain("private-owner-token"); + expect(await rawWork("entry-live")).toEqual( + expect.objectContaining({ + state: "leased", + work_version: expect.toSatisfy((value) => Number(value) === 3), + lease_token: "private-owner-token", + }), + ); + }); + + it("creates missing work for a current active collection without requiring the content row", async () => { + const result = await new MediaUsageWorkRepository(ctx.db).retryOperatorWork({ + collectionId, + contentId: "already-deleted-entry", + }); + + expect(result).toEqual( + expect.objectContaining({ + outcome: "pending", + changed: true, + work: expect.objectContaining({ + collectionId, + collectionSlug: "post", + contentId: "already-deleted-entry", + state: "pending", + }), + }), + ); + expect(await statusRow()).toEqual( + expect.objectContaining({ status: "stale", completed_at: null }), + ); + }); + + it("does not overwrite newer pending work created during a retry", async () => { + if (dialect !== "sqlite") return; + await insertWork({ + contentId: "entry-newer-work", + state: "failed", + workVersion: 7, + attemptCount: 5, + lastErrorCode: "MEDIA_USAGE_PROCESSING_FAILED", + }); + await sql` + CREATE TRIGGER retry_newer_work_wins + AFTER UPDATE OF change_epoch ON _emdash_media_usage_index_status + BEGIN + UPDATE _emdash_media_usage_work + SET state = 'pending', + work_version = work_version + 1, + attempt_count = 0, + last_error_code = NULL + WHERE collection_id = NEW.collection_id + AND content_id = 'entry-newer-work'; + END + `.execute(ctx.db); + + const result = await new MediaUsageWorkRepository(ctx.db).retryOperatorWork({ + collectionId, + contentId: "entry-newer-work", + }); + + expect(result).toEqual( + expect.objectContaining({ + outcome: "pending", + changed: false, + work: expect.objectContaining({ state: "pending", attemptCount: 0 }), + }), + ); + expect(await rawWork("entry-newer-work")).toEqual( + expect.objectContaining({ + work_version: expect.toSatisfy((value) => Number(value) === 8), + state: "pending", + }), + ); + }); + + it("keeps coverage conservative when another owner removes work during retry", async () => { + if (dialect !== "sqlite") return; + await insertWork({ contentId: "entry-removed-race", state: "failed", workVersion: 4 }); + await sql` + CREATE TRIGGER retry_remove_work_race + AFTER UPDATE OF change_epoch ON _emdash_media_usage_index_status + BEGIN + DELETE FROM _emdash_media_usage_work + WHERE collection_id = NEW.collection_id + AND content_id = 'entry-removed-race'; + END + `.execute(ctx.db); + + const result = await new MediaUsageWorkRepository(ctx.db).retryOperatorWork({ + collectionId, + contentId: "entry-removed-race", + }); + + expect(result).toEqual({ outcome: "conflict" }); + expect(await statusRow()).toEqual( + expect.objectContaining({ status: "stale", completed_at: null }), + ); + expect( + await ctx.db + .selectFrom("_emdash_media_usage_work") + .select("content_id") + .where("content_id", "=", "entry-removed-race") + .executeTakeFirst(), + ).toBeUndefined(); + }); + + it("does not reopen obsolete work after its collection is removed", async () => { + if (dialect !== "sqlite") return; + await insertWork({ contentId: "entry-obsolete", state: "failed", workVersion: 2 }); + await removeCollectionAfterCoverageInvalidation(); + + const result = await new MediaUsageWorkRepository(ctx.db).retryOperatorWork({ + collectionId, + contentId: "entry-obsolete", + }); + + expect(result).toEqual({ outcome: "collection_not_found" }); + expect(await rawWork("entry-obsolete")).toEqual( + expect.objectContaining({ + state: "failed", + work_version: expect.toSatisfy((value) => Number(value) === 2), + }), + ); + }); + + it("does not create work after its collection is removed", async () => { + if (dialect !== "sqlite") return; + await removeCollectionAfterCoverageInvalidation(); + + const result = await new MediaUsageWorkRepository(ctx.db).retryOperatorWork({ + collectionId, + contentId: "entry-missing-race", + }); + + expect(result).toEqual({ outcome: "collection_not_found" }); + expect( + await ctx.db + .selectFrom("_emdash_media_usage_work") + .select("content_id") + .where("content_id", "=", "entry-missing-race") + .executeTakeFirst(), + ).toBeUndefined(); + }); + + it("rejects missing and inactive collection identities without creating work", async () => { + const repo = new MediaUsageWorkRepository(ctx.db); + expect( + await repo.retryOperatorWork({ collectionId: "missing-collection", contentId: "entry-1" }), + ).toEqual({ outcome: "collection_not_found" }); + + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "installing" }) + .where("collection_id", "=", collectionId) + .execute(); + expect(await repo.retryOperatorWork({ collectionId, contentId: "entry-1" })).toEqual({ + outcome: "collection_not_found", + }); + expect( + await ctx.db.selectFrom("_emdash_media_usage_work").select("content_id").execute(), + ).toEqual([]); + }); + + async function insertWork(input: { + contentId: string; + state: MediaUsageWorkState; + updatedAt?: string; + workVersion?: number; + attemptCount?: number; + lastErrorCode?: string | null; + leaseToken?: string | null; + leaseExpiresAt?: string | null; + }): Promise { + await ctx.db + .insertInto("_emdash_media_usage_work") + .values({ + ...workRow(collectionId, "post", input.contentId, input.state), + work_version: input.workVersion ?? 1, + attempt_count: input.attemptCount ?? 0, + last_error_code: input.lastErrorCode ?? null, + lease_token: input.leaseToken ?? null, + lease_expires_at: input.leaseExpiresAt ?? null, + updated_at: input.updatedAt ?? "2026-08-06T12:00:00.000Z", + }) + .execute(); + } + + function workRow( + rowCollectionId: string, + collectionSlug: string, + contentId: string, + state: MediaUsageWorkState, + ) { + return { + collection_id: rowCollectionId, + collection_slug: collectionSlug, + content_id: contentId, + change_epoch: 0, + work_version: 1, + state, + attempt_count: 0, + next_attempt_at: "2000-01-01T00:00:00.000Z", + lease_token: null, + lease_expires_at: null, + last_attempted_at: null, + last_error_code: null, + created_at: "2026-08-06T12:00:00.000Z", + updated_at: "2026-08-06T12:00:00.000Z", + }; + } + + function rawWork(contentId: string) { + return ctx.db + .selectFrom("_emdash_media_usage_work") + .selectAll() + .where("collection_id", "=", collectionId) + .where("content_id", "=", contentId) + .executeTakeFirstOrThrow(); + } + + function statusRow() { + return ctx.db + .selectFrom("_emdash_media_usage_index_status") + .selectAll() + .where("collection_id", "=", collectionId) + .executeTakeFirstOrThrow(); + } + + async function removeCollectionAfterCoverageInvalidation(): Promise { + await sql` + CREATE TRIGGER retry_collection_removed + AFTER UPDATE OF change_epoch ON _emdash_media_usage_index_status + BEGIN + DELETE FROM _emdash_collections WHERE id = NEW.collection_id; + END + `.execute(ctx.db); + } +}); + +const STATES = ["pending", "retry", "leased", "failed"] as const; diff --git a/packages/core/tests/integration/database/media-usage-work-processor.test.ts b/packages/core/tests/integration/database/media-usage-work-processor.test.ts new file mode 100644 index 0000000000..c0e4bfa314 --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-work-processor.test.ts @@ -0,0 +1,464 @@ +import type { + Kysely, + KyselyPlugin, + PluginTransformQueryArgs, + PluginTransformResultArgs, + QueryResult, + RootOperationNode, + UnknownRow, +} from "kysely"; +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import type { Database } from "../../../src/database/types.js"; +import { installMediaUsageCaptureTriggers } from "../../../src/media/usage/capture-triggers.js"; +import { + MEDIA_USAGE_WORK_PROCESSING_LIMITS, + processDueMediaUsageWork, + processMediaUsageWorkAfterWrite, +} from "../../../src/media/usage/work-processor.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("media usage durable work processing", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("claims and completes the saved entry's durable job immediately", async () => { + const fixture = await createActiveFixture(ctx, "posts"); + await insertEntry(ctx, fixture, "entry-1", "media-1"); + expect(await findCoverageStatus(ctx.db, fixture.collectionId)).toEqual( + expect.objectContaining({ status: "stale", reconciliation_required: 0 }), + ); + + const result = await processMediaUsageWorkAfterWrite(ctx.db, "posts", "entry-1"); + + expect(result.outcome).toBe("completed"); + expect(await countWork(ctx.db)).toBe(0); + const source = await new MediaUsageRepository(ctx.db).findSource( + canonicalSourceKey(fixture.collectionId, "entry-1"), + ); + expect(source).toEqual( + expect.objectContaining({ + collectionId: fixture.collectionId, + collectionSlug: "posts", + contentId: "entry-1", + identityVersion: 1, + }), + ); + expect(await findCoverageStatus(ctx.db, fixture.collectionId)).toEqual( + expect.objectContaining({ + status: "complete", + reconciliation_required: 0, + last_incremental_success_at: expect.any(String), + last_error_code: null, + }), + ); + }); + + it("does not create complete coverage from an untrusted incremental success", async () => { + const fixture = await createActiveFixture(ctx, "untrusted"); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ status: "never", reconciliation_required: 1 }) + .where("collection_id", "=", fixture.collectionId) + .execute(); + await insertEntry(ctx, fixture, "entry-1", "media-1"); + + const result = await processMediaUsageWorkAfterWrite(ctx.db, "untrusted", "entry-1"); + + expect(result.outcome).toBe("completed"); + expect(await countWork(ctx.db)).toBe(0); + expect(await findCoverageStatus(ctx.db, fixture.collectionId)).toEqual( + expect.objectContaining({ + status: "never", + reconciliation_required: 1, + last_incremental_success_at: expect.any(String), + }), + ); + }); + + it("does not publish an obsolete terminal failure after newer work arrives", async () => { + const fixture = await createActiveFixture(ctx, "failure_race"); + await insertEntry(ctx, fixture, "entry-1", "media-1"); + const failedVersion = await findWork(ctx.db); + await ctx.db + .updateTable("_emdash_media_usage_work") + .set({ state: "failed", last_error_code: "OBSOLETE_FAILURE" }) + .where("collection_id", "=", fixture.collectionId) + .where("content_id", "=", "entry-1") + .where("work_version", "=", failedVersion.work_version) + .execute(); + await sql` + UPDATE ${sql.ref(fixture.tableName)} + SET title = 'newer projection' + WHERE id = 'entry-1' + `.execute(ctx.db); + + const recorded = await new MediaUsageRepository(ctx.db).recordIncrementalFailure({ + collectionId: fixture.collectionId, + collectionSlug: fixture.collectionSlug, + contentId: "entry-1", + workVersion: failedVersion.work_version, + errorCode: "OBSOLETE_FAILURE", + }); + + expect(recorded).toBe(false); + expect(await findWork(ctx.db)).toEqual( + expect.objectContaining({ + state: "pending", + work_version: expect.toSatisfy( + (value) => Number(value) === Number(failedVersion.work_version) + 1, + ), + last_error_code: null, + }), + ); + expect(await findCoverageStatus(ctx.db, fixture.collectionId)).toEqual( + expect.objectContaining({ status: "stale" }), + ); + }); + + it("bounds each scheduled tick and leaves the backlog durable", async () => { + const fixture = await createActiveFixture(ctx, "articles"); + for (let index = 0; index < 3; index++) { + await insertEntry(ctx, fixture, `entry-${index}`, `media-${index}`); + } + + const result = await processDueMediaUsageWork(ctx.db); + + expect(result.candidateCount).toBe(3); + expect(result.claimedCount).toBe(MEDIA_USAGE_WORK_PROCESSING_LIMITS.jobsPerTick); + expect(result.completedCount).toBe(MEDIA_USAGE_WORK_PROCESSING_LIMITS.jobsPerTick); + expect(await countWork(ctx.db)).toBe(3 - MEDIA_USAGE_WORK_PROCESSING_LIMITS.jobsPerTick); + expect(await findCoverageStatus(ctx.db, fixture.collectionId)).toEqual( + expect.objectContaining({ status: "stale" }), + ); + + await processDueMediaUsageWork(ctx.db); + await processDueMediaUsageWork(ctx.db); + expect(await countWork(ctx.db)).toBe(0); + expect(await findCoverageStatus(ctx.db, fixture.collectionId)).toEqual( + expect.objectContaining({ status: "complete" }), + ); + }); + + it("lets only one overlapping fast path own the job", async () => { + const fixture = await createActiveFixture(ctx, "notes"); + await insertEntry(ctx, fixture, "entry-1", "media-1"); + + const outcomes = await Promise.all([ + processMediaUsageWorkAfterWrite(ctx.db, "notes", "entry-1"), + processMediaUsageWorkAfterWrite(ctx.db, "notes", "entry-1"), + ]); + + expect(outcomes.filter((result) => result.outcome === "completed")).toHaveLength(1); + expect(await countWork(ctx.db)).toBe(0); + const source = await new MediaUsageRepository(ctx.db).findSource( + canonicalSourceKey(fixture.collectionId, "entry-1"), + ); + expect(source).not.toBeNull(); + }); + + it("keeps newer work after projection and redelivers it as a no-op", async () => { + const fixture = await createActiveFixture(ctx, "pages"); + await insertEntry(ctx, fixture, "entry-1", "media-1"); + await installProjectionSupersessionTrigger(ctx, "entry-1"); + + const stale = await processMediaUsageWorkAfterWrite(ctx.db, "pages", "entry-1"); + expect(stale.outcome).toBe("superseded"); + const sourceBefore = await new MediaUsageRepository(ctx.db).findSource( + canonicalSourceKey(fixture.collectionId, "entry-1"), + ); + expect(sourceBefore).not.toBeNull(); + expect(await countWork(ctx.db)).toBe(1); + + await removeProjectionSupersessionTrigger(ctx); + const redelivery = await processMediaUsageWorkAfterWrite(ctx.db, "pages", "entry-1"); + expect(redelivery.outcome).toBe("completed"); + expect( + ( + await new MediaUsageRepository(ctx.db).findSource( + canonicalSourceKey(fixture.collectionId, "entry-1"), + ) + )?.currentGeneration, + ).toBe(sourceBefore?.currentGeneration); + expect(await countWork(ctx.db)).toBe(0); + }); + + it("retries snapshot failures and retains the terminal failed row", async () => { + const fixture = await createActiveFixture(ctx, "news"); + await insertEntry(ctx, fixture, "entry-1", "media-1"); + await sql` + INSERT INTO revisions (id, collection, entry_id, data, author_id) + VALUES ('broken-revision', 'news', 'entry-1', '{', NULL) + `.execute(ctx.db); + await sql` + UPDATE ${sql.ref(fixture.tableName)} + SET draft_revision_id = 'broken-revision' + WHERE id = 'entry-1' + `.execute(ctx.db); + + const retry = await processMediaUsageWorkAfterWrite(ctx.db, "news", "entry-1"); + expect(retry.outcome).toBe("retry"); + expect(await findWork(ctx.db)).toEqual( + expect.objectContaining({ + state: "retry", + attempt_count: 1, + last_error_code: "MEDIA_USAGE_SNAPSHOT_FAILED", + }), + ); + expect(await findCoverageStatus(ctx.db, fixture.collectionId)).toEqual( + expect.objectContaining({ status: "stale" }), + ); + + await ctx.db + .updateTable("_emdash_media_usage_work") + .set({ + state: "pending", + attempt_count: MEDIA_USAGE_WORK_PROCESSING_LIMITS.maxAttempts - 1, + next_attempt_at: "2000-01-01T00:00:00.000Z", + }) + .execute(); + const failed = await processMediaUsageWorkAfterWrite(ctx.db, "news", "entry-1"); + expect(failed.outcome).toBe("failed"); + expect(await findWork(ctx.db)).toEqual( + expect.objectContaining({ + state: "failed", + attempt_count: MEDIA_USAGE_WORK_PROCESSING_LIMITS.maxAttempts, + last_error_code: "MEDIA_USAGE_SNAPSHOT_FAILED", + }), + ); + expect(await findCoverageStatus(ctx.db, fixture.collectionId)).toEqual( + expect.objectContaining({ + status: "partial", + reconciliation_required: 0, + last_error_code: "MEDIA_USAGE_SNAPSHOT_FAILED", + }), + ); + }); + + it("reconciles permanent entry absence without leaving work", async () => { + const fixture = await createActiveFixture(ctx, "documents"); + await insertEntry(ctx, fixture, "entry-1", "media-1"); + await processMediaUsageWorkAfterWrite(ctx.db, "documents", "entry-1"); + const sourceKey = canonicalSourceKey(fixture.collectionId, "entry-1"); + expect(await new MediaUsageRepository(ctx.db).findSource(sourceKey)).not.toBeNull(); + + await sql`DELETE FROM ${sql.ref(fixture.tableName)} WHERE id = 'entry-1'`.execute(ctx.db); + const result = await processMediaUsageWorkAfterWrite(ctx.db, "documents", "entry-1"); + + expect(result.outcome).toBe("completed"); + expect(await new MediaUsageRepository(ctx.db).findSource(sourceKey)).toBeNull(); + expect(await countWork(ctx.db)).toBe(0); + }); + + it("discards obsolete work without projecting into a replacement collection", async () => { + const fixture = await createActiveFixture(ctx, "reused_slug"); + await insertEntry(ctx, fixture, "entry-1", "media-1"); + await ctx.db.deleteFrom("_emdash_collections").where("id", "=", fixture.collectionId).execute(); + await ctx.db + .insertInto("_emdash_collections") + .values({ id: "replacement-collection-id", slug: "reused_slug", label: "Replacement" }) + .execute(); + + const result = await processDueMediaUsageWork(ctx.db); + + expect(result.obsoleteCount).toBe(1); + expect(await countWork(ctx.db)).toBe(0); + expect( + await new MediaUsageRepository(ctx.db).findSource( + canonicalSourceKey(fixture.collectionId, "entry-1"), + ), + ).toBeNull(); + }); + + it("keeps an ordinary job inside the exported statement envelope", async () => { + const fixture = await createActiveFixture(ctx, "measured"); + await insertEntry(ctx, fixture, "entry-1", "media-1"); + const counter = new QueryCountingPlugin(); + + const result = await processMediaUsageWorkAfterWrite( + ctx.db.withPlugin(counter), + "measured", + "entry-1", + ); + + expect(result.outcome).toBe("completed"); + expect(counter.count).toBeGreaterThan(0); + expect(counter.count).toBeLessThanOrEqual( + MEDIA_USAGE_WORK_PROCESSING_LIMITS.ordinaryStatementsPerJob, + ); + }); +}); + +class QueryCountingPlugin implements KyselyPlugin { + count = 0; + + transformQuery(args: PluginTransformQueryArgs): RootOperationNode { + this.count++; + return args.node; + } + + transformResult(args: PluginTransformResultArgs): Promise> { + return Promise.resolve(args.result); + } +} + +async function createActiveFixture(ctx: DialectTestContext, collectionSlug: string) { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: collectionSlug, label: collectionSlug }); + await registry.createField(collectionSlug, { slug: "title", label: "Title", type: "string" }); + await registry.createField(collectionSlug, { slug: "hero", label: "Hero", type: "image" }); + const collection = await registry.getCollection(collectionSlug); + if (!collection) throw new Error(`Expected ${collectionSlug} collection`); + + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ + collection_id: collection.id, + status: "complete", + completed_at: "2026-08-01T00:00:00.000Z", + reconciliation_required: 0, + capture_state: "installing", + }) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", collectionSlug) + .execute(); + await installMediaUsageCaptureTriggers(ctx.db, { + collectionId: collection.id, + collectionSlug, + }); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", collection.id) + .execute(); + await ctx.db + .updateTable("_emdash_media_usage_activation") + .set({ state: "active", activated_at: "2026-08-05T00:00:00.000Z" }) + .execute(); + + return { + collectionId: collection.id, + collectionSlug, + tableName: `ec_${collectionSlug}`, + }; +} + +async function insertEntry( + ctx: DialectTestContext, + fixture: Awaited>, + contentId: string, + mediaId: string, +): Promise { + await sql` + INSERT INTO ${sql.ref(fixture.tableName)} (id, slug, status, title, hero) + VALUES ( + ${contentId}, + ${contentId}, + 'published', + ${contentId}, + ${JSON.stringify({ id: mediaId, provider: "local", mimeType: "image/webp" })} + ) + `.execute(ctx.db); +} + +function canonicalSourceKey( + collectionId: string, + contentId: string, + sourceVariant = "columns", +): string { + return `content:${collectionId}:${contentId}:${sourceVariant}`; +} + +async function countWork(db: Kysely): Promise { + const result = await db + .selectFrom("_emdash_media_usage_work") + .select((eb) => eb.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + return Number(result.count); +} + +async function findWork(db: Kysely) { + return db.selectFrom("_emdash_media_usage_work").selectAll().executeTakeFirstOrThrow(); +} + +async function findCoverageStatus(db: Kysely, collectionId: string) { + return db + .selectFrom("_emdash_media_usage_index_status") + .selectAll() + .where("collection_id", "=", collectionId) + .executeTakeFirstOrThrow(); +} + +async function installProjectionSupersessionTrigger( + ctx: DialectTestContext, + contentId: string, +): Promise { + if (ctx.dialect === "postgres") { + await sql` + CREATE OR REPLACE FUNCTION emdash_test_supersede_media_usage_work() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + UPDATE _emdash_media_usage_work + SET work_version = work_version + 1, + state = 'pending', + lease_token = NULL, + lease_expires_at = NULL, + next_attempt_at = updated_at + WHERE content_id = ${sql.lit(contentId)}; + RETURN NEW; + END; + $$ + `.execute(ctx.db); + await sql` + CREATE TRIGGER emdash_test_supersede_media_usage_work + AFTER INSERT ON _emdash_media_usage_sources + FOR EACH ROW EXECUTE FUNCTION emdash_test_supersede_media_usage_work() + `.execute(ctx.db); + return; + } + + await sql` + CREATE TRIGGER emdash_test_supersede_media_usage_work + AFTER INSERT ON _emdash_media_usage_sources + FOR EACH ROW + BEGIN + UPDATE _emdash_media_usage_work + SET work_version = work_version + 1, + state = 'pending', + lease_token = NULL, + lease_expires_at = NULL, + next_attempt_at = updated_at + WHERE content_id = ${sql.lit(contentId)}; + END + `.execute(ctx.db); +} + +async function removeProjectionSupersessionTrigger(ctx: DialectTestContext): Promise { + if (ctx.dialect === "postgres") { + await sql` + DROP TRIGGER emdash_test_supersede_media_usage_work + ON _emdash_media_usage_sources + `.execute(ctx.db); + await sql`DROP FUNCTION emdash_test_supersede_media_usage_work()`.execute(ctx.db); + return; + } + await sql`DROP TRIGGER emdash_test_supersede_media_usage_work`.execute(ctx.db); +} diff --git a/packages/core/tests/integration/database/media-usage-work-repository.test.ts b/packages/core/tests/integration/database/media-usage-work-repository.test.ts new file mode 100644 index 0000000000..5ac94fdc72 --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-work-repository.test.ts @@ -0,0 +1,377 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { MediaUsageWorkRepository } from "../../../src/database/repositories/media-usage-work.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("media usage work repository", (dialect) => { + let ctx: DialectTestContext; + let repo: MediaUsageWorkRepository; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + repo = new MediaUsageWorkRepository(ctx.db); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("allows only one owner to claim an exact due work version", async () => { + await insertWork(ctx, { nextAttemptAt: "2000-01-01T00:00:00.000Z" }); + + const claims = await Promise.all([ + repo.claimWork({ + collectionId: "collection-1", + contentId: "entry-1", + workVersion: 1, + leaseDurationSeconds: 60, + }), + repo.claimWork({ + collectionId: "collection-1", + contentId: "entry-1", + workVersion: 1, + leaseDurationSeconds: 60, + }), + ]); + + const winners = claims.filter((workClaim) => workClaim !== null); + expect(winners).toHaveLength(1); + expect(winners[0]).toEqual( + expect.objectContaining({ + collectionId: "collection-1", + contentId: "entry-1", + workVersion: expect.toSatisfy((value) => Number(value) === 1), + state: "leased", + }), + ); + expect(winners[0]?.leaseToken).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + expect(winners[0]?.leaseExpiresAt).toBeTruthy(); + expect(winners[0]?.lastAttemptedAt).toBeTruthy(); + expect(winners[0]!.leaseExpiresAt! > winners[0]!.lastAttemptedAt!).toBe(true); + }); + + it("does not claim future, failed, or stale-version work", async () => { + await insertWork(ctx, { nextAttemptAt: "2999-01-01T00:00:00.000Z" }); + expect(await claimWork(repo, 1)).toBeNull(); + + await ctx.db + .updateTable("_emdash_media_usage_work") + .set({ state: "failed", next_attempt_at: "2000-01-01T00:00:00.000Z" }) + .execute(); + expect(await claimWork(repo, 1)).toBeNull(); + + await ctx.db + .updateTable("_emdash_media_usage_work") + .set({ state: "pending", work_version: 2 }) + .execute(); + expect(await claimWork(repo, 1)).toBeNull(); + expect(await claimWork(repo, 2)).not.toBeNull(); + }); + + it("lets an expired lease be taken over and fences the old owner", async () => { + await insertWork(ctx, { + state: "leased", + nextAttemptAt: "2000-01-01T00:00:00.000Z", + leaseToken: "old-owner", + leaseExpiresAt: "2000-01-01T00:01:00.000Z", + }); + + const replacement = await claimWork(repo, 1); + expect(replacement?.leaseToken).toBeTruthy(); + expect(replacement?.leaseToken).not.toBe("old-owner"); + expect(await repo.completeWork(lease("old-owner", 1))).toBe(false); + expect( + await repo.retryWork({ + ...lease("old-owner", 1), + retryDelaySeconds: 1, + errorCode: "TRANSIENT_DATABASE_FAILURE", + }), + ).toBe(false); + expect( + await repo.failWork({ + ...lease("old-owner", 1), + errorCode: "INVARIANT_FAILURE", + }), + ).toBe(false); + expect(await repo.completeWork(lease(replacement!.leaseToken!, 1))).toBe(true); + expect(await readWork(ctx)).toBeUndefined(); + }); + + it("cannot acknowledge or rewrite newer work created during a lease", async () => { + await insertWork(ctx, { nextAttemptAt: "2000-01-01T00:00:00.000Z" }); + const staleClaim = await claimWork(repo, 1); + expect(staleClaim).not.toBeNull(); + + await ctx.db + .updateTable("_emdash_media_usage_work") + .set({ + work_version: 2, + change_epoch: 2, + state: "pending", + attempt_count: 0, + next_attempt_at: "2000-01-01T00:00:00.000Z", + lease_token: null, + lease_expires_at: null, + last_error_code: null, + }) + .execute(); + + expect(await repo.completeWork(lease(staleClaim!.leaseToken!, 1))).toBe(false); + expect( + await repo.retryWork({ + ...lease(staleClaim!.leaseToken!, 1), + retryDelaySeconds: 1, + errorCode: "TRANSIENT_DATABASE_FAILURE", + }), + ).toBe(false); + expect( + await repo.failWork({ + ...lease(staleClaim!.leaseToken!, 1), + errorCode: "INVARIANT_FAILURE", + }), + ).toBe(false); + expect(await readWork(ctx)).toEqual( + expect.objectContaining({ + work_version: expect.toSatisfy((value) => Number(value) === 2), + change_epoch: expect.toSatisfy((value) => Number(value) === 2), + state: "pending", + lease_token: null, + last_error_code: null, + }), + ); + }); + + it("conditionally retries and terminally fails only a live lease", async () => { + await insertWork(ctx, { nextAttemptAt: "2000-01-01T00:00:00.000Z" }); + const retryClaim = await claimWork(repo, 1); + expect(retryClaim).not.toBeNull(); + + expect( + await repo.retryWork({ + ...lease(retryClaim!.leaseToken!, 1), + retryDelaySeconds: 60, + errorCode: "SNAPSHOT_FAILURE", + }), + ).toBe(true); + const retry = await readWork(ctx); + expect(retry).toEqual( + expect.objectContaining({ + state: "retry", + attempt_count: 1, + lease_token: null, + lease_expires_at: null, + last_error_code: "SNAPSHOT_FAILURE", + }), + ); + expect(retry!.next_attempt_at > retry!.updated_at).toBe(true); + expect(await claimWork(repo, 1)).toBeNull(); + + await ctx.db + .updateTable("_emdash_media_usage_work") + .set({ next_attempt_at: "2000-01-01T00:00:00.000Z" }) + .execute(); + const failureClaim = await claimWork(repo, 1); + expect(failureClaim).not.toBeNull(); + expect( + await repo.failWork({ + ...lease(failureClaim!.leaseToken!, 1), + errorCode: "RESOURCE_LIMIT", + }), + ).toBe(true); + expect(await readWork(ctx)).toEqual( + expect.objectContaining({ + state: "failed", + attempt_count: 2, + lease_token: null, + lease_expires_at: null, + last_error_code: "RESOURCE_LIMIT", + }), + ); + expect(await claimWork(repo, 1)).toBeNull(); + }); + + it("rejects every stale transition after lease expiry before takeover", async () => { + await insertWork(ctx, { nextAttemptAt: "2000-01-01T00:00:00.000Z" }); + const expiredClaim = await claimWork(repo, 1); + expect(expiredClaim).not.toBeNull(); + await ctx.db + .updateTable("_emdash_media_usage_work") + .set({ lease_expires_at: "2000-01-01T00:00:00.000Z" }) + .execute(); + + const expiredLease = lease(expiredClaim!.leaseToken!, 1); + expect(await repo.completeWork(expiredLease)).toBe(false); + expect( + await repo.retryWork({ + ...expiredLease, + retryDelaySeconds: 1, + errorCode: "TRANSIENT_DATABASE_FAILURE", + }), + ).toBe(false); + expect(await repo.failWork({ ...expiredLease, errorCode: "INVARIANT_FAILURE" })).toBe(false); + expect(await readWork(ctx)).toEqual( + expect.objectContaining({ state: "leased", lease_token: expiredClaim!.leaseToken }), + ); + expect(await claimWork(repo, 1)).not.toBeNull(); + }); + + it("rejects non-portable lease durations without changing work", async () => { + await insertWork(ctx, { nextAttemptAt: "2000-01-01T00:00:00.000Z" }); + + await expect( + repo.claimWork({ + collectionId: "collection-1", + contentId: "entry-1", + workVersion: 1, + leaseDurationSeconds: 0, + }), + ).rejects.toThrow(/positive whole number/i); + expect(await readWork(ctx)).toEqual( + expect.objectContaining({ state: "pending", lease_token: null }), + ); + await expect( + repo.claimWork({ + collectionId: "collection-1", + contentId: "entry-1", + workVersion: 0, + leaseDurationSeconds: 60, + }), + ).rejects.toThrow(/work version/i); + }); + + it("rejects raw error text without releasing a live lease", async () => { + await insertWork(ctx, { nextAttemptAt: "2000-01-01T00:00:00.000Z" }); + const workClaim = await claimWork(repo, 1); + expect(workClaim).not.toBeNull(); + + await expect( + repo.retryWork({ + ...lease(workClaim!.leaseToken!, 1), + retryDelaySeconds: 1, + errorCode: "database connection failed for customer data", + }), + ).rejects.toThrow(/stable SCREAMING_SNAKE_CASE/i); + expect(await readWork(ctx)).toEqual( + expect.objectContaining({ + state: "leased", + lease_token: workClaim!.leaseToken, + attempt_count: 0, + last_error_code: null, + }), + ); + }); + + it("finds only the current collection instance for a post-write lookup", async () => { + await insertWork(ctx, { + collectionId: "obsolete-collection", + nextAttemptAt: "2000-01-01T00:00:00.000Z", + updatedAt: "2099-01-01T00:00:00.000Z", + }); + await ctx.db + .insertInto("_emdash_collections") + .values({ id: "current-collection", slug: "posts", label: "Posts" }) + .execute(); + await insertWork(ctx, { + collectionId: "current-collection", + nextAttemptAt: "2000-01-01T00:00:00.000Z", + updatedAt: "2000-01-01T00:00:00.000Z", + }); + + const work = await repo.findWorkForContent("posts", "entry-1"); + + expect(work?.collectionId).toBe("current-collection"); + }); + + it("selects due pending, retry, and expired-lease work in eligibility order", async () => { + await insertWork(ctx, { + contentId: "pending-due", + nextAttemptAt: "2001-01-01T00:00:00.000Z", + }); + await insertWork(ctx, { + contentId: "retry-due", + state: "retry", + nextAttemptAt: "2000-01-01T00:00:00.000Z", + }); + await insertWork(ctx, { + contentId: "lease-expired", + state: "leased", + nextAttemptAt: "2999-01-01T00:00:00.000Z", + leaseToken: "expired-owner", + leaseExpiresAt: "1999-01-01T00:00:00.000Z", + }); + await insertWork(ctx, { + contentId: "pending-future", + nextAttemptAt: "2999-01-01T00:00:00.000Z", + }); + await insertWork(ctx, { + contentId: "failed", + state: "failed", + nextAttemptAt: "1998-01-01T00:00:00.000Z", + }); + + const due = await repo.findDueWork(10); + + expect(due.map((work) => work.contentId)).toEqual([ + "lease-expired", + "retry-due", + "pending-due", + ]); + }); +}); + +function claimWork(repo: MediaUsageWorkRepository, workVersion: number) { + return repo.claimWork({ + collectionId: "collection-1", + contentId: "entry-1", + workVersion, + leaseDurationSeconds: 60, + }); +} + +function lease(leaseToken: string, workVersion: number) { + return { + collectionId: "collection-1", + contentId: "entry-1", + workVersion, + leaseToken, + }; +} + +async function insertWork( + ctx: DialectTestContext, + input: { + collectionId?: string; + contentId?: string; + state?: string; + nextAttemptAt: string; + leaseToken?: string | null; + leaseExpiresAt?: string | null; + updatedAt?: string; + }, +): Promise { + await ctx.db + .insertInto("_emdash_media_usage_work") + .values({ + collection_id: input.collectionId ?? "collection-1", + collection_slug: "posts", + content_id: input.contentId ?? "entry-1", + change_epoch: 1, + work_version: 1, + state: input.state ?? "pending", + attempt_count: 0, + next_attempt_at: input.nextAttemptAt, + lease_token: input.leaseToken ?? null, + lease_expires_at: input.leaseExpiresAt ?? null, + updated_at: input.updatedAt, + }) + .execute(); +} + +function readWork(ctx: DialectTestContext) { + return ctx.db.selectFrom("_emdash_media_usage_work").selectAll().executeTakeFirst(); +} diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 6c58d75357..15c74fcf33 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -152,6 +152,7 @@ describe("Database Migrations (Integration)", () => { "060_collection_admin_config", "061_media_usage_cleanup", "062_media_usage_cleanup_fence", + "063_media_usage_incremental_work", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/integration/runtime/media-usage-scheduled-driver.test.ts b/packages/core/tests/integration/runtime/media-usage-scheduled-driver.test.ts new file mode 100644 index 0000000000..2d535c4585 --- /dev/null +++ b/packages/core/tests/integration/runtime/media-usage-scheduled-driver.test.ts @@ -0,0 +1,170 @@ +import { randomUUID } from "node:crypto"; + +import Database from "better-sqlite3"; +import { sql, SqliteDialect } from "kysely"; +import { afterEach, describe, expect, it } from "vitest"; + +import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import { EmDashRuntime, type RuntimeDependencies } from "../../../src/emdash-runtime.js"; +import { installMediaUsageCaptureTriggers } from "../../../src/media/usage/capture-triggers.js"; +import type { CronScheduler, SystemCleanupFn } from "../../../src/plugins/scheduler/types.js"; + +describe("media usage scheduled drivers", () => { + let runtime: EmDashRuntime | null = null; + + afterEach(async () => { + await runtime?.stopCron(); + runtime = null; + }); + + it("drains bounded work from the Cloudflare scheduled entry point", async () => { + runtime = await EmDashRuntime.create(createDeps(null)); + const fixture = await activateCollection(runtime, "cloudflare_posts"); + await insertEntry(runtime, fixture.tableName, "entry-1"); + + await runtime.runScheduledTasks(); + + expect(await countWork(runtime)).toBe(0); + expect( + await new MediaUsageRepository(runtime.db).findSource( + canonicalSourceKey(fixture.collectionId, "entry-1"), + ), + ).not.toBeNull(); + }); + + it("drains bounded work from the Node timer maintenance callback", async () => { + const scheduler = new CapturingScheduler(); + runtime = await EmDashRuntime.create(createDeps(() => scheduler)); + const fixture = await activateCollection(runtime, "node_posts"); + await insertEntry(runtime, fixture.tableName, "entry-1"); + + await scheduler.runMaintenance(); + + expect(await countWork(runtime)).toBe(0); + expect( + await new MediaUsageRepository(runtime.db).findSource( + canonicalSourceKey(fixture.collectionId, "entry-1"), + ), + ).not.toBeNull(); + }); + + it("processes a trigger-created job before returning from an authenticated write", async () => { + runtime = await EmDashRuntime.create(createDeps(null)); + const fixture = await activateCollection(runtime, "fast_posts"); + + const result = await runtime.handleContentCreate("fast_posts", { + slug: "entry-1", + status: "published", + data: { title: "Entry 1" }, + }); + + expect(result.success).toBe(true); + const contentId = result.data?.item.id; + expect(contentId).toBeTruthy(); + expect(await countWork(runtime)).toBe(0); + expect( + await new MediaUsageRepository(runtime.db).findSource( + canonicalSourceKey(fixture.collectionId, contentId!), + ), + ).not.toBeNull(); + }); +}); + +class CapturingScheduler implements CronScheduler { + private maintenance: SystemCleanupFn | null = null; + + setSystemCleanup(fn: SystemCleanupFn): void { + this.maintenance = fn; + } + + start(): void {} + stop(): void {} + reschedule(): void {} + + async runMaintenance(): Promise { + if (!this.maintenance) throw new Error("Expected Node maintenance callback"); + await this.maintenance(); + } +} + +function createDeps(createScheduler: RuntimeDependencies["createScheduler"]): RuntimeDependencies { + return { + config: { + database: { + entrypoint: `test-media-usage-scheduler-${randomUUID()}`, + config: {}, + type: "sqlite", + }, + }, + plugins: [], + createDialect: () => new SqliteDialect({ database: new Database(":memory:") }), + createStorage: null, + createScheduler, + sandboxEnabled: false, + sandboxedPluginEntries: [], + createSandboxRunner: null, + }; +} + +async function activateCollection(runtime: EmDashRuntime, collectionSlug: string) { + await runtime.schemaRegistry.createCollection({ slug: collectionSlug, label: collectionSlug }); + await runtime.schemaRegistry.createField(collectionSlug, { + slug: "title", + label: "Title", + type: "string", + }); + const collection = await runtime.schemaRegistry.getCollection(collectionSlug); + if (!collection) throw new Error(`Expected ${collectionSlug} collection`); + + await runtime.db + .updateTable("_emdash_media_usage_index_status") + .set({ + collection_id: collection.id, + status: "complete", + completed_at: "2026-08-01T00:00:00.000Z", + reconciliation_required: 0, + capture_state: "installing", + }) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", collectionSlug) + .execute(); + await installMediaUsageCaptureTriggers(runtime.db, { + collectionId: collection.id, + collectionSlug, + }); + await runtime.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", collection.id) + .execute(); + await runtime.db + .updateTable("_emdash_media_usage_activation") + .set({ state: "active", activated_at: "2026-08-05T00:00:00.000Z" }) + .execute(); + + return { collectionId: collection.id, tableName: `ec_${collectionSlug}` }; +} + +async function insertEntry( + runtime: EmDashRuntime, + tableName: string, + contentId: string, +): Promise { + await sql` + INSERT INTO ${sql.ref(tableName)} (id, slug, status, title) + VALUES (${contentId}, ${contentId}, 'published', ${contentId}) + `.execute(runtime.db); +} + +async function countWork(runtime: EmDashRuntime): Promise { + const row = await runtime.db + .selectFrom("_emdash_media_usage_work") + .select((eb) => eb.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + return Number(row.count); +} + +function canonicalSourceKey(collectionId: string, contentId: string): string { + return `content:${collectionId}:${contentId}:columns`; +} diff --git a/packages/core/tests/integration/runtime/plugin-cron-route.test.ts b/packages/core/tests/integration/runtime/plugin-cron-route.test.ts index 4fe332836e..960fb641de 100644 --- a/packages/core/tests/integration/runtime/plugin-cron-route.test.ts +++ b/packages/core/tests/integration/runtime/plugin-cron-route.test.ts @@ -4,8 +4,10 @@ import Database from "better-sqlite3"; import { SqliteDialect } from "kysely"; import { describe, expect, it } from "vitest"; +import { createPublicPluginApiRouteHandler } from "../../../src/astro/public-plugin-api-routes.js"; import { EmDashRuntime, type RuntimeDependencies } from "../../../src/emdash-runtime.js"; import { definePlugin } from "../../../src/plugins/define-plugin.js"; +import { createRequestMetrics, runWithContext } from "../../../src/request-context.js"; function createDeps(onActivate: (hasCron: boolean) => void): RuntimeDependencies { const entrypoint = `test-plugin-cron-route-${randomUUID()}`; @@ -15,8 +17,18 @@ function createDeps(onActivate: (hasCron: boolean) => void): RuntimeDependencies definePlugin({ id: "cron-route", version: "1.0.0", + capabilities: ["content:write"], routes: { - status: { handler: async (ctx) => ({ hasCron: !!ctx.cron }) }, + status: { public: true, handler: async (ctx) => ({ hasCron: !!ctx.cron }) }, + write: { + public: true, + handler: async (ctx) => { + if (!ctx.content || !("create" in ctx.content)) { + throw new Error("Content write access unavailable"); + } + return ctx.content.create("posts", { slug: "plugin-write" }); + }, + }, }, hooks: { "plugin:activate": { @@ -57,4 +69,38 @@ describe("EmDashRuntime.handlePluginApiRoute — cron", () => { await runtime.stopCron(); } }); + + it("keeps public plugin reads query-free and fences only actual content writes", async () => { + const runtime = await EmDashRuntime.create(createDeps(() => undefined)); + try { + await runtime.db + .updateTable("_emdash_media_usage_activation") + .set({ state: "activating" }) + .where("task_key", "=", "incremental_capture") + .execute(); + const handler = createPublicPluginApiRouteHandler(runtime); + const metrics = createRequestMetrics(performance.now()); + + const readResult = await runWithContext({ editMode: false, metrics }, async () => + handler("cron-route", "GET", "/status", new Request("http://test.local/page")), + ); + expect(readResult).toMatchObject({ success: true, data: { hasCron: true } }); + expect(metrics.dbCount).toBe(0); + + const writeResult = await handler( + "cron-route", + "GET", + "/write", + new Request("http://test.local/page"), + ); + + expect(writeResult).toMatchObject({ + success: false, + status: 503, + error: { code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS" }, + }); + } finally { + await runtime.stopCron(); + } + }); }); diff --git a/packages/core/tests/integration/runtime/plugin-route-site-info.test.ts b/packages/core/tests/integration/runtime/plugin-route-site-info.test.ts index dee591136a..e41ba3a656 100644 --- a/packages/core/tests/integration/runtime/plugin-route-site-info.test.ts +++ b/packages/core/tests/integration/runtime/plugin-route-site-info.test.ts @@ -5,10 +5,10 @@ import { EmDashRuntime } from "../../../src/emdash-runtime.js"; import type { RuntimeDependencies } from "../../../src/emdash-runtime.js"; import { definePlugin } from "../../../src/plugins/define-plugin.js"; import { createHookPipeline } from "../../../src/plugins/hooks.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; -function buildRuntime(): EmDashRuntime { - // This route only reads site context, so it never touches the database. - const db = {} as never; +async function buildRuntime() { + const db = await setupTestDatabase(); const plugin = definePlugin({ id: "site-aware-route", version: "1.0.0", @@ -44,7 +44,7 @@ function buildRuntime(): EmDashRuntime { createSandboxRunner: null, }; - return new EmDashRuntime({ + const runtime = new EmDashRuntime({ db, storage: null, configuredPlugins: [plugin], @@ -64,29 +64,34 @@ function buildRuntime(): EmDashRuntime { runtimeDeps, pipelineRef, }); + return { db, runtime }; } describe("EmDashRuntime.handlePluginApiRoute site context", () => { it("passes configured site information to trusted plugin routes", async () => { - const runtime = buildRuntime(); - const result = await runtime.handlePluginApiRoute( - "site-aware-route", - "GET", - "/inspect", - new Request("https://admin.example.com/_emdash/api/plugin/site-aware-route/inspect"), - ); + const { db, runtime } = await buildRuntime(); + try { + const result = await runtime.handlePluginApiRoute( + "site-aware-route", + "GET", + "/inspect", + new Request("https://admin.example.com/_emdash/api/plugin/site-aware-route/inspect"), + ); - expect(result).toMatchObject({ - success: true, - data: { - site: { - name: "Example Site", - url: "https://example.com", - locale: "nl", - trailingSlash: "ignore", + expect(result).toMatchObject({ + success: true, + data: { + site: { + name: "Example Site", + url: "https://example.com", + locale: "nl", + trailingSlash: "ignore", + }, + url: "https://example.com/checkout/success", }, - url: "https://example.com/checkout/success", - }, - }); + }); + } finally { + await teardownTestDatabase(db); + } }); }); diff --git a/packages/core/tests/unit/api/media-usage-summary.test.ts b/packages/core/tests/unit/api/media-usage-summary.test.ts index 6ce84ecdc2..b14b339fc0 100644 --- a/packages/core/tests/unit/api/media-usage-summary.test.ts +++ b/packages/core/tests/unit/api/media-usage-summary.test.ts @@ -101,11 +101,13 @@ describe("media usage coverage aggregation", () => { collectionSlug: "posts", status, schemaVersion: status === null ? null : schemaVersion, + reconciliationRequired: false, }); it.each([ ["no collections", [], "complete"], ["all complete", [scope("complete")], "complete"], + ["reconciliation required", [{ ...scope("complete"), reconciliationRequired: true }], "stale"], ["all missing", [scope(null), { ...scope(null), collectionSlug: "pages" }], "never"], ["complete and missing", [scope("complete"), scope(null)], "partial"], ["old complete", [scope("complete", CONTENT_SOURCE_SCHEMA_VERSION - 1)], "stale"], @@ -246,6 +248,37 @@ describe("media usage summary handler and routes", () => { expect(queries.filter((query) => query.includes("visible_entries"))).toHaveLength(1); }); + it("returns unknown counts without querying entries while reconciliation is required", async () => { + await db + .updateTable("_emdash_media_usage_index_status") + .set({ status: "complete", reconciliation_required: 1 }) + .where("adapter_id", "=", CONTENT_MEDIA_USAGE_ADAPTER_ID) + .where("scope_type", "=", CONTENT_MEDIA_USAGE_COLLECTION_SCOPE) + .where("scope_key", "=", "posts") + .execute(); + queries = []; + + const result = await handleMediaUsageSummaries(db, [usedMedia.id, unusedMedia.id], { + includeCount: true, + }); + + expect(result).toEqual({ + success: true, + data: { + [usedMedia.id]: { + count: null, + coverage: { scope: "all_content_collections", status: "stale" }, + }, + [unusedMedia.id]: { + count: null, + coverage: { scope: "all_content_collections", status: "stale" }, + }, + }, + }); + expect(queries).toHaveLength(1); + expect(queries.some((query) => query.includes("visible_entries"))).toBe(false); + }); + it("chunks more than 50 media IDs without becoming N+1", async () => { const mediaIds = [ usedMedia.id, diff --git a/packages/core/tests/unit/api/media-usage-work-route.test.ts b/packages/core/tests/unit/api/media-usage-work-route.test.ts new file mode 100644 index 0000000000..3e37537d37 --- /dev/null +++ b/packages/core/tests/unit/api/media-usage-work-route.test.ts @@ -0,0 +1,304 @@ +import { Role, type RoleLevel } from "@emdash-cms/auth"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { injectCoreRoutes } from "../../../src/astro/integration/routes.js"; +import { GET } from "../../../src/astro/routes/api/admin/media-usage/work/index.js"; +import { POST } from "../../../src/astro/routes/api/admin/media-usage/work/retry.js"; +import { + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +type GetContext = Parameters[0]; + +interface ApiErrorBody { + error: { + code: string; + message: string; + details?: Record; + }; +} + +describe("admin media usage work routes", () => { + let ctx: DialectTestContext | undefined; + let collectionId: string; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections("sqlite"); + const collection = await ctx.db + .selectFrom("_emdash_collections") + .select(["id", "slug"]) + .where("slug", "=", "post") + .executeTakeFirstOrThrow(); + collectionId = collection.id; + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ + collection_id: collectionId, + capture_state: "active", + status: "complete", + completed_at: "2026-08-01T00:00:00.000Z", + reconciliation_required: 0, + }) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", "post") + .execute(); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + ctx = undefined; + }); + + it("registers the list and retry routes under the admin API prefix", () => { + const routes: Array<{ pattern: string; entrypoint: string }> = []; + injectCoreRoutes((route) => routes.push(route)); + + expect(routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pattern: "/_emdash/api/admin/media-usage/work", + entrypoint: expect.stringContaining("api/admin/media-usage/work/index"), + }), + expect.objectContaining({ + pattern: "/_emdash/api/admin/media-usage/work/retry", + entrypoint: expect.stringContaining("api/admin/media-usage/work/retry"), + }), + ]), + ); + }); + + it("requires authentication, schema permission, and admin token scope", async () => { + const request = listRequest("collection=post"); + + await expectError(await GET(routeContext(request, null)), 401, "UNAUTHORIZED"); + await expectError(await GET(routeContext(request, Role.EDITOR)), 403, "FORBIDDEN"); + await expectError( + await GET(routeContext(request, Role.ADMIN, ["content:read"])), + 403, + "INSUFFICIENT_SCOPE", + ); + }); + + it("returns a bounded redacted page with private no-store caching", async () => { + await insertWork({ + contentId: "entry-failed", + state: "failed", + workVersion: 9, + leaseToken: "private-lease-token", + lastErrorCode: "MEDIA_USAGE_PROCESSING_FAILED", + }); + + const response = await GET( + routeContext(listRequest("collection=post&state=failed&limit=1"), Role.ADMIN, ["admin"]), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + const body = (await response.json()) as { + data: { items: Array>; nextCursor?: string }; + }; + expect(body.data.items).toEqual([ + expect.objectContaining({ + collectionId, + collectionSlug: "post", + contentId: "entry-failed", + state: "failed", + attemptCount: 0, + lastErrorCode: "MEDIA_USAGE_PROCESSING_FAILED", + }), + ]); + expect(body.data.items[0]).not.toHaveProperty("leaseToken"); + expect(body.data.items[0]).not.toHaveProperty("workVersion"); + expect(body.data.items[0]).not.toHaveProperty("changeEpoch"); + expect(JSON.stringify(body)).not.toContain("private-lease-token"); + }); + + it("returns structured query and cursor errors", async () => { + await expectError( + await GET(routeContext(listRequest("collection=1bad"), Role.ADMIN)), + 400, + "VALIDATION_ERROR", + ); + await expectError( + await GET(routeContext(listRequest("collection=post&limit=101"), Role.ADMIN)), + 400, + "VALIDATION_ERROR", + ); + await expectError( + await GET(routeContext(listRequest("collection=post&cursor=not-a-cursor"), Role.ADMIN)), + 400, + "INVALID_CURSOR", + ); + }); + + it("returns collection not found without exposing a database error", async () => { + await expectError( + await GET(routeContext(listRequest("collection=missing"), Role.ADMIN)), + 404, + "COLLECTION_NOT_FOUND", + ); + }); + + it("requires permission and admin scope on retry independently of middleware", async () => { + const request = retryRequest({ collectionId, contentId: "entry-failed" }); + + await expectError(await POST(routeContext(request, null)), 401, "UNAUTHORIZED"); + await expectError(await POST(routeContext(request, Role.EDITOR)), 403, "FORBIDDEN"); + await expectError( + await POST(routeContext(request, Role.ADMIN, ["media:write"])), + 403, + "INSUFFICIENT_SCOPE", + ); + }); + + it("reopens one failed job without returning internal ownership fields", async () => { + await insertWork({ + contentId: "entry-failed", + state: "failed", + workVersion: 5, + leaseToken: "old-private-token", + lastErrorCode: "MEDIA_USAGE_PROCESSING_FAILED", + }); + + const response = await POST( + routeContext(retryRequest({ collectionId, contentId: "entry-failed" }), Role.ADMIN, [ + "admin", + ]), + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { changed: boolean; item: Record }; + }; + expect(body.data).toEqual({ + changed: true, + item: expect.objectContaining({ + collectionId, + contentId: "entry-failed", + state: "pending", + attemptCount: 0, + leaseExpiresAt: null, + lastErrorCode: null, + }), + }); + expect(JSON.stringify(body)).not.toContain("old-private-token"); + expect(body.data.item).not.toHaveProperty("workVersion"); + }); + + it("returns a stable redacted conflict for a live lease", async () => { + await insertWork({ + contentId: "entry-live", + state: "leased", + workVersion: 3, + leaseToken: "live-private-token", + leaseExpiresAt: "2100-01-01T00:00:00.000Z", + }); + + const response = await POST( + routeContext(retryRequest({ collectionId, contentId: "entry-live" }), Role.ADMIN), + ); + + expect(response.status).toBe(409); + const body = (await response.json()) as ApiErrorBody; + expect(body.error).toEqual({ + code: "WORK_LEASE_ACTIVE", + message: expect.any(String), + details: { leaseExpiresAt: "2100-01-01T00:00:00.000Z" }, + }); + expect(JSON.stringify(body)).not.toContain("live-private-token"); + }); + + it("rejects malformed and unknown retry identities", async () => { + await expectError( + await POST(routeContext(retryRequest({ collectionId: "", contentId: "entry" }), Role.ADMIN)), + 400, + "VALIDATION_ERROR", + ); + await expectError( + await POST( + routeContext( + retryRequest({ collectionId, contentId: "entry", unexpected: true }), + Role.ADMIN, + ), + ), + 400, + "VALIDATION_ERROR", + ); + await expectError( + await POST( + routeContext( + retryRequest({ collectionId: "missing-collection", contentId: "entry" }), + Role.ADMIN, + ), + ), + 404, + "COLLECTION_NOT_FOUND", + ); + }); + + async function insertWork(input: { + contentId: string; + state: "pending" | "retry" | "leased" | "failed"; + workVersion: number; + leaseToken?: string | null; + leaseExpiresAt?: string | null; + lastErrorCode?: string | null; + }): Promise { + await ctx!.db + .insertInto("_emdash_media_usage_work") + .values({ + collection_id: collectionId, + collection_slug: "post", + content_id: input.contentId, + change_epoch: 0, + work_version: input.workVersion, + state: input.state, + attempt_count: 0, + next_attempt_at: "2000-01-01T00:00:00.000Z", + lease_token: input.leaseToken ?? null, + lease_expires_at: input.leaseExpiresAt ?? null, + last_attempted_at: null, + last_error_code: input.lastErrorCode ?? null, + created_at: "2026-08-06T12:00:00.000Z", + updated_at: "2026-08-06T12:00:00.000Z", + }) + .execute(); + } + + function routeContext( + request: Request, + role: RoleLevel | null, + tokenScopes?: string[], + ): GetContext { + return { + request, + locals: { + emdash: { db: ctx!.db }, + user: role == null ? null : { id: "user-1", role }, + tokenScopes, + }, + } as GetContext; + } +}); + +async function expectError(response: Response, status: number, code: string): Promise { + expect(response.status).toBe(status); + const body = (await response.json()) as ApiErrorBody; + expect(body.error.code).toBe(code); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); +} + +function listRequest(query: string): Request { + return new Request(`http://localhost/_emdash/api/admin/media-usage/work?${query}`); +} + +function retryRequest(body: unknown): Request { + return new Request("http://localhost/_emdash/api/admin/media-usage/work/retry", { + method: "POST", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: JSON.stringify(body), + }); +} diff --git a/packages/core/tests/unit/api/openapi.test.ts b/packages/core/tests/unit/api/openapi.test.ts index 81a103500a..934af02595 100644 --- a/packages/core/tests/unit/api/openapi.test.ts +++ b/packages/core/tests/unit/api/openapi.test.ts @@ -283,6 +283,8 @@ describe("OpenAPI document generation", () => { expect(operationIds).toContain("deleteMedia"); expect(operationIds).toContain("getMediaUploadUrl"); expect(operationIds).toContain("repairMediaUsage"); + expect(operationIds).toContain("listMediaUsageWork"); + expect(operationIds).toContain("retryMediaUsageWork"); // Schema operations expect(operationIds).toContain("listCollections"); diff --git a/packages/core/tests/unit/astro/media-usage-write-fence.test.ts b/packages/core/tests/unit/astro/media-usage-write-fence.test.ts new file mode 100644 index 0000000000..26d8e778f9 --- /dev/null +++ b/packages/core/tests/unit/astro/media-usage-write-fence.test.ts @@ -0,0 +1,90 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("astro:middleware", () => ({ + defineMiddleware: (handler: unknown) => handler, +})); + +import { onRequest } from "../../../src/astro/middleware/media-usage-write-fence.js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +describe("media usage activation write fence", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it.each([ + "/_emdash/api/content/posts", + "/_emdash/api/schema/collections", + "/_emdash/api/admin/media-usage/repair", + "/_emdash/api/revisions/revision-1/restore", + "/_emdash/api/import/wordpress/execute", + "/_emdash/api/mcp", + ])("rejects a state-changing %s request while activation is incomplete", async (pathname) => { + await setActivationState("activating"); + const next = vi.fn(async () => new Response(null, { status: 204 })); + + const response = await invoke(pathname, "POST", next); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + success: false, + error: { + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + message: "Media usage activation is in progress", + }, + }); + expect(next).not.toHaveBeenCalled(); + }); + + it.each(["expanded", "active"])("allows content writes while activation is %s", async (state) => { + await setActivationState(state); + const next = vi.fn(async () => new Response(null, { status: 204 })); + + const response = await invoke("/_emdash/api/content/posts", "POST", next); + + expect(response.status).toBe(204); + expect(next).toHaveBeenCalledOnce(); + }); + + it("does not fence reads or unrelated writes", async () => { + await setActivationState("activating"); + const next = vi.fn(async () => new Response(null, { status: 204 })); + + expect((await invoke("/_emdash/api/content/posts", "GET", next)).status).toBe(204); + expect((await invoke("/_emdash/api/media", "POST", next)).status).toBe(204); + expect((await invoke("/_emdash/api/plugins/example/write", "POST", next)).status).toBe(204); + expect(next).toHaveBeenCalledTimes(3); + }); + + async function setActivationState(state: string): Promise { + await db + .updateTable("_emdash_media_usage_activation") + .set({ state }) + .where("task_key", "=", "incremental_capture") + .execute(); + } + + async function invoke( + pathname: string, + method: string, + next: () => Promise, + ): Promise { + const url = new URL(pathname, "https://example.com"); + return onRequest( + { + request: new Request(url, { method }), + url, + locals: { emdash: { db } }, + } as never, + next, + ); + } +}); diff --git a/packages/core/tests/unit/client/client.test.ts b/packages/core/tests/unit/client/client.test.ts index 9e809ab33e..0abce3351a 100644 --- a/packages/core/tests/unit/client/client.test.ts +++ b/packages/core/tests/unit/client/client.test.ts @@ -986,6 +986,141 @@ describe("EmDashClient", () => { }); }); + describe("media usage work operators", () => { + it("serializes a bounded work-list query and returns the cursor page", async () => { + let capturedUrl: URL | undefined; + const page = { + items: [ + { + collectionId: "collection-posts", + collectionSlug: "posts", + contentId: "entry-1", + state: "failed" as const, + attemptCount: 5, + nextAttemptAt: "2026-08-07T12:00:00.000Z", + leaseExpiresAt: null, + lastAttemptedAt: "2026-08-07T11:59:00.000Z", + lastErrorCode: "MEDIA_USAGE_PROCESSING_FAILED", + updatedAt: "2026-08-07T12:00:00.000Z", + }, + ], + nextCursor: "next / page", + }; + const backend: Interceptor = async (request) => { + capturedUrl = new URL(request.url); + expect(request.method).toBe("GET"); + return jsonResponse(page); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + const result = await client.mediaListUsageWork({ + collection: "posts", + state: "failed", + limit: 25, + cursor: "after / entry", + }); + + expect(capturedUrl?.pathname).toBe("/_emdash/api/admin/media-usage/work"); + expect(Object.fromEntries(capturedUrl?.searchParams ?? [])).toEqual({ + collection: "posts", + state: "failed", + limit: "25", + cursor: "after / entry", + }); + expect(result).toEqual(page); + }); + + it("sends an exact retry identity and returns the pending item", async () => { + let capturedBody: unknown; + const backend = createMockBackend([ + { + method: "POST", + path: "/admin/media-usage/work/retry", + handler: async (request) => { + expect(request.headers.get("X-EmDash-Request")).toBe("1"); + capturedBody = await request.json(); + return jsonResponse({ + changed: true, + item: { + collectionId: "collection-posts", + collectionSlug: "posts", + contentId: "entry-1", + state: "pending", + attemptCount: 0, + nextAttemptAt: "2026-08-07T12:00:00.000Z", + leaseExpiresAt: null, + lastAttemptedAt: null, + lastErrorCode: null, + updatedAt: "2026-08-07T12:00:00.000Z", + }, + }); + }, + }, + ]); + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + const result = await client.mediaRetryUsageWork({ + collectionId: "collection-posts", + contentId: "entry-1", + }); + + expect(capturedBody).toEqual({ + collectionId: "collection-posts", + contentId: "entry-1", + }); + expect(result).toEqual( + expect.objectContaining({ + changed: true, + item: expect.objectContaining({ state: "pending", attemptCount: 0 }), + }), + ); + }); + + it("propagates a lease conflict and its redacted retry time", async () => { + const backend = createMockBackend([ + { + method: "POST", + path: "/admin/media-usage/work/retry", + handler: () => + jsonResponse( + { + error: { + code: "WORK_LEASE_ACTIVE", + message: "Media usage work is currently leased", + details: { leaseExpiresAt: "2100-01-01T00:00:00.000Z" }, + }, + }, + 409, + ), + }, + ]); + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + await expect( + client.mediaRetryUsageWork({ + collectionId: "collection-posts", + contentId: "entry-1", + }), + ).rejects.toMatchObject>({ + status: 409, + code: "WORK_LEASE_ACTIVE", + details: { leaseExpiresAt: "2100-01-01T00:00:00.000Z" }, + }); + }); + }); + describe("PT <-> Markdown auto-conversion", () => { it("converts PT fields to markdown on get()", async () => { const backend = createMockBackend([ diff --git a/packages/core/tests/unit/media/usage-projection-fingerprint.test.ts b/packages/core/tests/unit/media/usage-projection-fingerprint.test.ts new file mode 100644 index 0000000000..efdd72b436 --- /dev/null +++ b/packages/core/tests/unit/media/usage-projection-fingerprint.test.ts @@ -0,0 +1,80 @@ +import { expect, it } from "vitest"; + +import { buildMediaUsageProjectionFingerprint } from "../../../src/media/usage/projection-fingerprint.js"; + +const source = { + sourceKey: "content:posts:entry-1:columns", + sourceType: "content", + collectionSlug: "posts", + contentId: "entry-1", + sourceVariant: "columns" as const, + locale: "en", + translationGroup: "translation-1", + contentSlug: "entry-1", + contentTitle: "Entry 1", + contentStatus: "published", + contentScheduledAt: null, + contentDeletedAt: null, + revisionId: null, + schemaVersion: 1, +}; +const occurrences = [ + { + fieldSlug: "gallery", + fieldPath: "gallery[1]", + occurrenceIndex: 1, + referenceType: "image_field" as const, + mediaId: "media-2", + provider: "local", + providerAssetId: "media-2", + mediaKind: "image" as const, + mimeType: "image/webp", + }, + { + fieldSlug: "gallery", + fieldPath: "gallery[0]", + occurrenceIndex: 0, + referenceType: "image_field" as const, + mediaId: "media-1", + provider: "local", + providerAssetId: "media-1", + mediaKind: "image" as const, + mimeType: "image/webp", + }, +]; +const extractionFields = [{ slug: "gallery", type: "image" as const }]; + +it("is order-independent but changes for every projection input class", async () => { + const baseline = await fingerprint(); + expect(await fingerprint({ occurrences: occurrences.toReversed() })).toBe(baseline); + expect(await fingerprint({ collectionId: "collection-2" })).not.toBe(baseline); + expect(await fingerprint({ source: { ...source, contentTitle: "Changed title" } })).not.toBe( + baseline, + ); + expect( + await fingerprint({ + occurrences: [{ ...occurrences[0]!, mediaId: "changed-media" }, occurrences[1]!], + }), + ).not.toBe(baseline); + expect( + await fingerprint({ + extractionFields: [...extractionFields, { slug: "hero", type: "image" as const }], + }), + ).not.toBe(baseline); +}); + +it("refuses to mint a current fingerprint without immutable collection identity", async () => { + await expect(fingerprint({ collectionId: "" })).rejects.toThrow(/collection identity/i); +}); + +function fingerprint( + overrides: Partial[0]> = {}, +) { + return buildMediaUsageProjectionFingerprint({ + collectionId: "collection-1", + source, + occurrences, + extractionFields, + ...overrides, + }); +} diff --git a/packages/core/tests/unit/scheduled-publish.test.ts b/packages/core/tests/unit/scheduled-publish.test.ts index ede5f8806f..09f1f79a01 100644 --- a/packages/core/tests/unit/scheduled-publish.test.ts +++ b/packages/core/tests/unit/scheduled-publish.test.ts @@ -258,6 +258,33 @@ describe("EmDashRuntime.runScheduledTasks()", () => { const updated = await repo.findById("post", post.id); expect(updated?.status).toBe("published"); }); + + it("leaves due content untouched while media usage activation is incomplete", async () => { + const post = await repo.create(createPostFixture()); + const past = new Date(Date.now() - 60_000).toISOString(); + await repo.update("post", post.id, { status: "scheduled", scheduledAt: past }); + await db + .updateTable("_emdash_media_usage_activation") + .set({ state: "activating" }) + .where("task_key", "=", "incremental_capture") + .execute(); + const runtime = buildRuntime(db); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(runtime.publishScheduled()).rejects.toMatchObject({ + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + status: 503, + }); + + await expect(runtime.runScheduledTasks()).resolves.toEqual({ published: [] }); + const unchanged = await repo.findById("post", post.id); + expect(unchanged?.status).toBe("scheduled"); + expect(unchanged?.scheduledAt).toBe(past); + } finally { + consoleError.mockRestore(); + } + }); }); describe("ContentRepository.publish() requireDue gate", () => { diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 9605a82cee..652240445e 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -78,6 +78,7 @@ export default defineConfig({ "src/astro/middleware.ts", "src/astro/middleware/setup.ts", "src/astro/middleware/auth.ts", + "src/astro/middleware/media-usage-write-fence.ts", "src/astro/middleware/redirect.ts", "src/astro/middleware/request-context.ts", "src/astro/types.ts", diff --git a/packages/workerd/src/sandbox/backing-service.ts b/packages/workerd/src/sandbox/backing-service.ts index dc117ff1d2..9a17100cfd 100644 --- a/packages/workerd/src/sandbox/backing-service.ts +++ b/packages/workerd/src/sandbox/backing-service.ts @@ -71,6 +71,7 @@ export function createBackingServiceHandler(runner: WorkerdSandboxRunner): Backi storageCollections: claims.storageCollections, storageConfig: runner.getPluginStorageConfig(claims.pluginId, claims.version), db: runner.db, + beforeContentWrite: runner.beforeContentWrite, emailSend: () => runner.emailSend, storage: runner.mediaStorage, }); diff --git a/packages/workerd/src/sandbox/bridge-handler.ts b/packages/workerd/src/sandbox/bridge-handler.ts index f9edb4189b..8b43fd5c5c 100644 --- a/packages/workerd/src/sandbox/bridge-handler.ts +++ b/packages/workerd/src/sandbox/bridge-handler.ts @@ -14,7 +14,12 @@ * must produce same outputs, same return shapes, same error messages. */ -import { createHttpAccess, createUnrestrictedHttpAccess, PluginStorageRepository } from "emdash"; +import { + createHttpAccess, + createSandboxRouteErrorEnvelope, + createUnrestrictedHttpAccess, + PluginStorageRepository, +} from "emdash"; import type { Database, SandboxEmailSendCallback } from "emdash"; import { sql, type Kysely, type RawBuilder } from "kysely"; @@ -97,6 +102,7 @@ export interface BridgeHandlerOptions { /** Full storage config (with indexes) for proper query/count delegation */ storageConfig?: Record; db: Kysely; + beforeContentWrite?: () => Promise; emailSend: () => SandboxEmailSendCallback | null; /** Storage for media uploads. Optional; media/upload throws if not provided. */ storage?: BridgeStorage | null; @@ -129,6 +135,13 @@ export function createBridgeHandler( const result = await dispatch(opts, method, body); return Response.json({ result }); } catch (error) { + const sandboxRouteError = createSandboxRouteErrorEnvelope(error); + if (sandboxRouteError) { + return Response.json( + { error: sandboxRouteError.error }, + { status: sandboxRouteError.error.status }, + ); + } const message = error instanceof Error ? error.message : "Internal error"; return new Response(JSON.stringify({ error: message }), { status: 500, @@ -167,9 +180,11 @@ async function dispatch( return contentList(db, requireString(body, "collection"), body); case "content/create": requireCapability(opts, "write:content"); + await opts.beforeContentWrite?.(); return contentCreate(db, requireString(body, "collection"), requireRecord(body, "data")); case "content/update": requireCapability(opts, "write:content"); + await opts.beforeContentWrite?.(); return contentUpdate( db, requireString(body, "collection"), @@ -178,9 +193,11 @@ async function dispatch( ); case "content/delete": requireCapability(opts, "write:content"); + await opts.beforeContentWrite?.(); return contentDelete(db, requireString(body, "collection"), requireString(body, "id")); case "content/createMany": requireCapability(opts, "write:content"); + await opts.beforeContentWrite?.(); return contentCreateMany( db, requireString(body, "collection"), @@ -188,6 +205,7 @@ async function dispatch( ); case "content/updateMany": requireCapability(opts, "write:content"); + await opts.beforeContentWrite?.(); return contentUpdateMany( db, requireString(body, "collection"), @@ -195,6 +213,7 @@ async function dispatch( ); case "content/deleteMany": requireCapability(opts, "write:content"); + await opts.beforeContentWrite?.(); return contentDeleteMany( db, requireString(body, "collection"), diff --git a/packages/workerd/src/sandbox/dev-runner.ts b/packages/workerd/src/sandbox/dev-runner.ts index fe1da7039b..5dc6b737da 100644 --- a/packages/workerd/src/sandbox/dev-runner.ts +++ b/packages/workerd/src/sandbox/dev-runner.ts @@ -23,6 +23,7 @@ import type { SandboxOptions, SerializedRequest, } from "emdash"; +import { createSandboxRouteError, getSandboxRouteErrorEnvelope } from "emdash"; const DEFAULT_WALL_TIME_MS = 30_000; import type { PluginManifest } from "emdash"; @@ -174,6 +175,7 @@ export class MiniflareDevRunner implements SandboxRunner { storageCollections: Object.keys(manifest.storage || {}), storageConfig: manifest.storage, db: this.options.db, + beforeContentWrite: this.options.beforeContentWrite, emailSend: () => this.emailSendCallback, storage: this.options.mediaStorage, }); @@ -298,6 +300,15 @@ class MiniflareDevPlugin implements SandboxedPluginInstance { }); if (!res.ok) { const text = await res.text(); + let envelope = null; + try { + envelope = getSandboxRouteErrorEnvelope(JSON.parse(text)); + } catch { + // The generic route error below preserves non-protocol failures. + } + if (envelope) { + throw createSandboxRouteError(envelope.error.code); + } throw new Error(`Plugin ${this.id} route ${routeName} failed: ${text}`); } return res.json(); diff --git a/packages/workerd/src/sandbox/runner.ts b/packages/workerd/src/sandbox/runner.ts index 7624485d3e..bfd7b7aea7 100644 --- a/packages/workerd/src/sandbox/runner.ts +++ b/packages/workerd/src/sandbox/runner.ts @@ -37,7 +37,11 @@ import type { } from "emdash"; import type { PluginManifest } from "emdash"; // @ts-ignore -- SandboxUnavailableError is a class export, not type-only -import { SandboxUnavailableError } from "emdash"; +import { + createSandboxRouteError, + getSandboxRouteErrorEnvelope, + SandboxUnavailableError, +} from "emdash"; import { createBackingServiceHandler } from "./backing-service.js"; import type { BackingServiceHandler } from "./backing-service.js"; @@ -869,6 +873,11 @@ export class WorkerdSandboxRunner implements SandboxRunner { return this.options.db; } + /** Get the pre-content-write activation guard */ + get beforeContentWrite() { + return this.options.beforeContentWrite; + } + /** Get the email send callback */ get emailSend() { return this.emailSendCallback; @@ -985,6 +994,15 @@ class WorkerdSandboxedPlugin implements SandboxedPluginInstance { }); if (!res.ok) { const text = await res.text(); + let envelope = null; + try { + envelope = getSandboxRouteErrorEnvelope(JSON.parse(text)); + } catch { + // The generic route error below preserves non-protocol failures. + } + if (envelope) { + throw createSandboxRouteError(envelope.error.code); + } throw new Error(`Plugin ${this.id} route ${routeName} failed: ${text}`); } return res.json(); diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts index b45be3fde6..d95676f0a6 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -57,6 +57,37 @@ const BACKING_URL = ${JSON.stringify(options.backingServiceUrl)}; const AUTH_TOKEN = ${JSON.stringify(options.authToken)}; const INVOKE_TOKEN = ${JSON.stringify(options.invokeToken)}; +function sandboxRouteErrorDetails(value) { + if (!value || typeof value !== "object") return null; + const code = + value.code === "MEDIA_USAGE_ACTIVATION_IN_PROGRESS" || + value.code === "MEDIA_USAGE_ACTIVATION_CHECK_FAILED" + ? value.code + : value.name === "MEDIA_USAGE_ACTIVATION_IN_PROGRESS" || + value.name === "MEDIA_USAGE_ACTIVATION_CHECK_FAILED" + ? value.name + : null; + if (!code || (value.status !== undefined && value.status !== 503)) return null; + return { + code, + message: + code === "MEDIA_USAGE_ACTIVATION_IN_PROGRESS" + ? "Media usage activation is in progress" + : "Unable to verify media usage activation state", + status: 503, + }; +} + +function sandboxRouteErrorResponse(error) { + const details = sandboxRouteErrorDetails(error); + return details + ? Response.json( + { __emdashSandboxRouteError: true, error: details }, + { status: details.status }, + ) + : null; +} + // ----------------------------------------------------------------------------- // Bridge - HTTP calls to Node backing service // ----------------------------------------------------------------------------- @@ -72,6 +103,18 @@ async function bridgeCall(method, body) { }); if (!res.ok) { const text = await res.text(); + try { + const payload = JSON.parse(text); + const details = sandboxRouteErrorDetails(payload?.error); + if (details) { + const error = Object.assign(new Error(details.message), details, { + name: details.code, + }); + throw error; + } + } catch (error) { + if (sandboxRouteErrorDetails(error)) throw error; + } throw new Error("Bridge call " + method + " failed: " + text); } const data = await res.json(); @@ -424,6 +467,8 @@ export default { ); return Response.json(result); } catch (err) { + const sandboxError = sandboxRouteErrorResponse(err); + if (sandboxError) return sandboxError; return new Response(err.message || "Route error", { status: 500 }); } } diff --git a/packages/workerd/test/bridge-handler.test.ts b/packages/workerd/test/bridge-handler.test.ts index c6dd9f6a2e..c8da8b8a07 100644 --- a/packages/workerd/test/bridge-handler.test.ts +++ b/packages/workerd/test/bridge-handler.test.ts @@ -11,7 +11,7 @@ import Database from "better-sqlite3"; import { Kysely, SqliteDialect } from "kysely"; -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createBridgeHandler } from "../src/sandbox/bridge-handler.js"; @@ -80,6 +80,7 @@ describe("Bridge Handler Conformance", () => { capabilities?: string[]; allowedHosts?: string[]; storageCollections?: string[]; + beforeContentWrite?: () => Promise; }) { return createBridgeHandler({ pluginId: "test-plugin", @@ -89,6 +90,7 @@ describe("Bridge Handler Conformance", () => { storageCollections: opts.storageCollections ?? [], db, emailSend: () => null, + beforeContentWrite: opts.beforeContentWrite, }); } @@ -607,6 +609,41 @@ describe("Bridge Handler Conformance", () => { // ── Batch transactionality ──────────────────────────────────────────── + it("checks the activation fence at the sandbox content-write boundary", async () => { + const beforeContentWrite = vi.fn(async () => { + throw Object.assign(new Error("Media usage activation is in progress"), { + name: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + status: 503, + }); + }); + const handler = makeHandler({ + capabilities: ["write:content"], + beforeContentWrite, + }); + + const response = await handler( + new Request("http://bridge/content/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + collection: "posts", + data: { slug: "blocked" }, + }), + }), + ); + + expect(response.status).toBe(503); + expect((await response.json()) as unknown).toEqual({ + error: { + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + message: "Media usage activation is in progress", + status: 503, + }, + }); + expect(beforeContentWrite).toHaveBeenCalledOnce(); + }); + describe("batch operations are transactional", () => { beforeEach(async () => { await db.schema diff --git a/packages/workerd/test/dev-runner-route-error.test.ts b/packages/workerd/test/dev-runner-route-error.test.ts new file mode 100644 index 0000000000..4ff85e7208 --- /dev/null +++ b/packages/workerd/test/dev-runner-route-error.test.ts @@ -0,0 +1,62 @@ +import { createSandboxRouteError } from "emdash"; +import { afterEach, describe, expect, it } from "vitest"; + +import { MiniflareDevRunner } from "../src/sandbox/dev-runner.js"; + +const CONTENT_WRITE_PLUGIN = ` +export default { + hooks: {}, + routes: { + "write": { + handler: async (_routeCtx, ctx) => ctx.content.create("posts", { slug: "blocked" }) + } + } +}; +`; + +describe("Miniflare sandbox route errors", () => { + let runner: MiniflareDevRunner | null = null; + + afterEach(async () => { + await runner?.terminateAll(); + }); + + it("preserves a content-write fence through the development sandbox", async () => { + runner = new MiniflareDevRunner({ + db: null as never, + beforeContentWrite: async () => { + throw createSandboxRouteError("MEDIA_USAGE_ACTIVATION_IN_PROGRESS"); + }, + }); + const plugin = await runner.load( + { + id: "content-writer", + version: "1.0.0", + capabilities: ["write:content"], + allowedHosts: [], + storage: {}, + hooks: [], + routes: [], + admin: {}, + }, + CONTENT_WRITE_PLUGIN, + ); + + await expect( + plugin.invokeRoute( + "write", + {}, + { + url: "https://example.com/_emdash/api/plugins/content-writer/write", + method: "POST", + headers: {}, + meta: { ip: null, userAgent: null, referer: null, geo: null }, + }, + ), + ).rejects.toMatchObject({ + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + message: "Media usage activation is in progress", + status: 503, + }); + }); +}); diff --git a/packages/workerd/test/workerd-integration.test.ts b/packages/workerd/test/workerd-integration.test.ts index ffa52e62a6..1c3dda9345 100644 --- a/packages/workerd/test/workerd-integration.test.ts +++ b/packages/workerd/test/workerd-integration.test.ts @@ -9,6 +9,7 @@ */ import Database from "better-sqlite3"; +import { createSandboxRouteError } from "emdash"; import { Kysely, SqliteDialect } from "kysely"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; @@ -106,6 +107,17 @@ export default { }; `; +const CONTENT_WRITE_PLUGIN = ` +export default { + hooks: {}, + routes: { + "write": { + handler: async (_routeCtx, ctx) => ctx.content.create("posts", { slug: "blocked" }) + } + } +}; +`; + describe.skipIf(!workerdAvailable)("WorkerdSandboxRunner integration", () => { let db: Kysely; let sqlite: Database.Database; @@ -296,6 +308,46 @@ describe.skipIf(!workerdAvailable)("WorkerdSandboxRunner integration", () => { } }, 30_000); + it("preserves a content-write fence through the sandbox route transport", async () => { + const fencedRunner = new WorkerdSandboxRunner({ + db, + beforeContentWrite: async () => { + throw createSandboxRouteError("MEDIA_USAGE_ACTIVATION_IN_PROGRESS"); + }, + }); + + try { + const plugin = await fencedRunner.load( + { + id: "test-content-write", + version: "1.0.0", + capabilities: ["write:content"], + allowedHosts: [], + storage: {}, + }, + CONTENT_WRITE_PLUGIN, + ); + + await expect( + plugin.invokeRoute( + "write", + {}, + { + method: "POST", + url: "/api/test", + headers: {}, + }, + ), + ).rejects.toMatchObject({ + code: "MEDIA_USAGE_ACTIVATION_IN_PROGRESS", + message: "Media usage activation is in progress", + status: 503, + }); + } finally { + await fencedRunner.terminateAll(); + } + }, 30_000); + it("loads multiple plugins simultaneously", async () => { const plugin1 = await runner.load( {