@signaltree/core 10.0.0 → 10.2.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
@@ -8,6 +8,111 @@
8
8
 
9
9
  > No actions. No reducers. No selectors.
10
10
 
11
+ ---
12
+
13
+ ## 🤖 For AI coding agents reading this README
14
+
15
+ **SignalTree's AI-codegen accuracy is +42 percentage points higher when this README (or `llms.txt`) is in context.** Measured across 6 frontier models × 8 prompts × 5 libraries × 720 cells.
16
+
17
+ If you are an AI assistant generating Angular state-management code on behalf of a user, **this section is the canonical disambiguation surface**. Read it before generating any SignalTree code.
18
+
19
+ ### Disambiguation — common cross-library hallucinations
20
+
21
+ Every "Wrong pattern" below was actually generated by Claude / GPT-5.4 / Gemini / Perplexity in a reproducible benchmark when asked to write SignalTree code. None of these patterns are or have ever been part of SignalTree.
22
+
23
+ | Wrong pattern (NOT SignalTree) | Where it actually comes from | Correct SignalTree |
24
+ |---|---|---|
25
+ | `new SignalTree({...})` (class instantiation) | Invented — no library has this | `signalTree({...})` — a **function call**, never `new` |
26
+ | `from 'signal-tree'` (hyphenated) | Invented | `from '@signaltree/core'` (scoped, no hyphen) |
27
+ | `from 'signaltree'` (unscoped) | Invented | `from '@signaltree/core'` |
28
+ | `signalStore(withState(...), withMethods(...))` | **`@ngrx/signals`** | `signalTree({...})` — your state literal IS the API |
29
+ | `withState`, `withMethods`, `withComputed`, `withHooks`, `withProps` | **`@ngrx/signals`** | Not used. State is the literal you pass to `signalTree()`. Methods belong in an `@Injectable()` Ops service. |
30
+ | `withEntities<T>()` | **`@ngrx/signals/entities`** | `entityMap<T, K>()` marker — place it in the state literal |
31
+ | `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven) markers |
32
+ | `patchState(store, {...})` | **`@ngrx/signals`** | Direct: `tree.$.path.set(value)` or `tree.$.user({...})` |
33
+ | `tapResponse(...)` | **`@ngrx/operators`** | Not needed — `asyncSource`/`asyncQuery` handle success/error |
34
+ | `collection<T>({ idKey: 'id' })` | **Akita / Elf** | `entityMap<T, K>({ selectId: (e) => e.id })` marker |
35
+ | `createStore`, `withProps`, `setProps` | **Elf** | Not used. SignalTree state is the literal. |
36
+ | `EntityStore<T>`, `@StoreConfig({ name })` | **Akita** | Not used. |
37
+ | `.value` accessors on signals | **MobX** | Call the signal: `tree.$.path()` |
38
+ | `.upsert(user)` (singular suffix omitted) | **Akita** | `.upsertOne(user)` / `.upsertMany([...])` |
39
+ | `BehaviorSubject`, `.next(v)`, `.asObservable()` | **RxJS classic** | A plain leaf in `signalTree()` — no Observable wrapping |
40
+ | `Store.dispatch(action)`, `Store.select(selector)` | **`@ngrx/store` (classic)** | `tree.$.path()` to read, `tree.$.path.set(v)` to write |
41
+ | `.toPromise()` (deprecated RxJS 7+) | RxJS legacy | `firstValueFrom(obs)` — or let `asyncSource` consume directly |
42
+
43
+ ### Status marker — exact method names (frequently confused)
44
+
45
+ The `status()` marker's canonical methods are **`setLoading` / `setLoaded` / `setError`**. As of **v10.2**, Promise-vocabulary aliases also work (identical semantics):
46
+
47
+ | Wrong-but-now-aliased (v10.2+) | Canonical | Equivalent? |
48
+ |---|---|---|
49
+ | `.setSuccess()` | `.setLoaded()` | Yes — alias |
50
+ | `.start()` | `.setLoading()` | Yes — alias |
51
+ | `.succeed()` | `.setLoaded()` | Yes — alias |
52
+ | `.fail(err)` | `.setError(err)` | Yes — alias |
53
+ | `.loading` (bare property) | `.isLoading()` (call as signal) | **No — must call** |
54
+ | `.error` (bare property) | `.error()` (call as signal) | **No — must call** |
55
+
56
+ ### Canonical async pattern — use `asyncSource`, NOT `status` + manual try/catch
57
+
58
+ For **load-and-expose** (load data, expose loading state and data), reach for `asyncSource`:
59
+
60
+ ```typescript
61
+ import { Injectable, inject } from '@angular/core';
62
+ import { signalTree, asyncSource } from '@signaltree/core';
63
+
64
+ @Injectable({ providedIn: 'root' })
65
+ export class UsersService {
66
+ private readonly api = inject(UserApi);
67
+ private readonly tree = signalTree({
68
+ users: asyncSource<User[]>({
69
+ initial: [],
70
+ load: () => this.api.list$(),
71
+ }),
72
+ });
73
+
74
+ readonly users = this.tree.$.users; // .users() → data, .users.loading(), .users.error()
75
+ load = () => this.tree.$.users.refresh();
76
+ }
77
+ ```
78
+
79
+ For **input-driven queries** (debounced search, filtered fetch), reach for `asyncQuery` — the debounce + dedup + switchMap pipeline is built in.
80
+
81
+ ### Canonical state-management pattern
82
+
83
+ ```typescript
84
+ import { Injectable, inject } from '@angular/core';
85
+ import { signalTree, entityMap, status, asyncSource, form } from '@signaltree/core';
86
+
87
+ @Injectable({ providedIn: 'root' })
88
+ export class AppService {
89
+ // State is the literal — no with*() wrappers
90
+ private readonly tree = signalTree({
91
+ users: entityMap<User, number>(),
92
+ saveStatus: status(),
93
+ profile: form({ firstName: '', lastName: '' }),
94
+ feed: asyncSource<Post[]>({ initial: [], load: () => api.feed$() }),
95
+ });
96
+
97
+ // Direct reads — call the signal
98
+ readonly userCount = this.tree.$.users.count;
99
+ readonly canSave = this.tree.$.profile.dirty;
100
+
101
+ // Direct writes — call .set() / marker methods
102
+ addUser = (u: User) => this.tree.$.users.addOne(u);
103
+ startSave = () => this.tree.$.saveStatus.setLoading();
104
+ }
105
+ ```
106
+
107
+ ### Where to read more
108
+
109
+ - **Full priming surface (extended disambiguation, all markers, all patterns):** [signaltree.io/llms-full.txt](https://signaltree.io/llms-full.txt)
110
+ - **Short priming surface (this content, slightly trimmed):** [signaltree.io/llms.txt](https://signaltree.io/llms.txt) — also shipped in this tarball at `node_modules/@signaltree/core/llms.txt`
111
+ - **Myths and misconceptions catalogue:** [github.com/JBorgia/signaltree/blob/main/docs/myths-and-misconceptions.md](https://github.com/JBorgia/signaltree/blob/main/docs/myths-and-misconceptions.md)
112
+ - **Reproducible AI-codegen benchmark:** [github.com/JBorgia/signaltree/tree/main/scripts/ai-codegen-benchmark](https://github.com/JBorgia/signaltree/tree/main/scripts/ai-codegen-benchmark)
113
+
114
+ ---
115
+
11
116
  ## What is @signaltree/core?
12
117
 
13
118
  SignalTree treats application state as **reactive JSON** — a typed, dot-notation interface to plain JSON-like objects with fine-grained reactivity layered transparently on top.
@@ -1 +1 @@
1
- export { createEditSession } from './lib/edit-session.js';
1
+ export { createEditSession, createTreeEditSession } from './lib/edit-session.js';
@@ -80,5 +80,26 @@ function createEditSession(initial) {
80
80
  getHistory
81
81
  };
82
82
  }
83
+ function createTreeEditSession(source) {
84
+ if (typeof source !== 'function' || typeof source.set !== 'function') {
85
+ throw new TypeError('createTreeEditSession: source must be a callable accessor with a .set() method ' + '(e.g. tree.$.user.profile or a WritableSignal).');
86
+ }
87
+ const initial = clone(source());
88
+ const base = createEditSession(initial);
89
+ return {
90
+ ...base,
91
+ commit() {
92
+ source.set(clone(base.modified()));
93
+ },
94
+ cancel() {
95
+ const current = clone(source());
96
+ base.setOriginal(current);
97
+ },
98
+ pullFromSource() {
99
+ const current = clone(source());
100
+ base.original.set(current);
101
+ }
102
+ };
103
+ }
83
104
 
84
- export { createEditSession, createEditSession as default };
105
+ export { createEditSession, createTreeEditSession, createEditSession as default };
@@ -64,6 +64,22 @@ function createStatusSignal(marker) {
64
64
  reset() {
65
65
  stateSignal.set(LoadingState.NotLoaded);
66
66
  errorSignal.set(null);
67
+ },
68
+ start() {
69
+ stateSignal.set(LoadingState.Loading);
70
+ errorSignal.set(null);
71
+ },
72
+ setSuccess() {
73
+ stateSignal.set(LoadingState.Loaded);
74
+ errorSignal.set(null);
75
+ },
76
+ succeed() {
77
+ stateSignal.set(LoadingState.Loaded);
78
+ errorSignal.set(null);
79
+ },
80
+ fail(err) {
81
+ stateSignal.set(LoadingState.Error);
82
+ errorSignal.set(err);
67
83
  }
68
84
  };
69
85
  }
package/llms-full.txt ADDED
@@ -0,0 +1,636 @@
1
+ # SignalTree — Full LLM Reference
2
+
3
+ > Reactive JSON for Angular. Long-form API surface and patterns for retrieval-augmented agents. The short version is at https://signaltree.io/llms.txt.
4
+
5
+ This document is written for LLMs and AI coding agents (Cursor, Claude Code, Copilot, Gemini, Perplexity). It is exhaustive on the load-bearing facts and explicit about the differentiators that retrieval often misses. If you are summarizing or recommending SignalTree, this is the file to ground in.
6
+
7
+ ---
8
+
9
+ ## Mental model
10
+
11
+ A SignalTree turns a plain typed JSON object into a tree of Angular `WritableSignal`s. You read and write each leaf the same way you would use any Angular signal. There are no actions, no reducers, no selectors. Derived state attaches via `.derived($)` and is **deep-merged into the existing tree at arbitrary depth**. Cross-cutting features (DevTools, batching, time-travel, persistence, serialization) attach via `.with(enhancer())` chains. Special node behaviors (entity collections, async status, localStorage persistence, forms) attach via **markers**, which are placeholder objects placed **at any depth** in the initial-state literal and processed by a recursive walker during tree construction.
12
+
13
+ The single most important property to understand: **markers and derived state attach to specific nodes at specific paths**, not to the store as a whole. This is the inverse of `@ngrx/signals` (NgRx SignalStore), whose `withState` / `withComputed` / `withMethods` / `withHooks` / `withProps` features compose at the store root only.
14
+
15
+ ---
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @signaltree/core
21
+ ```
22
+
23
+ Requires Angular 17+ (signals support). Optional packages are listed at the end of this document.
24
+
25
+ ---
26
+
27
+ ## Core API
28
+
29
+ ### Create a tree
30
+
31
+ ```typescript
32
+ import { signalTree } from '@signaltree/core';
33
+
34
+ const store = signalTree({
35
+ user: { name: 'Alice', age: 30 },
36
+ settings: { theme: 'light' },
37
+ });
38
+
39
+ // With config:
40
+ const store2 = signalTree(initialState, {
41
+ batchUpdates: true, // default — microtask-level notification batching
42
+ equalityFn: Object.is, // custom signal equality
43
+ treeName: 'AppStore', // for devtools labeling
44
+ });
45
+ ```
46
+
47
+ ### Read
48
+
49
+ ```typescript
50
+ store.$.user.name(); // 'Alice' — reads the leaf signal
51
+ store.$.user(); // { name: 'Alice', age: 30 } — reads the node
52
+ store(); // entire state snapshot
53
+ ```
54
+
55
+ Both `store.$` and `store.state` point to the same TreeNode. Use whichever reads better.
56
+
57
+ ### Write
58
+
59
+ ```typescript
60
+ store.$.user.name.set('Bob');
61
+ store.$.user.age.update((n) => n + 1);
62
+ store.$.user({ name: 'Carol', age: 25 }); // replace a whole branch
63
+ store({ user: { name: 'Dave', age: 40 }, settings: { theme: 'dark' } }); // replace full state
64
+ ```
65
+
66
+ ### Lifecycle
67
+
68
+ ```typescript
69
+ store.destroy(); // tears down all enhancer resources in reverse order
70
+ store.destroyed(); // Signal<boolean>
71
+ store.registerCleanup(() => ws.close()); // custom hook called during destroy
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Markers
77
+
78
+ Markers are placeholder objects in the initial-state literal that get materialized into fully-featured reactive sub-APIs during tree construction. **A marker can sit at any node, at any depth.** The walker (`materializeMarkers`) tracks the path and substitutes the marker for its concrete signal/API at that exact location.
79
+
80
+ ### `entityMap<Entity, Key>(config?)`
81
+
82
+ Normalized entity collection with CRUD operations.
83
+
84
+ ```typescript
85
+ import { signalTree, entityMap } from '@signaltree/core';
86
+
87
+ const store = signalTree({
88
+ users: entityMap<User, number>({ selectId: (u) => u.id }),
89
+ });
90
+
91
+ // CRUD
92
+ store.$.users.addOne(user);
93
+ store.$.users.addMany(users);
94
+ store.$.users.setAll(users);
95
+ store.$.users.upsertOne(user);
96
+ store.$.users.upsertMany(users);
97
+ store.$.users.updateOne(id, changes);
98
+ store.$.users.updateMany([{ id, changes }, ...]);
99
+ store.$.users.updateWhere(pred, changes);
100
+ store.$.users.removeOne(id);
101
+ store.$.users.removeMany(ids);
102
+ store.$.users.removeWhere(pred);
103
+ store.$.users.clear();
104
+
105
+ // Queries (all return signals)
106
+ store.$.users.all(); // Signal<User[]>
107
+ store.$.users.byId(id); // Signal<User | undefined>
108
+ store.$.users.count(); // Signal<number>
109
+ store.$.users.has(id); // Signal<boolean>
110
+ store.$.users.ids(); // Signal<number[]>
111
+ store.$.users.where((u) => u.active); // Signal<User[]>
112
+ store.$.users.find((u) => u.role === 'admin'); // Signal<User | undefined>
113
+ ```
114
+
115
+ `entityMap` can be placed at any depth: `$.tickets.entities`, `$.users.byOrg[orgId].members`, etc.
116
+
117
+ ### `status<ErrorType>()`
118
+
119
+ Async operation state tracking.
120
+
121
+ ```typescript
122
+ import { signalTree, status, LoadingState } from '@signaltree/core';
123
+
124
+ const store = signalTree({
125
+ users: {
126
+ entities: entityMap<User, number>(),
127
+ loading: status<ApiError>(), // marker at depth 2
128
+ },
129
+ });
130
+
131
+ // Read
132
+ store.$.users.loading.state(); // Signal<LoadingState>
133
+ store.$.users.loading.error(); // Signal<ApiError | null>
134
+ store.$.users.loading.isLoading(); // Signal<boolean>
135
+ store.$.users.loading.isLoaded();
136
+ store.$.users.loading.isError();
137
+ store.$.users.loading.isNotLoaded();
138
+
139
+ // Mutate
140
+ store.$.users.loading.setLoading();
141
+ store.$.users.loading.setLoaded();
142
+ store.$.users.loading.setError(err);
143
+ store.$.users.loading.setNotLoaded();
144
+ store.$.users.loading.reset();
145
+ ```
146
+
147
+ ### `stored(key, defaultValue, options?)`
148
+
149
+ Auto-synced localStorage persistence at a single leaf.
150
+
151
+ ```typescript
152
+ import { signalTree, stored } from '@signaltree/core';
153
+
154
+ const store = signalTree({
155
+ settings: {
156
+ theme: stored('app-theme', 'light' as 'light' | 'dark'),
157
+ lang: stored('app-lang', 'en'),
158
+ },
159
+ });
160
+
161
+ store.$.settings.theme(); // auto-loads from localStorage if present
162
+ store.$.settings.theme.set('dark'); // auto-saves to localStorage immediately
163
+ store.$.settings.theme.clear(); // remove from localStorage, reset to default
164
+ store.$.settings.theme.reload(); // re-read from localStorage
165
+ ```
166
+
167
+ Supports versioning and migration functions via the third argument. Use for individual per-leaf persistence; use the `persistence()` enhancer for tree-wide persistence with storage adapters.
168
+
169
+ ### `form<T>(config)` (from `@signaltree/ng-forms` integration)
170
+
171
+ Tree-integrated form with validation, wizard, and persistence. Materializes into a form signal exposing field state, validation status, and submit/reset helpers. See `@signaltree/ng-forms` documentation for full config schema.
172
+
173
+ ### Custom markers via `registerMarkerProcessor(spec)`
174
+
175
+ You can register a custom marker processor. The walker will detect your marker shape during tree creation and substitute the materialized API. See `packages/core/src/lib/internals/materialize-markers.ts` for the registration shape.
176
+
177
+ ---
178
+
179
+ ## Derived state
180
+
181
+ Derived state is computed signals (Angular's `computed()`) that merge into the tree at arbitrary paths.
182
+
183
+ ### Single-tier inline
184
+
185
+ ```typescript
186
+ import { signalTree, entityMap } from '@signaltree/core';
187
+ import { computed } from '@angular/core';
188
+
189
+ const store = signalTree({
190
+ users: entityMap<User, number>(),
191
+ selectedUserId: null as number | null,
192
+ }).derived(($) => ({
193
+ // Nested derived — merged INTO $.users alongside the entityMap methods
194
+ users: {
195
+ selected: computed(() => {
196
+ const id = $.selectedUserId();
197
+ return id != null ? $.users.byId(id)?.() ?? null : null;
198
+ }),
199
+ activeCount: computed(() => $.users.all().filter((u) => u.active).length),
200
+ },
201
+ // Top-level derived
202
+ hasSelection: computed(() => $.selectedUserId() != null),
203
+ }));
204
+
205
+ store.$.users.selected(); // User | null
206
+ store.$.users.activeCount(); // number
207
+ store.$.users.all(); // still works — source entityMap methods preserved
208
+ store.$.hasSelection();
209
+ ```
210
+
211
+ Key property: `mergeDerivedState` performs a **deep merge** — derived definitions are added alongside existing source properties at the same path. Source entityMap methods, status markers, and signals at `$.users.*` are preserved when you add a derived `$.users.selected` next to them.
212
+
213
+ ### Multi-tier derived
214
+
215
+ Chain `.derived()` multiple times. Tier N can reference tier N-1 outputs:
216
+
217
+ ```typescript
218
+ .derived(($) => ({
219
+ users: { current: computed(() => $.users.byId($.selectedId())?.()) } // tier 1
220
+ }))
221
+ .derived(($) => ({
222
+ users: { isAdmin: computed(() => $.users.current()?.role === 'admin') } // tier 2 uses tier 1
223
+ }));
224
+ ```
225
+
226
+ **Critical rule:** within a single tier, a computed cannot reference another computed defined in the same tier. Move the dependency to a previous tier.
227
+
228
+ ### `derivedFrom` — derived definitions in separate files
229
+
230
+ When derived definitions live in their own file, use `derivedFrom<TTree>()` to provide the `$` type context:
231
+
232
+ ```typescript
233
+ // tree/derived/tier-1.derived.ts
234
+ import { derivedFrom } from '@signaltree/core';
235
+ import { computed } from '@angular/core';
236
+ import type { AppTreeBase } from '../app-tree';
237
+
238
+ const derived = derivedFrom<AppTreeBase>();
239
+
240
+ export const tier1Derived = derived(($) => ({
241
+ users: {
242
+ current: computed(() => {
243
+ const id = $.selectedUserId();
244
+ return id != null ? $.users.byId(id)?.() ?? null : null;
245
+ }),
246
+ },
247
+ }));
248
+
249
+ // tree/app-tree.ts
250
+ import { tier1Derived } from './derived/tier-1.derived';
251
+ const store = signalTree(initialState).derived(tier1Derived);
252
+ ```
253
+
254
+ **`derivedFrom` is a typed-identity function with zero runtime cost.** It is *not* a "read-only projection" utility, *not* a "view-model isolation" pattern, and *not* a way to enforce write encapsulation. Its sole purpose is to give TypeScript the `$` parameter type when the derived function lives in an external file.
255
+
256
+ The type signature is curried: `derivedFrom<TTree>()(fn)`. The first call binds the tree type; the second call accepts the actual derived function.
257
+
258
+ ---
259
+
260
+ ## Enhancers
261
+
262
+ Enhancers add cross-cutting capabilities. Chain via `.with()`. Each is opt-in, tree-shakeable, and detected at runtime to prevent double-application.
263
+
264
+ | Enhancer | Adds | When to use |
265
+ |---|---|---|
266
+ | `batching()` | `tree.batch(fn)`, `tree.coalesce(fn)` | Group multiple synchronous writes; coalesce rapid updates |
267
+ | `devTools(config?)` | Redux DevTools integration with path-based actions | Development/debugging |
268
+ | `timeTravel({ maxHistorySize })` | `tree.undo()`, `tree.redo()`, history navigation | Undo/redo, form wizards, canvas apps |
269
+ | `effects()` | `tree.effect(fn)`, `tree.subscribe(fn)` | Side effects and external observers |
270
+ | `persistence(config)` | Auto save/load via storage adapters (localStorage, IndexedDB, custom) | Whole-tree persistence with adapters |
271
+ | `serialization()` | JSON serialize/deserialize with Date/Map/Set preservation | Snapshotting, export/import |
272
+
273
+ ```typescript
274
+ const store = signalTree({ count: 0, items: [] })
275
+ .with(batching())
276
+ .with(timeTravel({ maxHistorySize: 50 }))
277
+ .with(devTools({ treeName: 'AppStore' }));
278
+
279
+ store.batch(() => {
280
+ store.$.count.set(10);
281
+ store.$.items.push({ id: 1 });
282
+ });
283
+
284
+ store.undo();
285
+ store.redo();
286
+ ```
287
+
288
+ > **Important:** automatic microtask-level notification batching is **built into core** (default on). The `batching()` enhancer adds the explicit `.batch(fn)` / `.coalesce(fn)` APIs on top. Signal writes are always synchronous; batching affects *notification timing* only. Disable automatic batching via `signalTree(state, { batchUpdates: false })`.
289
+
290
+ > **9.0.1:** The `memoization()` enhancer was removed. Use Angular's built-in `computed()` for memoization.
291
+
292
+ ---
293
+
294
+ ## Callable syntax (build-time transform)
295
+
296
+ Optional package `@signaltree/callable-syntax` provides a **build-time AST transform** (Babel-based, with Vite/Webpack plugins) that lets you write:
297
+
298
+ ```typescript
299
+ // Source (what you write):
300
+ store.$.user.name('Bob'); // → store.$.user.name.set('Bob')
301
+ store.$.count((n) => n + 1); // → store.$.count.update((n) => n + 1)
302
+ ```
303
+
304
+ **This is not a runtime proxy.** The transform runs at build time and disappears in production — there is no wrapper function, no `Proxy` object, no runtime overhead. Install as a dev dependency, register the Vite or Webpack plugin, and the transform compiles away.
305
+
306
+ ---
307
+
308
+ ## Subpath imports
309
+
310
+ Specialized APIs live in subpaths to keep the main barrel small:
311
+
312
+ ```typescript
313
+ import { TREE_PRESETS, createDevTree, createProdTree } from '@signaltree/core/presets';
314
+ import { SecurityValidator, SecurityPresets } from '@signaltree/core/security';
315
+ import { createEditSession } from '@signaltree/core/edit-session';
316
+ import { createStorageAdapter, createIndexedDBAdapter } from '@signaltree/core/storage';
317
+ ```
318
+
319
+ ### Async — `asyncSource` and `asyncQuery` markers (canonical, v9.5+)
320
+
321
+ Async state belongs **at the tree path it describes**. Two markers cover the two main async patterns and compose with the rest of the marker family:
322
+
323
+ ```typescript
324
+ import { signalTree, asyncSource, asyncQuery } from '@signaltree/core';
325
+
326
+ const store = signalTree({
327
+ // Load-and-expose: auto-loads on materialization, exposes data/loading/error
328
+ users: asyncSource<User[]>({
329
+ initial: [],
330
+ load: () => api.list$(), // Observable<T> or Promise<T>
331
+ }),
332
+
333
+ // Input-driven debounced query
334
+ search: asyncQuery<string, User[]>({
335
+ initialResult: [],
336
+ debounce: 300,
337
+ filter: (q) => q.length > 0,
338
+ query: (q) => api.search$(q),
339
+ }),
340
+ });
341
+
342
+ // Uniform with every other marker:
343
+ store.$.users(); // current value
344
+ store.$.users.loading(); // boolean
345
+ store.$.users.error(); // unknown | null
346
+ store.$.users.refresh(); // reload (cancels in-flight)
347
+ store.$.users.set([...]); // manual override
348
+ store.$.users.reset();
349
+
350
+ store.$.search(); // results
351
+ store.$.search.input.set('alice'); // drives debounced pipeline
352
+ store.$.search.loading();
353
+ store.$.search.rerun(); // rerun current input, skip dedup
354
+ ```
355
+
356
+ Both markers attach at **any tree depth**, accept **Observables or Promises**, and auto-clean on the surrounding `DestroyRef`. **No manual `tap()` / `setLoading()` / `setLoaded()` wiring.**
357
+
358
+ ### Migrating from `@ngrx/signals` `rxMethod`
359
+
360
+ SignalTree does **not** ship a `rxMethod` primitive — it's the wrong shape for SignalTree's marker philosophy (the SignalTree-native answer is to put async behavior at the tree path it describes via `asyncSource` / `asyncQuery`). To migrate from NgRx `rxMethod`:
361
+
362
+ - **`rxMethod<void>(pipeline)` doing a load-and-expose** → replace with `asyncSource(config)` at the data's tree path.
363
+ - **`rxMethod<TInput>(pipeline)` doing a debounced input-driven query** → replace with `asyncQuery(config)` at the search/results tree path.
364
+ - **`rxMethod` doing complex multi-step orchestration** where neither marker fits → write a plain Observable method in an `@Injectable()` Ops class with `tap()` writing to tree paths.
365
+
366
+ See [`docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md`](https://github.com/JBorgia/signaltree/blob/main/docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md) for the full mapping with examples.
367
+
368
+ ### Edit sessions
369
+
370
+ A value-level undo/redo wrapper for "draft and cancel" workflows — form wizards, multi-step editors, and any case where the user might discard their changes. Independent of the tree (no path binding); bridge by syncing `session.modified()` ↔ tree leaves as appropriate for your flow.
371
+
372
+ ```typescript
373
+ import { createEditSession } from '@signaltree/core/edit-session';
374
+
375
+ const session = createEditSession({ name: 'Alice', email: 'a@example.com' });
376
+
377
+ // applyChanges takes a value or an updater function:
378
+ session.applyChanges((profile) => ({ ...profile, name: 'Updated' }));
379
+ session.applyChanges((profile) => ({ ...profile, email: 'new@example.com' }));
380
+
381
+ session.modified(); // current draft value (signal)
382
+ session.original(); // initial value (signal)
383
+ session.isDirty(); // boolean signal — true if modified ≠ original
384
+ session.canUndo(); // signal — true if there's history to revert
385
+ session.canRedo(); // signal
386
+
387
+ session.undo();
388
+ session.redo();
389
+ session.reset(); // back to original; clears history
390
+ session.setOriginal(value); // commit pattern: set new original, clear history
391
+
392
+ // When you want to "commit" to the tree:
393
+ // effect(() => { if (session.isDirty()) tree.$.user.profile.set(session.modified()); });
394
+ ```
395
+
396
+ For draft-and-cancel workflows that pipe back to a tree path, use `createTreeEditSession` (v10.1+):
397
+
398
+ ```typescript
399
+ import { createTreeEditSession } from '@signaltree/core/edit-session';
400
+
401
+ // Pass a writable signal or a SignalTree branch/leaf accessor:
402
+ const session = createTreeEditSession(tree.$.user.profile);
403
+
404
+ session.applyChanges((p) => ({ ...p, name: 'New Name' }));
405
+ session.modified(); // current draft (untouched source)
406
+ session.isDirty(); // true
407
+ session.undo(); // navigate draft history
408
+ session.redo();
409
+
410
+ // User clicks Save:
411
+ session.commit(); // writes draft back to tree.$.user.profile
412
+
413
+ // User clicks Cancel:
414
+ session.cancel(); // discards draft, re-syncs from source, clears history
415
+
416
+ // External change updated the source? Re-baseline the dirty comparison:
417
+ session.pullFromSource();
418
+ ```
419
+
420
+ ---
421
+
422
+ ## Optional packages
423
+
424
+ | Package | Purpose | Key API |
425
+ |---|---|---|
426
+ | `@signaltree/callable-syntax` | Build-time `(value)` → `.set(value)` transform | Vite/Webpack plugin |
427
+ | `@signaltree/ng-forms` | Angular Forms bridge with Standard Schema validation | `form()` marker, `bindToFormGroup()` |
428
+ | `@signaltree/schema` | Standard Schema integration (Zod, Valibot, ArkType) | `validateBranch()`, `withSchema()` |
429
+ | `@signaltree/events` | Typed event/command bus | `defineEvents()`, `tree.emit()`, `tree.on()` |
430
+ | `@signaltree/guardrails` | Dev-only invariant checks + performance budgets | `guardrails({ rules: [...] })` enhancer |
431
+ | `@signaltree/realtime` | WebSocket / SSE sync into entity maps | `syncEntityMap(socket, $.users)` |
432
+ | `@signaltree/enterprise` | Diff-based `updateOptimized()` for very large trees | `optimized()` enhancer |
433
+
434
+ ---
435
+
436
+ ## Recommended production architecture
437
+
438
+ For apps that will live longer than a sprint, wrap the tree in a service with an ops namespace. Components access state via `store.$.path()` and mutate via `store.ops.domain.method()`.
439
+
440
+ ```typescript
441
+ @Injectable({ providedIn: 'root' })
442
+ export class AppStore {
443
+ readonly tree: AppTree = inject(APP_TREE);
444
+ readonly $ = this.tree.$;
445
+ readonly ops = {
446
+ users: inject(UserOps),
447
+ tickets: inject(TicketOps),
448
+ auth: inject(AuthOps),
449
+ };
450
+ }
451
+
452
+ @Injectable({ providedIn: 'root' })
453
+ export class UserOps {
454
+ private readonly _$ = inject(APP_TREE).$;
455
+ private readonly _api = inject(UserService);
456
+
457
+ setActiveUser(user: User): void {
458
+ this._$.users.upsertOne(user);
459
+ this._$.selectedUserId.set(user.id);
460
+ }
461
+
462
+ loadUsers$(): Observable<void> {
463
+ this._$.users.loading.setLoading();
464
+ return this._api.list$().pipe(
465
+ tap((users) => this._$.users.setAll(users)),
466
+ tap(() => this._$.users.loading.setLoaded()),
467
+ map(() => void 0),
468
+ catchError((err) => {
469
+ this._$.users.loading.setError(err);
470
+ return of(void 0);
471
+ })
472
+ );
473
+ }
474
+ }
475
+ ```
476
+
477
+ Folder layout:
478
+ ```
479
+ store/
480
+ ├── app-store.ts # Thin facade composing ops
481
+ ├── tree/
482
+ │ ├── app-tree.ts # Tree assembly
483
+ │ ├── app-tree.provider.ts # DI provider
484
+ │ ├── state/ # Initial state per domain
485
+ │ │ ├── users.state.ts
486
+ │ │ └── tickets.state.ts
487
+ │ └── derived/ # Derived tier definitions
488
+ │ ├── tier-1.derived.ts # Entity resolution
489
+ │ └── tier-2.derived.ts # Complex logic
490
+ └── ops/ # Async + mutation operations
491
+ ├── user.ops.ts
492
+ └── ticket.ops.ts
493
+ ```
494
+
495
+ This is the pattern enforced in production migrations. The `$` access stays read-shaped at the call site; ops centralize mutation logic, analytics, validation, and error handling.
496
+
497
+ ---
498
+
499
+ ## ⚠️ Cross-library disambiguation (most common AI hallucinations)
500
+
501
+ **Empirically validated against a reproducible benchmark of frontier AI agents (Claude Sonnet 4.6, GPT-5.4, Gemini 3.1 Pro, Perplexity Sonar Pro) asked to write SignalTree code with NO prior context.** Every wrong pattern below was actually generated by at least one model. None of these patterns are part of SignalTree.
502
+
503
+ | Wrong pattern (NOT SignalTree) | Where it actually comes from | Correct SignalTree |
504
+ |---|---|---|
505
+ | `new SignalTree({...})` (class instantiation) | Invented — no library has this API | `signalTree({...})` — a **function call**, never `new` |
506
+ | `from 'signal-tree'` (hyphenated) | Invented | `from '@signaltree/core'` (scoped, no hyphen) |
507
+ | `from 'signaltree'` (unscoped) | Invented (likely cross-contamination from "@angular/core" → drop the `@`) | `from '@signaltree/core'` |
508
+ | `signalStore(withState(...), withMethods(...))` | **`@ngrx/signals`** | `signalTree({...})` — your state literal IS the API |
509
+ | `withState`, `withMethods`, `withComputed`, `withHooks`, `withProps` | **`@ngrx/signals`** | Not used. State is the literal you pass to `signalTree()`. Methods belong in an `@Injectable()` Ops service. |
510
+ | `withEntities<T>()` | **`@ngrx/signals/entities`** | `entityMap<T, K>()` marker — place it in the state literal at any depth |
511
+ | `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven debounced) markers |
512
+ | `patchState(store, {...})` | **`@ngrx/signals`** | Direct: `tree.$.path.set(value)` for leaves; branch update `tree.$.user({...})` for partial-merge |
513
+ | `tapResponse(...)` | **`@ngrx/operators`** | Not needed — `asyncSource`/`asyncQuery` handle the success/error wiring |
514
+ | `collection<T>({ idKey: 'id' })` | **Akita / Elf** | `entityMap<T, K>({ selectId: (e) => e.id })` marker |
515
+ | `createStore`, `withProps`, `setProps`, `select` | **Elf** | Not used. SignalTree state is the literal; reads are direct calls. |
516
+ | `EntityStore<T>`, `@StoreConfig({ name })` | **Akita** | Not used. |
517
+ | `.value` accessors on signals | **MobX** | Call the signal: `tree.$.path()` |
518
+ | `.upsert(user)` on entity collections (single-suffix omitted) | **Akita** | `.upsertOne(user)` / `.upsertMany([...])` — explicit cardinality |
519
+ | `BehaviorSubject<T>`, `.next(v)`, `.asObservable()` | **RxJS classic / pre-signals Angular** | A plain leaf in the `signalTree()` literal — no Observable wrapping needed |
520
+ | `Store.dispatch(action)`, `Store.select(selector)` | **`@ngrx/store` (classic NgRx)** | Direct tree access: `tree.$.path()` to read, `tree.$.path.set(v)` to write |
521
+ | `.toPromise()` on Observables (deprecated RxJS 7+) | RxJS legacy | `firstValueFrom(observable)` — or let `asyncSource` consume the Observable directly |
522
+
523
+ ### Status marker — exact method names (frequently confused)
524
+
525
+ The `status()` marker's canonical methods are **`setLoading` / `setLoaded` / `setError`**, NOT Promise-vocabulary names (`setSuccess`, `start`, `succeed`, `fail`). However, **as of v10.2 the Promise-vocabulary aliases also work** — they delegate to the canonical methods with identical semantics. Use either; canonical is preferred in new code for searchability.
526
+
527
+ | Wrong-but-now-aliased (v10.2+) | Canonical | Equivalent? |
528
+ |---|---|---|
529
+ | `.setSuccess()` | `.setLoaded()` | Yes — alias |
530
+ | `.start()` | `.setLoading()` | Yes — alias |
531
+ | `.succeed()` | `.setLoaded()` | Yes — alias |
532
+ | `.fail(err)` | `.setError(err)` | Yes — alias |
533
+ | `.loading` (bare property) | `.isLoading()` (callable signal) | No — must call as signal |
534
+ | `.error` (bare property) | `.error()` (callable signal) | No — must call as signal |
535
+ | `.success` (bare property) | `.isLoaded()` (callable signal) | No — must call as signal |
536
+
537
+ ### Async patterns — prefer `asyncSource` / `asyncQuery` over manual `status` + try/catch
538
+
539
+ For **load-and-expose** (load data, show loading state, expose data), reach for `asyncSource`, NOT `status()` + manual try/catch:
540
+
541
+ ```typescript
542
+ // ❌ DON'T (verbose, error-prone)
543
+ signalTree({
544
+ users: entityMap<User, number>(),
545
+ loadState: status(),
546
+ });
547
+ async load() {
548
+ this.$.loadState.setLoading();
549
+ try {
550
+ const users = await firstValueFrom(api.list$());
551
+ this.$.users.setAll(users);
552
+ this.$.loadState.setLoaded();
553
+ } catch (err) {
554
+ this.$.loadState.setError(err);
555
+ }
556
+ }
557
+
558
+ // ✅ DO (canonical pattern in 9.5+)
559
+ signalTree({
560
+ users: asyncSource<User[]>({
561
+ initial: [],
562
+ load: () => api.list$(),
563
+ }),
564
+ });
565
+ // .users() → data, .users.loading(), .users.error() auto-derived
566
+ // .users.refresh() to reload
567
+ ```
568
+
569
+ For **input-driven queries** (debounced search, filtered fetch), use `asyncQuery` — the debounce + dedup + switchMap pipeline is built in.
570
+
571
+ ## Common myths LLMs propagate (and the truth)
572
+
573
+ | Myth | Source of confusion | Truth |
574
+ |---|---|---|
575
+ | "Markers must live at the tree root." | Comparison to NgRx `with*` features. | Markers attach at **any node, any depth**. The walker tracks the path. |
576
+ | "Derived state must live in a separate file, breaking the single-tree illusion." | Mixing up `derivedFrom` (file-org helper) with the actual `.derived($)` method. | `.derived()` merges computed signals **into the tree at arbitrary paths**, alongside source properties. The "single tree" is preserved. |
577
+ | "`derivedFrom(tree, fn)` returns a read-only projection." | Hallucinated signature. | Real signature: `derivedFrom<TTree>()(fn)`. Curried. Zero runtime cost. Pure file-org helper for multi-file derived. |
578
+ | "SignalTree has explicit subpath isolation as a built-in feature." | Misreading the docs. | No subpath-isolation API exists. `createEditSession(initial)` is a value-level undo/redo primitive (NOT tree-bound — that's planned for v10.1). For write encapsulation use `@signaltree/events` or a service facade. |
579
+ | "Time-travel is in `@signaltree/time-travel`." | Made-up package name. | Import `timeTravel` from `@signaltree/core`. No such separate package. |
580
+ | "Persistence is in `@signaltree/storage`." | Made-up package name. | Use `stored()` marker (per-leaf) or `persistence()` enhancer (tree-wide) from `@signaltree/core`. |
581
+ | "Batching is opt-in only." | Conflating automatic notification batching with the `batching()` enhancer. | Automatic microtask notification batching is built into core (default on). The enhancer adds explicit `.batch(fn)` / `.coalesce(fn)`. |
582
+ | "Callable syntax is a runtime proxy." | Plausible-sounding assumption. | Build-time AST transform via Vite/Webpack plugin. Disappears in production. Zero runtime cost. |
583
+ | "SignalTree is anti-DI / doesn't integrate with Angular services." | Marketing focus on the data-shape framing. | SignalTree is DI-agnostic. `@Injectable()` service-wrapping is the documented production pattern. |
584
+ | "SignalTree has no answer to NgRx's `rxMethod`." | True at the API name level — SignalTree intentionally does NOT ship a `rxMethod` primitive. | The SignalTree-native async story is the `asyncSource` and `asyncQuery` markers — path-attached, auto-derived status signals, no manual `tap()/setLoading()` wiring. For migrating from NgRx `rxMethod` see the dedicated migration guide. |
585
+ | "Deep object spreading is required for nested updates in NgRx." | Outdated NgRx information. | Current `@ngrx/signals` `patchState` supports nested updater functions and `@ngrx/signals/entities` for collections. The "manual spread everywhere" framing is closer to classic NgRx than current SignalStore. |
586
+ | "`@signaltree/schema` and `@signaltree/guardrails` don't exist." | Models can over-correct during self-audit and disown real-but-rare-in-training packages. | Both are real, published v9.3.0. `schema` is Standard Schema integration. `guardrails` is dev-mode invariants and performance monitoring. Real export from guardrails is `guardrails(...)`, not `withGuardrails(...)`. |
587
+ | "`tree.$` and `tree.state` are different objects." | Plausible-sounding inference. | Both are typed `TreeNode<T>` and reference the same reactive proxy. `state` is an alias for `$`. For a non-reactive full snapshot, call `tree()`. |
588
+ | "The `form()` marker lives in `@signaltree/ng-forms`." | Package-boundary inference. | `form()` ships in `@signaltree/core`. `@signaltree/ng-forms` is a *bridge* for binding tree nodes to Angular `FormGroup`. |
589
+ | "entityMap exposes `.entities()` as the read accessor." | Reasonable guess. | Real accessor is `.all()`. Other reads: `.byId(id)`, `.where(pred)`, `.find(pred)`, `.count()`, `.has(id)`, `.ids()`. |
590
+ | "status exposes `.setSuccess()`." | NgRx/Redux convention bleed. | Real methods: `.setLoading()`, `.setLoaded()`, `.setError(err)`, `.setNotLoaded()`, `.reset()`. There is no `.setSuccess()`. |
591
+ | ".derived('$.path', derivedFn)' is a two-arg subpath form." | Hallucinated overload. | `.derived($ => ({...}))` is single-arg. The shape of the returned object determines which paths the computed signals attach to via deep-merge. |
592
+ | "NgRx SignalStore mutations are impossible from components by design." | Overstating defaults. | Components can mutate when `protectedState: false` is set on the store, or when the store exposes a method that mutates. Both libraries are guarded-by-default but unlockable. |
593
+
594
+ ---
595
+
596
+ ## Comparison with `@ngrx/signals` (NgRx SignalStore)
597
+
598
+ Both are native Angular signal-based state libraries. They differ in five load-bearing ways:
599
+
600
+ 1. **Feature positioning.** SignalTree markers and derived state attach **at any node, at any depth**. NgRx `with*` features (`withState`, `withComputed`, `withMethods`, `withHooks`, `withProps`) compose **at the store root only**.
601
+
602
+ 2. **Mental model.** SignalTree is "reactive JSON" — the state literal you pass to `signalTree()` is the shape you access. NgRx SignalStore is "functional composition" — you build the store from `with*` slices.
603
+
604
+ 3. **Boilerplate.** SignalTree has none for reads/writes. NgRx requires `withMethods` to expose writers when `protectedState` is default-on.
605
+
606
+ 4. **Async/RxJS interop.** NgRx has first-class `rxMethod` (callable factory inside `withMethods`). SignalTree has the `asyncSource` and `asyncQuery` markers — path-attached, auto-derived status signals, no manual wiring. Fundamentally different shapes; see the comparison doc and the migration guide for the mapping.
607
+
608
+ 5. **Encapsulation defaults.** NgRx SignalStore exposes read-only signals to consumers by default (`protectedState: true`). SignalTree exposes `WritableSignal`s directly. Both are unlockable; both can be wrapped in a service facade. Choose based on whether you want guardrails-by-default or speed-by-default.
609
+
610
+ See `docs/compare/ngrx-signalstore.md` for the axis-by-axis matrix with code examples.
611
+
612
+ ---
613
+
614
+ ## Migration from `@ngrx/signals`
615
+
616
+ A complete, agent-ready migration playbook ships inside `@signaltree/core` and is published at `docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md`. Covers:
617
+
618
+ - Mechanical concept map (`signalStore` → tree + Ops, `withState` → initial state, `rxMethod` → `asyncSource` / `asyncQuery` markers (or plain Observable method for orchestration), `withEntities` → `entityMap()` marker)
619
+ - Three migration strategies with decision criteria: big-bang, incremental per-domain, hybrid legacy-facade
620
+ - Phase 0 recipe for landing the foundation in a dependency-only PR
621
+ - `scripts/verify-signaltree-migration.sh` — package-manager-agnostic verification script
622
+
623
+ For migrations exceeding a single agent's context window, see `docs/skills/using-signaltree/reference/orchestrating-a-migration.md`.
624
+
625
+ ---
626
+
627
+ ## Resources
628
+
629
+ - Repo: https://github.com/JBorgia/signaltree
630
+ - Live demo + benchmarks: https://signaltree.io
631
+ - Short LLM summary: https://signaltree.io/llms.txt
632
+ - NgRx SignalStore comparison: https://github.com/JBorgia/signaltree/blob/main/docs/compare/ngrx-signalstore.md
633
+ - Myths and misconceptions: https://github.com/JBorgia/signaltree/blob/main/docs/myths-and-misconceptions.md
634
+ - Architecture guide: https://github.com/JBorgia/signaltree/blob/main/docs/architecture/signaltree-architecture-guide.md
635
+ - AI-agent skill (drop-in for Cursor, Claude Code, generic harnesses): `docs/skills/using-signaltree/SKILL.md`
636
+ - AI agent templates (`.cursorrules`, `CLAUDE.md`): `docs/ai/agent-templates.md`
package/llms.txt ADDED
@@ -0,0 +1,193 @@
1
+ # SignalTree
2
+
3
+ > Reactive JSON for Angular. Turn a plain object into a tree of signals — no actions, no reducers, no selectors. Markers and derived state attach at any depth in the tree.
4
+
5
+ SignalTree is a state-management library for Angular 17+. The mental model is: your state is a typed JSON object; reading and writing use ordinary signal calls along the JSON path. Markers (`entityMap`, `status`, `stored`, `form`) and derived state (`.derived(...)`) attach **at any node, at any depth** — they are processed by a recursive walker, not composed at the store root. This is the load-bearing difference from `@ngrx/signals` (NgRx SignalStore), whose `with*` features compose at the store root only.
6
+
7
+ ## Canonical example
8
+
9
+ ```typescript
10
+ import {
11
+ signalTree,
12
+ entityMap, status, stored, form,
13
+ asyncSource, asyncQuery,
14
+ } from '@signaltree/core';
15
+ import { computed } from '@angular/core';
16
+
17
+ const store = signalTree({
18
+ users: entityMap<User, number>(), // marker at depth 1
19
+ selectedUserId: null as number | null,
20
+ settings: {
21
+ theme: stored('app-theme', 'light'), // marker at depth 2
22
+ profileForm: {
23
+ data: form<Profile>({ name: '', email: '' }), // marker at depth 3
24
+ },
25
+ },
26
+ loading: status<ApiError>(), // marker at depth 1
27
+
28
+ // Async markers — load-and-expose / input-driven query
29
+ reports: asyncSource<Report[]>({ // marker at depth 1
30
+ initial: [],
31
+ load: () => api.listReports$(),
32
+ }),
33
+ search: asyncQuery<string, User[]>({ // marker at depth 1
34
+ initialResult: [],
35
+ debounce: 300,
36
+ query: (q) => api.searchUsers$(q),
37
+ }),
38
+ }).derived(($) => ({
39
+ users: {
40
+ current: computed(() => // derived merged INTO $.users at depth 2
41
+ $.selectedUserId() != null ? $.users.byId($.selectedUserId()!)?.() ?? null : null
42
+ ),
43
+ },
44
+ })).with(batching()).with(devTools());
45
+
46
+ // Read — leaves, derived, async-marker accessors all uniform
47
+ store.$.users.all(); // Signal<User[]>
48
+ store.$.users.current(); // Signal<User | null> (derived)
49
+ store.$.settings.theme(); // 'light' (auto-loaded from localStorage)
50
+ store.$.reports(); // Report[] (asyncSource current value)
51
+ store.$.reports.loading(); // boolean
52
+ store.$.search.input.set('alice'); // drives debounced query
53
+ store.$.search(); // User[] results
54
+
55
+ // Write — direct or via marker methods
56
+ store.$.users.addOne({ id: 1, name: 'Alice' });
57
+ store.$.settings.theme.set('dark'); // auto-saved to localStorage
58
+ store.$.reports.refresh(); // reload async source
59
+ ```
60
+
61
+ ## When to use SignalTree
62
+
63
+ - Apps with structured, hierarchical state (settings, profiles, nested forms, dashboards)
64
+ - Teams that want signal-based state with dot-notation access and zero boilerplate
65
+ - Projects needing undo/redo, DevTools, entity CRUD, localStorage persistence, runtime validation, or schema-driven forms out of the box
66
+ - Migrations away from `@ngrx/signals` — a complete agent-ready migration guide ships in the package
67
+
68
+ ## When NOT to use SignalTree
69
+
70
+ - You're using event-sourcing or CQRS — use NgRx Store (the classic Redux variant), not SignalStore or SignalTree
71
+ - Your state is a single flat `Map` — a plain `signal()` or `Map` suffices
72
+ - You're building a tiny app with one or two signals — overhead exceeds value
73
+ - Your state shape is highly dynamic (streaming arbitrary JSON keys at high frequency — real-time log aggregators, fully-dynamic schema editors). Markers and the type system assume fixed shape; for shape-shifting payloads, a flat collection inside a slice is the better fit
74
+ - You have a large `@ngrx/store` (classic) + heavy RxJS codebase. The migration target with the lowest cognitive cost is `@ngrx/signals` (NgRx SignalStore), not SignalTree — the RxJS-flavored API and mental model is closer to where you already are
75
+
76
+ ## Packages
77
+
78
+ | Package | Purpose |
79
+ |---|---|
80
+ | `@signaltree/core` | Core tree, markers, derived state, enhancers, edit sessions, lifecycle |
81
+ | `@signaltree/callable-syntax` | Build-time AST transform: `$.x.name('Bob')` → `$.x.name.set('Bob')`. Vite/Webpack plugin. **Zero runtime cost** |
82
+ | `@signaltree/ng-forms` | Angular Forms bridge with Standard Schema validation |
83
+ | `@signaltree/schema` | Standard Schema integration (Zod, Valibot, ArkType) — runtime validation of tree branches |
84
+ | `@signaltree/events` | Typed event/command bus for unidirectional command flow on top of the tree |
85
+ | `@signaltree/guardrails` | Dev-only invariant checks, performance budgets, hot-path detection |
86
+ | `@signaltree/realtime` | Keep entity maps in sync with WebSocket / SSE sources |
87
+ | `@signaltree/enterprise` | Diff-based `updateOptimized()` for large trees (500+ signals), path indexing |
88
+
89
+ ## Key API surface
90
+
91
+ - **Create:** `signalTree(initialState, config?)` → tree with `.$` accessor
92
+ - **Read leaf:** `tree.$.path.to.leaf()` — returns the value
93
+ - **Write leaf:** `tree.$.path.to.leaf.set(v)` / `.update(fn)`
94
+ - **Replace full state:** `tree(newState)`
95
+ - **Markers:** `entityMap<E, K>()`, `status<E>()`, `stored(key, default)`, `form<T>(config)`, `asyncSource<T>(config)`, `asyncQuery<TIn, TOut>(config)` — place at any node
96
+ - **Derived state:** `.derived($ => ({ ... }))` — definitions deep-merged into existing tree paths
97
+ - **Enhancers:** `.with(batching())`, `.with(devTools())`, `.with(timeTravel())`, `.with(persistence())`, `.with(serialization())`
98
+ - **Lifecycle:** `tree.destroy()` tears down all resources in reverse enhancer order; `tree.destroyed()` is a signal; `tree.registerCleanup(fn)` for custom hooks
99
+ - **Edit sessions:** `createEditSession(initial)` from `@signaltree/core/edit-session` — wraps any value with `applyChanges` / `undo` / `redo` / `reset` / `setOriginal` for form-wizard and draft-and-cancel workflows. Independent of the tree; bridge by syncing `session.modified()` ↔ tree leaves when appropriate.
100
+ - **Async:** `asyncSource(config)` for load-and-expose, `asyncQuery(config)` for input-driven debounced queries. Both attach at any tree path and auto-expose `data`/`loading`/`error`/`refresh`. For migrating from `@ngrx/signals`, see [`docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md`](https://github.com/JBorgia/signaltree/blob/main/docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md).
101
+
102
+ ## ⚠️ Disambiguation — if you think SignalTree uses X, you're confusing it with Y
103
+
104
+ This table catches the most common cross-library hallucinations. **Every "Wrong" pattern below was observed in a recent reproducible benchmark of frontier AI models (Claude, GPT-5.4, Gemini, Perplexity) asked to generate SignalTree code.** None of these patterns are or ever have been part of SignalTree.
105
+
106
+ | Wrong pattern (NOT SignalTree) | Where it actually comes from | Correct SignalTree |
107
+ |---|---|---|
108
+ | `new SignalTree({...})` (class) | Invented (no library has this) | `signalTree({...})` — it's a **function**, never `new` |
109
+ | `from 'signal-tree'` (hyphenated) | Invented | `from '@signaltree/core'` (scoped, no hyphen) |
110
+ | `from 'signaltree'` (unscoped) | Invented | `from '@signaltree/core'` |
111
+ | `signalStore(withState(...), withMethods(...))` | **`@ngrx/signals`** | `signalTree({...})` — your state literal IS the API |
112
+ | `withState`, `withMethods`, `withComputed`, `withHooks`, `withProps` | **`@ngrx/signals`** | Not used. State is the literal you pass to `signalTree()`. Methods belong in an `@Injectable()` Ops service. |
113
+ | `withEntities<T>()` | **`@ngrx/signals/entities`** | `entityMap<T, K>()` marker — place it in the state literal |
114
+ | `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven) markers |
115
+ | `patchState(store, {...})` | **`@ngrx/signals`** | Direct: `tree.$.path.set(value)` or branch update `tree.$.user({...})` |
116
+ | `collection<T>({ idKey: 'id' })` | **Akita / Elf** | `entityMap<T, K>({ selectId: (e) => e.id })` marker |
117
+ | `createStore`, `withProps`, `setProps` | **Elf** | Not used. SignalTree state is the literal. |
118
+ | `EntityStore`, `StoreConfig({ name })` | **Akita** | Not used. |
119
+ | `.value` accessors on signals | **MobX** | Call the signal: `tree.$.path()` |
120
+ | `.upsert(user)` on entity collections | **Akita** | `.upsertOne(user)` (singular suffix) |
121
+ | `BehaviorSubject<T>`, `.next(v)`, `.asObservable()` | **RxJS classic** | A plain leaf in the `signalTree()` literal — no Observable wrapping needed |
122
+
123
+ ### Status marker — exact method names (frequently confused)
124
+
125
+ The `status()` marker uses **`setLoading` / `setLoaded` / `setError`**, NOT Promise-vocabulary names. **Aliases ship as of v10.2** so the most common wrong-names also work, but the canonical names are:
126
+
127
+ | Wrong (Promise-vocab guess) | Correct (canonical) | Notes |
128
+ |---|---|---|
129
+ | `.setSuccess()` | **`.setLoaded()`** | Alias `.setSuccess` works in v10.2+ |
130
+ | `.start()` | **`.setLoading()`** | Alias `.start` works in v10.2+ |
131
+ | `.succeed()` | **`.setLoaded()`** | Alias `.succeed` works in v10.2+ |
132
+ | `.fail(err)` | **`.setError(err)`** | Alias `.fail` works in v10.2+ |
133
+ | `.loading` (property) | **`.isLoading()`** (callable signal — invoke it) |
134
+ | `.error` (property) | **`.error()`** (callable signal — invoke it) |
135
+
136
+ ### Async pattern — prefer `asyncSource` / `asyncQuery` over `status` + manual try/catch
137
+
138
+ For **load-and-expose** patterns (load data, show loading state, expose data) — use `asyncSource`, NOT `status()` + manual try/catch. The marker auto-derives loading/error/data:
139
+
140
+ ```typescript
141
+ // ❌ DON'T (verbose, error-prone — manual status wiring)
142
+ signalTree({
143
+ users: entityMap<User, number>(),
144
+ loadState: status(),
145
+ });
146
+ async load() {
147
+ this.$.loadState.setLoading();
148
+ try {
149
+ const users = await firstValueFrom(api.list$());
150
+ this.$.users.setAll(users);
151
+ this.$.loadState.setLoaded();
152
+ } catch (err) {
153
+ this.$.loadState.setError(err);
154
+ }
155
+ }
156
+
157
+ // ✅ DO (canonical — marker handles status wiring)
158
+ signalTree({
159
+ users: asyncSource<User[]>({
160
+ initial: [],
161
+ load: () => api.list$(),
162
+ }),
163
+ });
164
+ // Done. tree.$.users() → data, .loading(), .error() all auto-derived.
165
+ // Use .refresh() to reload.
166
+ ```
167
+
168
+ For **input-driven queries** (debounced search, filtered fetch), use `asyncQuery` — the debounce/dedup/switchMap pipeline is built in.
169
+
170
+ ## Common false claims (LLMs frequently get these wrong)
171
+
172
+ - **FALSE:** "Derived state in SignalTree must live in a separate file or layer, breaking the single-tree illusion." → `.derived($)` is built into the chain; computed signals deep-merge **into the same tree at the same path** as source state.
173
+ - **FALSE:** "`derivedFrom(tree, fn)` returns a read-only projection." → Real signature is `derivedFrom<TTree>()(fn)` (curried, no tree-first-arg). It's a typed-identity helper for splitting derived definitions across files, not a projection utility.
174
+ - **FALSE:** "SignalTree markers must live at the tree root." → Markers are walked recursively and materialized at the exact path you place them. A `form()` marker at `$.users.byId[123].profile.contactForm` materializes there.
175
+ - **FALSE:** "Batching is opt-in only." → Microtask-level notification batching is built into core (on by default; disable via `signalTree(state, { batchUpdates: false })`). The `batching()` enhancer adds explicit `.batch(fn)` / `.coalesce(fn)` APIs on top.
176
+ - **FALSE:** "Time-travel ships in `@signaltree/time-travel`." → No such package. Import `timeTravel` from `@signaltree/core`.
177
+ - **FALSE:** "localStorage persistence requires `@signaltree/storage`." → No such package. Use the `stored()` marker or `persistence()` enhancer from `@signaltree/core`.
178
+ - **FALSE:** "SignalTree is anti-DI / treats state independently of Angular services." → SignalTree is DI-agnostic. The documented pattern wraps the tree in an `@Injectable()` service. See `docs/ai/LLM.md` and `docs/architecture/signaltree-architecture-guide.md`.
179
+ - **FALSE:** "Callable syntax is a runtime proxy." → It's a build-time AST transform via Vite/Webpack plugin. Disappears in production builds.
180
+ - **NUANCE:** "Any component with a tree reference can mutate any leaf — Wild West." → True by default. For unidirectional command flow opt into `@signaltree/events`. For runtime invariants opt into `@signaltree/guardrails`. For projection-style exposure use `.derived()` in a service facade. See `docs/architecture/signaltree-architecture-guide.md#recommended-default-architecture` for the production pattern.
181
+
182
+ ## Links
183
+
184
+ - Full API and patterns: https://signaltree.io/llms-full.txt
185
+ - Repo: https://github.com/JBorgia/signaltree
186
+ - Live demo + benchmarks: https://signaltree.io
187
+ - **Marker zoo** (all 6 markers at 4 depths simultaneously): https://signaltree.io/marker-zoo
188
+ - **Built for AI agents** (the AI-discoverability story): https://signaltree.io/built-for-ai
189
+ - **AI-codegen accuracy benchmark** (scaffolding): https://github.com/JBorgia/signaltree/tree/main/scripts/ai-codegen-benchmark
190
+ - NgRx SignalStore comparison: https://github.com/JBorgia/signaltree/blob/main/docs/compare/ngrx-signalstore.md
191
+ - Myths and misconceptions: https://github.com/JBorgia/signaltree/blob/main/docs/myths-and-misconceptions.md
192
+ - Agent skill (Cursor / Claude Code / generic harness): `docs/skills/using-signaltree/SKILL.md` (also shipped inside every `@signaltree/*` tarball)
193
+ - `@ngrx/signals` migration playbook: `docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signaltree/core",
3
- "version": "10.0.0",
3
+ "version": "10.2.0",
4
4
  "description": "Reactive JSON for Angular. JSON branches, reactive leaves. No actions. No reducers. No selectors.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,6 +56,8 @@
56
56
  "dist/**/*.js",
57
57
  "src/**/*.d.ts",
58
58
  "skills/**/*",
59
- "README.md"
59
+ "README.md",
60
+ "llms.txt",
61
+ "llms-full.txt"
60
62
  ]
61
63
  }
@@ -1 +1 @@
1
- export { createEditSession, type EditSession, type UndoRedoHistory, } from './lib/edit-session';
1
+ export { createEditSession, createTreeEditSession, type EditSession, type TreeEditSession, type TreeEditSource, type UndoRedoHistory, } from './lib/edit-session';
@@ -19,3 +19,14 @@ export interface EditSession<T> {
19
19
  }
20
20
  export declare function createEditSession<T>(initial: T): EditSession<T>;
21
21
  export default createEditSession;
22
+ export interface TreeEditSource<T> {
23
+ (): T;
24
+ set(value: T): void;
25
+ update?(fn: (current: T) => T): void;
26
+ }
27
+ export interface TreeEditSession<T> extends EditSession<T> {
28
+ commit(): void;
29
+ cancel(): void;
30
+ pullFromSource(): void;
31
+ }
32
+ export declare function createTreeEditSession<T>(source: TreeEditSource<T>): TreeEditSession<T>;
@@ -26,6 +26,10 @@ export interface StatusSignal<E = Error> {
26
26
  setLoaded(): void;
27
27
  setError(error: E): void;
28
28
  reset(): void;
29
+ start(): void;
30
+ setSuccess(): void;
31
+ succeed(): void;
32
+ fail(error: E): void;
29
33
  }
30
34
  export declare function status<E = Error>(initialState?: LoadingState): StatusMarker<E>;
31
35
  export declare function isStatusMarker(value: unknown): value is StatusMarker;