diff --git a/README.md b/README.md index fe22b03..b51fc20 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,22 @@ Requires PHP 8.4.1+ or 8.5 and Laravel 13. Works on MySQL, PostgreSQL and SQLite > **Pre-1.0.** 0.1.0 is the first tagged release. Before 1.0 a minor release may change the API; > [Upgrading](docs/upgrading.md) and the [CHANGELOG](CHANGELOG.md) say what changed. +```mermaid +flowchart LR + php["PHP API: facade, settings(), HasSettings, typed classes"] --> actions + rest["REST API, opt-in"] --> actions + cli["Artisan commands"] --> actions + actions["the same actions: authorize, validate, write, audit"] --> stores + subgraph stores ["Stores, each holding values per owner: the application, a user, a tenant, a product"] + direction LR + database["database layer"] --> runtime["runtime JSON file"] --> shipped["shipped defaults file"] + end +``` + +Reads walk a store's layers in order. While the application installs, a store in bootstrap mode +is answered by its files, and `promote` later moves what was written to them into the database. [Architecture](docs/architecture.md) +has the read and write paths and the tables. + ## Install ```bash diff --git a/docs/architecture.md b/docs/architecture.md index 114ccd0..590217a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,6 +18,164 @@ database exists, and after it does. - **Categories.** A shared `settings_categories` table, scoped per store, organises settings for people and interfaces; `group` remains a lightweight code namespace. +## How a read resolves + +`Services\Resolution\Resolver` walks the cascade one scope at a time: the owner, then its parents +(only when the definition, or else the store, inherits), then the global value. At each scope it +tries the requested locale, the fallback locale and no locale, and within each the store's layers +in their configured order; the first stored value wins, and a stored `null` is an answer. Nothing +stored anywhere falls through to the definition's default, then to the default passed to `get()`. +The diagram shows the default store's layers; a store lists its own. + +```mermaid +flowchart TD + read["get('ui.theme') on a handle for user:7"] --> owner["owner scope, user:7"] + owner -->|nothing stored| parents["parent scopes, e.g. team:3, when the setting inherits"] + parents -->|nothing stored| global["global scope"] + global -->|nothing stored| definition["the definition's default"] + definition -->|no default| caller["the default passed to get()"] + owner -.-> layers + parents -.-> layers + global -.-> layers + subgraph layers ["At each scope and locale, the store's layers in order; the first stored value wins"] + direction LR + database["database"] --> runtime["runtime JSON file"] --> shipped["shipped PHP file"] + end + subgraph loading ["How the database layer loads a scope"] + direction LR + preloaded["rows preloaded this request"] -->|miss| bag["cached bag for the scope"] -->|miss or old generation| query["one query on the values table"] + end + database -.-> preloaded +``` + +The cache sits inside the database layer (`Drivers\CachingDriver`), never in front of a resolved +value, so a write invalidates one scope's bag (see [Why cache raw bags?](#why-cache-raw-bags)). A +store in bootstrap mode skips its database layer while an install is under way, and the files answer. + +## How a write runs + +Every write, from any channel, is one action. The sequence below is `set()`; `forget()`, `lock()`, +`unlock()`, a rollback and an approved change request take the same path. + +```mermaid +sequenceDiagram + autonumber + participant Caller as Caller, from PHP, REST or a command + participant Guard as SetSetting and WriteGuard + participant Access as AccessGuard + participant Writer as SettingWriter + participant Values as ValuePipeline + participant Db as Database layer + Caller->>Guard: set key to value + Guard->>Access: authorize update, token ceiling then authorizer then step-up + alt the setting needs approval + Guard-->>Caller: a change request is opened instead, nothing is written + end + Guard->>Db: begin AuditedTransaction + Guard->>Writer: write + Writer->>Writer: refuse a locked row or an owner alias the store does not list + Writer->>Db: read the current row, FOR UPDATE on the retry + Writer->>Writer: compare the expected version, ConcurrentModification on a mismatch + Writer->>Values: validate, cast, encode, check values.max_bytes, encrypt + Writer->>Db: UPDATE at the version, else restore a trashed row, else INSERT in a savepoint + Writer->>Db: append the settings_history row + Guard->>Db: append the settings_audit row, outcome allowed + Guard->>Db: commit + Db-->>Caller: after commit, rotate the scope's cache generation and dispatch SettingSaved +``` + +A plain `set()` that loses a race retries once (`writes.retry_on_conflict`); a write naming an +expected version (`If-Match`) never does. A failure rolls the change, its history and its audit +row back together, and records a `failed` audit entry outside the transaction. A store without a +database layer writes to its first writable file layer, with no history row. + +## The tables + +The one migration creates these tables, on the store's connection. `settings` is the default +store's values table; each database store has its own, of the same shape. `settings_definitions` +and `settings_stores` live on the default store's connection only. A dedicated store has an +`owner_id` foreign key to its owner table instead of `settable_type` and `settable_id`, and in +`column` tenancy every table except those two gains a `tenant` column that leads its keys. + +```mermaid +erDiagram + settings_categories |o--o{ settings_categories : "parent_id" + settings_categories |o--o{ settings : "category_id, category_store" + settings_categories |o--o{ settings_definitions : "category_id" + settings ||..o{ settings_history : "uid is setting_ref" + settings ||..o{ settings_change_requests : "store, scope, key, locale" + settings_change_requests ||..o{ settings_audit : "change_request" + settings_categories { + ulid id PK + string store UK + string slug UK + ulid parent_id FK + json title + boolean is_system + } + settings { + ulid id PK + string scope UK "global or alias:id" + string key UK + string locale UK + ulid uid UK + string settable_type + string settable_id + ulid category_id FK + longText value + boolean is_encrypted + boolean is_locked + int version + timestamp deleted_at + } + settings_definitions { + ulid id PK + string store UK + string key UK + string slug UK + ulid category_id FK + string type + boolean is_system + } + settings_history { + ulid id PK + string setting_ref + string scope + string key + int version + longText old_value + longText new_value + } + settings_change_requests { + ulid id PK + string scope + string key + longText payload + int base_version + string status + timestamp expires_at + } + settings_audit { + ulid id PK + timestamp occurred_at + string action + string outcome + string scope + string key + string change_request + string channel + } + settings_stores { + ulid id PK + string kind UK + string name UK + json config + } +``` + +Solid lines are foreign keys; dotted lines are joins the package makes without one. Every column +is listed in [Database](tools/database.md). + ## How the code is layered | Layer | Holds | Examples | diff --git a/docs/recipes/moving-from-files-to-the-database.md b/docs/recipes/moving-from-files-to-the-database.md index 345aa7b..059825e 100644 --- a/docs/recipes/moving-from-files-to-the-database.md +++ b/docs/recipes/moving-from-files-to-the-database.md @@ -111,6 +111,33 @@ Settings::store('settings')->asSystem('installer')->promoteWhenReady(); It promotes at once when it can, and otherwise sets the store to `auto` for the rest of the request, job or command. +## The lifecycle + +Manual and automatic promotion are the same action, `Actions\PromoteStore`; they differ in what +starts it and in how a held lock is reported. + +```mermaid +flowchart TD + install["Bootstrap mode: writes go to the runtime file"] --> migrate["php artisan migrate"] + migrate --> mode{"the store's promote mode"} + mode -->|manual| command["laranail::settings.promote, or promote() in code"] + mode -->|auto| triggers["MigrationsEnded, each request once the response is sent, each queued job"] + code["promoteWhenReady() in code"] --> ready + triggers --> ready{"the file holds a selected value, the database answers and the table exists"} + ready -->|no| later["nothing runs; promoteWhenReady() sets the store to auto for the rest of the request"] + ready -->|yes| lock{"the promote lock"} + command --> lock + lock -->|held elsewhere| held["automatic: skipped quietly. Command: PromotionLocked"] + lock -->|taken| copy["copy the selected values in one audited transaction, skipping keys already in the database and, in a strict store, undefined keys"] + copy -->|fails| failed["automatic: a warning is logged, the file keeps answering, the next run retries"] + copy --> left{"values the selection left out"} + left -->|none| archive["archive the file as settings.promoted-timestamp.json"] + left -->|some| remainder["archive the original, rewrite the file with the remainder"] + left -->|all of them| untouched["the file is left as it is"] + archive --> done["reads come from the database"] + remainder --> done +``` + ## Leaving bootstrap mode Once the values are in the database, remove `LARANAIL_SETTINGS_BOOTSTRAP`. From then on an unreachable database raises diff --git a/docs/tools/approvals.md b/docs/tools/approvals.md index a3b4684..a96e6ba 100644 --- a/docs/tools/approvals.md +++ b/docs/tools/approvals.md @@ -65,6 +65,29 @@ The last approval applies the change through the normal write pipeline, with the | `stale` | the setting changed first; nothing written | | `expired` | not decided in time; nothing written | +```mermaid +stateDiagram-v2 + [*] --> open: propose(), or a write that needs approval + state open { + [*] --> pending + pending --> approved: an approval, more still needed + approved --> approved: another approval, more still needed + } + open --> applied: the last approval, setting still at its base version + open --> stale: the last approval, setting changed since + open --> rejected: reject() by an approver + open --> cancelled: cancel() by the requester + open --> expired: past expires_at, found on a decision or by prune-changes + applied --> [*] + stale --> [*] + rejected --> [*] + cancelled --> [*] + expired --> [*] +``` + +`pending` and `approved` are open; every other status is final, and `prune-changes` deletes a +final request once it is older than `approvals.keep_days`. + Events, all dispatched after commit and carrying identifiers only: `ChangeRequested`, `ChangeApproved` (with how many approvals remain), `ChangeApplied` (with the new version), `ChangeRejected`, `ChangeCancelled`, `ChangeStale`. The audit trail records the proposal (`propose`, outcome `pending`), each approval, the rejection or cancellation, and a self-approval refused, each with the change request's id. ## Where requests are kept diff --git a/docs/tools/commands.md b/docs/tools/commands.md index b8f7a66..4394783 100644 --- a/docs/tools/commands.md +++ b/docs/tools/commands.md @@ -9,7 +9,7 @@ Nineteen Artisan commands, all named `laranail::settings.`. | `laranail::settings.install` | publishes the config and the package's one migration, syncs definitions once the table exists; refuses to run over a foreign table of the same name | | `laranail::settings.make-store {store} [--owner=*] [--connection=]` | writes a new store's migration (a copy of the package migration pinned to that store) and prints its configuration entry | | `laranail::settings.promote [store] [--force] [--from=] [--only=] [--owner=]` | copies values written before install from the runtime file into the database; `--only=settings,owners` and `--owner=user,team` copy only some (default: the store's resolved `promote` options), and the rest stay in the file | -| `laranail::settings.doctor [--json] [--fix]` | checks readiness, missing columns and rows the database cannot guard; `--fix` syncs definitions first | +| `laranail::settings.doctor [--json] [--fix]` | checks each database store's readiness, waiting automatic promotion, columns, owner key type, scopes, encryption, overdue change requests and categories, then definitions and persisted configuration; `--fix` syncs definitions first | | `laranail::settings.show {key} [--store=] [--owner=] [--locale=] [--reveal]` | prints how one setting resolves: value, layer, scope, type, version, flags and the latest audit entry | | `laranail::settings.sync-definitions` | copies code and config definitions into the definitions table as system rows | | `laranail::settings.prune-orphans [--dry-run]` | removes the settings of owners that no longer exist | diff --git a/docs/tools/rest-api.md b/docs/tools/rest-api.md index 6e07241..7a3af7b 100644 --- a/docs/tools/rest-api.md +++ b/docs/tools/rest-api.md @@ -46,6 +46,40 @@ Every route, with request and response examples, status codes and problem types, Each route runs the same action the PHP API runs, and a test reading the live router fails if a route names no controller method, a controller method is not routed, or one does not delegate to an action. +## How a request is handled + +A write, end to end. A read takes the same path without the idempotency middleware, `If-Match` +or a change request. + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Mw as http.middleware, throttle and IdempotentWrites + participant Ctl as SettingsController + participant Access as ApiActor and AccessGuard + participant Action as SetSetting + Client->>Mw: PUT /api/settings/v1/stores/settings/settings/site.name + Mw->>Mw: authenticate, rate limit + alt Idempotency-Key already answered + Mw-->>Client: the stored response, Idempotent-Replayed true + end + Mw->>Ctl: the request + Ctl->>Access: resolve the actor on the rest channel, 401 for nobody or the system actor + Ctl->>Ctl: If-Match against the current ETag, 412 on a mismatch + Ctl->>Action: handle the write with the actor and the expected version + Action->>Access: token ability laranail-settings:update, then the authorizer, then step-up + alt written + Ctl-->>Mw: 200 with the setting and its new ETag + else the setting needs approval + Ctl-->>Mw: 202 with the change request + else refused or invalid + Ctl-->>Mw: problem+json, e.g. 403, 412, 422, 423 or 428 + end + Mw->>Mw: store the response under the Idempotency-Key, unless it is a 5xx + Mw-->>Client: the response +``` + ## Requests and responses A setting: diff --git a/docs/tools/tenancy.md b/docs/tools/tenancy.md index e910445..8df872b 100644 --- a/docs/tools/tenancy.md +++ b/docs/tools/tenancy.md @@ -19,6 +19,22 @@ two tenants can hold the same key, and `''` is the central application's rows. T Definitions are shared by every tenant: they describe settings, they hold no tenant's values. In `column` mode, synced definitions link to the central (`''`) categories. +```mermaid +flowchart TD + call["a read or a write"] --> mode{"tenancy.mode"} + mode -->|none| single["one set of rows, no tenant column"] + mode -->|column| current["the TenantDiscriminator names the current tenant, or '' outside one"] + current --> stamp["each write stamps the tenant column"] + current --> filter["each query adds where tenant is the current one, in the driver and the models' tenant scope"] + stamp --> unique["tenant leads the unique keys, so two tenants can hold the same key"] + mode -->|database| connection["the tenancy package switches the connection"] + connection --> own["the store queries the current tenant's own tables"] + single --> cache["cache keys and preloaded rows are partitioned by tenant in every mode"] + unique --> cache + filter --> cache + own --> cache +``` + ## Who the current tenant is ```php diff --git a/tests/Unit/DocsConformanceTest.php b/tests/Unit/DocsConformanceTest.php index aa82c15..3f0f18a 100644 --- a/tests/Unit/DocsConformanceTest.php +++ b/tests/Unit/DocsConformanceTest.php @@ -6,10 +6,10 @@ uses(AssertsDocsConformance::class); -// Floor = the 52 pages shipped today (7 guides, 26 reference, 19 recipes; counted 2026-11-11). Raise it as pages +// Floor = the 53 pages shipped today (7 guides, 26 reference, 20 recipes; counted 2026-11-14). Raise it as pages // are added; a glob that suddenly matches fewer has broken rather than found a clean tree. it('conforms to the org docs standard', function (): void { - $this->assertDocsConformToStandard(dirname(__DIR__, 2), 52); + $this->assertDocsConformToStandard(dirname(__DIR__, 2), 53); expect(true)->toBeTrue(); }); diff --git a/tests/Unit/MermaidDiagramsTest.php b/tests/Unit/MermaidDiagramsTest.php new file mode 100644 index 0000000..18491d2 --- /dev/null +++ b/tests/Unit/MermaidDiagramsTest.php @@ -0,0 +1,82 @@ +> file => its mermaid blocks */ +function mermaidBlocks(): array +{ + $root = dirname(__DIR__, 2); + $files = [$root . '/README.md', ...(glob($root . '/docs/*.md') ?: []), ...(glob($root . '/docs/*/*.md') ?: [])]; + $blocks = []; + + foreach ($files as $file) { + preg_match_all('/^```mermaid\n(.*?)^```$/ms', (string) file_get_contents($file), $matches); + $blocks[substr($file, strlen($root) + 1)] = $matches[1]; + } + + return $blocks; +} + +/** The first structural problem in one block, or null. */ +function mermaidProblem(string $block): ?string +{ + $lines = array_values(array_filter(array_map(trim(...), explode("\n", $block)), static fn (string $line): bool => $line !== '')); + $type = strtok($lines[0] ?? '', ' '); + + if (! in_array($type, ['flowchart', 'graph', 'sequenceDiagram', 'stateDiagram-v2', 'erDiagram', 'classDiagram'], true)) { + return sprintf('starts with [%s], not a known diagram type', $lines[0] ?? ''); + } + + foreach ($lines as $number => $line) { + if (substr_count($line, '"') % 2 !== 0) { + return sprintf('line %d has an unclosed quote: %s', $number + 1, $line); + } + + if ($type === 'sequenceDiagram' && preg_match('/^participant\s+(actor|participant|end|alt|else|opt|loop|par|note|box)\b/i', $line) === 1) { + return sprintf('line %d names a participant with a reserved word: %s', $number + 1, $line); + } + } + + // Brackets inside a quoted label are text, and an erDiagram cardinality (`|o--o{`) is not a + // bracket; everything else is syntax and must pair across the block. + $syntax = (string) preg_replace(['/"[^"]*"/', '/[|}][|o](--|\.\.)[|o][|{]/'], ['""', '--'], implode("\n", $lines)); + + foreach (['(' => ')', '[' => ']', '{' => '}'] as $open => $close) { + if (substr_count($syntax, $open) !== substr_count($syntax, $close)) { + return sprintf('unbalanced %s%s: %d against %d', $open, $close, substr_count($syntax, $open), substr_count($syntax, $close)); + } + } + + return null; +} + +it('keeps every mermaid diagram in the README and docs well formed', function (): void { + $blocks = mermaidBlocks(); + $count = array_sum(array_map(count(...), $blocks)); + + // 54 files (README and 53 pages) and 8 diagrams, counted 2026-11-14; fewer means a broken glob. + expect(count($blocks))->toBeGreaterThanOrEqual(54) + ->and($count)->toBeGreaterThanOrEqual(8); + + foreach ($blocks as $file => $diagrams) { + foreach ($diagrams as $index => $diagram) { + expect(mermaidProblem($diagram))->toBeNull(sprintf('%s, diagram %d', $file, $index + 1)); + } + } +}); + +it('reports a malformed diagram', function (string $block): void { + expect(mermaidProblem($block))->not->toBeNull(); +})->with([ + 'unknown type' => "flowchart-ish TD\n a --> b\n", + 'unbalanced bracket' => "flowchart TD\n a[\"label\" --> b\n", + 'unclosed quote' => "flowchart TD\n a[\"label] --> b\n", + 'reserved name' => "sequenceDiagram\n participant Actor as ApiActor\n", +]);