|
|
@@ -0,0 +1,235 @@
|
|
|
+# Phase 2 — Actions API: typed private data, GUID endpoints, fluent directives
|
|
|
+
|
|
|
+## Goal
|
|
|
+
|
|
|
+Make Statum actions first-class and ergonomic:
|
|
|
+
|
|
|
+1. **Create actions with private data to send back in state updates** — author a
|
|
|
+ strongly-typed private model, seal it into an action reference, embed the
|
|
|
+ reference in a snapshot's public data, and read it back typed in the handler.
|
|
|
+2. **Fluent directive builder** — build the response directives with a chained
|
|
|
+ API instead of hand-assembling `Series<DirectiveDto>`.
|
|
|
+3. **Couple strongly-typed private data models to actions** — an action declares
|
|
|
+ its private model type; the framework maps it to/from the sealed blob via
|
|
|
+ GObject introspection, seals it under a per-action-type namespace, and
|
|
|
+ auto-maps every action to a GUID endpoint (Spry-style — no hand-picked URIs).
|
|
|
+
|
|
|
+### Decisions (confirmed)
|
|
|
+
|
|
|
+- **Endpoints: GUID only.** Every action is served at `/_statum/action/{guid}`,
|
|
|
+ resolved by a single parameterised endpoint (mirrors Spry's
|
|
|
+ `ComponentEndpoint`/`PathProvider`). `StatumConfigurator.add_action<T>(route)`
|
|
|
+ is removed.
|
|
|
+- **Typed private models: GObject introspection.** A private model is a
|
|
|
+ `Object` subclass with `public T foo { get; set; }` properties; the framework
|
|
|
+ maps it to/from `Properties` via GObject property introspection (reusing the
|
|
|
+ pattern in Invercargill's `ObjectPropertyAccessor` — `list_properties` /
|
|
|
+ `get_property` / `Element.to_value` / `ValueElement`). No per-model mapper.
|
|
|
+- **Per-action-type namespace.** Each action type's private blob is sealed under
|
|
|
+ a namespace derived from the action type, so one action cannot decrypt
|
|
|
+ another's private blob (defence in depth).
|
|
|
+- **Fluent builder started from the handler.** A `protected DirectiveBuilder
|
|
|
+ directives()` on `StatumAction`/`StatumEntrypoint` starts the builder;
|
|
|
+ `handle()` returns the builder and the framework calls `build()` on it.
|
|
|
+
|
|
|
+## Design overview
|
|
|
+
|
|
|
+### Action registration & dispatch (Spry-style GUIDs)
|
|
|
+
|
|
|
+- **`ActionRegistry`** (singleton, `src/ActionRegistry.vala`) — the Statum
|
|
|
+ analogue of Spry's `PathProvider`:
|
|
|
+ - `register<TAction>()` — assigns `Uuid.string_random()` → `typeof(TAction)`
|
|
|
+ (`guid → type`, `type → guid`), idempotent.
|
|
|
+ - `guid_for<TAction>()` — resolves a type to its assigned GUID.
|
|
|
+ - `resolve(string guid)` → `Type?` for dispatch.
|
|
|
+ - `namespace_for<TAction>()` → `typeof(TAction).name()` (the seal namespace).
|
|
|
+- **`ActionEndpoint`** (singleton, `src/ActionEndpoint.vala`) — a single endpoint
|
|
|
+ registered at `/_statum/action/{guid}` (method handling: see A3). On a request
|
|
|
+ it resolves `guid → Type`, resolves the action from the request scope, lets the
|
|
|
+ base `StatumAction.handle_request` do its work (resolve held, decrypt private,
|
|
|
+ parse form, build request), then renders directives. Conceptually it is the
|
|
|
+ type-resolving shim; the per-request logic stays in `StatumAction`.
|
|
|
+- **`StatumModule`** registers `ActionRegistry` (singleton) and the single
|
|
|
+ `ActionEndpoint` route.
|
|
|
+- **`StatumConfigurator.action<TAction>()`** calls `ActionRegistry.register<T>()`
|
|
|
+ and `container.register_scoped<TAction>()` (resolvable by type for dispatch).
|
|
|
+ Replaces `add_action<T>(route)`.
|
|
|
+
|
|
|
+### Strongly-typed private data
|
|
|
+
|
|
|
+- **`StatumAction<TPrivate>`** (`src/StatumHandlers.vala`) — generic base where
|
|
|
+ `TPrivate : Object`. Provides:
|
|
|
+ - `protected TPrivate request_private` — the decrypted, typed private data
|
|
|
+ (mapped from `request.action_private` via GObject introspection; `null` if
|
|
|
+ absent). Cached per request.
|
|
|
+ - The action's seal namespace is `this.get_type().name()` (the concrete action
|
|
|
+ type), so blobs are scoped per action type.
|
|
|
+ - Non-generic **`StatumAction`** remains for actions that carry no private data
|
|
|
+ (its `request_private` is absent; `request.action_private` `Properties` is
|
|
|
+ still available for advanced/untyped use).
|
|
|
+- **`GObjectMapping`** helper (`src/GObjectMapping.vala`) — reuses the
|
|
|
+ `ObjectPropertyAccessor` pattern:
|
|
|
+ - `Properties to_properties<T>(T model)` — iterate `list_properties()`,
|
|
|
+ `get_property` into a `Value`, wrap (`ValueElement`/`NativeElement` by type),
|
|
|
+ build a `PropertyDictionary`.
|
|
|
+ - `T from_properties<T>(Properties props)` — iterate `list_properties()`,
|
|
|
+ look up each property name in `props`, `Element.to_value(prop.value_type)`,
|
|
|
+ `set_property`. Missing keys are skipped (leave default).
|
|
|
+- **Authoring references** — handlers embed action references in snapshot public
|
|
|
+ data so the client can invoke them:
|
|
|
+ - `protected ActionDto action<TAction, TPrivate>(TPrivate model, string method = "POST")`
|
|
|
+ on the handler base (delegates to `ActionRegistry` + `encryption_provider`):
|
|
|
+ seals `model` under `namespace_for<TAction>()`, builds
|
|
|
+ `ActionDto { uri = "/_statum/action/" + guid_for<TAction>(), method, private = blob }`.
|
|
|
+ - `ActionDto.to_element()` — serialise via `ActionDto.get_mapper()` to a
|
|
|
+ `JsonElement` so it embeds cleanly as a public-data value:
|
|
|
+ `state.public_data["checkout"] = action<CheckoutAction, CheckoutPrivate>(order).to_element();`
|
|
|
+ - Compile-time safety: `action<TAction, TPrivate>(TPrivate model)` requires the
|
|
|
+ caller to name `TPrivate`, matching the handler's `StatumAction<TPrivate>`.
|
|
|
+
|
|
|
+### Fluent directive builder
|
|
|
+
|
|
|
+- **`DirectiveBuilder`** (`src/DirectiveBuilder.vala`) — started via
|
|
|
+ `protected DirectiveBuilder directives()` on `StatumAction`/`StatumEntrypoint`
|
|
|
+ (bound to the handler's `state_service`). Fluent, self-returning:
|
|
|
+ ```vala
|
|
|
+ return directives()
|
|
|
+ .set(slot) // fresh slot: sign_slot -> set
|
|
|
+ .update(key, state) // mutate + auto-push to channels + sign -> set
|
|
|
+ .set_frame(frame) // pre-signed frame -> set
|
|
|
+ .clear(key) // clear directive
|
|
|
+ .subscribe(key) // realtime subscribe
|
|
|
+ .unsubscribe(key)
|
|
|
+ .transmit(key, retry) // transmit directive
|
|
|
+ .notify(kind, message) // notify directive
|
|
|
+ .error(message, code, data)// error directive
|
|
|
+ .post(uri, data) // terminal form-post
|
|
|
+ .navigate(uri); // terminal navigate
|
|
|
+ ```
|
|
|
+ - `set`/`update` are **intents** recorded in declaration order; pure directives
|
|
|
+ (clear/navigate/notify/…) are stored inline. `build()` is `async` and
|
|
|
+ materialises intents in order (`update` yields to push to channels; `set`
|
|
|
+ calls `sign_slot`), preserving ordering, then returns `Lot<DirectiveDto>`.
|
|
|
+ - `DirectiveBuilder.to_lot()` is an alias for `build()` for completeness.
|
|
|
+- **Handler signature change** — `handle()` returns the builder, not a lot:
|
|
|
+ ```vala
|
|
|
+ public abstract async DirectiveBuilder handle() throws GLib.Error;
|
|
|
+ ```
|
|
|
+ The base `handle_request`/entrypoint caller does:
|
|
|
+ ```vala
|
|
|
+ var builder = yield handle();
|
|
|
+ var directives = yield builder.build();
|
|
|
+ return StatumDirectives.to_result(directives);
|
|
|
+ ```
|
|
|
+
|
|
|
+## Tasks
|
|
|
+
|
|
|
+### Subsystem A — GUID action dispatch
|
|
|
+- **A1 `ActionRegistry`** (`src/ActionRegistry.vala`): GUID↔type maps,
|
|
|
+ `register<T>`, `guid_for<T>`, `resolve`, `namespace_for<T>`.
|
|
|
+- **A2 `ActionEndpoint`** (`src/ActionEndpoint.vala`): single
|
|
|
+ `/_statum/action/{guid}` endpoint; resolve guid → type → scoped instance;
|
|
|
+ delegate to `StatumAction.handle_request`. 404 on unknown guid.
|
|
|
+- **A3 Route method handling**: register `ActionEndpoint` so it accepts the
|
|
|
+ methods actions use (GET/POST at minimum; verify `EndpointRoute` method
|
|
|
+ matching in Astralis — if a route can be method-agnostic, prefer that; else
|
|
|
+ register for GET+POST+PUT+PATCH+DELETE). Document the chosen behaviour.
|
|
|
+- **A4 `StatumModule`**: register `ActionRegistry` singleton + the
|
|
|
+ `ActionEndpoint` route.
|
|
|
+- **A5 `StatumConfigurator.action<T>()`**: register type + scoped registration;
|
|
|
+ remove `add_action<T>(route)`.
|
|
|
+
|
|
|
+### Subsystem B — Typed private data
|
|
|
+- **B1 `GObjectMapping`** (`src/GObjectMapping.vala`): `to_properties<T>` /
|
|
|
+ `from_properties<T>` via GObject introspection (reuse
|
|
|
+ `ObjectPropertyAccessor` logic + `Element.to_value`/`ValueElement`). Unit-test
|
|
|
+ round-trip for primitives/strings/enums and a nested `Object` property.
|
|
|
+- **B2 `StatumAction<TPrivate>`**: generic base; typed `request_private`
|
|
|
+ (cached); per-action-type namespace from `get_type().name()`; decrypt via
|
|
|
+ `encryption_provider.read_properties(namespace, blob)` then
|
|
|
+ `from_properties<TPrivate>`. Keep non-generic `StatumAction` for private-less
|
|
|
+ actions; share the `handle_request`/form/held logic.
|
|
|
+- **B3 Authoring helper**: `protected ActionDto action<TAction, TPrivate>(TPrivate
|
|
|
+ model, string method)` on the handler base; `ActionDto.to_element()` for
|
|
|
+ embedding in public data.
|
|
|
+- **B4 Minor: typed `Properties` getter** — add a `Properties` extension/static
|
|
|
+ helper `get_native<T>(key)` (via `try_get` + `Element.as<T>`/`as_*_or_null`)
|
|
|
+ to remove the read-boilerplate noted in the gaps review.
|
|
|
+
|
|
|
+### Subsystem C — Fluent directives
|
|
|
+- **C1 `DirectiveBuilder`** (`src/DirectiveBuilder.vala`): ordered item list
|
|
|
+ (pure directives + intents), fluent mutators, `async build()` materialising
|
|
|
+ intents (`set`→`sign_slot`, `update`→`state_service.update`), returning
|
|
|
+ `Lot<DirectiveDto>`.
|
|
|
+- **C2 `directives()` on handler bases**; change `handle()` to return
|
|
|
+ `DirectiveBuilder`; update `StatumAction.handle_request` and the
|
|
|
+ `EntrypointEndpoint` to call `build()`.
|
|
|
+- **C3 Keep `StatumDirectives.to_result`** as the renderer (unchanged).
|
|
|
+
|
|
|
+### Subsystem D — Migration & validation
|
|
|
+- **D1 Update the example** to use the new API end-to-end:
|
|
|
+ - `HomeEntrypoint` embeds a `login` action ref (no private) into a page-scoped
|
|
|
+ slot's public data; returns it via `directives().set(slot)`.
|
|
|
+ - `LoginAction : StatumAction` reads the form `name`, builds an `auth` slot
|
|
|
+ (with a typed `AuthPrivate { is_admin }`), embeds a `logout` action ref into
|
|
|
+ `auth` public data, returns `directives().set(auth_slot).navigate("/")`.
|
|
|
+ - `LogoutAction : StatumAction` clears `auth` via
|
|
|
+ `directives().clear(key).navigate("/")`.
|
|
|
+ - `home.html` uses `stm-action="page.login"` / `stm-action="auth.logout"`.
|
|
|
+ - `Main.vala` uses `statum.action<LoginAction>()` / `statum.action<LogoutAction>()`.
|
|
|
+- **D2 Build + runtime smoke test**: confirm the client resolves embedded refs,
|
|
|
+ POSTs to `/_statum/action/{guid}`, the handler reads typed private, and the
|
|
|
+ directive response drives navigation/state. Verify a tampered/swapped
|
|
|
+ `X-Statum-Private` is rejected (namespace mismatch).
|
|
|
+
|
|
|
+## API sketch (end-to-end)
|
|
|
+
|
|
|
+```vala
|
|
|
+// Private model (GObject properties -> introspected)
|
|
|
+public class CheckoutPrivate : Object {
|
|
|
+ public string order_id { get; set; }
|
|
|
+ public int amount { get; set; }
|
|
|
+}
|
|
|
+
|
|
|
+// Action with typed private
|
|
|
+public class CheckoutAction : StatumAction<CheckoutPrivate> {
|
|
|
+ public override async DirectiveBuilder handle() throws GLib.Error {
|
|
|
+ var p = request_private; // typed CheckoutPrivate
|
|
|
+ // ... fulfil p.order_id / p.amount ...
|
|
|
+ return directives().navigate("/done");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Registration (GUID auto-assigned)
|
|
|
+statum.action<CheckoutAction>();
|
|
|
+
|
|
|
+// Authoring a reference with private data, embedded in a state update
|
|
|
+public override async DirectiveBuilder handle() {
|
|
|
+ var order = new CheckoutPrivate { order_id = "abc", amount = 4999 };
|
|
|
+ var pub = new PropertyDictionary();
|
|
|
+ pub["checkout"] = action<CheckoutAction, CheckoutPrivate>(order).to_element();
|
|
|
+ var state = new State() { type_name = "cart", public_data = pub, private_data = new PropertyDictionary() };
|
|
|
+ var slot = state_service.new_slot(Scope.SESSION, state);
|
|
|
+ return directives().set(slot);
|
|
|
+}
|
|
|
+```
|
|
|
+Client (unchanged): `<button stm-action="cart.checkout">Checkout</button>` →
|
|
|
+POST `/_statum/action/{guid}` with `X-Statum-Private`; `CheckoutAction` reads
|
|
|
+typed `request_private`.
|
|
|
+
|
|
|
+## Breaking changes (phase-1 API, pre-release)
|
|
|
+
|
|
|
+- `StatumConfigurator.add_action<T>(route)` → `action<T>()` (GUID).
|
|
|
+- `StatumAction.handle()` / `StatumEntrypoint.handle()` return `DirectiveBuilder`,
|
|
|
+ not `Lot<DirectiveDto>`.
|
|
|
+- Actions are no longer addressable by chosen URI; they live under
|
|
|
+ `/_statum/action/{guid}` and are referenced from state.
|
|
|
+
|
|
|
+## Out of scope / future
|
|
|
+
|
|
|
+- Stable, deploy-persistent action IDs (GUIDs are random per process start;
|
|
|
+ fine for app-internal refs that are re-embedded each entrypoint load).
|
|
|
+- Explicit `PropertyMapper` override for private models with non-GValue-friendly
|
|
|
+ nesting (the `GObjectMapping` helper covers GObject-property types; exotic
|
|
|
+ transforms can be added later).
|
|
|
+- Generating `stm-action` bindings / type-checked client contracts.
|