Skip to content

12 August 2026

14 changes:

  • Late writes into compressed chunks no longer have to be lost
  • print replaces the pdf document 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.

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 @page rule built from paperSize, orientation and margin;
  • 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-print hidden, .page-break honoured, and a screen preview so the author sees roughly what will come out;
  • window.print() on load — 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():

$db->getServerVersion(); // "10.11.6-MariaDB-1:10.11.6+maria~ubu2204"
$db->isMariaDB();        // true

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->type cannot 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:

  • _debug in 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 inside Application\Api. That is what makes it cover datatable endpoints and controllers that echo their own JSON.
  • X-Pramnos-Debug and Server-Timing headers, for everything with nowhere to put a key: a 204, 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 granteddebug: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 ajax tab that wraps fetch and XMLHttpRequest; 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 through DebugBarMiddleware. 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 '':

d.style.display = d.style.display === 'none' ? '' : 'none';

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-bottom is 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 localStorage under pramnos.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 localStorage fails 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

Fatal error: Uncaught Error: Call to a member function has() on null
  in McpServe.php:71

container is a magic property on Application, and nothing ever assigned it. Every call site read null:

  • mcp:serve could not start at all — the reported crash;
  • McpServiceProvider::register() and WebhookServiceProvider::register() would have killed init() outright, so enabling either feature broke the application;
  • Broadcastable::resolveBroadcastingManager() threw into its own try/catch, which quietly reported the wiring bug as "broadcasting is not configured";
  • BroadcastServe called $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 publishing window.__pramnosDebugBar, for inlining into a page, where an ESM export would be a syntax error that takes the whole script with it.
  • spaModule($appName) — the same source with an export 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 200 that 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 (title setting, or the TITLE constant), 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: /users is 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 showed Views 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 fetch path fell back to X-Pramnos-Debug when a response body could not carry a payload; the XMLHttpRequest path 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 through fetch reported both. Both now go through one headerPayload(), with Server-Timing as a second fallback.
  • X-Pramnos-Debug never counted exceptions. summary() looked for exceptions / errors keys that ExceptionsCollector has never emitted (it reports count and items), 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-Debug is written as JSON by ApiDebugPayload::summary(), and the renderer read it as k=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 from document.currentScript. Without it a strict style-src left 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:

select `setting`, `value` from `settings`

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, and stream() 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 and Last-Event-ID never arrives, so the spec's own answer to this could not even begin.
  • Last-Event-ID handling. stream() resumes from the header the browser sends on reconnect, from ?since= for clients that keep their own cursor, or from an explicit sinceId when 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, a 1699…-0 entry id in a Redis stream.
  • RedisStreamDriver — the same envelope on a Redis Stream instead of pub/sub. XADD with MAXLEN ~ caps history per channel (default 1000 entries), XREAD blocks for what comes next, and a cursor replays what was missed. Separate from RedisDriver rather 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

  • DatabaseDriver lost the same window although its events were durable. They were in the table the whole time; the loop started at latestId() and stepped over them because nothing told it where to begin. It now honours sinceId, 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.