Help us shape the future of Go! Take the 2026 Go Developer Survey

nodra

package module
v0.9.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 23 Imported by: 0

README

nodra

Nodra is a production-oriented reactive state store for Go desktop applications. It is a state library for MyGo apps with Zustand-like ergonomics, and does not tie your state to a renderer or a persistence backend.

Install

go get github.com/yldm-tech/nodra@latest

The core API is in github.com/yldm-tech/nodra. The optional local inspector is in github.com/yldm-tech/nodra/devtools, so applications can omit its server and UI.

MyGo usage

The MyGo integration targets v0.6.3, pinned in go.mod and shared by the native example.

Redraw a native MyGo window whenever its store publishes:

stop := nodra.Invalidate(store, window)

For a custom native host or test fake, implement InvalidationTarget and use InvalidateTarget. The returned stop function remains the lifecycle boundary:

stop := nodra.InvalidateTarget(store.ReadOnly(), target)
defer stop()

The subscription stops automatically when the window closes. Call stop() to detach it earlier; repeated calls are safe. A hidden window stays subscribed so it can be shown again. Each window has its own subscription, and closing one does not close the shared Store.

For a window that only displays part of the state, select that part so unrelated updates do not request a frame:

stop := nodra.InvalidateSelector(store, window, func(s AppState) int {
    return s.Count
})

InvalidateSelectorWith accepts SelectorOptions[V], including Equals for collections and Immediate to request a frame on attachment. It uses the same equality and ownership rules as SubscribeSelectorWith. Select every value the view displays; multiple windows can select different values from the same Store. This filters redraw requests, not individual UI elements, and does not track field reads automatically.

Keep local UI state, such as a draft or an open popover, in the view's Go owner or MyGo's ui.Local. For a widget that edits shared business state, bind a local snapshot field and commit only the changed field:

enabled := store.Get().Enabled
if ui.Checkbox(c, &enabled, "Enable sync").Changed() {
    if err := store.Update(func(s *AppState) { s.Enabled = enabled }); err != nil {
        log.Print(err)
    }
}

Replacing the whole snapshot with Set here could overwrite another field changed by a background task. Subscribers and selectors run on writer goroutines, so update other UI-owned fields through window.Update; the invalidation helpers only request a goroutine-safe redraw. Stopping does not wait for an already running callback, and a selector panic during attachment releases the window listener before propagating.

go run ./examples/mygo demonstrates a counter and a preferences window with independent selectors, field-level widget commits, and a background action.

Composing derived state

Observable[T] is the small versioned value interface shared by Stores, read-only views and derived values. Derive, selectors and MyGo invalidation accept it; Derived.Subscribe keeps its existing value callback, while SubscribeSnapshot exposes versions for composition.

These helpers now accept Observable instead of Reader. Ordinary calls remain valid; explicitly declared helper function types must update their parameter type. Reader retains the richer Store observation surface, while borrowed projections and DevTools attachment continue to require Reader.

canEdit := nodra.Combine(user.ReadOnly(), permissions.ReadOnly(),
    func(u User, p Permissions) bool { return u.Active && p.Edit },
    func(a, b bool) bool { return a == b })
label := nodra.Derive(canEdit, func(allowed bool) string {
    if allowed { return "Edit" }
    return "View only"
}, func(a, b string) bool { return a == b })
stop := nodra.Invalidate(label, window)
// At owner shutdown: stop(), then label.Close(), then canEdit.Close().

Nest Combine calls for more inputs. Independent writes converge to the latest observed inputs and remain eventually consistent. Atomic2 and AtomicGroup carry a transaction ID through the derived graph and settle a diamond before downstream callbacks run, so a transaction does not publish an intermediate combination. Constructors catch registration gaps; obsolete computations do not overwrite newer accepted results. Projectors and comparators must be pure and concurrency-safe, and derived reference values remain read-only. DeriveProjection can start a derived chain without full-source subscription clones. Closing a derived value detaches its inputs and freezes its last value; downstream owners still close their own derived values.

For a transaction spanning more than two stores, prepare typed updates with AtomicUpdate and commit them with AtomicGroup:

err := nodra.AtomicGroup(
    nodra.AtomicUpdate(user, func(value *User) error { value.Active = true; return nil }),
    nodra.AtomicUpdate(permissions, func(value *Permissions) error { value.Edit = true; return nil }),
    nodra.AtomicUpdate(settings, func(value *Settings) error { value.Revision++; return nil }),
)

All candidates are prepared before any store changes, and the group rolls back on an error or panic. Notifications carry one transaction ID and run after every participating store is visible. Do not call Store methods from an AtomicUpdate callback; use its candidate pointer.

Immediate field editing

BindField creates a small immediate binding; its Edit callback renders against a local value outside Store locks and commits only when the control reports a change:

enabled, err := nodra.BindField(store,
    func(s AppState) bool { return s.Enabled },
    func(s *AppState, value bool) error { s.Enabled = value; return nil })
// Handle construction errors before rendering.
err = enabled.Edit(func(value *bool) bool {
    return ui.Checkbox(c, value, "Enable sync").Changed()
})
// Surface err in the view or the owner's error handler.

The setter runs on the latest state candidate, so unrelated background changes survive. A returned validation error rolls back the write according to the Store's clone policy. Use NewWithClone for mutable reference fields; Get takes a normal isolated source snapshot. Forms that need Save/Cancel should keep UI-local drafts as in the workbench, rather than committing every edit. The native counter preferences example exercises this binding with MyGo controls.

Read-only state and lifecycle ownership

Pass store.ReadOnly() to UI code that should observe shared state without changing or closing it. The concrete wrapper hides its backing Store; returning an interface whose dynamic value is the writable Store would not provide that boundary. Reader[T] is the observation surface accepted by selectors, projections, derived getters, MyGo invalidation and DevTools attachment. A read-only DevTools source remains non-restorable even when inspector restoration is enabled. Read-only values retain the source's cloning/immutability policy; referenced data is not automatically frozen.

view := store.ReadOnly()
nodra.Invalidate(view, window)
count := nodra.Select(view, func(s AppState) int { return len(s.Items) }).Value

AsyncAction.Status() now returns ReadOnly[AsyncState] instead of *Store[AsyncState]. Ordinary read/subscription calls continue to work. Change explicit status variable/parameter types to ReadOnly[AsyncState] or Reader[AsyncState]; remove writes or closes on status and close its owning action instead. Helpers that previously accepted *Store[T] now accept Reader[T], so explicitly typed helper function variables may need the same parameter change.

Use a Scope for one window/module owner. Register stores first, persistence writers next, then actions; drains execute in reverse order. Observe owns unsubscribe functions separately so subscriptions stop before drains. A registration racing with shutdown is cleaned immediately and returns ErrClosed.

scope := nodra.NewScope()
_ = scope.Defer(func(context.Context) error { return store.Close() })
_ = scope.Defer(writer.Close)
_ = scope.Defer(action.Close)
_ = scope.Observe(nodra.Invalidate(store.ReadOnly(), window))
task, err := action.Run(scope.Context(), input)
// Handle err. At owner shutdown, use a fresh deadline:
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = scope.Close(shutdownCtx)

Close cancels scope.Context(), stops observations, drains registered resources and preserves all cleanup errors. Concurrent callers share the shutdown result with independent wait deadlines; the first Close supplies the cleanup context. Callbacks must respect that context. Use an independent worker context for write-behind persistence so it can drain after the Scope context is canceled. Do not synchronously close a Scope from one of its own cleanup callbacks, or from a task it must drain. Shared stores can outlive a Scope by leaving their Close unregistered. The workbench demonstrates Scope ownership and window-bound observations.

Async actions

Action and Do are synchronous mutations: their callbacks run under the Store write lock. Use NewAsyncAction for file/network work, then commit a short state mutation:

search, err := nodra.NewAsyncAction(store, "search", func(ctx context.Context, query string, progress func(float64)) ([]Item, error) {
    return fetchItems(ctx, query)
}, func(s *AppState, items []Item) error {
    s.Items = items
    return nil
})
// Handle err before using search.
task, err := search.Run(ctx, query)
// Handle err; task.Cancel() requests cancellation, task.Wait(waitCtx) observes completion.

Each run cancels its predecessor and only the latest uncanceled result can commit, including when old work ignores cancellation. Status() is an observable ReadOnly[AsyncState] with a run ID, running flag, monotonic progress from zero to one, and error text. It exposes no mutation or close methods. Attach it to MyGo invalidation or DevTools like any Store. Work runs outside the state lock; the apply callback follows normal UpdateErr ownership and rollback rules. Copy mutable inputs before Run, or otherwise keep them immutable for the entire run.

Action hooks and events cover the work and commit, with a per-store action ID and duration. Panics at the asynchronous user-code boundary become PanicError; a notification/hook panic can happen after a successful commit. Cancellation prevents result commits, but cannot undo network/file side effects already performed. Work must honor its context. Closing the source Store cancels active runs. At owner shutdown, call search.Close(shutdownCtx) before closing stores; it cancels and waits, and closes the status Store after terminal status is published. A timed-out Close can be called again. Do not wait or close synchronously from that task's own work, hooks or subscribers.

Scheduling and per-task status

NewAsyncActionWithOptions adds explicit scheduling without changing the default constructor's latest-result policy:

action, err := nodra.NewAsyncActionWithOptions(store, "load item", work, apply,
    nodra.ActionOptions[Request]{
        Policy: nodra.LatestWins,
        Key: func(request Request) string { return request.ItemID },
        MaxConcurrent: 4,
        QueueLimit: 32,
    })

LatestWins replaces work only for the same key; different keys are independent. Use QueueAll for work that must retain every accepted input. QueueAll defaults to one worker for FIFO starts/commits; with more workers, starts are FIFO and commits follow completion order. LatestWins with zero MaxConcurrent preserves unbounded legacy behavior. Bounded or debounced actions default to 64 pending entries, and a full queue returns ErrQueueFull without canceling existing work. A superseded queued entry can be replaced even when that queue is full. Canceled work that has not returned still occupies its concurrency slot. Run accepts input without waiting for a work slot; canceled queued tasks finish without executing work.

Set ActionOptions.Debounce to delay LatestWins work until a quiet interval after acceptance for its key. Replacing an input cancels its timer and reports superseded without executing work. Different keys have independent deadlines. Waiting for debounce does not occupy a worker slot, and eligible keys can start while another key is still waiting. Queue limits include live debounced inputs; cancellation and Close drain waiting tasks. A positive Debounce with QueueAll returns ErrInvalidAction, because QueueAll retains every input. Zero preserves existing immediate scheduling. The workbench search uses a 150 ms debounce.

Retries are opt-in through ActionOptions.Retry and require Idempotent: true. MaxAttempts includes the first work call; exponential backoff is capped by MaxBackoff. Only the work callback is retried; the state apply callback runs once after a successful result. TaskMetrics.Attempts reports one run's attempts, while action.Metrics() aggregates accepted/completed/succeeded/failed/canceled runs, retries, queue peak and duration totals.

Task.Status() exposes that task's key, queued/running/terminal phase, progress and diagnostic error. The status Store closes after terminal status. The action-level Status describes the most recently accepted run, which is not a count of all active work. Use ActiveTasks() or TaskSnapshot() on the source for current work. Do not wait for another queued task synchronously from work, hooks or subscribers of the same bounded action; that can exhaust its own work slots. Progress may be reported concurrently during work; stop reporting when work returns.

SubscribeActionTrace observes async acceptance, start, progress and terminal outcome. Action ID links traces to mutation and completion events; RunID is local to one action. TaskVersion orders one task's status while Revision orders source trace publication; callbacks can still overlap. Committed/Version identify the accepted publication even if notifications later fail. A Store Batch coalesces publication across writers, so its final mutation has no single ActionID and individual batched actions do not claim a published commit version. Duration includes queue waiting time. Terminal task entries are released from the source's active map.

Metrics on task status, traces and completion events separates Debounce, eligible Queue wait, Work and Commit. Commit measures the entire Store update attempt, including lock wait, clone/apply and synchronous mutation notifications; failed attempts still have a duration. Work excludes Before hooks. Other hooks and status callbacks are outside these phases, so they need not sum to Duration. QueueDepth counts live waiting tasks including debounce, and ActiveWorkers includes canceled work until it returns. Counts are sampled for that AsyncAction at publication time and are not a global live gauge. Tasks UI and JSON exports carry the same metrics.

AsyncAction.ExportMetrics exposes the current aggregate through the dependency-free MetricsSink interface. Implement that one method to bridge Nodra into Prometheus, OpenTelemetry, logs, or an existing telemetry system without adding an SDK to the core module.

Use SubscribeMetrics when the exporter should receive a fresh aggregate after each terminal task. Callbacks run outside action locks, are safe to unsubscribe repeatedly, and a callback panic is contained so it cannot strand task completion.

Owned projections for large state

Get and ordinary selector subscriptions preserve the Store's full-state cloning policy. For hot reads, Select borrows read-only state and copies only the projection you return:

count := nodra.Select(store, func(s AppState) int { return len(s.Items) }).Value
items := nodra.Select(store, func(s AppState) []Item { return slices.Clone(s.Items) }).Value

The callback runs under the read lock: do not mutate state, retain source references, reenter the Store or block. Copy nested mutable references too. The returned snapshot includes the source version. SubscribeProjection / SubscribeProjectionWith offer the same explicit ownership boundary for subscriptions without a whole-state copy per listener. Their projector runs on immutable source snapshots and must return independently owned projections; callbacks must treat current/previous projections as immutable. Default stores still require immutable referenced data. Existing SubscribeSelector remains the convenient isolated choice. Only opt in to projections where the smaller copy matters. InvalidateProjection / InvalidateProjectionWith apply the same policy to window-bound redraws; DeriveProjection memoizes an owned projection. These accept read-only views and preserve the projector contract throughout the binding/derived pipeline. Derived reference values and listener arguments are read-only.

Grouped and filtered undo

history := nodra.TrackWith(store, nodra.HistoryOptions[AppState]{
    Limit: 50,
    Equals: func(a, b AppState) bool { return a.Title == b.Title },
    Restore: func(current, recorded AppState) AppState {
        current.Title = recorded.Title
        return current
    },
})
end, err := history.BeginGroup() // keep end until the drag/edit interaction finishes
// Handle err. Updates still publish for each frame.
end()

Equals ignores changes outside the edited fields. Restore preserves their current values during undo/redo; filtering alone does not preserve fields when restoring whole snapshots. These callbacks must be pure, must not mutate arguments, and must not reenter the Store or History because they can run under locks. Nested groups merge into one undo step; repeated end calls are safe. Concurrent writers join an open group. Travel is rejected with ErrHistoryGroup until every group ends. An empty/filtered group adds no step. Track(store, limit) retains its existing behavior.

History payload budgets and availability

HistoryOptions.MaxBytes limits the combined estimated payloads in undo and redo, in addition to the undo entry limit. Supply Size func(T) int64 with an application-specific estimate; for a text document this can be the text length plus known owned payloads. Zero disables sizing and preserves the existing count-only behavior. Negative budgets, a positive budget without Size, or negative estimates panic with ErrInvalidHistory before accepting that invalid estimate. Size follows the existing pure/non-reentrant callback rules.

history := nodra.TrackWith(document, nodra.HistoryOptions[Document]{
    Limit: 100,
    MaxBytes: 8 << 20,
    Size: func(d Document) int64 { return int64(len(d.Text)) },
})
stop := nodra.Invalidate(history.Status(), window)
canUndo := history.Status().Get().CanUndo

Oldest undo entries are evicted first, then oldest redo entries if necessary; an individually oversized entry is discarded. The estimate excludes the current history value, Store state, container overhead and shared allocations, so it is not a process heap limit. History.Status() is a read-only observable with counts, Bytes, Dropped, grouping and actionable CanUndo/CanRedo flags. Group start/end notify even without a source mutation. Status callbacks run outside both history and source locks, can reenter reads, and may overlap like Store callbacks. Close publishes Closed, releases retained entries, then closes the status source. The legacy CanUndo/CanRedo methods continue to report stack presence even during a group; the status flags additionally account for grouping.

Desktop workflow example

go run ./examples/workbench opens a local file-name search and a settings form. It uses real directory traversal, cooperative search cancellation, bounded results, UI-local drafts, asynchronous validation and atomic settings-file writes. Invalid directories and failed writes leave the draft available and the committed preferences unchanged. Cancel edits reloads the committed preferences. The settings path defaults to the user configuration directory; override it with -settings /path/to/settings.json. Saves also maintain a .bak copy. Add -devtools to print a local read-only inspection URL for real search/save task lifecycles.

Core usage

import nodra "github.com/yldm-tech/nodra"

tasks := nodra.New([]Task{})
stop := tasks.SubscribeWith(nodra.SubscribeOptions[[]Task]{Immediate: true}, func(snapshot nodra.Snapshot[[]Task]) {
	log.Printf("%d tasks", len(snapshot.Value))
})
defer stop()

tasks.Update(func(value *[]Task) {
	*value = append(*value, task)
})

What is included

  • Generic, concurrency-safe Store[T] with versioned snapshots
  • Opt-in NewWithClone / DefineWithClone for isolated mutable state and deep rollback
  • Set, Update, and UpdateErr with value rollback on returned errors and panics
  • Subscribe and SubscribeWith with immediate delivery and per-subscriber equality
  • SubscribeSelector and SubscribeSelectorWith for focused updates with previous values
  • Derive for memoized computed/getter values
  • Action / Do with action timing and error events
  • Latest-result asynchronous actions with cancellation, progress, observable status and explicit shutdown
  • Opt-in owned projections that avoid whole-state copies for hot reads and subscriptions
  • SubscribeMutations with direct, patch, reset and action metadata
  • Create, Define, State, Patch, and Reset aliases for familiar Pinia/Zustand workflows
  • Store IDs and independent named store instances
  • Action lifecycle hooks: before, after, and error
  • Nested Batch / BatchErr notification groups that publish one final snapshot
  • Transaction for one serialized, fallible mutation
  • Atomic2, AtomicUpdate, and AtomicGroup for cross-store visibility boundaries
  • Track / TrackWith history with bounded undo/redo, recording filters and cross-frame edit groups
  • Persistence[T] with pluggable storage and codec interfaces
  • Ready-to-use FileStorage and JSONCodec[T] with atomic file replacement
  • Optional journaled FileStorage recovery for committed crash records
  • VersionedJSONCodec[T] with sequential schema migrations and explicit legacy JSON support
  • AutoSaveWithErrors for observable persistence failures
  • AutoSaveAsync for debounced, coalesced writes with Flush, Close and an error stream
  • AESGCMCodec and key-provider interfaces for authenticated encrypted persistence with key rotation
  • Context-bound, bounded Watch streams that close with the store
  • Invalidate and InvalidateSelector for window-bound redraw subscriptions with automatic cleanup
  • Optional DevTools with a local browser inspector, mutation/action timeline, lazy state trees, key/value search, bounded state diffs, snapshot export, JSON editing and opt-in state restoration

Design guarantees

  • Store locks are released before user listeners run, so listeners can safely read or update the same store.
  • Versions increment once per published update, not once per mutation inside a batch.
  • Closing a store is permanent; updates return nodra.ErrClosed and active watches terminate.
  • By default, values are not deep copied. Copy reference types before changing them, or use NewWithClone with a complete clone function. Cloned stores isolate constructor and Set inputs, update candidates, reads, each subscriber, mutation metadata and reset state.
  • Selectors and derived getters catch up with changes during initialization and ignore computations from older versions once a newer version has been processed. Callbacks still run on writer goroutines and may run concurrently.
  • Concurrent Save calls on one Persistence instance are serialized from state capture through storage write, so a delayed save cannot overwrite a newer save. Keep one persistence instance per destination; Load is intended for startup before concurrent writes.
  • Subscriptions discard obsolete snapshots once they have accepted a newer source version. Callbacks that already started may still overlap or finish out of order. Watch channels are bounded and retain the newest accepted version; slow consumers may skip intermediate snapshots.
  • History keeps source versions monotonic and commits stack changes with successful undo/redo writes. Failed writes leave stacks intact. Travel inside an open batch returns ErrHistoryBatch; finish the batch before undoing or redoing.

Isolated mutable state

When state contains maps, slices or pointers, opt in to cloning instead of maintaining copy-on-write manually:

store := nodra.NewWithClone(map[string]int{"count": 0}, func(v map[string]int) map[string]int {
    return maps.Clone(v)
})
err := store.Transaction(func(state *map[string]int) error {
    (*state)["count"]++
    return validate(*state) // an error discards the candidate, including map changes
})

Clone all mutable nested references too. The function must support concurrent calls and must not reenter the store. Each isolation boundary costs a copy; keep using New for immutable state or large data already managed with copy-on-write. Named factories support the same policy through DefineWithClone.

Collection selectors

Selectors accept slices, maps and values containing interfaces. By default dynamically comparable values use == (including pointer identity); other values use reflect.DeepEqual. Nil and empty collections differ with the default comparison. Supply Equals for domain-specific equality or faster comparisons of large collections:

stop := nodra.SubscribeSelectorWith(store,
    func(s State) []string { return s.Tags },
    func(current, previous []string) { window.Invalidate() },
    nodra.SelectorOptions[[]string]{Equals: slices.Equal[[]string]},
)
defer stop()

For maps use maps.Equal[map[string]int, map[string]int] or a custom comparator. Comparators must be pure and concurrency-safe; a supplied comparator replaces the default entirely. Selected reference values follow the source store's ownership policy: treat them as read-only and copy before changing them. SubscribeSelector offers the same default equality without previous values or options.

Versioned persistence

VersionedJSONCodec[T] wraps state in {"schemaVersion": N, "state": ...}. Migrations[n] transforms state JSON from version n to n+1. With AllowUnversioned: true, existing raw JSON enters the chain at version zero:

codec := nodra.VersionedJSONCodec[Settings]{
    Version: 2,
    AllowUnversioned: true,
    Migrations: map[int]nodra.JSONMigration{
        0: migrateLegacyToV1,
        1: migrateV1ToV2,
    },
}
persistence := nodra.NewPersistence(store, nodra.FileStorage{Path: path}, codec)
err := persistence.Load(ctx) // completes migrations before changing the store

Missing migrations, invalid migration output and future schemas return errors. A failed load leaves the store and file untouched. Load before starting autosave; successful migration is written by a subsequent Save or autosave.

Validated persistence and backup recovery

NewPersistenceWithOptions accepts PersistenceOptions[T]{Validate: ...}. Validation runs after decoding/migration and before Store replacement, and before encoding/saving. It must be pure and concurrency-safe. A failed load or validation leaves memory and files unchanged. LoadError identifies read, decode or validate, distinguishes backup loads, and preserves the underlying error for errors.Is/errors.As.

Enable FileStorage{Path: path, Backup: true} to write an independently replaced path + ".bak" copy after successful primary replacement. Backup I/O errors are returned; the primary may already have been saved when a backup fails. The two files are not one transaction. Keep one Persistence writer per destination, and reserve its .bak path for this backup. Applications must surface save errors rather than assume both files were updated.

Set FileStorage.Durable: true to sync the containing directory after each rename. This adds filesystem latency but closes the rename-directory-entry crash window on platforms/filesystems that support directory fsync. Set FileStorage.Journal: true when startup recovery from a committed crash record is required; an unfinished journal has no commit marker and is ignored, while a committed record is checksum-verified and promoted before Load returns. Journal mode adds filesystem writes and does not replace application-level validation. Use AESGCMCodec around an inner codec when state must be encrypted; the envelope stores an algorithm, key ID, nonce and authenticated ciphertext, never plaintext. Add a new key to a KeyProvider, switch Current, then save to rotate. Historical key IDs remain readable until removed by the application. Nodra does not manage keychains or secret storage.

After a failed startup Load, stop and present that error before starting autosave. persistence.LoadBackup(ctx) explicitly validates and loads the backup into memory without rewriting either file. LoadWithRecovery(ctx) is the explicit fallback form: it tries primary, then backup, and returns RecoveryError with both causes when neither candidate works. A later Save intentionally persists the recovered/current state. No silent fallback discards a corrupt original. Stores without a backup-capable backend return ErrNoBackup. Try go run ./examples/persistent -file /path/to/state.json -recover-backup to inspect recovery without modifying the files.

For multiple cooperating instances, explicitly enable content-conflict detection:

storage, err := (nodra.FileStorage{Path: path, Backup: true}).WithConflictDetection(ctx)
// Handle err, then use the checked backend with ordinary Persistence APIs.
persistence := nodra.NewPersistence(store, storage, codec)
if err := persistence.Save(ctx); errors.Is(err, nodra.ErrFileConflict) {
    // Ask the application to reload/merge or save a separate copy.
}

The constructor observes current primary bytes (or absence) as the baseline without loading Store state. CheckedFileStorage checks a SHA-256 fingerprint under an OS advisory lock before replacing the file. Independent instances/processes using this backend coordinate through path + ".lock"; reserve that sidecar and never remove it while instances may be active. A FileConflictError carries expected/actual fingerprints without file contents. Rejected saves preserve primary and backup bytes, and retries keep failing until the application explicitly loads/reconciles or chooses another destination. Load acknowledges current primary bytes, including absence while returning os.ErrNotExist; decode/validation errors must still stop startup autosave. LoadBackup does not acknowledge changed primary content. A backup failure after primary replacement acknowledges that primary so retry can complete the backup without a false conflict. Async writers surface conflicts through Errors/Flush/Close and remain pending.

All writers must opt in for coordinated replacement. Plain FileStorage and external editors do not honor the lock: changes completed before the check are detected, but an uncooperative concurrent writer can race the check and replacement. This is not an atomic compare-and-swap against arbitrary filesystem writers. Use regular local files and one canonical destination; primary symlinks/nonregular files are rejected, parent directory aliases are canonicalized, and hard-link aliases/network filesystem locking are outside the contract. Equal-content replacements and changes reverted before the next check do not count as content conflicts. The default FileStorage remains unchanged.

Atomic2 provides a visibility boundary for two different stores. Both values commit or both roll back; notifications carry a shared transaction ID after commit. Readers block while the transaction callback runs, so derived UI can avoid observing an intermediate pair.

Write-behind persistence

Move encoding and disk I/O off the goroutine updating the store:

writer, err := persistence.AutoSaveAsync(ctx, nodra.AsyncOptions{
    Debounce: 100 * time.Millisecond,
})
// Handle err before using writer.
// Updates now coalesce on one worker; Flush bypasses the debounce delay.
err = writer.Flush(flushCtx)
// At application exit, stop updates and drain before canceling ctx or closing store.
err = writer.Close(shutdownCtx)

The writer schedules the current state once at startup. Flush waits for the version visible at the call; canceling one flush wait leaves the worker running. Failed writes stay pending and retry on the next update or flush. Errors() is a bounded stream for background failures; Flush and Close return errors directly. Close stops observing, attempts the final flush, and stops even on failure. Retry a failed shutdown write with persistence.Save and a fresh context.

writer.Status() returns an observable ReadOnly[PersistenceState] for UI labels and MyGo invalidation. RequestedVersion is the newest requested source version and SavedVersion is this writer's durable acknowledgement; HasSaved distinguishes unsaved version zero. Pending, Saving, Error and Closed distinguish waiting, active I/O, failure and shutdown. Pending stays true if shutdown leaves changes unsaved, and retry clears the displayed failure while saving. Revision prevents delayed publishers from replacing newer status. Status publications are serialized outside writer locks, so subscriber completion order follows revisions; do not call Flush or Close from the writer's own status callbacks. Subscriber panics cannot terminate the worker. Writes made separately through Persistence.Save or another instance are outside this writer's acknowledgement.

Parent cancellation and store closure stop the worker without a guaranteed drain.

examples/persistent combines cloning, legacy migration, error handling and graceful shutdown:

go run ./examples/persistent -file /path/to/state.json

Repository layout

*.go                           store, history, persistence, MyGo invalidation, and tests (package nodra)
devtools/                      optional inspector, local HTTP API and embedded browser UI
examples/                      standalone and native MyGo examples
docs/architecture.md           lifecycle and threading details

The repository is one Go module, github.com/yldm-tech/nodra, with a single go.mod at the root.

DevTools

import "github.com/yldm-tech/nodra/devtools"

inspector := devtools.New(devtools.Options{}) // read-only by default
defer inspector.Close()
stop, err := devtools.Attach(inspector, "preferences", prefsStore)
// Handle err before using stop; call stop when this store is no longer in scope.
url, err := inspector.Listen() // random loopback port; open the full session URL

The browser shows multiple stores, live state, bounded mutation/action history, action durations and errors, before/after diffs, filtering and JSON export. Set AllowRestore: true explicitly to enable typed JSON editing and restoring a retained snapshot. Restoration calls Set and emits new events; it does not reverse external action effects. Redact filters state before capture and disables restoration. RedactMetadata separately filters names, task keys and error text before timeline/task retention, pending progress and export. Metadata callback failures discard the original text and use a fixed diagnostic. Store IDs must be public labels. No external assets, services or frontend build step are required.

For a controlled remote read-only session, call inspector.ListenRemoteReadOnly("host:port") with the default options. It exposes authenticated GET inspection only and rejects restore and clear; place a non-loopback listener behind TLS or another encrypted transport. RotateToken invalidates previously issued URLs, and Options.RequestsPerSecond adds a per-inspector API rate limit.

Try the interactive example and see the DevTools guide for lifecycle, redaction and browser tests:

go run ./examples/devtools -restore

Nodra DevTools inspecting a state change

Verification

go test -race ./...
go vet ./...

Run the native MyGo example with go run ./examples/mygo.

CI compares public APIs with the PR base using a pinned golang.org/x/exp/cmd/apidiff and runs scripts/check-consumer.sh in a temporary independent module with GOWORK=off. Post-release verification runs the same consumer against the version downloaded through the Go proxy, without a local replace. To reproduce API comparison, set APIDIFF to the installed tool and run bash scripts/check-api.sh /path/to/base-checkout. Intentional incompatible changes require an exact incompatible report in .github/api-changes/<base-commit-sha>.txt plus migration guidance in the PR; unrelated or stale approvals cannot hide additional changes. This checks source compatibility, not runtime semantics or behavior of every downstream application.

CI runs race tests and vet on Linux, macOS and Windows. It also runs golangci-lint, two 10-second fuzz targets, benchmark smoke tests, cross builds for Linux and Windows, and checks that the module path is github.com/yldm-tech/nodra, that there is no other go.mod, go.work or replace directive, and that go mod tidy leaves no diff.

Fuzzing and benchmarks
go test -run '^$' -fuzz=FuzzVersionedJSONDecode -fuzztime=30s -parallel=2 .
go test -run '^$' -fuzz=FuzzHistoryStateMachine -fuzztime=30s -parallel=2 .
go test -run '^$' -bench=. -benchmem -count=10 . ./devtools

Fuzzing checks accepted versioned/legacy JSON round trips and compares bounded undo/redo/batch operations with an independent state-machine model. Benchmarks report store updates with 0/1/100 listeners, full cloning of a 1,000-entry map, and selection of 1,000-element slices with default versus custom equality. Run repeated measurements on the same Go version, hardware and load before comparing changes. The CI -benchtime=1x run only checks execution. A separate performance workflow checks out the PR base and candidate on the same runner and runs identical compatibility workloads in alternating order, 10 samples each, with one toolchain and two CPUs. It uploads raw samples, environment details and benchstat comparisons. Candidate-only benchmarks measure derived pipelines, combinations, scheduling and inspector off/every/coalesced progress (128 reports per task). Timing changes are review evidence rather than a fixed hosted-runner pass/fail threshold.

For a local comparison, export the baseline sources (v0.6.0 or newer) to an independent directory, install the pinned golang.org/x/perf/cmd/benchstat@v0.0.0-20261009192801-be2c69fb417e tool outside this module, then run bash scripts/benchmark.sh /path/to/baseline /path/to/results. The script copies only the compatibility workload into that baseline directory; do not point it at a checkout with work you need to preserve. The baseline samples use the candidate's exact same workload. Keep raw results rather than comparing numbers measured on different machines or under different load.

An illustrative local run on Apple M4 Pro, darwin/arm64 measured approximately 73 ns per update without listeners, 101 ns with one listener and 1.51 µs with 100 listeners; cloning the map cost 6.81 µs. Slice selection including the projection clone took 6.57 µs with default equality and 1.13 µs with slices.Equal. These are machine-specific measurements, not API guarantees.

Releases

The module is released by release-please with plain vX.Y.Z tags and one root CHANGELOG.md.

  1. Every push to main updates the release PR when there are releasable commits since the last tag. Merging that PR tags the release and publishes a GitHub release.
  2. After a release, post-release.yml checks that the tag resolves through proxy.golang.org.

Commit messages follow Conventional Commits:

  • feat: ... adds a feature (minor bump)
  • fix: ... fixes a bug (patch bump)
  • feat!: ... or a BREAKING CHANGE: footer marks a breaking change (minor bump while the version is below 1.0.0)
  • docs, test, refactor, ci, build and chore do not trigger a release

Use a scope where it helps, e.g. fix(persist): ....

Release PRs are pushed with the workflow's GITHUB_TOKEN. GitHub holds their test runs at "approval required", so the release workflow approves those runs itself (.github/scripts/start-pr-ci.sh). Setting a RELEASE_PLEASE_TOKEN repository secret (a fine-grained PAT or GitHub App token with contents and pull request write access) makes them trigger CI directly instead.

Pinia/Zustand-style example

store := nodra.Create(Counter{Count: 0})
increment := store.Action("increment", func(state *Counter) error {
    state.Count++
    return nil
})
double := nodra.Derive(store, func(state Counter) int {
    return state.Count * 2
}, func(a, b int) bool { return a == b })

_ = increment()
fmt.Println(store.State().Count, double.Get())

See docs/architecture.md for the full concept mapping.

Named stores

counter := nodra.Define("counter", func() Counter {
    return Counter{Count: 0}
})
store := counter.New()

Each call to New creates an independent instance with the same initial-state factory. The ID is available to mutation and action observers for logging and devtools integrations.

Documentation

Overview

Package nodra is a state library for MyGo apps: concurrency-safe reactive stores whose snapshots drive native MyGo views. Invalidate redraws a window when its store publishes; the Store itself does not depend on a running MyGo app, so services and tests can use it as well.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrNoUndo = errors.New("nodra: nothing to undo")
	ErrNoRedo = errors.New("nodra: nothing to redo")
)

ErrNoUndo and ErrNoRedo report an empty history stack.

View Source
var (
	ErrSuperseded    = errors.New("nodra: async action superseded")
	ErrInvalidAction = errors.New("nodra: invalid async action")
	ErrQueueFull     = errors.New("nodra: async action queue full")
)
View Source
var (
	ErrUnsupportedVersion = errors.New("nodra: unsupported schema version")
	ErrMissingMigration   = errors.New("nodra: missing schema migration")
)
View Source
var ErrClosed = errors.New("nodra: store is closed")
View Source
var ErrFileConflict = errors.New("nodra: file content changed since last observation")
View Source
var ErrHistoryBatch = errors.New("nodra: cannot undo or redo inside an open batch")

ErrHistoryBatch reports travel without an independent publication boundary.

View Source
var ErrHistoryGroup = errors.New("nodra: cannot undo or redo inside an open history group")
View Source
var ErrInvalidBinding = errors.New("nodra: invalid field binding")
View Source
var ErrInvalidHistory = errors.New("nodra: invalid history size configuration")
View Source
var ErrInvalidKey = errors.New("nodra: invalid encryption key")
View Source
var ErrInvalidPersistence = errors.New("nodra: invalid persistence configuration")
View Source
var ErrNoBackup = errors.New("nodra: backup storage unavailable")
View Source
var ErrPersistenceRecovery = errors.New("nodra: primary and backup recovery failed")

ErrPersistenceRecovery marks a failure to load both the primary and backup candidates.

Functions

func Atomic2 added in v0.9.0

func Atomic2[A, B any](first *Store[A], second *Store[B], fn func(*A, *B) error) (err error)

Atomic2 commits two different stores as one visibility boundary. All source values are prepared while both store locks are held; a callback error or panic leaves both stores unchanged. Readers and ordinary writers wait until both values are committed. Notifications run after the boundary is released, so listeners may safely reenter either store. The callback must not call Store methods directly; use the provided pointers.

func AtomicGroup added in v0.9.0

func AtomicGroup(ops ...AtomicOp) (err error)

AtomicGroup commits any number of typed Store updates as one visibility boundary. Every callback is prepared while all participating store locks are held; an error or panic leaves every store unchanged. Lock order is stable across calls, so groups built in opposite order cannot deadlock. Notifications run after all locks are released and may safely reenter their stores.

func Invalidate added in v0.2.0

func Invalidate[T any](source Observable[T], window *mygo.Window) (stop func())

Invalidate requests a new frame of window's native Content whenever store publishes a snapshot. The subscription stops when the window closes; the returned function stops it earlier and is safe to call repeatedly. Nil inputs and destroyed windows subscribe to nothing. A callback already running when stopped may still request a frame.

func InvalidateProjection added in v0.6.0

func InvalidateProjection[T, V any](source Reader[T], window *mygo.Window, project func(T) V) func()

InvalidateProjection requests frames only when an owned projection changes, without cloning the entire source. The projector and callbacks follow SubscribeProjection's ownership rules. The window owns the subscription lifetime.

func InvalidateProjectionWith added in v0.6.0

func InvalidateProjectionWith[T, V any](source Reader[T], window *mygo.Window, project func(T) V, options SelectorOptions[V]) func()

func InvalidateSelector added in v0.5.0

func InvalidateSelector[T any, V any](source Observable[T], window *mygo.Window, selector func(T) V) (stop func())

InvalidateSelector requests a frame only when the selected value changes, using SubscribeSelector's equality rules. The subscription stops when the window closes or the returned function is called. Selectors run on writer goroutines and must be concurrency-safe; selected reference values follow the store's ownership policy.

func InvalidateSelectorWith added in v0.5.0

func InvalidateSelectorWith[T any, V any](source Observable[T], window *mygo.Window, selector func(T) V, options SelectorOptions[V]) (stop func())

InvalidateSelectorWith is InvalidateSelector with custom equality and optional immediate invalidation. Nil inputs and destroyed windows subscribe to nothing. As with Invalidate, stopping does not wait for callbacks already running.

func InvalidateTarget added in v0.9.0

func InvalidateTarget[T any](source Observable[T], target InvalidationTarget) func()

InvalidateTarget requests a redraw from any InvalidationTarget whenever the source publishes. It accepts read-only and derived sources without exposing a concrete UI toolkit. The caller owns the target lifecycle and should call the returned stop function when the target is no longer valid.

func SubscribeProjection added in v0.6.0

func SubscribeProjection[T, V any](source Reader[T], project func(T) V, fn func(V)) func()

func SubscribeProjectionWith added in v0.6.0

func SubscribeProjectionWith[T, V any](source Reader[T], project func(T) V, fn func(current, previous V), options SelectorOptions[V]) func()

SubscribeProjectionWith is an opt-in selector that borrows immutable source snapshots instead of cloning the entire Store per notification. project must be pure and concurrency-safe, must not retain or mutate source references, and must return independently owned projections. Callbacks must treat the current/previous projections as immutable. Equality, initialization and callback ordering match SubscribeSelectorWith. On default stores, source reference values must remain immutable as with any retained snapshot.

func SubscribeSelector

func SubscribeSelector[T any, S any](store Observable[T], selector func(T) S, fn func(S)) func()

func SubscribeSelectorWith

func SubscribeSelectorWith[T any, V any](source Observable[T], selector func(T) V, fn func(current, previous V), options SelectorOptions[V]) func()

SubscribeSelectorWith calls fn with current and previous selected values. It catches up after registration and ignores obsolete computations. Callbacks run on writer goroutines and may overlap, as with Store.Subscribe.

Types

type AESGCMCodec added in v0.9.0

type AESGCMCodec[T any] struct {
	Inner Codec[T]
	Keys  KeyProvider
}

AESGCMCodec encrypts an inner codec's bytes with a random nonce and stores only the key ID, nonce and ciphertext in a JSON envelope. It supports key rotation by reading historical IDs from the provider while new Encode calls use Current.

func (AESGCMCodec[T]) Decode added in v0.9.0

func (c AESGCMCodec[T]) Decode(data []byte) (T, error)

func (AESGCMCodec[T]) Encode added in v0.9.0

func (c AESGCMCodec[T]) Encode(value T) ([]byte, error)

type ActionEvent

type ActionEvent struct {
	Metrics   TaskMetrics
	ID        uint64
	RunID     uint64
	Key       string
	Version   uint64
	Committed bool
	StoreID   string
	Name      string
	Started   time.Time
	Finished  time.Time
	Duration  time.Duration
	Err       error
}

ActionEvent describes one completed action.

type ActionHooks

type ActionHooks struct {
	Before func(ActionEvent)
	After  func(ActionEvent)
	Error  func(ActionEvent)
}

ActionHooks observes action lifecycle events. Before runs before the mutation; After runs only on success; Error runs only on failure.

type ActionMetrics added in v0.9.0

type ActionMetrics struct {
	Accepted    uint64
	Completed   uint64
	Succeeded   uint64
	Failed      uint64
	Canceled    uint64
	Retried     uint64
	QueuePeak   int
	DurationSum time.Duration
	DurationMax time.Duration
}

ActionMetrics aggregates terminal outcomes for one AsyncAction without retaining completed Task values. Duration totals include each run once; Retries counts additional work attempts.

type ActionOptions added in v0.6.0

type ActionOptions[I any] struct {
	Policy        ActionPolicy
	Key           func(I) string
	MaxConcurrent int
	QueueLimit    int
	// Debounce delays LatestWins work per key until this interval after acceptance. Superseded inputs never start work. Zero preserves immediate scheduling; QueueAll rejects a positive delay.
	Debounce time.Duration
	Retry    RetryOptions
}

ActionOptions configures cooperative work scheduling. LatestWins (default) replaces only the preceding task with the same Key; nil Key uses one shared key. QueueAll keeps every accepted task. MaxConcurrent bounds work including canceled work that has not returned; zero is unbounded for LatestWins and defaults to one for QueueAll. QueueLimit bounds waiting tasks (default 64 when bounded). Starts are FIFO; with concurrency above one commits follow completion order. Key must be pure and concurrency-safe.

type ActionPolicy added in v0.6.0

type ActionPolicy uint8
const (
	LatestWins ActionPolicy = iota
	QueueAll
)

type ActionTrace added in v0.6.0

type ActionTrace struct {
	Metrics     TaskMetrics
	ID          uint64
	RunID       uint64
	StoreID     string
	Name        string
	Key         string
	Phase       TaskPhase
	Progress    float64
	Revision    uint64
	TaskVersion uint64
	Version     uint64
	Committed   bool
	At          time.Time
	Duration    time.Duration
	Err         error
}

ActionTrace observes asynchronous task lifecycles. ID is the source Store action ID; RunID is local to its AsyncAction. Revision orders trace notifications even when callbacks overlap. Version identifies a published commit when Committed is true. Progress is monotonic for one task. Completion ActionEvents remain a separate, backward-compatible stream.

type AsyncAction added in v0.6.0

type AsyncAction[T, I, R any] struct {
	// contains filtered or unexported fields
}

AsyncAction runs work outside the Store lock with an explicit commit policy. The default accepts only the latest uncanceled run per key. Input and result ownership follows Store's policy; copy mutable inputs before Run. Work must honor its context. Progress, status subscribers and action hooks can run on background goroutines.

func NewAsyncAction added in v0.6.0

func NewAsyncAction[T, I, R any](store *Store[T], name string, work func(context.Context, I, func(float64)) (R, error), apply func(*T, R) error) (*AsyncAction[T, I, R], error)

func NewAsyncActionWithOptions added in v0.6.0

func NewAsyncActionWithOptions[T, I, R any](store *Store[T], name string, work func(context.Context, I, func(float64)) (R, error), apply func(*T, R) error, options ActionOptions[I]) (*AsyncAction[T, I, R], error)

func (*AsyncAction[T, I, R]) Close added in v0.6.0

func (a *AsyncAction[T, I, R]) Close(ctx context.Context) error

Close prevents new runs, cancels every active run and waits for cooperative work within ctx. The status store closes after the final run's terminal status. A timed-out Close can be called again to wait for remaining work. Do not Close synchronously from work, hooks or subscribers of this action; cancel there and close from the owner instead.

func (*AsyncAction[T, I, R]) ExportMetrics added in v0.9.0

func (a *AsyncAction[T, I, R]) ExportMetrics(sink MetricsSink) error

ExportMetrics records the current aggregate metrics for one action. It takes a snapshot under the action lock and invokes the sink afterward, so sinks may reenter the action or perform blocking export work. A nil sink is rejected.

func (*AsyncAction[T, I, R]) Metrics added in v0.9.0

func (a *AsyncAction[T, I, R]) Metrics() ActionMetrics

func (*AsyncAction[T, I, R]) Run added in v0.6.0

func (a *AsyncAction[T, I, R]) Run(ctx context.Context, input I) (*Task, error)

Run accepts work according to ActionOptions. LatestWins supersedes the preceding task with the same key; QueueAll retains every accepted task. Canceled and superseded results never commit. ErrQueueFull rejects work without canceling existing tasks.

func (*AsyncAction[T, I, R]) Status added in v0.6.0

func (a *AsyncAction[T, I, R]) Status() ReadOnly[AsyncState]

Status returns the observable status of the latest run, suitable for MyGo invalidation or DevTools attachment. Callers must not write to this store.

func (*AsyncAction[T, I, R]) SubscribeMetrics added in v0.9.0

func (a *AsyncAction[T, I, R]) SubscribeMetrics(fn func(ActionMetrics)) func()

SubscribeMetrics observes aggregate metrics after each terminal task. The callback runs outside the action lock and is protected from panics so an exporter cannot strand a task or terminate the worker goroutine. The returned function is idempotent.

type AsyncOptions added in v0.3.0

type AsyncOptions struct {
	Debounce time.Duration
}

AsyncOptions configures write-behind persistence. Debounce is a trailing-edge delay for bursts of updates; zero starts immediately. Flush bypasses the delay.

type AsyncPersistence added in v0.3.0

type AsyncPersistence[T any] struct {
	// contains filtered or unexported fields
}

AsyncPersistence owns one worker and a bounded error stream. It coalesces updates without blocking writer goroutines on encoding or storage. Failed writes remain pending; the next update or Flush retries them without spinning. Values must be immutable or the store must use NewWithClone.

func (*AsyncPersistence[T]) Close added in v0.3.0

func (a *AsyncPersistence[T]) Close(ctx context.Context) error

Close stops observing updates, flushes the final observed state, then stops the worker even if the flush fails. It is idempotent; concurrent callers wait on the same result with their own context. Do not mutate the store concurrently with Close if those changes must be included. Retry failed shutdown writes with Persistence.Save; a closed async writer cannot be restarted.

func (*AsyncPersistence[T]) Errors added in v0.3.0

func (a *AsyncPersistence[T]) Errors() <-chan error

Errors reports failed write attempts. The bounded stream may drop errors if unread; Flush and Close return their write error directly. It closes on exit.

func (*AsyncPersistence[T]) Flush added in v0.3.0

func (a *AsyncPersistence[T]) Flush(ctx context.Context) error

Flush waits until the version visible when called has been saved, bypassing debounce and retrying a previous failure. Canceling this wait does not cancel the shared worker. Later updates may be included in the same write.

func (*AsyncPersistence[T]) Status added in v0.7.0

func (a *AsyncPersistence[T]) Status() ReadOnly[PersistenceState]

Status exposes a read-only observable suitable for MyGo invalidation and DevTools. Status publications are serialized while remaining outside writer locks, so subscriber completion order follows status revisions. Do not Flush or Close this writer from its own subscribers. Subscriber panics cannot stop persistence or shutdown.

type AsyncState added in v0.6.0

type AsyncState struct {
	Metrics  TaskMetrics
	ID       uint64
	Name     string
	Running  bool
	Progress float64
	Error    string
	Phase    TaskPhase
	Key      string
	Revision uint64
}

AsyncState describes the latest run. Progress is between zero and one; Error is diagnostic text. Treat the action's status store as read-only.

type AtomicOp added in v0.9.0

type AtomicOp struct {
	// contains filtered or unexported fields
}

AtomicOp is one typed update in an AtomicGroup. Create operations with AtomicUpdate; the fields are intentionally private so an operation cannot be constructed without the store lock and rollback guarantees.

func AtomicUpdate added in v0.9.0

func AtomicUpdate[T any](store *Store[T], fn func(*T) error) AtomicOp

AtomicUpdate prepares one store update for an AtomicGroup. The callback sees a private candidate and may return an error to abort the complete group. It must not call Store methods directly; use the provided pointer instead.

type CheckedFileStorage added in v0.7.0

type CheckedFileStorage struct {
	// contains filtered or unexported fields
}

CheckedFileStorage opts into content-conflict detection for a FileStorage destination. Use the returned pointer without copying it. Cooperating instances serialize checks and replacements through a persistent Path+".lock" sidecar. Never remove that sidecar while any instance may be active. Plain FileStorage and editors do not honor the advisory lock: their edits completed before the check are detected, but a concurrent uncooperative write can race the replacement. Use regular local files and the same canonical destination; hard-link aliases and network filesystem locking are outside this contract.

func (*CheckedFileStorage) Load added in v0.7.0

func (c *CheckedFileStorage) Load(ctx context.Context) (data []byte, err error)

Load explicitly accepts the bytes read as the new baseline. A missing primary acknowledges absence and still returns os.ErrNotExist. Decode/validation remains Persistence's responsibility; stop on those errors before starting autosave.

func (*CheckedFileStorage) LoadBackup added in v0.7.0

func (c *CheckedFileStorage) LoadBackup(ctx context.Context) (data []byte, err error)

LoadBackup reads the backup without acknowledging changed primary content. Recovery does not silently authorize replacing external primary edits.

func (*CheckedFileStorage) Save added in v0.7.0

func (c *CheckedFileStorage) Save(ctx context.Context, data []byte) error

Save rejects content that differs from the last successful Load or primary replacement. A new instance uses the constructor's baseline. A failed backup can follow a successful primary write; that primary is acknowledged so a retry does not conflict with this instance's own write. Conflicts leave primary and backup untouched and remain conflicts until the application explicitly loads/reconciles or chooses another destination.

type Codec

type Codec[T any] interface {
	Encode(T) ([]byte, error)
	Decode([]byte) (T, error)
}

Codec serializes and restores one state value.

type Definition

type Definition[T any] struct {
	// contains filtered or unexported fields
}

Definition is a reusable named store factory, analogous to Pinia's defineStore and Zustand's reusable vanilla store creator.

func Define

func Define[T any](id string, initial func() T) *Definition[T]

Define creates a store definition. Each New call gets independent state.

func DefineWithClone added in v0.3.0

func DefineWithClone[T any](id string, initial func() T, clone func(T) T) *Definition[T]

DefineWithClone creates a named factory with NewWithClone's isolation policy.

func (*Definition[T]) ID

func (d *Definition[T]) ID() string

func (*Definition[T]) New

func (d *Definition[T]) New() *Store[T]

type Derived

type Derived[T any, V any] struct {
	// contains filtered or unexported fields
}

Derived is a memoized getter derived from an observable value. It recomputes when the source publishes and only notifies listeners when the selected value changes. Reference values and listener arguments remain read-only.

func Combine added in v0.7.0

func Combine[A, B, V any](first Observable[A], second Observable[B], project func(A, B) V, equals func(V, V) bool) *Derived[Pair[A, B], V]

Combine derives a value from two stores or derived values. Independent writes are eventually consistent, while Atomic2 and AtomicGroup transaction notifications are settled through the derived graph before downstream callbacks run. Old source versions and obsolete computations cannot replace newer ones. Close the returned derived value to detach both sources. Projectors/comparators must be pure, concurrency-safe and keep source references immutable.

func Derive

func Derive[T any, V any](source Observable[T], selector func(T) V, equals func(V, V) bool) *Derived[T, V]

func DeriveProjection added in v0.6.0

func DeriveProjection[T any, V any](source Reader[T], project func(T) V, equals func(V, V) bool) *Derived[T, V]

DeriveProjection memoizes an owned projection without whole-source clones. It follows SubscribeProjection's ownership contract; returned reference values and listener arguments are read-only.

func (*Derived[T, V]) Close

func (d *Derived[T, V]) Close() error

func (*Derived[T, V]) Get

func (d *Derived[T, V]) Get() V

func (*Derived[T, V]) Snapshot added in v0.7.0

func (d *Derived[T, V]) Snapshot() Snapshot[V]

Snapshot returns the memoized value, its accepted source version and the cross-store transaction that produced it. Derived reference values are read-only; equality-suppressed changes still advance the version without notifying.

func (*Derived[T, V]) Subscribe

func (d *Derived[T, V]) Subscribe(fn func(V)) func()

func (*Derived[T, V]) SubscribeSnapshot added in v0.7.0

func (d *Derived[T, V]) SubscribeSnapshot(fn func(Snapshot[V])) func()

type Field added in v0.7.0

type Field[T, V any] struct {
	// contains filtered or unexported fields
}

Field binds an immediate editor to one field of shared state. A write runs against the latest Store candidate, preserving unrelated concurrent changes. Read/write callbacks follow the Store ownership policy; use NewWithClone for mutable references. Keep form drafts separate when edits require explicit Save/Cancel.

func BindField added in v0.7.0

func BindField[T, V any](store *Store[T], read func(T) V, write func(*T, V) error) (Field[T, V], error)

func (Field[T, V]) Edit added in v0.7.0

func (f Field[T, V]) Edit(edit func(*V) bool) error

Edit draws/edits a local field value and commits it only when edit reports a change. The editor runs outside Store locks, so it can render MyGo controls. Validation errors are returned to the owner; failed writes preserve shared state.

func (Field[T, V]) Get added in v0.7.0

func (f Field[T, V]) Get() V

Get reads an isolated Store snapshot before selecting the field. Returned reference values have the source's clone policy.

func (Field[T, V]) Set added in v0.7.0

func (f Field[T, V]) Set(value V) error

type FileConflictError added in v0.7.0

type FileConflictError struct {
	Path     string
	Expected FileFingerprint
	Actual   FileFingerprint
}

FileConflictError reports conflicting content without embedding file data. Rejected saves leave primary and backup bytes unchanged. Load explicitly acknowledges current primary content; use a different destination to preserve both versions instead.

func (*FileConflictError) Error added in v0.7.0

func (e *FileConflictError) Error() string

func (*FileConflictError) Unwrap added in v0.7.0

func (e *FileConflictError) Unwrap() error

type FileFingerprint added in v0.7.0

type FileFingerprint struct {
	Exists bool
	SHA256 [sha256.Size]byte
}

FileFingerprint identifies the bytes observed at a destination. Exists distinguishes a missing file from an empty file. Equal-content replacements and changes reverted before the next check are indistinguishable.

type FileStorage

type FileStorage struct {
	Path string
	Perm os.FileMode
	// Backup writes an independent Path+".bak" copy after each successful primary replacement. A backup failure is returned even though the primary may already contain the new state. Use one Persistence writer per destination.
	Backup bool
	// Durable syncs the containing directory after each rename. This makes the file name update durable across a crash on filesystems that support directory fsync. It may add latency and is opt-in for compatibility.
	Durable bool
	// Journal enables a two-phase crash recovery record. The journal is committed
	// only after the primary rename and directory sync; an unfinished record is
	// discarded on the next Load. It adds extra filesystem writes and is opt-in.
	Journal bool
}

FileStorage stores encoded state at Path. Save writes a temporary file in the same directory and renames it into place, so readers see a complete file.

func (FileStorage) Load

func (f FileStorage) Load(ctx context.Context) ([]byte, error)

func (FileStorage) LoadBackup added in v0.6.0

func (f FileStorage) LoadBackup(ctx context.Context) ([]byte, error)

func (FileStorage) Save

func (f FileStorage) Save(ctx context.Context, data []byte) error

func (FileStorage) WithConflictDetection added in v0.7.0

func (f FileStorage) WithConflictDetection(ctx context.Context) (*CheckedFileStorage, error)

WithConflictDetection creates the parent directory/lock sidecar and observes current primary bytes as the initial baseline, including absence. It does not load or validate Store state. Parent directory aliases are canonicalized; primary symlinks and nonregular files are rejected. All cooperating writers must opt in; ordinary FileStorage behavior remains unchanged.

type History

type History[T any] struct {
	// contains filtered or unexported fields
}

History records successful store snapshots and provides bounded undo/redo.

func Track

func Track[T any](store *Store[T], limit int) *History[T]

Track starts recording changes to store. limit is the maximum number of undo entries; values below one disable history recording.

func TrackWith added in v0.6.0

func TrackWith[T any](store *Store[T], options HistoryOptions[T]) *History[T]

func (*History[T]) BeginGroup added in v0.6.0

func (h *History[T]) BeginGroup() (func(), error)

BeginGroup merges edits across frames into one undo step while still publishing every update. Groups nest; the returned end function is idempotent. Concurrent writers join the group. Undo and Redo are rejected until all groups end. Filtering still applies, so a group containing only ignored changes adds no step.

func (*History[T]) CanRedo

func (h *History[T]) CanRedo() bool

func (*History[T]) CanUndo

func (h *History[T]) CanUndo() bool

func (*History[T]) Close

func (h *History[T]) Close() error

Close stops recording future changes. It does not close the underlying store.

func (*History[T]) Redo

func (h *History[T]) Redo() error

Redo restores the next state.

func (*History[T]) Status added in v0.7.0

func (h *History[T]) Status() ReadOnly[HistoryState]

func (*History[T]) Undo

func (h *History[T]) Undo() error

Undo restores the previous state.

type HistoryOptions added in v0.6.0

type HistoryOptions[T any] struct {
	Limit int
	// MaxBytes budgets estimated undo and redo payloads together; zero disables sizing. Positive budgets require Size. The current value, Store state, shared allocations and metadata are outside this estimate.
	MaxBytes int64
	// Size estimates one retained value. It must return a nonnegative value and follow Equals/Restore purity and lock rules. Invalid configurations or negative estimates panic with ErrInvalidHistory.
	Size    func(T) int64
	Equals  func(previous, next T) bool
	Restore func(current, recorded T) T
}

HistoryOptions configures recording. Equals excludes changes to irrelevant fields. Restore merges recorded fields into current state so ignored fields survive travel. Both functions must be pure, must not mutate arguments, and must not reenter the Store or History; they can run while those locks are held. With nil callbacks, every publication records and travel replaces the whole value.

type HistoryState added in v0.7.0

type HistoryState struct {
	Revision    uint64
	UndoEntries int
	RedoEntries int
	Bytes       int64
	MaxBytes    int64
	Dropped     uint64
	Grouped     bool
	Closed      bool
	CanUndo     bool
	CanRedo     bool
}

HistoryState describes history availability and estimated retained payloads. Revision orders status publications independently of source versions. Bytes excludes the current value and is an application estimate, not process heap usage. CanUndo/CanRedo are false while grouped or closed.

type InvalidationTarget added in v0.9.0

type InvalidationTarget interface {
	Invalidate()
}

InvalidationTarget is the minimal redraw capability required by InvalidateTarget. MyGo Window satisfies it, as do test fakes and other native UI hosts. Implementations must make Invalidate safe to call from a Store subscriber goroutine.

type JSONCodec

type JSONCodec[T any] struct{}

JSONCodec encodes a store value using encoding/json.

func (JSONCodec[T]) Decode

func (JSONCodec[T]) Decode(data []byte) (T, error)

func (JSONCodec[T]) Encode

func (JSONCodec[T]) Encode(value T) ([]byte, error)

type JSONMigration added in v0.3.0

type JSONMigration func(json.RawMessage) (json.RawMessage, error)

JSONMigration converts one schema version to the next. Inputs and outputs are state JSON, without the version envelope. Return an error to abort Load.

type KeyProvider added in v0.9.0

type KeyProvider interface {
	Current() (id string, key []byte, err error)
	Get(id string) ([]byte, error)
}

KeyProvider supplies the current key for new writes and historical keys for reads. Implementations should keep key material outside persisted state and rotate by adding the new key before making it current.

type LoadError added in v0.6.0

type LoadError struct {
	Stage  string
	Backup bool
	Err    error
}

LoadError identifies the failing load stage without changing state or files. Backup distinguishes explicit backup loads. Unwrap preserves codec, validation and filesystem errors.

func (*LoadError) Error added in v0.6.0

func (e *LoadError) Error() string

func (*LoadError) Unwrap added in v0.6.0

func (e *LoadError) Unwrap() error

type MemoryKeyProvider added in v0.9.0

type MemoryKeyProvider struct {
	// contains filtered or unexported fields
}

MemoryKeyProvider is a small concurrency-safe provider for tests and local applications. Production applications should implement KeyProvider over their OS keychain or secret manager.

func NewMemoryKeyProvider added in v0.9.0

func NewMemoryKeyProvider(id string, key []byte) (*MemoryKeyProvider, error)

func (*MemoryKeyProvider) Add added in v0.9.0

func (p *MemoryKeyProvider) Add(id string, key []byte) error

func (*MemoryKeyProvider) Current added in v0.9.0

func (p *MemoryKeyProvider) Current() (string, []byte, error)

func (*MemoryKeyProvider) Get added in v0.9.0

func (p *MemoryKeyProvider) Get(id string) ([]byte, error)

func (*MemoryKeyProvider) SetCurrent added in v0.9.0

func (p *MemoryKeyProvider) SetCurrent(id string) error

type MetricsSink added in v0.9.0

type MetricsSink interface {
	RecordActionMetrics(name string, metrics ActionMetrics)
}

MetricsSink is the dependency-free bridge for exporting Nodra action metrics. Adapters can translate one snapshot into Prometheus gauges, OpenTelemetry instruments, logs, or an application's existing telemetry system without making the core package depend on a telemetry SDK.

type Mutation

type Mutation[T any] struct {
	ActionID      uint64
	TransactionID uint64
	Type          MutationType
	Name          string
	Previous      T
	Value         T
	Version       uint64
	At            time.Time
}

Mutation contains Pinia-like subscription metadata.

type MutationType

type MutationType string

MutationType describes why a store published a snapshot.

const (
	MutationDirect      MutationType = "direct"
	MutationPatch       MutationType = "patch"
	MutationAction      MutationType = "action"
	MutationReset       MutationType = "reset"
	MutationTransaction MutationType = "transaction"
)

type Observable added in v0.7.0

type Observable[T any] interface {
	Snapshot() Snapshot[T]
	SubscribeSnapshot(func(Snapshot[T])) func()
}

Observable is a versioned value source shared by stores, read-only views and derived values. Callbacks may overlap and must treat reference values as read-only. Implementations must use monotonically increasing versions and subscribe without replay; constructors catch up after subscribing.

type Pair added in v0.7.0

type Pair[A, B any] struct {
	First  A
	Second B
}

Pair contains the latest observed values of two independent sources.

type PanicError added in v0.6.0

type PanicError struct{ Value any }

PanicError reports a panic at the asynchronous user-code boundary. The Store may already have committed if a notification or completion hook panicked.

func (*PanicError) Error added in v0.6.0

func (e *PanicError) Error() string

type Persistence

type Persistence[T any] struct {
	// contains filtered or unexported fields
}

Persistence connects a store to a Storage and Codec. It is explicit by default: call Load at startup and Save after successful application actions. Save calls on the same instance are serialized, including state capture and encoding. Separate instances sharing a storage destination are not coordinated.

func NewPersistence

func NewPersistence[T any](store *Store[T], storage Storage, codec Codec[T]) *Persistence[T]

func NewPersistenceWithOptions added in v0.6.0

func NewPersistenceWithOptions[T any](store *Store[T], storage Storage, codec Codec[T], options PersistenceOptions[T]) *Persistence[T]

func (*Persistence[T]) AutoSave

func (p *Persistence[T]) AutoSave(ctx context.Context) func()

AutoSave subscribes to updates and saves synchronously after each update. The returned function stops autosaving. Use Batch to coalesce writes.

func (*Persistence[T]) AutoSaveAsync added in v0.3.0

func (p *Persistence[T]) AutoSaveAsync(ctx context.Context, options AsyncOptions) (*AsyncPersistence[T], error)

AutoSaveAsync subscribes and schedules the current state once, then all subsequent versions. Use Close before closing the store to drain writes. Parent cancellation and store closure stop the worker without a guaranteed drain. Storage.Save must respect context cancellation to bound shutdown time.

func (*Persistence[T]) AutoSaveWithErrors

func (p *Persistence[T]) AutoSaveWithErrors(ctx context.Context) (<-chan error, func())

AutoSaveWithErrors subscribes to updates and reports persistence failures on a buffered channel. The channel closes when ctx is canceled or stop is called. Stop releases the background watcher but does not wait for in-flight saves.

func (*Persistence[T]) Load

func (p *Persistence[T]) Load(ctx context.Context) error

func (*Persistence[T]) LoadBackup added in v0.6.0

func (p *Persistence[T]) LoadBackup(ctx context.Context) error

LoadBackup explicitly restores validated backup state to memory. It never rewrites the primary file, so a corrupt original remains available for diagnosis. Call before starting autosave; a later Save intentionally writes the current state to disk.

func (*Persistence[T]) LoadWithRecovery added in v0.9.0

func (p *Persistence[T]) LoadWithRecovery(ctx context.Context) error

LoadWithRecovery explicitly tries the primary first and then the backup when the primary cannot be read, decoded or validated. It never rewrites either file and never hides a failure: if both candidates fail, RecoveryError retains both causes. Cancellation and a closed Store stop before attempting the backup.

func (*Persistence[T]) Save

func (p *Persistence[T]) Save(ctx context.Context) error

type PersistenceOptions added in v0.6.0

type PersistenceOptions[T any] struct{ Validate func(T) error }

PersistenceOptions validates decoded and saved state before it reaches the Store or storage. Validate must be pure and concurrency-safe, and must not mutate its input.

type PersistenceState added in v0.7.0

type PersistenceState struct {
	Revision         uint64
	RequestedVersion uint64
	SavedVersion     uint64
	HasSaved         bool
	Pending          bool
	Saving           bool
	Closed           bool
	Error            string
}

PersistenceState describes this async writer's durable acknowledgement, not writes made through other Persistence instances. HasSaved distinguishes an unsaved initial version zero. Pending remains true after failures and shutdown with unsaved changes. Error is the last write failure, cleared on retry. Revision orders publications independently of Store versions.

type ReadOnly added in v0.6.0

type ReadOnly[T any] struct {
	// contains filtered or unexported fields
}

ReadOnly grants observation without Set, Update, Reset, action hooks or Close. Values still follow the source's cloning/immutability policy; this is a capability boundary, not a deep-freeze operation. Its zero value is an empty, invalid source (Done returns nil).

func (ReadOnly[T]) ActiveTasks added in v0.6.0

func (r ReadOnly[T]) ActiveTasks() []ActionTrace

func (ReadOnly[T]) Done added in v0.6.0

func (r ReadOnly[T]) Done() <-chan struct{}

func (ReadOnly[T]) Get added in v0.6.0

func (r ReadOnly[T]) Get() T

func (ReadOnly[T]) ID added in v0.6.0

func (r ReadOnly[T]) ID() string

func (ReadOnly[T]) Snapshot added in v0.6.0

func (r ReadOnly[T]) Snapshot() Snapshot[T]

func (ReadOnly[T]) State added in v0.6.0

func (r ReadOnly[T]) State() T

func (ReadOnly[T]) Subscribe added in v0.6.0

func (r ReadOnly[T]) Subscribe(fn func(Snapshot[T])) func()

func (ReadOnly[T]) SubscribeActionTrace added in v0.6.0

func (r ReadOnly[T]) SubscribeActionTrace(fn func(ActionTrace)) func()

func (ReadOnly[T]) SubscribeActions added in v0.6.0

func (r ReadOnly[T]) SubscribeActions(fn func(ActionEvent)) func()

func (ReadOnly[T]) SubscribeMutations added in v0.6.0

func (r ReadOnly[T]) SubscribeMutations(fn func(Mutation[T])) func()

func (ReadOnly[T]) SubscribeSnapshot added in v0.7.0

func (r ReadOnly[T]) SubscribeSnapshot(fn func(Snapshot[T])) func()

func (ReadOnly[T]) SubscribeWith added in v0.6.0

func (r ReadOnly[T]) SubscribeWith(options SubscribeOptions[T], fn func(Snapshot[T])) func()

func (ReadOnly[T]) TaskSnapshot added in v0.6.0

func (r ReadOnly[T]) TaskSnapshot() TaskSnapshot

func (ReadOnly[T]) Version added in v0.6.0

func (r ReadOnly[T]) Version() uint64

func (ReadOnly[T]) Watch added in v0.6.0

func (r ReadOnly[T]) Watch(ctx context.Context, buffer int) <-chan Snapshot[T]

type Reader added in v0.6.0

type Reader[T any] interface {
	Get() T
	State() T
	Snapshot() Snapshot[T]
	SubscribeSnapshot(func(Snapshot[T])) func()
	Version() uint64
	ID() string
	Done() <-chan struct{}
	Subscribe(func(Snapshot[T])) func()
	SubscribeWith(SubscribeOptions[T], func(Snapshot[T])) func()
	SubscribeMutations(func(Mutation[T])) func()
	SubscribeActions(func(ActionEvent)) func()
	SubscribeActionTrace(func(ActionTrace)) func()
	ActiveTasks() []ActionTrace
	TaskSnapshot() TaskSnapshot
	Watch(context.Context, int) <-chan Snapshot[T]
	// contains filtered or unexported methods
}

Reader is the observation surface shared by Store and ReadOnly. Only Nodra sources implement it; a read-only wrapper never exposes its backing Store. Snapshot ownership and callback concurrency are unchanged.

type RecoveryError added in v0.9.0

type RecoveryError struct {
	Primary error
	Backup  error
}

RecoveryError preserves both the primary load failure and the backup load failure returned by LoadWithRecovery. errors.Is and errors.As can inspect either cause through the multi-error unwrap.

func (*RecoveryError) Error added in v0.9.0

func (e *RecoveryError) Error() string

func (*RecoveryError) Unwrap added in v0.9.0

func (e *RecoveryError) Unwrap() []error

type RetryOptions added in v0.9.0

type RetryOptions struct {
	MaxAttempts int
	Backoff     time.Duration
	MaxBackoff  time.Duration
	Idempotent  bool
}

RetryOptions enables bounded retries for an explicitly idempotent action. MaxAttempts includes the first run; zero disables retries. Backoff doubles between attempts and is capped by MaxBackoff.

type Scope added in v0.6.0

type Scope struct {
	// contains filtered or unexported fields
}

Scope owns a module's lifetime. Close cancels Context, stops observations, then runs deferred drains in reverse registration order. Register stores first, persistence second, and actions last so work finishes before persistence drains and stores close. Shared stores need not belong to a Scope.

func NewScope added in v0.6.0

func NewScope() *Scope

func (*Scope) Close added in v0.6.0

func (s *Scope) Close(ctx context.Context) error

Close starts shutdown once. Concurrent callers share its result but can stop waiting with their own context. The first caller supplies the drain context; resources receive that deadline even if the caller stops waiting. Persistence that must drain should use an independent worker context, not Scope.Context, which is canceled first.

func (*Scope) Context added in v0.6.0

func (s *Scope) Context() context.Context

func (*Scope) Defer added in v0.6.0

func (s *Scope) Defer(cleanup func(context.Context) error) error

Defer registers a context-aware drain. Late registrations run immediately with the original shutdown context and return ErrClosed plus any cleanup error. Drains must respect context cancellation and must not synchronously Close their own Scope.

func (*Scope) Done added in v0.6.0

func (s *Scope) Done() <-chan struct{}

func (*Scope) Observe added in v0.6.0

func (s *Scope) Observe(stop func()) error

Observe owns an unsubscribe function. A registration racing with Close is stopped immediately instead of leaking. Already executing source callbacks are not awaited.

type SelectorOptions

type SelectorOptions[V any] struct {
	Immediate bool
	Equals    func(previous, next V) bool
}

SelectorOptions configures a selector subscription. With no Equals function, comparable values use == (including pointer identity); other values use reflect.DeepEqual. A custom comparator can use slices.Equal or maps.Equal.

type Snapshot

type Snapshot[T any] struct {
	Value         T
	Version       uint64
	TransactionID uint64
}

Snapshot is a source value accepted at Version. TransactionID is non-zero when the publication belongs to Atomic2 or AtomicGroup; derived sources propagate it so one transaction can be settled before downstream delivery.

func Select added in v0.6.0

func Select[T, V any](source Reader[T], project func(T) V) Snapshot[V]

Select reads an owned projection and source version without cloning the whole Store. project borrows read-only state under the read lock: it must not mutate or retain source references, reenter the Store, or block. Return scalars or copy all mutable references in the projection (for example with slices.Clone). Existing Get and SubscribeSelector keep their full-state isolation policy.

type Storage

type Storage interface {
	Load(context.Context) ([]byte, error)
	Save(context.Context, []byte) error
}

Storage is the minimal persistence backend used by Persistence. Files, key-value databases and encrypted stores can implement it.

type Store

type Store[T any] struct {
	// contains filtered or unexported fields
}

func Create

func Create[T any](initial T) *Store[T]

Create is the Pinia/Zustand-style constructor name for a typed store.

func New

func New[T any](initial T) *Store[T]

func NewWithClone added in v0.3.0

func NewWithClone[T any](initial T, clone func(T) T) *Store[T]

NewWithClone isolates owned state, snapshots, subscriber values and rollback with clone. Clone must copy all mutable references, be safe for concurrent calls, and must not reenter the store. A nil clone preserves New's behavior.

func (*Store[T]) Action

func (s *Store[T]) Action(name string, fn func(*T) error) func() error

Action returns a reusable named action function, matching Pinia action definitions while keeping Go's explicit function types.

func (*Store[T]) ActiveTasks added in v0.6.0

func (s *Store[T]) ActiveTasks() []ActionTrace

ActiveTasks returns queued/running tasks independently of diagnostic sampling. The slice is owned by the caller.

func (*Store[T]) Batch

func (s *Store[T]) Batch(fn func()) error

Batch groups notifications from multiple updates into one final snapshot. It does not roll back successful updates when fn returns an error; use Atomic2 or AtomicGroup when the updates must commit as one transaction.

func (*Store[T]) BatchErr added in v0.9.1

func (s *Store[T]) BatchErr(fn func() error) error

BatchErr groups notifications like Batch and returns the callback's error. Updates completed before an error remain committed, but their subscribers are still notified once when the outermost batch ends.

func (*Store[T]) Close

func (s *Store[T]) Close() error

func (*Store[T]) Do

func (s *Store[T]) Do(name string, fn func(*T) error) error

Do runs a named mutation and emits an action event after it completes.

func (*Store[T]) Done added in v0.6.0

func (s *Store[T]) Done() <-chan struct{}

func (*Store[T]) Get

func (s *Store[T]) Get() T

func (*Store[T]) ID

func (s *Store[T]) ID() string

func (*Store[T]) Patch

func (s *Store[T]) Patch(fn func(*T)) error

func (*Store[T]) ReadOnly added in v0.6.0

func (s *Store[T]) ReadOnly() ReadOnly[T]

func (*Store[T]) Reset

func (s *Store[T]) Reset() error

func (*Store[T]) Set

func (s *Store[T]) Set(value T) error

func (*Store[T]) Snapshot

func (s *Store[T]) Snapshot() Snapshot[T]

func (*Store[T]) State

func (s *Store[T]) State() T

func (*Store[T]) Subscribe

func (s *Store[T]) Subscribe(fn func(Snapshot[T])) func()

func (*Store[T]) SubscribeActionHooks

func (s *Store[T]) SubscribeActionHooks(hooks ActionHooks) func()

SubscribeActionHooks registers lifecycle hooks and returns an unsubscribe function.

func (*Store[T]) SubscribeActionTrace added in v0.6.0

func (s *Store[T]) SubscribeActionTrace(fn func(ActionTrace)) func()

func (*Store[T]) SubscribeActions

func (s *Store[T]) SubscribeActions(fn func(ActionEvent)) func()

func (*Store[T]) SubscribeMutations

func (s *Store[T]) SubscribeMutations(fn func(Mutation[T])) func()

func (*Store[T]) SubscribeSnapshot added in v0.7.0

func (s *Store[T]) SubscribeSnapshot(fn func(Snapshot[T])) func()

func (*Store[T]) SubscribeWith

func (s *Store[T]) SubscribeWith(options SubscribeOptions[T], fn func(Snapshot[T])) func()

func (*Store[T]) TaskSnapshot added in v0.6.0

func (s *Store[T]) TaskSnapshot() TaskSnapshot

func (*Store[T]) Transaction

func (s *Store[T]) Transaction(fn func(*T) error) error

func (*Store[T]) Update

func (s *Store[T]) Update(fn func(*T)) error
Example
package main

import (
	"fmt"

	"github.com/yldm-tech/nodra"
)

func main() {
	store := nodra.New(0)
	store.Subscribe(func(snapshot nodra.Snapshot[int]) {
		fmt.Println(snapshot.Value, snapshot.Version)
	})
	if err := store.Update(func(value *int) { *value = 42 }); err != nil {
		fmt.Println(err)
	}
}
Output:
42 1

func (*Store[T]) UpdateErr

func (s *Store[T]) UpdateErr(fn func(*T) error) error

func (*Store[T]) Version

func (s *Store[T]) Version() uint64

func (*Store[T]) Watch

func (s *Store[T]) Watch(ctx context.Context, buffer int) <-chan Snapshot[T]

type SubscribeOptions

type SubscribeOptions[T any] struct {
	Immediate bool
	Equals    func(previous, next T) bool
}

type Task added in v0.6.0

type Task struct {
	ID uint64
	// contains filtered or unexported fields
}

Task is one run of an AsyncAction. Cancel requests cooperative cancellation; Wait waits for work and its completion notifications, independently of the waiter's context. Do not Wait from the task's own work, hooks or synchronous subscribers.

func (*Task) Cancel added in v0.6.0

func (t *Task) Cancel()

func (*Task) Done added in v0.6.0

func (t *Task) Done() <-chan struct{}

func (*Task) Status added in v0.6.0

func (t *Task) Status() ReadOnly[AsyncState]

func (*Task) Wait added in v0.6.0

func (t *Task) Wait(ctx context.Context) error

type TaskMetrics added in v0.7.0

type TaskMetrics struct {
	Attempts      int           `json:"attempts"`
	Debounce      time.Duration `json:"debounce"`
	Queue         time.Duration `json:"queue"`
	Work          time.Duration `json:"work"`
	Commit        time.Duration `json:"commit"`
	QueueDepth    int           `json:"queueDepth"`
	ActiveWorkers int           `json:"activeWorkers"`
}

TaskMetrics separates elapsed debounce, eligible queue wait, user work and commit attempt. Commit includes Store lock wait, cloning, apply and synchronous mutation notifications, even on failure. Work excludes Before hooks. These phases exclude other hooks/status callbacks and need not sum to Duration. QueueDepth counts live waiting tasks (including debounce); ActiveWorkers includes canceled work until it returns. Counts are sampled per event for this AsyncAction, not globally.

type TaskPhase added in v0.6.0

type TaskPhase string
const (
	TaskQueued     TaskPhase = "queued"
	TaskRunning    TaskPhase = "running"
	TaskSucceeded  TaskPhase = "succeeded"
	TaskCanceled   TaskPhase = "canceled"
	TaskSuperseded TaskPhase = "superseded"
	TaskFailed     TaskPhase = "failed"
)

type TaskSnapshot added in v0.6.0

type TaskSnapshot struct {
	Revision uint64
	Tasks    []ActionTrace
}

TaskSnapshot identifies current queued/running tasks and the source trace revision at capture time. Terminal tasks are released by the source; Task.Status retains their final state.

type VersionedJSONCodec added in v0.3.0

type VersionedJSONCodec[T any] struct {
	Version          int
	Migrations       map[int]JSONMigration
	AllowUnversioned bool
}

VersionedJSONCodec stores {"schemaVersion": N, "state": ...}. Migrations[n] upgrades version n to n+1, sequentially. AllowUnversioned accepts legacy raw JSON as version zero. Version must be positive; future versions are rejected. Decode never writes storage: persist a successful upgrade explicitly with Save.

func (VersionedJSONCodec[T]) Decode added in v0.3.0

func (c VersionedJSONCodec[T]) Decode(data []byte) (T, error)

func (VersionedJSONCodec[T]) Encode added in v0.3.0

func (c VersionedJSONCodec[T]) Encode(value T) ([]byte, error)

Directories

Path Synopsis
Package devtools provides an opt-in local inspector for Nodra stores.
Package devtools provides an opt-in local inspector for Nodra stores.
examples
basic command
devtools command
Command devtools runs a counter with a live local inspector.
Command devtools runs a counter with a live local inspector.
mygo command
Example mygo demonstrates two native windows sharing business state with independent redraw subscriptions.
Example mygo demonstrates two native windows sharing business state with independent redraw subscriptions.
persistent command
Command persistent demonstrates cloned state, schema migration and write-behind persistence.
Command persistent demonstrates cloned state, schema migration and write-behind persistence.
workbench command
Command workbench searches real local file names and edits persisted settings.
Command workbench searches real local file names and edits persisted settings.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL