From bc348ee3f3355a4db44799936a81c2c8f4823575 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Wed, 5 Aug 2026 16:35:17 +0100 Subject: [PATCH 01/11] feat: add media usage incremental capture foundation --- .../implementation/STEP_1.md | 31 ++ .../063_media_usage_incremental_work.ts | 429 +++++++++++++++ .../core/src/database/migrations/runner.ts | 2 + packages/core/src/database/types.ts | 52 ++ .../core/src/media/usage/capture-triggers.ts | 520 ++++++++++++++++++ .../media-usage-capture-trigger.test.ts | 426 ++++++++++++++ ...a-usage-incremental-work-migration.test.ts | 237 ++++++++ .../integration/database/migrations.test.ts | 1 + 8 files changed, 1698 insertions(+) create mode 100644 .opencode/memory/active/media-usage-index-used-in/implementation/STEP_1.md create mode 100644 packages/core/src/database/migrations/063_media_usage_incremental_work.ts create mode 100644 packages/core/src/media/usage/capture-triggers.ts create mode 100644 packages/core/tests/integration/database/media-usage-capture-trigger.test.ts create mode 100644 packages/core/tests/integration/database/media-usage-incremental-work-migration.test.ts diff --git a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_1.md b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_1.md new file mode 100644 index 0000000000..a983fb6d1a --- /dev/null +++ b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_1.md @@ -0,0 +1,31 @@ +# Sequence 1 — Step 1 implementation + +## Boundary + +Step 1 expands the schema and provides a reusable, dialect-aware capture-trigger installer. Migration `057_media_usage_incremental_work` leaves the singleton activation state at `expanded`; it neither installs triggers nor changes V1 reads. Activation, collection-lifecycle orchestration, workers, retries, repair integration, deletion cleanup, and APIs remain later work. + +## Schema + +- `_emdash_media_usage_activation` is a singleton rollout fence with durable `expanded`/`activating`/`active` state, cursor, lease, attempt, error, drain-confirmation, and activation timestamps. +- `_emdash_media_usage_work` coalesces work by immutable `(collection_id, content_id)`, versions newer mutations, and stores pending/retry/lease/failure state. Due, expired-lease, and bounded operator indexes support later processing. +- Content-collection status rows gain immutable collection identity, epoch, reconciliation, capture lifecycle, incremental-success, and deletion-cleanup checkpoint/lease fields. +- Sources gain nullable `collection_id` and `identity_version`. Existing sources and fingerprints remain unbound and unchanged. +- Expansion binds only current `content-media` collection status rows, removes unmatched legacy status metadata, creates no historical work, and performs no content-table scan. + +## Capture triggers + +SQLite/libSQL/D1 use three table-specific row triggers. PostgreSQL uses one shared trigger function plus table-specific triggers. Names contain the trigger-contract version and a 128-bit SHA-256 identity suffix, and remain below PostgreSQL's 63-byte limit. Catalog verification checks the exact SQLite definition or PostgreSQL event, function and identity arguments before accepting a no-op. Every trigger matches the embedded collection ID and slug against both an `active` status row and the live registry row, advances the epoch, invalidates complete coverage, and upserts one pending work row. Any missing guard or work failure aborts the entire content statement. + +Database time is stored as sortable UTC ISO text. A complete current trigger set makes repeated installation a no-op. Missing, partial, or stale sets are repaired only while the durable lifecycle is `installing`, and still require an external writer fence or a table that is not yet reachable: a non-active lifecycle cannot guard an operation whose trigger is absent. + +## Tests and dialect limits + +Cross-dialect integration tests cover migration preservation/idempotency, expansion-only compatibility, insert/update/delete coalescing, state reset, inactive or mismatched lifecycle rejection, repeated installation, and multi-row all-or-nothing rollback. SQLite always runs; PostgreSQL runs when `EMDASH_TEST_PG` is configured. These tests do not claim real D1/workerd coverage; that smoke test remains required before the complete Sequence 1 implementation is review-ready. + +## Remaining risks + +- Capture cost is proportional to rows affected by a statement; supported bulk-writer limits are measured in later steps. +- Existing collection activation still requires the approved external writer drain. +- Migration rollback refuses to discard active lifecycle state, pending work, canonical identities, or installed capture triggers. +- New/seed/orphan/deletion lifecycle ordering is not switched on by Step 1; doing so safely requires the later durable lifecycle orchestration. +- PostgreSQL coverage is skipped when no test server is configured, and real D1 syntax/budget behavior is not exercised here. 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..aa65d2a3f1 --- /dev/null +++ b/packages/core/src/database/migrations/063_media_usage_incremental_work.ts @@ -0,0 +1,429 @@ +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 [ + "cleanup_last_error_code", + "cleanup_next_attempt_at", + "cleanup_attempt_count", + "cleanup_lease_expires_at", + "cleanup_lease_token", + "cleanup_occurrence_cursor", + "cleanup_source_key", + "cleanup_work_cursor", + "cleanup_phase", + "cleanup_state", + "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"); + await addStatusTextColumn(db, "cleanup_state"); + await addStatusTextColumn(db, "cleanup_phase"); + await addStatusTextColumn(db, "cleanup_work_cursor"); + await addStatusTextColumn(db, "cleanup_source_key"); + await addStatusTextColumn(db, "cleanup_occurrence_cursor"); + await addStatusTextColumn(db, "cleanup_lease_token"); + await addStatusTextColumn(db, "cleanup_lease_expires_at"); + await addColumnIfMissing(db, "_emdash_media_usage_index_status", "cleanup_attempt_count", () => + db.schema + .alterTable("_emdash_media_usage_index_status") + .addColumn("cleanup_attempt_count", "integer", (column) => column.notNull().defaultTo(0)) + .execute(), + ); + await addStatusTextColumn(db, "cleanup_next_attempt_at"); + await addStatusTextColumn(db, "cleanup_last_error_code"); +} + +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(); + } + if (await columnExists(db, "_emdash_media_usage_index_status", "cleanup_state")) { + const cleanup = await sql<{ present: number }>` + SELECT 1 AS present FROM _emdash_media_usage_index_status + WHERE cleanup_state IS NOT NULL LIMIT 1 + `.execute(db); + if (cleanup.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/types.ts b/packages/core/src/database/types.ts index eef16d0dbb..914106143b 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,54 @@ 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; + cleanup_state: Generated; + cleanup_phase: Generated; + cleanup_work_cursor: Generated; + cleanup_source_key: Generated; + cleanup_occurrence_cursor: Generated; + cleanup_lease_token: Generated; + cleanup_lease_expires_at: Generated; + cleanup_attempt_count: Generated; + cleanup_next_attempt_at: Generated; + cleanup_last_error_code: 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 +612,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/media/usage/capture-triggers.ts b/packages/core/src/media/usage/capture-triggers.ts new file mode 100644 index 0000000000..0c524088f7 --- /dev/null +++ b/packages/core/src/media/usage/capture-triggers.ts @@ -0,0 +1,520 @@ +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, +): 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"]); + await removeCaptureTriggers(db, identifiers.tableName, installedNames); + + if (isPostgres(db)) { + for (const operation of captureOperations) { + await postgresCreateTrigger( + db, + identifiers.tableName, + identifiers.triggerNames[operation], + operation, + identity, + ); + } + return; + } + + for (const operation of captureOperations) { + await sqliteCreateTrigger( + db, + identifiers.tableName, + identifiers.triggerNames[operation], + operation, + identity, + ); + } + + await assertExpectedTriggerSet(db, identifiers, identity); +} + +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/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-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/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(); From a567a860f25d309879eb03eb04349dca6af5f4e8 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Wed, 5 Aug 2026 17:21:07 +0100 Subject: [PATCH 02/11] feat: add media usage work transitions and projection no-op --- .../implementation/STEP_2.md | 20 ++ .../database/repositories/media-usage-work.ts | 230 +++++++++++++ .../src/database/repositories/media-usage.ts | 56 ++++ .../core/src/media/usage/content-refresh.ts | 49 ++- .../core/src/media/usage/content-repair.ts | 2 +- .../core/src/media/usage/content-snapshots.ts | 139 +++----- .../src/media/usage/projection-fingerprint.ts | 114 +++++++ .../media-usage-content-refresh.test.ts | 104 ++++-- .../media-usage-content-snapshots.test.ts | 35 +- .../media-usage-work-repository.test.ts | 316 ++++++++++++++++++ .../usage-projection-fingerprint.test.ts | 80 +++++ 11 files changed, 990 insertions(+), 155 deletions(-) create mode 100644 .opencode/memory/active/media-usage-index-used-in/implementation/STEP_2.md create mode 100644 packages/core/src/database/repositories/media-usage-work.ts create mode 100644 packages/core/src/media/usage/projection-fingerprint.ts create mode 100644 packages/core/tests/integration/database/media-usage-work-repository.test.ts create mode 100644 packages/core/tests/unit/media/usage-projection-fingerprint.test.ts diff --git a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_2.md b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_2.md new file mode 100644 index 0000000000..8e73133f69 --- /dev/null +++ b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_2.md @@ -0,0 +1,20 @@ +# Sequence 1 — Step 2 implementation + +## Boundary + +Step 2 adds reusable conditional primitives for one durable entry job and makes guarded projection redelivery capable of a true no-op. It does not select candidates, connect content writes, run a scheduler, change coverage/repair epochs, activate canonical reads, or expose APIs. + +## Planned operations + +- Claim only an exact due `pending`/`retry` version or an exact expired `leased` version, using database time and a repository-generated lease token. +- Complete, retry, or fail only the exact version, token, collection/content identity, `leased` state, and unexpired database lease. +- Retry/failure increments the current attempt count and clears ownership; the caller supplies the later measured retry delay, a stable error code, and decides whether an error is terminal. +- A newer mutation or lease takeover makes every stale transition lose without changing the row. + +## Projection no-op + +Projection fingerprints are versioned SHA-256 digests over immutable collection identity, content/source identity, all V1-visible source metadata, extraction schema version, and a canonical occurrence representation. Guarded replacement reports an explicit unchanged outcome only for the current expected row and a current-format matching fingerprint; legacy fingerprints are never matches. An unchanged projection writes neither a source generation nor occurrences. + +## Tests and risks + +Cross-dialect tests cover due-state claims, competing owners, expired takeover, stale/newer work fencing, completion, retry, terminal failure, fingerprint invalidation, and generation-preserving redelivery. SQLite always runs; PostgreSQL requires `EMDASH_TEST_PG`. Real D1/workerd behavior and measured lease/backoff/resource constants remain later acceptance work. 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..f30df6ca63 --- /dev/null +++ b/packages/core/src/database/repositories/media-usage-work.ts @@ -0,0 +1,230 @@ +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"; + +export type MediaUsageWorkState = "pending" | "retry" | "leased" | "failed"; +export type MediaUsageWorkVersion = number | string; +const MAX_PORTABLE_DURATION_SECONDS = 365 * 24 * 60 * 60; +const POSITIVE_DECIMAL_PATTERN = /^[1-9][0-9]*$/; +const STABLE_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; + +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 class MediaUsageWorkRepository { + constructor(private db: Kysely) {} + + 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 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"): RawBuilder { + return isPostgres(this.db) + ? sql`${sql.ref(column)}::timestamptz <= clock_timestamp()` + : 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 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 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 { + return { + collectionId: row.collection_id, + collectionSlug: row.collection_slug, + contentId: row.content_id, + changeEpoch: row.change_epoch, + workVersion: row.work_version, + state: "leased", + 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, + }; +} diff --git a/packages/core/src/database/repositories/media-usage.ts b/packages/core/src/database/repositories/media-usage.ts index 5f02e4b9ac..f7d98daacd 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"; @@ -136,6 +137,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; } @@ -416,6 +418,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 +446,7 @@ export class MediaUsageRepository { return { replaced, + unchanged: false, source: replaced ? null : await this.findSource(source.sourceKey), }; } @@ -477,6 +486,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,6 +509,7 @@ export class MediaUsageRepository { return { replaced, + unchanged: false, source: replaced ? null : await this.findSource(source.sourceKey), }; } @@ -2001,6 +2017,46 @@ export class MediaUsageRepository { ]); } + 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) + .executeTakeFirst(); + return row !== undefined; + } + + private 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)) + .executeTakeFirst(); + return row !== undefined; + } + private nullableStringExpression( eb: ExpressionBuilder, column: MediaUsageSourceNullableStringColumn, diff --git a/packages/core/src/media/usage/content-refresh.ts b/packages/core/src/media/usage/content-refresh.ts index ffd705dc5d..90b798e841 100644 --- a/packages/core/src/media/usage/content-refresh.ts +++ b/packages/core/src/media/usage/content-refresh.ts @@ -102,13 +102,16 @@ async function refreshContentMediaUsageAttempt( contentId: string, ): Promise { const repo = new MediaUsageRepository(db); - const observedGenerations = await loadObservedContentSourceGenerations( - db, - collectionSlug, - contentId, - ); + const observedSources = await loadObservedContentSources(repo, collectionSlug, contentId); const snapshotsResult = await loadContentMediaUsageSnapshots(db, collectionSlug, contentId); if (!snapshotsResult.success) { + if ( + snapshotsResult.error === "CONTENT_NOT_FOUND" && + !(await contentCollectionExists(db, collectionSlug)) + ) { + const deletedSourceCount = await repo.deleteContentSources(collectionSlug, contentId); + return { ...ZERO_RESULT, deletedSourceCount }; + } return markSnapshotFailure(db, collectionSlug, snapshotsResult); } @@ -119,11 +122,15 @@ async function refreshContentMediaUsageAttempt( 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, @@ -145,10 +152,10 @@ async function refreshContentMediaUsageAttempt( ).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,12 +176,11 @@ async function refreshContentMediaUsageAttempt( }; } -async function loadObservedContentSourceGenerations( - db: Kysely, +async function loadObservedContentSources( + repo: MediaUsageRepository, collectionSlug: string, contentId: string, -): Promise> { - const generations = new Map(); +): Promise>> { const sourceKeys = MEDIA_USAGE_CONTENT_SOURCE_VARIANTS.map((sourceVariant) => buildContentMediaUsageSourceKey({ collectionSlug, @@ -182,20 +188,7 @@ async function loadObservedContentSourceGenerations( 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( diff --git a/packages/core/src/media/usage/content-repair.ts b/packages/core/src/media/usage/content-repair.ts index bff130547a..8d0e52c726 100644 --- a/packages/core/src/media/usage/content-repair.ts +++ b/packages/core/src/media/usage/content-repair.ts @@ -466,7 +466,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); diff --git a/packages/core/src/media/usage/content-snapshots.ts b/packages/core/src/media/usage/content-snapshots.ts index 707332c0d3..2783ef5419 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", @@ -69,6 +70,10 @@ export async function loadContentMediaUsageSnapshots( ]); 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 +85,22 @@ export async function loadContentMediaUsageSnapshots( data: columnsData, }); const columnsRevisionId = readNullableString(row.live_revision_id); - const columnsFingerprint = await buildSourceFingerprint({ + const columnsSource = buildContentSource({ collectionSlug, + 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, }, @@ -146,27 +150,27 @@ 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({ collectionSlug, + 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, }); } @@ -192,11 +196,15 @@ async function loadContentRow( ): 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} + WHERE content.id = ${contentId} LIMIT 1 `.execute(db); @@ -233,7 +241,6 @@ function buildContentSource(input: { sourceVariant: MediaUsageContentSourceVariant; revisionId: string | null; contentSlug?: string | null; - sourceFingerprint?: string | null; }): MediaUsageSourceInput { const { collectionSlug, row, displayData, sourceVariant, revisionId } = input; const contentId = readString(row.id) ?? ""; @@ -260,75 +267,9 @@ function buildContentSource(input: { sourceUpdatedAt: readNullableString(row.updated_at), sourceVersion: readNumber(row.version), }; - 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/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-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-work-repository.test.ts b/packages/core/tests/integration/database/media-usage-work-repository.test.ts new file mode 100644 index 0000000000..45985bfae9 --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-work-repository.test.ts @@ -0,0 +1,316 @@ +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, + }), + ); + }); +}); + +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: { + state?: string; + nextAttemptAt: string; + leaseToken?: string | null; + leaseExpiresAt?: string | null; + }, +): Promise { + await ctx.db + .insertInto("_emdash_media_usage_work") + .values({ + collection_id: "collection-1", + collection_slug: "posts", + content_id: "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, + }) + .execute(); +} + +function readWork(ctx: DialectTestContext) { + return ctx.db.selectFrom("_emdash_media_usage_work").selectAll().executeTakeFirst(); +} 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, + }); +} From 050a67520d54f56cf241bccc2862728e132ced83 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Wed, 5 Aug 2026 20:24:36 +0100 Subject: [PATCH 03/11] feat(core): process durable media usage work --- .../implementation/STEP_3.md | 33 ++ .../database/repositories/media-usage-work.ts | 84 +++- .../src/database/repositories/media-usage.ts | 91 ++++- packages/core/src/emdash-runtime.ts | 24 +- .../core/src/media/usage/content-refresh.ts | 142 ++++++- .../core/src/media/usage/content-snapshots.ts | 43 ++- packages/core/src/media/usage/source-key.ts | 4 + .../core/src/media/usage/work-processor.ts | 226 +++++++++++ .../database/media-usage-repository.test.ts | 82 ++++ .../media-usage-work-processor.test.ts | 359 ++++++++++++++++++ .../media-usage-work-repository.test.ts | 65 +++- .../media-usage-scheduled-driver.test.ts | 170 +++++++++ 12 files changed, 1288 insertions(+), 35 deletions(-) create mode 100644 .opencode/memory/active/media-usage-index-used-in/implementation/STEP_3.md create mode 100644 packages/core/src/media/usage/work-processor.ts create mode 100644 packages/core/tests/integration/database/media-usage-work-processor.test.ts create mode 100644 packages/core/tests/integration/runtime/media-usage-scheduled-driver.test.ts diff --git a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_3.md b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_3.md new file mode 100644 index 0000000000..7fef3aa1f1 --- /dev/null +++ b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_3.md @@ -0,0 +1,33 @@ +# Sequence 1 — Step 3 implementation + +## Boundary + +Step 3 connects trigger-created entry work to the authenticated post-write path and to the existing Cloudflare/Node maintenance heartbeat. It does not activate collections, change coverage/repair epochs, add operator APIs, or implement later acceptance/measurement work. + +## Processing flow + +- Before global activation, successful writes retain the released synchronous V1 refresh/delete behavior. +- After activation, the fast path resolves the current collection instance, conditionally claims only that entry's due work version, reconciles current database truth, and conditionally acknowledges the exact live lease. +- The scheduled driver uses the due, retry, and expired-lease indexes, merges a bounded candidate page, skips claim losers, and processes jobs sequentially through the same processor. Cloudflare calls it through `runScheduledTasks`; Node calls it from the timer maintenance callback. +- Canonical projections use immutable collection identity. Every canonical source write/delete rechecks the current registry ID and slug; obsolete-instance work is acknowledged without touching a replacement collection. + +## Initial bounds + +- return at most 4 due candidates after three index-backed probes capped at 4 rows each, and claim at most 1 job per scheduled tick; +- admit no new job after 5 seconds; +- lease one job for 60 seconds; +- retain a terminal failure after 5 failed attempts; +- retry from 30 seconds, capped at 15 minutes, with up to 25% jitter. +- keep an ordinary single-source job at or below 20 top-level database statements. + +These conservative exported limits leave headroom below Cloudflare's 50-query Free invocation limit, 100-bind query limit, and 30-second query limit. Behavioral tests measure the ordinary statement ceiling and generation-preserving no-op path; the final large-entry resource gate remains later work. + +## Failure behavior and tests + +Tests cover bounded/order-stable selection, immediate processing, duplicate/claim races, lease expiry, stale completion, projection no-op redelivery, retry/terminal transitions, entry deletion, and independent Cloudflare/Node scheduling. Process death before claim leaves pending work; death after claim recovers after expiry; death after projection redelivers as a fingerprint no-op; a newer work version cannot be acknowledged by the old owner. + +## Remaining risks + +- A single accepted entry can still contain many occurrences; the full resource-admission measurement and terminal resource envelope remain a later Sequence 1 acceptance task. +- Real PostgreSQL and D1/workerd verification remain required even though shared SQLite/PostgreSQL tests exercise the portable behavior. +- Hosts without scheduled maintenance retain durable work but receive no automatic freshness deadline. diff --git a/packages/core/src/database/repositories/media-usage-work.ts b/packages/core/src/database/repositories/media-usage-work.ts index f30df6ca63..141e5da454 100644 --- a/packages/core/src/database/repositories/media-usage-work.ts +++ b/packages/core/src/database/repositories/media-usage-work.ts @@ -7,6 +7,7 @@ import type { Database, MediaUsageWorkTable } 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 POSITIVE_DECIMAL_PATTERN = /^[1-9][0-9]*$/; const STABLE_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; @@ -37,6 +38,65 @@ export interface MediaUsageWorkRecord extends MediaUsageWorkIdentity { export class MediaUsageWorkRepository { constructor(private db: Kysely) {} + 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; @@ -211,13 +271,16 @@ function assertErrorCode(value: string): void { } 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: "leased", + state: row.state, attemptCount: row.attempt_count, nextAttemptAt: row.next_attempt_at, leaseToken: row.lease_token, @@ -228,3 +291,22 @@ function rowToWork(row: Selectable): MediaUsageWorkRecord { updatedAt: row.updated_at, }; } + +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 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 f7d98daacd..1160b9ac52 100644 --- a/packages/core/src/database/repositories/media-usage.ts +++ b/packages/core/src/database/repositories/media-usage.ts @@ -26,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" @@ -76,6 +77,7 @@ const CONTENT_SOURCE_ELIGIBILITY = sql`( export interface MediaUsageSourceInput { sourceKey: string; sourceType: string; + collectionId?: string | null; collectionSlug?: string | null; contentId?: string | null; sourceVariant: MediaUsageContentSourceVariant; @@ -91,6 +93,7 @@ export interface MediaUsageSourceInput { sourceUpdatedAt?: string | null; sourceVersion?: number | null; sourceFingerprint?: string | null; + identityVersion?: number | null; sourceCompleteness?: MediaUsageSourceCompleteness; lastAttemptedAt?: string | null; lastErrorCode?: string | null; @@ -111,6 +114,7 @@ export interface MediaUsageOccurrenceInput { export interface MediaUsageSource { sourceKey: string; sourceType: string; + collectionId: string | null; collectionSlug: string | null; contentId: string | null; sourceVariant: string; @@ -127,6 +131,7 @@ export interface MediaUsageSource { sourceUpdatedAt: string | null; sourceVersion: number | null; sourceFingerprint: string | null; + identityVersion: number | null; sourceCompleteness: string; lastAttemptedAt: string | null; lastErrorCode: string | null; @@ -298,6 +303,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; @@ -314,6 +320,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; @@ -346,6 +353,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; @@ -362,6 +370,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; @@ -515,6 +524,19 @@ export class MediaUsageRepository { } 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); @@ -546,12 +568,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()); @@ -824,6 +846,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; @@ -855,6 +880,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})`, ) @@ -1771,6 +1799,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, @@ -1787,6 +1816,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, @@ -1811,7 +1841,9 @@ export class MediaUsageRepository { private async persistSourceIfWriteLease( db: DatabaseExecutor, - row: ReturnType, + row: + | ReturnType + | ReturnType, leaseToken: string, conflict: RawBuilder, ): Promise { @@ -1819,6 +1851,7 @@ export class MediaUsageRepository { INSERT INTO _emdash_media_usage_sources ( source_key, source_type, + collection_id, collection_slug, content_id, source_variant, @@ -1835,6 +1868,7 @@ export class MediaUsageRepository { source_updated_at, source_version, source_fingerprint, + identity_version, source_completeness, last_attempted_at, last_error_code, @@ -1844,6 +1878,7 @@ export class MediaUsageRepository { SELECT ${row.source_key}, ${row.source_type}, + ${row.collection_id}, ${row.collection_slug}, ${row.content_id}, ${row.source_variant}, @@ -1860,6 +1895,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}, @@ -1873,6 +1909,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; @@ -1967,6 +2004,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; } @@ -1983,6 +2021,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; } @@ -1998,6 +2037,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; } @@ -2007,10 +2047,12 @@ 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), @@ -2031,6 +2073,9 @@ export class MediaUsageRepository { .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; } @@ -2053,6 +2098,9 @@ export class MediaUsageRepository { .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; } @@ -2065,9 +2113,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); @@ -2087,6 +2148,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, @@ -2103,6 +2165,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", @@ -2117,6 +2180,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, @@ -2133,6 +2197,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, @@ -2156,6 +2221,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; @@ -2173,6 +2239,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; } @@ -2182,6 +2249,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, @@ -2198,6 +2266,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, @@ -2210,6 +2279,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", @@ -2226,6 +2296,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", @@ -2282,6 +2353,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, @@ -2298,6 +2370,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, @@ -2330,6 +2403,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, @@ -2346,6 +2420,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/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 5cba3c64ea..024702a6ae 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -47,6 +47,10 @@ 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 type { SandboxedPluginInstance, @@ -524,6 +528,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 */ @@ -711,6 +726,7 @@ export class EmDashRuntime { console.error("[cleanup] System cleanup failed:", error); } + await runScheduledMediaUsageWork(this.db); try { await this.syncPluginStorageIndexesOnce(); } catch (error) { @@ -1666,6 +1682,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) { @@ -3522,7 +3539,10 @@ export class EmDashRuntime { ): Promise { for (const contentId of new Set(contentIds)) { try { - await refreshContentMediaUsageAfterWrite(this.db, collection, contentId); + const work = await processMediaUsageWorkAfterWrite(this.db, collection, contentId); + if (work.outcome === "inactive") { + await refreshContentMediaUsageAfterWrite(this.db, collection, contentId); + } } catch (error) { console.error( `[media-usage] Failed after content write ${collection}/${contentId}:`, @@ -3537,6 +3557,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( diff --git a/packages/core/src/media/usage/content-refresh.ts b/packages/core/src/media/usage/content-refresh.ts index 90b798e841..3b4b0a138e 100644 --- a/packages/core/src/media/usage/content-refresh.ts +++ b/packages/core/src/media/usage/content-refresh.ts @@ -34,6 +34,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 +62,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 +89,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,11 +132,34 @@ async function refreshContentMediaUsageAttempt( db: Kysely, collectionSlug: string, contentId: string, + options: ContentMediaUsageRefreshOptions, ): Promise { const repo = new MediaUsageRepository(db); - const observedSources = await loadObservedContentSources(repo, collectionSlug, contentId); - const snapshotsResult = await loadContentMediaUsageSnapshots(db, collectionSlug, contentId); + 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, + ); if (!snapshotsResult.success) { + 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)) @@ -112,10 +167,15 @@ async function refreshContentMediaUsageAttempt( const deletedSourceCount = await repo.deleteContentSources(collectionSlug, contentId); return { ...ZERO_RESULT, deletedSourceCount }; } - return markSnapshotFailure(db, collectionSlug, snapshotsResult); + 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 }; } @@ -139,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 }; } @@ -148,7 +211,12 @@ 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) { @@ -180,9 +248,11 @@ async function loadObservedContentSources( repo: MediaUsageRepository, collectionSlug: string, contentId: string, + collectionId?: string, ): Promise>> { const sourceKeys = MEDIA_USAGE_CONTENT_SOURCE_VARIANTS.map((sourceVariant) => buildContentMediaUsageSourceKey({ + collectionId, collectionSlug, contentId, sourceVariant, @@ -225,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; } @@ -435,6 +504,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-snapshots.ts b/packages/core/src/media/usage/content-snapshots.ts index 2783ef5419..1e8d76b19f 100644 --- a/packages/core/src/media/usage/content-snapshots.ts +++ b/packages/core/src/media/usage/content-snapshots.ts @@ -56,18 +56,30 @@ 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]); @@ -86,7 +98,9 @@ export async function loadContentMediaUsageSnapshots( }); const columnsRevisionId = readNullableString(row.live_revision_id); const columnsSource = buildContentSource({ + collectionId: options.collectionId, collectionSlug, + identityVersion: options.identityVersion, row, displayData, sourceVariant: "columns", @@ -109,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", @@ -155,7 +171,9 @@ export async function loadContentMediaUsageSnapshots( data: draftOverlayData, }); const draftSource = buildContentSource({ + collectionId: options.collectionId, collectionSlug, + identityVersion: options.identityVersion, row, displayData: draftDisplayData, sourceVariant: "draft_overlay", @@ -193,6 +211,7 @@ 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]); @@ -204,6 +223,7 @@ async function loadContentRow( 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); @@ -235,23 +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; }): 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, @@ -266,6 +298,7 @@ function buildContentSource(input: { schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, sourceUpdatedAt: readNullableString(row.updated_at), sourceVersion: readNumber(row.version), + identityVersion, }; return source; } 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..d5ed113c98 --- /dev/null +++ b/packages/core/src/media/usage/work-processor.ts @@ -0,0 +1,226 @@ +import type { Kysely } from "kysely"; + +import { + MediaUsageWorkRepository, + type MediaUsageWorkRecord, +} from "../../database/repositories/media-usage-work.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) { + return { + outcome: (await repo.completeWork(lease)) ? "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) { + return { + outcome: (await repo.failWork({ ...lease, errorCode })) ? "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/tests/integration/database/media-usage-repository.test.ts b/packages/core/tests/integration/database/media-usage-repository.test.ts index 60c9650b57..555c67aaa5 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"), 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..e1b8edd6a5 --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-work-processor.test.ts @@ -0,0 +1,359 @@ +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"); + + 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, + }), + ); + }); + + 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); + }); + + 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", + }), + ); + + 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", + }), + ); + }); + + 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): string { + return `content:${collectionId}:${contentId}:columns`; +} + +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 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 index 45985bfae9..5ac94fdc72 100644 --- a/packages/core/tests/integration/database/media-usage-work-repository.test.ts +++ b/packages/core/tests/integration/database/media-usage-work-repository.test.ts @@ -265,6 +265,63 @@ describeEachDialect("media usage work repository", (dialect) => { }), ); }); + + 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) { @@ -288,18 +345,21 @@ function lease(leaseToken: string, workVersion: number) { 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: "collection-1", + collection_id: input.collectionId ?? "collection-1", collection_slug: "posts", - content_id: "entry-1", + content_id: input.contentId ?? "entry-1", change_epoch: 1, work_version: 1, state: input.state ?? "pending", @@ -307,6 +367,7 @@ async function insertWork( next_attempt_at: input.nextAttemptAt, lease_token: input.leaseToken ?? null, lease_expires_at: input.leaseExpiresAt ?? null, + updated_at: input.updatedAt, }) .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`; +} From c97e9e72bc9d5ab8596409963ec0d2ec750d9aca Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Thu, 6 Aug 2026 15:27:52 +0100 Subject: [PATCH 04/11] feat(core): apply media usage coverage epochs --- .../implementation/STEP_4.md | 67 ++++ .../database/repositories/media-usage-work.ts | 22 ++ .../src/database/repositories/media-usage.ts | 296 +++++++++++++++++- .../core/src/media/usage/content-fields.ts | 8 +- .../core/src/media/usage/content-repair.ts | 239 ++++++++++---- .../core/src/media/usage/work-processor.ts | 22 +- .../media-usage-content-repair.test.ts | 214 +++++++++++++ .../database/media-usage-repository.test.ts | 70 +++++ .../media-usage-work-processor.test.ts | 101 ++++++ 9 files changed, 967 insertions(+), 72 deletions(-) create mode 100644 .opencode/memory/active/media-usage-index-used-in/implementation/STEP_4.md diff --git a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_4.md b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_4.md new file mode 100644 index 0000000000..920a361757 --- /dev/null +++ b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_4.md @@ -0,0 +1,67 @@ +# Sequence 1 — Step 4 implementation + +## Boundary and verified starting point + +Step 4 makes the existing coverage epoch authoritative for incremental completion and the existing synchronous repair. It does not activate collections, add reconciliation paging, change public repair responses, add operator APIs, or implement Sequence 2. + +- Capture triggers already advance `change_epoch`, coalesce one work row per entry, and change trusted `complete` coverage to `stale` in the same content statement. +- The worker conditionally claims and acknowledges an exact version/lease, but acknowledgement currently does not restore truthful coverage or record terminal failure. +- Repair currently scans by slug, writes legacy slug-keyed sources, overwrites repair status without an epoch fence, and leaves durable work untouched. +- Schema expansion leaves global activation at `expanded`. Released V1 repair behaviour must remain unchanged until activation is `active`. + +## Smallest coherent implementation + +### Incremental completion + +After an exact work acknowledgement succeeds, read the current ID-bound status epoch, then conditionally update that same row. Record database-time `last_incremental_success_at`; restore `complete`, clear the error and set `completed_at` only when the observed epoch still matches, `reconciliation_required = 0`, no work row of any state remains for the collection, the current registry identity still matches, and the status is a previously trusted `complete`/`stale`/`partial` state. A concurrent trigger either advances the epoch before this update or runs afterward and makes coverage stale again. + +Retry retains the trigger-established non-complete state. After an exact terminal-failure transition succeeds, conditionally record its stable error on the current ID-bound status. A row with `reconciliation_required = 0` becomes `partial`. An untrusted `running` row becomes `stale`; any other untrusted non-complete state is preserved. The update must still prove that the exact failed work version exists so a newer mutation cannot publish an obsolete failure. + +Obsolete, claim-lost and superseded work never changes current collection coverage. A database failure after acknowledgement can leave coverage conservatively non-complete, but cannot create false completeness or resurrect work. + +### Activation-aware manual repair + +Repair resolves the current collection ID once. While global activation is not `active`, it preserves the released slug-keyed path and status contract exactly. While activation is `active`, it uses an epoch-fenced canonical path: + +1. Atomically change the exact current ID-bound status row to `running`, set `reconciliation_required = true`, retain existing work, and return its starting `change_epoch` with the repair token. This keeps a crashed non-resumable repair from later being mistaken for an incrementally recoverable baseline. Missing or mismatched active lifecycle state fails safely as the existing structured repair error rather than creating a slug-only status row. +2. Scan the existing collection synchronously as today, but bind content-ID scans, field discovery, snapshots, source selection and source mutation to that exact collection ID. Build, load, write and delete only canonical source keys carrying the ID and identity version. Unbound legacy sources are neither selected nor mutated. +3. Keep the existing guarded source conflict rules and final content-ID scan. Every canonical projection statement retains the repository's current-registry identity guard. +4. If the scan is complete, delete only this collection ID's work with `change_epoch <= starting_epoch`, then conditionally finalize `complete` only when the repair token, collection ID and epoch still match, the registry identity is current, and no work remains. Clear `reconciliation_required` only in this transition. +5. Partial or failed scans retain all work and set `reconciliation_required = true`; otherwise a later unrelated incremental success could falsely restore complete coverage. Their status update still matches the repair token, collection ID and starting epoch. +6. If the epoch changed while the same run token still owns `running`, demote that run to conservative `stale`/conflict state and require reconciliation. Never overwrite a newer repair or another winning status transition. + +The D1-safe ordering is intentional: clearing proven pre-run work before the final conditional status update means a crash can leave non-complete coverage, not false `complete`. SQLite and PostgreSQL `UPDATE ... RETURNING` provide the epoch from the row modified by repair start; SQLite cannot portably compose DML `RETURNING` as a subquery, so the remaining operations stay as separately guarded statements. + +All-collection repair carries each initially listed collection ID into the per-collection call. If that identity disappears or its slug is recreated, it is excluded without repairing the replacement and without deleting or updating the replacement's status. Legacy pre-activation filtering keeps its released slug-based behaviour. + +## Concurrency, compatibility and cost invariants + +- A mutation before repair finalization advances the epoch and creates newer work atomically; the repair cannot delete that work or finalize complete. +- A mutation after successful finalization immediately changes coverage back to non-complete and creates work. +- Multiple workers may finish out of epoch order; coverage completes only after the final work row drains at a still-observed epoch. +- A terminal row, including a failed row for another entry, is incompatible with complete coverage. +- Repair may delete a pre-run leased row after proving the full current collection. Its stale owner cannot acknowledge because the exact row is gone, and guarded source generations prevent it from overwriting a repair winner. +- Collection deletion or same-slug replacement makes ID/registry predicates lose; repair and incremental bookkeeping cannot update the replacement. +- Existing REST, client, CLI and MCP request/response shapes, authorization, synchronous execution and pre-activation source keys do not change. +- No logged-out query changes. Incremental success adds one bounded epoch read and one conditional status update after an acknowledged job; terminal failure adds one conditional update. The ordinary-job statement-count test must remain within the exported Step 3 ceiling. Repair remains the explicitly unbounded operator operation until Sequence 2. + +## Behavioural tests + +- A trusted collection becomes stale with captured work and returns to complete only after all jobs drain; `last_incremental_success_at` is recorded. +- `never`/reconciliation-required coverage cannot become complete from incremental success. +- Retry remains non-complete; an exact terminal failure becomes visible and cannot be overwritten after a newer mutation. +- Superseded, obsolete and out-of-order completions cannot report complete early. +- Active repair writes canonical ID-bound projections, preserves unbound legacy evidence, clears only pre-run work, and clears reconciliation only on proven completion. +- A write during active repair advances the epoch, retains the newer job, prevents complete finalization and does not leave a falsely running repair. +- Partial/failed active repair retains work and reconciliation-required state. +- Same-slug recreation cannot receive the old repair's sources or status. +- Active all-collection repair skips an identity replaced after listing and leaves the replacement untouched. +- The existing pre-activation repair suite continues to prove backwards-compatible legacy behaviour. + +SQLite runs for every test; the existing real-PostgreSQL dialect wrapper runs when configured. Real D1/workerd execution remains a final Sequence 1 acceptance risk rather than being claimed by Step 4. + +## Remaining risks + +- A crash after successful work deletion but before coverage bookkeeping leaves safe stale/partial coverage until a later job or full repair; there is no false-complete window. +- Manual repair remains whole-collection, synchronous and non-resumable by approved scope. Step 4 adds fences, not scalability claims. +- Hosts without a real PostgreSQL test database or D1/workerd cannot close those platform evidence gaps in this step. diff --git a/packages/core/src/database/repositories/media-usage-work.ts b/packages/core/src/database/repositories/media-usage-work.ts index 141e5da454..eb7c3dbcc5 100644 --- a/packages/core/src/database/repositories/media-usage-work.ts +++ b/packages/core/src/database/repositories/media-usage-work.ts @@ -8,6 +8,7 @@ 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}$/; @@ -152,6 +153,20 @@ export class MediaUsageWorkRepository { 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; @@ -255,6 +270,13 @@ function assertIdentity(input: MediaUsageWorkIdentity): void { } } +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"); } diff --git a/packages/core/src/database/repositories/media-usage.ts b/packages/core/src/database/repositories/media-usage.ts index 1160b9ac52..5a2ce1df83 100644 --- a/packages/core/src/database/repositories/media-usage.ts +++ b/packages/core/src/database/repositories/media-usage.ts @@ -227,6 +227,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; @@ -938,14 +962,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)); } @@ -1384,6 +1412,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 { @@ -1398,16 +1655,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, 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-repair.ts b/packages/core/src/media/usage/content-repair.ts index 8d0e52c726..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) { @@ -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/work-processor.ts b/packages/core/src/media/usage/work-processor.ts index d5ed113c98..ceefbfe52d 100644 --- a/packages/core/src/media/usage/work-processor.ts +++ b/packages/core/src/media/usage/work-processor.ts @@ -4,6 +4,7 @@ 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, @@ -144,8 +145,15 @@ async function processCandidate( 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: (await repo.completeWork(lease)) ? "completed" : "superseded", + outcome: completed ? "completed" : "superseded", claimed: true, }; } @@ -159,8 +167,18 @@ async function processCandidate( 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: (await repo.failWork({ ...lease, errorCode })) ? "failed" : "superseded", + outcome: failed ? "failed" : "superseded", claimed: true, }; } 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..94969797ea 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,136 @@ 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( + await ctx.db + .selectFrom("_emdash_media_usage_work") + .select((eb) => eb.fn.countAll().as("count")) + .where("collection_id", "=", collectionId) + .executeTakeFirstOrThrow(), + ).toEqual(expect.objectContaining({ count: 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 +1534,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-repository.test.ts b/packages/core/tests/integration/database/media-usage-repository.test.ts index 555c67aaa5..e132923f39 100644 --- a/packages/core/tests/integration/database/media-usage-repository.test.ts +++ b/packages/core/tests/integration/database/media-usage-repository.test.ts @@ -1303,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", @@ -1416,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-work-processor.test.ts b/packages/core/tests/integration/database/media-usage-work-processor.test.ts index e1b8edd6a5..a73d4e668f 100644 --- a/packages/core/tests/integration/database/media-usage-work-processor.test.ts +++ b/packages/core/tests/integration/database/media-usage-work-processor.test.ts @@ -40,6 +40,9 @@ describeEachDialect("media usage durable work processing", (dialect) => { 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"); @@ -56,6 +59,76 @@ describeEachDialect("media usage durable work processing", (dialect) => { 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 () => { @@ -70,6 +143,16 @@ describeEachDialect("media usage durable work processing", (dialect) => { 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 () => { @@ -137,6 +220,9 @@ describeEachDialect("media usage durable work processing", (dialect) => { 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") @@ -155,6 +241,13 @@ describeEachDialect("media usage durable work processing", (dialect) => { 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 () => { @@ -300,6 +393,14 @@ 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, From 5abb0e52de90f684a0fd39f9e14cc1f36dc0545c Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Fri, 7 Aug 2026 15:53:44 +0100 Subject: [PATCH 05/11] feat(core): add media usage work controls --- .changeset/steady-cats-retry.md | 5 + .../src/content/docs/guides/media-library.mdx | 50 ++ docs/src/content/docs/reference/rest-api.mdx | 72 ++ packages/core/src/api/errors.ts | 6 + packages/core/src/api/handlers/index.ts | 10 + .../core/src/api/handlers/media-usage-work.ts | 113 +++ packages/core/src/api/openapi/document.ts | 52 ++ packages/core/src/api/schemas/media-usage.ts | 80 ++ packages/core/src/astro/integration/routes.ts | 10 + .../api/admin/media-usage/work/index.ts | 26 + .../api/admin/media-usage/work/retry.ts | 26 + packages/core/src/client/index.ts | 63 ++ .../database/repositories/media-usage-work.ts | 419 +++++++++- .../src/database/repositories/media-usage.ts | 2 +- packages/core/src/emdash-runtime.ts | 6 +- .../core/src/media/usage/content-refresh.ts | 40 + .../core/src/media/usage/work-processor.ts | 8 +- .../media-usage-work-measurement.test.ts | 782 ++++++++++++++++++ .../media-usage-work-operator.test.ts | 495 +++++++++++ .../media-usage-work-processor.test.ts | 272 +++++- .../unit/api/media-usage-work-route.test.ts | 304 +++++++ packages/core/tests/unit/api/openapi.test.ts | 2 + .../core/tests/unit/client/client.test.ts | 135 +++ 23 files changed, 2970 insertions(+), 8 deletions(-) create mode 100644 .changeset/steady-cats-retry.md create mode 100644 packages/core/src/api/handlers/media-usage-work.ts create mode 100644 packages/core/src/astro/routes/api/admin/media-usage/work/index.ts create mode 100644 packages/core/src/astro/routes/api/admin/media-usage/work/retry.ts create mode 100644 packages/core/tests/integration/database/media-usage-work-measurement.test.ts create mode 100644 packages/core/tests/integration/database/media-usage-work-operator.test.ts create mode 100644 packages/core/tests/unit/api/media-usage-work-route.test.ts diff --git a/.changeset/steady-cats-retry.md b/.changeset/steady-cats-retry.md new file mode 100644 index 0000000000..7a48e927ed --- /dev/null +++ b/.changeset/steady-cats-retry.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Adds bounded Media Usage indexing controls and reports changed live or draft projections over 14 media occurrences as terminal failures without partial index updates. diff --git a/docs/src/content/docs/guides/media-library.mdx b/docs/src/content/docs/guides/media-library.mdx index 4899f6e3ba..aa8b40e7bb 100644 --- a/docs/src/content/docs/guides/media-library.mdx +++ b/docs/src/content/docs/guides/media-library.mdx @@ -252,6 +252,56 @@ const { entry: post } = await getEmDashEntry("posts", Astro.params.slug); `priority` is for the primary above-the-fold image. It sets `loading="eager"` and `fetchpriority="high"`: `loading` controls whether loading is deferred, and `fetchpriority` gives the browser a priority hint for the request. +## Monitoring Media Usage Indexing + +EmDash records durable work when a supported content save changes its media references. It tries +the work immediately and, when scheduled maintenance is enabled, retries transient failures in +bounded background runs. Completed work is removed from the queue. + +Operators with `schema:manage` can inspect one collection at a time without loading or counting +the whole backlog: + +```http +GET /_emdash/api/admin/media-usage/work?collection=posts&state=failed&limit=50 +``` + +The optional `state` is `pending`, `retry`, `leased`, or `failed`. Follow `nextCursor` to request +another page. A `failed` item has reached the automatic retry limit and remains visible until a +later content save or an explicit retry reopens it. + +Automatic indexing accepts at most 14 extracted media occurrences in each changed live or draft +projection. A larger changed projection fails immediately with `MEDIA_USAGE_RESOURCE_LIMIT`, +without consuming normal retries or publishing part of the new projection. Do not repeatedly retry +this error: reduce the projection to 14 occurrences or fewer, or reconcile it under +operator-controlled collection repair after assessing that collection-wide operation's cost. An +already-indexed oversized projection can still complete as an unchanged no-op. + +Retry one item using the immutable `collectionId` and `contentId` returned by the list: + +```http +POST /_emdash/api/admin/media-usage/work/retry +Content-Type: application/json +X-EmDash-Request: 1 + +{ + "collectionId": "01COLLECTION...", + "contentId": "01CONTENT..." +} +``` + +The same operations are available through `EmDashClient.mediaListUsageWork()` and +`EmDashClient.mediaRetryUsageWork()`. Retrying already-pending work is a successful no-op. A live +worker lease returns `409 WORK_LEASE_ACTIVE`; wait until its reported expiry instead of trying to +replace the active worker. Retrying `MEDIA_USAGE_RESOURCE_LIMIT` without changing or reconciling the +entry returns it to the same terminal state. + + + ## Deleting Media 1. Select the file(s) you want to delete diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx index dd195a67da..93c71d4966 100644 --- a/docs/src/content/docs/reference/rest-api.mdx +++ b/docs/src/content/docs/reference/rest-api.mdx @@ -363,6 +363,78 @@ 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 +{ + "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. A failed item with `lastErrorCode: "MEDIA_USAGE_RESOURCE_LIMIT"` +contains a changed live or draft projection with more than 14 extracted media occurrences. It +failed before any projection writes and did not consume the normal retry sequence. + +### 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. Retrying `MEDIA_USAGE_RESOURCE_LIMIT` without first +reducing the changed projection to 14 occurrences or fewer, or reconciling it through +operator-controlled collection repair after assessing that collection-wide operation's cost, +returns the job to the same terminal state. Oversized projections that already match the stored +index remain valid no-ops. + +These endpoints operate only on durable work already known for an entry. They do 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/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/openapi/document.ts b/packages/core/src/api/openapi/document.ts index 43649e9ad8..aa2cfb2432 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. `MEDIA_USAGE_RESOURCE_LIMIT` identifies a changed live or draft projection over the 14-occurrence automatic-indexing limit; it fails before projection writes.", + 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. Retrying `MEDIA_USAGE_RESOURCE_LIMIT` without reducing the changed live or draft projection to 14 occurrences or fewer, or reconciling it through operator-controlled repair, returns the job to the same terminal state.", + 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..599c52870f 100644 --- a/packages/core/src/api/schemas/media-usage.ts +++ b/packages/core/src/api/schemas/media-usage.ts @@ -109,8 +109,88 @@ 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(), + }), + ]), + }) + .meta({ id: "MediaUsageWorkRetryConflict" }); + 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/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/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/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/repositories/media-usage-work.ts b/packages/core/src/database/repositories/media-usage-work.ts index eb7c3dbcc5..ded65ebe9e 100644 --- a/packages/core/src/database/repositories/media-usage-work.ts +++ b/packages/core/src/database/repositories/media-usage-work.ts @@ -3,6 +3,7 @@ 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; @@ -12,6 +13,10 @@ 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; @@ -36,9 +41,386 @@ export interface MediaUsageWorkRecord extends MediaUsageWorkIdentity { 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( @@ -222,7 +604,9 @@ export class MediaUsageWorkRepository { : sql`lease_expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`; } - private timestampIsDue(column: "next_attempt_at" | "lease_expires_at"): RawBuilder { + private timestampIsDue( + column: "next_attempt_at" | "lease_expires_at" | "work.lease_expires_at", + ): RawBuilder { return isPostgres(this.db) ? sql`${sql.ref(column)}::timestamptz <= clock_timestamp()` : sql`${sql.ref(column)} <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`; @@ -243,6 +627,16 @@ export class MediaUsageWorkRepository { } } +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) || @@ -314,6 +708,24 @@ function rowToWork(row: Selectable): MediaUsageWorkRecord { }; } +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"; } @@ -327,6 +739,11 @@ function compareDueWork(a: MediaUsageWorkRecord, b: MediaUsageWorkRecord): numbe 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"); diff --git a/packages/core/src/database/repositories/media-usage.ts b/packages/core/src/database/repositories/media-usage.ts index 5a2ce1df83..1185f45104 100644 --- a/packages/core/src/database/repositories/media-usage.ts +++ b/packages/core/src/database/repositories/media-usage.ts @@ -2362,7 +2362,7 @@ export class MediaUsageRepository { return row !== undefined; } - private async projectionMatchesExpectedSource( + async projectionMatchesExpectedSource( source: MediaUsageSourceInput, expectedSource: MediaUsageSource, ): Promise { diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 024702a6ae..5b1bbcebae 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -3540,14 +3540,14 @@ export class EmDashRuntime { for (const contentId of new Set(contentIds)) { try { const work = await processMediaUsageWorkAfterWrite(this.db, collection, contentId); - if (work.outcome === "inactive") { - await refreshContentMediaUsageAfterWrite(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; } } } diff --git a/packages/core/src/media/usage/content-refresh.ts b/packages/core/src/media/usage/content-refresh.ts index 3b4b0a138e..1d27c94b66 100644 --- a/packages/core/src/media/usage/content-refresh.ts +++ b/packages/core/src/media/usage/content-refresh.ts @@ -20,6 +20,7 @@ export const CONTENT_MEDIA_USAGE_COLLECTION_SCOPE = "collection"; const CONTENT_USAGE_LOCKS_KEY = Symbol.for("emdash.mediaUsage.contentLocks"); const CONTENT_USAGE_COLLECTION_LOCKS_KEY = Symbol.for("emdash.mediaUsage.collectionLocks"); const CONTENT_USAGE_REFRESH_MAX_ATTEMPTS = 2; +export const CONTENT_MEDIA_USAGE_MAX_CHANGED_OCCURRENCES = 14; // These maps only de-dupe usage work inside the current isolate/process. Cross-worker // correctness comes from expected-generation guards on repository writes. @@ -32,6 +33,7 @@ export type ContentMediaUsageRefreshErrorCode = | "CONTENT_USAGE_REFRESH_ERROR" | "CONTENT_USAGE_DELETE_ERROR" | "CONTENT_USAGE_GENERATION_CONFLICT" + | "CONTENT_USAGE_RESOURCE_LIMIT" | "CONTENT_USAGE_STALE"; interface ContentMediaUsageRefreshOptions { @@ -179,9 +181,25 @@ async function refreshContentMediaUsageAttempt( const deletedSourceCount = await repo.deleteContentSources(collectionSlug, contentId); return { ...ZERO_RESULT, deletedSourceCount }; } + const oversizedUnchangedSourceKeys = options.durableWork + ? await preflightOversizedSnapshots(repo, snapshotsResult.snapshots, observedSources) + : new Set(); + if (!oversizedUnchangedSourceKeys) { + return { + success: false, + refreshedSourceCount: 0, + deletedSourceCount: 0, + failedSourceCount: 1, + errorCode: "CONTENT_USAGE_RESOURCE_LIMIT", + }; + } let refreshedSourceCount = 0; for (const snapshot of snapshotsResult.snapshots) { + if (oversizedUnchangedSourceKeys.has(snapshot.source.sourceKey)) { + refreshedSourceCount++; + continue; + } const result = await repo.replaceSourceIfMatching( snapshot.source, snapshot.occurrences, @@ -244,6 +262,28 @@ async function refreshContentMediaUsageAttempt( }; } +async function preflightOversizedSnapshots( + repo: MediaUsageRepository, + snapshots: Awaited< + Extract>, { success: true }> + >["snapshots"], + observedSources: Awaited>, +): Promise | null> { + const unchangedSourceKeys = new Set(); + for (const snapshot of snapshots) { + if (snapshot.occurrences.length <= CONTENT_MEDIA_USAGE_MAX_CHANGED_OCCURRENCES) continue; + const expectedSource = observedSources.get(snapshot.source.sourceKey); + if ( + !expectedSource || + !(await repo.projectionMatchesExpectedSource(snapshot.source, expectedSource)) + ) { + return null; + } + unchangedSourceKeys.add(snapshot.source.sourceKey); + } + return unchangedSourceKeys; +} + async function loadObservedContentSources( repo: MediaUsageRepository, collectionSlug: string, diff --git a/packages/core/src/media/usage/work-processor.ts b/packages/core/src/media/usage/work-processor.ts index ceefbfe52d..86e94dbb38 100644 --- a/packages/core/src/media/usage/work-processor.ts +++ b/packages/core/src/media/usage/work-processor.ts @@ -166,7 +166,10 @@ async function processCandidate( } const errorCode = processingErrorCode(refresh.errorCode); - if (claimed.attemptCount + 1 >= MEDIA_USAGE_WORK_PROCESSING_LIMITS.maxAttempts) { + if ( + errorCode === "MEDIA_USAGE_RESOURCE_LIMIT" || + claimed.attemptCount + 1 >= MEDIA_USAGE_WORK_PROCESSING_LIMITS.maxAttempts + ) { const failed = await repo.failWork({ ...lease, errorCode }); if (failed) { await new MediaUsageRepository(db).recordIncrementalFailure({ @@ -240,5 +243,8 @@ function processingErrorCode(errorCode: ContentMediaUsageRefreshErrorCode | unde if (errorCode === "CONTENT_USAGE_GENERATION_CONFLICT") { return "MEDIA_USAGE_GENERATION_CONFLICT"; } + if (errorCode === "CONTENT_USAGE_RESOURCE_LIMIT") { + return "MEDIA_USAGE_RESOURCE_LIMIT"; + } return "MEDIA_USAGE_PROCESSING_FAILED"; } diff --git a/packages/core/tests/integration/database/media-usage-work-measurement.test.ts b/packages/core/tests/integration/database/media-usage-work-measurement.test.ts new file mode 100644 index 0000000000..6f7680005f --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-work-measurement.test.ts @@ -0,0 +1,782 @@ +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect, sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import { MediaUsageWorkRepository } from "../../../src/database/repositories/media-usage-work.js"; +import type { Database as DatabaseSchema } from "../../../src/database/types.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; +import { installMediaUsageCaptureTriggers } from "../../../src/media/usage/capture-triggers.js"; +import { + processDueMediaUsageWork, + processMediaUsageWorkAfterWrite, +} from "../../../src/media/usage/work-processor.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { createTestRuntime } from "../../utils/mcp-runtime.js"; + +interface CapturedQuery { + sql: string; + parameters: readonly unknown[]; +} + +interface Measurement { + statements: number; + d1EquivalentStatements: number; + maxBinds: number; + changedRows: number; + durationMs: number; +} + +let sqlite: Database.Database; +let db: Kysely; +let captured: CapturedQuery[]; +let fixture: Awaited>; + +beforeEach(async () => { + setI18nConfig(null); + captured = []; + sqlite = new Database(":memory:"); + db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + log(event) { + if (event.level === "query") { + captured.push({ sql: event.query.sql, parameters: event.query.parameters }); + } + }, + }); + await runMigrations(db); + fixture = await createActiveFixture(); +}); + +afterEach(async () => { + setI18nConfig(null); + await db.destroy(); +}); + +it("measures complete changed jobs as portable-text and repeater occurrences grow", async () => { + let previousStatementCount = 0; + for (const occurrenceCount of [0, 1, 7, 8, 14]) { + const contentId = `columns-${occurrenceCount}`; + await insertEntry(contentId, mediaData(occurrenceCount, contentId)); + + const { value, measurement } = await measure(() => + processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), + ); + + expect(value.outcome).toBe("completed"); + expect(measurement.maxBinds).toBeLessThanOrEqual(100); + expect(measurement.statements).toBeGreaterThanOrEqual(previousStatementCount); + previousStatementCount = measurement.statements; + } +}); + +it("measures complete changed jobs with columns and draft-overlay sources", async () => { + let previousStatementCount = 0; + for (const occurrencesPerSource of [0, 7, 14]) { + const contentId = `both-${occurrencesPerSource}`; + await insertEntry(contentId, mediaData(occurrencesPerSource, `${contentId}-columns`)); + await addDraft(contentId, mediaData(occurrencesPerSource, `${contentId}-draft`)); + + const { value, measurement } = await measure(() => + processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), + ); + + expect(value.outcome).toBe("completed"); + expect(measurement.maxBinds).toBeLessThanOrEqual(100); + expect(measurement.statements).toBeGreaterThanOrEqual(previousStatementCount); + previousStatementCount = measurement.statements; + } +}); + +it("keeps the largest admitted scheduled job inside the Free-plan query budget", async () => { + const contentId = "scheduled-boundary"; + await insertEntry(contentId, mediaData(14, "scheduled-columns")); + await addDraft(contentId, mediaData(14, "scheduled-draft")); + + const result = await measure(() => processDueMediaUsageWork(db)); + + expect(result.value.completedCount).toBe(1); + expect(result.measurement.d1EquivalentStatements).toBeLessThanOrEqual(40); + expect(result.measurement.maxBinds).toBeLessThanOrEqual(100); +}); + +it("measures worst-position guarded conflicts across one and two sources", async () => { + const oneSourceCounts = [7, 14]; + for (const occurrenceCount of oneSourceCounts) { + const contentId = `conflict-one-${occurrenceCount}`; + await insertEntry(contentId, mediaData(occurrenceCount, `${contentId}-initial`)); + await processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId); + await updateEntry(contentId, mediaData(occurrenceCount, `${contentId}-changed`)); + } + await installAnySourceConflictTrigger(); + try { + for (const occurrenceCount of oneSourceCounts) { + const contentId = `conflict-one-${occurrenceCount}`; + const result = await measure(() => + processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), + ); + expect(result.value.outcome).toBe("retry"); + expect(result.measurement.maxBinds).toBeLessThanOrEqual(100); + } + } finally { + await sql`DROP TRIGGER measure_any_generation_conflict`.execute(db); + } + + const perSourceCounts = [7, 14]; + for (const occurrenceCount of perSourceCounts) { + const contentId = `conflict-both-${occurrenceCount}`; + await insertEntry(contentId, mediaData(occurrenceCount, `${contentId}-columns-initial`)); + await addDraft(contentId, mediaData(occurrenceCount, `${contentId}-draft-initial`)); + await processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId); + await updateEntry(contentId, mediaData(occurrenceCount, `${contentId}-columns-changed`)); + await updateDraft(contentId, mediaData(occurrenceCount, `${contentId}-draft-changed`)); + } + await installDraftSourceConflictTrigger(); + try { + for (const occurrenceCount of perSourceCounts) { + const contentId = `conflict-both-${occurrenceCount}`; + const result = await measure(() => + processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), + ); + expect(result.value.outcome).toBe("retry"); + expect(result.measurement.maxBinds).toBeLessThanOrEqual(100); + } + } finally { + await sql`DROP TRIGGER measure_draft_generation_conflict`.execute(db); + } +}); + +it("measures the complete runtime create and draft-update invocation", async () => { + const runtime = createTestRuntime(db); + + for (const occurrenceCount of [0, 7, 14]) { + const slug = `runtime-${occurrenceCount}`; + const created = await measure(() => + runtime.handleContentCreate(fixture.collectionSlug, { + slug, + data: { title: slug, ...mediaData(occurrenceCount, `${slug}-live`) }, + }), + ); + expect(created.value.success).toBe(true); + if (!created.value.success) throw new Error(created.value.error.message); + expect(created.measurement.maxBinds).toBeLessThanOrEqual(100); + + const updated = await measure(() => + runtime.handleContentUpdate(fixture.collectionSlug, created.value.data.item.id, { + data: mediaData(occurrenceCount, `${slug}-draft`), + }), + ); + expect(updated.value.success).toBe(true); + expect(updated.measurement.maxBinds).toBeLessThanOrEqual(100); + if (occurrenceCount === 14) { + expect(updated.measurement.d1EquivalentStatements).toBeLessThanOrEqual(41); + } + } +}); + +it("keeps translated sibling work out of the saved entry's immediate budget", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "fr", "de", "es"] }); + const localized = await createActiveLocalizedFixture(); + const runtime = createTestRuntime(db); + let rootId = ""; + for (const locale of ["en", "fr", "de", "es"]) { + const created = await runtime.handleContentCreate(localized.collectionSlug, { + slug: `localized-${locale}`, + locale, + translationOf: rootId || undefined, + data: { + title: locale, + shared_body: mediaData(1, `initial-${locale}`).body, + }, + }); + expect(created.success).toBe(true); + if (!created.success) throw new Error(created.error.message); + if (!rootId) rootId = created.data.item.id; + } + + const updated = await measure(() => + runtime.handleContentUpdate(localized.collectionSlug, rootId, { + data: { shared_body: mediaData(14, "shared").body }, + }), + ); + + expect(updated.value.success).toBe(true); + expect(updated.measurement.d1EquivalentStatements).toBeLessThanOrEqual(41); + expect(updated.measurement.maxBinds).toBeLessThanOrEqual(100); + const remaining = await db + .selectFrom("_emdash_media_usage_work") + .select(["content_id", "state"]) + .where("collection_id", "=", localized.collectionId) + .orderBy("content_id") + .execute(); + expect(remaining).toHaveLength(3); + expect(remaining.every((work) => work.state === "pending")).toBe(true); +}); + +it("measures the worst source split for candidate total-occurrence budgets", async () => { + const runtime = createTestRuntime(db); + const worst: Record = {}; + + for (const totalOccurrences of [14, 15, 21, 28]) { + const minimumLiveOccurrences = Math.max(0, totalOccurrences - 14); + const maximumLiveOccurrences = Math.min(14, totalOccurrences); + for ( + let liveOccurrences = minimumLiveOccurrences; + liveOccurrences <= maximumLiveOccurrences; + liveOccurrences++ + ) { + const draftOccurrences = totalOccurrences - liveOccurrences; + const slug = `split-${totalOccurrences}-${liveOccurrences}`; + const created = await runtime.handleContentCreate(fixture.collectionSlug, { + slug, + data: { title: slug, ...mediaData(liveOccurrences, `${slug}-live`) }, + }); + expect(created.success).toBe(true); + if (!created.success) throw new Error(created.error.message); + + const updated = await measure(() => + runtime.handleContentUpdate(fixture.collectionSlug, created.data.item.id, { + data: mediaData(draftOccurrences, `${slug}-draft`), + }), + ); + expect(updated.value.success).toBe(true); + if (updated.measurement.d1EquivalentStatements > (worst[totalOccurrences] ?? 0)) { + worst[totalOccurrences] = updated.measurement.d1EquivalentStatements; + } + } + } + + for (const totalOccurrences of [14, 15, 21, 28]) { + expect(worst[totalOccurrences]).toBeLessThanOrEqual(41); + } +}); + +it("measures complete runtime lifecycle invocations", async () => { + const runtime = createTestRuntime(db); + const results: Record = {}; + + const publishEntry = await runtime.handleContentCreate(fixture.collectionSlug, { + slug: "lifecycle-publish", + data: { title: "Publish", ...mediaData(0, "publish-live") }, + }); + if (!publishEntry.success) throw new Error(publishEntry.error.message); + await runtime.handleContentUpdate(fixture.collectionSlug, publishEntry.data.item.id, { + data: mediaData(14, "publish-draft"), + }); + const published = await measure(() => + runtime.handleContentPublish(fixture.collectionSlug, publishEntry.data.item.id), + ); + expect(published.value.success).toBe(true); + results.publish = published.measurement; + + const unpublished = await measure(() => + runtime.handleContentUnpublish(fixture.collectionSlug, publishEntry.data.item.id), + ); + expect(unpublished.value.success).toBe(true); + results.unpublish = unpublished.measurement; + + const scheduled = await measure(() => + runtime.handleContentSchedule( + fixture.collectionSlug, + publishEntry.data.item.id, + "2100-01-01T00:00:00.000Z", + ), + ); + expect(scheduled.value.success).toBe(true); + results.schedule = scheduled.measurement; + + const unscheduled = await measure(() => + runtime.handleContentUnschedule(fixture.collectionSlug, publishEntry.data.item.id), + ); + expect(unscheduled.value.success).toBe(true); + results.unschedule = unscheduled.measurement; + + const trashed = await measure(() => + runtime.handleContentDelete(fixture.collectionSlug, publishEntry.data.item.id), + ); + expect(trashed.value.success).toBe(true); + results.trash = trashed.measurement; + + const restored = await measure(() => + runtime.handleContentRestore(fixture.collectionSlug, publishEntry.data.item.id), + ); + expect(restored.value.success).toBe(true); + results.restore = restored.measurement; + + const trashedAgain = await runtime.handleContentDelete( + fixture.collectionSlug, + publishEntry.data.item.id, + ); + expect(trashedAgain.success).toBe(true); + const permanentlyDeleted = await measure(() => + runtime.handleContentPermanentDelete(fixture.collectionSlug, publishEntry.data.item.id), + ); + expect(permanentlyDeleted.value.success).toBe(true); + results.permanentDelete = permanentlyDeleted.measurement; + + expect(results.publish?.d1EquivalentStatements).toBeLessThanOrEqual(33); + expect(results.unpublish?.d1EquivalentStatements).toBeLessThanOrEqual(39); + expect(results.schedule?.d1EquivalentStatements).toBeLessThanOrEqual(35); + expect(results.unschedule?.d1EquivalentStatements).toBeLessThanOrEqual(35); + expect(results.trash?.d1EquivalentStatements).toBeLessThanOrEqual(32); + expect(results.restore?.d1EquivalentStatements).toBeLessThanOrEqual(32); + expect(results.permanentDelete?.d1EquivalentStatements).toBeLessThanOrEqual(22); + for (const measurement of Object.values(results)) { + expect(measurement.maxBinds).toBeLessThanOrEqual(100); + } +}); + +it("measures no-op, changed, conflict, absence, and dense-backlog paths", async () => { + const contentId = "path-matrix"; + await insertEntry(contentId, mediaData(14, "path-columns-initial")); + await addDraft(contentId, mediaData(14, "path-draft-initial")); + await processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId); + + await updateEntry(contentId, mediaData(14, "path-columns-initial")); + const noOp = await measure(() => + processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), + ); + expect(noOp.value.outcome).toBe("completed"); + + await updateEntry(contentId, mediaData(14, "path-columns-changed")); + await updateDraft(contentId, mediaData(14, "path-draft-changed")); + const changed = await measure(() => + processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), + ); + expect(changed.value.outcome).toBe("completed"); + + const conflictId = "path-conflict"; + await insertEntry(conflictId, mediaData(14, "conflict-initial")); + await processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, conflictId); + await updateEntry(conflictId, mediaData(14, "conflict-changed")); + await installGenerationConflictTrigger(); + let conflict: { + value: Awaited>; + measurement: Measurement; + } | null = null; + try { + conflict = await measure(() => + processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, conflictId), + ); + expect(conflict.value.outcome).toBe("retry"); + } finally { + await sql`DROP TRIGGER measure_generation_conflict`.execute(db); + } + if (!conflict) throw new Error("Expected conflict measurement"); + + const absentId = "path-absent"; + await insertEntry(absentId, mediaData(14, "absent")); + await addDraft(absentId, mediaData(14, "absent-draft")); + await processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, absentId); + await sql`DELETE FROM ${sql.ref(fixture.tableName)} WHERE id = ${absentId}`.execute(db); + const absent = await measure(() => + processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, absentId), + ); + expect(absent.value.outcome).toBe("completed"); + + const oversizedId = "path-oversized"; + await insertEntry(oversizedId, mediaData(15, "oversized")); + const oversized = await measure(() => + processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, oversizedId), + ); + expect(oversized.value.outcome).toBe("failed"); + + for (let index = 0; index < 120; index++) { + await insertEntry(`backlog-${index}`, mediaData(1, `backlog-${index}`)); + } + const backlog = await measure(() => processDueMediaUsageWork(db)); + expect(backlog.value.candidateCount).toBe(4); + expect(backlog.value.claimedCount).toBe(1); + + for (const result of [noOp, changed, conflict, absent, oversized, backlog]) { + expect(result.measurement.maxBinds).toBeLessThanOrEqual(100); + } + expect(noOp.measurement.d1EquivalentStatements).toBeLessThanOrEqual(15); + expect(changed.measurement.d1EquivalentStatements).toBeLessThanOrEqual(32); + expect(conflict.measurement.d1EquivalentStatements).toBeLessThanOrEqual(45); + expect(absent.measurement.d1EquivalentStatements).toBeLessThanOrEqual(21); + expect(oversized.measurement.d1EquivalentStatements).toBeLessThanOrEqual(20); + expect(backlog.measurement.d1EquivalentStatements).toBeLessThanOrEqual(20); +}); + +it("keeps operator pages bounded and on the operator index", async () => { + for (let index = 0; index < 128; index++) { + await insertOperatorWork( + `operator-${String(index).padStart(3, "0")}`, + ["pending", "retry", "leased", "failed"][index % 4] as + | "pending" + | "retry" + | "leased" + | "failed", + `2026-08-06T12:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`, + ); + } + + const repo = new MediaUsageWorkRepository(db); + const filtered = await measure(() => + repo.findOperatorPage({ collectionSlug: fixture.collectionSlug, state: "failed", limit: 50 }), + ); + expect(filtered.value?.items).toHaveLength(32); + expect(filtered.measurement.d1EquivalentStatements).toBe(2); + expectOperatorPlans(filtered.queries, 1); + + const unfiltered = await measure(() => + repo.findOperatorPage({ collectionSlug: fixture.collectionSlug, limit: 50 }), + ); + expect(unfiltered.value?.items).toHaveLength(50); + expect(unfiltered.value?.nextCursor).toEqual(expect.any(String)); + expect(unfiltered.measurement.d1EquivalentStatements).toBe(5); + expectOperatorPlans(unfiltered.queries, 4); +}); + +it("bounds every operator retry outcome", async () => { + const repo = new MediaUsageWorkRepository(db); + await insertOperatorWork("retry-pending", "pending", "2026-08-06T12:00:00.000Z"); + const pending = await measure(() => + repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-pending" }), + ); + expect(pending.value).toEqual(expect.objectContaining({ outcome: "pending", changed: false })); + expect(pending.measurement.d1EquivalentStatements).toBe(2); + + await insertOperatorWork("retry-failed", "failed", "2026-08-06T12:00:01.000Z"); + const failed = await measure(() => + repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-failed" }), + ); + expect(failed.value).toEqual(expect.objectContaining({ outcome: "pending", changed: true })); + expect(failed.measurement.d1EquivalentStatements).toBeLessThanOrEqual(4); + + await insertOperatorWork("retry-expired", "leased", "2026-08-06T12:00:02.000Z"); + await db + .updateTable("_emdash_media_usage_work") + .set({ lease_expires_at: "2000-01-01T00:00:00.000Z" }) + .where("collection_id", "=", fixture.collectionId) + .where("content_id", "=", "retry-expired") + .execute(); + const expired = await measure(() => + repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-expired" }), + ); + expect(expired.value).toEqual(expect.objectContaining({ outcome: "pending", changed: true })); + expect(expired.measurement.d1EquivalentStatements).toBeLessThanOrEqual(4); + + await insertOperatorWork("retry-live", "leased", "2026-08-06T12:00:03.000Z"); + const live = await measure(() => + repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-live" }), + ); + expect(live.value).toEqual( + expect.objectContaining({ outcome: "lease_active", leaseExpiresAt: expect.any(String) }), + ); + expect(live.measurement.d1EquivalentStatements).toBeLessThanOrEqual(7); + + const missing = await measure(() => + repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-missing" }), + ); + expect(missing.value).toEqual(expect.objectContaining({ outcome: "pending", changed: true })); + expect(missing.measurement.d1EquivalentStatements).toBeLessThanOrEqual(4); + + await insertOperatorWork("retry-conflict", "failed", "2026-08-06T12:00:04.000Z"); + await sql` + CREATE TRIGGER measure_operator_retry_conflict + AFTER UPDATE OF change_epoch ON _emdash_media_usage_index_status + BEGIN + DELETE FROM _emdash_media_usage_work + WHERE content_id = 'retry-conflict'; + END + `.execute(db); + try { + const conflict = await measure(() => + repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-conflict" }), + ); + expect(conflict.value).toEqual({ outcome: "conflict" }); + expect(conflict.measurement.d1EquivalentStatements).toBeLessThanOrEqual(7); + } finally { + await sql`DROP TRIGGER measure_operator_retry_conflict`.execute(db); + } +}); + +async function createActiveFixture() { + const collectionSlug = "measured_jobs"; + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: collectionSlug, label: "Measured jobs" }); + await registry.createField(collectionSlug, { slug: "title", label: "Title", type: "string" }); + await registry.createField(collectionSlug, { + slug: "body", + label: "Body", + type: "portableText", + }); + await registry.createField(collectionSlug, { + slug: "sections", + label: "Sections", + type: "repeater", + validation: { subFields: [{ slug: "image", type: "image", label: "Image" }] }, + }); + const collection = await registry.getCollection(collectionSlug); + if (!collection) throw new Error("Expected measured collection"); + + await 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(db, { + collectionId: collection.id, + collectionSlug, + }); + await db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", collection.id) + .execute(); + await 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 createActiveLocalizedFixture() { + const collectionSlug = "localized_measured"; + const registry = new SchemaRegistry(db); + await registry.createCollection({ + slug: collectionSlug, + label: "Localized measured", + supports: [], + }); + await registry.createField(collectionSlug, { slug: "title", label: "Title", type: "string" }); + await registry.createField(collectionSlug, { + slug: "shared_body", + label: "Shared body", + type: "portableText", + translatable: false, + }); + const collection = await registry.getCollection(collectionSlug); + if (!collection) throw new Error("Expected localized measured collection"); + + await 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(db, { + collectionId: collection.id, + collectionSlug, + }); + await db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", collection.id) + .execute(); + + return { collectionId: collection.id, collectionSlug }; +} + +async function insertEntry(contentId: string, data: ReturnType): Promise { + await sql` + INSERT INTO ${sql.ref(fixture.tableName)} (id, slug, status, title, body, sections) + VALUES ( + ${contentId}, + ${contentId}, + 'published', + ${contentId}, + ${JSON.stringify(data.body)}, + ${JSON.stringify(data.sections)} + ) + `.execute(db); +} + +async function updateEntry(contentId: string, data: ReturnType): Promise { + await sql` + UPDATE ${sql.ref(fixture.tableName)} + SET body = ${JSON.stringify(data.body)}, sections = ${JSON.stringify(data.sections)} + WHERE id = ${contentId} + `.execute(db); +} + +async function addDraft(contentId: string, data: ReturnType): Promise { + const revisionId = `revision-${contentId}`; + await db + .insertInto("revisions") + .values({ + id: revisionId, + collection: fixture.collectionSlug, + entry_id: contentId, + data: JSON.stringify(data), + author_id: null, + }) + .execute(); + await sql` + UPDATE ${sql.ref(fixture.tableName)} + SET draft_revision_id = ${revisionId} + WHERE id = ${contentId} + `.execute(db); +} + +async function updateDraft(contentId: string, data: ReturnType): Promise { + await db + .updateTable("revisions") + .set({ data: JSON.stringify(data) }) + .where("id", "=", `revision-${contentId}`) + .execute(); + await sql` + UPDATE ${sql.ref(fixture.tableName)} + SET title = title + WHERE id = ${contentId} + `.execute(db); +} + +async function insertOperatorWork( + contentId: string, + state: "pending" | "retry" | "leased" | "failed", + updatedAt: string, +): Promise { + await db + .insertInto("_emdash_media_usage_work") + .values({ + collection_id: fixture.collectionId, + collection_slug: fixture.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: state === "leased" ? `lease-${contentId}` : null, + lease_expires_at: state === "leased" ? "2100-01-01T00:00:00.000Z" : null, + last_attempted_at: null, + last_error_code: state === "failed" ? "MEDIA_USAGE_PROCESSING_FAILED" : null, + created_at: updatedAt, + updated_at: updatedAt, + }) + .execute(); +} + +function mediaData(count: number, prefix: string) { + const portableTextCount = Math.ceil(count / 2); + const repeaterCount = Math.floor(count / 2); + return { + body: Array.from({ length: portableTextCount }, (_, index) => ({ + _type: "image", + _key: `body-${index}`, + asset: { _ref: `${prefix}-body-${index}` }, + })), + sections: Array.from({ length: repeaterCount }, (_, index) => ({ + _key: `section-${index}`, + image: { + id: `${prefix}-section-${index}`, + provider: "local", + mimeType: "image/webp", + }, + })), + }; +} + +async function installGenerationConflictTrigger(): Promise { + await sql` + CREATE TRIGGER measure_generation_conflict + AFTER INSERT ON _emdash_media_usage_generation_writes + BEGIN + UPDATE _emdash_media_usage_sources + SET updated_at = updated_at || 'x' + WHERE content_id = 'path-conflict'; + END + `.execute(db); +} + +async function installAnySourceConflictTrigger(): Promise { + await sql` + CREATE TRIGGER measure_any_generation_conflict + AFTER INSERT ON _emdash_media_usage_generation_writes + BEGIN + UPDATE _emdash_media_usage_sources + SET updated_at = updated_at || 'x' + WHERE source_key = NEW.source_key; + END + `.execute(db); +} + +async function installDraftSourceConflictTrigger(): Promise { + await sql` + CREATE TRIGGER measure_draft_generation_conflict + AFTER INSERT ON _emdash_media_usage_generation_writes + WHEN NEW.source_key LIKE '%:draft_overlay' + BEGIN + UPDATE _emdash_media_usage_sources + SET updated_at = updated_at || 'x' + WHERE source_key = NEW.source_key; + END + `.execute(db); +} + +async function measure( + operation: () => Promise, +): Promise<{ value: T; measurement: Measurement; queries: CapturedQuery[] }> { + captured = []; + const beforeChanges = totalChanges(); + const startedAt = performance.now(); + const value = await operation(); + const durationMs = performance.now() - startedAt; + return { + value, + queries: [...captured], + measurement: { + statements: captured.length, + d1EquivalentStatements: captured.filter( + (query) => !/^(?:begin|commit|rollback)$/i.test(query.sql), + ).length, + maxBinds: Math.max(0, ...captured.map((query) => query.parameters.length)), + changedRows: totalChanges() - beforeChanges, + durationMs: Number(durationMs.toFixed(3)), + }, + }; +} + +function totalChanges(): number { + return (sqlite.prepare("SELECT total_changes() AS count").get() as { count: number }).count; +} + +function expectOperatorPlans(queries: CapturedQuery[], expectedCount: number): void { + const listQueries = queries.filter((query) => + query.sql.includes('from "_emdash_media_usage_work" as "work"'), + ); + expect(listQueries).toHaveLength(expectedCount); + for (const query of listQueries) { + const plan = explain(query); + expect(plan).toContain("idx__emdash_media_usage_work_operator"); + expect(plan).not.toContain("USE TEMP B-TREE"); + expect(plan).not.toContain("SCAN work"); + } +} + +function explain(query: CapturedQuery): string { + const rows = sqlite + .prepare(`EXPLAIN QUERY PLAN ${query.sql}`) + .all(...query.parameters.map(bindable)) as { detail: string }[]; + return rows.map((row) => row.detail).join("\n"); +} + +function bindable(parameter: unknown): unknown { + if (typeof parameter === "boolean") return parameter ? 1 : 0; + if (parameter instanceof Date) return parameter.toISOString(); + if (parameter === undefined) return null; + return parameter; +} 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 index a73d4e668f..794b464438 100644 --- a/packages/core/tests/integration/database/media-usage-work-processor.test.ts +++ b/packages/core/tests/integration/database/media-usage-work-processor.test.ts @@ -10,9 +10,11 @@ import type { import { sql } from "kysely"; import { afterEach, beforeEach, expect, it } from "vitest"; +import { MediaUsageWorkRepository } from "../../../src/database/repositories/media-usage-work.js"; 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 { loadContentMediaUsageSnapshots } from "../../../src/media/usage/content-snapshots.js"; import { MEDIA_USAGE_WORK_PROCESSING_LIMITS, processDueMediaUsageWork, @@ -250,6 +252,143 @@ describeEachDialect("media usage durable work processing", (dialect) => { ); }); + it("publishes changed live and draft projections at the 14-occurrence boundary", async () => { + const fixture = await createResourceFixture(ctx, "bounded_usage"); + await insertResourceEntry(ctx, fixture, "entry-1", mediaData(14, "live")); + await addResourceDraft(ctx, fixture, "entry-1", mediaData(14, "draft")); + + const result = await processMediaUsageWorkAfterWrite(ctx.db, "bounded_usage", "entry-1"); + + expect(result.outcome).toBe("completed"); + expect(await countWork(ctx.db)).toBe(0); + expect( + await countCurrentOccurrences(ctx.db, canonicalSourceKey(fixture.collectionId, "entry-1")), + ).toBe(14); + expect( + await countCurrentOccurrences( + ctx.db, + canonicalSourceKey(fixture.collectionId, "entry-1", "draft_overlay"), + ), + ).toBe(14); + }); + + it("fails changed oversized work terminally before publishing either projection", async () => { + const fixture = await createResourceFixture(ctx, "oversized_usage"); + await insertResourceEntry(ctx, fixture, "entry-1", mediaData(1, "initial-live")); + await addResourceDraft(ctx, fixture, "entry-1", mediaData(1, "initial-draft")); + expect( + (await processMediaUsageWorkAfterWrite(ctx.db, "oversized_usage", "entry-1")).outcome, + ).toBe("completed"); + + const usageRepo = new MediaUsageRepository(ctx.db); + const columnsKey = canonicalSourceKey(fixture.collectionId, "entry-1"); + const draftKey = canonicalSourceKey(fixture.collectionId, "entry-1", "draft_overlay"); + const columnsBefore = await usageRepo.findSource(columnsKey); + const draftBefore = await usageRepo.findSource(draftKey); + const storedColumnsBefore = await countStoredOccurrences(ctx.db, columnsKey); + const storedDraftBefore = await countStoredOccurrences(ctx.db, draftKey); + await updateResourceEntry(ctx, fixture, "entry-1", mediaData(14, "changed-live")); + await updateResourceDraft(ctx, "entry-1", mediaData(15, "changed-draft")); + + const result = await processMediaUsageWorkAfterWrite(ctx.db, "oversized_usage", "entry-1"); + + expect(result.outcome).toBe("failed"); + expect((await usageRepo.findSource(columnsKey))?.currentGeneration).toBe( + columnsBefore?.currentGeneration, + ); + expect((await usageRepo.findSource(draftKey))?.currentGeneration).toBe( + draftBefore?.currentGeneration, + ); + expect(await countCurrentOccurrences(ctx.db, columnsKey)).toBe(1); + expect(await countCurrentOccurrences(ctx.db, draftKey)).toBe(1); + expect(await countStoredOccurrences(ctx.db, columnsKey)).toBe(storedColumnsBefore); + expect(await countStoredOccurrences(ctx.db, draftKey)).toBe(storedDraftBefore); + expect(await findWork(ctx.db)).toEqual( + expect.objectContaining({ + state: "failed", + attempt_count: 1, + last_error_code: "MEDIA_USAGE_RESOURCE_LIMIT", + }), + ); + expect(await findCoverageStatus(ctx.db, fixture.collectionId)).toEqual( + expect.objectContaining({ + status: "partial", + reconciliation_required: 0, + last_error_code: "MEDIA_USAGE_RESOURCE_LIMIT", + }), + ); + + const reopened = await new MediaUsageWorkRepository(ctx.db).retryOperatorWork({ + collectionId: fixture.collectionId, + contentId: "entry-1", + }); + expect(reopened).toEqual(expect.objectContaining({ outcome: "pending", changed: true })); + expect( + (await processMediaUsageWorkAfterWrite(ctx.db, "oversized_usage", "entry-1")).outcome, + ).toBe("failed"); + expect(await findWork(ctx.db)).toEqual( + expect.objectContaining({ + state: "failed", + attempt_count: 1, + last_error_code: "MEDIA_USAGE_RESOURCE_LIMIT", + }), + ); + expect((await usageRepo.findSource(columnsKey))?.currentGeneration).toBe( + columnsBefore?.currentGeneration, + ); + expect((await usageRepo.findSource(draftKey))?.currentGeneration).toBe( + draftBefore?.currentGeneration, + ); + }); + + it("completes an oversized projection only when the stored projection is an unchanged no-op", async () => { + const fixture = await createResourceFixture(ctx, "oversized_noop"); + await insertResourceEntry(ctx, fixture, "entry-1", mediaData(15, "unchanged")); + const snapshots = await loadContentMediaUsageSnapshots( + ctx.db, + fixture.collectionSlug, + "entry-1", + undefined, + { collectionId: fixture.collectionId, identityVersion: 1 }, + ); + if (!snapshots.success) throw new Error(`Expected snapshots, received ${snapshots.error}`); + const usageRepo = new MediaUsageRepository(ctx.db); + for (const snapshot of snapshots.snapshots) { + await usageRepo.replaceSource(snapshot.source, snapshot.occurrences); + } + const sourceKey = canonicalSourceKey(fixture.collectionId, "entry-1"); + const before = await usageRepo.findSource(sourceKey); + + const result = await processMediaUsageWorkAfterWrite(ctx.db, "oversized_noop", "entry-1"); + + expect(result.outcome).toBe("completed"); + expect(await countWork(ctx.db)).toBe(0); + expect((await usageRepo.findSource(sourceKey))?.currentGeneration).toBe( + before?.currentGeneration, + ); + expect(await countCurrentOccurrences(ctx.db, sourceKey)).toBe(15); + + await ctx.db + .updateTable("_emdash_media_usage_sources") + .set({ last_error_code: "PROJECTION_INVALID" }) + .where("source_key", "=", sourceKey) + .execute(); + await sql` + UPDATE ${sql.ref(fixture.tableName)} + SET title = title + WHERE id = 'entry-1' + `.execute(ctx.db); + expect( + (await processMediaUsageWorkAfterWrite(ctx.db, "oversized_noop", "entry-1")).outcome, + ).toBe("failed"); + expect(await findWork(ctx.db)).toEqual( + expect.objectContaining({ last_error_code: "MEDIA_USAGE_RESOURCE_LIMIT" }), + ); + expect((await usageRepo.findSource(sourceKey))?.currentGeneration).toBe( + before?.currentGeneration, + ); + }); + it("reconciles permanent entry absence without leaving work", async () => { const fixture = await createActiveFixture(ctx, "documents"); await insertEntry(ctx, fixture, "entry-1", "media-1"); @@ -359,6 +498,19 @@ async function createActiveFixture(ctx: DialectTestContext, collectionSlug: stri }; } +async function createResourceFixture(ctx: DialectTestContext, collectionSlug: string) { + const fixture = await createActiveFixture(ctx, collectionSlug); + const registry = new SchemaRegistry(ctx.db); + await registry.createField(collectionSlug, { slug: "body", label: "Body", type: "portableText" }); + await registry.createField(collectionSlug, { + slug: "sections", + label: "Sections", + type: "repeater", + validation: { subFields: [{ slug: "image", type: "image", label: "Image" }] }, + }); + return fixture; +} + async function insertEntry( ctx: DialectTestContext, fixture: Awaited>, @@ -377,8 +529,124 @@ async function insertEntry( `.execute(ctx.db); } -function canonicalSourceKey(collectionId: string, contentId: string): string { - return `content:${collectionId}:${contentId}:columns`; +async function insertResourceEntry( + ctx: DialectTestContext, + fixture: Awaited>, + contentId: string, + data: ReturnType, +): Promise { + await sql` + INSERT INTO ${sql.ref(fixture.tableName)} (id, slug, status, title, hero, body, sections) + VALUES ( + ${contentId}, + ${contentId}, + 'published', + ${contentId}, + NULL, + ${JSON.stringify(data.body)}, + ${JSON.stringify(data.sections)} + ) + `.execute(ctx.db); +} + +async function updateResourceEntry( + ctx: DialectTestContext, + fixture: Awaited>, + contentId: string, + data: ReturnType, +): Promise { + await sql` + UPDATE ${sql.ref(fixture.tableName)} + SET body = ${JSON.stringify(data.body)}, sections = ${JSON.stringify(data.sections)} + WHERE id = ${contentId} + `.execute(ctx.db); +} + +async function addResourceDraft( + ctx: DialectTestContext, + fixture: Awaited>, + contentId: string, + data: ReturnType, +): Promise { + const revisionId = `revision-${contentId}`; + await ctx.db + .insertInto("revisions") + .values({ + id: revisionId, + collection: fixture.collectionSlug, + entry_id: contentId, + data: JSON.stringify(data), + author_id: null, + }) + .execute(); + await sql` + UPDATE ${sql.ref(fixture.tableName)} + SET draft_revision_id = ${revisionId} + WHERE id = ${contentId} + `.execute(ctx.db); +} + +async function updateResourceDraft( + ctx: DialectTestContext, + contentId: string, + data: ReturnType, +): Promise { + await ctx.db + .updateTable("revisions") + .set({ data: JSON.stringify(data) }) + .where("id", "=", `revision-${contentId}`) + .execute(); +} + +function mediaData(count: number, prefix: string) { + const portableTextCount = Math.ceil(count / 2); + const repeaterCount = Math.floor(count / 2); + return { + body: Array.from({ length: portableTextCount }, (_, index) => ({ + _type: "image", + _key: `body-${index}`, + asset: { _ref: `${prefix}-body-${index}` }, + })), + sections: Array.from({ length: repeaterCount }, (_, index) => ({ + _key: `section-${index}`, + image: { + id: `${prefix}-section-${index}`, + provider: "local", + mimeType: "image/webp", + }, + })), + }; +} + +function canonicalSourceKey( + collectionId: string, + contentId: string, + sourceVariant = "columns", +): string { + return `content:${collectionId}:${contentId}:${sourceVariant}`; +} + +async function countCurrentOccurrences(db: Kysely, sourceKey: string): Promise { + const result = await db + .selectFrom("_emdash_media_usage as usage") + .innerJoin("_emdash_media_usage_sources as source", (join) => + join + .onRef("source.source_key", "=", "usage.source_key") + .onRef("source.current_generation", "=", "usage.generation"), + ) + .select((eb) => eb.fn.countAll().as("count")) + .where("source.source_key", "=", sourceKey) + .executeTakeFirstOrThrow(); + return Number(result.count); +} + +async function countStoredOccurrences(db: Kysely, sourceKey: string): Promise { + const result = await db + .selectFrom("_emdash_media_usage") + .select((eb) => eb.fn.countAll().as("count")) + .where("source_key", "=", sourceKey) + .executeTakeFirstOrThrow(); + return Number(result.count); } async function countWork(db: Kysely): Promise { 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/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([ From e21e3eed111049fc2b1d04a27899124096e775a2 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Fri, 7 Aug 2026 17:41:23 +0100 Subject: [PATCH 06/11] feat(core): add media usage activation service --- .changeset/tidy-berries-activate.md | 5 + packages/core/src/media/usage/activation.ts | 427 ++++++++++++++++++ .../core/src/media/usage/capture-triggers.ts | 51 ++- .../database/media-usage-activation.test.ts | 308 +++++++++++++ 4 files changed, 777 insertions(+), 14 deletions(-) create mode 100644 .changeset/tidy-berries-activate.md create mode 100644 packages/core/src/media/usage/activation.ts create mode 100644 packages/core/tests/integration/database/media-usage-activation.test.ts diff --git a/.changeset/tidy-berries-activate.md b/.changeset/tidy-berries-activate.md new file mode 100644 index 0000000000..d38a4d7326 --- /dev/null +++ b/.changeset/tidy-berries-activate.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Adds the explicit activation step required to enable durable Media Usage indexing safely after deployment writers have been drained. diff --git a/packages/core/src/media/usage/activation.ts b/packages/core/src/media/usage/activation.ts new file mode 100644 index 0000000000..a4cb38f5be --- /dev/null +++ b/packages/core/src/media/usage/activation.ts @@ -0,0 +1,427 @@ +import { sql, type Kysely, type RawBuilder, type Selectable } from "kysely"; +import { ulid } from "ulidx"; + +import { isPostgres } 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 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(); +} + +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 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 status IN ('complete', 'running') THEN 'stale' ELSE status END`, + completed_at: sql< + string | null + >`CASE WHEN status IN ('complete', 'running') THEN NULL ELSE completed_at END`, + cursor: null, + collection_id: collection.id, + reconciliation_required: 1, + capture_state: "installing", + updated_at: now, + }) + .where((eb) => + eb.and([ + eb.or([eb("collection_id", "is", null), eb("collection_id", "=", collection.id)]), + eb.or([eb("capture_state", "is", null), eb("capture_state", "!=", "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 index 0c524088f7..0eb557fc89 100644 --- a/packages/core/src/media/usage/capture-triggers.ts +++ b/packages/core/src/media/usage/capture-triggers.ts @@ -21,6 +21,7 @@ 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); @@ -30,10 +31,21 @@ export async function installMediaUsageCaptureTriggers( } await assertCaptureLifecycle(db, identity, ["installing"]); - await removeCaptureTriggers(db, identifiers.tableName, installedNames); + 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)), + ); - if (isPostgres(db)) { - for (const operation of captureOperations) { + for (const operation of captureOperations) { + if (retainedNames.has(identifiers.triggerNames[operation])) continue; + if (isPostgres(db)) { await postgresCreateTrigger( db, identifiers.tableName, @@ -41,23 +53,34 @@ export async function installMediaUsageCaptureTriggers( operation, identity, ); + } else { + await sqliteCreateTrigger( + db, + identifiers.tableName, + identifiers.triggerNames[operation], + operation, + identity, + ); } - return; - } - - for (const operation of captureOperations) { - 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, 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..d996bcb7da --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-activation.test.ts @@ -0,0 +1,308 @@ +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + activateMediaUsageCapture, + MEDIA_USAGE_ACTIVATION_LIMITS, +} from "../../../src/media/usage/activation.js"; +import { installMediaUsageCaptureTriggers } 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"; + +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("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 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(); + } +}); From 38d77381c5909fb8896b4fb01fce7709c9c03564 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Sun, 9 Aug 2026 10:50:10 +0100 Subject: [PATCH 07/11] fix(core): address media usage sequence 1 review findings --- packages/cloudflare/src/sandbox/bridge.ts | 30 +- packages/cloudflare/src/sandbox/runner.ts | 9 +- packages/cloudflare/src/sandbox/wrapper.ts | 34 +- .../bridge-content-write-fence.test.ts | 120 +++++ .../tests/sandbox/runner-route-error.test.ts | 76 +++ packages/core/package.json | 4 + packages/core/src/api/handlers/media-usage.ts | 8 +- .../core/src/api/media-usage-write-fence.ts | 64 +++ packages/core/src/api/schemas/media-usage.ts | 30 +- packages/core/src/astro/integration/index.ts | 5 + .../middleware/media-usage-write-fence.ts | 30 ++ .../routes/api/admin/plugins/[id]/disable.ts | 4 + .../routes/api/admin/plugins/[id]/enable.ts | 4 + .../api/admin/plugins/[id]/uninstall.ts | 4 + .../routes/api/admin/plugins/[id]/update.ts | 4 + .../admin/plugins/marketplace/[id]/install.ts | 4 + .../admin/plugins/registry/[id]/uninstall.ts | 4 + .../api/admin/plugins/registry/[id]/update.ts | 4 + .../api/admin/plugins/registry/install.ts | 4 + .../database/repositories/media-usage-work.ts | 5 +- .../src/database/repositories/media-usage.ts | 77 ++- packages/core/src/emdash-runtime.ts | 45 +- packages/core/src/index.ts | 7 + packages/core/src/media/usage/activation.ts | 194 +++++++ .../core/src/media/usage/content-refresh.ts | 28 + packages/core/src/plugins/context.ts | 13 +- packages/core/src/plugins/index.ts | 7 + packages/core/src/plugins/routes.ts | 8 + packages/core/src/plugins/sandbox/index.ts | 11 +- packages/core/src/plugins/sandbox/types.ts | 72 +++ packages/core/src/schema/registry.ts | 478 ++++++++++++++---- .../astro/admin-plugins-sandboxed.test.ts | 65 ++- .../database/media-usage-activation.test.ts | 389 +++++++++++++- .../media-usage-read-repository.test.ts | 117 ++++- .../media-usage-runtime-refresh.test.ts | 2 +- .../database/media-usage-stale-bypass.test.ts | 137 +++++ .../media-usage-work-processor.test.ts | 27 +- .../runtime/plugin-cron-route.test.ts | 48 +- .../runtime/plugin-route-site-info.test.ts | 49 +- .../unit/api/media-usage-summary.test.ts | 33 ++ .../astro/media-usage-write-fence.test.ts | 90 ++++ .../core/tests/unit/scheduled-publish.test.ts | 27 + packages/core/tsdown.config.ts | 1 + .../workerd/src/sandbox/backing-service.ts | 1 + .../workerd/src/sandbox/bridge-handler.ts | 21 +- packages/workerd/src/sandbox/dev-runner.ts | 11 + packages/workerd/src/sandbox/runner.ts | 20 +- packages/workerd/src/sandbox/wrapper.ts | 45 ++ packages/workerd/test/bridge-handler.test.ts | 39 +- .../test/dev-runner-route-error.test.ts | 62 +++ .../workerd/test/workerd-integration.test.ts | 52 ++ 51 files changed, 2430 insertions(+), 193 deletions(-) create mode 100644 packages/cloudflare/tests/sandbox/bridge-content-write-fence.test.ts create mode 100644 packages/cloudflare/tests/sandbox/runner-route-error.test.ts create mode 100644 packages/core/src/api/media-usage-write-fence.ts create mode 100644 packages/core/src/astro/middleware/media-usage-write-fence.ts create mode 100644 packages/core/tests/unit/astro/media-usage-write-fence.test.ts create mode 100644 packages/workerd/test/dev-runner-route-error.test.ts 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/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..4ad2d6c445 --- /dev/null +++ b/packages/core/src/api/media-usage-write-fence.ts @@ -0,0 +1,64 @@ +import type { Kysely } from "kysely"; + +import type { Database } from "../database/types.js"; +import { isMissingTableError } from "../utils/db-errors.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 { + 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) { + if (isMissingTableError(error)) return null; + 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/schemas/media-usage.ts b/packages/core/src/api/schemas/media-usage.ts index 599c52870f..cfec6256a8 100644 --- a/packages/core/src/api/schemas/media-usage.ts +++ b/packages/core/src/api/schemas/media-usage.ts @@ -167,22 +167,20 @@ export const mediaUsageWorkRetryResponseSchema = z }) .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(), - }), - ]), - }) - .meta({ id: "MediaUsageWorkRetryConflict" }); +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; 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/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/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/database/repositories/media-usage-work.ts b/packages/core/src/database/repositories/media-usage-work.ts index ded65ebe9e..ffa48e1474 100644 --- a/packages/core/src/database/repositories/media-usage-work.ts +++ b/packages/core/src/database/repositories/media-usage-work.ts @@ -608,7 +608,10 @@ export class MediaUsageWorkRepository { column: "next_attempt_at" | "lease_expires_at" | "work.lease_expires_at", ): RawBuilder { return isPostgres(this.db) - ? sql`${sql.ref(column)}::timestamptz <= clock_timestamp()` + ? 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')`; } diff --git a/packages/core/src/database/repositories/media-usage.ts b/packages/core/src/database/repositories/media-usage.ts index 1185f45104..f958cc2c45 100644 --- a/packages/core/src/database/repositories/media-usage.ts +++ b/packages/core/src/database/repositories/media-usage.ts @@ -69,11 +69,33 @@ 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; @@ -310,6 +332,7 @@ export interface MediaUsageCollectionIndexStatusScope { collectionSlug: string; status: string | null; schemaVersion: number | null; + reconciliationRequired: boolean; } export interface MediaUsageEntrySource { @@ -632,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), ), ), @@ -669,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(); @@ -677,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, })); } @@ -691,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) { @@ -724,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"), ) @@ -732,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) @@ -1362,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 { @@ -1740,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); } diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 5b1bbcebae..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, @@ -52,6 +53,7 @@ import { 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, @@ -397,6 +399,7 @@ export interface EmDashRuntimeParts { pipelineFactoryOptions: { db: Kysely; getDb?: () => Kysely; + beforeContentWrite?: () => Promise; storage?: Storage; siteInfo?: { siteName?: string; @@ -600,6 +603,7 @@ export class EmDashRuntime { private pipelineFactoryOptions: { db: Kysely; getDb?: () => Kysely; + beforeContentWrite?: () => Promise; storage?: Storage; siteInfo?: { siteName?: string; @@ -672,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, }); } @@ -711,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); } @@ -1512,6 +1520,7 @@ export class EmDashRuntime { const pipelineFactoryOptions = { db, getDb: resolveDb, + beforeContentWrite: () => assertMediaUsageActivationWriteAllowed(resolveDb()), storage: storage ?? undefined, siteInfo, }; @@ -1666,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); } @@ -1994,6 +2003,7 @@ export class EmDashRuntime { createSandboxRunnerOptions( { db, + beforeContentWrite: () => assertMediaUsageActivationWriteAllowed(db), mediaStorage: mediaStorage ? { upload: (opts) => @@ -2117,6 +2127,7 @@ export class EmDashRuntime { createSandboxRunnerOptions( { db, + beforeContentWrite: () => assertMediaUsageActivationWriteAllowed(db), mediaStorage: { upload: (opts) => storage.upload({ @@ -4137,6 +4148,7 @@ export class EmDashRuntime { success: boolean; data?: unknown; error?: { code: string; message: string }; + status?: number; }> { const routeName = path.replace(LEADING_SLASH_PATTERN, ""); @@ -4155,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 index a4cb38f5be..e4595c7a44 100644 --- a/packages/core/src/media/usage/activation.ts +++ b/packages/core/src/media/usage/activation.ts @@ -3,6 +3,7 @@ import { ulid } from "ulidx"; import { isPostgres } from "../../database/dialect-helpers.js"; import type { Database, MediaUsageActivationTable } from "../../database/types.js"; +import { isMissingTableError } from "../../utils/db-errors.js"; import { installMediaUsageCaptureTriggers, verifyMediaUsageCaptureTriggers, @@ -27,6 +28,188 @@ export type MediaUsageActivationResult = | { 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 }, @@ -119,6 +302,17 @@ async function findActivation( .executeTakeFirstOrThrow(); } +async function findActivationIfAvailable( + db: Kysely, +): Promise | null> { + try { + return await findActivation(db); + } catch (error) { + if (isMissingTableError(error)) return null; + throw error; + } +} + function assertRuntimeGeneration(activation: Selectable): void { if (activation.runtime_generation !== MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION) { throw new Error("Media usage activation runtime generation mismatch"); diff --git a/packages/core/src/media/usage/content-refresh.ts b/packages/core/src/media/usage/content-refresh.ts index 1d27c94b66..6a33394986 100644 --- a/packages/core/src/media/usage/content-refresh.ts +++ b/packages/core/src/media/usage/content-refresh.ts @@ -4,6 +4,7 @@ import { MediaUsageRepository } from "../../database/repositories/media-usage.js import type { Database } from "../../database/types.js"; import { validateIdentifier } from "../../database/validate.js"; import { isI18nEnabled } from "../../i18n/config.js"; +import { isMissingTableError } from "../../utils/db-errors.js"; import { loadContentMediaUsageFields } from "./content-fields.js"; import { CONTENT_SOURCE_SCHEMA_VERSION, @@ -473,6 +474,33 @@ export async function markContentMediaUsageCollectionStale( }); } +export async function invalidateContentMediaUsageSchemaChange( + db: Kysely, + collectionSlug: string, +): Promise { + validateIdentifier(collectionSlug, "collection slug"); + let activation: { state: string } | undefined; + try { + activation = await db + .selectFrom("_emdash_media_usage_activation") + .select("state") + .where("task_key", "=", "incremental_capture") + .executeTakeFirst(); + } catch (error) { + if (isMissingTableError(error)) return false; + throw error; + } + 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, 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 index d996bcb7da..d73414a3d2 100644 --- a/packages/core/tests/integration/database/media-usage-activation.test.ts +++ b/packages/core/tests/integration/database/media-usage-activation.test.ts @@ -6,7 +6,10 @@ import { MEDIA_USAGE_ACTIVATION_LIMITS, } from "../../../src/media/usage/activation.js"; import { installMediaUsageCaptureTriggers } from "../../../src/media/usage/capture-triggers.js"; -import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + buildSeedCollectionCaptureFingerprint, + SchemaRegistry, +} from "../../../src/schema/registry.js"; import { describeEachDialect, setupForDialect, @@ -59,6 +62,390 @@ describeEachDialect("media usage production activation", (dialect) => { 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" }); 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-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-processor.test.ts b/packages/core/tests/integration/database/media-usage-work-processor.test.ts index 794b464438..c15d5f138b 100644 --- a/packages/core/tests/integration/database/media-usage-work-processor.test.ts +++ b/packages/core/tests/integration/database/media-usage-work-processor.test.ts @@ -21,6 +21,7 @@ import { processMediaUsageWorkAfterWrite, } from "../../../src/media/usage/work-processor.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; +import type { CreateFieldInput } from "../../../src/schema/types.js"; import { describeEachDialect, setupForDialect, @@ -456,11 +457,16 @@ class QueryCountingPlugin implements KyselyPlugin { } } -async function createActiveFixture(ctx: DialectTestContext, collectionSlug: string) { +async function createActiveFixture( + ctx: DialectTestContext, + collectionSlug: string, + extraFields: readonly CreateFieldInput[] = [], +) { 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" }); + for (const field of extraFields) await registry.createField(collectionSlug, field); const collection = await registry.getCollection(collectionSlug); if (!collection) throw new Error(`Expected ${collectionSlug} collection`); @@ -499,16 +505,15 @@ async function createActiveFixture(ctx: DialectTestContext, collectionSlug: stri } async function createResourceFixture(ctx: DialectTestContext, collectionSlug: string) { - const fixture = await createActiveFixture(ctx, collectionSlug); - const registry = new SchemaRegistry(ctx.db); - await registry.createField(collectionSlug, { slug: "body", label: "Body", type: "portableText" }); - await registry.createField(collectionSlug, { - slug: "sections", - label: "Sections", - type: "repeater", - validation: { subFields: [{ slug: "image", type: "image", label: "Image" }] }, - }); - return fixture; + return createActiveFixture(ctx, collectionSlug, [ + { slug: "body", label: "Body", type: "portableText" }, + { + slug: "sections", + label: "Sections", + type: "repeater", + validation: { subFields: [{ slug: "image", type: "image", label: "Image" }] }, + }, + ]); } async function insertEntry( 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/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/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( { From 1a790306c4a4d6be36ac8ea9828bd6116ad1e75e Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Mon, 10 Aug 2026 11:52:10 +0100 Subject: [PATCH 08/11] chore: untrack local media usage notes --- .../implementation/STEP_1.md | 31 --------- .../implementation/STEP_2.md | 20 ------ .../implementation/STEP_3.md | 33 --------- .../implementation/STEP_4.md | 67 ------------------- 4 files changed, 151 deletions(-) delete mode 100644 .opencode/memory/active/media-usage-index-used-in/implementation/STEP_1.md delete mode 100644 .opencode/memory/active/media-usage-index-used-in/implementation/STEP_2.md delete mode 100644 .opencode/memory/active/media-usage-index-used-in/implementation/STEP_3.md delete mode 100644 .opencode/memory/active/media-usage-index-used-in/implementation/STEP_4.md diff --git a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_1.md b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_1.md deleted file mode 100644 index a983fb6d1a..0000000000 --- a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_1.md +++ /dev/null @@ -1,31 +0,0 @@ -# Sequence 1 — Step 1 implementation - -## Boundary - -Step 1 expands the schema and provides a reusable, dialect-aware capture-trigger installer. Migration `057_media_usage_incremental_work` leaves the singleton activation state at `expanded`; it neither installs triggers nor changes V1 reads. Activation, collection-lifecycle orchestration, workers, retries, repair integration, deletion cleanup, and APIs remain later work. - -## Schema - -- `_emdash_media_usage_activation` is a singleton rollout fence with durable `expanded`/`activating`/`active` state, cursor, lease, attempt, error, drain-confirmation, and activation timestamps. -- `_emdash_media_usage_work` coalesces work by immutable `(collection_id, content_id)`, versions newer mutations, and stores pending/retry/lease/failure state. Due, expired-lease, and bounded operator indexes support later processing. -- Content-collection status rows gain immutable collection identity, epoch, reconciliation, capture lifecycle, incremental-success, and deletion-cleanup checkpoint/lease fields. -- Sources gain nullable `collection_id` and `identity_version`. Existing sources and fingerprints remain unbound and unchanged. -- Expansion binds only current `content-media` collection status rows, removes unmatched legacy status metadata, creates no historical work, and performs no content-table scan. - -## Capture triggers - -SQLite/libSQL/D1 use three table-specific row triggers. PostgreSQL uses one shared trigger function plus table-specific triggers. Names contain the trigger-contract version and a 128-bit SHA-256 identity suffix, and remain below PostgreSQL's 63-byte limit. Catalog verification checks the exact SQLite definition or PostgreSQL event, function and identity arguments before accepting a no-op. Every trigger matches the embedded collection ID and slug against both an `active` status row and the live registry row, advances the epoch, invalidates complete coverage, and upserts one pending work row. Any missing guard or work failure aborts the entire content statement. - -Database time is stored as sortable UTC ISO text. A complete current trigger set makes repeated installation a no-op. Missing, partial, or stale sets are repaired only while the durable lifecycle is `installing`, and still require an external writer fence or a table that is not yet reachable: a non-active lifecycle cannot guard an operation whose trigger is absent. - -## Tests and dialect limits - -Cross-dialect integration tests cover migration preservation/idempotency, expansion-only compatibility, insert/update/delete coalescing, state reset, inactive or mismatched lifecycle rejection, repeated installation, and multi-row all-or-nothing rollback. SQLite always runs; PostgreSQL runs when `EMDASH_TEST_PG` is configured. These tests do not claim real D1/workerd coverage; that smoke test remains required before the complete Sequence 1 implementation is review-ready. - -## Remaining risks - -- Capture cost is proportional to rows affected by a statement; supported bulk-writer limits are measured in later steps. -- Existing collection activation still requires the approved external writer drain. -- Migration rollback refuses to discard active lifecycle state, pending work, canonical identities, or installed capture triggers. -- New/seed/orphan/deletion lifecycle ordering is not switched on by Step 1; doing so safely requires the later durable lifecycle orchestration. -- PostgreSQL coverage is skipped when no test server is configured, and real D1 syntax/budget behavior is not exercised here. diff --git a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_2.md b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_2.md deleted file mode 100644 index 8e73133f69..0000000000 --- a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_2.md +++ /dev/null @@ -1,20 +0,0 @@ -# Sequence 1 — Step 2 implementation - -## Boundary - -Step 2 adds reusable conditional primitives for one durable entry job and makes guarded projection redelivery capable of a true no-op. It does not select candidates, connect content writes, run a scheduler, change coverage/repair epochs, activate canonical reads, or expose APIs. - -## Planned operations - -- Claim only an exact due `pending`/`retry` version or an exact expired `leased` version, using database time and a repository-generated lease token. -- Complete, retry, or fail only the exact version, token, collection/content identity, `leased` state, and unexpired database lease. -- Retry/failure increments the current attempt count and clears ownership; the caller supplies the later measured retry delay, a stable error code, and decides whether an error is terminal. -- A newer mutation or lease takeover makes every stale transition lose without changing the row. - -## Projection no-op - -Projection fingerprints are versioned SHA-256 digests over immutable collection identity, content/source identity, all V1-visible source metadata, extraction schema version, and a canonical occurrence representation. Guarded replacement reports an explicit unchanged outcome only for the current expected row and a current-format matching fingerprint; legacy fingerprints are never matches. An unchanged projection writes neither a source generation nor occurrences. - -## Tests and risks - -Cross-dialect tests cover due-state claims, competing owners, expired takeover, stale/newer work fencing, completion, retry, terminal failure, fingerprint invalidation, and generation-preserving redelivery. SQLite always runs; PostgreSQL requires `EMDASH_TEST_PG`. Real D1/workerd behavior and measured lease/backoff/resource constants remain later acceptance work. diff --git a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_3.md b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_3.md deleted file mode 100644 index 7fef3aa1f1..0000000000 --- a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_3.md +++ /dev/null @@ -1,33 +0,0 @@ -# Sequence 1 — Step 3 implementation - -## Boundary - -Step 3 connects trigger-created entry work to the authenticated post-write path and to the existing Cloudflare/Node maintenance heartbeat. It does not activate collections, change coverage/repair epochs, add operator APIs, or implement later acceptance/measurement work. - -## Processing flow - -- Before global activation, successful writes retain the released synchronous V1 refresh/delete behavior. -- After activation, the fast path resolves the current collection instance, conditionally claims only that entry's due work version, reconciles current database truth, and conditionally acknowledges the exact live lease. -- The scheduled driver uses the due, retry, and expired-lease indexes, merges a bounded candidate page, skips claim losers, and processes jobs sequentially through the same processor. Cloudflare calls it through `runScheduledTasks`; Node calls it from the timer maintenance callback. -- Canonical projections use immutable collection identity. Every canonical source write/delete rechecks the current registry ID and slug; obsolete-instance work is acknowledged without touching a replacement collection. - -## Initial bounds - -- return at most 4 due candidates after three index-backed probes capped at 4 rows each, and claim at most 1 job per scheduled tick; -- admit no new job after 5 seconds; -- lease one job for 60 seconds; -- retain a terminal failure after 5 failed attempts; -- retry from 30 seconds, capped at 15 minutes, with up to 25% jitter. -- keep an ordinary single-source job at or below 20 top-level database statements. - -These conservative exported limits leave headroom below Cloudflare's 50-query Free invocation limit, 100-bind query limit, and 30-second query limit. Behavioral tests measure the ordinary statement ceiling and generation-preserving no-op path; the final large-entry resource gate remains later work. - -## Failure behavior and tests - -Tests cover bounded/order-stable selection, immediate processing, duplicate/claim races, lease expiry, stale completion, projection no-op redelivery, retry/terminal transitions, entry deletion, and independent Cloudflare/Node scheduling. Process death before claim leaves pending work; death after claim recovers after expiry; death after projection redelivers as a fingerprint no-op; a newer work version cannot be acknowledged by the old owner. - -## Remaining risks - -- A single accepted entry can still contain many occurrences; the full resource-admission measurement and terminal resource envelope remain a later Sequence 1 acceptance task. -- Real PostgreSQL and D1/workerd verification remain required even though shared SQLite/PostgreSQL tests exercise the portable behavior. -- Hosts without scheduled maintenance retain durable work but receive no automatic freshness deadline. diff --git a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_4.md b/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_4.md deleted file mode 100644 index 920a361757..0000000000 --- a/.opencode/memory/active/media-usage-index-used-in/implementation/STEP_4.md +++ /dev/null @@ -1,67 +0,0 @@ -# Sequence 1 — Step 4 implementation - -## Boundary and verified starting point - -Step 4 makes the existing coverage epoch authoritative for incremental completion and the existing synchronous repair. It does not activate collections, add reconciliation paging, change public repair responses, add operator APIs, or implement Sequence 2. - -- Capture triggers already advance `change_epoch`, coalesce one work row per entry, and change trusted `complete` coverage to `stale` in the same content statement. -- The worker conditionally claims and acknowledges an exact version/lease, but acknowledgement currently does not restore truthful coverage or record terminal failure. -- Repair currently scans by slug, writes legacy slug-keyed sources, overwrites repair status without an epoch fence, and leaves durable work untouched. -- Schema expansion leaves global activation at `expanded`. Released V1 repair behaviour must remain unchanged until activation is `active`. - -## Smallest coherent implementation - -### Incremental completion - -After an exact work acknowledgement succeeds, read the current ID-bound status epoch, then conditionally update that same row. Record database-time `last_incremental_success_at`; restore `complete`, clear the error and set `completed_at` only when the observed epoch still matches, `reconciliation_required = 0`, no work row of any state remains for the collection, the current registry identity still matches, and the status is a previously trusted `complete`/`stale`/`partial` state. A concurrent trigger either advances the epoch before this update or runs afterward and makes coverage stale again. - -Retry retains the trigger-established non-complete state. After an exact terminal-failure transition succeeds, conditionally record its stable error on the current ID-bound status. A row with `reconciliation_required = 0` becomes `partial`. An untrusted `running` row becomes `stale`; any other untrusted non-complete state is preserved. The update must still prove that the exact failed work version exists so a newer mutation cannot publish an obsolete failure. - -Obsolete, claim-lost and superseded work never changes current collection coverage. A database failure after acknowledgement can leave coverage conservatively non-complete, but cannot create false completeness or resurrect work. - -### Activation-aware manual repair - -Repair resolves the current collection ID once. While global activation is not `active`, it preserves the released slug-keyed path and status contract exactly. While activation is `active`, it uses an epoch-fenced canonical path: - -1. Atomically change the exact current ID-bound status row to `running`, set `reconciliation_required = true`, retain existing work, and return its starting `change_epoch` with the repair token. This keeps a crashed non-resumable repair from later being mistaken for an incrementally recoverable baseline. Missing or mismatched active lifecycle state fails safely as the existing structured repair error rather than creating a slug-only status row. -2. Scan the existing collection synchronously as today, but bind content-ID scans, field discovery, snapshots, source selection and source mutation to that exact collection ID. Build, load, write and delete only canonical source keys carrying the ID and identity version. Unbound legacy sources are neither selected nor mutated. -3. Keep the existing guarded source conflict rules and final content-ID scan. Every canonical projection statement retains the repository's current-registry identity guard. -4. If the scan is complete, delete only this collection ID's work with `change_epoch <= starting_epoch`, then conditionally finalize `complete` only when the repair token, collection ID and epoch still match, the registry identity is current, and no work remains. Clear `reconciliation_required` only in this transition. -5. Partial or failed scans retain all work and set `reconciliation_required = true`; otherwise a later unrelated incremental success could falsely restore complete coverage. Their status update still matches the repair token, collection ID and starting epoch. -6. If the epoch changed while the same run token still owns `running`, demote that run to conservative `stale`/conflict state and require reconciliation. Never overwrite a newer repair or another winning status transition. - -The D1-safe ordering is intentional: clearing proven pre-run work before the final conditional status update means a crash can leave non-complete coverage, not false `complete`. SQLite and PostgreSQL `UPDATE ... RETURNING` provide the epoch from the row modified by repair start; SQLite cannot portably compose DML `RETURNING` as a subquery, so the remaining operations stay as separately guarded statements. - -All-collection repair carries each initially listed collection ID into the per-collection call. If that identity disappears or its slug is recreated, it is excluded without repairing the replacement and without deleting or updating the replacement's status. Legacy pre-activation filtering keeps its released slug-based behaviour. - -## Concurrency, compatibility and cost invariants - -- A mutation before repair finalization advances the epoch and creates newer work atomically; the repair cannot delete that work or finalize complete. -- A mutation after successful finalization immediately changes coverage back to non-complete and creates work. -- Multiple workers may finish out of epoch order; coverage completes only after the final work row drains at a still-observed epoch. -- A terminal row, including a failed row for another entry, is incompatible with complete coverage. -- Repair may delete a pre-run leased row after proving the full current collection. Its stale owner cannot acknowledge because the exact row is gone, and guarded source generations prevent it from overwriting a repair winner. -- Collection deletion or same-slug replacement makes ID/registry predicates lose; repair and incremental bookkeeping cannot update the replacement. -- Existing REST, client, CLI and MCP request/response shapes, authorization, synchronous execution and pre-activation source keys do not change. -- No logged-out query changes. Incremental success adds one bounded epoch read and one conditional status update after an acknowledged job; terminal failure adds one conditional update. The ordinary-job statement-count test must remain within the exported Step 3 ceiling. Repair remains the explicitly unbounded operator operation until Sequence 2. - -## Behavioural tests - -- A trusted collection becomes stale with captured work and returns to complete only after all jobs drain; `last_incremental_success_at` is recorded. -- `never`/reconciliation-required coverage cannot become complete from incremental success. -- Retry remains non-complete; an exact terminal failure becomes visible and cannot be overwritten after a newer mutation. -- Superseded, obsolete and out-of-order completions cannot report complete early. -- Active repair writes canonical ID-bound projections, preserves unbound legacy evidence, clears only pre-run work, and clears reconciliation only on proven completion. -- A write during active repair advances the epoch, retains the newer job, prevents complete finalization and does not leave a falsely running repair. -- Partial/failed active repair retains work and reconciliation-required state. -- Same-slug recreation cannot receive the old repair's sources or status. -- Active all-collection repair skips an identity replaced after listing and leaves the replacement untouched. -- The existing pre-activation repair suite continues to prove backwards-compatible legacy behaviour. - -SQLite runs for every test; the existing real-PostgreSQL dialect wrapper runs when configured. Real D1/workerd execution remains a final Sequence 1 acceptance risk rather than being claimed by Step 4. - -## Remaining risks - -- A crash after successful work deletion but before coverage bookkeeping leaves safe stale/partial coverage until a later job or full repair; there is no false-complete window. -- Manual repair remains whole-collection, synchronous and non-resumable by approved scope. Step 4 adds fences, not scalability claims. -- Hosts without a real PostgreSQL test database or D1/workerd cannot close those platform evidence gaps in this step. From 39794ae0bcacc66e901aeafaf78965e78d020d36 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:11:46 +0100 Subject: [PATCH 09/11] fix(core): remove incomplete media usage systems --- .changeset/steady-cats-retry.md | 2 +- .changeset/tidy-berries-activate.md | 5 - .../src/content/docs/guides/media-library.mdx | 30 +- docs/src/content/docs/reference/rest-api.mdx | 19 +- .../core/src/api/media-usage-write-fence.ts | 4 +- packages/core/src/api/openapi/document.ts | 4 +- .../063_media_usage_incremental_work.ts | 32 - packages/core/src/database/types.ts | 10 - packages/core/src/media/usage/activation.ts | 17 +- .../core/src/media/usage/content-refresh.ts | 60 +- .../core/src/media/usage/work-processor.ts | 8 +- .../database/media-usage-activation.test.ts | 33 + .../media-usage-content-repair.test.ts | 16 +- .../media-usage-work-measurement.test.ts | 782 ------------------ .../media-usage-work-processor.test.ts | 271 +----- 15 files changed, 86 insertions(+), 1207 deletions(-) delete mode 100644 .changeset/tidy-berries-activate.md delete mode 100644 packages/core/tests/integration/database/media-usage-work-measurement.test.ts diff --git a/.changeset/steady-cats-retry.md b/.changeset/steady-cats-retry.md index 7a48e927ed..abffcef6c7 100644 --- a/.changeset/steady-cats-retry.md +++ b/.changeset/steady-cats-retry.md @@ -2,4 +2,4 @@ "emdash": patch --- -Adds bounded Media Usage indexing controls and reports changed live or draft projections over 14 media occurrences as terminal failures without partial index updates. +Adds bounded controls for listing and retrying durable Media Usage indexing work. diff --git a/.changeset/tidy-berries-activate.md b/.changeset/tidy-berries-activate.md deleted file mode 100644 index d38a4d7326..0000000000 --- a/.changeset/tidy-berries-activate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"emdash": patch ---- - -Adds the explicit activation step required to enable durable Media Usage indexing safely after deployment writers have been drained. diff --git a/docs/src/content/docs/guides/media-library.mdx b/docs/src/content/docs/guides/media-library.mdx index aa8b40e7bb..e4e1596a61 100644 --- a/docs/src/content/docs/guides/media-library.mdx +++ b/docs/src/content/docs/guides/media-library.mdx @@ -254,9 +254,14 @@ const { entry: post } = await getEmDashEntry("posts", Astro.params.slug); ## Monitoring Media Usage Indexing -EmDash records durable work when a supported content save changes its media references. It tries -the work immediately and, when scheduled maintenance is enabled, retries transient failures in -bounded background runs. Completed work is removed from the queue. +Incremental Media Usage capture is staged but deliberately inactive in this release. Normal saves +do not create or process durable indexing work until activation becomes available in a later +safety-complete release. Use collection-scoped Media Usage repair to refresh the index in the +meantime. + +After activation, EmDash records durable work when a supported content save changes its media +references. It tries the work immediately and, when scheduled maintenance is enabled, retries +transient failures in bounded background runs. Completed work is removed from the queue. Operators with `schema:manage` can inspect one collection at a time without loading or counting the whole backlog: @@ -269,13 +274,6 @@ The optional `state` is `pending`, `retry`, `leased`, or `failed`. Follow `nextC another page. A `failed` item has reached the automatic retry limit and remains visible until a later content save or an explicit retry reopens it. -Automatic indexing accepts at most 14 extracted media occurrences in each changed live or draft -projection. A larger changed projection fails immediately with `MEDIA_USAGE_RESOURCE_LIMIT`, -without consuming normal retries or publishing part of the new projection. Do not repeatedly retry -this error: reduce the projection to 14 occurrences or fewer, or reconcile it under -operator-controlled collection repair after assessing that collection-wide operation's cost. An -already-indexed oversized projection can still complete as an unchanged no-op. - Retry one item using the immutable `collectionId` and `contentId` returned by the list: ```http @@ -292,14 +290,14 @@ X-EmDash-Request: 1 The same operations are available through `EmDashClient.mediaListUsageWork()` and `EmDashClient.mediaRetryUsageWork()`. Retrying already-pending work is a successful no-op. A live worker lease returns `409 WORK_LEASE_ACTIVE`; wait until its reported expiry instead of trying to -replace the active worker. Retrying `MEDIA_USAGE_RESOURCE_LIMIT` without changing or reconciling the -entry returns it to the same terminal state. +replace the active worker. ## Deleting Media diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx index 93c71d4966..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": [ { @@ -378,6 +379,7 @@ page's `nextCursor`. The endpoint does not calculate an exact backlog count. ```json { + "success": true, "data": { "items": [ { @@ -399,9 +401,7 @@ page's `nextCursor`. The endpoint does not calculate an exact backlog count. ``` Responses omit work versions, lease tokens, raw database errors, indexed content, media -references, and exact counts. A failed item with `lastErrorCode: "MEDIA_USAGE_RESOURCE_LIMIT"` -contains a changed live or draft projection with more than 14 extracted media occurrences. It -failed before any projection writes and did not consume the normal retry sequence. +references, and exact counts. ### Retry Media Usage Work @@ -424,14 +424,11 @@ as the list endpoint. 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. Retrying `MEDIA_USAGE_RESOURCE_LIMIT` without first -reducing the changed projection to 14 occurrences or fewer, or reconciling it through -operator-controlled collection repair after assessing that collection-wide operation's cost, -returns the job to the same terminal state. Oversized projections that already match the stored -index remain valid no-ops. - -These endpoints operate only on durable work already known for an entry. They do not scan for -historical gaps. Use collection-scoped Media Usage repair after imports or direct database writes. +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. diff --git a/packages/core/src/api/media-usage-write-fence.ts b/packages/core/src/api/media-usage-write-fence.ts index 4ad2d6c445..2e44ff6ec2 100644 --- a/packages/core/src/api/media-usage-write-fence.ts +++ b/packages/core/src/api/media-usage-write-fence.ts @@ -1,7 +1,7 @@ import type { Kysely } from "kysely"; +import { tableExists } from "../database/dialect-helpers.js"; import type { Database } from "../database/types.js"; -import { isMissingTableError } from "../utils/db-errors.js"; import { apiError } from "./error.js"; export interface MediaUsageActivationWriteFenceError { @@ -38,6 +38,7 @@ export async function assertMediaUsageActivationWriteAllowed(db: Kysely, ): Promise { + if (!(await tableExists(db, "_emdash_media_usage_activation"))) return null; try { const row = await db .selectFrom("_emdash_media_usage_activation") @@ -52,7 +53,6 @@ export async function findMediaUsageActivationWriteFenceError( }; } } catch (error) { - if (isMissingTableError(error)) return null; console.error("[media-usage] Failed to check the activation write fence:", error); return { code: "MEDIA_USAGE_ACTIVATION_CHECK_FAILED", diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts index aa2cfb2432..8fe7221c8a 100644 --- a/packages/core/src/api/openapi/document.ts +++ b/packages/core/src/api/openapi/document.ts @@ -800,7 +800,7 @@ function buildMediaPaths(maxUploadSize: number) { 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. `MEDIA_USAGE_RESOURCE_LIMIT` identifies a changed live or draft projection over the 14-occurrence automatic-indexing limit; it fails before projection writes.", + "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: { @@ -820,7 +820,7 @@ function buildMediaPaths(maxUploadSize: number) { 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. Retrying `MEDIA_USAGE_RESOURCE_LIMIT` without reducing the changed live or draft projection to 14 occurrences or fewer, or reconciling it through operator-controlled repair, returns the job to the same terminal state.", + "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, 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 index aa65d2a3f1..3086207e48 100644 --- a/packages/core/src/database/migrations/063_media_usage_incremental_work.ts +++ b/packages/core/src/database/migrations/063_media_usage_incremental_work.ts @@ -175,16 +175,6 @@ export async function down(db: Kysely): Promise { } for (const columnName of [ - "cleanup_last_error_code", - "cleanup_next_attempt_at", - "cleanup_attempt_count", - "cleanup_lease_expires_at", - "cleanup_lease_token", - "cleanup_occurrence_cursor", - "cleanup_source_key", - "cleanup_work_cursor", - "cleanup_phase", - "cleanup_state", "capture_state", "last_incremental_success_at", "reconciliation_required", @@ -221,21 +211,6 @@ async function addStatusColumns(db: Kysely): Promise { ); await addStatusTextColumn(db, "last_incremental_success_at"); await addStatusTextColumn(db, "capture_state"); - await addStatusTextColumn(db, "cleanup_state"); - await addStatusTextColumn(db, "cleanup_phase"); - await addStatusTextColumn(db, "cleanup_work_cursor"); - await addStatusTextColumn(db, "cleanup_source_key"); - await addStatusTextColumn(db, "cleanup_occurrence_cursor"); - await addStatusTextColumn(db, "cleanup_lease_token"); - await addStatusTextColumn(db, "cleanup_lease_expires_at"); - await addColumnIfMissing(db, "_emdash_media_usage_index_status", "cleanup_attempt_count", () => - db.schema - .alterTable("_emdash_media_usage_index_status") - .addColumn("cleanup_attempt_count", "integer", (column) => column.notNull().defaultTo(0)) - .execute(), - ); - await addStatusTextColumn(db, "cleanup_next_attempt_at"); - await addStatusTextColumn(db, "cleanup_last_error_code"); } async function assertRollbackIsEmptyAndInactive(db: Kysely): Promise { @@ -324,13 +299,6 @@ async function assertRollbackIsEmptyAndInactive(db: Kysely): Promise 0) throwRollbackLifecycleError(); } - if (await columnExists(db, "_emdash_media_usage_index_status", "cleanup_state")) { - const cleanup = await sql<{ present: number }>` - SELECT 1 AS present FROM _emdash_media_usage_index_status - WHERE cleanup_state IS NOT NULL LIMIT 1 - `.execute(db); - if (cleanup.rows.length > 0) throwRollbackLifecycleError(); - } } function throwRollbackLifecycleError(): never { diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index 914106143b..fc797fc640 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -200,16 +200,6 @@ export interface MediaUsageIndexStatusTable { reconciliation_required: Generated; last_incremental_success_at: Generated; capture_state: Generated; - cleanup_state: Generated; - cleanup_phase: Generated; - cleanup_work_cursor: Generated; - cleanup_source_key: Generated; - cleanup_occurrence_cursor: Generated; - cleanup_lease_token: Generated; - cleanup_lease_expires_at: Generated; - cleanup_attempt_count: Generated; - cleanup_next_attempt_at: Generated; - cleanup_last_error_code: Generated; } export interface MediaUsageActivationTable { diff --git a/packages/core/src/media/usage/activation.ts b/packages/core/src/media/usage/activation.ts index e4595c7a44..f67ff50ecf 100644 --- a/packages/core/src/media/usage/activation.ts +++ b/packages/core/src/media/usage/activation.ts @@ -1,9 +1,8 @@ import { sql, type Kysely, type RawBuilder, type Selectable } from "kysely"; import { ulid } from "ulidx"; -import { isPostgres } from "../../database/dialect-helpers.js"; +import { isPostgres, tableExists } from "../../database/dialect-helpers.js"; import type { Database, MediaUsageActivationTable } from "../../database/types.js"; -import { isMissingTableError } from "../../utils/db-errors.js"; import { installMediaUsageCaptureTriggers, verifyMediaUsageCaptureTriggers, @@ -305,12 +304,8 @@ async function findActivation( async function findActivationIfAvailable( db: Kysely, ): Promise | null> { - try { - return await findActivation(db); - } catch (error) { - if (isMissingTableError(error)) return null; - throw error; - } + if (!(await tableExists(db, "_emdash_media_usage_activation"))) return null; + return findActivation(db); } function assertRuntimeGeneration(activation: Selectable): void { @@ -406,6 +401,8 @@ async function prepareCollectionForActivation( 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 row = await db .insertInto("_emdash_media_usage_index_status") .values({ @@ -422,10 +419,10 @@ async function prepareCollectionForActivation( conflict .columns(["adapter_id", "scope_type", "scope_key"]) .doUpdateSet({ - status: sql`CASE WHEN status IN ('complete', 'running') THEN 'stale' ELSE status END`, + status: sql`CASE WHEN ${existingStatus} IN ('complete', 'running') THEN 'stale' ELSE ${existingStatus} END`, completed_at: sql< string | null - >`CASE WHEN status IN ('complete', 'running') THEN NULL ELSE completed_at END`, + >`CASE WHEN ${existingStatus} IN ('complete', 'running') THEN NULL ELSE ${existingCompletedAt} END`, cursor: null, collection_id: collection.id, reconciliation_required: 1, diff --git a/packages/core/src/media/usage/content-refresh.ts b/packages/core/src/media/usage/content-refresh.ts index 6a33394986..e0d93cf4c3 100644 --- a/packages/core/src/media/usage/content-refresh.ts +++ b/packages/core/src/media/usage/content-refresh.ts @@ -1,10 +1,10 @@ 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"; import { isI18nEnabled } from "../../i18n/config.js"; -import { isMissingTableError } from "../../utils/db-errors.js"; import { loadContentMediaUsageFields } from "./content-fields.js"; import { CONTENT_SOURCE_SCHEMA_VERSION, @@ -21,7 +21,6 @@ export const CONTENT_MEDIA_USAGE_COLLECTION_SCOPE = "collection"; const CONTENT_USAGE_LOCKS_KEY = Symbol.for("emdash.mediaUsage.contentLocks"); const CONTENT_USAGE_COLLECTION_LOCKS_KEY = Symbol.for("emdash.mediaUsage.collectionLocks"); const CONTENT_USAGE_REFRESH_MAX_ATTEMPTS = 2; -export const CONTENT_MEDIA_USAGE_MAX_CHANGED_OCCURRENCES = 14; // These maps only de-dupe usage work inside the current isolate/process. Cross-worker // correctness comes from expected-generation guards on repository writes. @@ -34,7 +33,6 @@ export type ContentMediaUsageRefreshErrorCode = | "CONTENT_USAGE_REFRESH_ERROR" | "CONTENT_USAGE_DELETE_ERROR" | "CONTENT_USAGE_GENERATION_CONFLICT" - | "CONTENT_USAGE_RESOURCE_LIMIT" | "CONTENT_USAGE_STALE"; interface ContentMediaUsageRefreshOptions { @@ -182,25 +180,8 @@ async function refreshContentMediaUsageAttempt( const deletedSourceCount = await repo.deleteContentSources(collectionSlug, contentId); return { ...ZERO_RESULT, deletedSourceCount }; } - const oversizedUnchangedSourceKeys = options.durableWork - ? await preflightOversizedSnapshots(repo, snapshotsResult.snapshots, observedSources) - : new Set(); - if (!oversizedUnchangedSourceKeys) { - return { - success: false, - refreshedSourceCount: 0, - deletedSourceCount: 0, - failedSourceCount: 1, - errorCode: "CONTENT_USAGE_RESOURCE_LIMIT", - }; - } - let refreshedSourceCount = 0; for (const snapshot of snapshotsResult.snapshots) { - if (oversizedUnchangedSourceKeys.has(snapshot.source.sourceKey)) { - refreshedSourceCount++; - continue; - } const result = await repo.replaceSourceIfMatching( snapshot.source, snapshot.occurrences, @@ -263,28 +244,6 @@ async function refreshContentMediaUsageAttempt( }; } -async function preflightOversizedSnapshots( - repo: MediaUsageRepository, - snapshots: Awaited< - Extract>, { success: true }> - >["snapshots"], - observedSources: Awaited>, -): Promise | null> { - const unchangedSourceKeys = new Set(); - for (const snapshot of snapshots) { - if (snapshot.occurrences.length <= CONTENT_MEDIA_USAGE_MAX_CHANGED_OCCURRENCES) continue; - const expectedSource = observedSources.get(snapshot.source.sourceKey); - if ( - !expectedSource || - !(await repo.projectionMatchesExpectedSource(snapshot.source, expectedSource)) - ) { - return null; - } - unchangedSourceKeys.add(snapshot.source.sourceKey); - } - return unchangedSourceKeys; -} - async function loadObservedContentSources( repo: MediaUsageRepository, collectionSlug: string, @@ -479,17 +438,12 @@ export async function invalidateContentMediaUsageSchemaChange( collectionSlug: string, ): Promise { validateIdentifier(collectionSlug, "collection slug"); - let activation: { state: string } | undefined; - try { - activation = await db - .selectFrom("_emdash_media_usage_activation") - .select("state") - .where("task_key", "=", "incremental_capture") - .executeTakeFirst(); - } catch (error) { - if (isMissingTableError(error)) return false; - throw error; - } + 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( diff --git a/packages/core/src/media/usage/work-processor.ts b/packages/core/src/media/usage/work-processor.ts index 86e94dbb38..ceefbfe52d 100644 --- a/packages/core/src/media/usage/work-processor.ts +++ b/packages/core/src/media/usage/work-processor.ts @@ -166,10 +166,7 @@ async function processCandidate( } const errorCode = processingErrorCode(refresh.errorCode); - if ( - errorCode === "MEDIA_USAGE_RESOURCE_LIMIT" || - claimed.attemptCount + 1 >= MEDIA_USAGE_WORK_PROCESSING_LIMITS.maxAttempts - ) { + if (claimed.attemptCount + 1 >= MEDIA_USAGE_WORK_PROCESSING_LIMITS.maxAttempts) { const failed = await repo.failWork({ ...lease, errorCode }); if (failed) { await new MediaUsageRepository(db).recordIncrementalFailure({ @@ -243,8 +240,5 @@ function processingErrorCode(errorCode: ContentMediaUsageRefreshErrorCode | unde if (errorCode === "CONTENT_USAGE_GENERATION_CONFLICT") { return "MEDIA_USAGE_GENERATION_CONFLICT"; } - if (errorCode === "CONTENT_USAGE_RESOURCE_LIMIT") { - return "MEDIA_USAGE_RESOURCE_LIMIT"; - } return "MEDIA_USAGE_PROCESSING_FAILED"; } diff --git a/packages/core/tests/integration/database/media-usage-activation.test.ts b/packages/core/tests/integration/database/media-usage-activation.test.ts index d73414a3d2..b8cbafaa02 100644 --- a/packages/core/tests/integration/database/media-usage-activation.test.ts +++ b/packages/core/tests/integration/database/media-usage-activation.test.ts @@ -1,11 +1,14 @@ 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, @@ -41,6 +44,36 @@ describeEachDialect("media usage production activation", (dialect) => { 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 }); 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 94969797ea..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 @@ -157,12 +157,16 @@ describeEachDialect("content media usage repair", (dialect) => { }), ); expect( - await ctx.db - .selectFrom("_emdash_media_usage_work") - .select((eb) => eb.fn.countAll().as("count")) - .where("collection_id", "=", collectionId) - .executeTakeFirstOrThrow(), - ).toEqual(expect.objectContaining({ count: 0 })); + 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") diff --git a/packages/core/tests/integration/database/media-usage-work-measurement.test.ts b/packages/core/tests/integration/database/media-usage-work-measurement.test.ts deleted file mode 100644 index 6f7680005f..0000000000 --- a/packages/core/tests/integration/database/media-usage-work-measurement.test.ts +++ /dev/null @@ -1,782 +0,0 @@ -import Database from "better-sqlite3"; -import { Kysely, SqliteDialect, sql } from "kysely"; -import { afterEach, beforeEach, expect, it } from "vitest"; - -import { runMigrations } from "../../../src/database/migrations/runner.js"; -import { MediaUsageWorkRepository } from "../../../src/database/repositories/media-usage-work.js"; -import type { Database as DatabaseSchema } from "../../../src/database/types.js"; -import { setI18nConfig } from "../../../src/i18n/config.js"; -import { installMediaUsageCaptureTriggers } from "../../../src/media/usage/capture-triggers.js"; -import { - processDueMediaUsageWork, - processMediaUsageWorkAfterWrite, -} from "../../../src/media/usage/work-processor.js"; -import { SchemaRegistry } from "../../../src/schema/registry.js"; -import { createTestRuntime } from "../../utils/mcp-runtime.js"; - -interface CapturedQuery { - sql: string; - parameters: readonly unknown[]; -} - -interface Measurement { - statements: number; - d1EquivalentStatements: number; - maxBinds: number; - changedRows: number; - durationMs: number; -} - -let sqlite: Database.Database; -let db: Kysely; -let captured: CapturedQuery[]; -let fixture: Awaited>; - -beforeEach(async () => { - setI18nConfig(null); - captured = []; - sqlite = new Database(":memory:"); - db = new Kysely({ - dialect: new SqliteDialect({ database: sqlite }), - log(event) { - if (event.level === "query") { - captured.push({ sql: event.query.sql, parameters: event.query.parameters }); - } - }, - }); - await runMigrations(db); - fixture = await createActiveFixture(); -}); - -afterEach(async () => { - setI18nConfig(null); - await db.destroy(); -}); - -it("measures complete changed jobs as portable-text and repeater occurrences grow", async () => { - let previousStatementCount = 0; - for (const occurrenceCount of [0, 1, 7, 8, 14]) { - const contentId = `columns-${occurrenceCount}`; - await insertEntry(contentId, mediaData(occurrenceCount, contentId)); - - const { value, measurement } = await measure(() => - processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), - ); - - expect(value.outcome).toBe("completed"); - expect(measurement.maxBinds).toBeLessThanOrEqual(100); - expect(measurement.statements).toBeGreaterThanOrEqual(previousStatementCount); - previousStatementCount = measurement.statements; - } -}); - -it("measures complete changed jobs with columns and draft-overlay sources", async () => { - let previousStatementCount = 0; - for (const occurrencesPerSource of [0, 7, 14]) { - const contentId = `both-${occurrencesPerSource}`; - await insertEntry(contentId, mediaData(occurrencesPerSource, `${contentId}-columns`)); - await addDraft(contentId, mediaData(occurrencesPerSource, `${contentId}-draft`)); - - const { value, measurement } = await measure(() => - processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), - ); - - expect(value.outcome).toBe("completed"); - expect(measurement.maxBinds).toBeLessThanOrEqual(100); - expect(measurement.statements).toBeGreaterThanOrEqual(previousStatementCount); - previousStatementCount = measurement.statements; - } -}); - -it("keeps the largest admitted scheduled job inside the Free-plan query budget", async () => { - const contentId = "scheduled-boundary"; - await insertEntry(contentId, mediaData(14, "scheduled-columns")); - await addDraft(contentId, mediaData(14, "scheduled-draft")); - - const result = await measure(() => processDueMediaUsageWork(db)); - - expect(result.value.completedCount).toBe(1); - expect(result.measurement.d1EquivalentStatements).toBeLessThanOrEqual(40); - expect(result.measurement.maxBinds).toBeLessThanOrEqual(100); -}); - -it("measures worst-position guarded conflicts across one and two sources", async () => { - const oneSourceCounts = [7, 14]; - for (const occurrenceCount of oneSourceCounts) { - const contentId = `conflict-one-${occurrenceCount}`; - await insertEntry(contentId, mediaData(occurrenceCount, `${contentId}-initial`)); - await processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId); - await updateEntry(contentId, mediaData(occurrenceCount, `${contentId}-changed`)); - } - await installAnySourceConflictTrigger(); - try { - for (const occurrenceCount of oneSourceCounts) { - const contentId = `conflict-one-${occurrenceCount}`; - const result = await measure(() => - processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), - ); - expect(result.value.outcome).toBe("retry"); - expect(result.measurement.maxBinds).toBeLessThanOrEqual(100); - } - } finally { - await sql`DROP TRIGGER measure_any_generation_conflict`.execute(db); - } - - const perSourceCounts = [7, 14]; - for (const occurrenceCount of perSourceCounts) { - const contentId = `conflict-both-${occurrenceCount}`; - await insertEntry(contentId, mediaData(occurrenceCount, `${contentId}-columns-initial`)); - await addDraft(contentId, mediaData(occurrenceCount, `${contentId}-draft-initial`)); - await processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId); - await updateEntry(contentId, mediaData(occurrenceCount, `${contentId}-columns-changed`)); - await updateDraft(contentId, mediaData(occurrenceCount, `${contentId}-draft-changed`)); - } - await installDraftSourceConflictTrigger(); - try { - for (const occurrenceCount of perSourceCounts) { - const contentId = `conflict-both-${occurrenceCount}`; - const result = await measure(() => - processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), - ); - expect(result.value.outcome).toBe("retry"); - expect(result.measurement.maxBinds).toBeLessThanOrEqual(100); - } - } finally { - await sql`DROP TRIGGER measure_draft_generation_conflict`.execute(db); - } -}); - -it("measures the complete runtime create and draft-update invocation", async () => { - const runtime = createTestRuntime(db); - - for (const occurrenceCount of [0, 7, 14]) { - const slug = `runtime-${occurrenceCount}`; - const created = await measure(() => - runtime.handleContentCreate(fixture.collectionSlug, { - slug, - data: { title: slug, ...mediaData(occurrenceCount, `${slug}-live`) }, - }), - ); - expect(created.value.success).toBe(true); - if (!created.value.success) throw new Error(created.value.error.message); - expect(created.measurement.maxBinds).toBeLessThanOrEqual(100); - - const updated = await measure(() => - runtime.handleContentUpdate(fixture.collectionSlug, created.value.data.item.id, { - data: mediaData(occurrenceCount, `${slug}-draft`), - }), - ); - expect(updated.value.success).toBe(true); - expect(updated.measurement.maxBinds).toBeLessThanOrEqual(100); - if (occurrenceCount === 14) { - expect(updated.measurement.d1EquivalentStatements).toBeLessThanOrEqual(41); - } - } -}); - -it("keeps translated sibling work out of the saved entry's immediate budget", async () => { - setI18nConfig({ defaultLocale: "en", locales: ["en", "fr", "de", "es"] }); - const localized = await createActiveLocalizedFixture(); - const runtime = createTestRuntime(db); - let rootId = ""; - for (const locale of ["en", "fr", "de", "es"]) { - const created = await runtime.handleContentCreate(localized.collectionSlug, { - slug: `localized-${locale}`, - locale, - translationOf: rootId || undefined, - data: { - title: locale, - shared_body: mediaData(1, `initial-${locale}`).body, - }, - }); - expect(created.success).toBe(true); - if (!created.success) throw new Error(created.error.message); - if (!rootId) rootId = created.data.item.id; - } - - const updated = await measure(() => - runtime.handleContentUpdate(localized.collectionSlug, rootId, { - data: { shared_body: mediaData(14, "shared").body }, - }), - ); - - expect(updated.value.success).toBe(true); - expect(updated.measurement.d1EquivalentStatements).toBeLessThanOrEqual(41); - expect(updated.measurement.maxBinds).toBeLessThanOrEqual(100); - const remaining = await db - .selectFrom("_emdash_media_usage_work") - .select(["content_id", "state"]) - .where("collection_id", "=", localized.collectionId) - .orderBy("content_id") - .execute(); - expect(remaining).toHaveLength(3); - expect(remaining.every((work) => work.state === "pending")).toBe(true); -}); - -it("measures the worst source split for candidate total-occurrence budgets", async () => { - const runtime = createTestRuntime(db); - const worst: Record = {}; - - for (const totalOccurrences of [14, 15, 21, 28]) { - const minimumLiveOccurrences = Math.max(0, totalOccurrences - 14); - const maximumLiveOccurrences = Math.min(14, totalOccurrences); - for ( - let liveOccurrences = minimumLiveOccurrences; - liveOccurrences <= maximumLiveOccurrences; - liveOccurrences++ - ) { - const draftOccurrences = totalOccurrences - liveOccurrences; - const slug = `split-${totalOccurrences}-${liveOccurrences}`; - const created = await runtime.handleContentCreate(fixture.collectionSlug, { - slug, - data: { title: slug, ...mediaData(liveOccurrences, `${slug}-live`) }, - }); - expect(created.success).toBe(true); - if (!created.success) throw new Error(created.error.message); - - const updated = await measure(() => - runtime.handleContentUpdate(fixture.collectionSlug, created.data.item.id, { - data: mediaData(draftOccurrences, `${slug}-draft`), - }), - ); - expect(updated.value.success).toBe(true); - if (updated.measurement.d1EquivalentStatements > (worst[totalOccurrences] ?? 0)) { - worst[totalOccurrences] = updated.measurement.d1EquivalentStatements; - } - } - } - - for (const totalOccurrences of [14, 15, 21, 28]) { - expect(worst[totalOccurrences]).toBeLessThanOrEqual(41); - } -}); - -it("measures complete runtime lifecycle invocations", async () => { - const runtime = createTestRuntime(db); - const results: Record = {}; - - const publishEntry = await runtime.handleContentCreate(fixture.collectionSlug, { - slug: "lifecycle-publish", - data: { title: "Publish", ...mediaData(0, "publish-live") }, - }); - if (!publishEntry.success) throw new Error(publishEntry.error.message); - await runtime.handleContentUpdate(fixture.collectionSlug, publishEntry.data.item.id, { - data: mediaData(14, "publish-draft"), - }); - const published = await measure(() => - runtime.handleContentPublish(fixture.collectionSlug, publishEntry.data.item.id), - ); - expect(published.value.success).toBe(true); - results.publish = published.measurement; - - const unpublished = await measure(() => - runtime.handleContentUnpublish(fixture.collectionSlug, publishEntry.data.item.id), - ); - expect(unpublished.value.success).toBe(true); - results.unpublish = unpublished.measurement; - - const scheduled = await measure(() => - runtime.handleContentSchedule( - fixture.collectionSlug, - publishEntry.data.item.id, - "2100-01-01T00:00:00.000Z", - ), - ); - expect(scheduled.value.success).toBe(true); - results.schedule = scheduled.measurement; - - const unscheduled = await measure(() => - runtime.handleContentUnschedule(fixture.collectionSlug, publishEntry.data.item.id), - ); - expect(unscheduled.value.success).toBe(true); - results.unschedule = unscheduled.measurement; - - const trashed = await measure(() => - runtime.handleContentDelete(fixture.collectionSlug, publishEntry.data.item.id), - ); - expect(trashed.value.success).toBe(true); - results.trash = trashed.measurement; - - const restored = await measure(() => - runtime.handleContentRestore(fixture.collectionSlug, publishEntry.data.item.id), - ); - expect(restored.value.success).toBe(true); - results.restore = restored.measurement; - - const trashedAgain = await runtime.handleContentDelete( - fixture.collectionSlug, - publishEntry.data.item.id, - ); - expect(trashedAgain.success).toBe(true); - const permanentlyDeleted = await measure(() => - runtime.handleContentPermanentDelete(fixture.collectionSlug, publishEntry.data.item.id), - ); - expect(permanentlyDeleted.value.success).toBe(true); - results.permanentDelete = permanentlyDeleted.measurement; - - expect(results.publish?.d1EquivalentStatements).toBeLessThanOrEqual(33); - expect(results.unpublish?.d1EquivalentStatements).toBeLessThanOrEqual(39); - expect(results.schedule?.d1EquivalentStatements).toBeLessThanOrEqual(35); - expect(results.unschedule?.d1EquivalentStatements).toBeLessThanOrEqual(35); - expect(results.trash?.d1EquivalentStatements).toBeLessThanOrEqual(32); - expect(results.restore?.d1EquivalentStatements).toBeLessThanOrEqual(32); - expect(results.permanentDelete?.d1EquivalentStatements).toBeLessThanOrEqual(22); - for (const measurement of Object.values(results)) { - expect(measurement.maxBinds).toBeLessThanOrEqual(100); - } -}); - -it("measures no-op, changed, conflict, absence, and dense-backlog paths", async () => { - const contentId = "path-matrix"; - await insertEntry(contentId, mediaData(14, "path-columns-initial")); - await addDraft(contentId, mediaData(14, "path-draft-initial")); - await processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId); - - await updateEntry(contentId, mediaData(14, "path-columns-initial")); - const noOp = await measure(() => - processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), - ); - expect(noOp.value.outcome).toBe("completed"); - - await updateEntry(contentId, mediaData(14, "path-columns-changed")); - await updateDraft(contentId, mediaData(14, "path-draft-changed")); - const changed = await measure(() => - processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, contentId), - ); - expect(changed.value.outcome).toBe("completed"); - - const conflictId = "path-conflict"; - await insertEntry(conflictId, mediaData(14, "conflict-initial")); - await processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, conflictId); - await updateEntry(conflictId, mediaData(14, "conflict-changed")); - await installGenerationConflictTrigger(); - let conflict: { - value: Awaited>; - measurement: Measurement; - } | null = null; - try { - conflict = await measure(() => - processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, conflictId), - ); - expect(conflict.value.outcome).toBe("retry"); - } finally { - await sql`DROP TRIGGER measure_generation_conflict`.execute(db); - } - if (!conflict) throw new Error("Expected conflict measurement"); - - const absentId = "path-absent"; - await insertEntry(absentId, mediaData(14, "absent")); - await addDraft(absentId, mediaData(14, "absent-draft")); - await processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, absentId); - await sql`DELETE FROM ${sql.ref(fixture.tableName)} WHERE id = ${absentId}`.execute(db); - const absent = await measure(() => - processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, absentId), - ); - expect(absent.value.outcome).toBe("completed"); - - const oversizedId = "path-oversized"; - await insertEntry(oversizedId, mediaData(15, "oversized")); - const oversized = await measure(() => - processMediaUsageWorkAfterWrite(db, fixture.collectionSlug, oversizedId), - ); - expect(oversized.value.outcome).toBe("failed"); - - for (let index = 0; index < 120; index++) { - await insertEntry(`backlog-${index}`, mediaData(1, `backlog-${index}`)); - } - const backlog = await measure(() => processDueMediaUsageWork(db)); - expect(backlog.value.candidateCount).toBe(4); - expect(backlog.value.claimedCount).toBe(1); - - for (const result of [noOp, changed, conflict, absent, oversized, backlog]) { - expect(result.measurement.maxBinds).toBeLessThanOrEqual(100); - } - expect(noOp.measurement.d1EquivalentStatements).toBeLessThanOrEqual(15); - expect(changed.measurement.d1EquivalentStatements).toBeLessThanOrEqual(32); - expect(conflict.measurement.d1EquivalentStatements).toBeLessThanOrEqual(45); - expect(absent.measurement.d1EquivalentStatements).toBeLessThanOrEqual(21); - expect(oversized.measurement.d1EquivalentStatements).toBeLessThanOrEqual(20); - expect(backlog.measurement.d1EquivalentStatements).toBeLessThanOrEqual(20); -}); - -it("keeps operator pages bounded and on the operator index", async () => { - for (let index = 0; index < 128; index++) { - await insertOperatorWork( - `operator-${String(index).padStart(3, "0")}`, - ["pending", "retry", "leased", "failed"][index % 4] as - | "pending" - | "retry" - | "leased" - | "failed", - `2026-08-06T12:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`, - ); - } - - const repo = new MediaUsageWorkRepository(db); - const filtered = await measure(() => - repo.findOperatorPage({ collectionSlug: fixture.collectionSlug, state: "failed", limit: 50 }), - ); - expect(filtered.value?.items).toHaveLength(32); - expect(filtered.measurement.d1EquivalentStatements).toBe(2); - expectOperatorPlans(filtered.queries, 1); - - const unfiltered = await measure(() => - repo.findOperatorPage({ collectionSlug: fixture.collectionSlug, limit: 50 }), - ); - expect(unfiltered.value?.items).toHaveLength(50); - expect(unfiltered.value?.nextCursor).toEqual(expect.any(String)); - expect(unfiltered.measurement.d1EquivalentStatements).toBe(5); - expectOperatorPlans(unfiltered.queries, 4); -}); - -it("bounds every operator retry outcome", async () => { - const repo = new MediaUsageWorkRepository(db); - await insertOperatorWork("retry-pending", "pending", "2026-08-06T12:00:00.000Z"); - const pending = await measure(() => - repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-pending" }), - ); - expect(pending.value).toEqual(expect.objectContaining({ outcome: "pending", changed: false })); - expect(pending.measurement.d1EquivalentStatements).toBe(2); - - await insertOperatorWork("retry-failed", "failed", "2026-08-06T12:00:01.000Z"); - const failed = await measure(() => - repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-failed" }), - ); - expect(failed.value).toEqual(expect.objectContaining({ outcome: "pending", changed: true })); - expect(failed.measurement.d1EquivalentStatements).toBeLessThanOrEqual(4); - - await insertOperatorWork("retry-expired", "leased", "2026-08-06T12:00:02.000Z"); - await db - .updateTable("_emdash_media_usage_work") - .set({ lease_expires_at: "2000-01-01T00:00:00.000Z" }) - .where("collection_id", "=", fixture.collectionId) - .where("content_id", "=", "retry-expired") - .execute(); - const expired = await measure(() => - repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-expired" }), - ); - expect(expired.value).toEqual(expect.objectContaining({ outcome: "pending", changed: true })); - expect(expired.measurement.d1EquivalentStatements).toBeLessThanOrEqual(4); - - await insertOperatorWork("retry-live", "leased", "2026-08-06T12:00:03.000Z"); - const live = await measure(() => - repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-live" }), - ); - expect(live.value).toEqual( - expect.objectContaining({ outcome: "lease_active", leaseExpiresAt: expect.any(String) }), - ); - expect(live.measurement.d1EquivalentStatements).toBeLessThanOrEqual(7); - - const missing = await measure(() => - repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-missing" }), - ); - expect(missing.value).toEqual(expect.objectContaining({ outcome: "pending", changed: true })); - expect(missing.measurement.d1EquivalentStatements).toBeLessThanOrEqual(4); - - await insertOperatorWork("retry-conflict", "failed", "2026-08-06T12:00:04.000Z"); - await sql` - CREATE TRIGGER measure_operator_retry_conflict - AFTER UPDATE OF change_epoch ON _emdash_media_usage_index_status - BEGIN - DELETE FROM _emdash_media_usage_work - WHERE content_id = 'retry-conflict'; - END - `.execute(db); - try { - const conflict = await measure(() => - repo.retryOperatorWork({ collectionId: fixture.collectionId, contentId: "retry-conflict" }), - ); - expect(conflict.value).toEqual({ outcome: "conflict" }); - expect(conflict.measurement.d1EquivalentStatements).toBeLessThanOrEqual(7); - } finally { - await sql`DROP TRIGGER measure_operator_retry_conflict`.execute(db); - } -}); - -async function createActiveFixture() { - const collectionSlug = "measured_jobs"; - const registry = new SchemaRegistry(db); - await registry.createCollection({ slug: collectionSlug, label: "Measured jobs" }); - await registry.createField(collectionSlug, { slug: "title", label: "Title", type: "string" }); - await registry.createField(collectionSlug, { - slug: "body", - label: "Body", - type: "portableText", - }); - await registry.createField(collectionSlug, { - slug: "sections", - label: "Sections", - type: "repeater", - validation: { subFields: [{ slug: "image", type: "image", label: "Image" }] }, - }); - const collection = await registry.getCollection(collectionSlug); - if (!collection) throw new Error("Expected measured collection"); - - await 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(db, { - collectionId: collection.id, - collectionSlug, - }); - await db - .updateTable("_emdash_media_usage_index_status") - .set({ capture_state: "active" }) - .where("collection_id", "=", collection.id) - .execute(); - await 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 createActiveLocalizedFixture() { - const collectionSlug = "localized_measured"; - const registry = new SchemaRegistry(db); - await registry.createCollection({ - slug: collectionSlug, - label: "Localized measured", - supports: [], - }); - await registry.createField(collectionSlug, { slug: "title", label: "Title", type: "string" }); - await registry.createField(collectionSlug, { - slug: "shared_body", - label: "Shared body", - type: "portableText", - translatable: false, - }); - const collection = await registry.getCollection(collectionSlug); - if (!collection) throw new Error("Expected localized measured collection"); - - await 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(db, { - collectionId: collection.id, - collectionSlug, - }); - await db - .updateTable("_emdash_media_usage_index_status") - .set({ capture_state: "active" }) - .where("collection_id", "=", collection.id) - .execute(); - - return { collectionId: collection.id, collectionSlug }; -} - -async function insertEntry(contentId: string, data: ReturnType): Promise { - await sql` - INSERT INTO ${sql.ref(fixture.tableName)} (id, slug, status, title, body, sections) - VALUES ( - ${contentId}, - ${contentId}, - 'published', - ${contentId}, - ${JSON.stringify(data.body)}, - ${JSON.stringify(data.sections)} - ) - `.execute(db); -} - -async function updateEntry(contentId: string, data: ReturnType): Promise { - await sql` - UPDATE ${sql.ref(fixture.tableName)} - SET body = ${JSON.stringify(data.body)}, sections = ${JSON.stringify(data.sections)} - WHERE id = ${contentId} - `.execute(db); -} - -async function addDraft(contentId: string, data: ReturnType): Promise { - const revisionId = `revision-${contentId}`; - await db - .insertInto("revisions") - .values({ - id: revisionId, - collection: fixture.collectionSlug, - entry_id: contentId, - data: JSON.stringify(data), - author_id: null, - }) - .execute(); - await sql` - UPDATE ${sql.ref(fixture.tableName)} - SET draft_revision_id = ${revisionId} - WHERE id = ${contentId} - `.execute(db); -} - -async function updateDraft(contentId: string, data: ReturnType): Promise { - await db - .updateTable("revisions") - .set({ data: JSON.stringify(data) }) - .where("id", "=", `revision-${contentId}`) - .execute(); - await sql` - UPDATE ${sql.ref(fixture.tableName)} - SET title = title - WHERE id = ${contentId} - `.execute(db); -} - -async function insertOperatorWork( - contentId: string, - state: "pending" | "retry" | "leased" | "failed", - updatedAt: string, -): Promise { - await db - .insertInto("_emdash_media_usage_work") - .values({ - collection_id: fixture.collectionId, - collection_slug: fixture.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: state === "leased" ? `lease-${contentId}` : null, - lease_expires_at: state === "leased" ? "2100-01-01T00:00:00.000Z" : null, - last_attempted_at: null, - last_error_code: state === "failed" ? "MEDIA_USAGE_PROCESSING_FAILED" : null, - created_at: updatedAt, - updated_at: updatedAt, - }) - .execute(); -} - -function mediaData(count: number, prefix: string) { - const portableTextCount = Math.ceil(count / 2); - const repeaterCount = Math.floor(count / 2); - return { - body: Array.from({ length: portableTextCount }, (_, index) => ({ - _type: "image", - _key: `body-${index}`, - asset: { _ref: `${prefix}-body-${index}` }, - })), - sections: Array.from({ length: repeaterCount }, (_, index) => ({ - _key: `section-${index}`, - image: { - id: `${prefix}-section-${index}`, - provider: "local", - mimeType: "image/webp", - }, - })), - }; -} - -async function installGenerationConflictTrigger(): Promise { - await sql` - CREATE TRIGGER measure_generation_conflict - AFTER INSERT ON _emdash_media_usage_generation_writes - BEGIN - UPDATE _emdash_media_usage_sources - SET updated_at = updated_at || 'x' - WHERE content_id = 'path-conflict'; - END - `.execute(db); -} - -async function installAnySourceConflictTrigger(): Promise { - await sql` - CREATE TRIGGER measure_any_generation_conflict - AFTER INSERT ON _emdash_media_usage_generation_writes - BEGIN - UPDATE _emdash_media_usage_sources - SET updated_at = updated_at || 'x' - WHERE source_key = NEW.source_key; - END - `.execute(db); -} - -async function installDraftSourceConflictTrigger(): Promise { - await sql` - CREATE TRIGGER measure_draft_generation_conflict - AFTER INSERT ON _emdash_media_usage_generation_writes - WHEN NEW.source_key LIKE '%:draft_overlay' - BEGIN - UPDATE _emdash_media_usage_sources - SET updated_at = updated_at || 'x' - WHERE source_key = NEW.source_key; - END - `.execute(db); -} - -async function measure( - operation: () => Promise, -): Promise<{ value: T; measurement: Measurement; queries: CapturedQuery[] }> { - captured = []; - const beforeChanges = totalChanges(); - const startedAt = performance.now(); - const value = await operation(); - const durationMs = performance.now() - startedAt; - return { - value, - queries: [...captured], - measurement: { - statements: captured.length, - d1EquivalentStatements: captured.filter( - (query) => !/^(?:begin|commit|rollback)$/i.test(query.sql), - ).length, - maxBinds: Math.max(0, ...captured.map((query) => query.parameters.length)), - changedRows: totalChanges() - beforeChanges, - durationMs: Number(durationMs.toFixed(3)), - }, - }; -} - -function totalChanges(): number { - return (sqlite.prepare("SELECT total_changes() AS count").get() as { count: number }).count; -} - -function expectOperatorPlans(queries: CapturedQuery[], expectedCount: number): void { - const listQueries = queries.filter((query) => - query.sql.includes('from "_emdash_media_usage_work" as "work"'), - ); - expect(listQueries).toHaveLength(expectedCount); - for (const query of listQueries) { - const plan = explain(query); - expect(plan).toContain("idx__emdash_media_usage_work_operator"); - expect(plan).not.toContain("USE TEMP B-TREE"); - expect(plan).not.toContain("SCAN work"); - } -} - -function explain(query: CapturedQuery): string { - const rows = sqlite - .prepare(`EXPLAIN QUERY PLAN ${query.sql}`) - .all(...query.parameters.map(bindable)) as { detail: string }[]; - return rows.map((row) => row.detail).join("\n"); -} - -function bindable(parameter: unknown): unknown { - if (typeof parameter === "boolean") return parameter ? 1 : 0; - if (parameter instanceof Date) return parameter.toISOString(); - if (parameter === undefined) return null; - return parameter; -} 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 index c15d5f138b..c0e4bfa314 100644 --- a/packages/core/tests/integration/database/media-usage-work-processor.test.ts +++ b/packages/core/tests/integration/database/media-usage-work-processor.test.ts @@ -10,18 +10,15 @@ import type { import { sql } from "kysely"; import { afterEach, beforeEach, expect, it } from "vitest"; -import { MediaUsageWorkRepository } from "../../../src/database/repositories/media-usage-work.js"; 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 { loadContentMediaUsageSnapshots } from "../../../src/media/usage/content-snapshots.js"; import { MEDIA_USAGE_WORK_PROCESSING_LIMITS, processDueMediaUsageWork, processMediaUsageWorkAfterWrite, } from "../../../src/media/usage/work-processor.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; -import type { CreateFieldInput } from "../../../src/schema/types.js"; import { describeEachDialect, setupForDialect, @@ -253,143 +250,6 @@ describeEachDialect("media usage durable work processing", (dialect) => { ); }); - it("publishes changed live and draft projections at the 14-occurrence boundary", async () => { - const fixture = await createResourceFixture(ctx, "bounded_usage"); - await insertResourceEntry(ctx, fixture, "entry-1", mediaData(14, "live")); - await addResourceDraft(ctx, fixture, "entry-1", mediaData(14, "draft")); - - const result = await processMediaUsageWorkAfterWrite(ctx.db, "bounded_usage", "entry-1"); - - expect(result.outcome).toBe("completed"); - expect(await countWork(ctx.db)).toBe(0); - expect( - await countCurrentOccurrences(ctx.db, canonicalSourceKey(fixture.collectionId, "entry-1")), - ).toBe(14); - expect( - await countCurrentOccurrences( - ctx.db, - canonicalSourceKey(fixture.collectionId, "entry-1", "draft_overlay"), - ), - ).toBe(14); - }); - - it("fails changed oversized work terminally before publishing either projection", async () => { - const fixture = await createResourceFixture(ctx, "oversized_usage"); - await insertResourceEntry(ctx, fixture, "entry-1", mediaData(1, "initial-live")); - await addResourceDraft(ctx, fixture, "entry-1", mediaData(1, "initial-draft")); - expect( - (await processMediaUsageWorkAfterWrite(ctx.db, "oversized_usage", "entry-1")).outcome, - ).toBe("completed"); - - const usageRepo = new MediaUsageRepository(ctx.db); - const columnsKey = canonicalSourceKey(fixture.collectionId, "entry-1"); - const draftKey = canonicalSourceKey(fixture.collectionId, "entry-1", "draft_overlay"); - const columnsBefore = await usageRepo.findSource(columnsKey); - const draftBefore = await usageRepo.findSource(draftKey); - const storedColumnsBefore = await countStoredOccurrences(ctx.db, columnsKey); - const storedDraftBefore = await countStoredOccurrences(ctx.db, draftKey); - await updateResourceEntry(ctx, fixture, "entry-1", mediaData(14, "changed-live")); - await updateResourceDraft(ctx, "entry-1", mediaData(15, "changed-draft")); - - const result = await processMediaUsageWorkAfterWrite(ctx.db, "oversized_usage", "entry-1"); - - expect(result.outcome).toBe("failed"); - expect((await usageRepo.findSource(columnsKey))?.currentGeneration).toBe( - columnsBefore?.currentGeneration, - ); - expect((await usageRepo.findSource(draftKey))?.currentGeneration).toBe( - draftBefore?.currentGeneration, - ); - expect(await countCurrentOccurrences(ctx.db, columnsKey)).toBe(1); - expect(await countCurrentOccurrences(ctx.db, draftKey)).toBe(1); - expect(await countStoredOccurrences(ctx.db, columnsKey)).toBe(storedColumnsBefore); - expect(await countStoredOccurrences(ctx.db, draftKey)).toBe(storedDraftBefore); - expect(await findWork(ctx.db)).toEqual( - expect.objectContaining({ - state: "failed", - attempt_count: 1, - last_error_code: "MEDIA_USAGE_RESOURCE_LIMIT", - }), - ); - expect(await findCoverageStatus(ctx.db, fixture.collectionId)).toEqual( - expect.objectContaining({ - status: "partial", - reconciliation_required: 0, - last_error_code: "MEDIA_USAGE_RESOURCE_LIMIT", - }), - ); - - const reopened = await new MediaUsageWorkRepository(ctx.db).retryOperatorWork({ - collectionId: fixture.collectionId, - contentId: "entry-1", - }); - expect(reopened).toEqual(expect.objectContaining({ outcome: "pending", changed: true })); - expect( - (await processMediaUsageWorkAfterWrite(ctx.db, "oversized_usage", "entry-1")).outcome, - ).toBe("failed"); - expect(await findWork(ctx.db)).toEqual( - expect.objectContaining({ - state: "failed", - attempt_count: 1, - last_error_code: "MEDIA_USAGE_RESOURCE_LIMIT", - }), - ); - expect((await usageRepo.findSource(columnsKey))?.currentGeneration).toBe( - columnsBefore?.currentGeneration, - ); - expect((await usageRepo.findSource(draftKey))?.currentGeneration).toBe( - draftBefore?.currentGeneration, - ); - }); - - it("completes an oversized projection only when the stored projection is an unchanged no-op", async () => { - const fixture = await createResourceFixture(ctx, "oversized_noop"); - await insertResourceEntry(ctx, fixture, "entry-1", mediaData(15, "unchanged")); - const snapshots = await loadContentMediaUsageSnapshots( - ctx.db, - fixture.collectionSlug, - "entry-1", - undefined, - { collectionId: fixture.collectionId, identityVersion: 1 }, - ); - if (!snapshots.success) throw new Error(`Expected snapshots, received ${snapshots.error}`); - const usageRepo = new MediaUsageRepository(ctx.db); - for (const snapshot of snapshots.snapshots) { - await usageRepo.replaceSource(snapshot.source, snapshot.occurrences); - } - const sourceKey = canonicalSourceKey(fixture.collectionId, "entry-1"); - const before = await usageRepo.findSource(sourceKey); - - const result = await processMediaUsageWorkAfterWrite(ctx.db, "oversized_noop", "entry-1"); - - expect(result.outcome).toBe("completed"); - expect(await countWork(ctx.db)).toBe(0); - expect((await usageRepo.findSource(sourceKey))?.currentGeneration).toBe( - before?.currentGeneration, - ); - expect(await countCurrentOccurrences(ctx.db, sourceKey)).toBe(15); - - await ctx.db - .updateTable("_emdash_media_usage_sources") - .set({ last_error_code: "PROJECTION_INVALID" }) - .where("source_key", "=", sourceKey) - .execute(); - await sql` - UPDATE ${sql.ref(fixture.tableName)} - SET title = title - WHERE id = 'entry-1' - `.execute(ctx.db); - expect( - (await processMediaUsageWorkAfterWrite(ctx.db, "oversized_noop", "entry-1")).outcome, - ).toBe("failed"); - expect(await findWork(ctx.db)).toEqual( - expect.objectContaining({ last_error_code: "MEDIA_USAGE_RESOURCE_LIMIT" }), - ); - expect((await usageRepo.findSource(sourceKey))?.currentGeneration).toBe( - before?.currentGeneration, - ); - }); - it("reconciles permanent entry absence without leaving work", async () => { const fixture = await createActiveFixture(ctx, "documents"); await insertEntry(ctx, fixture, "entry-1", "media-1"); @@ -457,16 +317,11 @@ class QueryCountingPlugin implements KyselyPlugin { } } -async function createActiveFixture( - ctx: DialectTestContext, - collectionSlug: string, - extraFields: readonly CreateFieldInput[] = [], -) { +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" }); - for (const field of extraFields) await registry.createField(collectionSlug, field); const collection = await registry.getCollection(collectionSlug); if (!collection) throw new Error(`Expected ${collectionSlug} collection`); @@ -504,18 +359,6 @@ async function createActiveFixture( }; } -async function createResourceFixture(ctx: DialectTestContext, collectionSlug: string) { - return createActiveFixture(ctx, collectionSlug, [ - { slug: "body", label: "Body", type: "portableText" }, - { - slug: "sections", - label: "Sections", - type: "repeater", - validation: { subFields: [{ slug: "image", type: "image", label: "Image" }] }, - }, - ]); -} - async function insertEntry( ctx: DialectTestContext, fixture: Awaited>, @@ -534,95 +377,6 @@ async function insertEntry( `.execute(ctx.db); } -async function insertResourceEntry( - ctx: DialectTestContext, - fixture: Awaited>, - contentId: string, - data: ReturnType, -): Promise { - await sql` - INSERT INTO ${sql.ref(fixture.tableName)} (id, slug, status, title, hero, body, sections) - VALUES ( - ${contentId}, - ${contentId}, - 'published', - ${contentId}, - NULL, - ${JSON.stringify(data.body)}, - ${JSON.stringify(data.sections)} - ) - `.execute(ctx.db); -} - -async function updateResourceEntry( - ctx: DialectTestContext, - fixture: Awaited>, - contentId: string, - data: ReturnType, -): Promise { - await sql` - UPDATE ${sql.ref(fixture.tableName)} - SET body = ${JSON.stringify(data.body)}, sections = ${JSON.stringify(data.sections)} - WHERE id = ${contentId} - `.execute(ctx.db); -} - -async function addResourceDraft( - ctx: DialectTestContext, - fixture: Awaited>, - contentId: string, - data: ReturnType, -): Promise { - const revisionId = `revision-${contentId}`; - await ctx.db - .insertInto("revisions") - .values({ - id: revisionId, - collection: fixture.collectionSlug, - entry_id: contentId, - data: JSON.stringify(data), - author_id: null, - }) - .execute(); - await sql` - UPDATE ${sql.ref(fixture.tableName)} - SET draft_revision_id = ${revisionId} - WHERE id = ${contentId} - `.execute(ctx.db); -} - -async function updateResourceDraft( - ctx: DialectTestContext, - contentId: string, - data: ReturnType, -): Promise { - await ctx.db - .updateTable("revisions") - .set({ data: JSON.stringify(data) }) - .where("id", "=", `revision-${contentId}`) - .execute(); -} - -function mediaData(count: number, prefix: string) { - const portableTextCount = Math.ceil(count / 2); - const repeaterCount = Math.floor(count / 2); - return { - body: Array.from({ length: portableTextCount }, (_, index) => ({ - _type: "image", - _key: `body-${index}`, - asset: { _ref: `${prefix}-body-${index}` }, - })), - sections: Array.from({ length: repeaterCount }, (_, index) => ({ - _key: `section-${index}`, - image: { - id: `${prefix}-section-${index}`, - provider: "local", - mimeType: "image/webp", - }, - })), - }; -} - function canonicalSourceKey( collectionId: string, contentId: string, @@ -631,29 +385,6 @@ function canonicalSourceKey( return `content:${collectionId}:${contentId}:${sourceVariant}`; } -async function countCurrentOccurrences(db: Kysely, sourceKey: string): Promise { - const result = await db - .selectFrom("_emdash_media_usage as usage") - .innerJoin("_emdash_media_usage_sources as source", (join) => - join - .onRef("source.source_key", "=", "usage.source_key") - .onRef("source.current_generation", "=", "usage.generation"), - ) - .select((eb) => eb.fn.countAll().as("count")) - .where("source.source_key", "=", sourceKey) - .executeTakeFirstOrThrow(); - return Number(result.count); -} - -async function countStoredOccurrences(db: Kysely, sourceKey: string): Promise { - const result = await db - .selectFrom("_emdash_media_usage") - .select((eb) => eb.fn.countAll().as("count")) - .where("source_key", "=", sourceKey) - .executeTakeFirstOrThrow(); - return Number(result.count); -} - async function countWork(db: Kysely): Promise { const result = await db .selectFrom("_emdash_media_usage_work") From 418412ead9088bb11e9dcc99d740cd09128ab717 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:39:48 +0100 Subject: [PATCH 10/11] fix(core): qualify activation conflict guards --- packages/core/src/media/usage/activation.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/media/usage/activation.ts b/packages/core/src/media/usage/activation.ts index f67ff50ecf..749263c44f 100644 --- a/packages/core/src/media/usage/activation.ts +++ b/packages/core/src/media/usage/activation.ts @@ -403,6 +403,8 @@ async function prepareCollectionForActivation( 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({ @@ -431,8 +433,14 @@ async function prepareCollectionForActivation( }) .where((eb) => eb.and([ - eb.or([eb("collection_id", "is", null), eb("collection_id", "=", collection.id)]), - eb.or([eb("capture_state", "is", null), eb("capture_state", "!=", "deleting")]), + eb.or([ + eb(existingCollectionId, "is", null), + eb(existingCollectionId, "=", collection.id), + ]), + eb.or([ + eb(existingCaptureState, "is", null), + eb(existingCaptureState, "!=", "deleting"), + ]), activeActivationLease(db, leaseToken), ]), ), From 2203f92d3dcd5e3bb0250386d0a78110c2629c09 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Tue, 11 Aug 2026 13:29:55 +0100 Subject: [PATCH 11/11] docs: remove dormant media usage guidance --- .../src/content/docs/guides/media-library.mdx | 48 ------------------- 1 file changed, 48 deletions(-) diff --git a/docs/src/content/docs/guides/media-library.mdx b/docs/src/content/docs/guides/media-library.mdx index e4e1596a61..4899f6e3ba 100644 --- a/docs/src/content/docs/guides/media-library.mdx +++ b/docs/src/content/docs/guides/media-library.mdx @@ -252,54 +252,6 @@ const { entry: post } = await getEmDashEntry("posts", Astro.params.slug); `priority` is for the primary above-the-fold image. It sets `loading="eager"` and `fetchpriority="high"`: `loading` controls whether loading is deferred, and `fetchpriority` gives the browser a priority hint for the request. -## Monitoring Media Usage Indexing - -Incremental Media Usage capture is staged but deliberately inactive in this release. Normal saves -do not create or process durable indexing work until activation becomes available in a later -safety-complete release. Use collection-scoped Media Usage repair to refresh the index in the -meantime. - -After activation, EmDash records durable work when a supported content save changes its media -references. It tries the work immediately and, when scheduled maintenance is enabled, retries -transient failures in bounded background runs. Completed work is removed from the queue. - -Operators with `schema:manage` can inspect one collection at a time without loading or counting -the whole backlog: - -```http -GET /_emdash/api/admin/media-usage/work?collection=posts&state=failed&limit=50 -``` - -The optional `state` is `pending`, `retry`, `leased`, or `failed`. Follow `nextCursor` to request -another page. A `failed` item has reached the automatic retry limit and remains visible until a -later content save or an explicit retry reopens it. - -Retry one item using the immutable `collectionId` and `contentId` returned by the list: - -```http -POST /_emdash/api/admin/media-usage/work/retry -Content-Type: application/json -X-EmDash-Request: 1 - -{ - "collectionId": "01COLLECTION...", - "contentId": "01CONTENT..." -} -``` - -The same operations are available through `EmDashClient.mediaListUsageWork()` and -`EmDashClient.mediaRetryUsageWork()`. Retrying already-pending work is a successful no-op. A live -worker lease returns `409 WORK_LEASE_ACTIVE`; wait until its reported expiry instead of trying to -replace the active worker. - - - ## Deleting Media 1. Select the file(s) you want to delete