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) 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'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

# 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-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

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

method has-slot(
    Str:D $name
) returns Bool

True when the class declares a slot of this name (inherited slots included).

method slot-value

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

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 db.with(user-name => 'Kai', tasks => @new);

method merge

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

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

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

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

method FALLBACK(
    Str:D $name,
    |c
) returns Mu

Provides 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.

Selkie v0.16.0

High-level TUI framework built on Notcurses

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Notcurses::Native:ver<0.6.5+>:auth<zef:apogee>

Test Dependencies

Provides

  • Selkie
  • Selkie::Align
  • Selkie::Alpha
  • Selkie::App
  • Selkie::App::Internal::Animation
  • Selkie::App::Internal::Dispatch
  • Selkie::App::Internal::ErrorLog
  • Selkie::App::Internal::ErrorLogPlatform
  • Selkie::App::Internal::FocusTree
  • Selkie::App::Internal::HitTest
  • Selkie::App::Internal::IdleBudget
  • Selkie::App::Internal::OverlayTree
  • Selkie::App::Internal::PosixFD
  • Selkie::App::Internal::RenderLoop
  • Selkie::App::Internal::ScreenModalLifecycle
  • Selkie::App::Internal::Terminal
  • Selkie::App::Internal::TerminalPlatform
  • Selkie::App::Internal::TerminalReport
  • Selkie::App::Internal::TerminalSequences
  • Selkie::BorderStyle
  • Selkie::Container
  • Selkie::EffectiveBounds
  • Selkie::Event
  • Selkie::Gradient
  • Selkie::Layout::Allocate
  • Selkie::Layout::HBox
  • Selkie::Layout::Split
  • Selkie::Layout::VBox
  • Selkie::Plot::Palette
  • Selkie::Plot::Scaler
  • Selkie::Plot::Ticks
  • Selkie::ScreenManager
  • Selkie::Sizing
  • Selkie::Store
  • Selkie::Store::Schema
  • Selkie::Store::Schema::UI
  • Selkie::Style
  • Selkie::Test::Focus
  • Selkie::Test::Keys
  • Selkie::Test::Snapshot
  • Selkie::Test::Snapshot::Harness
  • Selkie::Test::SnapshotPlatform
  • Selkie::Test::Store
  • Selkie::Test::Supply
  • Selkie::Test::Tree
  • Selkie::Theme
  • Selkie::Trace
  • Selkie::Tree
  • Selkie::Tween
  • Selkie::Widget
  • Selkie::Widget::Axis
  • Selkie::Widget::BarChart
  • Selkie::Widget::Border
  • Selkie::Widget::Button
  • Selkie::Widget::CardList
  • Selkie::Widget::Checkbox
  • Selkie::Widget::CommandPalette
  • Selkie::Widget::ConfirmModal
  • Selkie::Widget::FileBrowser
  • Selkie::Widget::FocusableByDefault
  • Selkie::Widget::GradientFill
  • Selkie::Widget::Heatmap
  • Selkie::Widget::HelpOverlay
  • Selkie::Widget::Histogram
  • Selkie::Widget::Image
  • Selkie::Widget::Legend
  • Selkie::Widget::LineChart
  • Selkie::Widget::ListView
  • Selkie::Widget::Modal
  • Selkie::Widget::MultiLineInput
  • Selkie::Widget::PasswordStrength
  • Selkie::Widget::Plot
  • Selkie::Widget::ProgressBar
  • Selkie::Widget::RadioGroup
  • Selkie::Widget::RichText
  • Selkie::Widget::RichText::Span
  • Selkie::Widget::ScatterPlot
  • Selkie::Widget::ScrollView
  • Selkie::Widget::Select
  • Selkie::Widget::Sparkline
  • Selkie::Widget::Spinner
  • Selkie::Widget::TabBar
  • Selkie::Widget::Table
  • Selkie::Widget::Text
  • Selkie::Widget::TextInput
  • Selkie::Widget::TextInput::HighlightSpan
  • Selkie::Widget::TextStream
  • Selkie::Widget::Toast
  • Selkie::Widget::ViewportedCardList
  • X::Selkie::WidgetCycle

Documentation

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.