Selkie.git | docs/api/ | Selkie--Store--Schema.md
NAME
====
Selkie::Store::Schema - Typed, immutable state tree for Selkie::Store
SYNOPSIS
========
```raku
use Selkie::Store;
use Selkie::Store::Schema;
# Nested slots are Schema subclasses of their own.
class MyApp::Ui is Selkie::Store::Schema::UI {
has Bool $.sidebar-open = True;
}
# The ROOT schema must declare a `ui` slot holding a
# Selkie::Store::Schema::UI (or subclass) instance — the framework's
# focus machinery lives there. This line belongs in every root schema:
class MyApp::Db is Selkie::Store::Schema {
has MyApp::Ui $.ui .= new;
has Str $.user-name = '';
has @.tasks;
has %.prefs;
}
my $store = Selkie::Store.new(state-class => MyApp::Db);
# Handlers patch state through the same effects as the Hash store —
# now validated against the schema at dispatch time:
$store.register-handler('user/rename', -> $st, %ev {
(db => { user-name => %ev<name> },);
});
# Or transform the whole tree with the db-update effect:
$store.register-handler('tasks/clear', -> $st, %ev {
(db-update => { fn => -> MyApp::Db $s { $s.with(tasks => []) } },);
});
# Reads: typed accessors via .state, or the classic get-in:
say $store.state.user-name;
say $store.get-in('ui', 'sidebar-open');
```
DESCRIPTION
===========
`Selkie::Store::Schema` is the base class for **typed store state**. An app defines its state tree as a class (nested state as nested Schema subclasses), passes it to `Selkie::Store.new(:state-class(...))`, and every write effect — `db`, `db-replace`, `db-delete`, `db-update`, and the low-level `assoc-in` — is validated against the declared slots at dispatch time. A typo'd key or a wrong-typed value becomes a loud `__effect-error` naming the offending slot instead of a silently malformed state tree.
The untyped Hash store remains the default and is not deprecated: apps opt in per store. See "TYPED STORE" in [Selkie::Store](Selkie--Store.md) for the when-to-use-which discussion.
Immutability and identity
-------------------------
Schema instances are **immutable by convention**: every write produces a new instance via `with` / `merge` / `set-at` / `deleted-at`, sharing unchanged sub-structure with the previous instance by reference. Two consequences the whole design leans on:
* **No-op writes keep identity.** Setting a slot to a value that is already there (`===`, or both sides undefined) returns `self` — the same instance. Subscriptions comparing by identity or by digest therefore never fire on writes that change nothing.
* **Changes are discoverable by identity diff.** After a write, walking old and new trees comparing slots with `===` finds exactly the changed paths in O(changed) time, because unchanged branches are the *same object* on both sides. `Selkie::Store` uses `changed-paths` for precise push-subscription dirty marking.
Do not mutate a Schema instance's `@.`/`%.`/object slots in place — in-place mutation is invisible to identity diffing, exactly like the documented in-place-mutation caveat on `:identity-check-only` Hash subscriptions. Replace, don't mutate.
Collection slots (`@.` / `%.`)
------------------------------
Raku's `clone` gives a clone fresh `@`/`%` attribute containers, so container identity can't distinguish "untouched" from "replaced" for collection slots. They therefore compare by **one-level element identity**: same element count, every element `===` its counterpart (with both-undefined counting as equal). Consequences:
* An untouched collection slot never reads as changed across clones (the containers differ, the elements don't).
* Writing a fresh array with identical elements is a **no-op** — `$db.with(tasks =` [|$db.tasks])> returns `self`.
* The comparison walks the collection once per write to the owning node. For large collections (thousands of elements), prefer a `$.` slot holding a List replaced wholesale — a scalar slot's value keeps its identity across clones, so it compares in O(1) and follows the exact replace-wholesale idiom [Selkie::Store](Selkie--Store.md)'s digest documentation recommends.
* Elements compare by identity only — no deep recursion. An element mutated in place is invisible; replace the element.
Slots
-----
A **slot** is a public attribute (`has $.name`, `has @.name`, `has %.name`) on a Schema subclass, addressed by its attribute name — the path segment for `'user-name'` is the attribute `$.user-name`. Private attributes are not slots and are invisible to the store. Slots typed as Schema subclasses form the typed tree; slots holding plain Hashes are "untyped territory" and keep classic Hash-store semantics (auto-vivified intermediates, `:delete`) inside that region, on copied hashes so immutability is preserved.
The `ui` slot contract
----------------------
The framework owns two pieces of store state: the focused widget and the pending focus action. On a typed store they live in the root schema's `ui` slot, which must be a defined `Selkie::Store::Schema::UI` (or subclass) instance. `Selkie::Store.new` refuses construction otherwise, with the exact line to add. Apps with their own ui state subclass `Selkie::Store::Schema::UI` and type the slot with the subclass, as in the SYNOPSIS.
(The base Schema class cannot declare the slot itself: nested schemas are Schema subclasses too, and a `ui` slot on the base would recurse — every `ui` would need its own `ui`.)
Deletion semantics
------------------
`db-delete` / `deleted-at` on a schema slot means **reset**, not removal — attributes cannot be removed from an instance. A `$.` slot resets to its container default (the type object — note this is the *type's* default, not a `= value` declared default), `@.` to empty, `%.` to empty. A slot whose type constraint rejects the undefined value (`Int:D`) throws, and the store routes the throw to `__effect-error` — deleting a can't-be-absent slot is a programming error, and the schema's job is to say so. Inside a plain-Hash region deletion is classic `:delete`.
METHODS
=======
Method-level docs are on each method below; the load-bearing ones:
* `with(*%overrides)` — clone with named slots replaced; `self` when nothing changes; dies on unknown slots; type constraints enforced by the clone.
* `with-E<lt>slotE<gt>($value)` — per-slot sugar for `with`, provided via `FALLBACK` (so it works for every slot without code generation): `$db.with-user-name('Kai')`.
* `merge(%updates)` — recursive counterpart of the Hash store's deep-merge `db` effect: Hash values merge into Schema/Hash slots, everything else replaces.
* `set-at(@path, $value)` / `deleted-at(@path)` — path-addressed write/delete used by the store's `db-replace` / `db-delete` / `assoc-in`.
* `changed-paths($before)` — identity-diff against an older instance; returns the changed slot paths.
* `has-slot($name)` / `slot-value($name)` / `slot-names` — introspection used by the store's `get-in`.
EXAMPLES
========
Nested patch, three equivalent spellings
----------------------------------------
```raku
# via with (typed, explicit):
$db .= with(ui => $db.ui.with(sidebar-open => False));
# via merge (the shape a `db` effect payload has):
$db .= merge({ ui => { sidebar-open => False } });
# via set-at (the shape db-replace / assoc-in use):
$db .= set-at(('ui', 'sidebar-open'), False);
```
All three share every untouched branch with the old instance, and all three return the *original* `$db` unchanged if `sidebar-open` was already `False`.
Catching a typo at dispatch time
--------------------------------
```raku
# Hash store: silently creates a 'user-nmae' key, UI never updates.
# Schema store: the db effect dies —
# No such slot 'user-nmae' on MyApp::Db …
# — and the store routes it to __effect-error with the event name.
(db => { user-nmae => 'Kai' },)
```
SEE ALSO
========
* [Selkie::Store](Selkie--Store.md) — the store; "TYPED STORE" section for opt-in, effects, and migration guidance
* [Selkie::Test::Store](Selkie--Test--Store.md) — `mock-store` accepts `:state-class` / `:initial-state`
class Selkie::Store::Schema
---------------------------
Base class for typed store state. Subclass per tree node; public attributes are the slots. Instances are immutable by convention — all writes go through with / merge / set-at / deleted-at, which return new instances sharing unchanged branches.
### method slot-names
```raku
method slot-names() returns List
```
All slot names on this class, sorted. Introspection for docs, debugging, and the store's error messages.
### method has-slot
```raku
method has-slot(
Str:D $name
) returns Bool
```
True when the class declares a slot of this name (inherited slots included).
### method slot-value
```raku
method slot-value(
Str:D $name
) returns Mu
```
Read a slot's current value by name. Dies on an unknown slot — reads through the store's `get-in` get the Hash-parity Nil instead; this method is the strict path.
### method with
```raku
method with(
*%overrides
) returns Selkie::Store::Schema
```
Clone with the named slots replaced. The heart of the write path: =item Unknown slot names die (the schema's whole point). =item A value identical to the current one (`===`, or both sides undefined) is dropped; if every override is dropped, `self` is returned — **same instance**, so no subscriber ever fires on a no-op write. =item Type constraints are enforced by the clone assignment (`X::TypeCheck::Assignment` on violation). my $next = $db.with(user-name => 'Kai', tasks => @new);
### method merge
```raku
method merge(
%updates
) returns Selkie::Store::Schema
```
Deep-merge a Hash of updates — the typed counterpart of the `db` effect's deep-merge: =item a Hash value merging into a Schema slot recurses with `merge` on that slot; =item a Hash value merging into a plain-Hash slot deep-merges on copied hashes (classic Hash-store semantics, immutably); =item anything else replaces the slot via the `with` rules. An empty `%updates` is a no-op returning `self` — same contract as the Hash store's documented "empty Hash merge is a no-op". Unknown keys die.
### method set-at
```raku
method set-at(
@path,
$value
) returns Selkie::Store::Schema
```
Set the value at a slot path, returning the new tree. The first segment must name a slot on this class; descent continues through Schema slots (validated at every level) or into plain-Hash regions (Hash-store semantics: intermediates auto-vivify, non-Associative intermediates are replaced — untyped territory keeps untyped rules). No-op sets keep identity all the way up.
### method deleted-at
```raku
method deleted-at(
@path
) returns Selkie::Store::Schema
```
Delete at a slot path, returning the new tree. On a schema slot this means **reset** (see "Deletion semantics" in the Pod): `$.` slots to the container default, `@.`/`%.` to empty. Inside a plain-Hash region it is a classic key delete; a path that doesn't exist there is a no-op returning `self`. The first segment must name a slot — unknown slots die, like every schema write.
### method changed-paths
```raku
method changed-paths(
Selkie::Store::Schema:D $before
) returns List
```
Identity-diff this (newer) instance against an older one, returning the List of changed slot paths (each a List of segments). Unchanged branches are recognised by `===` — with the immutable-clone convention they are the same object, so the walk costs O(changed paths). A slot whose old and new values are both Schema instances of the same class recurses for precision; everything else (plain Hashes included) marks the slot's whole subtree, which the store's ancestor/descendant prefix matching plus the digest gate turns into correct — if blunter — notifications.
### method FALLBACK
```raku
method FALLBACK(
Str:D $name,
|c
) returns Mu
```
Provides the per-slot C<with-E<lt>slotE<gt>($value)> sugar without code generation: any `with-*` call whose tail names a slot delegates to `with`. Anything else dies with a method-not-found message, so typos stay loud.
class Selkie::Store::Schema::UI
-------------------------------
The framework-owned slot every ROOT schema must carry as its `ui` slot (see "The ui slot contract" in the Pod). Subclass it to add app ui state: class MyApp::Ui is Selkie::Store::Schema::UI { has Bool $.sidebar-open = True; } `focused-widget` holds the focused Selkie::Widget (untyped here so this module stays dependency-free); `focus-action` holds the pending 'next' / 'prev' focus request the App consumes. Both are written by the built-in `ui/focus*` handlers.