@sladg/apex-state 4.0.0 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @sladg/apex-state
2
2
 
3
- Reactive state management for React built on [Valtio](https://github.com/pmndrs/valtio). Declare what your fields need — validation, conditional UI, sync, listeners — and the store handles the rest. Optional Rust/WASM accelerator for complex workloads (up to 367x faster).
3
+ Reactive state management for React built on [Valtio](https://github.com/pmndrs/valtio). Declare what your fields need — validation, conditional UI, sync, listeners — and the store handles the rest. Rust/WASM pipeline handles all heavy computation: graph traversal, expression evaluation, listener dispatch.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -59,13 +59,17 @@ const UserForm = () => {
59
59
  | Feature | Description | Details |
60
60
  |---|---|---|
61
61
  | **Type-safe paths** | `DeepKey<T>` / `DeepValue<T, P>` — compile-time path safety with configurable depth | |
62
- | **Concerns** | Validation (Zod), BoolLogic conditions, dynamic text | [Concerns Guide](docs/guides/CONCERNS_GUIDE.md) |
62
+ | **Concerns** | Validation (Zod), BoolLogic conditions, ValueLogic conditional values, dynamic text | [Concerns Guide](docs/guides/CONCERNS_GUIDE.md) |
63
63
  | **Side effects** | Sync paths, flip paths, aggregations, listeners | [Side Effects Guide](docs/SIDE_EFFECTS_GUIDE.md) |
64
- | **WASM mode** | Rust-powered pipeline for bulk operations (up to 367x faster) | [Architecture](docs/WASM_ARCHITECTURE.md) |
64
+ | **WASM mode** | Rust-powered pipeline for bulk operations (full catalog refresh in 7.4ms) | [Architecture](docs/WASM_ARCHITECTURE.md) |
65
65
  | **Composable hooks** | Buffered, throttled, transformed field wrappers | [Store & Hooks](docs/guides/STORE_HOOKS.md) |
66
66
  | **Record/wildcard** | `Record<string, V>` with `_()` hash key paths | [Wildcard Guide](docs/WILD_FUNCTION_GUIDE.md) |
67
67
  | **Testing mock** | Drop-in `vi.mock` replacement with call tracking | [Testing Mock](docs/TESTING_MOCK.md) |
68
68
 
69
+ **Side effects** enforce data invariants — when X changes, Y must follow (sync, flip, aggregate, listen). **Concerns** annotate fields with UI meaning — given settled state, is this field valid, disabled, visible? They run at different times and write to different proxies; neither drives the other.
70
+
71
+ Side effects and declarative concerns (`boolLogic`, `valueLogic`, `validationState`) are evaluated **synchronously inside the WASM pipeline** — results are available immediately after the state change. Custom `evaluate()` concerns use valtio's `effect()` and are **not guaranteed synchronous** — valtio may batch or defer their evaluation to the next microtask.
72
+
69
73
  ## Full Example
70
74
 
71
75
  ```tsx
@@ -125,214 +129,101 @@ const App = () => (
125
129
  )
126
130
  ```
127
131
 
128
- ## Reading and Writing State
129
-
130
- ```tsx
131
- const store = createGenericStore<MyState>()
132
-
133
- // useStore — simple [value, setter] tuple (like useState)
134
- const NameInput = () => {
135
- const [name, setName] = store.useStore('user.name')
136
- return <input value={name} onChange={(e) => setName(e.target.value)} />
137
- }
138
-
139
- // useFieldStore — object API with concerns merged in
140
- const EmailInput = () => {
141
- const { value, setValue, validationState, disabledWhen } =
142
- store.useFieldStore('user.email')
143
- return (
144
- <input
145
- value={value}
146
- onChange={(e) => setValue(e.target.value)}
147
- disabled={disabledWhen}
148
- className={validationState?.isError ? 'error' : ''}
149
- />
150
- )
151
- }
152
-
153
- // useJitStore — bulk operations and non-reactive reads
154
- const ImportButton = () => {
155
- const { setChanges, getState } = store.useJitStore()
156
-
157
- const handleImport = (data: Record<string, unknown>) => {
158
- setChanges([
159
- ['user.name', data.name, {}],
160
- ['user.email', data.email, {}],
161
- ['user.age', data.age, {}],
162
- ])
163
- }
164
-
165
- return <button onClick={() => handleImport({ name: 'Alice', email: 'a@b.com', age: 30 })}>Import</button>
166
- }
167
- ```
168
-
169
- ## Validation with Zod
170
-
171
- ```tsx
172
- import { createGenericStore } from '@sladg/apex-state'
173
- import { z } from 'zod'
174
-
175
- type ProfileState = {
176
- user: { name: string; email: string; age: number; bio: string }
177
- }
178
-
179
- const store = createGenericStore<ProfileState>()
180
-
181
- const ProfileForm = () => {
182
- // Register validation schemas for multiple fields at once
183
- store.useConcerns('profile-validation', {
184
- 'user.name': {
185
- validationState: { schema: z.string().min(2, 'Name too short').max(50) },
186
- },
187
- 'user.email': {
188
- validationState: { schema: z.string().email('Enter a valid email') },
189
- },
190
- 'user.age': {
191
- validationState: { schema: z.number().min(18, 'Must be 18+').max(120) },
192
- },
193
- 'user.bio': {
194
- validationState: { schema: z.string().max(500, 'Bio too long') },
195
- },
196
- })
197
-
198
- const email = store.useFieldStore('user.email')
199
- const age = store.useFieldStore('user.age')
200
-
201
- return (
202
- <form>
203
- <div>
204
- <input value={email.value} onChange={(e) => email.setValue(e.target.value)} />
205
- {email.validationState?.isError && (
206
- <ul>{email.validationState.errors.map((err, i) => <li key={i}>{err.message}</li>)}</ul>
207
- )}
208
- </div>
209
- <div>
210
- <input
211
- type="number"
212
- value={age.value}
213
- onChange={(e) => age.setValue(Number(e.target.value))}
214
- />
215
- {age.validationState?.isError && <span>{age.validationState.errors[0]?.message}</span>}
216
- </div>
217
- </form>
218
- )
219
- }
220
- ```
132
+ ## Concerns
221
133
 
222
- ## Conditional UI with BoolLogic
134
+ Concerns describe *what a field means* for the UI given the current state — valid, disabled, visible, what label to show. They run after state settles and write to a separate `_concerns` proxy, never to `state` itself.
223
135
 
224
136
  ```tsx
225
- store.useConcerns('conditional-ui', {
226
- // Disable when another field has a specific value
137
+ store.useConcerns('checkout', {
138
+ // Zod validation
227
139
  'user.email': {
140
+ validationState: { schema: z.string().email('Enter a valid email') },
228
141
  disabledWhen: { boolLogic: { IS_EQUAL: ['status', 'submitted'] } },
142
+ dynamicTooltip: { template: 'Sending confirmation to {{user.email}}' },
229
143
  },
230
144
 
231
- // Show only when multiple conditions are true
145
+ // Card number: validate + show only when paying by card
232
146
  'payment.cardNumber': {
233
- visibleWhen: {
234
- boolLogic: {
235
- AND: [
236
- { IS_EQUAL: ['payment.method', 'card'] },
237
- { EXISTS: 'user.email' },
238
- ],
239
- },
240
- },
147
+ validationState: { schema: z.string().regex(/^\d{16}$/, 'Must be 16 digits') },
148
+ visibleWhen: { boolLogic: { IS_EQUAL: ['payment.method', 'card'] } },
241
149
  },
242
150
 
243
- // Make readonly when value exceeds threshold
151
+ // Lock order total above threshold
244
152
  'order.total': {
245
153
  readonlyWhen: { boolLogic: { GT: ['order.total', 10000] } },
246
154
  },
247
155
 
248
- // Complex nested logic with OR, NOT
249
- 'shipping.express': {
250
- disabledWhen: {
251
- boolLogic: {
252
- OR: [
253
- { IS_EQUAL: ['status', 'shipped'] },
254
- { NOT: { EXISTS: 'shipping.address' } },
255
- ],
156
+ // ValueLogic: label adapts to stock level
157
+ 'product.quantity': {
158
+ dynamicLabel: {
159
+ valueLogic: {
160
+ IF: { LTE: ['product.quantity', 5] },
161
+ THEN: 'Quantity (low stock)',
162
+ ELSE: 'Quantity',
256
163
  },
257
164
  },
258
165
  },
259
166
  })
260
167
 
168
+ // Read all concerns for a field via useFieldStore
169
+ const { value, setValue, validationState, disabledWhen, dynamicTooltip } =
170
+ store.useFieldStore('user.email')
171
+
261
172
  // Available BoolLogic operators:
262
173
  // IS_EQUAL, EXISTS, IS_EMPTY, GT, LT, GTE, LTE, IN, AND, OR, NOT
263
174
  ```
264
175
 
265
176
  ## Side Effects
266
177
 
267
- ### Sync Paths
178
+ Side effects describe *what must change* when state changes — enforcing invariants, keeping related fields in sync, reacting to mutations. They run inside the pipeline and can write back to `state`.
268
179
 
269
180
  ```tsx
270
- store.useSideEffects('sync', {
181
+ store.useSideEffects('checkout', {
182
+ // Sync billing ↔ shipping contact details bidirectionally
271
183
  syncPaths: [
272
- ['billing.email', 'shipping.email'], // changing one updates the other
184
+ ['billing.email', 'shipping.email'],
273
185
  ['billing.phone', 'shipping.phone'],
274
186
  ],
275
- })
276
- // When user types in billing.email, shipping.email updates automatically
277
- ```
278
-
279
- ### Flip Paths
280
187
 
281
- ```tsx
282
- store.useSideEffects('flips', {
188
+ // Express and standard shipping are mutually exclusive
283
189
  flipPaths: [
284
- ['isActive', 'isInactive'], // setting isActive=true -> isInactive=false
285
- ['isExpanded', 'isCollapsed'],
190
+ ['shipping.express', 'shipping.standard'],
286
191
  ],
287
- })
288
- ```
289
192
 
290
- ### Aggregations
291
-
292
- ```tsx
293
- store.useSideEffects('agg', {
193
+ // Order total = consensus across items (undefined if they differ)
294
194
  aggregations: [
295
- // Target is ALWAYS first. Multiple pairs with same target form a group.
296
- ['summary.price', 'legs.0.price'],
297
- ['summary.price', 'legs.1.price'],
298
- ['summary.price', 'legs.2.price'],
195
+ ['order.total', 'items.0.price'],
196
+ ['order.total', 'items.1.price'],
197
+ ['order.total', 'items.2.price'],
299
198
  ],
300
- })
301
- // summary.price = leg price if ALL legs match, undefined if they differ
302
- ```
303
-
304
- ### Listeners
305
199
 
306
- ```tsx
307
- store.useSideEffects('listeners', {
200
+ // Stamp audit trail on any profile change
308
201
  listeners: [
309
202
  {
310
- path: 'user.profile', // watch changes under this path
311
- scope: 'user.profile', // receive scoped state and relative paths
312
- fn: (changes, state) => {
313
- // changes: [['name', 'Alice', {}]] — paths relative to scope
314
- // state: { name: 'Alice', email: '...' } — user.profile sub-object
315
- return [['audit.lastEdit', Date.now(), {}]] // return FULL paths for new changes
316
- },
203
+ path: 'user',
204
+ scope: 'user',
205
+ fn: (_changes, _state) => [['audit.lastModified', Date.now(), {}]],
317
206
  },
318
207
  ],
319
208
  })
320
209
  ```
321
210
 
322
- ## Dynamic Text
211
+ ## Reading and Writing State
323
212
 
324
213
  ```tsx
325
- store.useConcerns('dynamic-text', {
326
- 'legs.0.strike': {
327
- dynamicTooltip: { template: 'Current strike: {{legs.0.strike}}' },
328
- dynamicLabel: { template: 'Strike for {{legs.0.product}}' },
329
- dynamicPlaceholder: { template: 'Enter value (min {{legs.0.minStrike}})' },
330
- },
331
- })
332
-
333
- const { dynamicTooltip, dynamicLabel } = store.useFieldStore('legs.0.strike')
334
- // dynamicTooltip -> "Current strike: 105"
335
- // dynamicLabel -> "Strike for AAPL"
214
+ // useStore — simple [value, setter] tuple (like useState)
215
+ const [name, setName] = store.useStore('user.name')
216
+
217
+ // useFieldStore object API with all concerns merged in
218
+ const { value, setValue, validationState, disabledWhen } =
219
+ store.useFieldStore('user.email')
220
+
221
+ // useJitStore — bulk updates and non-reactive reads
222
+ const { setChanges } = store.useJitStore()
223
+ setChanges([
224
+ ['user.name', 'Alice', {}],
225
+ ['user.email', 'alice@example.com', {}],
226
+ ])
336
227
  ```
337
228
 
338
229
  ## Composable Field Hooks
@@ -341,44 +232,24 @@ const { dynamicTooltip, dynamicLabel } = store.useFieldStore('legs.0.strike')
341
232
  import { useBufferedField, useThrottledField, useTransformedField } from '@sladg/apex-state'
342
233
 
343
234
  // Buffer edits locally, commit/cancel explicitly
344
- const PriceEditor = () => {
345
- const raw = store.useFieldStore('product.price')
346
- const buffered = useBufferedField(raw)
347
-
348
- return (
349
- <div>
350
- <input value={buffered.value} onChange={(e) => buffered.setValue(Number(e.target.value))} />
351
- <button onClick={buffered.commit} disabled={!buffered.isDirty}>Save</button>
352
- <button onClick={buffered.cancel}>Cancel</button>
353
- </div>
354
- )
355
- }
235
+ const raw = store.useFieldStore('product.price')
236
+ const buffered = useBufferedField(raw)
237
+ // buffered.value, buffered.setValue, buffered.commit(), buffered.cancel(), buffered.isDirty
356
238
 
357
- // Throttle rapid setValue calls (e.g., slider input)
358
- const VolumeSlider = () => {
359
- const raw = store.useFieldStore('audio.volume')
360
- const throttled = useThrottledField(raw, { ms: 100 })
361
- return <input type="range" value={throttled.value} onChange={(e) => throttled.setValue(Number(e.target.value))} />
362
- }
239
+ // Throttle rapid updates (e.g. range sliders)
240
+ const throttled = useThrottledField(raw, { ms: 100 })
363
241
 
364
- // Transform display format (cents <-> dollars)
365
- const CurrencyInput = () => {
366
- const raw = store.useFieldStore('price')
367
- const formatted = useTransformedField(raw, {
368
- to: (cents: number) => (cents / 100).toFixed(2), // store -> display
369
- from: (dollars: string) => Math.round(parseFloat(dollars) * 100), // display -> store
370
- })
371
- return <input value={formatted.value} onChange={(e) => formatted.setValue(e.target.value)} />
372
- }
242
+ // Transform display format (cents dollars)
243
+ const formatted = useTransformedField(raw, {
244
+ to: (cents: number) => (cents / 100).toFixed(2),
245
+ from: (dollars: string) => Math.round(parseFloat(dollars) * 100),
246
+ })
373
247
 
374
- // Chain them: buffered + transformed
375
- const raw = store.useFieldStore('price')
376
- const buffered = useBufferedField(raw)
377
- const display = useTransformedField(buffered, {
248
+ // Chain: buffered + transformed
249
+ const display = useTransformedField(useBufferedField(raw), {
378
250
  to: (cents) => (cents / 100).toFixed(2),
379
251
  from: (dollars) => Math.round(parseFloat(dollars) * 100),
380
252
  })
381
- // display has: value, setValue, commit, cancel, isDirty
382
253
  ```
383
254
 
384
255
  ## Hash Key Paths for Record Types
@@ -387,19 +258,19 @@ const display = useTransformedField(buffered, {
387
258
  import { _ } from '@sladg/apex-state'
388
259
 
389
260
  // _() marks a segment as a hash key for Record-typed paths
390
- const strikePath = `portfolio.legs.${_('l1')}.strike`
261
+ const itemPath = `catalog.categories.${_('c1')}.items`
391
262
  // -> typed string containing HASH_KEY marker
392
263
 
393
264
  // Use with concerns — applies to ALL keys in the Record
394
265
  store.useConcerns('wildcards', {
395
- [strikePath]: {
266
+ [itemPath]: {
396
267
  validationState: { schema: z.number().min(0) },
397
268
  disabledWhen: { boolLogic: { IS_EQUAL: ['status', 'locked'] } },
398
269
  },
399
270
  })
400
271
 
401
272
  // Multiple hash keys for deeply nested Records
402
- const nestedPath = `books.${_('b1')}.products.${_('p1')}.legs.${_('l1')}.notional`
273
+ const nestedPath = `catalog.${_('c1')}.products.${_('p1')}.variants.${_('v1')}.price`
403
274
  ```
404
275
 
405
276
  ## Type-Safe Paths with Configurable Depth
@@ -512,14 +383,23 @@ it('tracks concern and side effect registrations', () => {
512
383
 
513
384
  ## Performance
514
385
 
515
- Benchmarked with 60 variants across 3 Record layers, 75 syncs, 40 flips, 100 BoolLogic conditions, 85 listeners:
386
+ Benchmarks run on Apple M4 Pro against a full e-commerce pipeline: 75 sync pairs, 40 flip pairs, 100 BoolLogic expressions, 85 listeners with real JS handlers.
516
387
 
517
- | Operation | Duration |
518
- |---|---|
519
- | Single field edit | 1.4µs |
520
- | 7 changes + cascading listeners | 0.11ms |
521
- | 60 bulk price changes | 2.9ms |
522
- | 135 changes (full catalog refresh) | 2.99ms |
388
+ | Scenario | Changes | Duration |
389
+ |---|---|---|
390
+ | Single field change | 1 | 1.4µs |
391
+ | Order confirmation | 1 | 40µs |
392
+ | Dashboard aggregation (10 orders) | 10 | 295µs |
393
+ | Bulk price update (3 Record levels deep) | 60 | 7.2ms |
394
+ | Full catalog refresh (everything fires) | 135 | 7.4ms |
395
+
396
+ Typical interactions are sub-millisecond. The stress case — 135 changes triggering cascading sync, flip, BoolLogic, and 85 listener callbacks across 3 Record levels — completes in **7.4ms**.
397
+
398
+ **Bundle:** ~105 KB JS + ~697 KB WASM, loaded separately and cached by the browser. For eager loading:
399
+
400
+ ```ts
401
+ import '@sladg/apex-state/preload'
402
+ ```
523
403
 
524
404
  See [Benchmark Comparison](docs/BENCHMARK_COMPARISON.md) for the full analysis.
525
405
 
@@ -562,21 +442,32 @@ See [Benchmark Comparison](docs/BENCHMARK_COMPARISON.md) for the full analysis.
562
442
  setValue("email", "alice@example.com")
563
443
  |
564
444
  v
565
- [WASM/Rust] shadow state + sync + flip + BoolLogic evaluation
445
+ [WASM/Rust] single processChanges() call:
446
+ - shadow state update
447
+ - aggregation → sync → flip
448
+ - expression evaluation (BoolLogic + ValueLogic, unified)
449
+ - listener waves + validator dispatch (via externref — no JS round-trips)
566
450
  |
567
451
  v
568
- [JS] execute listeners + Zod validators
452
+ [JS] partition result changes by meta flag (state vs _concerns)
569
453
  |
570
- v
571
- [WASM/Rust] diff -> compute final changes
454
+ ├── applyBatch(stateChanges) → valtio state proxy
455
+ └── applyConcernChanges(concernChanges) valtio _concerns proxy
572
456
  |
573
457
  v
574
- valtio proxy -> React re-render
458
+ React re-render
575
459
  ```
576
460
 
577
- **Dual-layer design:** JS/React owns reactivity and rendering. Rust/WASM owns heavy computation (graphs, diffing, pipeline orchestration). The boundary is thin: paths cross as strings, values as JSON. WASM decides the execution plan, JS executes user functions.
461
+ **Dual-layer design:** JS/React owns reactivity and rendering. Rust/WASM owns the full pipeline — shadow state, sync/flip graphs, expression evaluation, and listener dispatch (via externref callbacks). The JS layer normalizes inputs, calls one WASM function, and applies the result. The boundary is thin: paths as strings, values as JSON.
462
+
463
+ **Side effects vs. concerns:** These are two distinct systems with different authority and different timing.
464
+
465
+ - **Side effects** model *causality* — because X changed, Y must change. They enforce invariants in the data model (sync, flip, aggregate, listen). They run *during* the pipeline and can write to `state`.
466
+ - **Concerns** model *interpretation* — given settled state, what does this field mean for the UI? They annotate fields with metadata (valid, disabled, visible, label). They run *after* the pipeline and can only write to `_concerns`.
467
+
468
+ Both use the same expression language (`BoolLogic`, `ValueLogic`) — that's just a shared way to write declarative conditions. What those conditions *drive* is determined by the registration context, not the expression itself.
578
469
 
579
- See [WASM Architecture](docs/WASM_ARCHITECTURE.md) for the full specification.
470
+ See [Architecture Guide](docs/guides/ARCHITECTURE.md) and [WASM Architecture](docs/WASM_ARCHITECTURE.md) for the full specification.
580
471
 
581
472
  ## Development
582
473
 
@@ -609,7 +500,7 @@ cargo install wasm-pack
609
500
  | [Wildcard Paths](docs/WILD_FUNCTION_GUIDE.md) | `_()` hash key utility for Record types |
610
501
  | [String Interpolation](docs/INTERPOLATION.md) | Template helpers for dynamic text concerns |
611
502
  | [Testing Mock](docs/TESTING_MOCK.md) | Mock module for consumer tests (`vi.mock`) |
612
- | [Debug Timing](docs/DEBUG_TIMING.md) | Performance debugging utilities |
503
+ | [Debug Logging](docs/DEBUG_LOGGING.md) | Pipeline trace, console output, debug configuration |
613
504
  | [Full Index](docs/README.md) | Complete documentation index |
614
505
 
615
506
  ## Roadmap