@objectstack/metadata 17.3.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/migrations/driver-exec.ts","../../src/migrations/migrate-env-id-to-project-id.ts","../../src/migrations/migrate-project-id-to-environment-id.ts","../../src/migrations/drop-projection-tables.ts","../../src/migrations/migrate-sys-notification-to-event.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * How the migrations in this directory obtain a raw-SQL entry point.\n *\n * Every helper here used to guard on — and drive through — `driver.raw(sql,\n * bindings?)`. **No data driver in this repo defines `raw`.** Measured on\n * `origin/main`, the only `raw(` member anywhere outside a test double is\n * `packages/verify/src/harness.ts`, an HTTP harness whose signature is\n * `(path, init)`. `SqlDriver` keeps its knex handle `protected`, so\n * `driver.raw` is `undefined` there too, and `SqliteWasmDriver` inherits that.\n * The result was a published, operator-documented migration path that refused\n * every driver the platform ships — quietly, because\n * `migrateSysNotificationToEvent` *returns* `{ status: 'error' }` rather than\n * throwing, and the message blamed the operator's driver instead of saying the\n * migration did not run.\n *\n * ## Why `execute` is tried FIRST\n *\n * `IDataDriver` (`@objectstack/spec/contracts`, `data-driver.ts`) declares\n *\n * ```ts\n * execute(command: unknown, parameters?: unknown[], options?: DriverOptions): Promise<unknown>;\n * ```\n *\n * — **non-optional**, with bound parameters as the second POSITIONAL argument,\n * which is the exact shape `raw(sql, bindings?)` was being called in. `raw` has\n * never appeared on that interface. So `execute` is not merely the surface the\n * shipped drivers happen to have; it is the only raw-execution surface the\n * contract guarantees at all, and a driver that satisfies `IDataDriver` always\n * has it. Trying it first is therefore the order that matches the declaration.\n *\n * ⚠️ `IDataEngine.execute?(command, options?)` (`data-engine.ts`) is a DIFFERENT\n * member on a different interface — its second parameter is an options bag, not\n * bindings. These helpers take an `IDataDriver`, so `data-driver.ts` governs.\n * Do not reason about this call from the engine declaration.\n *\n * ## Prior art, and why the order had to be chosen rather than copied\n *\n * `packages/metadata-protocol/src/migrations/` already resolves both surfaces\n * instead of assuming one — twice, and **in opposite orders**:\n * `partial-index-probe.ts` tries `raw` first, `seed-tenancy-backfill.ts` tries\n * `execute` first. `metadata-protocol/src/protocol.ts` (`ensureOverlayIndex`)\n * is a third, raw-first. One operation with three implementations and two\n * behaviours resolves to the declaration-bound side, so this directory adopts\n * `execute`-first uniformly across all four of its members.\n *\n * `raw` is kept as a fallback rather than dropped: nothing in this repo defines\n * it, but a host or a third-party driver may, and removing a surface that\n * currently works is not what this repair is for. The refusal below therefore\n * fires only for a driver that has NEITHER.\n *\n * ## Known limitation, deliberately not papered over here\n *\n * Two shipped drivers satisfy `typeof driver.execute === 'function'` without\n * being able to run SQL: `MemoryDriver.execute` logs a warning and returns\n * `null` for every command, and `MongoDbDriver.execute` returns a string\n * command back verbatim. Both are selected by the probe below and then answer\n * every column probe with \"absent\", so a migration reports `not_applicable` /\n * `table_missing` instead of refusing. `IDataDriver` exposes no capability flag\n * that would separate \"implements the escape hatch\" from \"can run SQL\"\n * (`DriverCapabilities` has no such member), so distinguishing them is a\n * contract question, not something to guess at with a driver-name sniff.\n * Filed separately.\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\n/**\n * A raw-SQL entry point resolved off a driver. `bindings` are passed\n * positionally, matching `IDataDriver.execute`'s declared `parameters`.\n */\nexport type DriverExec = (sql: string, bindings?: readonly unknown[]) => Promise<any>;\n\n/**\n * Resolve the raw-SQL entry point of `driver`, or `undefined` when it offers\n * neither surface.\n *\n * Callers that must refuse should pair this with {@link driverExecRefusal} so\n * every member of this directory states the same remedy.\n */\nexport function resolveDriverExec(driver: IDataDriver | null | undefined): DriverExec | undefined {\n const candidate = driver as any;\n if (!candidate) return undefined;\n // Declared surface first — see the header.\n if (typeof candidate.execute === 'function') {\n return (sql, bindings) => candidate.execute(sql, bindings ? [...bindings] : []);\n }\n if (typeof candidate.raw === 'function') {\n return (sql, bindings) => candidate.raw(sql, bindings ? [...bindings] : []);\n }\n return undefined;\n}\n\n/**\n * The single refusal sentence used by every migration in this directory, for a\n * driver that offers neither surface.\n *\n * Assembled in one place because the wording carries pinned properties: the\n * remedy is stated exactly ONCE (a guard here once concatenated its instruction\n * twice), the two sentences stay separated rather than running together, and a\n * conforming driver is named so the operator has something to act on.\n */\nexport function driverExecRefusal(helper: string): string {\n return (\n `${helper}: driver must expose an .execute(sql, bindings?) or .raw(sql, bindings?) method. ` +\n 'SqlDriver (better-sqlite3/knex) exposes .execute(), as does its SqliteWasmDriver subclass; ' +\n 'cloud-side TursoDriver also conforms.'\n );\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: env_id → project_id\n *\n * Renames the `env_id` column to `project_id` on the metadata storage tables:\n * - sys_metadata\n * - sys_metadata_history\n *\n * (The per-type projection tables `sys_object` / `sys_view` / `sys_flow` /\n * `sys_agent` / `sys_tool` were removed in 2026-05 along with the projection\n * pipeline — see ADR 0005 addendum. They are intentionally not included.)\n *\n * Safe to run multiple times (idempotent): checks for column existence before\n * attempting to rename. If `project_id` already exists, the step is skipped.\n *\n * Usage:\n * import { migrateEnvIdToProjectId } from '@objectstack/metadata/migrations';\n * await migrateEnvIdToProjectId(driver);\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nimport { type DriverExec, driverExecRefusal, resolveDriverExec } from './driver-exec.js';\n\nconst AFFECTED_TABLES = [\n 'sys_metadata',\n 'sys_metadata_history',\n] as const;\n\nexport interface MigrationResult {\n table: string;\n status: 'renamed' | 'already_done' | 'table_missing' | 'error';\n error?: string;\n}\n\n/**\n * Rename `env_id` → `project_id` on all metadata tables.\n *\n * @param driver An IDataDriver with access to the target database. Raw SQL is\n * issued through the surface `IDataDriver` declares —\n * `execute(sql, bindings?)` — falling back to\n * `raw(sql, bindings?)`; see `./driver-exec.ts`.\n * @returns Per-table migration results.\n */\nexport async function migrateEnvIdToProjectId(driver: IDataDriver): Promise<MigrationResult[]> {\n const exec = resolveDriverExec(driver);\n\n if (!exec) {\n throw new Error(driverExecRefusal('migrateEnvIdToProjectId'));\n }\n\n const results: MigrationResult[] = [];\n\n for (const table of AFFECTED_TABLES) {\n try {\n // Detect dialect: SQLite uses PRAGMA, others use information_schema.\n const hasColumn = await _columnExists(exec, table, 'env_id');\n const alreadyMigrated = await _columnExists(exec, table, 'project_id');\n\n if (alreadyMigrated && !hasColumn) {\n results.push({ table, status: 'already_done' });\n continue;\n }\n\n if (!hasColumn) {\n // Neither column exists — table might not exist yet.\n results.push({ table, status: 'table_missing' });\n continue;\n }\n\n // Perform the rename. SQLite ≥ 3.25.0 supports ALTER TABLE RENAME COLUMN.\n await exec(`ALTER TABLE \"${table}\" RENAME COLUMN env_id TO project_id`);\n\n results.push({ table, status: 'renamed' });\n } catch (err: any) {\n results.push({ table, status: 'error', error: err?.message ?? String(err) });\n }\n }\n\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nasync function _columnExists(exec: DriverExec, table: string, column: string): Promise<boolean> {\n try {\n // SQLite: PRAGMA table_info returns rows with `name` column.\n const rows: any[] = await exec(`PRAGMA table_info(\"${table}\")`);\n if (Array.isArray(rows) && rows.length > 0) {\n // knex wraps PRAGMA result; handle both `rows` and `rows[0]` shapes.\n const list: any[] = Array.isArray(rows[0]) ? rows[0] : rows;\n return list.some((r: any) => r?.name === column);\n }\n\n // Fallback for non-SQLite: query information_schema.\n const result: any[] = await exec(\n `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,\n [table, column]\n );\n const list: any[] = Array.isArray(result[0]) ? result[0] : result;\n return list.length > 0;\n } catch {\n return false;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: project_id → environment_id\n *\n * Renames the `project_id` column to `environment_id` on the metadata storage\n * tables — but only on the tables whose CURRENT declaration actually knows\n * `environment_id`.\n *\n * Forward counterpart of {@link migrateEnvIdToProjectId} (which performed the\n * earlier `env_id → project_id` rename). Together they let an operator walk an\n * old schema all the way forward in two steps:\n *\n * migrateEnvIdToProjectId(driver); // env_id → project_id (legacy)\n * migrateProjectIdToEnvironmentId(driver); // project_id → environment_id (v5)\n *\n * ─────────────────────────────────────────────────────────────────────\n * Why the table list is DERIVED and not written out (#13205)\n *\n * This migration is the terminal step of that chain: its target column is\n * the CURRENT declared shape, so \"should this table be renamed?\" is not an\n * independent fact — it is `does this object still declare environment_id?`.\n * Written out by hand, the two drifted apart: `sys_metadata_history` stayed\n * on the list after the branch/project-removal amendment (M1) removed\n * `environment_id` from its declaration, so against a database whose\n * physical `sys_metadata_history` still carried `project_id` this migration\n * renamed it to a column NO declaration knows about — minting exactly the\n * orphan column class the metadata drift audit exists to remove.\n *\n * The old guard could not catch it: the loop gates on `project_id` existing\n * PHYSICALLY (`_columnExists`), which says nothing about the target column\n * being DECLARED. So the list is now computed from the declarations in\n * `@objectstack/metadata-core` (already a dependency of this package — no\n * new edge), and a candidate that does not declare the target column is\n * reported as `skipped_not_declared` rather than dropped silently: an\n * operator reading the result sees the table was considered and why nothing\n * happened, instead of having to guess whether it was forgotten again.\n *\n * ⚠️ The sibling `migrate-env-id-to-project-id.ts` is deliberately NOT\n * changed this way. Its target (`project_id`) is an INTERMEDIATE column that\n * no current declaration carries by design — gating it on today's\n * declarations would disable the chain's first step entirely. The rule\n * \"target must be declared\" is sound only for the terminal migration.\n * ─────────────────────────────────────────────────────────────────────\n *\n * (The per-type projection tables `sys_object` / `sys_view` / `sys_flow` /\n * `sys_agent` / `sys_tool` were removed in 2026-05 along with the projection\n * pipeline — see ADR 0005 addendum. They are intentionally not included.)\n *\n * Safe to run multiple times (idempotent): checks for column existence before\n * attempting to rename. If `environment_id` already exists, the step is\n * skipped.\n *\n * Usage:\n * import { migrateProjectIdToEnvironmentId } from '@objectstack/metadata/migrations';\n * await migrateProjectIdToEnvironmentId(driver);\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nimport { type DriverExec, driverExecRefusal, resolveDriverExec } from './driver-exec.js';\nimport { SysMetadataObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';\n\n/** The column this migration RENAMES AWAY FROM. */\nconst SOURCE_COLUMN = 'project_id';\n\n/** The column this migration PRODUCES. Must be declared, or the rename mints an orphan. */\nconst TARGET_COLUMN = 'environment_id';\n\n/**\n * Every metadata storage table this migration considers. Membership here says\n * \"this table has, historically, carried the tenancy column\" — whether the\n * rename actually runs is decided by {@link AFFECTED_TABLES} below, from the\n * declaration.\n */\nconst CANDIDATE_OBJECTS = [SysMetadataObject, SysMetadataHistoryObject] as const;\n\nfunction declaresColumn(object: { fields?: Record<string, unknown> }, column: string): boolean {\n return Object.prototype.hasOwnProperty.call(object.fields ?? {}, column);\n}\n\n/** Candidate table names, in declaration order. */\nconst CANDIDATE_TABLES: readonly string[] = CANDIDATE_OBJECTS.map((o) => o.name);\n\n/**\n * The tables this migration will actually rename: the candidates whose CURRENT\n * declaration carries {@link TARGET_COLUMN}.\n *\n * Exported for the pin in `migrate-project-id-to-environment-id.test.ts` (not\n * re-exported from `./index.ts` — this is not package surface).\n */\nexport const AFFECTED_TABLES: readonly string[] = CANDIDATE_OBJECTS\n .filter((o) => declaresColumn(o, TARGET_COLUMN))\n .map((o) => o.name);\n\nexport interface ProjectIdToEnvironmentIdResult {\n table: string;\n /**\n * `skipped_not_declared` — the table is a known metadata storage table, but\n * its current declaration has no `environment_id`, so renaming into it\n * would create a column nothing declares. Nothing was executed.\n */\n status: 'renamed' | 'already_done' | 'table_missing' | 'skipped_not_declared' | 'error';\n error?: string;\n}\n\n/**\n * Rename `project_id` → `environment_id` on all metadata tables that still\n * declare `environment_id`.\n *\n * @param driver An IDataDriver with access to the target database. Raw SQL is\n * issued through the surface `IDataDriver` declares —\n * `execute(sql, bindings?)` — falling back to\n * `raw(sql, bindings?)`; see `./driver-exec.ts`.\n * @returns Per-table migration results — one entry per candidate table,\n * including the ones skipped for lacking the declared target.\n */\nexport async function migrateProjectIdToEnvironmentId(\n driver: IDataDriver,\n): Promise<ProjectIdToEnvironmentIdResult[]> {\n const exec = resolveDriverExec(driver);\n\n if (!exec) {\n throw new Error(driverExecRefusal('migrateProjectIdToEnvironmentId'));\n }\n\n const results: ProjectIdToEnvironmentIdResult[] = [];\n\n for (const table of CANDIDATE_TABLES) {\n // The declared-target gate, ahead of every physical probe: a table whose\n // declaration lost `environment_id` must never be renamed INTO it, no\n // matter what the physical schema still carries (#13205).\n if (!AFFECTED_TABLES.includes(table)) {\n results.push({ table, status: 'skipped_not_declared' });\n continue;\n }\n\n try {\n const hasColumn = await _columnExists(exec, table, SOURCE_COLUMN);\n const alreadyMigrated = await _columnExists(exec, table, TARGET_COLUMN);\n\n if (alreadyMigrated && !hasColumn) {\n results.push({ table, status: 'already_done' });\n continue;\n }\n\n if (!hasColumn) {\n results.push({ table, status: 'table_missing' });\n continue;\n }\n\n await exec(\n `ALTER TABLE \"${table}\" RENAME COLUMN ${SOURCE_COLUMN} TO ${TARGET_COLUMN}`,\n );\n\n results.push({ table, status: 'renamed' });\n } catch (err: any) {\n results.push({ table, status: 'error', error: err?.message ?? String(err) });\n }\n }\n\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nasync function _columnExists(exec: DriverExec, table: string, column: string): Promise<boolean> {\n try {\n const rows: any[] = await exec(`PRAGMA table_info(\"${table}\")`);\n if (Array.isArray(rows) && rows.length > 0) {\n const list: any[] = Array.isArray(rows[0]) ? rows[0] : rows;\n return list.some((r: any) => r?.name === column);\n }\n\n const result: any[] = await exec(\n `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,\n [table, column],\n );\n const list: any[] = Array.isArray(result[0]) ? result[0] : result;\n return list.length > 0;\n } catch {\n return false;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: drop deprecated metadata projection tables.\n *\n * In 2026-05 the per-type projection tables (`sys_object` / `sys_view` /\n * `sys_flow` / `sys_agent` / `sys_tool`) and the corresponding\n * `MetadataProjector` were removed (see ADR 0005 addendum). All metadata\n * now lives as JSON inside `sys_metadata` — these projection tables are\n * dead weight on any existing database.\n *\n * This migration drops them if present. It is idempotent and safe to run\n * on databases that never had them (the `DROP TABLE IF EXISTS` is a no-op).\n *\n * Usage:\n * import { dropProjectionTables } from '@objectstack/metadata/migrations';\n * await dropProjectionTables(driver);\n */\n\nimport type { IDataDriver } from '@objectstack/spec/contracts';\n\nimport { driverExecRefusal, resolveDriverExec } from './driver-exec.js';\n\nconst DEPRECATED_TABLES = [\n 'sys_object',\n 'sys_view',\n 'sys_flow',\n 'sys_agent',\n 'sys_tool',\n] as const;\n\nexport interface DropProjectionResult {\n table: string;\n status: 'dropped' | 'not_present' | 'error';\n error?: string;\n}\n\n/**\n * Drop the deprecated per-type metadata projection tables.\n *\n * @param driver An `IDataDriver`. Raw SQL is issued through the surface\n * `IDataDriver` declares — `execute(sql, bindings?)` — falling\n * back to `raw(sql, bindings?)`; see `./driver-exec.ts`.\n * @returns Per-table results.\n */\nexport async function dropProjectionTables(driver: IDataDriver): Promise<DropProjectionResult[]> {\n const exec = resolveDriverExec(driver);\n if (!exec) {\n throw new Error(driverExecRefusal('dropProjectionTables'));\n }\n\n const results: DropProjectionResult[] = [];\n for (const table of DEPRECATED_TABLES) {\n try {\n await exec(`DROP TABLE IF EXISTS ${table}`);\n results.push({ table, status: 'dropped' });\n } catch (error) {\n results.push({\n table,\n status: 'error',\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n return results;\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Migration: sys_notification (per-user inbox) → notification event (ADR-0030)\n *\n * ADR-0030 re-models `sys_notification` from a per-user *inbox* into the L2\n * *event* (one row per `emit`). This migration preserves users' existing bell\n * notifications across the cut-over by splitting each legacy row into the new\n * layered model:\n *\n * legacy sys_notification row (recipient_id, type, title, body, url,\n * actor_name, is_read, read_at, …)\n * │\n * ├─► sys_inbox_message (L5 in-app materialization, keyed by user)\n * ├─► sys_notification_receipt (L5 read-state: 'read' if is_read else 'delivered')\n * └─► the sys_notification row itself is rewritten to the event shape\n * (topic ← type, payload ← {title,body,url,actor_name}) and its legacy\n * inbox columns are cleared.\n *\n * Idempotent: it acts only on rows that still carry the legacy shape\n * (`recipient_id IS NOT NULL`); a second run is a no-op. Safe when the legacy\n * columns were never present (a fresh install created directly in the new\n * shape) — it reports `not_applicable`.\n *\n * Usage:\n * import { migrateSysNotificationToEvent } from '@objectstack/metadata/migrations';\n * await migrateSysNotificationToEvent({ driver, data });\n *\n * `driver` provides raw access to read legacy columns the re-modeled schema no\n * longer projects and to clear them — through the surface `IDataDriver`\n * declares, `execute(sql, bindings?)`, falling back to `raw(sql, bindings?)`\n * (see `./driver-exec.ts`); `data` (IDataEngine) performs the\n * structured inbox/receipt writes and the event rewrite so ids, JSON fields and\n * tenant stamping are handled uniformly across drivers.\n */\n\nimport type { IDataDriver, IDataEngine } from '@objectstack/spec/contracts';\n\nimport { type DriverExec, driverExecRefusal, resolveDriverExec } from './driver-exec.js';\n\nconst EVENT_OBJECT = 'sys_notification';\nconst INBOX_OBJECT = 'sys_inbox_message';\nconst RECEIPT_OBJECT = 'sys_notification_receipt';\n\n/** Legacy inbox columns cleared once a row is rewritten to the event shape. */\nconst LEGACY_COLUMNS = [\n 'recipient_id',\n 'type',\n 'title',\n 'body',\n 'url',\n 'actor_name',\n 'is_read',\n 'read_at',\n] as const;\n\nexport interface SysNotificationMigrationResult {\n status: 'migrated' | 'already_done' | 'not_applicable' | 'error';\n /** Number of legacy rows split into inbox + receipt + event. */\n migrated: number;\n error?: string;\n}\n\nexport interface SysNotificationMigrationOptions {\n driver: IDataDriver;\n data: IDataEngine;\n /** Defaults to `() => new Date().toISOString()`. */\n now?(): string;\n}\n\nexport async function migrateSysNotificationToEvent(\n opts: SysNotificationMigrationOptions,\n): Promise<SysNotificationMigrationResult> {\n const { data } = opts;\n const now = opts.now ?? (() => new Date().toISOString());\n\n const exec = resolveDriverExec(opts.driver);\n if (!exec) {\n return {\n status: 'error',\n migrated: 0,\n error: driverExecRefusal('migrateSysNotificationToEvent'),\n };\n }\n\n // No legacy `recipient_id` column → the table never held the inbox shape.\n if (!(await columnExists(exec, EVENT_OBJECT, 'recipient_id'))) {\n return { status: 'not_applicable', migrated: 0 };\n }\n\n // Only null-out columns that actually exist on this deployment.\n const presentLegacy: string[] = [];\n for (const col of LEGACY_COLUMNS) {\n if (await columnExists(exec, EVENT_OBJECT, col)) presentLegacy.push(col);\n }\n\n let migrated = 0;\n try {\n const rows = await selectLegacyRows(exec);\n if (rows.length === 0) return { status: 'already_done', migrated: 0 };\n\n for (const row of rows) {\n const id = String(row.id);\n const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;\n if (!recipientId) continue; // defensive — guarded by the SELECT filter\n const orgId = row.organization_id != null ? String(row.organization_id) : null;\n const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();\n const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification');\n const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1';\n // One topic for both the inbox row and the rewritten event, so the\n // materialization and its L2 event never disagree (empty/null legacy\n // `type` → 'legacy').\n const eventTopic = row.type != null && String(row.type).length > 0 ? String(row.type) : 'legacy';\n\n // L5 in-app materialization.\n await data.insert(INBOX_OBJECT, {\n user_id: recipientId,\n notification_id: id,\n topic: eventTopic,\n title,\n body_md: row.body ?? null,\n severity: 'info',\n action_url: row.url ?? null,\n organization_id: orgId,\n created_at: createdAt,\n });\n\n // L5 receipt (read-state spine).\n await data.insert(RECEIPT_OBJECT, {\n notification_id: id,\n delivery_id: null,\n user_id: recipientId,\n channel: 'inbox',\n state: isRead ? 'read' : 'delivered',\n at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,\n organization_id: orgId,\n created_at: createdAt,\n });\n\n // Rewrite the row itself to the L2 event shape (engine handles JSON).\n await data.update(\n EVENT_OBJECT,\n {\n id,\n topic: eventTopic,\n severity: 'info',\n payload: {\n title: row.title ?? null,\n body: row.body ?? null,\n url: row.url ?? null,\n actorName: row.actor_name ?? null,\n },\n },\n { where: { id } },\n );\n\n // Clear the legacy inbox columns so the row no longer matches the\n // migration filter (idempotency) and carries no stale recipient.\n if (presentLegacy.length > 0) {\n const setClause = presentLegacy.map((c) => `\"${c}\" = NULL`).join(', ');\n await exec(`UPDATE \"${EVENT_OBJECT}\" SET ${setClause} WHERE id = ?`, [id]);\n }\n\n migrated += 1;\n }\n\n return { status: 'migrated', migrated };\n } catch (err: any) {\n return { status: 'error', migrated, error: err?.message ?? String(err) };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * The canonical text spelling of a timestamp read back out of the legacy table.\n *\n * `selectLegacyRows` reads through `driver.raw`/`execute`, which hands the\n * dialect client's own materialisation straight back — that door does not run\n * `formatOutput`, so none of its repairs apply here on any dialect:\n *\n * - `created_at` is a BUILTIN audit column, so it is never in `datetimeFields`\n * and no declared-field coercion reaches it; `formatOutput` repairs it only\n * inside its `if (this.isSqlite)` arm (`repairNaiveUtcAuditTimestamp` over\n * `AUDIT_TIMESTAMP_COLUMNS`).\n * - `read_at` is a LEGACY column ADR-0030 removed from the object, so it is\n * not declared either — it can never enter `datetimeFields`, and it is not\n * an audit column, so no arm of `formatOutput` could reach it even at the\n * record read door.\n *\n * On SQLite both arrive as canonical ISO text and `String()` is the identity —\n * which is why every test in this directory stayed green. On Postgres and\n * MySQL an instant column materialises as a JS `Date`\n * (`withPostgresCalendarDayAsText` leaves the instant types alone deliberately;\n * pinned in `sql-driver-13567-audit-stamp-materialisation.test.ts`), and\n * `String(date)` spells\n *\n * Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)\n *\n * — whole seconds in the MIGRATING HOST's zone, with the milliseconds gone.\n * This migration is one-way and this value is WRITTEN, so that spelling is what\n * the platform would carry afterwards: either accepted and stored skewed and\n * de-precisioned, or rejected outright, since the trailing zone name is in no\n * dialect's timestamp grammar (#13998).\n *\n * Canonicalising HERE, at the consumer that writes, is deliberate and is the\n * only shape that could also repair an already-migrated deployment (#13973\n * option A). It is not a tolerant alias: `Date` and ISO text are two\n * materialisations of ONE instant, not two spellings of a key. Matches the\n * repo's existing correct form at `metadata-protocol/src/protocol.ts` (the\n * `occurred_at` read in `readMetadataAuditEvents`); anything that is neither a\n * string nor a `Date` keeps its previous `String()` rendering unchanged rather\n * than having a unit guessed for it on a one-way write path.\n */\nfunction canonicalTimestampText(value: unknown): string {\n if (typeof value === 'string') return value;\n if (value instanceof Date) return value.toISOString();\n return String(value);\n}\n\nasync function selectLegacyRows(exec: DriverExec): Promise<any[]> {\n const result: any[] = await exec(\n `SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` +\n `FROM \"${EVENT_OBJECT}\" WHERE recipient_id IS NOT NULL`,\n );\n // knex wraps some results as `[rows]`; normalize both shapes.\n if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0])) {\n return result[0];\n }\n return Array.isArray(result) ? result : [];\n}\n\nasync function columnExists(exec: DriverExec, table: string, column: string): Promise<boolean> {\n // SQLite path: PRAGMA table_info. On Postgres/others this raises a syntax\n // error — swallow it *locally* and fall through to information_schema (the\n // outer-catch version of this would never reach the fallback, making the\n // migration silently no-op on every non-SQLite DB).\n try {\n const rows: any = await exec(`PRAGMA table_info(\"${table}\")`);\n const list: any[] = Array.isArray(rows)\n ? (Array.isArray(rows[0]) ? rows[0] : rows)\n : [];\n if (list.length > 0 && list.some((r: any) => r?.name != null)) {\n return list.some((r: any) => r?.name === column);\n }\n } catch {\n /* not SQLite — fall through to information_schema */\n }\n // Postgres / others.\n try {\n const result: any = await exec(\n `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,\n [table, column],\n );\n const list: any[] = Array.isArray(result)\n ? (Array.isArray(result[0]) ? result[0] : result)\n : [];\n return list.length > 0;\n } catch {\n return false;\n }\n}\n"],"mappings":"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","names":["list","AFFECTED_TABLES","_columnExists","list"]}
1
+ {"version":3,"sources":["../../src/migrations/driver-exec.ts","../../src/migrations/migrate-env-id-to-project-id.ts","../../src/migrations/migrate-project-id-to-environment-id.ts","../../src/migrations/drop-projection-tables.ts","../../src/migrations/migrate-sys-notification-to-event.ts"],"mappings":";AAiFO,SAAS,kBAAkB,QAAgE;AAC9F,QAAM,YAAY;AAClB,MAAI,CAAC,UAAW,QAAO;AAEvB,MAAI,OAAO,UAAU,YAAY,YAAY;AACzC,WAAO,CAAC,KAAK,aAAa,UAAU,QAAQ,KAAK,WAAW,CAAC,GAAG,QAAQ,IAAI,CAAC,CAAC;AAAA,EAClF;AACA,MAAI,OAAO,UAAU,QAAQ,YAAY;AACrC,WAAO,CAAC,KAAK,aAAa,UAAU,IAAI,KAAK,WAAW,CAAC,GAAG,QAAQ,IAAI,CAAC,CAAC;AAAA,EAC9E;AACA,SAAO;AACX;AAWO,SAAS,kBAAkB,QAAwB;AACtD,SACI,GAAG,MAAM;AAIjB;;;ACpFA,IAAM,kBAAkB;AAAA,EACpB;AAAA,EACA;AACJ;AAiBA,eAAsB,wBAAwB,QAAiD;AAC3F,QAAM,OAAO,kBAAkB,MAAM;AAErC,MAAI,CAAC,MAAM;AACP,UAAM,IAAI,MAAM,kBAAkB,yBAAyB,CAAC;AAAA,EAChE;AAEA,QAAM,UAA6B,CAAC;AAEpC,aAAW,SAAS,iBAAiB;AACjC,QAAI;AAEA,YAAM,YAAY,MAAM,cAAc,MAAM,OAAO,QAAQ;AAC3D,YAAM,kBAAkB,MAAM,cAAc,MAAM,OAAO,YAAY;AAErE,UAAI,mBAAmB,CAAC,WAAW;AAC/B,gBAAQ,KAAK,EAAE,OAAO,QAAQ,eAAe,CAAC;AAC9C;AAAA,MACJ;AAEA,UAAI,CAAC,WAAW;AAEZ,gBAAQ,KAAK,EAAE,OAAO,QAAQ,gBAAgB,CAAC;AAC/C;AAAA,MACJ;AAGA,YAAM,KAAK,gBAAgB,KAAK,sCAAsC;AAEtE,cAAQ,KAAK,EAAE,OAAO,QAAQ,UAAU,CAAC;AAAA,IAC7C,SAAS,KAAU;AACf,cAAQ,KAAK,EAAE,OAAO,QAAQ,SAAS,OAAO,KAAK,WAAW,OAAO,GAAG,EAAE,CAAC;AAAA,IAC/E;AAAA,EACJ;AAEA,SAAO;AACX;AAMA,eAAe,cAAc,MAAkB,OAAe,QAAkC;AAC5F,MAAI;AAEA,UAAM,OAAc,MAAM,KAAK,sBAAsB,KAAK,IAAI;AAC9D,QAAI,MAAM,QAAQ,IAAI,KAAK,KAAK,SAAS,GAAG;AAExC,YAAMA,QAAc,MAAM,QAAQ,KAAK,CAAC,CAAC,IAAI,KAAK,CAAC,IAAI;AACvD,aAAOA,MAAK,KAAK,CAAC,MAAW,GAAG,SAAS,MAAM;AAAA,IACnD;AAGA,UAAM,SAAgB,MAAM;AAAA,MACxB;AAAA,MACA,CAAC,OAAO,MAAM;AAAA,IAClB;AACA,UAAM,OAAc,MAAM,QAAQ,OAAO,CAAC,CAAC,IAAI,OAAO,CAAC,IAAI;AAC3D,WAAO,KAAK,SAAS;AAAA,EACzB,QAAQ;AACJ,WAAO;AAAA,EACX;AACJ;;;AC9CA,SAAS,mBAAmB,gCAAgC;AAG5D,IAAM,gBAAgB;AAGtB,IAAM,gBAAgB;AAQtB,IAAM,oBAAoB,CAAC,mBAAmB,wBAAwB;AAEtE,SAAS,eAAe,QAA8C,QAAyB;AAC3F,SAAO,OAAO,UAAU,eAAe,KAAK,OAAO,UAAU,CAAC,GAAG,MAAM;AAC3E;AAGA,IAAM,mBAAsC,kBAAkB,IAAI,CAAC,MAAM,EAAE,IAAI;AASxE,IAAMC,mBAAqC,kBAC7C,OAAO,CAAC,MAAM,eAAe,GAAG,aAAa,CAAC,EAC9C,IAAI,CAAC,MAAM,EAAE,IAAI;AAwBtB,eAAsB,gCAClB,QACyC;AACzC,QAAM,OAAO,kBAAkB,MAAM;AAErC,MAAI,CAAC,MAAM;AACP,UAAM,IAAI,MAAM,kBAAkB,iCAAiC,CAAC;AAAA,EACxE;AAEA,QAAM,UAA4C,CAAC;AAEnD,aAAW,SAAS,kBAAkB;AAIlC,QAAI,CAACA,iBAAgB,SAAS,KAAK,GAAG;AAClC,cAAQ,KAAK,EAAE,OAAO,QAAQ,uBAAuB,CAAC;AACtD;AAAA,IACJ;AAEA,QAAI;AACA,YAAM,YAAY,MAAMC,eAAc,MAAM,OAAO,aAAa;AAChE,YAAM,kBAAkB,MAAMA,eAAc,MAAM,OAAO,aAAa;AAEtE,UAAI,mBAAmB,CAAC,WAAW;AAC/B,gBAAQ,KAAK,EAAE,OAAO,QAAQ,eAAe,CAAC;AAC9C;AAAA,MACJ;AAEA,UAAI,CAAC,WAAW;AACZ,gBAAQ,KAAK,EAAE,OAAO,QAAQ,gBAAgB,CAAC;AAC/C;AAAA,MACJ;AAEA,YAAM;AAAA,QACF,gBAAgB,KAAK,mBAAmB,aAAa,OAAO,aAAa;AAAA,MAC7E;AAEA,cAAQ,KAAK,EAAE,OAAO,QAAQ,UAAU,CAAC;AAAA,IAC7C,SAAS,KAAU;AACf,cAAQ,KAAK,EAAE,OAAO,QAAQ,SAAS,OAAO,KAAK,WAAW,OAAO,GAAG,EAAE,CAAC;AAAA,IAC/E;AAAA,EACJ;AAEA,SAAO;AACX;AAMA,eAAeA,eAAc,MAAkB,OAAe,QAAkC;AAC5F,MAAI;AACA,UAAM,OAAc,MAAM,KAAK,sBAAsB,KAAK,IAAI;AAC9D,QAAI,MAAM,QAAQ,IAAI,KAAK,KAAK,SAAS,GAAG;AACxC,YAAMC,QAAc,MAAM,QAAQ,KAAK,CAAC,CAAC,IAAI,KAAK,CAAC,IAAI;AACvD,aAAOA,MAAK,KAAK,CAAC,MAAW,GAAG,SAAS,MAAM;AAAA,IACnD;AAEA,UAAM,SAAgB,MAAM;AAAA,MACxB;AAAA,MACA,CAAC,OAAO,MAAM;AAAA,IAClB;AACA,UAAM,OAAc,MAAM,QAAQ,OAAO,CAAC,CAAC,IAAI,OAAO,CAAC,IAAI;AAC3D,WAAO,KAAK,SAAS;AAAA,EACzB,QAAQ;AACJ,WAAO;AAAA,EACX;AACJ;;;AClKA,IAAM,oBAAoB;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACJ;AAgBA,eAAsB,qBAAqB,QAAsD;AAC7F,QAAM,OAAO,kBAAkB,MAAM;AACrC,MAAI,CAAC,MAAM;AACP,UAAM,IAAI,MAAM,kBAAkB,sBAAsB,CAAC;AAAA,EAC7D;AAEA,QAAM,UAAkC,CAAC;AACzC,aAAW,SAAS,mBAAmB;AACnC,QAAI;AACA,YAAM,KAAK,wBAAwB,KAAK,EAAE;AAC1C,cAAQ,KAAK,EAAE,OAAO,QAAQ,UAAU,CAAC;AAAA,IAC7C,SAAS,OAAO;AACZ,cAAQ,KAAK;AAAA,QACT;AAAA,QACA,QAAQ;AAAA,QACR,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAAA,MAChE,CAAC;AAAA,IACL;AAAA,EACJ;AACA,SAAO;AACX;;;ACvBA;AAAA,EACI;AAAA,EACA;AAAA,OACG;AAIP,IAAM,eAAe;AACrB,IAAM,eAAe;AACrB,IAAM,iBAAiB;AAkDvB,IAAM,oBAAoB,EAAE,SAAS,EAAE,eAAe,KAAK,EAAE;AAG7D,IAAM,iBAAiB;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACJ;AAsDA,eAAsB,8BAClB,MACuC;AACvC,QAAM,MAAM,KAAK,QAAQ,OAAM,oBAAI,KAAK,GAAE,YAAY;AACtD,QAAM,UAAU,MAAM,8BAA8B,MAAM,GAAG;AAK7D,QAAM,UAAU,MAAM,+BAA+B,KAAK,MAAM,QAAQ,QAAQ,IAAI,CAAC;AACrF,SAAO,EAAE,GAAG,SAAS,QAAQ;AACjC;AAEA,eAAe,8BACX,MACA,KACyB;AACzB,QAAM,EAAE,KAAK,IAAI;AAEjB,QAAM,OAAO,kBAAkB,KAAK,MAAM;AAC1C,MAAI,CAAC,MAAM;AACP,WAAO;AAAA,MACH,QAAQ;AAAA,MACR,UAAU;AAAA,MACV,OAAO,kBAAkB,+BAA+B;AAAA,IAC5D;AAAA,EACJ;AAGA,MAAI,CAAE,MAAM,aAAa,MAAM,cAAc,cAAc,GAAI;AAC3D,WAAO,EAAE,QAAQ,kBAAkB,UAAU,EAAE;AAAA,EACnD;AAGA,QAAM,gBAA0B,CAAC;AACjC,aAAW,OAAO,gBAAgB;AAC9B,QAAI,MAAM,aAAa,MAAM,cAAc,GAAG,EAAG,eAAc,KAAK,GAAG;AAAA,EAC3E;AAEA,MAAI,WAAW;AACf,MAAI;AACA,UAAM,OAAO,MAAM,iBAAiB,IAAI;AACxC,QAAI,KAAK,WAAW,EAAG,QAAO,EAAE,QAAQ,gBAAgB,UAAU,EAAE;AAEpE,eAAW,OAAO,MAAM;AACpB,YAAM,KAAK,OAAO,IAAI,EAAE;AACxB,YAAM,cAAc,IAAI,gBAAgB,OAAO,OAAO,IAAI,YAAY,IAAI;AAC1E,UAAI,CAAC,YAAa;AAClB,YAAM,QAAQ,IAAI,mBAAmB,OAAO,OAAO,IAAI,eAAe,IAAI;AAC1E,YAAM,YAAY,IAAI,cAAc,OAAO,uBAAuB,IAAI,UAAU,IAAI,IAAI;AACxF,YAAM,QAAQ,IAAI,SAAS,OAAO,OAAO,IAAI,KAAK,IAAK,IAAI,QAAQ,OAAO,OAAO,IAAI,IAAI,IAAI;AAC7F,YAAM,SAAS,IAAI,YAAY,QAAQ,IAAI,YAAY,KAAK,IAAI,YAAY;AAI5E,YAAM,aAAa,IAAI,QAAQ,QAAQ,OAAO,IAAI,IAAI,EAAE,SAAS,IAAI,OAAO,IAAI,IAAI,IAAI;AAGxF,YAAM,KAAK;AAAA,QACP;AAAA,QACA;AAAA,UACI,SAAS;AAAA,UACT,iBAAiB;AAAA,UACjB,OAAO;AAAA,UACP;AAAA,UACA,SAAS,IAAI,QAAQ;AAAA,UACrB,UAAU;AAAA,UACV,YAAY,IAAI,OAAO;AAAA,UACvB,iBAAiB;AAAA,UACjB,YAAY;AAAA,QAChB;AAAA,QACA;AAAA,MACJ;AAGA,YAAM,KAAK;AAAA,QACP;AAAA,QACA;AAAA,UACI,iBAAiB;AAAA,UACjB,aAAa;AAAA,UACb,SAAS;AAAA,UACT,SAAS;AAAA,UACT,OAAO,SAAS,SAAS;AAAA,UACzB,IAAI,UAAU,IAAI,WAAW,OAAO,uBAAuB,IAAI,OAAO,IAAI;AAAA,UAC1E,iBAAiB;AAAA,UACjB,YAAY;AAAA,QAChB;AAAA,QACA;AAAA,MACJ;AAGA,YAAM,KAAK;AAAA,QACP;AAAA,QACA;AAAA,UACI;AAAA,UACA,OAAO;AAAA,UACP,UAAU;AAAA,UACV,SAAS;AAAA,YACL,OAAO,IAAI,SAAS;AAAA,YACpB,MAAM,IAAI,QAAQ;AAAA,YAClB,KAAK,IAAI,OAAO;AAAA,YAChB,WAAW,IAAI,cAAc;AAAA,UACjC;AAAA,QACJ;AAAA,QACA,EAAE,OAAO,EAAE,GAAG,EAAE;AAAA,MACpB;AAIA,UAAI,cAAc,SAAS,GAAG;AAC1B,cAAM,YAAY,cAAc,IAAI,CAAC,MAAM,IAAI,CAAC,UAAU,EAAE,KAAK,IAAI;AACrE,cAAM,KAAK,WAAW,YAAY,SAAS,SAAS,iBAAiB,CAAC,EAAE,CAAC;AAAA,MAC7E;AAEA,kBAAY;AAAA,IAChB;AAEA,WAAO,EAAE,QAAQ,YAAY,SAAS;AAAA,EAC1C,SAAS,KAAU;AACf,WAAO,EAAE,QAAQ,SAAS,UAAU,OAAO,KAAK,WAAW,OAAO,GAAG,EAAE;AAAA,EAC3E;AACJ;AAyCA,IAAM,eAEF;AAAA,EACA,UAAU,EAAE,QAAQ,MAAM,iBAAiB,KAAK;AAAA,EAChD,cAAc,EAAE,QAAQ,MAAM,iBAAiB,MAAM;AAAA,EACrD,gBAAgB,EAAE,QAAQ,MAAM,iBAAiB,MAAM;AAAA;AAAA;AAAA,EAGvD,OAAO,EAAE,QAAQ,OAAO,iBAAiB,MAAM;AACnD;AAoBA,IAAM,iBAAiB,CAAC,aAAa,QAAQ,UAAU,QAAQ;AAG/D,SAAS,uBAAuB,MAAgD;AAC5E,QAAM,YAAY;AAClB,aAAW,UAAU,gBAAgB;AACjC,QAAI,OAAO,UAAU,MAAM,MAAM,WAAY,QAAO;AAAA,EACxD;AACA,SAAO;AACX;AAiCA,SAAS,4BACL,QACA,KACA,QACuB;AACvB,QAAM,QAAQ,aAAa,MAAM;AACjC,QAAM,MAA+B;AAAA,IACjC,IAAI;AAAA,IACJ,aAAa;AAAA,IACb,UAAU;AAAA,IACV,SAAS,KAAK,UAAU,EAAE,SAAS,OAAO,CAAC;AAAA,IAC3C,YAAY;AAAA,EAChB;AACA,MAAI,MAAM,gBAAiB,KAAI,aAAa;AAC5C,MAAI,CAAC,QAAQ;AAIT,QAAI,aAAa,MAAM,kBAAkB,MAAM;AAC/C,QAAI,cAAc;AAClB,QAAI,aAAa;AAAA,EACrB;AACA,SAAO;AACX;AAWA,eAAe,+BACX,MACA,QACA,KACwC;AACxC,MAAI,CAAC,aAAa,MAAM,EAAE,OAAQ,QAAO,EAAE,SAAS,cAAc;AAElE,QAAM,SAAS,uBAAuB,IAAI;AAC1C,MAAI,CAAC,QAAQ;AACT,WAAO;AAAA,MACH,SAAS;AAAA,MACT,QACI,mDAAmD,eAAe,KAAK,GAAG,CAAC,SACxE,0BAA0B;AAAA,IACrC;AAAA,EACJ;AAEA,MAAI;AACA,QAAI,CAAC,OAAO,UAAU,0BAA0B,GAAG;AAC/C,aAAO;AAAA,QACH,SAAS;AAAA,QACT,QACI,GAAG,0BAA0B;AAAA,MAErC;AAAA,IACJ;AACA,UAAM,UAAU,EAAE,UAAU,KAAK;AACjC,UAAM,OAAO,MAAM,OAAO;AAAA,MACtB;AAAA,MACA,EAAE,OAAO,EAAE,IAAI,gCAAgC,GAAG,OAAO,EAAE;AAAA,MAC3D,EAAE,QAAQ;AAAA,IACd;AACA,UAAM,SAAS,OAAO,CAAC,GAAG,OAAO;AACjC,UAAM,MAAM,4BAA4B,QAAQ,KAAK,MAAM;AAG3D,QAAI,QAAQ;AACR,YAAM,OAAO,OAAO,4BAA4B,KAAK,EAAE,QAAQ,CAAC;AAChE,aAAO,EAAE,SAAS,UAAU;AAAA,IAChC;AACA,UAAM,OAAO,OAAO,4BAA4B,KAAK,EAAE,QAAQ,CAAC;AAChE,WAAO,EAAE,SAAS,WAAW;AAAA,EACjC,SAAS,KAAU;AACf,WAAO,EAAE,SAAS,UAAU,QAAQ,KAAK,WAAW,OAAO,GAAG,EAAE;AAAA,EACpE;AACJ;AA0DA,SAAS,uBAAuB,OAAwB;AACpD,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,iBAAiB,KAAM,QAAO,MAAM,YAAY;AACpD,SAAO,OAAO,KAAK;AACvB;AAEA,eAAe,iBAAiB,MAAkC;AAC9D,QAAM,SAAgB,MAAM;AAAA,IACxB,oHACa,YAAY;AAAA,EAC7B;AAEA,MAAI,MAAM,QAAQ,MAAM,KAAK,OAAO,SAAS,KAAK,MAAM,QAAQ,OAAO,CAAC,CAAC,GAAG;AACxE,WAAO,OAAO,CAAC;AAAA,EACnB;AACA,SAAO,MAAM,QAAQ,MAAM,IAAI,SAAS,CAAC;AAC7C;AAEA,eAAe,aAAa,MAAkB,OAAe,QAAkC;AAK3F,MAAI;AACA,UAAM,OAAY,MAAM,KAAK,sBAAsB,KAAK,IAAI;AAC5D,UAAM,OAAc,MAAM,QAAQ,IAAI,IAC/B,MAAM,QAAQ,KAAK,CAAC,CAAC,IAAI,KAAK,CAAC,IAAI,OACpC,CAAC;AACP,QAAI,KAAK,SAAS,KAAK,KAAK,KAAK,CAAC,MAAW,GAAG,QAAQ,IAAI,GAAG;AAC3D,aAAO,KAAK,KAAK,CAAC,MAAW,GAAG,SAAS,MAAM;AAAA,IACnD;AAAA,EACJ,QAAQ;AAAA,EAER;AAEA,MAAI;AACA,UAAM,SAAc,MAAM;AAAA,MACtB;AAAA,MACA,CAAC,OAAO,MAAM;AAAA,IAClB;AACA,UAAM,OAAc,MAAM,QAAQ,MAAM,IACjC,MAAM,QAAQ,OAAO,CAAC,CAAC,IAAI,OAAO,CAAC,IAAI,SACxC,CAAC;AACP,WAAO,KAAK,SAAS;AAAA,EACzB,QAAQ;AACJ,WAAO;AAAA,EACX;AACJ;","names":["list","AFFECTED_TABLES","_columnExists","list"]}
package/dist/node.cjs CHANGED
@@ -197,6 +197,9 @@ var init_hmr_routes = __esm({
197
197
  // src/node.ts
198
198
  var node_exports = {};
199
199
  __export(node_exports, {
200
+ AMBIGUOUS_METADATA_STEM_CODE: () => AMBIGUOUS_METADATA_STEM_CODE,
201
+ AMBIGUOUS_METADATA_STEM_STATUS: () => AMBIGUOUS_METADATA_STEM_STATUS,
202
+ AmbiguousMetadataStemError: () => AmbiguousMetadataStemError,
200
203
  DatabaseLoader: () => DatabaseLoader,
201
204
  FilesystemLoader: () => FilesystemLoader,
202
205
  HistoryCleanupManager: () => HistoryCleanupManager,
@@ -214,7 +217,8 @@ __export(node_exports, {
214
217
  calculateChecksum: () => calculateChecksum,
215
218
  deriveViewContainerObject: () => deriveViewContainerObject,
216
219
  generateDiffSummary: () => generateDiffSummary,
217
- generateSimpleDiff: () => generateSimpleDiff
220
+ generateSimpleDiff: () => generateSimpleDiff,
221
+ isAmbiguousMetadataStemError: () => isAmbiguousMetadataStemError
218
222
  });
219
223
  module.exports = __toCommonJS(node_exports);
220
224
 
@@ -642,7 +646,7 @@ async function _columnExists(exec, table, column) {
642
646
  // src/loaders/database-loader.ts
643
647
  function canonicalIsoInstant(value) {
644
648
  if (value === null || value === void 0) return void 0;
645
- if (value instanceof Date) return value.toISOString();
649
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? void 0 : value.toISOString();
646
650
  if (typeof value === "string") return value;
647
651
  return String(value);
648
652
  }
@@ -698,7 +702,7 @@ var DatabaseLoader = class {
698
702
  if (cacheEnabled) {
699
703
  const lruOpts = {
700
704
  maxSize: cacheOpts?.maxSize ?? 500,
701
- ttl: cacheOpts?.ttl ?? 6e4
705
+ ttl: cacheOpts?.ttlMs ?? 6e4
702
706
  };
703
707
  this.loadCache = new LRUCache(lruOpts);
704
708
  this.loadManyCache = new LRUCache(lruOpts);
@@ -1548,6 +1552,33 @@ function generateId() {
1548
1552
  return `meta_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
1549
1553
  }
1550
1554
 
1555
+ // src/loaders/ambiguous-metadata-stem.ts
1556
+ var AMBIGUOUS_METADATA_STEM_CODE = "AMBIGUOUS_METADATA_STEM";
1557
+ var AMBIGUOUS_METADATA_STEM_STATUS = 500;
1558
+ var AMBIGUOUS_METADATA_STEM_BRAND = /* @__PURE__ */ Symbol.for("objectstack.metadata.ambiguousStem");
1559
+ var _a, _b;
1560
+ var AmbiguousMetadataStemError = class extends (_b = Error, _a = AMBIGUOUS_METADATA_STEM_BRAND, _b) {
1561
+ constructor(type, stem, paths) {
1562
+ const sorted = [...paths].sort();
1563
+ super(
1564
+ `Ambiguous metadata name \`${stem}\` for type \`${type}\`: ${sorted.length} files resolve to the same name \u2014 ${sorted.map((p) => `\`${p}\``).join(", ")}. Only the first would ever be served (extension precedence: .json, .yaml, .yml, .ts, .js), so the others are listed and unreachable. Delete or rename all but one.`
1565
+ );
1566
+ /** Brand — see the module doc on why this is not `instanceof`. */
1567
+ this[_a] = true;
1568
+ /** ADR-0112 wire code. */
1569
+ this.code = AMBIGUOUS_METADATA_STEM_CODE;
1570
+ /** HTTP status a transport should answer. */
1571
+ this.status = AMBIGUOUS_METADATA_STEM_STATUS;
1572
+ this.name = "AmbiguousMetadataStemError";
1573
+ this.type = type;
1574
+ this.stem = stem;
1575
+ this.paths = sorted;
1576
+ }
1577
+ };
1578
+ function isAmbiguousMetadataStemError(err) {
1579
+ return typeof err === "object" && err !== null && err[AMBIGUOUS_METADATA_STEM_BRAND] === true;
1580
+ }
1581
+
1551
1582
  // src/endpoint-matcher.ts
1552
1583
  var import_api = require("@objectstack/spec/api");
1553
1584
 
@@ -1698,6 +1729,8 @@ var EndpointMatcher = class {
1698
1729
  // src/view-container-expansion.ts
1699
1730
  var import_spec2 = require("@objectstack/spec");
1700
1731
  var import_shared2 = require("@objectstack/spec/shared");
1732
+
1733
+ // src/view-container.ts
1701
1734
  function deriveViewContainerObject(container) {
1702
1735
  if (!container || typeof container !== "object") return void 0;
1703
1736
  const c = container;
@@ -1705,6 +1738,8 @@ function deriveViewContainerObject(container) {
1705
1738
  const byName = typeof c.name === "string" && c.name ? c.name : void 0;
1706
1739
  return own ?? c?.list?.data?.object ?? c?.form?.data?.object ?? byName;
1707
1740
  }
1741
+
1742
+ // src/view-container-expansion.ts
1708
1743
  function expandRuntimeViewContainer(data) {
1709
1744
  if (!(0, import_spec2.isAggregatedViewContainer)(data)) return [];
1710
1745
  const container = data;
@@ -2410,6 +2445,9 @@ var _MetadataManager = class _MetadataManager {
2410
2445
  await this.admitLoaderItems(loader, type, items);
2411
2446
  this.reportLoaderReadRecovered(loader.contract.name);
2412
2447
  } catch (e) {
2448
+ if (isAmbiguousMetadataStemError(e)) {
2449
+ throw e;
2450
+ }
2413
2451
  degraded = true;
2414
2452
  errors.push(`${loader.contract.name}: ${e instanceof Error ? e.message : String(e)}`);
2415
2453
  this.reportLoaderReadFailure(loader.contract.name, type, e);
@@ -2747,6 +2785,30 @@ var _MetadataManager = class _MetadataManager {
2747
2785
  }
2748
2786
  /**
2749
2787
  * List all names of metadata items of a given type
2788
+ *
2789
+ * ## [#14423] One loader's fault does not take the whole enumeration down
2790
+ *
2791
+ * This loop used to be bare — `const result = await loader.list(type)` with
2792
+ * no `try`, while the two sibling plural reads (`list()` via
2793
+ * {@link admitLoaderItems}, and {@link loadMany}) have carried a per-loader
2794
+ * `catch` since #5108. That asymmetry is the defect, independent of any one
2795
+ * caller: the SAME storage outage was swallowed by one plural read and
2796
+ * thrown out of the other, so which answer a caller got depended only on
2797
+ * which method it happened to call. A caller reading both — the action
2798
+ * governance audit is one — saw `loadMany` report a short-but-successful
2799
+ * set and `listNames` throw, and had no way to tell that one fact was
2800
+ * behind both.
2801
+ *
2802
+ * Same shape as `loadMany`'s, deliberately, down to the helpers: the outage
2803
+ * is spoken once per loader through {@link reportLoaderReadFailure} and
2804
+ * un-said through {@link reportLoaderReadRecovered}. ⛔ Not a third spelling
2805
+ * for "a loader faulted" — a second vocabulary for one event is how the two
2806
+ * reads drifted apart in the first place.
2807
+ *
2808
+ * The degradation is the same one `list()` documents and is graded the same
2809
+ * way (AGENTS.md → "Degradation log levels"): the caller still gets an
2810
+ * array, nothing 500s, and the set is quietly short — so it is reported at
2811
+ * `error`, by the shared helper, rather than being re-graded here.
2750
2812
  */
2751
2813
  async listNames(type) {
2752
2814
  type = (0, import_core.canonicalMetadataServiceType)(type);
@@ -2758,8 +2820,16 @@ var _MetadataManager = class _MetadataManager {
2758
2820
  }
2759
2821
  }
2760
2822
  for (const loader of this.loaders.values()) {
2761
- const result = await loader.list(type);
2762
- result.forEach((item) => names.add(item));
2823
+ try {
2824
+ const result = await loader.list(type);
2825
+ result.forEach((item) => names.add(item));
2826
+ this.reportLoaderReadRecovered(loader.contract.name);
2827
+ } catch (e) {
2828
+ if (isAmbiguousMetadataStemError(e)) {
2829
+ throw e;
2830
+ }
2831
+ this.reportLoaderReadFailure(loader.contract.name, type, e);
2832
+ }
2763
2833
  }
2764
2834
  return Array.from(names);
2765
2835
  }
@@ -3619,6 +3689,99 @@ var _MetadataManager = class _MetadataManager {
3619
3689
  }
3620
3690
  return results;
3621
3691
  }
3692
+ /**
3693
+ * [#14423] {@link loadMany}, read under the identity the STORE holds each
3694
+ * item by — the keyed plural read, beside the unkeyed one.
3695
+ *
3696
+ * ## Why a second method and not a widened `loadMany`
3697
+ *
3698
+ * `loadMany` keys nothing: it returns bodies, and every consumer that needs
3699
+ * an identity reads `body.name` off them. #14205 already ruled what identity
3700
+ * IS — the key the store holds the item under (`register(type, name, data)`
3701
+ * takes it as the ARGUMENT, and a body is not required to name itself) — so
3702
+ * `body.name` is a guess that happens to be right for most items and drops
3703
+ * the rest ENTIRELY: an item whose body carries no `name` is served by
3704
+ * `load(type, name)` and is not nameable from `loadMany`'s answer at all.
3705
+ *
3706
+ * Widening `loadMany`'s return would fix that and break every consumer of a
3707
+ * published shape (the ones counted on this card all read `body.name` as the
3708
+ * identity). So this is additive: `loadMany`'s return shape is untouched,
3709
+ * and a caller that needs the key asks for the key.
3710
+ *
3711
+ * ## What it reads — the same population `loadMany` reads
3712
+ *
3713
+ * Loaders only, deliberately, so this is `loadMany` keyed and nothing more.
3714
+ * It is NOT `list()`/{@link listNames}, which also merge the in-memory
3715
+ * `register()` registry; a caller wanting that set has those. Reading the
3716
+ * loaders alone is also what makes this the enumerable twin of
3717
+ * {@link loadDiagnosed}, which walks the same loaders by name — that pairing
3718
+ * is the point on the audit side of #14423, where an enumeration and a
3719
+ * by-name read that disagree about a population make one subsystem accuse
3720
+ * another of a defect neither has.
3721
+ *
3722
+ * ## Delegate first, fall back second — and why that order is not a style
3723
+ *
3724
+ * Per loader: {@link MetadataLoader.loadManyKeyed} where the loader offers
3725
+ * one, else its `list()` + a per-name `load()`. Measured, on
3726
+ * `DatabaseLoader`: the keyed method shares `loadMany`'s single query
3727
+ * (`{find:1, findOne:0}` — zero extra cost), while enumerate-then-read-each
3728
+ * on that same loader is a real N+1 (`{find:1, findOne:5}` for five items).
3729
+ * The fallback exists for loaders that cannot produce keys at all
3730
+ * (`RemoteLoader`'s wire format carries bodies only), and it recovers the
3731
+ * nameless item the pre-#14205 `loadMany`-and-key-by-`body.name` fallback
3732
+ * drops — which is why it is `list()` + `load()` and not `loadMany()`.
3733
+ *
3734
+ * ## Failure posture
3735
+ *
3736
+ * Per-loader `try`/`catch`, the same seam and the same helpers as
3737
+ * {@link loadMany} and `list()` — one loader's outage does not take the
3738
+ * enumeration down, and it is reported once through
3739
+ * {@link reportLoaderReadFailure} rather than in a third vocabulary.
3740
+ * Earlier loaders win a key collision, mirroring `list()`.
3741
+ */
3742
+ async loadManyKeyed(type, options) {
3743
+ const items = /* @__PURE__ */ new Map();
3744
+ for (const loader of this.loaders.values()) {
3745
+ try {
3746
+ await this.admitKeyedLoaderItems(loader, type, items, options);
3747
+ this.reportLoaderReadRecovered(loader.contract.name);
3748
+ } catch (e) {
3749
+ this.reportLoaderReadFailure(loader.contract.name, type, e);
3750
+ }
3751
+ }
3752
+ return Array.from(items, ([name, data]) => ({ name, data }));
3753
+ }
3754
+ /**
3755
+ * Merge ONE loader's answer for `type` into `items`, keyed by that loader's
3756
+ * own key for each item — {@link loadManyKeyed}'s per-loader body.
3757
+ *
3758
+ * Distinct from {@link admitLoaderItems} on exactly one axis, and that axis
3759
+ * is the whole of #14423: the fallback for a loader with no
3760
+ * `loadManyKeyed`. `admitLoaderItems` falls back to `loadMany` keyed by
3761
+ * `data.name` — the pre-#14205 behaviour, verbatim, which drops a nameless
3762
+ * body. Here the fallback is `list()` + a per-name `load()`, so a loader
3763
+ * that cannot enumerate keys and bodies together still answers with both.
3764
+ *
3765
+ * Read failures are NOT caught here — the caller owns that verdict, as in
3766
+ * {@link admitLoaderItems}.
3767
+ */
3768
+ async admitKeyedLoaderItems(loader, type, items, options) {
3769
+ if (typeof loader.loadManyKeyed === "function") {
3770
+ const keyed = await loader.loadManyKeyed(type, options);
3771
+ for (const entry of keyed) {
3772
+ if (!entry || typeof entry.name !== "string" || entry.name === "") continue;
3773
+ if (items.has(entry.name)) continue;
3774
+ items.set(entry.name, entry.data);
3775
+ }
3776
+ return;
3777
+ }
3778
+ for (const name of await loader.list(type)) {
3779
+ if (typeof name !== "string" || name === "" || items.has(name)) continue;
3780
+ const result = await loader.load(type, name, options);
3781
+ if (result?.data == null) continue;
3782
+ items.set(name, result.data);
3783
+ }
3784
+ }
3622
3785
  /**
3623
3786
  * Save metadata item to a loader
3624
3787
  */
@@ -4251,33 +4414,34 @@ var _FilesystemLoader = class _FilesystemLoader {
4251
4414
  const globPatterns = patterns.map(
4252
4415
  (pattern) => path.join(typeDir, pattern)
4253
4416
  );
4417
+ const files = [];
4254
4418
  for (const pattern of globPatterns) {
4255
- const files = await (0, import_glob.glob)(pattern, {
4256
- ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*", "**/*[*]*"],
4257
- nodir: true
4258
- });
4259
- for (const file of files) {
4260
- if (limit && items.length >= limit) {
4261
- break;
4262
- }
4263
- try {
4264
- const content = await fs.readFile(file, "utf-8");
4265
- const format = this.detectFormat(file);
4266
- const serializer = this.getSerializer(format);
4267
- if (serializer) {
4268
- const data = serializer.deserialize(content);
4269
- items.push({ file, data });
4270
- }
4271
- } catch (error) {
4272
- this.logger?.warn("Failed to load file", {
4273
- file,
4274
- error: error instanceof Error ? error.message : String(error)
4275
- });
4276
- }
4277
- }
4419
+ files.push(
4420
+ ...await (0, import_glob.glob)(pattern, {
4421
+ ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*", "**/*[*]*"],
4422
+ nodir: true
4423
+ })
4424
+ );
4425
+ }
4426
+ this.resolvableNames(type, typeDir, files);
4427
+ for (const file of files) {
4278
4428
  if (limit && items.length >= limit) {
4279
4429
  break;
4280
4430
  }
4431
+ try {
4432
+ const content = await fs.readFile(file, "utf-8");
4433
+ const format = this.detectFormat(file);
4434
+ const serializer = this.getSerializer(format);
4435
+ if (serializer) {
4436
+ const data = serializer.deserialize(content);
4437
+ items.push({ file, data });
4438
+ }
4439
+ } catch (error) {
4440
+ this.logger?.warn("Failed to load file", {
4441
+ file,
4442
+ error: error instanceof Error ? error.message : String(error)
4443
+ });
4444
+ }
4281
4445
  }
4282
4446
  return items;
4283
4447
  } catch (error) {
@@ -4346,13 +4510,13 @@ var _FilesystemLoader = class _FilesystemLoader {
4346
4510
  */
4347
4511
  async list(type) {
4348
4512
  const typeDir = path.join(this.rootDir, type);
4513
+ let files;
4349
4514
  try {
4350
- const files = await (0, import_glob.glob)("**/*", {
4515
+ files = await (0, import_glob.glob)("**/*", {
4351
4516
  cwd: typeDir,
4352
4517
  ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*"],
4353
4518
  nodir: true
4354
4519
  });
4355
- return files.map((file) => this.resolvableNameForPath(typeDir, path.join(typeDir, file))).filter((name) => name !== null);
4356
4520
  } catch (error) {
4357
4521
  this.logger?.error("Failed to list", void 0, {
4358
4522
  type,
@@ -4360,6 +4524,7 @@ var _FilesystemLoader = class _FilesystemLoader {
4360
4524
  });
4361
4525
  return [];
4362
4526
  }
4527
+ return this.resolvableNames(type, typeDir, files.map((file) => path.join(typeDir, file)));
4363
4528
  }
4364
4529
  async save(type, name, data, options) {
4365
4530
  const startTime = Date.now();
@@ -4484,6 +4649,49 @@ var _FilesystemLoader = class _FilesystemLoader {
4484
4649
  }
4485
4650
  return _FilesystemLoader.nameFromFilename(rel);
4486
4651
  }
4652
+ /**
4653
+ * [#14921] The names this loader reports for `files` — and the ONE place an
4654
+ * ambiguous stem is refused.
4655
+ *
4656
+ * Shared by {@link list} and {@link loadManyEntries} so the two can never
4657
+ * disagree about which trees are admissible: a stem that `list()` refuses
4658
+ * must not still be walked and returned as two bodies by `loadMany()`, which
4659
+ * is exactly the split this card measured.
4660
+ *
4661
+ * Refuses on the FIRST colliding name in sorted order, so a tree holding more
4662
+ * than one collision always names the same one — a refusal that moves
4663
+ * between runs reads as flakiness rather than as the fixed authoring error it
4664
+ * is. Paths are deduplicated because two overlapping `patterns` legitimately
4665
+ * match one file twice, and counting that as a collision would refuse a
4666
+ * perfectly good tree.
4667
+ *
4668
+ * ⛔ Not a precedence resolver. Picking a winner here is what the ruling
4669
+ * declined (option 2, keep the precedence and log): the loser would stay
4670
+ * unreachable and the listed set would stay different from the addressable
4671
+ * one.
4672
+ */
4673
+ resolvableNames(type, typeDir, files) {
4674
+ const byName = /* @__PURE__ */ new Map();
4675
+ for (const file of files) {
4676
+ const name = this.resolvableNameForPath(typeDir, file);
4677
+ if (name === null) {
4678
+ continue;
4679
+ }
4680
+ let paths = byName.get(name);
4681
+ if (!paths) {
4682
+ paths = /* @__PURE__ */ new Set();
4683
+ byName.set(name, paths);
4684
+ }
4685
+ paths.add(file);
4686
+ }
4687
+ for (const name of [...byName.keys()].sort()) {
4688
+ const paths = byName.get(name);
4689
+ if (paths.size > 1) {
4690
+ throw new AmbiguousMetadataStemError(type, name, [...paths]);
4691
+ }
4692
+ }
4693
+ return [...byName.keys()];
4694
+ }
4487
4695
  /**
4488
4696
  * Find file for a given type and name
4489
4697
  */
@@ -5571,9 +5779,43 @@ var RemoteLoader = class {
5571
5779
  format: "json"
5572
5780
  };
5573
5781
  }
5782
+ /**
5783
+ * [#15037] Report only the names that ARE names.
5784
+ *
5785
+ * This read used to be `loadMany<{ name: string }>(type)` mapped straight to
5786
+ * `items.map(i => i.name)`. That type argument is an ASSERTION about bodies
5787
+ * that arrived over HTTP, and nothing checked it: a body with no top-level
5788
+ * `name` yielded `undefined`, which went into an array this signature
5789
+ * declares as `string[]` and reached consumers through
5790
+ * `MetadataManager.listNames()` — a runtime violation of a declared type,
5791
+ * not an untidy entry. A consumer that keys by it, lower-cases it, or feeds
5792
+ * it back to a by-name `load()` gets `undefined` where the type says it
5793
+ * cannot be.
5794
+ *
5795
+ * The guard is `DatabaseLoader.list()`'s, one file away: same cast-then-map
5796
+ * spelling, one `typeof` filter behind it. Silently dropping is the landed
5797
+ * direction, not a preference — `DatabaseLoader` drops rather than throws,
5798
+ * and `FilesystemLoader`'s narrowing carries a maintainer ruling (via the
5799
+ * director seat on #14486, 2026-09-02) that chose narrowing (A) over
5800
+ * refusing loudly (B), because a name in the list that the door answers
5801
+ * `null` for is the silent failure an author reads as their own typo. An
5802
+ * `undefined` here is the extreme form of that name.
5803
+ *
5804
+ * ⛔ NOT copied from the siblings: `MemoryLoader` answers with its store
5805
+ * keys, and #14205 ruled that identity is the key the store holds an item
5806
+ * under rather than `body.name`. This loader reads over HTTP and holds no
5807
+ * store key, so `body.name` is the only identity it has — the list is
5808
+ * narrowed to agree with the door instead. `loadMany()` is deliberately
5809
+ * untouched: it keys nothing, so a nameless body is still served there.
5810
+ *
5811
+ * The predicate is spelled as a type guard, and the mapped element type left
5812
+ * `unknown`, so `tsc` PROVES the declared `string[]` instead of a cast
5813
+ * asserting it — otherwise the compiler reads the filter as always-true and
5814
+ * a later reader deletes it as dead.
5815
+ */
5574
5816
  async list(type) {
5575
5817
  const items = await this.loadMany(type);
5576
- return items.map((i) => i.name);
5818
+ return items.map((item) => item.name).filter((name) => typeof name === "string");
5577
5819
  }
5578
5820
  async save(type, name, data, _options) {
5579
5821
  const response = await fetch(`${this.baseUrl}/${type}/${name}`, {
@@ -5613,9 +5855,9 @@ var HistoryCleanupManager = class {
5613
5855
  return;
5614
5856
  }
5615
5857
  const intervalMs = (this.policy.cleanupIntervalHours ?? 24) * 60 * 60 * 1e3;
5616
- void this.runCleanup();
5858
+ void runCleanupAndReport(this);
5617
5859
  this.cleanupTimer = setInterval(() => {
5618
- void this.runCleanup();
5860
+ void runCleanupAndReport(this);
5619
5861
  }, intervalMs);
5620
5862
  }
5621
5863
  /**
@@ -5642,7 +5884,7 @@ var HistoryCleanupManager = class {
5642
5884
  try {
5643
5885
  if (this.policy.maxAgeDays) {
5644
5886
  const cutoffDate = /* @__PURE__ */ new Date();
5645
- cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
5887
+ cutoffDate.setUTCDate(cutoffDate.getUTCDate() - this.policy.maxAgeDays);
5646
5888
  const cutoffISO = cutoffDate.toISOString();
5647
5889
  const filter = {
5648
5890
  recorded_at: { $lt: cutoffISO }
@@ -5762,7 +6004,7 @@ var HistoryCleanupManager = class {
5762
6004
  if (organizationId) baseWhere.organization_id = organizationId;
5763
6005
  if (this.policy.maxAgeDays) {
5764
6006
  const cutoffDate = /* @__PURE__ */ new Date();
5765
- cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
6007
+ cutoffDate.setUTCDate(cutoffDate.getUTCDate() - this.policy.maxAgeDays);
5766
6008
  const cutoffISO = cutoffDate.toISOString();
5767
6009
  const filter = {
5768
6010
  recorded_at: { $lt: cutoffISO },
@@ -5809,6 +6051,23 @@ var HistoryCleanupManager = class {
5809
6051
  };
5810
6052
  }
5811
6053
  };
6054
+ async function runCleanupAndReport(manager) {
6055
+ let outcome;
6056
+ try {
6057
+ outcome = await manager.runCleanup();
6058
+ } catch (error) {
6059
+ console.error(
6060
+ "History cleanup: the run did not complete, so no history row past the retention policy was deleted and the table keeps growing while the system reports healthy. Fix: the cause below comes from the configured data driver, not from the retention policy; call `runCleanup()` directly to reproduce it. Cause:",
6061
+ error
6062
+ );
6063
+ return;
6064
+ }
6065
+ if (outcome.errors > 0) {
6066
+ console.error(
6067
+ `History cleanup: ${outcome.errors} delete operation(s) failed and ${outcome.deleted} row(s) were deleted. The history rows those deletes were meant to remove are still in the table, nothing retries them, and the table grows past the retention policy while the system keeps reporting healthy. Fix: check the data driver delete path for the metadata history table. The per-failure causes are not carried out of \`runCleanup()\`, so reproduce them against the driver directly.`
6068
+ );
6069
+ }
6070
+ }
5812
6071
 
5813
6072
  // src/migration/index.ts
5814
6073
  var migration_exports = {};
@@ -5867,6 +6126,9 @@ var MigrationExecutor = class {
5867
6126
  };
5868
6127
  // Annotate the CommonJS export names for ESM import in node:
5869
6128
  0 && (module.exports = {
6129
+ AMBIGUOUS_METADATA_STEM_CODE,
6130
+ AMBIGUOUS_METADATA_STEM_STATUS,
6131
+ AmbiguousMetadataStemError,
5870
6132
  DatabaseLoader,
5871
6133
  FilesystemLoader,
5872
6134
  HistoryCleanupManager,
@@ -5884,6 +6146,7 @@ var MigrationExecutor = class {
5884
6146
  calculateChecksum,
5885
6147
  deriveViewContainerObject,
5886
6148
  generateDiffSummary,
5887
- generateSimpleDiff
6149
+ generateSimpleDiff,
6150
+ isAmbiguousMetadataStemError
5888
6151
  });
5889
6152
  //# sourceMappingURL=node.cjs.map