12 August 2026¶
14 changes:
- Late writes into compressed chunks no longer have to be lost
printreplaces thepdfdocument type, which had not worked for years- MariaDB stops pretending to be MySQL
- The toolbar now sees what a page does after it renders, and can be opened on a live server
- Nine queries per request, most of them avoidable
- The SPA debug panel now says it exists
- The docs now say when you would need them
- The toolbar's hide button now hides the toolbar
spa:dev/spa:build, and a service container that actually exists- One toolbar, delivered two ways — and the SPA panel gains every tab
- A name for every request, and the log lines it wrote
- The server-rendered toolbar now uses the one renderer too
- Settings read every key one at a time on PostgreSQL, and said nothing
- SSE events published during a reconnect are no longer lost
Late writes into compressed chunks no longer have to be lost¶
A hypertable with a compression policy refuses inserts into the ranges it has already compressed. Until now the only answers were to lose the row or to leave the table uncompressed for ever.
The problem¶
Every application that writes late data meets this: a delayed reading, a backfill, a correction, a webhook that arrives months after the event it describes. The insert simply fails.
The workaround that suggests itself — decompress the chunk, write the row, compress it back — is correct and unusable, because that pair costs the same for one row as for ten thousand. Done per row it is slower than the data is worth; done per chunk it is cheap. That grouping is the whole trick, it is not obvious, and every application that meets compression will rediscover it, probably the slow way first.
Added¶
Pramnos\Database\DeferredWriteQueue — both halves of the pattern.
On the write side, write() decides per row whether the target time is still
writable, reading the cutoff from the live compression policy rather than
from a constant that drifts the first time somebody changes the policy. Recent
rows go straight in; late ones are queued in deferredwrites. A row that is
cleared and then fails anyway — the policy compressed the chunk in the second
between the two — is queued rather than lost.
On a database with no compression policy, on MySQL, and on any development or CI box without TimescaleDB, there is no cutoff, nothing is ever deferred, and this is a plain insert with one cached lookup in front of it. The same call works on every backend, which is the point: the application does not branch on the database it happens to be running against.
php pramnos timescale:drain — writes what is waiting, one
decompress/compress pair per chunk however large the backlog. It asks
TimescaleDB only for the chunks that actually have rows waiting, so a drain is
proportional to the backlog rather than to the table's age. --status reports
the backlog and the cutoff without touching anything; --retry-failed puts
failed rows back.
HypertableRegistry gained deferred_writes, conflict and
conflict_update, so the threshold and the overwrite rule are declared next to
the compression policy they belong to instead of being constants in a model.
Adding a table to the queue is a registry entry, not a code change — the queue
stores rows as JSON and carries no per-table knowledge.
SchemaBuilder::compressChunk() / decompressChunk() — chunk-level
compression, quoted through PostgreSQL's own format('%I.%I', …). Both return
false on a backend without TimescaleDB rather than raising.
deferredwrites — a core framework migration, created on every backend.
Behaviour worth knowing¶
A batch runs in one transaction. When it raises, the batch is replayed row by row, so one bad row is marked failed and its five hundred blameless neighbours are still written — the difference between a queue that drains and one that jams behind a single row.
Failed rows are kept with their error message and never retried automatically. A
row that fails once usually fails the same way for ever, and an hourly retry
hides the problem instead of showing it. Fix the cause, then --retry-failed.
The chunk is compressed again even when every row in it failed, because a chunk left decompressed never recompresses on its own — the policy only looks at chunks it has not already handled.
Documentation¶
Hypertables guide — new section, Writing late data into a compressed table.
print replaces the pdf document type, which had not worked for years¶
getDocument('pdf') rendered through TCPDF. TCPDF is not a dependency of this
framework, so the type raised a fatal error on its first line — every caller of
it was already broken.
What was there¶
Pdf::render() called new TCPDF(...) with no leading backslash, which inside
Pramnos\Document\DocumentTypes resolves to a class in that namespace. There is
no such class and no package that provides one. It also read
$this->printpaper, a property no class declares. The type could not have run.
What it offered when it did work, years ago, is worse than what a browser does today: an HTML subset, almost no CSS, and a font matrix to maintain. Every current browser prints real CSS with real fonts and real page breaks, and offers Save as PDF in its own dialog.
Added¶
Pramnos\Document\DocumentTypes\PrintDocument, as document type print. It
is an Html document — theme, meta tags, addCss(), enqueueStyle(),
enqueueScript() all work as they do on any HTML page — with three things
attached:
- an
@pagerule built frompaperSize,orientationandmargin; - a small print stylesheet: no backgrounds suppressed by the browser's
ink-saving, headings that do not sit alone at the foot of a page, table rows
that do not split,
.no-printhidden,.page-breakhonoured, and a screen preview so the author sees roughly what will come out; window.print()onload— after the images and web fonts have arrived, rather than before.
$doc = \Pramnos\Framework\Factory::getDocument('print');
$doc->title = 'Invoice 2026-0042';
$doc->paperSize = 'A4';
$doc->margin = '15mm';
$doc->addCss('/css/invoice.css');
$doc->addPrintCss('.totals { break-inside: avoid; }');
$doc->noPrint('.site-nav');
Every part is optional: autoPrint = false for a page the reader should check
first, baseStyles = false for a document with its own complete print CSS,
closeAfterPrint = true for a printable view opened in its own tab.
Being an HTML document is the point — the old type could not take a stylesheet at all, so a printable page had to be built twice.
Removed¶
Pramnos\Document\DocumentTypes\Pdf, and its tests, which passed only against a
stub of the library that was never installed.
getDocument('pdf') still answers, with the printable document, so existing
links produce a page a user can save as a PDF instead of a stack trace. Use
'print' in new code.
Documentation¶
Document output guide — the Printable Document Type section, with the full property list.
MariaDB stops pretending to be MySQL¶
MariaDB has always worked with the framework, configured as type = 'mysql', and nothing
crashed. But "nothing crashed" was doing a lot of work: $schema->nextVal() quietly returned
0 on a server that has had real sequences since 10.3, and every capability gate in the
codebase asked who the server is rather than what it can do.
Added¶
Flavor detection on Database¶
Two additive accessors, both cached per connection and reset by connect():
Detection reads the live server version string, not the configuration. It cannot come from
config: MariaDB installations are configured as mysql and must stay that way. MariaDB is the
only server that puts the literal MariaDB in its version string, which makes the string the
one reliable signal available.
Both degrade safely. On an unconnected Database — the shape unit tests construct —
getServerVersion() returns '' without opening a connection as a side effect, and does not
memoise that answer, so a later call after connect() still gets the real version. On
PostgreSQL, isMariaDB() short-circuits on the engine type before the version is ever read.
Driver-level failures are swallowed rather than thrown: capability detection runs during
grammar selection, where an exception would break code that is otherwise working fine.
DatabaseCapabilities: flavor, version, and four "can it?" constants¶
New: ENGINE_MARIADB / isMariaDB(), getVersion(), atLeast(), and the feature constants
SEQUENCES, RETURNING, NATIVE_JSON, CHECK_CONSTRAINTS with matching hasX() methods.
| Constant | True when |
|---|---|
SEQUENCES |
PostgreSQL; MariaDB ≥ 10.3 |
RETURNING |
PostgreSQL; MariaDB ≥ 10.5 |
NATIVE_JSON |
PostgreSQL; MySQL ≥ 5.7.8 — not MariaDB |
CHECK_CONSTRAINTS |
PostgreSQL; MariaDB ≥ 10.2; MySQL ≥ 8.0.16 |
NATIVE_JSON is the entry that surprises people. MariaDB accepts the JSON keyword, so naive
detection says yes — but the column is LONGTEXT with a CHECK (json_valid(...)) constraint.
It is neither binary storage nor a distinct type. The coarser, pre-existing FEATURE_JSON
("can I store JSON at all") is unchanged and still true everywhere; NATIVE_JSON is the
stricter question, added alongside it rather than replacing it.
getVersion() normalises the raw string before comparison, which matters more than it sounds:
MariaDB advertises itself to old clients as 5.5.5-10.11.6-MariaDB-…. Taken literally, every
MariaDB in the world looks like MySQL 5.5 and fails every version gate. The prefix is stripped.
An unknown version answers false to every version gate. An unidentifiable server is treated
as too old, which keeps new behaviour opt-in rather than accidental.
isMySQL() still returns true on MariaDB — deliberately¶
This is the interconnection point, and the decision most likely to be "fixed" wrongly later, so
it is documented at length in the DatabaseCapabilities class docblock.
MariaDB is a member of the MySQL family, not a separate engine. Fifteen call sites in src/
read isMySQL() as "compile MySQL-compatible grammar" — backtick quoting,
information_schema introspection, SET FOREIGN_KEY_CHECKS, AUTO_INCREMENT — every one of
which is correct on MariaDB. Making isMySQL() false there would silently route all fifteen
down the PostgreSQL branch.
So isMySQL() is the family, isMariaDB() narrows it, and isMariaDB() implies isMySQL().
The rule for new code: ask the feature question (has(SEQUENCES)), not the identity question.
MariaDBSchemaGrammar¶
Extends MySQLSchemaGrammar and overrides exactly four methods — the sequence compilers —
plus one private quoting helper. A test asserts by reflection that the list of overridden
methods is precisely those five, so a fifth override cannot creep in unnoticed.
$schema->createSequence('order_seq', start: 1000, increment: 5);
$id = $schema->nextVal('order_seq'); // a real value, no longer 0
Dialect differences the grammar hides: MariaDB spells the negative cycle option NOCYCLE
(one word, not PostgreSQL's NO CYCLE), and NEXTVAL/SETVAL take a bare quoted identifier
rather than PostgreSQL's string literal. MariaDB's third SETVAL argument is named is_used
but means what PostgreSQL's is_called means.
Grammar selection in SchemaBuilder::makeGrammar() gates on the SEQUENCES capability,
not on the flavor name. A MariaDB older than 10.3 has no sequence objects, so it keeps the
plain MySQL grammar and its existing no-op behaviour instead of being handed DDL it cannot
parse.
Considered and deliberately left out¶
RETURNING on MariaDB ≥ 10.5¶
The capability constant exists and reports correctly, but the query grammar does not use it
and MariaDBGrammar was not created. This is a behaviour decision, not an oversight.
Database::insertDataToTable() calls $qb->returning($primarykey) unconditionally whenever a
primary key is passed. On the MySQL grammar compileReturning() returns '', so the clause
vanishes and callers read the new id via getInsertId() → mysqli_insert_id(). Enabling
RETURNING would change that silently for every insert in every application running MariaDB
10.5+, without a single line of application code changing.
The mechanism is concrete. In Database::execute(), the mysqli branch uses
$statement->get_result() === false as its signal that the statement was DML, and only then
captures affected_rows — a value that becomes unavailable after close(). An
INSERT … RETURNING id returns a result set, so get_result() succeeds, affected_rows is
never captured, and the Result handed back to the caller looks like a SELECT instead of an
insert. Every caller that tests the return value of insertDataToTable() for truthiness or
reads an affected-row count would be reading something different from what it reads today, on
a server they did not change.
Enabling it safely means threading the capability through QueryBuilder::insert() and
teaching execute() to distinguish "DML with a result set" from "SELECT" — a change to the
core execution path that deserves its own commit, its own integration coverage on a real
MariaDB, and a way for callers to opt in rather than be opted in. It is not a grammar
one-liner, so it is not in this one.
Testing¶
tests/Unit/Database/MariaDBCapabilitiesTest.php covers detection and the capability matrix at
every version boundary that matters — 10.2, 10.3, 10.5, 5.7.8, 8.0.16 — plus the legacy
5.5.5- prefix, unknown versions, driver failure, the unconnected case, grammar selection, and
the emitted sequence SQL.
tests/Integration/Database/ServerFlavorDetectionTest.php exercises the driver-specific
lookups (mysqli_get_server_info(), pg_version()) against the real containers, and pins down
that MySQL 8.0 and PostgreSQL 14 are not misidentified and that their grammar selection is
unchanged.
There is no MariaDB service in docker-compose.yml, so the MariaDB branch has no
integration coverage. The unit tests carry that weight by injecting the version string. Adding
a mariadb:10.11 service would be cheap and isolated — it needs no fixtures beyond an empty
database, and would let the four sequence statements be executed for real rather than merely
compiled — but it is a change to the shared test topology and was left for a separate decision.
Documentation¶
docs/Pramnos_Schema_Builder_Guide.md— new capability constants, the engine/flavor/version section, and the sequences chapter retitled from "PostgreSQL only" to "PostgreSQL and MariaDB 10.3+".docs/Pramnos_Database_API_Guide.md— a "Which server am I talking to?" section covering the new accessors and why$database->typecannot answer the question.
The toolbar now sees what a page does after it renders, and can be opened on a live server¶
Two gaps, both about the requests that matter most: the ones a page makes after it has loaded, and the ones happening on the server you cannot turn debugging on for.
The requests after the render¶
Every tab in the toolbar described a single request — the one that built the page. But a page is rarely finished when it renders. A datatable pages and sorts, a form saves, a widget polls, a single-page application does nothing else at all. Those requests ran queries nobody was watching.
Added: an ajax tab. No setup. The toolbar wraps fetch and
XMLHttpRequest, and every call the page makes appears with its method, URL,
status, server time and query count. Click a row for the statements.
The data reaches it through two channels, because one is not enough:
_debugin the body, for any JSON object response — the full payload, with the queries. This is now attached centrally, in the output-buffer callback, rather than only insideApplication\Api. That is what makes it cover datatable endpoints and controllers that echo their own JSON.X-Pramnos-DebugandServer-Timingheaders, for everything with nowhere to put a key: a204, a redirect, an HTML fragment, a top-level JSON array. They carry a summary — time, memory, query count, route.
The header never carries query text. A header is written to the web server's access log and to every proxy in front of it, and statements there would put customer data in files nobody treats as sensitive.
An annotated response also declares Vary: Cookie, and Cache-Control:
no-store, private when the grant came from a token. On a live server the
toolbar is open for one browser while everyone else gets the same URLs, and a
shared cache in front of the application cannot tell them apart — a cached body
with a _debug key would hand one browser's query log to the next visitor.
The wrapper obeys three rules, because it runs inside somebody else's
application: the original fetch/XMLHttpRequest is always called and its
result returned unchanged, bodies are only read through clone() so the
application still consumes them, and every part is wrapped in try/catch. A
toolbar that breaks the page it measures is worse than no toolbar.
Opening it for one browser on a live server¶
The toolbar is off in production, and it should be. But the bugs that deserve a toolbar are mostly the ones that only happen there.
Added: php pramnos debug:token.
$ php pramnos debug:token --ttl=2h
https://example.com/?_debug=1786237200.9f86d081898637d1…
Valid until 2026-08-12 16:40:00 (2h)
Open the link once; the toolbar then follows that browser — every page, and
every XHR those pages make — until the token expires. ?_debug=off ends it.
The token is <expiry>.<hmac>: the expiry, and an HMAC-SHA256 of it under the
application key. No storage, nothing to clean up, and it stops working by
itself. The expiry is what is signed, so it cannot be extended by its holder;
rotating APP_KEY revokes every outstanding token; comparison is hash_equals().
Twelve hours is the ceiling. A debug token that lasts a month is a backdoor with a friendly name.
With no application key, nothing is granted — debug:token refuses and every
check returns false. There is deliberately no fallback secret: a predictable one
here would hand a live server's query log to anyone who read the source.
Two decisions worth stating¶
A cookie, not the session. Service providers boot before
Application::init() starts the session, so at the moment the toolbar decides
whether to exist there is no session to ask — $_COOKIE is already populated.
It also means the grant travels with every later request on its own, including
the XHR calls, which is what makes the ajax tab work on a live server at all.
A grant opens the toolbar, not debug mode. The check sits next to
isDebugMode() rather than inside it, because that method also decides whether
errors are shown to the browser. One person gets to watch; nobody gets a stack
trace on a public page.
Also¶
A statement served from cache is labelled CACHE in the ajax panel, as it
already was in the main queries panel — and in the text the copy button
produces. Showing it as 0ms reads as "instant" rather than "did not run", and
the difference between those two is the reason for looking at the panel at all.
The per-request header says how many of its statements were live and how many
came from cache.
Documentation¶
New guide: Debugging — the ajax tab, both data channels, the token mechanism, and a security checklist for using it in production.
Nine queries per request, most of them avoidable¶
A datatable asking for one page of results ran nine statements. Two of them read settings that do not exist, one built a theme nothing would render, three logged that the request had happened, and one asked whether the migrations were up to date — again, having asked on the previous request a moment earlier.
What was happening¶
Every request, including every XHR a page makes after it has rendered:
| Statement | Why |
|---|---|
DELETE FROM sessions WHERE time < … |
garbage collection, on every request |
SELECT logout FROM sessions WHERE visitorid = … |
to read a flag that is almost never set |
INSERT … ON CONFLICT … sessions |
recording the visit |
SELECT value FROM settings WHERE setting = 'theme_default_settings' |
a setting that does not exist |
SELECT value FROM settings WHERE setting = 'theme_default_widgets' |
another one |
SELECT 1 FROM schemaversion WHERE key = '__fw_auto_…' |
the auto-migration check |
SELECT urlid FROM urls WHERE hash = … |
resolving a URL to an id |
INSERT INTO tokenactions … |
logging the request |
SELECT LASTVAL() |
for an id only the API path uses |
Fixed¶
Settings that do not exist no longer cost a query each, every time. The bulk load already read every row in one statement; a key still missing afterwards is missing, and asking the database again cannot change that. It was asking on every read — so an absent setting cost one query per read, per request, for ever. The two theme lookups were exactly that.
The dead theme settings read is gone. Theme::loadSettings() fetched
theme_<name>_settings, compared it to '', and did nothing with it: the only
statement inside the if was commented out years ago, when the settings form it
fed stopped existing.
A theme is no longer built for a response that cannot render one. The load
runs before the controller, which is before anything knows the response will be
JSON — so a datatable endpoint built a theme, read its widget configuration and
looked for its screenshot, for a reply with nowhere to put any of it. Opt-in
('lazytheme' => true in app.php), because a controller is entitled to read
$document->themeObject while it runs.
Session garbage collection is occasional. Rows go stale five minutes after
their last request and nothing reads a stale row, so how promptly they are swept
does not matter — only that somebody does it. One request in
session_gc_divisor (default 100) sweeps; 1 restores the old behaviour and
0 turns it off for a scheduled task to do instead.
The forced-logout check is folded into the upsert it sat next to. On
PostgreSQL the upsert no longer clears logout blindly; it returns the value the
row had, and the rare request that finds a 1 pays one extra statement to clear
it. Two statements become one for everybody else. MySQL keeps both, having no
RETURNING on an upsert.
The auto-migration check answers from cache, keyed on the migration files themselves. A time-based cache would be wrong here: after a deploy that adds a migration, a stale "all applied" leaves the schema behind the code. But the fingerprint already describes the files — their count, the latest timestamp, the cutoff — so using it as the key makes the cache invalidate itself. A deploy changes the key; nothing else can. No lifetime has to be guessed, and there is no window in which the code is ahead of the schema. APCu where available, a marker file otherwise.
get_browser() no longer warns on every request. Two separate faults: the
call needs the browscap ini directive, which is unset by default, so it could
only fail — and the toolbar's error handler reported the warning even though the
code suppressed it with @, because a custom handler is called for suppressed
diagnostics too. The handler now respects @, and the call is not made when
there is no browscap file. While there, a request with no User-Agent header —
a health check, a script — stopped raising "Undefined array key".
The session row is not rewritten twice a second. It records who is online
and what they are looking at, and a page that loads and then calls its own API
wrote it twice with the same values. One write per session_write_interval
seconds (default 60) — but a change of URL always writes, because "what are
they looking at" is the field somebody actually watches, so only the timestamp
goes stale. The cost is that a visitor leaves the online list up to a minute
later than they might, and a forced logout is noticed up to a minute later; set
the interval to 0 for the old behaviour.
A datatable's count is cached on the same terms as its rows. count() took
no caching parameters, so it could not be cached at all: a datatable that asked
for caching served its page from cache and then ran a full COUNT(*) anyway, on
every request, for a number that changes far less often than the rows do.
A datatable no longer counts the same rows twice. The unfiltered and filtered counts were both issued unconditionally, and with no search typed they are character-for-character identical — on a large table the most expensive statement of the request, run twice.
Added¶
Pramnos\Database\WriteSpool — a buffer for writes that should not be paid
for while somebody is waiting. An audit row, an access log, a counter: worth
keeping, worth nothing individually, written on every request and read in bulk
much later.
The backend order is measured rather than assumed, per row, against a real PostgreSQL and a real Redis:
| ms/row | |
|---|---|
| INSERT into the real table (hypertable + indexes) | 2.807 |
| INSERT into a plain, unindexed spool table | 2.362 |
Redis RPUSH |
0.041 |
file append under LOCK_EX |
0.003 |
Both obvious guesses are wrong. A spool table in the database is not worth building: the cost is the round trip, not the indexes, so an unindexed table saves 16% for the price of a table, a migration and a drain. And the file beats Redis — Redis is also a round trip, to another host, while an append is a syscall. So the file is the default; Redis is the setting to reach for when the buffer must be shared between servers.
The spool streams when it drains. Reading a 100 MB backlog into an array peaked
at 130 MB, which on a default memory_limit is a fatal error — and a fatal
error there spirals, because the spool that could not be drained is the one that
keeps growing. Batches of 500 hold nothing, and measured twice as fast.
php pramnos spool:drain, and a framework schedule that runs it.
Pramnos\Scheduling\FrameworkSchedule — the framework's own periodic work,
declared once. app/schedule.php is written at scaffold time, so a framework
that ships a background command and then relies on every project to add a line
to it has shipped an obligation, not a feature. These register whether or not the
application has a schedule file; FrameworkSchedule::disable() and
disableAll() opt out.
php pramnos work — one process that runs the schedule continuously, for
containers and anywhere else without a crontab. Not the queue worker:
queue:process runs jobs and polls constantly to keep latency low, this runs the
clock and sleeps a minute at a time.
Changed¶
Token::addAction() holds its row instead of writing it. Logging an API call
was an INSERT, then an UPDATE of the row it had just made, plus a round trip for
the generated id — all on the critical path. The row is held until the response
is known and written once, through the spool. updateAction()'s signature is
unchanged and its old behaviour is intact for a caller that passes a real id.
The URL travels as a URL. Resolving it to an id meant a SELECT against the
registry on every logged request, to look up a value that never changes; the
drain does it instead — a long-running process, whose memory of what it has
resolved is worth far more than a per-request one. A bounded cache, because a
worker runs for days and a site can generate URLs without limit.
WriteSpool::transform() is the general form of that: a buffered row can be
cheaper to produce than the row the table wants, and the difference is made up
where there is time.
The token row is not rewritten on every request. Logging a request called
save(), which UPDATEs every column of usertokens — the token itself, the
device description, the scope — in order to move lastused forward and add one
to actions. Neither needs to be accurate to the second, so it is written once
a minute. A new address or a new device writes immediately regardless: those are
what somebody investigating a stolen token looks at, and delaying them to save a
write would be saving the wrong thing.
Prepared statements appear in the query log, with their values. They were
absent from it entirely — which is most of what an application runs, since
everything the query builder produces goes through that path. The ones that did
appear showed their template: WHERE userid = %i, with no way to see which user
or paste the statement into a client.
The SPA debug panel now says it exists¶
The panel has shipped in every SPA project since 10 August, wired into
lib/api.js and fed by every response. It is also invisible until a request
carries debug data — which is a very good property for production and a very bad
one for being found.
The failure¶
Read a scaffolded project's docs and you were told that "every response carries a
_debug key and the panel in the corner shows it". Nowhere was the panel's file
named. So the reasonable conclusion — reached by people and by coding assistants
alike — was that the framework provides the data for a SPA and that drawing it
is the application's job. The result was a second panel written next to a working
one, with the framework's version left dark because nobody knew to look at it.
Two supporting errors travelled with that conclusion, both worth correcting:
- "The framework's toolbar would freeze on the shell." It would not. Since
10 August the toolbar has an
ajaxtab that wrapsfetchandXMLHttpRequest; it keeps updating for as long as the page lives. - "So the framework only ships the payload." The real reason the HTML
toolbar cannot appear in a SPA is narrower and more fixable-sounding: the
shell (
www/spa.php) requires only the autoloader, never boots the framework, and so never passes throughDebugBarMiddleware. There is nothing to inject into — not a rendering that would go stale.
Fixed: the generated docs name the file¶
CLAUDE.md and README.md in every SPA project now list
lib/debug.js in the source tree as framework-owned, state plainly that no
second panel should be written, explain why the HTML toolbar is not an option
for the shell, and give the command to recover the file. Paths follow the
project's own stack — frontend/lib/debug.js for the Vite stacks,
www/assets/js/lib/debug.js for the build-less one — so the documented path is
one that exists.
Added: project:resync --debug-panel¶
A project scaffolded before the panel existed had no way to obtain it. The framework-owned front-end files are now a resync group:
./pramnos project:resync --debug-panel --all # add the panel
./pramnos project:resync --debug-panel # refresh an existing one
./pramnos project:resync --debug-panel --dry-run # preview
The destination is read from app_style/spa_stack in app/app.php rather
than guessed, because a panel written to frontend/ in a build-less project is
a file no page loads — indistinguishable, from the browser, from a panel that
does not work. An MVC project has no front-end sources, so the group yields
nothing there. With no scope flag the group syncs along with the others.
lib/api.js is reported, not rewritten. If the client never calls
recordDebug, the command says so and prints the two lines to add. That file is
the project's own and people edit it; regenerating it from the stub to fix one
import would discard those edits. A panel nothing feeds is silent in exactly the
way a missing panel is, which is the whole reason this entry exists — so the
silence is at least named.
Also¶
project:resync loaded app/app.php with a bare require at its single call
site. A second caller would have received true rather than the configuration
array, since require of an already-included file returns a boolean. The
config is now loaded once per run and shared.
Documentation¶
Debugging gains an "In a single-page application" section: why the shell cannot carry the HTML toolbar, where the panel lives per stack, how it is wired, and the resync commands.
Tests¶
ProjectResyncTest — the panel refreshed in place with the app name
substituted, created under --all, skipped without it, resolved to
www/assets/js/ for the build-less stack, a no-op for an MVC project, scoped
away from the other groups, included in a default run, honoured under
--dry-run, and the wiring warning in all three states (unwired, wired, no
client at all). InitSpaScaffoldingTest — the generated CLAUDE.md and
README.md name the panel, mark it framework-owned, forbid a rewrite, carry the
recovery command, and use each stack's real path.
The docs now say when you would need them¶
docs/ ships inside the composer package, so it already sits in every project's
vendor/. That makes it the documentation an assistant working in that project
reads from — and it is version-correct for free, because the vendored docs match
the vendored code. What was missing was any way to choose a page without
reading all of them.
use_cases: on every indexable page¶
Every guide now opens with frontmatter describing the task a reader has in hand:
---
use_cases:
- Writing a controller that reads or writes the database
- Converting existing raw SQL to query-builder calls
- Diagnosing a query that returns nothing or the wrong rows
---
Phrased as the question, not as a description of the page: "Adding a column to an existing table" is findable, "Schema builder reference" is not. 36 pages carry it.
This is the field a retrieval tool selects on before fetching anything —
the same shape the Svelte MCP server's list-sections exposes as use_cases,
and for the same reason: the title of a page is a poor predictor of whether it
answers your question.
The guide is no longer optional¶
Rule 1 required a dated changelog post with every change. It now requires both the post and the guide page that owns the topic, brought to current state.
The posts are a stream of deltas — 57 of them. Somebody asking how a feature works has to land on one page describing it as it is, not reconstruct it from three dated entries. That is not hypothetical: the SPA debug panel was documented only in two changelog posts, and an assistant working in a real project concluded from that the framework shipped no panel and wrote a second one beside the working one. The guide section that would have prevented it was written the same day this rule was.
Posts stay deltas. Guides describe current state. Neither substitutes for the other.
Both invariants are now tested¶
tests/Unit/Docs/DocsRetrievabilityTest.php asserts that every indexable page
declares at least one non-trivial use case, that every one is reachable from
mkdocs.yml nav, that every nav entry resolves to a file that exists, and that
each exemption still describes a page that is there.
The nav check earned itself immediately: four pages were outside the nav —
Application Styles, Queues, Redis and the frozen v1.2 reference. MkDocs reports
that as INFO, not a warning, so the build passed and nobody noticed. The first
three are now in the nav; the frozen reference is an enumerated exemption, along
with releases.md (a release index is history, not guidance).
Exemptions live in the test and are never inferred, so a new page cannot become silently exempt — the failure mode this whole change exists to close.
Scaffolded projects are told where the corpus is¶
A corpus nobody knows about is not a corpus. Every project scaffolded from now on
gets a section in its CLAUDE.md naming the directory
(vendor/mrpc/pramnosframework/docs/), showing how to read the use_cases:
headers to pick a page, and stating the two conclusions that matter: the guides
are current state while the posts are history, and a capability documented in
those guides is not to be reimplemented in the project.
AI_INSTRUCTIONS.md gains the same obligation for work on the framework itself,
as core directive 6.
Also documented¶
project:resync was not in the Console guide at all — a command with three
scope flags, documented nowhere except the changelog. It now has a section
covering all three groups, the "only refresh what you have" default, and what
the merges preserve. Its SPA flag was also renamed from --spa to
--debug-panel: --spa read as "resync all the SPA sources" when it syncs one
framework-owned file.
Also on the roadmap¶
Two items recorded rather than done: the MCP tools that would read this corpus
(list_doc_sections / get_doc_section, plus a pramnos_check for the rules
that prose does not prevent), and the fact that index.md and
Getting_Started.md are the same document under two nav labels — which matters
more now that two identical pages compete for the same question.
The toolbar's hide button now hides the toolbar¶
Reported from two applications at once: the ✕ did nothing. Not "hid the wrong
thing" — nothing at all, in the server-rendered toolbar and in the SPA panel
alike.
What it was doing¶
Both handlers toggled the panel's inline display, not the bar's, starting
from '':
The stylesheet already hides the panel (#pdb-panels{…display:none}). So the
first click set display:none on something invisible, and the second set '',
handing it back to the stylesheet — which hid it again. Two clicks, no visible
effect, forever. Closing an open panel is what clicking its own tab already does,
so even working as written the button had no job.
What it does now¶
✕ hides the whole bar and leaves a small ⚙ handle in the bottom-right corner
to bring it back. Both bars behave identically:
- The page's
padding-bottomis released with the bar, so a hidden toolbar leaves no unexplained gap — and it is now set by the same code path that hides it, rather than by a separate inline script that could disagree. - The choice is remembered in
localStorageunderpramnos.debugbar.hidden, shared between the toolbar and the SPA panel. A bar that came back on the next page would be the same complaint, one step later. - Storage that throws — Safari's private mode, a blocked origin, where reading
localStoragefails on access rather than on the call — costs the memory, not the button. It still hides; it just cannot remember that it did. - The restore handle is rendered outside
#pramnos-debugbar. Nested inside, hiding the bar would hide the only way back.
An existing SPA project picks the fix up with
project:resync --debug-panel.
Tests¶
The behaviour is driven in JavaScript, because a PHP test can assert that a button is emitted but not that clicking it does anything — which is exactly the gap that let this ship.
New tests/js/debugbar-hide.test.js extracts DebugBar::js() from PHP, runs it
against a DOM stub and clicks the buttons: the bar hides and the padding is
freed, the handle restores both, a bar hidden on an earlier page loads hidden,
and throwing storage does not take the toolbar with it. spa-debug-panel.test.js
gains the same four for the scaffolded panel. DebugBarTest pins the markup
half — the button, the handle, and the handle being outside the bar.
Also: ./testjs ran nothing in the container¶
Two faults that hid each other. The runner passed a container path glob for the
host shell to expand, so node received the pattern literally and reported
Could not find '/var/www/html/tests/js/*.test.js' — and the JS tests that
extract PHP output called ReflectionMethod::setAccessible(), which has had no
effect since PHP 8.1 and is deprecated in 8.5, so the container's CLI printed
that deprecation to stdout, in front of the script, where it was parsed as
JavaScript.
Fixed both: the glob is expanded by the container's shell, and the no-op call is
gone. ./testjs now runs all 74 JS tests inside the container, which is where it
was always meant to run them.
spa:dev / spa:build, and a service container that actually exists¶
Two things reported from a real project on the same afternoon: the front-end
workflow had no place in the CLI, and mcp:serve died before printing anything.
Added: the front end has CLI commands¶
Building and serving a SPA were ./dockernpm run build and ./dockernpm run
dev — correct, and absent from pramnos list, so they had to be remembered
from the docs rather than found in the CLI everything else in the project uses.
php bin/pramnos spa:dev # dev server with HMR (alias: spa:serve)
php bin/pramnos spa:build # production build → www/assets/spa/
php bin/pramnos spa:build --watch # rebuild on change, no dev server
Both wrap npm; ./dockernpm run <script> still covers everything else in
package.json. The init summary now points at these instead.
Where npm comes from is worked out rather than assumed. The scaffolded CLI
wrapper is docker-compose exec -u www-data app php <cli>.php, so the console is
normally already inside the container — and the first version of this command
delegated to ./dockernpm from there, which is Docker asking to exec into
Docker. It failed with "The app container is not running", printed from inside
the container it was talking about. Inside, npm now runs directly (with
HOME=/tmp, because www-data's home is not writable and npm wants a cache); from
the host, ./dockernpm is used, so build output is never left owned by root.
Missing node_modules is installed rather than reported: npm's own error names a
missing binary several lines down and says nothing about what to do.
Both commands refuse where they cannot apply, with the reason that fits — an MVC
project has no front end, and the build-less stack serves www/assets/js/ exactly
as written, so there is nothing to build and nothing for a dev server to supply.
spa:dev prints the application's URL, because the one unguessable thing about
this workflow is that the Vite port serves no HTML.
Fixed: $app->container was always null¶
container is a magic property on Application, and nothing ever assigned
it. Every call site read null:
mcp:servecould not start at all — the reported crash;McpServiceProvider::register()andWebhookServiceProvider::register()would have killedinit()outright, so enabling either feature broke the application;Broadcastable::resolveBroadcastingManager()threw into its owntry/catch, which quietly reported the wiring bug as "broadcasting is not configured";BroadcastServecalled$app->getContainer(), a method that did not exist, and its own catch swallowed that too.
Added Application::getContainer(), which creates the container on first use
and stores it back on $this->container, so existing $app->container->… code
keeps working. Lazily rather than in init(), because the console reaches the
application without initialising it — which is precisely the path that crashed.
An application that assigns its own container keeps it.
Two test doubles had been quietly papering over this: one hand-rolled a
container-shaped object because there was no getContainer() to satisfy, and
another overrode the missing method. Both now use the real Container, which is
what let this bug live in the first place.
Added: mcp:serve says what it is doing¶
It printed nothing and blocked on STDIN, which is indistinguishable from a hang. It now announces the server, its tools and its resources — on STDERR. STDOUT is the JSON-RPC channel: a greeting there is not cosmetic damage, the client fails on the first line and reports the server as broken. MCP clients route stderr to a log and ignore it, so it is the only place a human-facing word can go.
MCP server ready on stdio.
5 tools: list-tables, query-schema, migration-status, model-inspect, route-list
3 resources: Claude Code guide, Project README, App config
Waiting for JSON-RPC on stdin — this is normally launched by an
MCP client (see .mcp.json), not run by hand. Ctrl-C to stop.
Tests¶
SpaCommandsTest — 16 cases over the three decisions that produced wrong
answers in practice: which npm to use (inside the container, on the host, neither
available, a container built without node), whether the project can be built at
all (MVC, build-less, no project), and what happens around dependencies (install
first, abort on a failed install, propagate the build's exit code) — plus
--watch, and the URL hint in its four states. McpServeTest gains the
uninitialised-application case that reproduces the crash, and asserts the banner
reaches stderr while stdout stays empty. ApplicationTest covers getContainer()
creating one, caching it, and never replacing an existing one.
One toolbar, delivered two ways — and the SPA panel gains every tab¶
The toolbar was drawn twice: ~970 lines of PHP for server-rendered pages, and a
separate scaffolded module for SPA projects, both turning the same collector data
into the same tables. They drifted, and then the ✕ that hid nothing had to be
fixed in both.
One source¶
Pramnos\Debug\DebugBarAsset now owns the renderer, in two shapes because the
two contexts load code differently — not because the code differs:
source()— an IIFE publishingwindow.__pramnosDebugBar, for inlining into a page, where an ESMexportwould be a syntax error that takes the whole script with it.spaModule($appName)— the same source with anexport function record()appended that forwards to that instance rather than reimplementing it.
init and project:resync --debug-panel both write lib/debug.js from it, so
scaffolding/templates/spa-debug-panel.js.stub is gone: it was the second copy.
The SPA panel draws every collector¶
The payload was never the limitation. ApiDebugPayload::build() has always
attached every registered collector — session, logs, views, models,
migrations, exceptions, route — and the SPA panel drew requests and statements.
It now draws all of them, because it is the same renderer that draws them on a
server-rendered page.
The model is a list of entries, one per request with the payload it produced.
Selecting a request in the requests tab switches every other tab to it. On a
server-rendered page entry #0 is the page itself; in a SPA the first entry arrives
with the first API call. A collector the payload does not carry gets no tab,
rather than an empty one that reads as "nothing happened", and a collector that
threw says so — the payload carries {error: …} in its place.
Deliberate details¶
The data island is a <div hidden>, not a <script type="application/json">.
A data block inside a script element is treated differently by different browsers
under a strict Content-Security-Policy, and this has to work on every install.
No transport wrapping in a SPA. boot() wraps fetch/XMLHttpRequest only
when the data island is present. A SPA's API client calls record() itself, and
wrapping fetch as well would record every one of those calls twice.
Still to come¶
This landed the SPA half: the module is generated from the single source and the
tabs are there. DebugBar::render() still builds its own HTML — swapping it for
the data island plus this script is the next step, and the point at which the
~970 lines of PHP renderers are deleted rather than merely bypassed.
Tests¶
DebugBarAssetTest — the classic shape carries no ESM syntax, the module's export
forwards rather than duplicates, the generated header names the application and
says not to edit it, and an application name with markup in it is escaped rather
than allowed to corrupt the bar. tests/js/spa-debug-panel.test.js is rewritten
against the shipped module: production silence, every collector becoming a tab,
each tab drawing its own data, a failed collector reported, newest-first request
order, a 204 still recorded, hide/restore, and storage that throws costing the
memory rather than the button.
A name for every request, and the log lines it wrote¶
The toolbar's data travels with the response it describes. That is what makes it
work with nothing to correlate and nothing to clean up — and it is also its
ceiling, which a deliberately broken endpoint made obvious: a request that dies
has no response to carry anything. An error page is not a JSON object, so there
is no _debug key, and the header that still gets through has room for a count
but never for a message. The toolbar could say something was raised here and
nothing more.
Added¶
Pramnos\Debug\RequestId — a 16-character name for the current request,
issued only while the toolbar is active. Logger writes it on every line, the
response announces it in X-Request-Id, and it rides in the payload and in the
X-Pramnos-Debug summary.
Pramnos\Debug\RequestLog — reads those lines back out of the log directory:
the tail of each *.log, matched on the id, capped. Nothing here takes a path
from a caller.
GET /devpanel/logs?request=<id> — the endpoint, replying JSON and
no-store. It accepts the same signed debug:token grant that opened the
toolbar, as well as the DevPanel's own admin check: the developer holding a token
on a live server is usually not an admin user, and requiring both would have made
the feature useless exactly where it is needed.
In the toolbar, the Logs and Exceptions tabs offer Ask the server for this
request's log lines whenever a request has an id, and show what comes back under
"From the server's log". The request goes through the unwrapped fetch, so
looking does not add another row to the list being looked at.
By id, never by time¶
"Everything logged between the request and its response" is the obvious implementation and the wrong one. On a live server the toolbar is open for one browser, by grant, while every other visitor is logging into the same seconds — a time window hands their lines over too. That is a data leak wearing a debugging hat, so a line qualifies only by carrying the id.
An incoming X-Request-Id is also deliberately ignored. Honouring one is the
conventional thing to do, but here the id decides which log lines are handed
back, and accepting a caller-supplied one means accepting a caller who chooses to
be indistinguishable from somebody else's request.
Nothing changes in production¶
Ids are issued only when DebugBarServiceProvider boots, which only happens in
debug mode. With none issued, RequestId::activeId() is null, Logger adds
nothing, and every line keeps the exact shape it has always had.
Changed¶
- Picking a request no longer changes tab. It jumped to SQL on every pick, so comparing one tab across two requests meant navigating back to it each time. The open tab stays open.
- A request can be released: click the selected row again, or the
✕on the chip naming it. A selection with no way out is a mode, and a mode nobody can leave is where "the toolbar is showing the wrong numbers" comes from. - A failed request is red across the whole row, not just in the status cell —
including a
200that raised something, which is the case nobody would go looking for. A red cell in the narrowest column of six is a signal placed where nobody is looking.
The server-rendered toolbar now uses the one renderer too¶
DebugBarAsset became the single toolbar source, and the SPA panel started
drawing every collector. The other half stayed where it was: DebugBar::render()
still built its own HTML, CSS and inline JavaScript, so a server-rendered page
had neither the new tabs nor its own request in the list it was showing.
What changed¶
DebugBar::render() emits two things and no markup of its own:
<div id="pramnos-debug-data" hidden>{"time":61.2,"queries":{…},"request_method":"GET",…}</div>
<script nonce="…">/* DebugBarAsset::source() */</script>
The island is ApiDebugPayload::build() — the same payload an API response
carries — plus request_method, request_path and status_code, so the page's
own request can sit in the requests list beside the calls that follow it, marked
(page). The renderer boots from it and then wraps fetch and
XMLHttpRequest, as it always did for a page that has one.
A <div hidden> rather than a <script type="application/json">: a data island
inside a script element is a grey area under a strict CSP, and this has to work
on every install. The JSON is hex-escaped (JSON_HEX_TAG and friends), so there
is nothing in a query or a log message that a parser could read as the end of the
element.
Added¶
- Server-rendered pages get every tab the payload carries — Views, Models, Migrations, Exceptions and the rest — and a requests list that includes the page itself. Selecting a request switches every other tab to it.
- The bar is branded with the application's name (
titlesetting, or theTITLEconstant), the way a scaffolded SPA panel already was.
Fixed¶
- The tabs no longer follow the newest request on a server-rendered page.
Reported from a real application:
/usersis a datatable, which fetches its rows the moment it renders, and the toolbar moved onto that JSON call — so a page that had just rendered a template showedViews 0. The page's own request is now selected until the reader picks another, and a request they picked is not replaced by the ones that follow. A SPA, which has no page request, still follows the newest call. - Logs and Exceptions aggregate across requests until one is picked, with a column naming the request each line came from. Both are streams: an entry happens at a moment, and which request produced it is a detail of it — an error logged by a background call was invisible while another request was in view. No other tab aggregates; Route and Session describe one request, and a combined SQL table would lose which call ran what.
- An XHR never read the debug headers. The
fetchpath fell back toX-Pramnos-Debugwhen a response body could not carry a payload; theXMLHttpRequestpath did not — and every datatable is XHR. A call that returned an error page, a 204 or an HTML fragment reported—for server time and query count, while the identical call throughfetchreported both. Both now go through oneheaderPayload(), withServer-Timingas a second fallback. X-Pramnos-Debugnever counted exceptions.summary()looked forexceptions/errorskeys thatExceptionsCollectorhas never emitted (it reportscountanditems), so the one thing a dead request could still have said about itself was always absent. The test that covered this passed because it built a fake collector with an invented shape; it now uses the real one.- Picking a request that carried nothing emptied the bar. No payload meant no tabs, one line of grey text, and nothing to do — at the exact moment somebody clicked a red row because it had gone wrong. The stream tabs now stay reachable there, and the panel says which of the possible reasons applies.
-
An exception is now visible without being looked for: the tab turns red, carries a
⚠, and counts what any request raised — including a request whose response could only carry a count with no messages, where the row says so and points at the error log. -
X-Pramnos-Debugis written as JSON byApiDebugPayload::summary(), and the renderer read it ask=v;k=v. Every bodiless response — a 204 from a save, a redirect — lost its server time and query count. JSON is now tried first, with the old reading kept as a fallback for a gateway that rewrites the value. - The
<style>the renderer injects carries the script tag's CSP nonce, read fromdocument.currentScript. Without it a strictstyle-srcleft the toolbar as an unreadable column of text on exactly the installs that configure one.
Removed¶
Roughly 500 lines: css(), js(), ajaxJs(), renderPanel(),
formatTabLabel(), renderInfoStrip() and the nine render*() methods. Nothing
called them once the island existed, and bypassed dead code is how two renderers
happen a second time.
Tests¶
tests/js/debugbar-ajax.test.js and debugbar-hide.test.js used to extract the
deleted ajaxJs() and js() by reflection. They now run DebugBarAsset::source()
in a VM against a DOM stub, covering what is only true of this delivery: booting
from the island, wrapping the transports, returning the application's own response
untouched with its body unread, and the nonce reaching the stylesheet.
DebugBarTest's assertions on generated HTML became assertions on the island's
JSON. That is the honest boundary now — PHP collects, the island carries, and the
JavaScript tests drive the drawing for real.
One behaviour changed with it: a bar with only a MemoryCollector used to render
nothing, because memory had no tab of its own. Which collectors deserve a tab is
the renderer's decision now, so the data always travels.
Settings read every key one at a time on PostgreSQL, and said nothing¶
Settings::loadAllSettings() reads every setting in one cached query, so the
rest of the request can answer lookups from memory instead of going back to the
database for each one. On PostgreSQL it had never worked.
The statement was hand-built with MySQL backticks and passed straight to
query(), without going through prepareQuery() — so nothing translated it:
PostgreSQL answers that with syntax error at or near ",". The catch
(\Throwable) around the call — there so a fresh install without a settings
table can still boot and run its migrations — turned the error into silence.
So nothing appeared to break. The bulk read did nothing, every lookup fell back to a query of its own (the N round-trips the bulk read exists to replace), and each request wrote another line into the error log. It ran that way in a real application until the log was read for an unrelated reason.
Fixed¶
loadAllSettings(), the per-key read in getSetting(), and all three
statements in setSetting() are now query-builder calls. The builder is the
only layer that knows the dialect: it quotes identifiers per driver, resolves
the table prefix and binds values instead of interpolating them.
$result = self::$database->queryBuilder()
->table('#PREFIX#settings')
->select(['setting', 'value'])
->get(true, self::CACHE_TTL, 'settings');
Tests¶
tests/Integration/Application/SettingsPostgreSQLTest.php — against a live
PostgreSQL, because the bug was in the dialect and only a dialect can report it.
The unit tests exercise the in-memory store, where no SQL is generated at all,
and could never have caught this. Besides the round trips, one test asserts that
no statement the settings path issues contains a backtick — the same shape of
mistake, next time, fails a test instead of a request.
Documentation¶
The Query Builder guide now says that #PREFIX# is written by the caller: the
builder substitutes the token but never adds a prefix on its own, so omitting it
produces a query against a name that exists only where the prefix is empty —
working on a developer's machine and finding nothing on an installation that has
one.
SSE events published during a reconnect are no longer lost¶
Three true statements that add up to a false one. EventSource reconnects by
itself, so the client side needs no code. maxRuntime: 95 ends the stream
deliberately, to stay under an edge timeout. The Redis backplane is pub/sub.
Together: every client reconnects on a schedule, and everything published in the window between the close and the new subscription is delivered to nobody. Nothing errors. Two applications lost events this way before anyone noticed.
Added¶
id:frames.SseWriter::event()writes the backplane's event id, andstream()attaches it automatically — an application callback that just calls$sse->event(...)produces one without knowing it exists. Without an id frame the browser has nothing to remember andLast-Event-IDnever arrives, so the spec's own answer to this could not even begin.Last-Event-IDhandling.stream()resumes from the header the browser sends on reconnect, from?since=for clients that keep their own cursor, or from an explicitsinceIdwhen the endpoint knows better. A first connection starts live rather than replaying whatever history exists.SubscriptionOptions::$sinceId— the driver-facing half. A string, because ids belong to the backplane: a row id in a table, a1699…-0entry id in a Redis stream.RedisStreamDriver— the same envelope on a Redis Stream instead of pub/sub.XADDwithMAXLEN ~caps history per channel (default 1000 entries),XREADblocks for what comes next, and a cursor replays what was missed. Separate fromRedisDriverrather than a flag on it: they have different storage, different memory behaviour, and "how much history do I keep?" is not a question pub/sub can be asked.
Fixed¶
DatabaseDriverlost the same window although its events were durable. They were in the table the whole time; the loop started atlatestId()and stepped over them because nothing told it where to begin. It now honourssinceId, which was close to a one-line fix once the option existed.- The event id is passed to consumers as a fourth argument. Callbacks written before this take three parameters and are unaffected.
Unchanged on purpose¶
RedisDriver still uses pub/sub and is still the default. It is right for a
WebSocket daemon that stays connected, and switching a deployment to streams is
a decision about retention — not something to apply behind an operator's back.
What replay does not solve¶
A capped stream covers a reconnect, not a laptop closed for an hour. The guide now says so, and says what to do instead: a snapshot on connect, with stable ids so the client can discard duplicates. An event published during the snapshot query arrives both ways, and that is the safe direction to err in.