# Changelog 0.3

## v0.3.0 — 2026-10-05

0.3.0 adds extensions, domains, functions and triggers, views, and roles and grants, and checks that each one round-trips.

### ✨ Added

#### dialects

- `extension()` from `okmodel/ext` declares an extension. `schema({ extensions })` stores those objects and, when the catalog is built, asks each one for its record. `citext()` (`okmodel/pg/citext`) and `pgTrgm()` (`okmodel/pg/pg_trgm`) are the built-ins. `pgTrgm.similar` and `pgTrgm.wordSimilar` are where filters.

- A second declaration of the same extension is OKM1813. A `citext` or `ltree` column whose extension is missing from a non-empty list is OKM1810. Omitting the list leaves the existing dependency check (OKM1020).

- Migrations create an extension before the tables that use it, upgrade with `ALTER EXTENSION … UPDATE` (the step is `path-unverified` until apply checks `pg_extension_update_paths`), move a relocatable extension with `SET SCHEMA`, and drop with `DROP EXTENSION` and no `CASCADE`. A downgrade or a non-relocatable move is OKM1814. Apply checks `pg_available_extensions` before any statement (OKM1811). An unpinned declaration matches whatever version is installed.

- `pgTrgm.gin()` and `pgTrgm.gist()` store a `using gin` or `using gist` index with `gin_trgm_ops` or `gist_trgm_ops`. The plan creates that index, and introspection of it matches the declaration.

- `t.domain(name, base, check)` is a catalog type. The column's TypeScript type and codec are the base column's. Creating the domain, changing its check, and dropping it are planned. A check change is `ALTER DOMAIN … ADD CONSTRAINT … NOT VALID`, then `VALIDATE CONSTRAINT`, then a drop of the previous check, and never `CASCADE`. Changing the base type is OKM1020. An enum cannot be the base (OKM1060).

- `fn()` and `trigger()` from `okmodel/fn` declare functions and triggers. `schema({ functions, triggers })` stores them. A function's identity is schema, name, and argument types, so overloads are distinct. A trigger's identity is table and name. plpgsql without `dependsOn` is OKM1824. `security: "definer"` without `searchPath` is OKM1823.

- A plan creates types and tables, then functions, then triggers. The same signature is `CREATE OR REPLACE` and the step is marked `behavior: change`. A return-type change drops dependents and recreates them, and never uses `CASCADE`. A drop goes in reverse and is refused while a dependent remains (OKM1821). Triggers are dropped before their functions.

- `timestamps({ enforce: "trigger" })` adds a before-update trigger and its function, with timestamps provenance. `LANGUAGE sql` that starts with `BEGIN ATOMIC` takes its dependencies from `pg_depend`.

- `view()` and `materializedView()` from `okmodel/view` declare views. `schema({ views })` stores them. A view is SQL with declared columns. Appending columns is `CREATE OR REPLACE` and the step is marked `behavior: change`. An incompatible replace, or a change to a column the view reads, drops the dependents and recreates them, and never uses `CASCADE`. A drop goes in reverse and is refused while a dependent remains (OKM1821).

- A materialized view has no `CREATE OR REPLACE`. Creation is `WITH NO DATA`, and populate is a planned data step. `refresh: "concurrently"` needs a unique index (OKM1822). Scratch verification reads column dependencies from `pg_depend` and stores the server reprint of the query.

- `db.views.<name>.find(...)` reads a view. Writes are not on the handle. A view that exposes the tenant key takes the tenant predicate. A view that reads a tenant table and does not expose the key is OKM1820 in `okm check` unless declared `global("reason")`.

- `defineConfig({ roles: { migration, app } })` adds role, grant, and default-privilege objects. A name is external unless it is listed in `managed`. A managed role is created when `pg_roles` does not have it, altered in place, and never dropped. Grants and default privileges are for the application role on every managed table, view, materialized view, sequence, and function. Fine-grained grants stay in M2.

#### tooling

- `okm ext list` prints the extensions the connected server can install and the version that is installed. `okm ext check` compares those versions with the schema. A missing extension is OKM1811. A pin the server does not meet is OKM1812. `okm ext test` and `okm ext scaffold` are not in this version.

- `okm doctor` lists the triggers on each table. `okm doctor OKMxxxx` prints that code. When `roles` is set, doctor also checks that an external role exists, that a managed role can be created (`CREATEROLE`), and that the application role can reach each managed object (OKM1825).

- The 0.3 gate pushes an extension, a domain, a function, a trigger from `timestamps({ enforce: "trigger" })`, a view, a materialized view, and roles onto an empty database, checks for no drift, alters each one, and removes it without `CASCADE`. An extension the server cannot install is OKM1811 before any statement.

- Apply runs migrations as the migration role. When that role exists and is not `current_user`, the runner issues one `SET ROLE` before any statement and records it on the run report. The statement is not a plan step. `CREATEROLE` is checked before any statement when the plan creates or alters a role. `CREATE ROLE` has no `IF NOT EXISTS`; a role that already exists is skipped.

#### docs

- Known limits: a typed call such as `fn.slugify(col)` is not in this version, `inspect()` does not list triggers, a plpgsql body is `prosrc`, and a `BEGIN ATOMIC` body is the server reprint.

- Known limits: a view is SQL plus a declared column list, and `db.views.<name>.find(...)` is read-only. The query builder form (`view(name, (q) => q.from(...))`) is not in this version (D190). Column dependencies come from `pg_depend`. The stored query is the server reprint. `refresh: "concurrently"` is not stored by Postgres, and the plan's first populate is a plain `REFRESH`. `security_invoker` is not set.

- Known limits for roles and grants: fine-grained grants are M2, a role is never dropped, OKM1825 is doctor only, and a function grant is `name(argTypes)`. The non-owner RLS check moved to M2.

- Known limits name this version. Typed `fn` calls, builder-defined views, `okm ext test`, `okm ext scaffold`, column-level grants, and row-level security stay unbuilt. A domain check or a `BEGIN ATOMIC` body that differs from the Postgres reprint only by parentheses or casts is still a plan change. `okm check` does not warn. Write the text Postgres prints.

- The README names PostgreSQL 15 to 18, lists what 0.2 and 0.3 ship, and adds `okm ext list`, `okm ext check`, and `okm doctor` to the command table. The size rows are the `bun run size` output. The version line is 0.3.0 and the 0.3 roadmap item is done.

### ♻️ Changed

- Startup gzip replaces lazy chunk ids with a fixed id of the same length before compressing, so a chunk whose only change is its content hash does not move the number.

### 🐛 Fixed

- `okm check` reprints each view and materialized view with `pg_get_viewdef` on the connected server before it compares. A query Postgres 15 prints differently from Postgres 18 is not drift after push. A query that reprints to something else still is.

- `okm_meta` and `okm_history` stay out of the author diff. Plan, push, check, and doctor neither create nor drop them, in either direction.

- A primary key's name key is `pkey` in the schema and in introspection. `CREATE TABLE` writes that constraint's name, so a name other than `{table}_pkey` round-trips. After push, `okm check` reports no drift for a plain key, a composite key, and a named key. When `okm_meta` already exists, `okm check` compares the database with the schema and reports OKM1520 if they differ.

- A plan treats a schema type spelling and the Postgres `format_type` spelling as the same type (D191). `timestamptz` matches `timestamp with time zone`, and the same for `timestamp`, `time`, `timetz`, `varchar(n)`, `char(n)`, and the integer, boolean, float, and `decimal`/`numeric` aliases. Length, precision, scale, and array ranks stay. An unknown name is compared as written. `varchar(20)` to `varchar(30)`, and `timestamp` to `timestamptz`, still alter. After push, `timestamps({ enforce: "trigger" })` plans to no steps.
