/**
 * Shared fact-row scaffolding (D0091 / W-fact-table-registry-v1).
 *
 * Every v1 fact table carries the same identity spine, the join keys back to
 * the existing `reports` / `area_scores` views, the DAY-partition value, a
 * `gs_uri` for drill-through to the full artifact in GCS, and a `_loaded_at`
 * audit column. This module owns that spine once so each descriptor declares
 * only its own analytic columns.
 *
 * ## The `_loaded_at` audit column is schema-only
 *
 * `_loaded_at` records *when the loader wrote the row*, which is neither
 * available to a pure extractor (it has no clock) nor stable across re-runs
 * (idempotent MERGE re-derives the same natural key repeatedly). So it lives
 * in each descriptor's `schema` (the DDL the store provisions) but **not** in
 * the row type or `rowSchema`, the store adapter stamps it at MERGE time
 * (W-bigquery-fact-store-adapter). The schema-sync guard treats it as the one
 * column allowed to exist in `schema` without a matching row field.
 *
 * @see docs/decisions/D0091-bigquery-analytics-fact-loader.md
 * @see docs/design-docs/bigquery-analytics-fact-loader.md
 */
import { z } from "zod";
/**
 * The name of the load-audit column. Present in every table's `schema`,
 * absent from every row type, see the module doc.
 */
export const LOADED_AT_COLUMN_NAME = "_loaded_at";
/** The load-audit column, appended to every fact table's schema. */
export const LOADED_AT_COLUMN = {
    name: LOADED_AT_COLUMN_NAME,
    type: "TIMESTAMP",
    description: "When the fact loader wrote this row (stamped by the store at MERGE).",
};
/**
 * The `BigQueryColumn`s for {@link RunFactBase}, in row order. Spread at the
 * front of each descriptor's `schema`; `LOADED_AT_COLUMN` goes at the end.
 */
export const RUN_FACT_BASE_COLUMNS = [
    {
        name: "run_id",
        type: "STRING",
        mode: "REQUIRED",
        description: "Run join key.",
    },
    {
        name: "report_id",
        type: "STRING",
        description: "Report join key to reports / area_scores (null if none).",
    },
    {
        name: "completed_at",
        type: "TIMESTAMP",
        mode: "REQUIRED",
        description: "Run completion time, DAY partition.",
    },
    {
        name: "gs_uri",
        type: "STRING",
        description: "GCS URI of the source artifact, for drill-through.",
    },
];
/** The zod fields for {@link RunFactBase}. Spread into each table's `rowSchema`. */
export const runFactBaseShape = {
    run_id: z.string(),
    report_id: z.string().nullable(),
    completed_at: z.string(),
    gs_uri: z.string(),
};
/**
 * Build the {@link RunFactBase} portion of a row from the extraction context
 * and the drill-through URI of the specific artifact the row came from.
 */
export function runFactBase(ctx, sourceUri) {
    return {
        run_id: ctx.runId,
        report_id: ctx.reportId ?? null,
        completed_at: ctx.completedAt,
        gs_uri: sourceUri,
    };
}
/**
 * Coerce an {@link AssociationValues} axis value (`string | number | undefined`)
 * to a string, defaulting to `fallback` when the axis is absent. Association
 * axes on the read side arrive as the values the writer stamped; a missing axis
 * means the artifact did not carry it, which we surface as a stable default
 * rather than `undefined` leaking into a row.
 */
export function axisString(value, fallback = "unknown") {
    if (value === undefined)
        return fallback;
    return String(value);
}
// ---------------------------------------------------------------------------
// Defensive field coercion
// ---------------------------------------------------------------------------
//
// Artifact payloads arrive JSON-deserialized but unvalidated (`unknown`). The
// extractors read them field-by-field with these guards, a wrong-typed field
// becomes `null` rather than corrupting a row, and the descriptor's `rowSchema`
// is the final trust boundary before load. Mirrors the loose-cast + typeof
// pattern the artifact-registry manifest-preview extractors use.
/** A finite number, or null. */
export function numOrNull(v) {
    return typeof v === "number" && Number.isFinite(v) ? v : null;
}
/** A finite, non-negative integer, or null (truncates fractional inputs). */
export function nonNegIntOrNull(v) {
    return typeof v === "number" && Number.isFinite(v) && v >= 0
        ? Math.trunc(v)
        : null;
}
/** A boolean, or null. */
export function boolOrNull(v) {
    return typeof v === "boolean" ? v : null;
}
/** A non-empty string, or null. */
export function strOrNull(v) {
    return typeof v === "string" && v.length > 0 ? v : null;
}
