Files
php-qml/CHANGELOG.md

121 lines
22 KiB
Markdown
Raw Normal View History

# Changelog
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Pre-v1.0.0, minor bumps may break public API.
## [Unreleased]
qml: defer ReactiveListModel/ReactiveObject initial fetch to componentComplete() setBaseUrl() and setSource() used to fire refresh() inline as soon as both `baseUrl` and `source` were populated — but setToken() never triggered a refresh. QML evaluates literal property assignments before bindings to other objects' properties, so a model declared with literal `source` plus bindings to `BackendConnection.url` / `BackendConnection.token` (the exact shape of make:bridge:window's output) could fire its GET *before* the `token` binding had landed. The unauthenticated request hit Symfony's SessionAuthenticator, came back 401, and the model parked at `ready === false` with an empty list. Mercure subscribed anonymously (the model explicitly clears the SSE client's bearer), so subsequent server-side mutations propagated fine — masking the initial-fetch failure as "list is empty until something changes". Hit by the second window in examples/todo. Both classes now implement QQmlParserStatus and trigger the initial refresh from componentComplete(), where every binding (literal *and* singleton-derived) is guaranteed to have landed. After completion, individual setter changes still trigger refresh inline — so token rotation / URL reassignment after first load behave unchanged. Regression test under framework/qml/tests/tst_reactive_list_model.qml using the v0.2.0 qmltestrunner harness. Adds a TestHttpServer helper that mimics SessionAuthenticator's 401-on-no-bearer behaviour so the regression is observable; verified the test fails against the unfixed production code (`Actual: ""` vs `Expected: "Bearer testtoken"` on the captured Authorization header). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:19:56 +02:00
### Fixed
qml: defer ReactiveListModel/ReactiveObject initial fetch to componentComplete() setBaseUrl() and setSource() used to fire refresh() inline as soon as both `baseUrl` and `source` were populated — but setToken() never triggered a refresh. QML evaluates literal property assignments before bindings to other objects' properties, so a model declared with literal `source` plus bindings to `BackendConnection.url` / `BackendConnection.token` (the exact shape of make:bridge:window's output) could fire its GET *before* the `token` binding had landed. The unauthenticated request hit Symfony's SessionAuthenticator, came back 401, and the model parked at `ready === false` with an empty list. Mercure subscribed anonymously (the model explicitly clears the SSE client's bearer), so subsequent server-side mutations propagated fine — masking the initial-fetch failure as "list is empty until something changes". Hit by the second window in examples/todo. Both classes now implement QQmlParserStatus and trigger the initial refresh from componentComplete(), where every binding (literal *and* singleton-derived) is guaranteed to have landed. After completion, individual setter changes still trigger refresh inline — so token rotation / URL reassignment after first load behave unchanged. Regression test under framework/qml/tests/tst_reactive_list_model.qml using the v0.2.0 qmltestrunner harness. Adds a TestHttpServer helper that mimics SessionAuthenticator's 401-on-no-bearer behaviour so the regression is observable; verified the test fails against the unfixed production code (`Actual: ""` vs `Expected: "Bearer testtoken"` on the captured Authorization header). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:19:56 +02:00
- **`ReactiveListModel` / `ReactiveObject`: defer the initial fetch to `componentComplete()`.** Both classes now implement `QQmlParserStatus` and only fire the first `refresh()` from `componentComplete()` instead of inline from `setBaseUrl()` / `setSource()`. The pre-fix behaviour fired the GET as soon as the second of {`baseUrl`, `source`} was set — and because QML evaluates literal property assignments before bindings to other objects' properties, a model declared with literal `source` + bindings to `BackendConnection.url` / `BackendConnection.token` could fire its GET *before* the `token` binding had landed. The unauthenticated request hit Symfony's `SessionAuthenticator`, returned 401, and the model parked at `ready === false` with an empty list. Mercure subscribed anonymously (the model explicitly sets the SSE client's bearer to `""`), so subsequent server-side mutations propagated fine — masking the initial-fetch failure as "list is empty until something changes". Most visible when opening a second window via `make:bridge:window` after the first window's bindings had populated `BackendConnection`. After componentComplete, individual setter changes still trigger refresh inline as before, so token rotation / URL changes after first load behave unchanged. Regression test under [`framework/qml/tests/tst_reactive_list_model.qml`](framework/qml/tests/tst_reactive_list_model.qml) using the v0.2.0 `qmltestrunner` harness; added a `TestHttpServer` helper in the test scope that mimics `SessionAuthenticator`'s 401-on-no-bearer behaviour so the regression is observable as `ready === false` + empty `lastAuthHeader`.
## [0.2.0] — 2026-05-03
First minor release. Pre-1.0 SemVer permits API breaks; the only one is `ModelPublisher::publishEntityChange()`'s `string $op``BridgeOp $op` signature change. Apps that only consumed the framework via the makers, the Doctrine listener, and the QML module are unaffected.
The release closes the post-v0.1.2 architecture audit (interfaces, typed enum, `BridgeBundleInfo`, maker DRY, DTO-shaped controller scaffold) and delivers the §12 *Operations* row from PLAN.md (port negotiation, pre-migration auto-backup, `bridge:export`, periodic auto-update check, native-dialogs boundary doc) plus two new makers (`make:bridge:event`, `make:bridge:read-model`) and `qmltestrunner`-based QML unit tests in CI.
v0.2.0 (1/N): public API surface — interfaces + BridgeOp enum Establishes the contract layer the rest of v0.2.0 builds on. Pre-1.0 SemVer break: ModelPublisher::publishEntityChange() now takes BridgeOp instead of a raw string. Interfaces (Symfony idiom: same namespace as concrete, like HubInterface next to Hub): - PublisherInterface — publish(string, array, bool) - ModelPublisherInterface — publishEntityChange(object, BridgeOp) - CorrelationContextInterface — set/get/clear App code should typehint these instead of the concretes so swappable implementations (offline-buffer publisher, multi-hub fan-out, request- stamp correlation) remain non-breaking. Concrete classes implement them unchanged; autowire continues to inject the implementations transparently. BridgeOp: PHP 8.1 string-backed enum with cases Upsert / Delete / Replace / Event matching PLAN.md §4's envelope `op` wire format. Internal call sites updated; tests use the cases directly. Switched typehints: - ModelPublisher ctor: PublisherInterface + CorrelationContextInterface - DoctrineBridgeListener ctor: ModelPublisherInterface - HealthController ctor: PublisherInterface (still emits `Publisher` as bundle canary value — `::class` resolves to the concrete class name regardless of typehint, so bundled-supervisor.sh's grep stays green) - skeleton PingController ctor: PublisherInterface (canonical app pattern — example/todo has no Publisher consumer to update) Drive-by: removed deprecated setAccessible(true) call in ModelPublisher::extractId — PHP 8.1+ allows reflection without it. PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot all pass; dev container compiles in the example app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:50:01 +02:00
### Added
v0.2.0 (1/N): public API surface — interfaces + BridgeOp enum Establishes the contract layer the rest of v0.2.0 builds on. Pre-1.0 SemVer break: ModelPublisher::publishEntityChange() now takes BridgeOp instead of a raw string. Interfaces (Symfony idiom: same namespace as concrete, like HubInterface next to Hub): - PublisherInterface — publish(string, array, bool) - ModelPublisherInterface — publishEntityChange(object, BridgeOp) - CorrelationContextInterface — set/get/clear App code should typehint these instead of the concretes so swappable implementations (offline-buffer publisher, multi-hub fan-out, request- stamp correlation) remain non-breaking. Concrete classes implement them unchanged; autowire continues to inject the implementations transparently. BridgeOp: PHP 8.1 string-backed enum with cases Upsert / Delete / Replace / Event matching PLAN.md §4's envelope `op` wire format. Internal call sites updated; tests use the cases directly. Switched typehints: - ModelPublisher ctor: PublisherInterface + CorrelationContextInterface - DoctrineBridgeListener ctor: ModelPublisherInterface - HealthController ctor: PublisherInterface (still emits `Publisher` as bundle canary value — `::class` resolves to the concrete class name regardless of typehint, so bundled-supervisor.sh's grep stays green) - skeleton PingController ctor: PublisherInterface (canonical app pattern — example/todo has no Publisher consumer to update) Drive-by: removed deprecated setAccessible(true) call in ModelPublisher::extractId — PHP 8.1+ allows reflection without it. PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot all pass; dev container compiles in the example app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:50:01 +02:00
- **`PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`.** The bridge's three public services now ship as interfaces (same namespace as concrete, mirroring upstream `HubInterface`/`Hub`). App controllers and listeners should typehint these instead of the concrete classes so swappable implementations (offline-buffer publisher, request-stamp correlation context, etc.) remain non-breaking. Existing `Publisher` / `ModelPublisher` / `CorrelationContext` classes implement the new interfaces unchanged.
- **`BridgeOp` enum.** PHP 8.1 string-backed enum (`Upsert` / `Delete` / `Replace` / `Event`) replacing the raw `'upsert'`/`'delete'` strings previously passed between `DoctrineBridgeListener` and `ModelPublisher::publishEntityChange`. Values match PLAN.md §4's envelope `op` wire format. Typo'd ops are now caught at the type level instead of silently producing envelopes clients ignore.
- **`BridgeBundleInfo` value object** carrying the bundle's name + class FQCN. `HealthController` now constructor-injects this instead of `PublisherInterface` as the deep-load canary, so the readiness probe is no longer coupled to the publisher's contract. `/healthz` response gains a `name` field (`php-qml/bridge`); the `bundle` field now reports `PhpQml\Bridge\BridgeBundle` (was `PhpQml\Bridge\Publisher`).
- **`Maker\Support\NameInput`** — shared interactive name prompt. All three `make:bridge:*` makers (`resource`, `command`, `window`) re-implemented the same "prompt, trim, ucfirst, reject empty" closure inline; collapsed into one call site so empty-argument and validation behaviour stay in lockstep.
- **`Maker\Support\Naming`** — `camelTo($name, $separator)` helper. Replaces inline `preg_replace('/(?<!^)[A-Z]/', $sep.'$0', $name)` regex copies (BridgeResourceMaker emits `_`-joined route plurals, BridgeCommandMaker emits `-`-joined kebab slugs).
v0.2.0 (4/N): make:bridge:resource --with-dto + symfony/validator Closes the input-validation gap that was the audit's headline finding. The legacy generated controller's `if (isset($data['title']))…` body accepted any JSON: empty title slipped through, malformed JSON got swallowed by `?? []`, wrong types were silently coerced via casts. The --with-dto flag generates: - src/Dto/Create<Name>Dto.php — readonly DTO with #[Assert\NotBlank] on title and #[Assert\Length(max: 255)] - src/Dto/Update<Name>Dto.php — same DTO with all fields nullable so PATCH callers send only what changed - src/Controller/<Name>Controller.php — same shape as the legacy controller but actions dispatch via #[MapRequestPayload] Validation failures (missing required field, wrong type, malformed JSON, oversize string) become RFC 7807 application/problem+json automatically — Symfony's RequestPayloadValueResolver does the work. No `if-isset` boilerplate, no silent coercion. Behaviour: - --with-dto is opt-in; legacy template still ships unchanged - audit suggests flipping to default-on once stable; that's a follow-up - maker fails loud (composer require hint) if symfony/validator isn't autoloadable - skeleton + example/todo composer.json pull symfony/validator so scaffolded apps work out of the box Snapshot test exercises both modes (legacy + --with-dto). New baselines TodoControllerWithDto.php / CreateTodoDto.php / UpdateTodoDto.php under tests/snapshot/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:10:52 +02:00
- **`make:bridge:resource --with-dto` opt-in.** Generates `Create<Name>Dto` + `Update<Name>Dto` under `src/Dto/` alongside the controller, and the controller dispatches via `#[MapRequestPayload]`. Closes the input-validation gap from the audit: malformed JSON, missing required fields, or `#[Assert\NotBlank]` violations now produce RFC 7807 `application/problem+json` automatically (Symfony's `RequestPayloadValueResolver`) — no more `if (isset($data['title']))` boilerplate, no silent type coercion. Update DTOs use nullable defaults so PATCH callers send only the fields they want changed. Without `--with-dto` the legacy template still ships unchanged. Maker fails loud if `symfony/validator` isn't autoloadable. Skeleton + example/todo composer.json pull `symfony/validator` so scaffolded apps work out of the box. Snapshot test exercises both modes.
- **`make:bridge:event <Name>` maker.** Generates a domain-event class (`src/Event/<Name>Event.php`, readonly value object), a subscriber (`src/EventSubscriber/<Name>Subscriber.php`) that republishes via `PublisherInterface` on `app://event/<kebab-name>`, and a QML stub (`{qml_path}/<Name>EventHandler.qml`) that listens via `MercureClient` and re-emits as a typed `signal`. Closes the third row of PLAN.md §8's makers table; pairs with the existing `make:bridge:resource` / `command` / `window` makers so domain events have a one-command path from PHP through to QML.
v0.2.0 (8/N): make:bridge:read-model maker Implements PLAN.md §8's fourth makers-table row. Read-models are server-side projections — joined fetches, aggregates, denormalised views — that QML reads without going through a writable #[BridgeResource]. The maker emits: - src/ReadModel/<Name>ReadModel.php — query service stub injecting EntityManagerInterface; user fills query() with DQL / QueryBuilder / raw SQL as fits. - src/Controller/<Name>Controller.php — single GET handler at /api/<kebab-plural>, just normalises the read-model output to JSON. - {qml_path}/<Name>List.qml — ReactiveListModel bound to the route, deliberately no Mercure topic. The "no topic" choice is the design call worth documenting: read-models are queries, not reactive resources, and pretending otherwise would either auto-publish stale aggregates on every entity change or require the user to invent invalidation logic in the listener. Better: pair the read-model with `make:bridge:event` and call refresh() from the QML event-handler when the underlying data really changes. Naming convention: kebab-PLURAL routes (`/api/todo-summaries`) for consistency with REST list semantics; resource path stays singular under `src/ReadModel/`. Wired into services.yaml's when@dev block. Three new snapshot baselines (TodoSummaryReadModel.php / TodoSummaryController.php / TodoSummaryList.qml) plus runner extension. All 14 maker outputs verify on the committed state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:28:50 +02:00
- **`make:bridge:read-model <Name>` maker.** Generates a query-only projection: `src/ReadModel/<Name>ReadModel.php` (query service stub injecting `EntityManagerInterface`), `src/Controller/<Name>Controller.php` (single GET handler at `/api/<kebab-plural>`), and `{qml_path}/<Name>List.qml` (`ReactiveListModel` bound to the route, deliberately *no* Mercure topic — read-models aren't auto-reactive; invalidation is event-driven via `make:bridge:event`). Closes the fourth row of PLAN.md §8's makers table.
- **Pre-migration auto-backup of `var/data.sqlite`.** Bundled-mode supervisor copies the SQLite file to `var/data.sqlite.<unix-timestamp>.bak` before invoking `doctrine:migrations:migrate`; trims to the 5 most recent. SQLite's lack of transactional DDL means a half-applied migration can corrupt the database with no rollback path; cheap insurance against that. Skipped on first launch (no DB to back up); failure to copy logs a warning and continues (a missing safety-net is not a reason to refuse to boot). Backup runs only in bundled mode — dev mode users own their `var/data.sqlite` lifecycle. Bundled-supervisor integration test gained an assertion that a `.bak` file appears under the user data dir on second launch.
v0.2.0 (10/N): bridge:export console command + QML hook PLAN.md §12 *Data backup / export* called for "a bridge:export console command and a UI hook for backup-to-file from v1". Shipping both now so the v0.2.0 surface has the data-portability story end-to-end: PHP side — `bin/console bridge:export <destination>`: - Reads the source path from DATABASE_URL so it works in dev mode (developer's source-tree var/data.sqlite) and bundled mode (user data dir SQLite) without environment-aware logic. - SQLite-only by design (PLAN.md §6 — single-instance SQLite-first); emits a clear error for non-sqlite:// URLs rather than pretending to support drivers that need driver-specific dump tooling. - Overwrites the destination if it exists (the FileDialog or shell redirect that produced the path has already confirmed). - 4 unit tests: happy path, non-SQLite URL, missing source, overwrite. Test count 24 → 28. QML side — Q_INVOKABLE BackendConnection.exportDatabase(path): - Bundled mode only; dev mode emits databaseExportFailed and returns false (developers own their SQLite directly). - Accepts both filesystem paths and `file://` URLs (FileDialog results). - Returns synchronously with bool but also emits async signals databaseExported(dst) / databaseExportFailed(reason) so QML can drive a snackbar / log without polling the return value. - Removes any existing destination first (QFile::copy refuses to overwrite); the picker has already confirmed the choice. Drive-by: parse_url() rejects sqlite:///abs/path on PHP 8.5+ (the host-less triple-slash trips its strictness). Switched to a prefix-strip — Doctrine DBAL only emits two URL shapes for SQLite anyway (sqlite:///abs and sqlite://relative). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:36:51 +02:00
- **`bridge:export` console command + QML hook.** New `bin/console bridge:export <destination>` copies the active SQLite database to a user-chosen path (overwrites if the destination exists; reads the source path from `DATABASE_URL` so it works in both dev and bundled mode). Mirrored on the QML side as `BackendConnection.exportDatabase(path)` (`Q_INVOKABLE bool`) returning success synchronously and emitting `databaseExported(path)` / `databaseExportFailed(reason)` for async UX. QML callers typically pair it with `Qt.labs.platform.FileDialog` (see `docs/native-dialogs.md`). 4 unit tests cover the command's success / non-SQLite-URL / missing-source / overwrite paths.
v0.2.0 (11/N): periodic auto-update check PLAN.md §11 *Auto-update* described "check on launch and once per N hours; offer install on next restart, never auto-restart". v0.1.0 shipped checkForUpdates() and applyUpdate() Q_INVOKABLEs but only manual triggers — no scheduling. This wires the polling. armAutoUpdateOnFirstOnline() runs from setState(Online) in bundled mode: - A QTimer::singleShot fires checkForUpdates() 10 s after the first Online transition (lets cold-boot bandwidth/CPU settle first). - A recurring QTimer fires checkForUpdates() every 6 hours after that. - One-shot guard via m_autoUpdateArmed so reconnect cycles don't re-arm the timers. Dev mode skips entirely (developers don't want their `make dev` workflow polling AppImageUpdate). Env-var knobs: - BRIDGE_AUTO_UPDATE_DISABLE=1 — skip entirely (respect-opt-out baseline; user-facing settings UI can layer on top later). - BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes> — override the period (handy for testing or shorter intervals on power-user opt-in). The actual install (apply + restart) stays manual — never auto- restart, per PLAN.md's UX rule. checkForUpdates emits updatesAvailable(); QML decides whether/when to show a banner and call applyUpdate(). Verified locally with QT_LOGGING_RULES=phpqml.bridge.bundled.info=true: "phpqml.bridge.bundled: auto-update armed: launch check in 10000 ms, period 360 min" appears in the host log after the BackendConnection probe sees /healthz=200. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:50:59 +02:00
- **Periodic auto-update check.** Bundled-mode supervisor arms an `AppImageUpdate` poll on the first `Online` transition: a launch-time check 10 s after backend ready, then a recurring check every 6 hours. PLAN.md §11 *Auto-update* called for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the existing `checkForUpdates()` Q_INVOKABLE remains the install trigger, this just automates the polling. Disable with `BRIDGE_AUTO_UPDATE_DISABLE=1`; override the period with `BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes>`. Dev mode skips entirely.
- **Bundled-mode port negotiation.** The hardcoded `m_port = 8765` is replaced with a runtime-negotiated free ephemeral port: bind a `QTcpServer` to `QHostAddress::LocalHost` port 0, capture `serverPort()`, close, then hand the port to FrankenPHP via the existing `PORT` env var (the Caddyfile already reads `{$PORT:8765}`). Two installed php-qml apps no longer collide on first launch — whichever loses the port-8765 race used to go Offline; now each picks its own. Test harnesses can pin the port via `BRIDGE_PORT=<n>` for reproducibility (the existing `bundled-supervisor.sh` and `perfsmoke.sh` both export it). Each launch also writes the chosen port to `var/bridge.port` so any external tool that needs the runtime address can read it without parsing Qt's log.
v0.2.0 (13/N): qmltestrunner harness + CI wiring + close out v0.2.0 plan Closes the testing-strategy row of PLAN.md §13 v0.2.0 and parks the two remaining items with rationales. Shipped: - framework/qml/tests/{CMakeLists.txt, main.cpp, tst_smoke.qml} Qt Quick Test scaffold: QUICK_TEST_MAIN bootstrap + one smoke test proving the harness loads. New tests land as tst_<feature>.qml in the same dir; qmltestrunner auto-discovers them. Built only when -DBUILD_TESTING=ON (production AppImages stay clean). - skeleton + example/todo Makefiles: `make qmltest` target invokes the configure → build → ctest dance. `make quality` now depends on qmltest. - .gitea/workflows/ci.yml: `QML unit tests` step after qmllint in the Quality job. Out-of-tree build dir (build-tests) so the CTest run doesn't pollute the cached release build. Verified locally: configure + build + ctest pass, both smoke assertions pass, runs in 0.5s. Closed in PLAN.md §13 v0.2.0 with rationale (no code change): - Build-time Symfony cache warmup → moved to v0.3.0. The obvious approach (cache:warmup at build, copy at first launch) doesn't save any time because Symfony bakes absolute kernel.project_dir into the compiled cache, and the AppImage's FUSE mount path changes every launch — every cached path is stale on launch N+1. Doing it properly requires virtualising getProjectDir(), symlink fix-up, multi-app namespacing — its own minor's worth of design. - ReactiveObject cursor pagination → closed N/A. ReactiveObject already has pending / invoke() / Idempotency-Key correlation / version-gap detection at parity with ReactiveListModel; the only feature it lacks is *pagination*, which is meaningless for a single-entity model. That fully closes the v0.2.0 plan as documented. Remaining v0.2.0 items in PLAN.md §13 are the audit-ends already shipped earlier in the cycle (interfaces / BridgeOp / BridgeBundleInfo / Maker DRY / --with-dto / port negotiation / pre-migration backup / bridge:export / periodic auto-update / native-dialogs doc / event maker / read-model maker / qmltestrunner) plus the two parked items documented above. Ready to tag when the user gives the word. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:02:30 +02:00
- **`qmltestrunner` QML unit tests + CI wiring.** `framework/qml/tests/` now ships a Qt Quick Test executable target (`qml_unit_tests`) discovered by CTest. Built only when configured with `-DBUILD_TESTING=ON` so production AppImages don't carry it. One smoke test (`tst_smoke.qml`) proves the harness; future per-feature tests land beside it as `tst_<feature>.qml`. Wired into `make qmltest` (skeleton + example/todo) and into the Gitea Actions `Quality` job after qmllint.
v0.2.0 (1/N): public API surface — interfaces + BridgeOp enum Establishes the contract layer the rest of v0.2.0 builds on. Pre-1.0 SemVer break: ModelPublisher::publishEntityChange() now takes BridgeOp instead of a raw string. Interfaces (Symfony idiom: same namespace as concrete, like HubInterface next to Hub): - PublisherInterface — publish(string, array, bool) - ModelPublisherInterface — publishEntityChange(object, BridgeOp) - CorrelationContextInterface — set/get/clear App code should typehint these instead of the concretes so swappable implementations (offline-buffer publisher, multi-hub fan-out, request- stamp correlation) remain non-breaking. Concrete classes implement them unchanged; autowire continues to inject the implementations transparently. BridgeOp: PHP 8.1 string-backed enum with cases Upsert / Delete / Replace / Event matching PLAN.md §4's envelope `op` wire format. Internal call sites updated; tests use the cases directly. Switched typehints: - ModelPublisher ctor: PublisherInterface + CorrelationContextInterface - DoctrineBridgeListener ctor: ModelPublisherInterface - HealthController ctor: PublisherInterface (still emits `Publisher` as bundle canary value — `::class` resolves to the concrete class name regardless of typehint, so bundled-supervisor.sh's grep stays green) - skeleton PingController ctor: PublisherInterface (canonical app pattern — example/todo has no Publisher consumer to update) Drive-by: removed deprecated setAccessible(true) call in ModelPublisher::extractId — PHP 8.1+ allows reflection without it. PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot all pass; dev container compiles in the example app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:50:01 +02:00
### Changed
- **`ModelPublisher::publishEntityChange()` signature: `string $op``BridgeOp $op`.** Pre-1.0 SemVer break. Internal callers updated; external callers (rare) need to migrate from raw strings to enum cases.
- **Internal typehints switched to interfaces.** `ModelPublisher` constructor takes `PublisherInterface` + `CorrelationContextInterface`; `DoctrineBridgeListener` takes `ModelPublisherInterface`; `HealthController` takes `BridgeBundleInfo`; the skeleton's `PingController` takes `PublisherInterface`. Autowire continues to inject the concrete implementations transparently.
- **`/healthz` response shape.** `bundle` field's value changes from `PhpQml\Bridge\Publisher` to `PhpQml\Bridge\BridgeBundle`; new `name` field reports the Composer package name. JSON consumers ignoring unknown keys are unaffected; consumers asserting the `bundle` value need to migrate.
v0.2.0 (1/N): public API surface — interfaces + BridgeOp enum Establishes the contract layer the rest of v0.2.0 builds on. Pre-1.0 SemVer break: ModelPublisher::publishEntityChange() now takes BridgeOp instead of a raw string. Interfaces (Symfony idiom: same namespace as concrete, like HubInterface next to Hub): - PublisherInterface — publish(string, array, bool) - ModelPublisherInterface — publishEntityChange(object, BridgeOp) - CorrelationContextInterface — set/get/clear App code should typehint these instead of the concretes so swappable implementations (offline-buffer publisher, multi-hub fan-out, request- stamp correlation) remain non-breaking. Concrete classes implement them unchanged; autowire continues to inject the implementations transparently. BridgeOp: PHP 8.1 string-backed enum with cases Upsert / Delete / Replace / Event matching PLAN.md §4's envelope `op` wire format. Internal call sites updated; tests use the cases directly. Switched typehints: - ModelPublisher ctor: PublisherInterface + CorrelationContextInterface - DoctrineBridgeListener ctor: ModelPublisherInterface - HealthController ctor: PublisherInterface (still emits `Publisher` as bundle canary value — `::class` resolves to the concrete class name regardless of typehint, so bundled-supervisor.sh's grep stays green) - skeleton PingController ctor: PublisherInterface (canonical app pattern — example/todo has no Publisher consumer to update) Drive-by: removed deprecated setAccessible(true) call in ModelPublisher::extractId — PHP 8.1+ allows reflection without it. PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot all pass; dev container compiles in the example app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:50:01 +02:00
### Fixed
- **`ModelPublisher::extractId` reflection cleanup.** Removed the `setAccessible(true)` call (deprecated since PHP 8.1; all properties are accessible via Reflection without it).
### Tests
- **`BridgeOpTest` wire-format contract.** Locks the four enum case values (`upsert` / `delete` / `replace` / `event`) against accidental rename — QML clients hardcode the strings, so a `value` change is a wire-protocol break and the test fails the build before it ships.
v0.2.0 (6/N): docs/native-dialogs.md — boundary doc + Qt.labs.platform examples PLAN.md §12 noted "Native dialogs (file pickers, notifications) — where do they live?" as an open question with the bias "QML side via Qt". That bias was never written up; without the doc, a Symfony developer new to Qt would reasonably reach for "POST /api/show-dialog" or roll a custom QML "FileDialog" using Window + ListView. Both are wrong. The doc: - States the boundary plainly (native UI = QML side, never PHP) plus the architectural reason (PHP's process can't reach the user's window manager; Qt's can and already wraps every platform's native API). - Walks through Qt.labs.platform.FileDialog / MessageDialog / SystemTrayIcon / StandardPaths with copy-pasteable examples so apps don't need to discover Qt.labs the hard way. - Explains the trigger-vs-effect split: user-initiated confirmations open from the QML handler that fired the action; server-side events route through Mercure and let QML decide how to surface them (toast / dialog / tray notification). Anti-pattern callouts: don't dispatch dialogs from Doctrine listeners, don't add HTTP endpoints whose only job is to trigger UI side-effects, don't roll a custom QML file browser. Notifications caveat: Qt.labs.platform.SystemTrayIcon::showMessage covers the common case but routes through the tray. Richer notifications (action buttons, replies) need platform-specific code and are deferred — flagged in-doc. PLAN.md §13 also mentioned "ship a small Q_INVOKABLE helper for the common cases". Skipped: every common case Qt.labs.platform already covers, and a wrapper would just shadow upstream's API. If a future need surfaces a real gap (XDG portal notifications without tray, say), that's the time to add framework-side code; the doc will point at it. No code changes; doc-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:21:23 +02:00
### Documentation
- **`docs/native-dialogs.md`.** Documents the framework boundary §12 already implied: native UI affordances (file pickers, confirmations, system notifications) live on the QML side via `Qt.labs.platform`, not in PHP. Includes copy-pasteable examples of `FileDialog`, `MessageDialog`, `SystemTrayIcon`, and the trigger-vs-effect split for server-pushed-event-driven dialogs.
## [0.1.2] — 2026-05-03
Bugfix release. Bundles the v0.1.1 follow-up that surfaced during the v0.1.2 cycle (bundled-mode supervisor cleanly SIGTERMs its child on host exit) with three non-breaking fixes from a post-v0.1.1 architecture audit.
### Fixed
- **Bundled supervisor: clean child shutdown.** `BackendConnection`'s destructor was the only path that called `teardownChild()`, but it ran during stack unwinding *after* `app.exec()` returned — by then the Qt event loop was already mid-shutdown and `QProcess::waitForFinished` couldn't reliably reap the child. Symptom: Qt logged `QProcess: Destroyed while process ("...frankenphp") is still running`, frankenphp + its PHP workers became orphans. The constructor now also connects `QCoreApplication::aboutToQuit``teardownChild`, so the child is SIGTERM'd while the event loop is still active. The bundled-supervisor integration test gained a clean-shutdown assertion (no Qt warning + no orphan frankenphp under the host's PGID after SIGTERM).
- **`bridge.qml_path` is now actually configurable.** The `BridgeResourceMaker` and `BridgeWindowMaker` docstrings claimed the QML scaffold path was settable via the bundle's `qml_path` option, but the bundle's `configure()` was empty and the constructor default (`'../qml/'`) was the only knob. `BridgeBundle::configure` now defines a `qml_path` scalar node; `loadExtension` exposes it as the `bridge.qml_path` container parameter; `services.yaml` binds it into both makers. Apps can override with `config/packages/bridge.yaml`: `bridge: { qml_path: ../qml/ }`. Default unchanged.
- **`SessionAuthenticator`: problem+json on the entry-point path.** `onAuthenticationFailure` already returned RFC 7807 `application/problem+json` for *bad-token* requests, but Symfony's default `AuthenticationEntryPointInterface::start` fired for *no-token* requests, returning a Form-flavoured 302/401 with the wrong shape for QML's `RestClient` error mapping. The authenticator now implements `AuthenticationEntryPointInterface` and routes both paths through a shared `problemJson()` helper so QML sees one error shape regardless of which firewall path was taken. New test covers the entry-point response.
- **`CorrelationKeyListener::onTerminate` sub-request guard.** `onRequest` already guarded with `isMainRequest()`, but `onTerminate` cleared unconditionally — a sub-request finishing mid-controller would wipe the main request's correlation key, causing the matching Mercure echo to lose its `correlationKey` field and the optimistic UI to never reconcile. FrankenPHP worker mode does not currently emit sub-requests so the user-visible impact is nil, but the asymmetry was a defensive bug.
## [0.1.1] — 2026-05-03
Bugfix release closing the four follow-ups identified during the v0.1.0 shakedown. No new public API surface; `/healthz` response gains an additive `bundle` field (existing JSON consumers ignore unknown keys).
### Fixed
bundled: wipe Symfony cache on every launch — mount path bakes into cache Reproduces with the v0.1.1 AppImage on the second launch (same user data dir, fresh AppImage mount): phpqml.bridge.bundled: symfony: "/tmp/.mount_Todo-xllnOHH/..." Cannot load migrations from "/tmp/.mount_Todo-xDBkOfG/.../migrations" ^^^^^^^ stale path from PREVIOUS launch's cache Symfony compiles `kernel.project_dir` (an absolute path) into its cached container under var/cache/. We redirect var/cache into the user data dir for read-only-mount survival (v0.1.0 fix), but the *content* of that cache references the mount path that was active when the cache was built. Next launch gets a different /tmp/.mount_<random>; the cached refs are stale; first project_dir-sensitive lookup blows up (doctrine migrations was the canary; would also surface as misrouted assets, broken Twig template paths, etc.). Fix: BackendConnection::initBundledMode does QDir(cacheDir).removeRecursively() right after creating the dirs but before runMigrations spawns the doctrine subprocess. Symfony rebuilds the cache against the current mount on every launch. Cost: ~1-2s of warmup per cold start. Permanent fix is build-time cache warmup (ship the prod cache inside the AppImage, copy to user data dir on first launch, no per-launch warmup) — already tracked as a v0.2.0 item in PLAN.md §13. v0.1.1 takes the simpler always-wipe approach since it's bugfix-class. Regression guard: examples/todo/tests/bundled-supervisor.sh gains a "2nd launch from fresh staging" step that tears down the first host, re-stages a fresh fake AppImage layout (different /tmp dir = different "mount path" from BackendConnection's perspective), and asserts /healthz comes back up. Without the cache wipe, that step would fail exactly the way doctrine did in the user's report. Verified locally: - bundled-supervisor.sh passes (incl. 2nd-launch step) - Real AppImage: two consecutive launches both reach "phpqml.bridge.bundled: migrations OK" + frankenphp spawn Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:23:30 +02:00
- **Wipe Symfony cache on bundled-mode launch.** Symfony's compiled container bakes `kernel.project_dir` as an absolute path. In bundled mode that path lives inside the AppImage's FUSE mount (`/tmp/.mount_<random>`), which is regenerated every launch. So the cache from launch N referenced mount-N's path; launch N+1 (different mount) hit `InvalidDirectory` from doctrine-migrations on the first launch-2 (and similar at any kernel.project_dir-sensitive lookup). `BackendConnection::initBundledMode` now `rmdir`s the cache before each spawn. Costs ~1-2s of warmup per launch; build-time cache warmup is the permanent fix (PLAN.md §13 v0.2.0). The bundled-supervisor integration test gained a 2nd-launch-from-fresh-staging step so this regresses if forgotten.
- **`HealthController` deep-loads the bundle.** Constructor-injects `Publisher` so `/healthz` returns 200 only when `BridgeBundle` is fully container-resolvable. v0.1.0's `/healthz` returned 200 against half-loaded bundles — both the path-repo symlink dangling at runtime and the read-only-cache failure shipped green through perfsmoke as a result. Response body now includes `bundle: "PhpQml\\Bridge\\Publisher"` as the canary value.
- **Caddyfile formatting.** `framework/skeleton/Caddyfile` and `examples/todo/Caddyfile` reformatted with `caddy fmt`. The "Caddyfile input is not formatted; run 'caddy fmt --overwrite'" warning that fired on every FrankenPHP boot is gone.
### Added
- **Bundled-mode supervisor integration test** (`examples/todo/tests/bundled-supervisor.sh`, `make integration-bundled`). Stages a fake AppImage layout in `/tmp` (host binary copied — Qt's `applicationDirPath()` dereferences symlinks via `/proc/self/exe`, so the real layout has to be mimicked closely; staged Symfony tree is `chmod -R a-w` to actually exercise the read-only-mount cache redirect) and exercises the supervisor end-to-end without needing a real `.AppImage` build. Asserts `/healthz` deep-load + cache redirect. Wired into `.gitea/workflows/ci.yml` after the existing dev-mode integration test.
- **Skeleton AppImage parity.** `framework/skeleton/Makefile` gains `staging-symfony` + `appimage` targets mirroring `examples/todo/Makefile`'s. New `framework/skeleton/packaging/skeleton.{desktop,png}` provide minimal AppImage assembly inputs. `bin/php-qml-init` now: (a) renames packaging files to match the scaffolded app name, (b) rewrites the `.desktop` file's `Name`/`Exec`/`Icon`, (c) substitutes the new `BUNDLE_SRC` and `PACKAGING` Makefile variables to either absolute framework paths (default) or vendored `.bridge` / `.bridge-packaging` paths (`--vendor`). Scaffolded apps inherit `make appimage` working out of the box.
### Notes
- `BackendConnection::m_port` stays hardcoded to 8765 — port-collision between two installed php-qml apps is a real bug surfaced during v0.1.1 prep, but the fix touches every consumer that hardcodes 8765 (perfsmoke, the new bundled-supervisor test) so it's tracked as a v0.2.0 item rather than a v0.1.x bugfix.
## [0.1.0] — 2026-05-03
First public preview. Phases 0 through 4a in PLAN.md are complete plus the Phase 5 DX-polish sub-commits. Linux is the only packaged target; macOS and Windows are deferred to 4b / 4c. Tagging is the user's call (release CI runs on `v*` tags).
### Added
- **Process-pair architecture.** Qt/QML host owns rendering; bundled FrankenPHP child runs the Symfony app in worker mode. They communicate via local HTTP and Mercure SSE.
- **Symfony bundle (`php-qml/bridge`).** `BridgeBundle` wires Doctrine subscriber, `ModelPublisher`, `bridge:doctor` console command, and the `#[BridgeResource]` attribute so app code stays idiomatic Symfony.
- **Qt module (`PhpQml.Bridge`).** `BackendConnection` (lifecycle + Update Semantics state machine: Connecting / Online / Reconnecting / Offline), `RestClient`, `MercureClient`, `ReactiveListModel`, `ReactiveObject`, `AppShell`, `SingleInstance` (QLocalServer-backed lock with launch-arg forwarding), `DevConsole`.
- **Update Semantics.** Optimistic mutations with `Idempotency-Key` round-tripped to Mercure as `correlationKey`; in-flight `pending` role; offline overlay + reconnecting banner via `AppShell`.
- **Headline makers** (`symfony/maker-bundle`):
- `make:bridge:resource <Name>` — entity (`#[BridgeResource]` + UUIDv7 by default, `--int-id` for auto-increment), CRUD controller, starter `<Name>List.qml`.
- `make:bridge:command <Name>` — controller stub for non-CRUD endpoints.
- `make:bridge:window <Name>` — second-window QML scaffold.
- **Skeleton application** (`framework/skeleton`) — minimal reference app exercised by every CI job.
- **POC todo app** (`examples/todo`) — full list UI, multi-window mirror, mark-all-done command, end-to-end test of multi-window coherence and crash-recovery.
- **bundled mode.** When `BRIDGE_URL` is unset (typical AppImage case), `BackendConnection` spawns the embedded FrankenPHP, generates a per-session bearer token, runs first-launch migrations into `~/.local/share/<app>/var/data.sqlite`, and supervises the child with `prctl(PR_SET_PDEATHSIG, SIGTERM)` for cleanup safety.
- **Linux AppImage packaging.** `packaging/linux/build-appimage.sh` + `make appimage` produce a single ~150 MB binary (Qt + Symfony + FrankenPHP + AppImageUpdate sidecar).
- **AppImageUpdate auto-update.** Embedded `update-info` ELF section points at the canonical Gitea Releases URL. `BackendConnection.checkForUpdates()` / `applyUpdate()` invoke the bundled sidecar.
- **Release CI** (`.gitea/workflows/release.yml`). Triggers on `v*` tags. Builds the AppImage, runs `tests/perfsmoke.sh` against PLAN.md §11 budgets (bundle ≤ 200 MB, cold start ≤ 4 s on shared CI runners, idle RSS ≤ 200 MB), generates zsync metadata + `latest.json` appcast + `SHA256SUMS`, optionally GPG-signs them, and uploads everything to the Gitea Release.
- **Quality CI** (`.gitea/workflows/ci.yml`). PHPStan + php-cs-fixer (check) + PHPUnit + qmllint + maker snapshot test + bridge-integration test (HTTP/SSE round-trip + crash-recover) on every push to `main`.
- **DX polish (Phase 5):**
- `DevConsole.qml` — opt-in window into the bundled FrankenPHP child's merged stdout/stderr; 500-line ring buffer; `Ctrl+`` toggles it in skeleton + todo example.
- `bin/php-qml-init <name>` — bash scaffolder. Copies `framework/skeleton/`, rewrites identifiers, repoints the path-composer-repo and CMake `add_subdirectory(framework/qml)` reference, runs `composer install` and migrations. `--vendor` produces a portable copy.
- `.vscode/launch.json` + `tasks.json` + `settings.json` and `.idea/runConfigurations/` shipped with skeleton and todo example.
- Hot-reload story documented end-to-end (FrankenPHP `--watch`, Qt Creator Reload, `qmlls` live preview).
### Notes
- Tooling versions enforced: PHP 8.4+, Symfony 8, Doctrine ORM 3, Qt 6.5+, FrankenPHP 1.12.2.
- The bundle ships without `composer.lock` (it's a library); the skeleton and the todo example carry their own.
Release prep v0.1.0: LGPL-3.0-or-later + real Gitea host URL Closes the two release-prep items called out in the Phase 5 closure paragraph (a3d35a7). License: LGPL-3.0-or-later. Chosen to align with Qt 6's LGPLv3, which keeps the AppImage's relinkability obligation (PLAN.md §12) satisfied and avoids version-mixing friction with upstream Qt. Two files at the repo root: - LICENSE — LGPL-3.0 text (the project license). - LICENSE.GPL — GPL-3.0 text the LGPL-3.0 explicitly incorporates ("This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License…"). framework/php/composer.json: "license": "proprietary" → SPDX "LGPL-3.0-or-later". CHANGELOG Notes section updated with the actual license + LICENSE/LICENSE.GPL pointer. Repo URL: every `gitea.example/<org|you>/php-qml` (and `<org>/<repo>` in docs/packaging-linux.md) replaced with the real `src.bundespruefstelle.ch/magdev/php-qml`. Touched README.md, CHANGELOG.md (compare + tag links), docs/getting-started.md, docs/packaging-linux.md (build-appimage --update-info example + latest.json appcast example). PLAN.md: status line bumped to "v0.1.0 ready to tag — LGPL-3.0-or-later license shipped, repo URL fixed". Phase 5 closure paragraph rewritten to record both items resolved (rather than pending). Only remaining manual edit at tag time: CHANGELOG `[0.1.0] — TBD` → `[0.1.0] — YYYY-MM-DD` (per Keep-a-Changelog), and the actual `git tag v0.1.0 && git push --tags` itself, which triggers .gitea/workflows/release.yml. Per the branching memory, releases land on main — merge dev → main first. Verified: `make quality` from framework/skeleton green (16 tests, 45 assertions; PHPStan + cs-fixer clean; maker snapshots match). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:50:15 +02:00
- Licensed under **LGPL-3.0-or-later** (`LICENSE` + `LICENSE.GPL` at the repo root). Chosen to align with Qt 6's LGPLv3 licensing — see PLAN.md §12 for the relinkability obligations the AppImage build already honours.
[Unreleased]: https://src.bundespruefstelle.ch/magdev/php-qml/compare/v0.2.0...HEAD
[0.2.0]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.2.0
bundled: SIGTERM the frankenphp child via aboutToQuit, not just the dtor Symptom (user report on v0.1.1): QProcess: Destroyed while process ("/tmp/.mount_Todo-xbNoHHL/usr/bin/frankenphp") is still running. …and the frankenphp child + its PHP workers were left orphaned after the host exited. Cause: teardownChild() was only called from ~BackendConnection. By the time that destructor runs, app.exec() has already returned, QQmlApplicationEngine is mid-destruction, and Qt's event loop is half-torn-down. waitForFinished() doesn't reliably reap the child in that window — QProcess gets destroyed by the QObject parent-chain cleanup before the kernel reports the child as exited. Fix: in BackendConnection's constructor, connect QCoreApplication::aboutToQuit → teardownChild. aboutToQuit fires while the event loop is still active and BEFORE main() starts unwinding the stack, so SIGTERM + waitForFinished can do their job properly. The destructor's teardownChild call stays as belt-and- suspenders (no-op once aboutToQuit has already cleaned up — the function is idempotent via the m_child = nullptr at its end). The connect happens unconditionally in the constructor (not just for bundled mode) because m_child is also nullptr in dev mode and teardownChild handles that with its leading `if (!m_child) return;`. Regression guard: examples/todo/tests/bundled-supervisor.sh gains a "graceful shutdown" step: - Snapshots the host's child PIDs before SIGTERM - SIGTERMs the host, waits up to 3s for clean exit - Greps the host log for "QProcess: Destroyed while" — fail if found - Iterates the snapshotted PIDs, fails on any frankenphp orphan still alive Verified locally: real AppImage + the integration test both clean up without Qt warnings or orphan processes. PLAN.md: new v0.1.2 section above v0.1.1, this is its first entry. CHANGELOG.md: [0.1.2] — TBD section with the same Fixed entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:49:13 +02:00
[0.1.2]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.2
[0.1.1]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.1
Release prep v0.1.0: LGPL-3.0-or-later + real Gitea host URL Closes the two release-prep items called out in the Phase 5 closure paragraph (a3d35a7). License: LGPL-3.0-or-later. Chosen to align with Qt 6's LGPLv3, which keeps the AppImage's relinkability obligation (PLAN.md §12) satisfied and avoids version-mixing friction with upstream Qt. Two files at the repo root: - LICENSE — LGPL-3.0 text (the project license). - LICENSE.GPL — GPL-3.0 text the LGPL-3.0 explicitly incorporates ("This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License…"). framework/php/composer.json: "license": "proprietary" → SPDX "LGPL-3.0-or-later". CHANGELOG Notes section updated with the actual license + LICENSE/LICENSE.GPL pointer. Repo URL: every `gitea.example/<org|you>/php-qml` (and `<org>/<repo>` in docs/packaging-linux.md) replaced with the real `src.bundespruefstelle.ch/magdev/php-qml`. Touched README.md, CHANGELOG.md (compare + tag links), docs/getting-started.md, docs/packaging-linux.md (build-appimage --update-info example + latest.json appcast example). PLAN.md: status line bumped to "v0.1.0 ready to tag — LGPL-3.0-or-later license shipped, repo URL fixed". Phase 5 closure paragraph rewritten to record both items resolved (rather than pending). Only remaining manual edit at tag time: CHANGELOG `[0.1.0] — TBD` → `[0.1.0] — YYYY-MM-DD` (per Keep-a-Changelog), and the actual `git tag v0.1.0 && git push --tags` itself, which triggers .gitea/workflows/release.yml. Per the branching memory, releases land on main — merge dev → main first. Verified: `make quality` from framework/skeleton green (16 tests, 45 assertions; PHPStan + cs-fixer clean; maker snapshots match). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:50:15 +02:00
[0.1.0]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0