Skip to content

4 September 2026

9 changes:

  • getRelationKind() — the question to ask before a DROP VIEW
  • usage_statistics is left to whoever owns the name
  • geographic_analysis builds whether ip_address is VARCHAR or INET
  • An aggregate that already exists keeps its definition, and says so
  • migrate and migrate:status read this installation's configuration
  • When a continuous aggregate is possible, and when it cannot be
  • A migration whose statements were all rejected no longer reads as one that worked
  • createTable() puts a schema-qualified table in its schema
  • The maintenance page no longer fatals on an entry point without DS

getRelationKind() — the question to ask before a DROP VIEW

dropView() and dropMaterializedView() are not interchangeable, and hasView() cannot tell you which one you need: it answers yes to a plain view, a materialized view and a TimescaleDB continuous aggregate alike. That is the right question when all you need to know is whether a SELECT will be answered, and useless before a drop, where PostgreSQL refuses outright:

ERROR:  "usage_statistics" is not a view
HINT:  Use DROP MATERIALIZED VIEW to remove a materialized view.

Note that IF EXISTS is no protection there. The relation does exist — it is just not the kind the statement can drop.

Added

SchemaBuilder::getRelationKind(string $name): ?string returns one pg_class.relkind letter — 'v' a view (a continuous aggregate reports 'v' too), 'm' materialized, 'r' a table, 'p' partitioned, 'f' foreign — or null when nothing owns the name. MySQL has neither materialized views nor a relkind, so it answers with the only two it can distinguish, v and r.

Documentation

Pramnos_Schema_Builder_Guide.md gains Which kind of relation owns a name under View Operations, and the method in Introspection.

usage_statistics is left to whoever owns the name

Reported from an upgrade of a production installation: DROP VIEW IF EXISTS applications.usage_statistics failed, and failed partway through — the eight views created before it committed, usage_statistics and everything after it never ran, and the runner recorded result = 0 and retried on every deploy that changed the fingerprint.

The framework ships that view live: a multi-CTE aggregation over the whole of usertokens plus the OAuth grant and webhook-event tables. That installation has the identical query — same CTEs, same 38 columns — as a materialized view, refreshed every four hours by a TimescaleDB job, read one row at a time on every admin page view.

So the divergence is not a different definition. It is a deliberate choice of cached over live, made by the only installation with production traffic to measure it against.

The obvious fix is to make the drop relation-kind aware: DROP MATERIALIZED VIEW when relkind = 'm'. That was rejected. It fixes the visible half — the migration completes — and creates two worse problems: it swaps a cached read for a full aggregation underneath an installation that is serving traffic, which is a performance decision a migration is in no position to take; and it leaves refresh_usage_statistics scheduled against a relation that no longer exists, so a job that had been quietly working starts failing every four hours, far from the deploy that caused it.

Fixed

A relation of any kind other than a plain view is left exactly as it is, and the skip is logged to the migrations channel with what an operator would need to do to adopt the framework's version instead — drop the relation together with whatever refreshes it, and re-run. down() carries the same guard, for the same reason: it did not create that relation, so it does not drop it.

The guard is deliberately narrow. A plain view under that name is the framework's own, from an earlier run, and is still replaced — so the framework never loses the ability to update what it created. Skipping on any pre-existing relation would mean every later change to the definition silently failing to land on every installation that had migrated once, which is the worse failure of the two because nothing reports it.

Both shapes answer SELECT * FROM applications.usage_statistics WHERE appid = ? identically, so nothing that reads the view has to know which one it got.

What an installation with the materialized view has to do: nothing. Keep it, keep the refresh job. The migration completes around it.

Documentation

Pramnos_Migration_Guide.md gains A migration is not the first thing to touch the schema — the checks that cover this class of failure, and why saying what you skipped is part of the fix.

geographic_analysis builds whether ip_address is VARCHAR or INET

Same upgrade, same shape — a migration assuming it is the first thing to touch the schema. authserver.geographic_analysis grouped on SPLIT_PART(ip_address, '.', 1), against an ip_address the application had created as INET rather than VARCHAR(45):

ERROR:  function split_part(inet, unknown, integer) does not exist

That view is the last one in its migration, so the failure cost exactly one object — but it cost it on every deploy.

Fixed

SPLIT_PART(ip_address::text, '.', 1), in both the SELECT and the GROUP BY. SPLIT_PART has no INET overload and HOST() has no VARCHAR one, so neither works on both column types — but ::text is defined for both and yields the dotted form either way, which is all the /8 grouping needs.

The fix belongs here rather than in the application: converging from that side would have meant ALTER COLUMN … TYPE VARCHAR(45) on a hypertable, giving up a type that validates addresses for the sake of one analytics view.

Documentation

Cast, do not assume a column's type joins the checks in Pramnos_Migration_Guide.md.

An aggregate that already exists keeps its definition, and says so

Third from the same upgrade. createContinuousAggregate() against an aggregate the application's own migration had already built, column for column:

ERROR:  continuous aggregate "tokenactions_hourly" already exists

Pure noise — the two definitions were identical — and the only alternative from the application's side was dropping a continuous aggregate with materialised history off a hypertable and rebuilding it to arrive at the same thing.

Fixed

createContinuousAggregate() returns without issuing any DDL when the name is already taken, and logs to the migrations channel that it kept the existing definition. Four framework migrations call this method; all four are now safe to meet a database that got there first.

It is a pre-existence check rather than IF NOT EXISTS for three reasons: TimescaleDB accepts that clause but MySQL's CREATE VIEW has none, so one branch would still have failed; the clause is silent, and "kept what was there" is a different outcome from "created what was asked for"; and the case in the field is not a re-run but a consumer who built the same rollup first, which is worth a line in the log for whoever later wonders whose definition is in force.

The consequence to know is that the definition in force is the one that was already there. A changed aggregate body does not land on an installation holding the old one, and the log line is the only notice — dropping and rebuilding is a deliberate act, because on TimescaleDB it discards materialised history.

Documentation

The guarantee and its two consequences are under TimescaleDB Operations in Pramnos_Schema_Builder_Guide.md; prefer the builder method that already guards joins the checks in Pramnos_Migration_Guide.md.

migrate and migrate:status read this installation's configuration

There were two answers in this codebase to "which migrations apply to this installation", and the CLI read the wrong one. Application::runAutoMigrations() filtered the framework's migration directories through the features gate and the migrations themselves through app.php's migration_cutoff. MigrationLoader::resolveDefaultDirectories() — what the console commands read — globbed every feature directory and knew nothing about a cutoff.

On the installation this was reported from, whose schema predates the migration system, migrate:status reported 44 pending migrations. Forty-two were the 2020_01_01_* baseline epoch that 'migration_cutoff' => '2026-01-01 00:00:00' exists to skip. The other two belonged to broadcasting and changelog, features that application has not enabled and whose tables it does not want. migrate on a deploy would have run all of them.

That matters more than a wrong number, because the upgrade guide tells an operator to review pending migrations first with migrate:status — so the one check meant to be trustworthy before migrating was the one inventing work.

Added

Application::migrationScope() is now the single answer, returning the eligible directories, the set that is deliberately out of scope with a reason for each, and the normalised cutoff. MigrationLoader::scopeFor($app) is the accessor everything else calls — console commands, MCP tools and the dev panel each reach an application they did not construct, and one of them may hold none at all or a test double that answers nothing.

Nothing in it touches the database, which is what lets migrate:status answer before a connection exists.

Fixed

migrate applies the configured cutoff without anyone passing --cutoff, and the features gate, so it no longer creates tables an installation declined or attempts a baseline epoch against tables that have existed for years.

migrate:status reports the scope instead of the disk — and reports it rather than trimming it. Out-of-scope migrations are listed with the reason in the Status column, Skipped (cutoff) or Skipped (feature: broadcasting), followed by a count and the cutoff that decided it. A report that silently omitted those rows would leave an operator with no way to find out why a migration they can see on disk is never going to run, which is the same silence in a new place.

--path and --cutoff still override the configuration, and they stay independent: --path names where to look and does not switch the cutoff off.

migrate:status without a database connection now says so and lists what it found on disk, instead of refusing with exit 1. The console reaches an application without initialising it, so no connection is an ordinary state here; only the Ran/Pending distinction needs one. A missing application is still fatal, because there is then nothing to hand the loader.

The same wrong answer in three more places. The MCP migration-status and status tools and the dev panel's migration card all read the unfiltered list too — a pending_count an assistant repeats to a developer, and a card on a dashboard. All three now read the scope. SchemaDriftTool deliberately still walks every migration file on disk: it compares declarations against the real schema, and for that question the whole tree is the right input.

Documentation

Pramnos_Migration_Guide.md gains The CLI and the request lifecycle answer the same question under Running Migrations: the two filters, what overrides them, what Skipped (...) means, and the no-connection behaviour.

When a continuous aggregate is possible, and when it cannot be

A question a consuming application could not answer from the guides: why is usage_statistics a materialised view with a four-hourly refresh job, when the framework has continuous aggregates?

The mechanics of createContinuousAggregate() were documented and the boundary was not, so the answer had to be derived from TimescaleDB's own documentation — to arrive at the conclusion that the framework's tool does not apply.

Documentation

Pramnos_Hypertable_Guide.md gains three sections, and Pramnos_Schema_Builder_Guide.md links to the first from createContinuousAggregate() itself — a reader with that method in front of them is the reader who needs to know when it will refuse.

Caching an aggregate: continuous aggregate, or materialised view. The three requirements (hypertable source, time_bucket() on its time dimension, the incrementally-maintainable SQL subset), each with the refusal it produces; a qualifying and a non-qualifying query side by side (tokenactions_hourly and usage_statistics) and the sentence that separates them — a continuous aggregate answers this measure, per time bucket, from this time series, and anything whose answer is the current state of this entity is the other kind. Then the fallback, createMaterializedView() plus add_job, and its three costs.

What belongs in a hypertable, and what does not. The general criterion — append-mostly rows queried by time range, not live mutable state looked up by identity — worked through usertokens, where making it a hypertable would cost the uniqueness guarantee on token_lookup and break fk_tokenactions_tokenid, and buy nothing.

Keeping a rollup refreshed. ContinuousAggregateRegistry had no guide page at all; it existed only in changelog entries. It is the framework's single declaration of the refresh parameters, read both by the migrations that create these views and by timescale:ensure.

Measured rather than quoted

Every error string in the new pages was produced against this project's stack — PostgreSQL 14.17 with TimescaleDB 2.19.3 — and two of the restrictions commonly listed as absolute turn out not to hold there: COUNT(DISTINCT …) and a LEFT JOIN to an ordinary table both create successfully. The guide says so, and says to ask your own version rather than trust a list, because the maintainable subset has grown across releases.

Two behaviours worth having written down, both measured:

  • add_job accepts a refresh function with the wrong signature and fails only when the scheduler runs it, with cache lookup failed for function 0 — which names nothing searchable. A refresh function must take (job_id INT, config JSONB).
  • A job outliving its view stays scheduled, erroring every interval with relation "…" does not exist, far from whatever dropped the view. Which is why create_applications_views checks getRelationKind() first — the fix from earlier today.

A migration whose statements were all rejected no longer reads as one that worked

Two behaviours from one afternoon in a consuming application, both while writing ordinary migrations. Neither is exotic; the first cost a wrong conclusion and the second cost the time it took to guess which file to delete.

Fixed — the ledger stops claiming success that did not happen

executeQueries() runs queued statements tolerantly: a rejected one does not stop the statements behind it. That is deliberate and stays — a re-run whose ADD COLUMN is already applied must not abandon the eleven statements after it, and installations exist with a hundred-odd numbered migrations relying on exactly that.

What was not deliberate is that up() returning was the only thing either ledger looked at. So a migration whose every statement failed was indistinguishable from one that worked: recorded as applied, reported by migrate:status as Ran, with the only trace in var/logs/upgradeerrors.log, which nothing points at. Two real cases — a DROP MATERIALIZED VIEW that needed CASCADE and dropped nothing, and 115 INSERTs naming a column the ledger did not have yet.

executeQueries() now records every rejected statement and returns how many, Migration exposes them (hasFailedStatements(), getFailedStatements(), failedStatementSummary()), and MigrationRunner records a third state, RESULT_RAN_WITH_ERRORS = 2, with the detail in error_message. migrate:status shows Ran with errors and prints what was rejected under the table; migrate prints Migrated* and lists them in its summary.

Such a migration is still recorded, still counted as run, and migrate still exits 0 — a redundant statement must not make a migration re-run for ever, nor break a deploy script that has re-run the same migrations for years. What changed is only that the report stopped lying.

benign is a label, not a gate. Failures that look like work already applied are marked as such, and nothing is skipped or failed on the strength of it. It cannot be more than a label: only MySQL supplies an error code here — its mysqli_sql_exception propagates with the real errno — while PostgreSQL failures arrive as a plain Exception whose code is 0, because Database::setError() is called with error number 0 on that driver and no SQLSTATE is captured anywhere. That leaves message text, which is localisable, and gating on it would start failing migrations whose statements were merely redundant on any database not running English messages.

Also counted now: Database::query() has one path that returns false without throwing, for a statement that cannot be prepared. Catching only exceptions kept missing the quietest case.

Fixed — a failed migration no longer bricks the application

runMigration() raised var/MAINTENANCE before up() and cleared it after, with no try/finally. A migration that threw left the flag on disk — and Application::__construct() refuses to build an application at all while that file exists, so every request afterwards answered Maintenance Mode: the web application, the API front controllers, and the test bootstrap, which is how a whole suite starts failing with a page instead of a test result.

The page carried nothing to act on either. showError() was called with no message, so what rendered was a var-dump of the last database error — usually Array ( [message] => '' [code] => 0 ). Meanwhile startMaintenance() has always accepted a reason and written it into the file, and runMigration() called it without one, so not even the file named the migration.

Now: try/finally around up(); the reason names the migration and its version; and the page reads the flag back, HTML-escaped. Under DEVELOPMENT it also names the file to delete — in production the reason alone, since a public visitor on a 503 has no use for a path.

The flag is cleared only when that call raised it. startMaintenance() returns early when the file already exists, so clearing unconditionally would have taken the site out of a maintenance window somebody entered on purpose.

Documentation

Pramnos_Migration_Guide.md gains When a statement is refused — the three result states, deciding an outcome in a migration's own code, and why benign cannot be a gate. Pramnos_Framework_Guide.md gains The flag belongs to the process that raised it and What the page says under maintenance mode.

createTable() puts a schema-qualified table in its schema

Found while making the suite green after the change above, which is the only reason it surfaced: new tests shifted the order of the suite, a test that rolls back CreatePramnosSchema ran before the changelog tests for the first time, and CREATE TABLE pramnos.changelog answered ERROR: schema "pramnos" does not exist.

The reordering was mine; the defect was not. createTable() took a qualified name and issued the DDL, leaving "the schema is already there" as somebody else's problem. That assumption holds while every migration runs in order, from the start, against one database — and stops holding the moment one feature's migrations run alone, which is what an installation enabling a feature after the fact does, and what every integration test touching that feature does.

Twenty-five of the twenty-six framework migrations that create a schema-qualified table carried the assumption. Fixing the one that happened to fail would have left twenty-four waiting for the next reordering.

Fixed

createTable() ensures the schema of a qualified name before creating the table, so no migration has to remember. moveToSchema() already did exactly this before its SET SCHEMA — the precedent was in the same file.

A no-op on MySQL, where resolveTable() has already flattened pramnos.changelog into pramnos_changelog and there is no dot left to read. public is skipped, because it always exists and asking would be one wasted statement per table. And it stays quiet when the role may not create schemas: the CREATE TABLE that follows is the better error to report.

The maintenance page no longer fatals on an entry point without DS

Reported from a consuming installation within minutes of pulling the change above, by raising a flag by hand and asking for a page — the exact scenario that change was written for.

Application::__construct()'s first act is the flag check, and DS is defined by setDefines() further down the same constructor. Every method the maintenance path touched built the flag's location with DS, so on any entry point that had not defined it the page was:

Fatal error: Uncaught Error: Undefined constant "Pramnos\Application\DS"

The entry point that exposed it is a shape the framework's own older scaffolding produces — ROOT, autoload, new Application() — and nothing there should have to define DS: a filesystem check cannot depend on constants its own caller has not set yet.

Worse than what it replaced, for the window it mattered in. The flag goes up on its own from runMigration() for the duration of every migration on a deploy, so a request arriving in that window got a PHP fatal instead of the 503 with Retry-After the same release was careful to send.

Fixed

maintenanceFlagFile() is now the single place that knows where the flag lives, built with a literal /, and the constructor, isInMaintenance(), maintenanceMessage(), startMaintenance() and stopMaintenance() all go through it. DS bought nothing in a path: PHP's filesystem functions accept / on every platform.

The dependency lived at six sites, which is why the fix is one method rather than one line. Correcting only the line in the traceback would have moved the fatal from maintenanceMessage() to isInMaintenance(), three lines further along the same path — measured, by putting the old call back: the page was already a fatal on that entry point before the previous change, which had moved the line number and not the outcome.

The test that should have caught it

It could not have. The suite's bootstrap defines DS, so no test inside the suite's process can reproduce the condition — which is how this reached a release with a green suite behind it. The regression test shells out to a subprocess running that bare front controller verbatim, and was checked by reverting the fix.