4 September 2026¶
9 changes:
getRelationKind()— the question to ask before aDROP VIEWusage_statisticsis left to whoever owns the namegeographic_analysisbuilds whetherip_addressis VARCHAR or INET- An aggregate that already exists keeps its definition, and says so
migrateandmigrate:statusread 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):
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:
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_jobaccepts a refresh function with the wrong signature and fails only when the scheduler runs it, withcache 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 whycreate_applications_viewschecksgetRelationKind()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:
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.