/** * Bringing a database up to date with a bundle's collections, additively. * * ## Why this exists * * A managed runtime boots someone else's compiled project against a database it * has never seen. Auth tables are ensured at boot already, but collection tables * were not created by anything: the platform ran the app and every `/api/data/*` * request answered 500 on a missing relation. `rebase db push` cannot help — it * is an Atlas-driven CLI command, and the runtime image ships no CLI. * * ## Why additive-only, forever * * This runs unattended, against a database with customers' data in it, with no * human reading a diff. So it may only ever do things that cannot lose data: * create a missing table, add a missing column, create a missing enum type. * * It will **never** drop a table or a column, narrow a type, or alter a * constraint. A removed field leaves its column behind; a renamed field looks * like an addition and the old column stays. That is the correct trade for an * automated path — the alternative is an unattended process that can silently * destroy a column, which is precisely the failure `db push` was hardened * against. Destructive changes stay a deliberate, human-reviewed migration. * * Because of that, this is safe to run on every boot, and re-running it is a * no-op. */ import { type CollectionConfig } from "@rebasepro/types"; /** * The subset of a database handle this needs: run a statement, get rows back. * * Deliberately parameterless. Everything here is DDL or catalogue reads keyed by * schema name, and schema names are identifiers — they cannot be bound as * parameters anyway. They are validated against {@link SAFE_IDENTIFIER} before * they reach a statement, so a config that somehow carried a quote is refused * rather than concatenated. */ export interface Queryable { query(sql: string): Promise<{ rows: T[]; }>; } /** What the database currently has, as the planner needs it. */ export interface ExistingSchema { /** `schema.table` → set of column names. */ tables: Map>; /** `schema.typename` of every enum type that already exists. */ enums: Set; /** * `schema.table.constraint` of every constraint that already exists. * * Optional so a caller that only cares about tables can still build one by * hand; absent is read as "none known", which at worst re-attempts a * constraint that then fails harmlessly as a duplicate. */ constraints?: Set; /** * `schema.table.column` → that column's comment, for the columns that have * one. This is where a generated search column's fingerprint lives, so it * is the only evidence that a `search` block has changed since the column * was built. Absent is read as "no column is stamped", which plans a stamp * and reports nothing as drifted. */ columnComments?: Map; } export interface EnsureAction { kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column" | "create-extension" | "create-function" | "create-index" | "comment-column"; /** Qualified target, for logging: `public.posts` or `public.posts.title`. */ target: string; sql: string; } export interface EnsurePlan { actions: EnsureAction[]; /** Every statement, in dependency order. Empty when the schema is current. */ statements: string[]; /** * Relation columns this plan is about to create where the table already * carries the same column under its pre-singularization name. * * The reason this is reported rather than silently handled: the ensure is * additive, so it would add `category_id` beside a populated * `categorie_id` and the relation would then read the new, empty one. No * statement fails, no table is missing, and the only symptom is relations * resolving to nothing — which is indistinguishable from having no data. */ legacyForeignKeys: LegacyForeignKey[]; /** * Generated search columns whose `search` block has changed since they were * built. Reported, never planned into `actions` — see * {@link SearchColumnDrift} for why applying it is not this path's call. */ searchDrift: SearchColumnDrift[]; /** * Generated search columns that exist but carry no fingerprint — created * before this check existed, or by `search.sql` on an older CLI. The plan * stamps them so the *next* change is detectable; whether they match the * current block cannot be known, which is what the caller reports. */ searchAdopted: { table: string; column: string; }[]; } /** * A generated search column built from a `search` block that has since changed. * * Reported instead of applied because the two ways to apply it are both worse * than stopping. `ALTER COLUMN … SET EXPRESSION` exists only on PG17+ and * rewrites the table either way; `DROP COLUMN` + `ADD COLUMN` rewrites it under * an ACCESS EXCLUSIVE lock and rebuilds the GIN index. This module runs * unattended against live customer data with nobody reading a diff — the same * reason it withholds `SET NOT NULL` from an adopted table — so a multi-minute * outage is not a decision it may take on its own. * * Not applying it silently is not an option either: that is the bug this * detection exists for. A collection that added a field, flipped `unaccent` or * raised a weight kept indexing the *old* set forever, and the only symptom was * searches returning nothing for content plainly in the row. */ export interface SearchColumnDrift { /** `schema.table`. */ table: string; column: string; /** The fingerprint recorded on the column. */ found: string; /** The fingerprint the current `search` block computes. */ expected: string; /** The statements that would rebuild the column, for the operator to run. */ rebuild: string[]; } /** A relation column whose old and new spellings both plausibly apply. */ export interface LegacyForeignKey { /** `schema.table`. */ table: string; /** The name the current rule derives, and what this plan would create. */ expected: string; /** The name the old rule derived, which the table already has. */ legacy: string; } export interface EnsureOutcome extends EnsurePlan { /** * Actions that could not be applied and are non-fatal by nature. * * Two kinds qualify. A foreign key can only fail on data that already * violates it, and the column it would police exists either way, so the * collection still serves; refusing to boot over one would turn a * pre-existing data problem into an outage. A column comment is the search * fingerprint, which needs table ownership — losing it costs drift * detection on the next boot, not the deployment. Both are reported loudly. */ failures: { kind: EnsureAction["kind"]; target: string; error: string; }[]; } /** * Decide what to add. Pure — the caller supplies what exists and runs the result. * * Ordering matters and is deliberate: enum types before the tables and columns * that reference them, tables before the columns added to other tables (a new * table may be the target of a relation), and nothing is emitted twice. */ export declare function planCollectionSchemaEnsure(allCollections: CollectionConfig[], existing: ExistingSchema): EnsurePlan; /** Read what the database has, for the schemas the collections live in. */ export declare function readExistingSchema(client: Queryable, schemas: string[]): Promise; /** * Bring the database up to date. Returns what it did. * * Each statement runs on its own rather than in one transaction: they are all * independently safe and idempotent, and a single failure (an enum label that * cannot be added, say) should not roll back the tables that were created fine. * The error is surfaced with the statement that caused it. */ export declare function ensureCollectionTables(client: Queryable, collections: CollectionConfig[], log?: (message: string) => void): Promise;