Selkie--Store--Schema
NAME
Selkie::Store::Schema - Typed, immutable state tree for Selkie::Store
SYNOPSIS
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 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) returnsselfā 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::Storeuseschanged-pathsfor 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])> returnsself.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'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;selfwhen nothing changes; dies on unknown slots; type constraints enforced by the clone.with-E<lt>slotE<gt>($value)ā per-slot sugar forwith, provided viaFALLBACK(so it works for every slot without code generation):$db.with-user-name('Kai').merge(%updates)ā recursive counterpart of the Hash store's deep-mergedbeffect: 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'sdb-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'sget-in.
EXAMPLES
Nested patch, three equivalent spellings
# 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
# 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 ā the store; "TYPED STORE" section for opt-in, effects, and migration guidance
Selkie::Test::Store ā
mock-storeaccepts: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
method slot-names() returns ListAll slot names on this class, sorted. Introspection for docs, debugging, and the store's error messages.
method has-slot
method has-slot(
Str:D $name
) returns BoolTrue when the class declares a slot of this name (inherited slots included).
method slot-value
method slot-value(
Str:D $name
) returns MuRead 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
method with(
*%overrides
) returns Selkie::Store::SchemaClone 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 db.with(user-name => 'Kai', tasks => @new);
method merge
method merge(
%updates
) returns Selkie::Store::SchemaDeep-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
method set-at(
@path,
$value
) returns Selkie::Store::SchemaSet 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
method deleted-at(
@path
) returns Selkie::Store::SchemaDelete 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
method changed-paths(
Selkie::Store::Schema:D $before
) returns ListIdentity-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
method FALLBACK(
Str:D $name,
|c
) returns MuProvides the per-slot C<with-EslotE($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.