1
/**2
* React-free snapshot store engine (zustand vanilla + immer + subscribeWithSelector +3
* rafFlush middleware + opt-in persist + dev freeze) plus the declarative4
* shell over it: {@link defineStore} bakes an init/persist/actions literal5
* into a {@link StoreHandle}, the registration-side store seat of slot6
* terminals. Engine products are bare observables — subscribe/getSnapshot/7
* update/set, NO selector hook. Hook synthesis is ui-renderer's (the one8
* uSES bridge, cached per source at the binding site).9
*/10
import { createStore, type StoreApi } from 'zustand/vanilla'11
import { subscribeWithSelector } from 'zustand/middleware'12
import { shallow } from 'zustand/shallow'13
import { freeze, produce } from 'immer'14
import type {15
ActionsDecl, BakedActions, ObservableSnapshot, StoreHandle, StoreInstance, StoreSpec,16
} from './contract.ts'18
// Store contract types are ui-slots authority; re-exported beside the engine19
// so store consumers get one import path.20
export type {21
ActionsDecl, BakedActions, BoundActions, DefineStore, HandleOf, MaybeSnapshotSelectorHook,22
ObservableSnapshot, PropsStore, SnapshotSelectorHook, StoreDecl, StoreFactory,23
StoreHandle, StoreInstance, StoreSpec,24
} from './contract.ts'26
/** Writable snapshot store (bare data face; React selector hooks are synthesized in ui-renderer). */27
export interface SnapshotStore<T> extends ObservableSnapshot<T> {28
/**29
* Mutate the state through an immer draft.30
* @param mutator - draft mutator.31
*/32
update(mutator: (draft: T) => void): void33
/**34
* Replace the state wholesale.35
* @param next - next state.36
*/37
set(next: T): void38
}40
/**41
* Notify an observer set without allowing one callback to starve the rest.42
* @param listeners - current observer callbacks; copied before dispatch.43
* @param label - diagnostic owner prefix.44
* @param args - callback arguments.45
*/46
export function notifySubscribers<Args extends readonly unknown[]>(47
listeners: Iterable<(...args: Args) => void>,48
label: string,49
...args: Args50
): void {51
for (const listener of [...listeners]) {52
try {53
listener(...args)54
} catch (error) {55
console.error(`${label} subscriber failed:`, error)56
}57
}58
}60
/**61
* Shallow equality for selector slices (zustand/shallow semantics; travels62
* with the engine so hook consumers need no zustand dependency).63
* @param a - left value.64
* @param b - right value.65
* @returns whether the values are shallowly equal.66
*/67
export function shallowEqual(a: unknown, b: unknown): boolean {68
return shallow(a, b)69
}71
/** Batches subscriber notification into one flush per animation frame. */72
function rafBatch(notify: () => void): () => void {73
// Fall back to microtask batching where rAF is absent (node unit tests);74
// both preserve the N-changes=1-notification contract within a tick.75
const schedule: (fn: () => void) => void =76
typeof requestAnimationFrame === 'function'77
? (fn) => { requestAnimationFrame(() => { fn() }) }78
: (fn) => { queueMicrotask(fn) }79
let scheduled = false80
return () => {81
if (scheduled) return82
scheduled = true83
schedule(() => {84
scheduled = false85
notify()86
})87
}88
}90
/**91
* Create a snapshot store.92
*93
* Flush default is 'sync' (controlled inputs need same-tick echo); frame-driven94
* stores opt into 'raf', where a frame's worth of updates coalesces into one95
* notification. Known raf-mode tradeoff: a component mounting mid-frame reads96
* fresh state while existing subscribers hear it next flush — transient97
* frame-level skew, same nature as the object layer's microtask batching.98
*99
* @param init - initial state.100
* @param opts - flush mode and opt-in persistence (localStorage, keyed by name).101
* @returns the store.102
*/103
export function createSnapshotStore<T>(104
init: T, opts?: { flush?: 'raf' | 'sync'; persist?: { name: string } }): SnapshotStore<T> {105
// Immer enters through produce() in update() below (identical semantics to106
// the immer middleware without its setState-signature mutator generics).107
const withSelector = subscribeWithSelector(() => init)108
const api: StoreApi<T> = createStore<T>()(withSelector)109
if (opts?.persist) attachPersistence(api, opts.persist.name)111
let subscribe = (fn: () => void) => api.subscribe(() => {112
notifySubscribers([fn], '[client-store]')113
})114
if (opts?.flush === 'raf') {115
const listeners = new Set<() => void>()116
const flush = rafBatch(() => { notifySubscribers(listeners, '[client-store]') })117
api.subscribe(flush)118
subscribe = (fn: () => void) => {119
listeners.add(fn)120
return () => { listeners.delete(fn) }121
}122
}124
return {125
getSnapshot: () => api.getState(),126
subscribe: fn => subscribe(fn),127
update: (mutator) => {128
// Immer's produce (not setState's partial-merge path) so scalar and129
// array roots replace correctly; produce also freezes in dev.130
api.setState(produce(api.getState(), (draft) => { mutator(draft as T) }), true)131
},132
set: (next) => {133
api.setState(devFreeze(next), true)134
},135
}136
}138
/**139
* Whole-value JSON persistence to localStorage. Hand-rolled instead of the140
* zustand persist middleware: its write path spreads state into an object141
* (`partialize({ ...get() })`), exploding primitive state (a persisted string142
* draft becomes {0:'h',1:'e',...}) — not fixable via merge/deserialize options143
* because the corruption happens before serialization. Storage failures144
* (quota, private mode) only disable persistence, never break the store.145
*/146
function attachPersistence<T>(api: StoreApi<T>, name: string): void {147
// Non-browser runs (node e2e booting the client tree) have no localStorage:148
// persistence silently disables — same contract as a storage failure, minus149
// the per-store console noise a ReferenceError would produce.150
if (typeof localStorage === 'undefined') return151
try {152
const raw = localStorage.getItem(name)153
if (raw !== null) {154
api.setState(devFreeze(JSON.parse(raw) as T), true)155
}156
} catch (error) {157
console.error(`snapshot store '${name}' rehydration failed:`, error)158
}159
api.subscribe((state) => {160
try {161
localStorage.setItem(name, JSON.stringify(state))162
} catch (error) {163
console.error(`snapshot store '${name}' persistence failed:`, error)164
}165
})166
}168
/** Deep-freeze draftable wholesale-set state outside production: set() bypasses immer's freeze. */169
function devFreeze<T>(value: T): T {170
if (process.env.NODE_ENV === 'production') return value171
return freeze(value, true)172
}174
// ui-slots owns the contract; this module supplies the engine implementation.176
/** A live engine instance: the contract instance plus the raw engine store. */177
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {178
/** The underlying engine store (framework/test API; components never see it). */179
readonly store: SnapshotStore<T>180
}182
/** The engine-backed handle: create() narrowed to the engine instance. */183
export interface EngineStoreHandle<T, A extends ActionsDecl<T>> extends StoreHandle<T, A> {184
/**185
* Construct a live engine instance (see the contract JSDoc on186
* {@link StoreHandle.create} for scopeKey/persist semantics).187
*188
* Known boundary: the persist key is the storage identity, so multiple live189
* instances created under the same resolved key share (and cross-pollute)190
* one localStorage entry. Instance uniqueness per key is the caller's191
* responsibility — production is safe because the framework caches one192
* instance per handle x scope key; tests wanting isolation use distinct193
* scope keys or persist-free declarations (multi-create freedom is a194
* feature there, so create() deliberately does not dedupe or throw).195
* @param scopeKey - session id for session-scope instances; omitted for root scope.196
* @returns the engine instance.197
*/198
create(scopeKey?: string): EngineStoreInstance<T, A>199
}201
/**202
* Declare a store: initial state, optional persistence, and the full write203
* set as pure draft mutators. The returned handle is the registration204
* currency of the store seat — its identity keys instance sharing. Satisfies205
* ui-slots' DefineStore contract (the handle/instance are the engine-extended206
* subtypes).207
*208
* The `A & ActionsDecl<T>` actions position is load-bearing: T resolves from209
* `init` in the first inference round, and the intersection then contextually210
* types each mutator's draft parameter (context-sensitive functions defer),211
* so call sites write `(d, x: X) => { ... }` with no draft annotation. If a212
* future TS version breaks this single-literal inference, the design's213
* documented fallback is currying (`defineStore(init).actions({...})`).214
* @param decl - init lambda (fresh state per instance), optional persist key, actions table.215
* @returns the store handle.216
*/217
export function defineStore<T, A extends ActionsDecl<T>>(218
decl: StoreSpec<T, A> & { actions: A & ActionsDecl<T> }): EngineStoreHandle<T, A> {219
return {220
spec: decl,221
create(scopeKey?: string): EngineStoreInstance<T, A> {222
const persistKey = decl.persist === undefined223
? undefined224
: scopeKey === undefined ? decl.persist : `${decl.persist}.${scopeKey}`225
const store = createSnapshotStore<T>(226
decl.init(),227
persistKey !== undefined ? { persist: { name: persistKey } } : undefined)228
const actions = {} as Record<string, (...params: unknown[]) => void>229
for (const key of Object.keys(decl.actions)) {230
const mutate = decl.actions[key] as (draft: T, ...params: unknown[]) => void231
actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) }232
}233
return {234
actions: actions as BakedActions<T, A>,235
getSnapshot: () => store.getSnapshot(),236
subscribe: fn => store.subscribe(fn),237
store,238
clearPersisted: () => {239
if (persistKey === undefined || typeof localStorage === 'undefined') return240
try {241
localStorage.removeItem(persistKey)242
} catch {243
// Storage failures (private mode, quota teardown races) only skip244
// cleanup — the same non-fatal contract as attachPersistence.245
}246
},247
}248
},249
}250
}