diff --git a/.apm/instructions/architecture.instructions.md b/.apm/instructions/architecture.instructions.md index a3e070d2..0078fbb9 100644 --- a/.apm/instructions/architecture.instructions.md +++ b/.apm/instructions/architecture.instructions.md @@ -73,7 +73,7 @@ docs/architecture/ # Architecture documents (backend, drag-and-drop) ## PHP Testing - PHPUnit 11 — runs inside Docker via `make test` -- PHPUnit config: `.docker/app/phpunit.xml.dist` (defines `unit`, `integration`, `functional`, and `fluent` testsuites, selected via `--testsuite` flag) +- PHPUnit config: `.docker/app/phpunit.xml.dist` (defines `unit`, `integration`, `functional`, `fluent`, and `kfc` testsuites, selected via `--testsuite` flag; `fluent` and `kfc` require the optional-modules env — `make test-modules`) - Test namespace: `WeDevelop\Grid\Tests\` → `tests/` (Unit/ + Integration/) ## Static Analysis diff --git a/.apm/instructions/commands.instructions.md b/.apm/instructions/commands.instructions.md index cffb64ba..f1de0592 100644 --- a/.apm/instructions/commands.instructions.md +++ b/.apm/instructions/commands.instructions.md @@ -42,7 +42,7 @@ applyTo: "**/*" | `make test-unit` | Run PHP unit tests (no database/framework) | | `make test-integration` | Run PHP integration tests (full SilverStripe env) | | `make test-functional` | Run PHP functional tests (HTTP/controller tests) | -| `make test-fluent` | Run integration + functional + fluent tests in Fluent env | +| `make test-modules` | Run integration + functional + fluent + kfc tests in the optional-modules env (Fluent + keys-for-cache installed) | | `make test-js` | Run JavaScript tests (Vitest, no Docker needed) | | `make coverage` | Merged PHP coverage report (HTML + Clover) | | `make coverage-unit` | PHP unit test coverage only | diff --git a/.apm/instructions/gotchas.instructions.md b/.apm/instructions/gotchas.instructions.md index c27db3e2..f78f7447 100644 --- a/.apm/instructions/gotchas.instructions.md +++ b/.apm/instructions/gotchas.instructions.md @@ -35,3 +35,4 @@ applyTo: "**/*" ## Tooling - **CLAUDE.md and AGENTS.md are regenerated from `.apm/instructions/` on every `apm compile`** — direct edits to the generated files get overwritten silently. Edit the APM sources instead. +- **Editing a single-file bind mount (e.g. `composer.json`) while dev containers run breaks it inside the container.** `.docker/compose.yml` mounts individual host files (notably `../composer.json:/module/composer.json:ro`) into both `app` and `app-modules`. A single-file bind mount is pinned to the host file's inode; saving the file via atomic replace (write-temp + rename — what most editors and tooling do) swaps the inode, so the mount inside an already-running container goes stale and `/module/composer.json` reads as "No such file or directory". This surfaces as `ModuleResourceLoader` throwing `InvalidArgumentException: Can't find module 'wedevelopnl/silverstripe-grid'`, cascading into many unrelated-looking test errors (FixtureLoaderTest, ColumnWidthPickerFieldTest, FixtureControllerTest, …) — classes still autoload (separate from the module manifest), so only resource-resolution paths break. Fix: `docker compose -f .docker/compose.yml [--profile modules] up -d --force-recreate --wait ` to re-resolve the mount. Not a code bug and not a CI problem (CI starts containers fresh after checkout). After editing `composer.json` while containers are up, `--force-recreate` before trusting test results. diff --git a/.docker/app/composer.json b/.docker/app/composer.json index 242ddf77..6bdb85b9 100644 --- a/.docker/app/composer.json +++ b/.docker/app/composer.json @@ -1,5 +1,5 @@ { - "_comment": "Keep in sync with composer.fluent.json (adds Fluent dependency)", + "_comment": "Keep in sync with composer.modules.json (which adds the optional Fluent and keys-for-cache dependencies)", "minimum-stability": "dev", "prefer-stable": true, "require": { diff --git a/.docker/app/composer.fluent.json b/.docker/app/composer.modules.json similarity index 89% rename from .docker/app/composer.fluent.json rename to .docker/app/composer.modules.json index 6dc85d09..e8895897 100644 --- a/.docker/app/composer.fluent.json +++ b/.docker/app/composer.modules.json @@ -1,9 +1,10 @@ { - "_comment": "Fluent variant of composer.json — keep in sync, only difference is tractorcow/silverstripe-fluent in require", + "_comment": "Optional-modules variant of composer.json — keep in sync with composer.json; adds tractorcow/silverstripe-fluent and silverstripe-terraformers/keys-for-cache to require", "minimum-stability": "dev", "prefer-stable": true, "require": { "silverstripe/recipe-cms": "^6.0", + "silverstripe-terraformers/keys-for-cache": "^3.0", "tractorcow/silverstripe-fluent": "^8.0", "wedevelopnl/silverstripe-grid": "*" }, diff --git a/.docker/app/phpunit.xml.dist b/.docker/app/phpunit.xml.dist index 28faf1cc..52827fc8 100644 --- a/.docker/app/phpunit.xml.dist +++ b/.docker/app/phpunit.xml.dist @@ -12,6 +12,7 @@ vendor/wedevelopnl/silverstripe-grid/tests/Integration vendor/wedevelopnl/silverstripe-grid/tests/Integration/Fluent + vendor/wedevelopnl/silverstripe-grid/tests/Integration/KeysForCache vendor/wedevelopnl/silverstripe-grid/tests/Functional @@ -19,6 +20,9 @@ vendor/wedevelopnl/silverstripe-grid/tests/Integration/Fluent + + vendor/wedevelopnl/silverstripe-grid/tests/Integration/KeysForCache + diff --git a/.docker/compose.yml b/.docker/compose.yml index 8ec6c337..946ee69a 100644 --- a/.docker/compose.yml +++ b/.docker/compose.yml @@ -39,14 +39,14 @@ services: SS_PHPUNIT_FLUSH: 1 SS_GRID_ADAPTER: ${SS_GRID_ADAPTER} - app-fluent: - profiles: [fluent] + app-modules: + profiles: [modules] build: context: . dockerfile: Dockerfile args: PHP_VERSION: ${PHP_VERSION:-8.3} - COMPOSER_FILE: app/composer.fluent.json + COMPOSER_FILE: app/composer.modules.json volumes: - ../composer.json:/module/composer.json:ro - ../src:/module/src @@ -59,7 +59,7 @@ services: - ../lang:/module/lang - ../templates:/module/templates:ro - ../coverage:/app/coverage - - vendor-fluent:/app/vendor + - vendor-modules:/app/vendor healthcheck: test: test -f /tmp/.app-ready interval: 3s @@ -70,7 +70,7 @@ services: condition: service_healthy environment: SS_DATABASE_SERVER: db - SS_DATABASE_NAME: silverstripe_fluent + SS_DATABASE_NAME: silverstripe_modules SS_DATABASE_USERNAME: silverstripe SS_DATABASE_PASSWORD: silverstripe SS_DEFAULT_ADMIN_USERNAME: admin @@ -98,5 +98,5 @@ services: volumes: vendor: - vendor-fluent: + vendor-modules: db-data: diff --git a/.docker/db-init/create-fluent-db.sql b/.docker/db-init/create-fluent-db.sql deleted file mode 100644 index c0dec09b..00000000 --- a/.docker/db-init/create-fluent-db.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE DATABASE IF NOT EXISTS `silverstripe_fluent`; diff --git a/.docker/db-init/create-modules-db.sql b/.docker/db-init/create-modules-db.sql new file mode 100644 index 00000000..2f310b9c --- /dev/null +++ b/.docker/db-init/create-modules-db.sql @@ -0,0 +1 @@ +CREATE DATABASE IF NOT EXISTS `silverstripe_modules`; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27c03099..705123ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,18 +63,18 @@ jobs: php: ['8.3', '8.4', '8.5'] env: PHP_VERSION: ${{ matrix.php }} - COMPOSE: docker compose -f .docker/compose.yml --profile fluent + COMPOSE: docker compose -f .docker/compose.yml --profile modules steps: - uses: actions/checkout@v4 - run: make .docker/.env - run: $COMPOSE up -d --build --wait name: Start services - run: >- - $COMPOSE exec app-fluent vendor/bin/phpunit + $COMPOSE exec app-modules vendor/bin/phpunit --log-junit coverage/junit.xml --coverage-clover coverage/clover.xml name: Run all tests with coverage - - run: $COMPOSE exec app-fluent vendor/bin/coverage-check coverage/clover.xml 90 + - run: $COMPOSE exec app-modules vendor/bin/coverage-check coverage/clover.xml 90 name: Check coverage threshold (90%) - uses: dorny/test-reporter@v1 if: always() diff --git a/AGENTS.md b/AGENTS.md index 2a889886..c7e63b99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,10 @@ # AGENTS.md - - - + + ## Files matching `**/*` - # Architecture ``` @@ -77,7 +75,7 @@ docs/architecture/ # Architecture documents (backend, drag-and-drop) ## PHP Testing - PHPUnit 11 — runs inside Docker via `make test` -- PHPUnit config: `.docker/app/phpunit.xml.dist` (defines `unit`, `integration`, `functional`, and `fluent` testsuites, selected via `--testsuite` flag) +- PHPUnit config: `.docker/app/phpunit.xml.dist` (defines `unit`, `integration`, `functional`, `fluent`, and `kfc` testsuites, selected via `--testsuite` flag; `fluent` and `kfc` require the optional-modules env — `make test-modules`) - Test namespace: `WeDevelop\Grid\Tests\` → `tests/` (Unit/ + Integration/) ## Static Analysis @@ -97,14 +95,12 @@ docs/architecture/ # Architecture documents (backend, drag-and-drop) - `FlipTypeControlToUseExclusiveTypeRector` — `$x !== null` on a typed `?Foo` property is more honest about intent than `$x instanceof Foo` - `PostIncDecToPreIncDecRector` — pure micro-style, codebase consistently uses post-increment - # Code Style - 4 spaces: PHP, `composer.json` - 2 spaces: YML, JS, TS, TSX, JSON, CSS, SCSS (enforced via `.editorconfig`) - LF line endings, UTF-8, trailing newline - # Commands ## npm @@ -144,7 +140,7 @@ docs/architecture/ # Architecture documents (backend, drag-and-drop) | `make test-unit` | Run PHP unit tests (no database/framework) | | `make test-integration` | Run PHP integration tests (full SilverStripe env) | | `make test-functional` | Run PHP functional tests (HTTP/controller tests) | -| `make test-fluent` | Run integration + functional + fluent tests in Fluent env | +| `make test-modules` | Run integration + functional + fluent + kfc tests in the optional-modules env (Fluent + keys-for-cache installed) | | `make test-js` | Run JavaScript tests (Vitest, no Docker needed) | | `make coverage` | Merged PHP coverage report (HTML + Clover) | | `make coverage-unit` | PHP unit test coverage only | @@ -163,7 +159,6 @@ docs/architecture/ # Architecture documents (backend, drag-and-drop) | `make qa` | Full QA suite (PHPStan + Rector + PHP coverage + JS QA, parallel) | | `make qa-js` | JavaScript QA (Biome + Stylelint + typecheck + Vitest + vite build) | - # Docker Dev Environment - `make up` auto-generates `.docker/.env` if missing — no manual step needed @@ -173,7 +168,6 @@ docs/architecture/ # Architecture documents (backend, drag-and-drop) - Default admin credentials: `admin`/`admin` - Use `make up`/`make down` to manage services (see Commands) - # Element Hierarchy ## Structure @@ -295,7 +289,6 @@ YAML fixtures are written **top-down** (page → section → row → column → See [docs/testing/e2e-fixtures.md](../../docs/testing/e2e-fixtures.md) for the full protocol (YAML schema, post-actions, URL segment conventions, the `FixtureController` HTTP endpoint). - # Gotchas ## Grid Domain @@ -328,8 +321,8 @@ See [docs/testing/e2e-fixtures.md](../../docs/testing/e2e-fixtures.md) for the f ## Tooling - **CLAUDE.md and AGENTS.md are regenerated from `.apm/instructions/` on every `apm compile`** — direct edits to the generated files get overwritten silently. Edit the APM sources instead. +- **Editing a single-file bind mount (e.g. `composer.json`) while dev containers run breaks it inside the container.** `.docker/compose.yml` mounts individual host files (notably `../composer.json:/module/composer.json:ro`) into both `app` and `app-modules`. A single-file bind mount is pinned to the host file's inode; saving the file via atomic replace (write-temp + rename — what most editors and tooling do) swaps the inode, so the mount inside an already-running container goes stale and `/module/composer.json` reads as "No such file or directory". This surfaces as `ModuleResourceLoader` throwing `InvalidArgumentException: Can't find module 'wedevelopnl/silverstripe-grid'`, cascading into many unrelated-looking test errors (FixtureLoaderTest, ColumnWidthPickerFieldTest, FixtureControllerTest, …) — classes still autoload (separate from the module manifest), so only resource-resolution paths break. Fix: `docker compose -f .docker/compose.yml [--profile modules] up -d --force-recreate --wait ` to re-resolve the mount. Not a code bug and not a CI problem (CI starts containers fresh after checkout). After editing `composer.json` while containers are up, `--force-recreate` before trusting test results. - # Grid Adapter System Grid adapters translate the abstract grid model (viewports, column widths, offsets, visibility) into CSS framework-specific class names. All consumers depend on `GridAdapterInterface`, never on a concrete adapter. @@ -369,7 +362,6 @@ The adapter is entirely configuration-driven. `GridAdapter` is a single concrete For the full property reference, sprintf arg tables, implementation walkthrough, and content layout details, see `docs/architecture/grid-adapter.md`. - # Project Overview SilverStripe Grid — a grid-based content block system for SilverStripe 6 CMS providing structured Section > Row > Column layouts with configurable CSS framework adapters (Bootstrap, Tailwind, Bulma). @@ -386,7 +378,6 @@ Package: `wedevelopnl/silverstripe-grid` (type: `silverstripe-vendormodule`) ## Files matching `**/*.php` - # PHP Conventions ## SilverStripe Dependency Injection @@ -439,7 +430,6 @@ Use the narrowest PHPStan PHPDoc type that matches the domain constraint. Prefer ## Files matching `**/*.{ts,tsx}` - # TypeScript & React Conventions ## Stack diff --git a/CLAUDE.md b/CLAUDE.md index 5fa93a15..813b71fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,13 +1,11 @@ # CLAUDE.md - - + # Project Standards ## Files matching `**/*` - # Architecture ``` @@ -78,7 +76,7 @@ docs/architecture/ # Architecture documents (backend, drag-and-drop) ## PHP Testing - PHPUnit 11 — runs inside Docker via `make test` -- PHPUnit config: `.docker/app/phpunit.xml.dist` (defines `unit`, `integration`, `functional`, and `fluent` testsuites, selected via `--testsuite` flag) +- PHPUnit config: `.docker/app/phpunit.xml.dist` (defines `unit`, `integration`, `functional`, `fluent`, and `kfc` testsuites, selected via `--testsuite` flag; `fluent` and `kfc` require the optional-modules env — `make test-modules`) - Test namespace: `WeDevelop\Grid\Tests\` → `tests/` (Unit/ + Integration/) ## Static Analysis @@ -98,14 +96,12 @@ docs/architecture/ # Architecture documents (backend, drag-and-drop) - `FlipTypeControlToUseExclusiveTypeRector` — `$x !== null` on a typed `?Foo` property is more honest about intent than `$x instanceof Foo` - `PostIncDecToPreIncDecRector` — pure micro-style, codebase consistently uses post-increment - # Code Style - 4 spaces: PHP, `composer.json` - 2 spaces: YML, JS, TS, TSX, JSON, CSS, SCSS (enforced via `.editorconfig`) - LF line endings, UTF-8, trailing newline - # Commands ## npm @@ -145,7 +141,7 @@ docs/architecture/ # Architecture documents (backend, drag-and-drop) | `make test-unit` | Run PHP unit tests (no database/framework) | | `make test-integration` | Run PHP integration tests (full SilverStripe env) | | `make test-functional` | Run PHP functional tests (HTTP/controller tests) | -| `make test-fluent` | Run integration + functional + fluent tests in Fluent env | +| `make test-modules` | Run integration + functional + fluent + kfc tests in the optional-modules env (Fluent + keys-for-cache installed) | | `make test-js` | Run JavaScript tests (Vitest, no Docker needed) | | `make coverage` | Merged PHP coverage report (HTML + Clover) | | `make coverage-unit` | PHP unit test coverage only | @@ -164,7 +160,6 @@ docs/architecture/ # Architecture documents (backend, drag-and-drop) | `make qa` | Full QA suite (PHPStan + Rector + PHP coverage + JS QA, parallel) | | `make qa-js` | JavaScript QA (Biome + Stylelint + typecheck + Vitest + vite build) | - # Docker Dev Environment - `make up` auto-generates `.docker/.env` if missing — no manual step needed @@ -174,7 +169,6 @@ docs/architecture/ # Architecture documents (backend, drag-and-drop) - Default admin credentials: `admin`/`admin` - Use `make up`/`make down` to manage services (see Commands) - # Element Hierarchy ## Structure @@ -296,7 +290,6 @@ YAML fixtures are written **top-down** (page → section → row → column → See [docs/testing/e2e-fixtures.md](../../docs/testing/e2e-fixtures.md) for the full protocol (YAML schema, post-actions, URL segment conventions, the `FixtureController` HTTP endpoint). - # Gotchas ## Grid Domain @@ -329,8 +322,8 @@ See [docs/testing/e2e-fixtures.md](../../docs/testing/e2e-fixtures.md) for the f ## Tooling - **CLAUDE.md and AGENTS.md are regenerated from `.apm/instructions/` on every `apm compile`** — direct edits to the generated files get overwritten silently. Edit the APM sources instead. +- **Editing a single-file bind mount (e.g. `composer.json`) while dev containers run breaks it inside the container.** `.docker/compose.yml` mounts individual host files (notably `../composer.json:/module/composer.json:ro`) into both `app` and `app-modules`. A single-file bind mount is pinned to the host file's inode; saving the file via atomic replace (write-temp + rename — what most editors and tooling do) swaps the inode, so the mount inside an already-running container goes stale and `/module/composer.json` reads as "No such file or directory". This surfaces as `ModuleResourceLoader` throwing `InvalidArgumentException: Can't find module 'wedevelopnl/silverstripe-grid'`, cascading into many unrelated-looking test errors (FixtureLoaderTest, ColumnWidthPickerFieldTest, FixtureControllerTest, …) — classes still autoload (separate from the module manifest), so only resource-resolution paths break. Fix: `docker compose -f .docker/compose.yml [--profile modules] up -d --force-recreate --wait ` to re-resolve the mount. Not a code bug and not a CI problem (CI starts containers fresh after checkout). After editing `composer.json` while containers are up, `--force-recreate` before trusting test results. - # Grid Adapter System Grid adapters translate the abstract grid model (viewports, column widths, offsets, visibility) into CSS framework-specific class names. All consumers depend on `GridAdapterInterface`, never on a concrete adapter. @@ -370,7 +363,6 @@ The adapter is entirely configuration-driven. `GridAdapter` is a single concrete For the full property reference, sprintf arg tables, implementation walkthrough, and content layout details, see `docs/architecture/grid-adapter.md`. - # Project Overview SilverStripe Grid — a grid-based content block system for SilverStripe 6 CMS providing structured Section > Row > Column layouts with configurable CSS framework adapters (Bootstrap, Tailwind, Bulma). @@ -387,7 +379,6 @@ Package: `wedevelopnl/silverstripe-grid` (type: `silverstripe-vendormodule`) ## Files matching `**/*.php` - # PHP Conventions ## SilverStripe Dependency Injection @@ -440,7 +431,6 @@ Use the narrowest PHPStan PHPDoc type that matches the domain constraint. Prefer ## Files matching `**/*.{ts,tsx}` - # TypeScript & React Conventions ## Stack @@ -486,7 +476,3 @@ See the `dnd-guide` skill for the full reference — coordinate spaces, collisio ### GridSettings (Default + Overrides) Column grid settings use an intent-based model: `{ default: ViewportSettings, overrides: Record }`. The `default` holds the base layout (width, offset, visible) applied to all viewports. The `overrides` map holds per-viewport deviations. `resolveViewportSettings` resolves the effective settings for a given viewport by checking for an override, falling back to the default. - ---- -*This file was generated by APM CLI. Do not edit manually.* -*To regenerate: `apm compile`* diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000..7ee18ef6 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,6 @@ +# GEMINI.md + + + + +@./AGENTS.md diff --git a/Makefile b/Makefile index 5ce48dac..34d95a44 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ COMPOSE := docker compose -f .docker/compose.yml -.PHONY: up down destroy build test test-unit test-integration test-functional ensure-up-fluent test-fluent test-js test-e2e test-e2e-ui coverage coverage-unit coverage-integration coverage-functional coverage-js coverage-check mutate mutate-js analyse rector rector-dry qa qa-js flush dev-build _qa-analyse _qa-coverage _qa-lint _qa-format _qa-typecheck _qa-test-js _qa-build _qa-rector +.PHONY: up down destroy build test test-unit test-integration test-functional ensure-up-modules test-modules test-js test-e2e test-e2e-ui coverage coverage-unit coverage-integration coverage-functional coverage-js coverage-check mutate mutate-js analyse rector rector-dry qa qa-js flush dev-build _qa-analyse _qa-coverage _qa-lint _qa-format _qa-typecheck _qa-test-js _qa-build _qa-rector ## Generate .docker/.env with deterministic ports (auto-runs if missing) .docker/.env: @@ -42,21 +42,21 @@ test-integration: ensure-up test-functional: ensure-up $(COMPOSE) exec app vendor/bin/phpunit --testsuite functional -## Ensure Fluent services are running and ready -ensure-up-fluent: .docker/.env - @$(COMPOSE) --profile fluent exec app-fluent true 2>/dev/null || $(COMPOSE) --profile fluent up -d --build --wait +## Ensure optional-module services are running and ready +ensure-up-modules: .docker/.env + @$(COMPOSE) --profile modules exec app-modules true 2>/dev/null || $(COMPOSE) --profile modules up -d --build --wait -## Run all integration + fluent tests in Fluent environment -test-fluent: ensure-up-fluent - $(COMPOSE) exec app-fluent vendor/bin/phpunit --testsuite integration,functional,fluent +## Run all integration + functional + fluent + kfc tests in the optional-modules environment +test-modules: ensure-up-modules + $(COMPOSE) exec app-modules vendor/bin/phpunit --testsuite integration,functional,fluent,kfc ## Run JavaScript tests (Vitest) test-js: npm run test ## Run all tests with merged coverage (HTML + Clover XML) -coverage: ensure-up-fluent - $(COMPOSE) exec app-fluent vendor/bin/phpunit \ +coverage: ensure-up-modules + $(COMPOSE) exec app-modules vendor/bin/phpunit \ --coverage-html coverage/combined/html \ --coverage-clover coverage/combined/clover.xml @@ -67,8 +67,8 @@ coverage-unit: ensure-up --coverage-clover coverage/unit/clover.xml ## Run integration tests with coverage (individual report) -coverage-integration: ensure-up-fluent - $(COMPOSE) exec app-fluent vendor/bin/phpunit --testsuite integration,fluent \ +coverage-integration: ensure-up-modules + $(COMPOSE) exec app-modules vendor/bin/phpunit --testsuite integration,fluent,kfc \ --coverage-html coverage/integration/html \ --coverage-clover coverage/integration/clover.xml @@ -87,8 +87,8 @@ coverage-check: coverage $(COMPOSE) exec app vendor/bin/coverage-check coverage/combined/clover.xml 90 ## Run PHP mutation testing (Infection) — uses Fluent container so all tests run -mutate: ensure-up-fluent - $(COMPOSE) exec app-fluent php -d memory_limit=256M vendor/bin/infection --threads=4 +mutate: ensure-up-modules + $(COMPOSE) exec app-modules php -d memory_limit=256M vendor/bin/infection --threads=4 ## Run JavaScript mutation testing (Stryker) mutate-js: @@ -107,7 +107,7 @@ rector-dry: ensure-up $(COMPOSE) exec app vendor/bin/rector process --dry-run ## Run full QA suite (all checks in parallel) -qa: ensure-up ensure-up-fluent +qa: ensure-up ensure-up-modules $(MAKE) -j8 --output-sync=target _qa-analyse _qa-coverage _qa-lint _qa-format _qa-typecheck _qa-test-js _qa-build _qa-rector ## QA sub-targets (not intended to be called directly) @@ -123,10 +123,10 @@ _qa-rector: _qa-coverage: @echo "==> [coverage] running PHPUnit with coverage (slow, several minutes)..." - $(COMPOSE) exec -T app-fluent vendor/bin/phpunit \ + $(COMPOSE) exec -T app-modules vendor/bin/phpunit \ --coverage-html coverage/combined/html \ --coverage-clover coverage/combined/clover.xml - $(COMPOSE) exec -T app-fluent vendor/bin/coverage-check coverage/combined/clover.xml 90 + $(COMPOSE) exec -T app-modules vendor/bin/coverage-check coverage/combined/clover.xml 90 @echo "==> [coverage] done" _qa-lint: diff --git a/_config/keys-for-cache.yml b/_config/keys-for-cache.yml new file mode 100644 index 00000000..ca4cd2b8 --- /dev/null +++ b/_config/keys-for-cache.yml @@ -0,0 +1,16 @@ +--- +Name: grid-keys-for-cache +Only: + moduleexists: silverstripe-terraformers/keys-for-cache +--- +WeDevelop\Grid\Model\GridElement: + has_cache_key: true +WeDevelop\Grid\Model\Section: + cares: + - Rows +WeDevelop\Grid\Model\Row: + cares: + - Columns +WeDevelop\Grid\Model\Column: + cares: + - Elements diff --git a/composer.json b/composer.json index aa3fcdd2..0d3e3876 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ ] }, "suggest": { + "silverstripe-terraformers/keys-for-cache": "Enables automatic cache-key generation and invalidation for grid elements (see docs/architecture/keys-for-cache.md)", "silverstripe/reports": "Enables the Grid Elements report in CMS → Reports", "tractorcow/silverstripe-fluent": "Required for multi-locale support with isolated element records per locale" }, diff --git a/docs/architecture/keys-for-cache.md b/docs/architecture/keys-for-cache.md new file mode 100644 index 00000000..46eee48f --- /dev/null +++ b/docs/architecture/keys-for-cache.md @@ -0,0 +1,44 @@ +# Keys for Cache Integration + +The grid optionally integrates with [keys-for-cache](https://github.com/silverstripe-terraformers/keys-for-cache) (KFC) to provide reliable, auto-invalidating cache keys for grid elements. KFC is **not** a hard dependency — the module imports no KFC classes and works without it. Install KFC `^3.0` (the SilverStripe 6-compatible release) to enable the integration. + +## What activates + +`_config/keys-for-cache.yml` is guarded by `Only: moduleexists: silverstripe-terraformers/keys-for-cache`, so its config applies only when KFC is installed. With KFC present it: + +- Opts every grid element into a maintained cache key by setting `has_cache_key: true` on `GridElement`. This is inherited by `Section`, `Row`, `Column`, `ContentElement`, and any custom block you subclass from them. +- Declares `cares` downward — `Section` → `Rows`, `Row` → `Columns`, `Column` → `Elements` — so a change to any descendant invalidates its ancestors' cache keys. + +KFC applies its `CacheKeyExtension` to `DataObject` globally, so this module imports no KFC classes — the integration is config-only. + +## Using the cache key in templates + +Every grid element exposes a `$CacheKey`. Wrap whichever level you cache with a `<% cached %>` block. For example, caching each top-level section: + +```html +<% loop $Sections %><% cached $CacheKey %>$Me<% end_cached %><% end_loop %> +``` + +## Media (known limitation) + +Media images are **not** part of the cache-key graph. KFC can only `cares` about a relation that has a reciprocal back-relation, and the grid's media (`BlockMediaExtension`'s `MediaImage` / `VideoCustomThumbnail`) are `has_one` relations to the shared `Image` class, which has no reciprocal relation back to the element. Declaring a `cares` dependency on them is therefore not possible. + +Consequences: + +- Editing media **via the element's CMS form** re-saves the element, which **does** invalidate the element's cache key (through the downward `cares` chain). +- Editing an in-use `Image` record **directly in asset-admin** does **not** auto-invalidate the element's cache key. + +A per-element owned media model — one KFC could traverse with a reciprocal relation — is a planned follow-up. + +## With Fluent + +KFC's cache keys are not locale-aware out of the box. For locale-safe keys, include the locale (and reading mode / user) in the global cache key, in the project's config: + +```yaml +SilverStripe\View\SSViewer: + global_key: '$CurrentReadingMode, $CurrentUser.ID, $CurrentLocale' +``` + +## Cost + +KFC processes its relationship graph synchronously on every write, so very large grids pay a per-write cost. This applies only when KFC is installed. diff --git a/tests/Integration/KeysForCache/GridCacheKeyTest.php b/tests/Integration/KeysForCache/GridCacheKeyTest.php new file mode 100644 index 00000000..4ba6151c --- /dev/null +++ b/tests/Integration/KeysForCache/GridCacheKeyTest.php @@ -0,0 +1,151 @@ +Title = 'KFC Test'; + $page->URLSegment = $segment; + $page->writeToStage(Versioned::DRAFT); + + return $page; + } + + /** Build Section→Row→Column (auto-scaffolded) and return the leaf Column. */ + private function createGrid(Page $page): Column + { + $section = Section::create(); + $section->Title = 'Section'; + $section->Zone = 'main'; + $section->ParentID = $page->ID; + $section->ParentClass = $page::class; + $section->write(); + + /** @var Row $row */ + $row = $section->getChildren()->first(); + /** @var Column $column */ + $column = $row->getChildren()->first(); + + return $column; + } + + /** Re-fetch a record from the DB so cached in-memory state is dropped. */ + private function fresh(string $class, int $id): object + { + return $class::get()->byID($id); + } + + public function testRelationshipGraphBuildsWithoutError(): void + { + // KFC builds its relationship graph lazily on the first write of a + // has_cache_key object, throwing GraphBuildException if any `cares` + // relation name in our config is unresolvable. Writing a Section + // (and its auto-scaffolded children) exercises that path. + $page = $this->createPage('kfc-graph'); + $this->createGrid($page); + self::assertTrue(true, 'No GraphBuildException thrown while building the grid'); + } + + public function testGridElementsExposeCacheKey(): void + { + $page = $this->createPage('kfc-keys'); + $column = $this->createGrid($page); + $row = $column->Parent(); + $section = $row->Parent(); + + self::assertNotNull($section->getCacheKey()); + self::assertNotSame('', $section->getCacheKey()); + self::assertNotNull($row->getCacheKey()); + self::assertNotSame('', $row->getCacheKey()); + self::assertNotNull($column->getCacheKey()); + self::assertNotSame('', $column->getCacheKey()); + } + + public function testDescendantChangeInvalidatesAncestorKeys(): void + { + $page = $this->createPage('kfc-transitive'); + $column = $this->createGrid($page); + $row = $column->Parent(); + $section = $row->Parent(); + + $sectionBefore = $section->getCacheKey(); + $rowBefore = $row->getCacheKey(); + $columnBefore = $column->getCacheKey(); + + // KFC only updates a record's cache key once per "request". A PHPUnit + // test case is one request, so the scaffolding writes above already + // processed the ancestor keys. Flush KFC's processed-update cache so + // the descendant write below actually invalidates them — this is the + // mechanism KFC's own unit-testing docs prescribe. + ProcessedUpdatesService::singleton()->flush(); + + // Add a leaf content element under the column and write it. + $element = ContentElement::create(); + $element->Title = 'Leaf'; + $element->ParentID = $column->ID; + $element->ParentClass = $column::class; + $element->write(); + + $sectionAfter = $this->fresh(Section::class, $section->ID)->getCacheKey(); + $rowAfter = $this->fresh(Row::class, $row->ID)->getCacheKey(); + $columnAfter = $this->fresh(Column::class, $column->ID)->getCacheKey(); + + self::assertNotSame($columnBefore, $columnAfter, 'Column key must change'); + self::assertNotSame($rowBefore, $rowAfter, 'Row key must change (transitive)'); + self::assertNotSame($sectionBefore, $sectionAfter, 'Section key must change (transitive)'); + } + + public function testUnrelatedTreeKeyIsUnaffected(): void + { + $pageA = $this->createPage('kfc-iso-a'); + $columnA = $this->createGrid($pageA); + + $pageB = $this->createPage('kfc-iso-b'); + $columnB = $this->createGrid($pageB); + $sectionB = $columnB->Parent()->Parent(); + $sectionBBefore = $sectionB->getCacheKey(); + + // Flush KFC's per-request processed-update cache so the write below + // genuinely fires cache invalidation (see the transitive test). Without + // this the unrelated key would be unchanged for the wrong reason. + ProcessedUpdatesService::singleton()->flush(); + + // Change tree A. + $element = ContentElement::create(); + $element->Title = 'A leaf'; + $element->ParentID = $columnA->ID; + $element->ParentClass = $columnA::class; + $element->write(); + + $sectionBAfter = $this->fresh(Section::class, $sectionB->ID)->getCacheKey(); + self::assertSame($sectionBBefore, $sectionBAfter, 'Unrelated tree key must not change'); + } +} diff --git a/tests/Integration/KeysForCache/StagingCacheKeyTest.php b/tests/Integration/KeysForCache/StagingCacheKeyTest.php new file mode 100644 index 00000000..aef7e96f --- /dev/null +++ b/tests/Integration/KeysForCache/StagingCacheKeyTest.php @@ -0,0 +1,81 @@ +byID($sectionId); + + return $section?->getCacheKey(); + }); + } + + public function testDraftEditDoesNotChangeLiveKeyUntilPublish(): void + { + $page = Page::create(); + $page->Title = 'Staging'; + $page->URLSegment = 'kfc-staging'; + $page->write(); + + $section = Section::create(); + $section->Title = 'Section'; + $section->Zone = 'main'; + $section->ParentID = $page->ID; + $section->ParentClass = $page::class; + $section->write(); + + /** @var Column $column */ + $column = $section->getChildren()->first()->getChildren()->first(); + + // Publish the whole owned tree to LIVE. + $page->publishRecursive(); + + $liveKeyBefore = $this->liveSectionKey($section->ID); + self::assertNotNull($liveKeyBefore); + + // Draft-only edit: add a leaf element, do NOT publish. + ProcessedUpdatesService::singleton()->flush(); + $element = ContentElement::create(); + $element->Title = 'Draft leaf'; + $element->ParentID = $column->ID; + $element->ParentClass = $column::class; + $element->write(); + + $liveKeyAfterDraft = $this->liveSectionKey($section->ID); + self::assertSame($liveKeyBefore, $liveKeyAfterDraft, 'Live key must be stable across draft-only edits'); + + // Publish the new element; live key must now change. + ProcessedUpdatesService::singleton()->flush(); + $element->publishRecursive(); + + $liveKeyAfterPublish = $this->liveSectionKey($section->ID); + self::assertNotSame($liveKeyBefore, $liveKeyAfterPublish, 'Live key must change after publish'); + } +}