Skip to content

4 October 2026

6 changes:

  • Change-log events can belong to a tenant, and be read back by it
  • Permissions::allow() and deny() with a list of privileges grant to the right subject
  • A failed migration no longer marks the schema as up to date
  • The scaffolded SPA's navigation fits a phone
  • Permissions::allowMany(): an organisation's roles in one write
  • The PostgreSQL test database commits without waiting for the disk

Change-log events can belong to a tenant, and be read back by it

A multi-tenant application could not ask the change log for one organisation's events. The rows had entity, itemid and userid and nothing else. The workaround was a key inside details, filtered with details->>'organization_id', which on a compressed hypertable decompresses every chunk in the window.

Added

  • changelog_events.scope: a nullable string column, added by the migration 2026_10_04_000001_add_scope_to_changelog_events. On PostgreSQL its index on (scope, created_at) is partial (WHERE scope IS NOT NULL), so an application with no tenants pays nothing for it. MySQL, which has no partial indexes, gets no index. The column is not in the compression segmentby, because changing segmentby on a live hypertable means decompressing every chunk first. A test adds the column to a table whose chunks are already compressed.
  • Why a column, and not a separate table or the organisation as the row's entity: the record (entity), the person who acted (userid) and the tenant are three different things, and only the tenant was missing. It is copied onto the event and not joined from the record, so post.deleted stays in the organisation's log and a post that moves to another organisation keeps its old history.
  • Model::changeScope(): ?string, which returns null by default. logEvent() writes the scope only when the model returns one, so an unscoped application's rows are unchanged.
  • ChangelogReader::eventsInScope($scope, $entity = null, $limit = 50), which returns one tenant's events, newest first.

Safety

A model that overrides changeScope() can be deployed before migrate runs. The events transform drops the scope when the column is not there yet, so the event is still written, and eventsInScope() returns [] instead of failing.

Documentation

Permissions::allow() and deny() with a list of privileges grant to the right subject

When $privilege was an array, allow() and deny() called themselves once for each privilege and left $resourceElement out of the call. So every argument after it moved one place left: - the resource type was stored as the element; - the subject type was stored as the resource type; - the subject type fell back to user.

As a result, allow('7', 'posts', ['read', 'update'], '', 'module', 'group') granted both privileges to the user with id 7. Even the plainest call, allow($userId, 'posts', ['read', 'update']), was stored on element module of resource type user, where isAllowed() never looks.

Fixed

  • Both list forms pass all six arguments through. Characterization tests on MySQL and PostgreSQL cover a group grant, which must not reach the user with the same id, and a denial on one element. Both tests fail without the fix.

Upgrading

Grants written with the list form are already wrong in the database. The Upgrade Guide has the query that finds them. Delete them, then run the code that wrote them again.

Documentation

A failed migration no longer marks the schema as up to date

The automatic migration run ignored what MigrationRunner::run() returned and recorded its fingerprint anyway. When one migration failed, the migrations that depend on it did not run, and every later run found the fingerprint and reported the schema as up to date. One project dropped its test database. The next run built 47 of its 252 tables, the suite failed almost everywhere, and no message said why.

Fixed

  • An explicit run fails loudly. runPendingMigrations(), which the test bootstrap and deploy scripts use, throws a RuntimeException that names each failed migration with the first line of its error, and records no fingerprint. The next run tries again. TestEnvironment already prints that exception to STDERR.
  • A request does not throw. The automatic path in a request logs the same message instead, and keeps the fingerprint so a failing migration is not attempted on every request. pramnos migrate retries it.

Integration tests on MySQL and PostgreSQL cover a migration that fails until its cause is fixed, and a request that keeps serving. Both fail without the fix.

Documentation

The scaffolded SPA's navigation fits a phone

The header's row of screen buttons did not wrap. With five or six screens it pushed a phone's page wider than the screen, and everything scrolled sideways. One project, installed as an app on a phone, was reported like that.

Fixed

  • Two forms of one list. The row shows from lg up (hidden lg:flex). On a phone the same screens are a daisyUI dock along the bottom, with an icon and a label each. Both are rendered from one snippet, so they cannot drift apart. A registry entry's optional icon replaces the default square. With more than five screens the dock keeps only the icons, and the labels stay for screen readers.
  • The page around it fits too. The page gets pb-16 lg:pb-0, so the dock does not cover the footer. <main> is p-4 sm:p-6. The username next to Sign out is hidden below sm. The Omnibox input is w-28 sm:w-48, and its results are capped at the screen's width.
  • The scaffolded App.test.js checks that the dock carries the same screens as the row, and that it switches to icons only past five. Verified by running the generated project's Vitest suite: 100 tests.

For an existing project

App.svelte belongs to the project and is not rewritten. To adopt the change, copy the screenLink / screenLinks snippets, the two <nav> elements and the padding classes from scaffolding/templates/spa-svelte-app.svelte.stub.

Documentation

Permissions::allowMany(): an organisation's roles in one write

Each allow() on the authserver.permissions store did all of this, once per privilege: - deleted the old row and inserted the new one; - flushed the permission cache; - queued permissions_changed, whose holder lookup ran even for a role created a moment earlier that nobody holds yet.

One application gives every new organisation five roles and 62 privileges. In one of its test classes, that setup was 58% of the time: 1,674 allow() calls for 27 tests.

Added

  • Permissions::allowMany($subject, [resource => [privileges]], $resourceType, $subjectType):
  • writes the rows in one transaction, so either every grant lands or none does, and a failure is raised;
  • flushes the cache once;
  • queues permissions_changed once for the subject.

On the legacy table, which has no webhook, it grants one privilege at a time. - allow() with a list of privileges and no element now goes through allowMany(). - The authserver store's integration tests now also run on MySQL, through PermissionsAuthserverStoreMySQLTest. Until now that class ran on PostgreSQL only. New tests cover every grant being readable, a deny being replaced, a failing grant rolling back the others, and a subject type the store cannot represent.

The PostgreSQL test database commits without waiting for the disk

Added

  • TestEnvironment sets synchronous_commit = off on the PostgreSQL test database, on every run. Measured in one project: a test class from 47 s to 28.5 s, and the whole suite from 12:00 to 6:45. The setting is per database, and an integration test checks that a database beside it still waits. Without ownership of the database, the step is skipped.

Fixed

  • Test settings with 'type' => 'timescaledb' set up the test database as PostgreSQL. They were sent to the MySQL setup.

Documentation