Commit Graph

27 Commits

Author SHA1 Message Date
3c027255c8 caddyfile: apply caddy fmt — silence "input is not formatted" warning
FrankenPHP logs a warning on every boot:

  Caddyfile input is not formatted; run 'caddy fmt --overwrite' to
  fix inconsistencies

Cosmetic but clutters the dev console (and the bundled-mode logs).
The actual diff is one blank line in each file: caddy fmt rejects an
empty line between a leading comment and the `{` global-options
block. tests/var/Caddyfile was already clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:14:22 +02:00
68ee6efefe bundled: write Symfony cache + log to user data dir (AppImage is read-only)
All checks were successful
CI / Quality (push) Successful in 4m40s
Release / Linux AppImage (push) Successful in 4m48s
Symfony defaults Kernel::getCacheDir/getLogDir to <project>/var/cache
and <project>/var/log. In bundled mode those resolve inside the
AppImage's FUSE mount (/tmp/.mount_<hash>/usr/share/<app>/symfony/) —
read-only. Migrations fail at startup with:

  Unable to create the "cache" directory
  (/tmp/.mount_<hash>/usr/share/<app>/symfony/var/cache/prod).

…and frankenphp's worker can't warm a cache either, so even after the
binary spawns, the app is in a half-working state (which probably also
explains the persistent Reconnecting banner the user reported — once
migrations fail the supervisor sets Offline; even a successful
re-probe of /healthz wouldn't recover from a half-warm state).

Two-part fix, framework-side seam + app-side override:

  1. BackendConnection.cpp (runMigrations + spawnChild): mkdir
     <m_dataDir>/var/{cache,log} and pass them as APP_CACHE_DIR /
     APP_LOG_DIR env vars. <m_dataDir> resolves to
     ~/.local/share/<app> via QStandardPaths::AppDataLocation, so
     it's user-writable.

  2. App Kernel.php (skeleton + todo): override getCacheDir /
     getLogDir to honour the env vars. Falls back to parent
     behaviour when unset (dev mode keeps writing to var/cache like
     normal).

Database file already lives at <m_dataDir>/var/data.sqlite, so the DB
side was fine. Caddy autosaves to ~/.config/caddy and TLS storage to
~/.local/share/caddy — both user-writable. Mercure ran in-memory
mode in earlier logs so no extra storage redirect needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:21:10 +02:00
2f4766c7cb bridge: fix doubled bin/ in bundled-mode frankenphp path resolution
Some checks failed
CI / Quality (push) Successful in 5m4s
Release / Linux AppImage (push) Failing after 5m36s
resolveFrankenphpBin() returned `applicationDirPath() + "/bin/frankenphp"`,
but applicationDirPath() inside an AppImage is already `usr/bin/` —
where the host binary itself runs from. The "/bin/" prefix produced
the doubled path, e.g.:

  /tmp/appimage_extracted_<hash>/usr/bin/bin/frankenphp

…which doesn't exist. The supervisor logged the lookup failure and
kept retrying, eventually hitting the perfsmoke 8s cold-start
deadline.

build-appimage.sh:148 installs frankenphp at `usr/bin/frankenphp`
(sibling of the host binary, per the layout comment at line 18). The
fix is to drop the spurious `/bin/`. Other resolvers in the same
file (resolveSymfonyDir, resolveCaddyfilePath) already use the
correct `here + "/<file>"` pattern.

Bug shipped since the bundled-mode supervisor was added — would
have hit anyone running the AppImage. Local `make quality` only
exercises dev mode (BRIDGE_URL set), so the integration test loop
never reached this codepath; CI's perfsmoke against the actual
AppImage is the only thing that catches it. Manual test would be:
launch the AppImage with BRIDGE_FRANKENPHP_BIN unset and watch the
phpqml.bridge.bundled log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:20:27 +02:00
fcf7dc26cf qml: silence skeleton + todo Main.qml qmllint warnings
Some checks failed
CI / Quality (push) Successful in 5m3s
Release / Linux AppImage (push) Failing after 4m50s
Two warnings, two distinct kinds of fix:

1. `Item { width: 12 }` (skeleton:77) — explicit width on a
   layout-managed Item is undefined behaviour per qmllint. Replaced
   with `Item { Layout.preferredWidth: 12 }`, matching the pattern
   already used at line 85 (`Item { Layout.fillWidth: true }`). Real
   fix, not a suppression.

2. `target: SingleInstance` (skeleton:48, todo/Main.qml:220) — false
   positive. SingleInstance is intentionally a context property set
   by main() before the QML engine boots (see SingleInstance.h
   doc comment), so qmllint can't see it via static analysis.
   Disabled the `unqualified` warning at the call site with an inline
   `// qmllint disable unqualified` directive plus a one-line
   explanation comment above. (Note: the disable directive parses
   every word after `disable` as a category name, so the prose has
   to live on the previous line — found the hard way after qmllint
   complained about the "unknown category" of every English word in
   the explanation.)

Verified `make quality` from framework/skeleton green (qmllint clean
across both targets — `php_qml_bridge_qmllint` and `skeleton_qmllint`
both build with zero warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:05:17 +02:00
8ed452495c qml: silence DevConsole qmllint warnings (pragma + required property)
Qt 6.5.3's qmllint exits 255 on these two warnings even though older
qmllints only warn:

  - Outer-id access from a delegate (`width: view.width`) requires
    `pragma ComponentBehavior: Bound` to make the binding explicit.
  - Implicit role injection (`text: model.text`) should be a
    `required property`. Renamed the model role from "text" to "line"
    so the required property doesn't shadow Label's own `text`.

Behaviour unchanged. Verified `make quality` from framework/skeleton
green; the framework's `php_qml_bridge_qmllint` target now lints
clean (no more warnings on DevConsole.qml).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:58:03 +02:00
1389b92906 qml: pin OUTPUT_DIRECTORY of PhpQml.Bridge to match its URI path
Some checks failed
CI / Quality (push) Failing after 3m40s
Release / Linux AppImage (push) Failing after 5m26s
qmllint resolves QML modules by walking the import path looking for a
directory layout that mirrors the URI (PhpQml.Bridge → PhpQml/Bridge/).
qt_add_qml_module's OUTPUT_DIRECTORY defaults to CMAKE_CURRENT_BINARY_DIR
— which, when consumers add_subdirectory() this with their own binary_dir
(skeleton: build/qml/php_qml_bridge, todo: build/qml/php_qml_bridge),
ends in `php_qml_bridge` instead of `PhpQml/Bridge`. cmake configure
warns about the mismatch:

    The php_qml_bridge target is a QML module with target path
    PhpQml/Bridge. It uses an OUTPUT_DIRECTORY of .../php_qml_bridge,
    which should end in the same target path, but doesn't. Tooling
    such as qmllint may not work correctly.

…and at lint time, qmllint can't find the module, so every file that
`import PhpQml.Bridge` (AppShell.qml, DevConsole.qml) fails with
"Failed to import PhpQml.Bridge", which cascades into bogus
"Unqualified access" warnings for every BackendConnection reference.
The cascade exits 255 in Qt 6.5.3's qmllint (CI), even when an older
local qmllint would only warn.

Fix: pin OUTPUT_DIRECTORY in the framework's own qt_add_qml_module so
the layout is correct regardless of how consumers wire up the
add_subdirectory binary_dir. Single source of truth in the framework,
no consumer-side change needed.

Verified locally: rebuild from scratch + `make quality` green
(qmllint clean of the cascade — only the pre-existing
DevConsole/Main.qml warnings remain, all non-fatal). PHPStan +
cs-fixer + 16 tests + maker snapshots also still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:48:47 +02:00
919517c3ce 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
b925774eea Phase 5 sub-commit 3: hot-reload docs + .vscode/.idea editor configs
Skeleton README documents the hot-reload story end-to-end:
- PHP-side: frankenphp run --watch (already what `make dev` uses).
- QML-side: Qt Creator Reload, qmlls live preview, run-from-source.
- Dev console: Ctrl+` toggle from sub-commit 1.

Both skeleton and todo example ship .vscode/ (launch.json with Xdebug
attach + Qt-host gdb launch + a compound config, tasks.json for the
make targets, settings.json) and .idea/runConfigurations/ shell run
configs for `make dev`, `make doctor`, `make quality` (and `make
appimage` in the todo example). PhpStorm's Xdebug listener is global
so we don't ship a project-level run config for it; the README
points users at the toolbar toggle.

php-qml-init also rewrites .vscode/launch.json's binary path and
config label so a fresh scaffold's debugger configs point at the
new project's binary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:28:02 +02:00
4c15ac281c Phase 5 sub-commit 1: DevConsole + child-output capture + Ctrl+` toggle
BackendConnection now captures the bundled FrankenPHP child's merged
stdout+stderr into a 500-line ring buffer, mirrors each line through
qCInfo(lcBundled) so terminal users still see logs, and exposes
childLogTail() / childLogLine for QML.

DevConsole.qml is an opt-in monospaced viewer with auto-scroll + clear
that the skeleton and the todo example bind to Ctrl+`. Dev mode (when
BRIDGE_URL is set, no bundled child) renders an explanatory hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:58:53 +02:00
fddb70f877 Phase 4a sub-commit 4: AppImageUpdate sidecar + appcast + checkForUpdates()
Wires in the option-(a) sidecar approach: the AppImage carries a
bundled AppImageUpdate AppImage and an embedded update-info string
in the .upd_info ELF section. BackendConnection drives both the
check and the apply via QProcess.

BackendConnection:
  - Q_INVOKABLE checkForUpdates()
        Bundled mode only. Spawns AppImageUpdate.AppImage with
        --check-for-update <APPIMAGE>. Exits 0 → noUpdatesAvailable,
        1 → updatesAvailable, anything else → updateCheckFailed.
        Dev mode: emits updateCheckFailed("…dev-mode only").
  - Q_INVOKABLE applyUpdate()
        Bundled mode only. Spawns AppImageUpdate.AppImage with
        --remove-old <APPIMAGE>. Replaces the running AppImage in
        place; user must restart. Emits updateApplied or
        updateApplyFailed.
  - Sidecar path resolves to applicationDirPath()/AppImageUpdate.AppImage
    by default, overridable via BRIDGE_APPIMAGEUPDATE_BIN.
  - APPIMAGE env (set by the AppImage runtime) determines the target
    file. Outside an AppImage both methods fail loudly.

build-appimage.sh:
  - Auto-downloads AppImageUpdate-x86_64.AppImage into the cached
    tools dir and copies it into AppDir/usr/bin/AppImageUpdate.AppImage.
  - New --update-info flag, forwarded to appimagetool's -u so the
    .upd_info ELF section carries an "zsync|<URL>" string the sidecar
    will fetch.

examples/todo Makefile forwards APPIMAGE_UPDATE_INFO env to the
script as --update-info.

release.yml:
  - Builds the AppImage with APPIMAGE_UPDATE_INFO set to the canonical
    Gitea Releases asset URL for this tag.
  - Installs zsync, runs zsyncmake to generate Todo-x86_64.AppImage.zsync.
  - Generates a JSON appcast (latest.json) with version / url / sha256 /
    size / zsync URL / released_at — useful as an HTTP-fetchable
    fallback for clients that prefer a structured manifest.
  - SHA256SUMS now covers AppImage + zsync + latest.json.
  - Uploads all four assets to the Gitea Release.

AppImage size grows from ~104 MB to ~152 MB with the sidecar bundled.
Embedding verified: objdump shows .upd_info populated with the
expected zsync URL after a local build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:58:02 +02:00
d8726bac94 Fix CI: bump PHP requirement to ^8.4 (Symfony 8 enforces it)
CI was failing on the Install-bundle-dependencies step because
shivammathur/setup-php was installing 8.3 while Symfony 8.x dependencies
declare php >= 8.4. Local composer install worked because the dev box
runs PHP 8.5.5; CI doesn't.

Bumps:
  - framework/php/composer.json
  - framework/skeleton/symfony/composer.json
  - examples/todo/symfony/composer.json
  - .gitea/workflows/ci.yml         php-version: '8.3' → '8.4'
  - .gitea/workflows/release.yml    same
  - PLAN.md §13 Phase 1 *Detailed scope* PHP minimum row

PHPStan / cs-fixer / PHPUnit stay green locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:53:00 +02:00
a1cc06abbb Phase 4a sub-commit 1: bundled-mode startup in BackendConnection
Auto-detected on construction:

  - BRIDGE_URL env set → dev mode (today's behaviour, unchanged).
  - BRIDGE_URL unset → bundled mode: BackendConnection now
    1. Resolves the user app data dir (QStandardPaths::AppDataLocation,
       ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/,
       var/cache/ exist there.
    2. Generates a per-session 32-byte URL-safe token and a 48-byte
       Mercure JWT secret.
    3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n`
       against the user's DATABASE_URL with a 60s timeout.
    4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT
       in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and
       a supervisor that re-spawns up to 5 times on unexpected exit.
       Each restart fires tokenRotated(newToken).

Path resolution defaults to applicationDirPath() + bin/frankenphp,
applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with
both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for
AppImage-style layouts. All three are overridable via
BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars.

Caddyfiles in skeleton + example now use {$VAR:default} substitution
for PORT and the Mercure JWT keys, so the same Caddyfile works in both
modes. Dev defaults match symfony/.env.

restart() in bundled mode re-spawns the child (resets the supervisor
counter); in dev mode it stays a probe-only no-op.

Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=…  BRIDGE_SYMFONY_DIR=…
BRIDGE_CADDYFILE=…  ./build/qml/todo` (no BRIDGE_URL): bundled mode
created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration,
spawned FrankenPHP, served /healthz, accepted a POST /api/todos with
the per-session bearer. Dev mode (`make dev`) still works unchanged.

Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures
surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
adc0cdc11d Phase 3 sub-commit 5: maker-output snapshot test + phase closure
framework/php/tests/snapshot/ holds reference output for every shipped
maker (resource Todo, command MarkAllDone, window Todo). The
run.sh script:

  - git-archives the skeleton into a temp dir
  - composer-installs against the bundle's real path
  - removes the existing maker outputs so the regenerators don't bail
  - runs the three makers
  - diffs each generated file against the matching baseline

CI / make quality fail on any drift; if a template change is intended,
the baselines must be regenerated in the same commit. Wired into:

  - framework/skeleton/Makefile's `quality` target (local/dev runs)
  - .gitea/workflows/ci.yml (CI runs after qmllint)

Plus a few hardenings discovered while wiring this up:

  - The resource maker template now injects NormalizerInterface
    (not SerializerInterface — that interface lacks ::normalize()).
    All Todo controllers re-rendered to match.
  - The command maker template emits a $this->em->flush() so the
    injected EntityManager isn't a property.onlyWritten violation
    in PHPStan after the user fills in the body.
  - phpstan.neon and php-cs-fixer's Finder both exclude tests/snapshot
    so the baselines aren't auto-rewritten or analysed as live code.

CI workflow now also installs FrankenPHP, builds the todo example, and
runs the bridge-integration test from Phase 3 sub-commit 4.

Phase 3 done. Outstanding follow-ups (deferred per spec): the
qmltestrunner-driven QML unit tests, make:bridge:event,
make:bridge:read-model, ReactiveObject pagination.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:03:41 +02:00
9c97984bc9 Phase 3 sub-commit 2: make:bridge:window + make:bridge:command makers
Two new makers complete the trio the todo POC needs:

`make:bridge:window <Name>`:
  - emits {qml_path}/<Name>Window.qml — an ApplicationWindow wrapping
    AppShell with a content slot to fill in. Apps open it via
    Qt.createComponent() / a Component { } block to get extra
    instances for the multi-window test (PLAN.md §13 Phase 3).
  - pure-QML output, no PHP runtime deps.

`make:bridge:command <Name>`:
  - emits src/Controller/<Name>Controller.php mounted at
    POST /api/<kebab-name>. The body is a TODO stub that fills in
    domain logic and flushes via the injected EntityManager —
    Doctrine listeners pick up the changes and publish to Mercure
    automatically. Synchronous by design (no Messenger plumbing for
    a POC); apps that need async dispatch can add Messenger and
    refactor.

Templates excluded from PHPStan / cs-fixer the same way the resource
maker's are. Smoke-tested both makers against `MarkAllDone` and
`AboutDialog` — output is correct PHP / QML and re-running them
reproduces byte-for-byte. composer quality stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:15:37 +02:00
d4343977e1 Phase 3 sub-commit 1: ReactiveObject (single-entity twin)
ReactiveObject mirrors ReactiveListModel for a single entity. Loads via
GET <baseUrl><source>, stays in sync via Mercure SSE on `topic`, and
exposes the entity's JSON keys on a `data` QQmlPropertyMap so QML reads
them as `obj.data.title` with bindings that re-evaluate on change.

Properties:
  - source / topic / baseUrl / token (configuration)
  - data (QQmlPropertyMap*)            — entity fields
  - ready                              — initial fetch finished
  - exists                             — entity present (false on 404 / delete)
  - pending                            — at least one optimistic mutation in flight
  - error

invoke(method, path, body, optimistic) is identical in shape to
ReactiveListModel.invoke(): apply optimistic to `data`, send the
request with an Idempotency-Key, clear `pending` on the matching
Mercure echo, roll back on 4xx/5xx or 10s timeout. The rollback
restores backed-up values and removes keys we added optimistically.

Wired into the QML module; the skeleton builds clean. Used by Phase 3
sub-commit 3's todo edit form.

Includes the merged CI trigger change (workflow now runs on `main`
branch only, not `dev` — keeps Gitea-runner pressure low while we're
iterating on dev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:12:50 +02:00
1964a52f99 Phase 2 sub-commit 5: convention test passes, skeleton walkthrough, phase 2 closed
Some checks failed
CI / Quality (push) Failing after 1m50s
Runs `make:bridge:resource Todo` against the skeleton, then `make:migration`
+ `doctrine:migrations:migrate`, and verifies the round-trip end-to-end:

  - POST /api/todos creates a row with a UUIDv7 id
  - GET /api/todos returns the row
  - Mercure dual-publishes:
    - app://model/todo (collection topic)
    - app://model/todo/{uuid} (entity topic)
  - The published envelope shape matches PLAN.md §4 exactly:
    {op:"upsert", id:..., version:..., data:{...}, correlationKey:"..."}
  - correlationKey echoes the request's Idempotency-Key, ready to be
    matched by ReactiveListModel's pending state on the QML side.

Generated files committed as the regression baseline (Phase 3 will add
a CI check that re-running the maker reproduces these byte-for-byte):

  - framework/skeleton/symfony/src/Entity/Todo.php
  - framework/skeleton/symfony/src/Controller/TodoController.php
  - framework/skeleton/symfony/migrations/Version20260502004612.php
  - framework/skeleton/qml/TodoList.qml

framework/skeleton/README.md captures the three-command flow plus a
curl walkthrough so future readers can reproduce. Phase 2 done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:49:23 +02:00
4a42de702b Phase 2 sub-commit 4: make:bridge:resource maker
Some checks failed
CI / Quality (push) Failing after 1m55s
Bundle picks up symfony/maker-bundle as require-dev. New BridgeResourceMaker
under PhpQml\Bridge\Maker generates three files for a named resource:

  - src/Entity/<Name>.php  — Doctrine entity with #[BridgeResource]
                             and a UUIDv7 id by default. --int-id flips
                             to auto-incrementing int IDs.
  - src/Controller/<Name>Controller.php — CRUD on /api/{plural} (list,
                             create, update, delete) with serializer-
                             normalised JSON responses.
  - {qml_path}/<Name>List.qml — starter ListView wrapped around a
                             ReactiveListModel bound to the right topic
                             and source URL.

The Doctrine subscriber from sub-commit 2 picks the entity up
automatically — no per-resource listener generated. The QML snippet
target defaults to '../qml/' (relative to the Symfony project root)
and is overridable via the maker's $qmlPath constructor arg.

Templates live under src/Maker/templates/ as .tpl.php files using
short-echo and alternative-syntax control structures by convention.
PHPStan and php-cs-fixer skip them — the maker's Generator binds the
template variables at render time.

Skeleton picks up MakerBundle as a `dev` bundle and require-dev'd
symfony/maker-bundle, so `bin/console make:bridge:resource Todo`
works out-of-the-box.

Verified: maker runs end-to-end against `Todo` and emits readable,
syntactically valid output. composer quality (16 tests, 45 assertions,
PHPStan clean, cs-fixer clean) stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:45:42 +02:00
030502ca38 Phase 2 sub-commit 3: full Update Semantics + ReactiveListModel + AppShell
Some checks failed
CI / Quality (push) Failing after 1m45s
BackendConnection's ConnectionState enum is now Connecting / Online /
Reconnecting / Offline (PLAN.md §5). The probe loop tracks the first
failure since the last Online and transitions to Reconnecting on any
failed probe, then to Offline once the configurable threshold (30 s
default) is exceeded. The Error state is gone; Reconnecting + the
exposed `error` string subsume its UI role.

ReactiveListModel is the headline QML type:

- QAbstractListModel that GETs `baseUrl + source` for an initial JSON
  array and then keeps in sync via an internal MercureClient subscribed
  to `topic`.
- Role names are derived dynamically from the first row's keys plus an
  internal `pending` boolean role used by optimistic mutations.
- Diff application: upsert (insert-or-update), delete, replace; gap
  detection via the envelope `version` field with auto re-fetch.
- `invoke(method, path, body, optimistic)` is the optimistic command
  primitive. Generates an Idempotency-Key, applies the local diff,
  POST/PATCH/DELETEs with that key, and resolves on the matching
  Mercure echo (correlation-key matched in ModelPublisher's envelope).
  Rolls back and emits commandFailed on 4xx/5xx, commandTimedOut after
  10 s without an echo. Phase 4 packaging will surface configuration
  for the timeout.

AppShell.qml is the optional convenience root:

- Reads BackendConnection.connectionState.
- Reconnecting → top banner.
- Offline → modal overlay with the error string and a Retry button
  (calls BackendConnection.restart()).
- Wraps user content via `default property alias content`.

Apps that want full chrome control can skip AppShell entirely; the
skeleton's Main.qml keeps its own status display for demonstration
and is unaffected.

ReactiveObject (single-entity twin of ReactiveListModel) is intentionally
deferred — same envelope handling, smaller surface; will land in Phase 2
follow-up or Phase 3 alongside the todo example. Cursor pagination is
similarly deferred (the Phase 2 done criterion uses small lists).

Smoke tested: /healthz + /api/ping round-trip cleanly, zero Mercure 401s,
clean shutdown. composer quality stays green (16 tests, 45 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:40:12 +02:00
1c5a5761f6 Phase 2 sub-commit 2: ModelPublisher + #[BridgeResource] + Doctrine listener
Some checks failed
CI / Quality (push) Failing after 1m54s
Bundle gains the model layer that bridges Doctrine entities to Mercure
without per-resource glue. Three new pieces:

- `#[BridgeResource(name: ?string)]` attribute marks an entity as a
  reactive bridge model. Topic name defaults to the lowercased class
  basename and can be overridden per resource.

- `ModelPublisher` translates entity changes into PLAN.md §4 envelopes
  ({op, id, data, version, ?correlationKey}) and dual-publishes them
  on `app://model/{name}` (collection topic) and `app://model/{name}/{id}`
  (entity topic). Entity normalisation goes through Symfony's Serializer
  (ObjectNormalizer + DateTime + BackedEnum) for predictable JSON. The
  envelope `version` field is a per-process monotonic counter — fine for
  single-instance dev mode; production should back this with a Postgres
  SEQUENCE or equivalent (noted for Phase 4).

- `DoctrineBridgeListener` registers `postPersist`/`postUpdate`/
  `postRemove` via `#[AsDoctrineListener]` and routes events through
  ModelPublisher. Entities without `#[BridgeResource]` are silently
  skipped.

Plus the correlation-key plumbing the §5 Update Semantics layer needs:

- `CorrelationContext` is a per-request holder for the originating
  request's `Idempotency-Key`.
- `CorrelationKeyListener` reads the header on `KernelEvents::REQUEST`
  and clears the context on `KernelEvents::TERMINATE` (worker mode
  hygiene). CLI mutations see no key, which is correct.

Bundle composer.json picks up `doctrine/dbal`, `doctrine/orm`,
`doctrine/doctrine-bundle`, `symfony/serializer`, `symfony/property-*`,
`symfony/uid`. PHPStan extension `phpstan-doctrine` added so the listener's
event-args types resolve. Skeleton's framework.yaml enables `serializer`
and `property_info`.

Tests: 5 new for ModelPublisher (dual publish, correlation echo, delete
op omits data, untagged entities ignored, version increments). Total:
16 tests, 45 assertions, PHPStan clean, cs-fixer clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:32:51 +02:00
6bd4d13a77 Phase 2 sub-commit 1: Doctrine ORM 3 + Migrations + SQLite
Some checks failed
CI / Quality (push) Has been cancelled
Skeleton gains Doctrine ORM 3.6 (with DoctrineBundle 3.x and Migrations
4.x), pointed at a SQLite file under var/data.sqlite. Apps move to
Postgres/MySQL by overriding DATABASE_URL in .env.local.

config/packages/doctrine.yaml registers the symfony/uid UuidType so
Phase 2 sub-commit 4's UUIDv7 default works without per-app config,
and pre-wires the App\Entity attribute mapping under src/Entity/ for
the maker to drop entities into.

Bundle gains an optional doctrine/dbal Connection via Autowire; when
present, bridge:doctor adds a "Database reachable" SELECT-1 probe.
The bundle still installs cleanly without doctrine/dbal — apps that
opt out get a doctor table without the database row.

Verified: `bin/console bridge:doctor` is all green against a fresh
SQLite. composer quality (PHPStan + cs-fixer + PHPUnit) stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:25:48 +02:00
7323b9affe Phase 1 sub-commit 7: CI quality job
Some checks failed
CI / Quality (push) Has been cancelled
PHPStan (level 6 + symfony extension) and PHP CS Fixer (Symfony +
PHP83Migration ruleset) configs at framework/php/. composer.json
exposes phpstan / cs:check / cs:fix / phpunit / quality scripts.
PHPStan-clean across the bundle; cs:check is happy after auto-fix
applied @Symfony idioms (yoda, leading-backslash JSON_*, blank-line
before return). Test mocks consolidated into a HubSpy helper to keep
PHPStan happy about by-ref captures.

Skeleton's Makefile target `quality` chains `composer quality` (in
framework/php/) with cmake's all_qmllint target. Local run is green —
11 tests / 32 assertions, no PHPStan errors, cs-fixer clean, qmllint
emits advisory warnings only.

Layout fix in skeleton's Main.qml: status-dot Rectangles inside
RowLayout now use Layout.preferredWidth/Height instead of width/height
to satisfy Quick.layout-positioning checks.

.gitea/workflows/ci.yml replaces the placeholder with a real `quality`
job: setup-php, composer install (cached), the four PHP checks, Qt 6
via install-qt-action (cached), QML module build, qmllint via the
all_qmllint CMake target. Workflow exists from this commit onward
even if a runner isn't provisioned yet.

bridge:doctor lost the Publisher dependency since it was only used as
a "service is wired" marker — the command being injectable already
proves that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:15:06 +02:00
d671b26cac Phase 1 sub-commit 6: skeleton wiring — make dev runs end-to-end
All checks were successful
CI / Quality (push) Successful in 5s
Symfony app under framework/skeleton/symfony/: minimal bin/console,
public/index.php, MicroKernel-based src/Kernel.php, services.yaml,
framework/security/mercure config, and a demo App\Controller\PingController
that GETs /api/ping (returning JSON pong) and republishes the same
payload to the Mercure topic app://ping. composer.json uses a path
repository to symlink the bundle from ../../php so local edits are
picked up live.

QML app under framework/skeleton/qml/: top-level CMake that
add_subdirectory's framework/qml, a main.cpp that creates the Qt
process, runs SingleInstance.acquireOrForward before any QML loads,
exposes SingleInstance via context property, and loadFromModule's
Skeleton.Main. Main.qml uses BackendConnection / RestClient /
MercureClient from PhpQml.Bridge and renders status dots, a Ping
button, and an event log.

Caddyfile binds 127.0.0.1:8765, enables in-memory Mercure with a
256-bit dev JWT (matches symfony/.env, lcobucci/jwt requires this).
Makefile wraps build / dev / doctor / clean; scripts/dev.sh starts
FrankenPHP --watch and the Qt host together with explicit PID-based
teardown (process-group `kill 0` proved unreliable when frankenphp's
watch fork reparented).

Bug fixes uncovered in this sub-commit:
- SingleInstance.acquireOrForward: probe-first, then removeServer +
  retry-listen. The original loop-with-removeServer-after-failed-bind
  silently exited on stale sockets from prior runs.
- Main.qml: MercureClient does NOT inherit BackendConnection.token —
  Mercure subscribes anonymously in dev (Caddyfile), and forwarding
  the bridge bearer made it 401-loop.
- /api/ping was 500ing because the dev MERCURE_JWT_SECRET was 144 bits;
  bumped to 64-char (>=256 bit) to satisfy lcobucci/jwt.
- Linked the framework lib (php_qml_bridge) explicitly in addition to
  the QML plugin so SingleInstance.h resolves.
- Auto-generated config/reference.php gitignored.

Smoke verified offscreen: /healthz 200, /api/ping 200, 1 publish, 1
subscriber, zero 401s, clean shutdown with no zombies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:50:16 +02:00
75840a240e Phase 1 sub-commit 5: Qt transport types
All checks were successful
CI / Quality (push) Successful in 5s
MercureClient is a single-topic SSE subscriber: opens a long-lived GET
on the hub URL with the topic query and Accept: text/event-stream,
parses the line protocol into update(data, id) signals, and reconnects
with 1s→2s→…→30s exponential backoff on drop. Tracks lastEventId across
reconnects and sends it as Last-Event-ID so the hub can replay missed
messages — backing the "Sleep / wake" path in PLAN.md §3 *Edge cases*.
One client per topic by design; multi-topic aggregation is Phase 2.

RestClient.qml is a Promise-style XMLHttpRequest wrapper. Auto-attaches
an RFC4122-v4 Idempotency-Key to every non-GET request (PLAN.md §4 and
§7) so retries are safe by default. Maps application/problem+json error
bodies into structured rejections for downstream UI.

Standalone CMake build remains green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:21:59 +02:00
87b5b2283c Phase 1 sub-commit 4: Qt foundation types
All checks were successful
CI / Quality (push) Successful in 5s
BackendConnection (QML singleton via create() factory) reads BRIDGE_URL
and BRIDGE_TOKEN from env, periodically probes <url>/healthz with a 2s
transfer timeout, and exposes a Connecting/Online/Error state machine
plus error/token properties to QML. Bundled-mode startup (spawning the
embedded FrankenPHP child) is a Phase 4 deliverable; restart() is a
no-op for now. tokenRotated signal is reserved for the per-session
secret rotation described in PLAN.md §3.

SingleInstance is C++-only — main() must call acquireOrForward() before
the QML engine boots, so it's exposed via context property rather than
QML_SINGLETON. QLocalServer-based lock with stale-socket detection,
launch-arg forwarding via QDataStream, and the deadlock-avoiding race
fallback specified in §3 *Edge cases*.

CMakeLists.txt declares the PhpQml.Bridge static QML module with both
sources and is dual-mode: stands alone for sanity builds, integrates
via add_subdirectory from the skeleton's top-level CMake (Phase 1
sub-commit 6). Standalone build verified clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:18:43 +02:00
b3932674dd Phase 1 sub-commit 3: bridge:doctor console command
All checks were successful
CI / Quality (push) Successful in 5s
Console command bridge:doctor surfaces actionable hints for env / wiring
problems so first-run failures aren't a "connection refused" mystery.
Checks PHP version, ext-curl, ext-json, the Publisher service is wired
(meaning BridgeBundle loaded), and the BRIDGE_TOKEN / MERCURE_URL /
MERCURE_PUBLISHER_JWT_KEY / MERCURE_SUBSCRIBER_JWT_KEY env vars. With
--connect, also probes the configured URL via plain stream context (no
extra dep) and fails the run when unreachable.

CommandTester suite covers green path, missing-env path, and an
unreachable-URL probe — 11 tests, 32 assertions, all green.

Skeleton's Makefile target stays a TBD until sub-commit 6 stands up the
Symfony app the command runs from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:08:09 +02:00
eafe12b588 Phase 1 sub-commit 2: Symfony bundle internals
All checks were successful
CI / Quality (push) Successful in 4s
Bundle code for php-qml/bridge: BridgeBundle (AbstractBundle, autoloads
config/services.yaml), Publisher (thin wrapper over Mercure HubInterface
that enforces envelope-as-JSON), SessionAuthenticator (bearer-token
custom Symfony authenticator with problem+json failures), and
HealthController (GET /healthz readiness probe).

Composer constraints bumped to Symfony ^8.0 across the board (per user
request); mercure component to ^0.7. PHPUnit 11 suite covers Publisher
publish + private flag and SessionAuthenticator support/auth/failure
paths — 8 tests, 22 assertions, all green.

PLAN.md §13 updated to record the Symfony 8 minimum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:05:19 +02:00
9001386f92 Phase 1 sub-commit 1: scaffold framework, skeleton, CI
All checks were successful
CI / Quality (push) Successful in 48s
Stands up the directory structure Phase 1 fills in over subsequent
sub-commits: framework/php (Composer package php-qml/bridge),
framework/qml (Qt module placeholder), framework/skeleton (Caddyfile +
Makefile stubs), and .gitea/workflows/ci.yml. Root .gitignore covers
the build/composer/Symfony/Qt/CMake/IDE artefacts the rest of Phase 1
will produce. No bundle code, no Qt module sources, no working dev mode
yet — those land in sub-commits 2-7. Spike still in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:59:06 +02:00