@signaltree/enterprise 13.1.1 → 13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signaltree/enterprise",
3
- "version": "13.1.1",
3
+ "version": "13.2.0",
4
4
  "description": "Enterprise optimizations for SignalTree reactive JSON. Diff-based updates, bulk operations, and advanced change tracking for large-scale applications.",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",
@@ -68,7 +68,7 @@
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@angular/core": "^20.0.0 || ^21.0.0 || ^22.0.0",
71
- "@signaltree/core": "^13.1.1",
71
+ "@signaltree/core": "^13.2.0",
72
72
  "tslib": "^2.0.0"
73
73
  },
74
74
  "peerDependenciesMeta": {},
@@ -0,0 +1,153 @@
1
+ ---
2
+ name: using-signaltree
3
+ description: Guides AI agents integrating SignalTree (@signaltree/core and related packages) into Angular 20+ applications. Use when the user mentions SignalTree, @signaltree/core, @signaltree/ng-forms, @signaltree/enterprise, @signaltree/callable-syntax, @signaltree/guardrails, @signaltree/events, @signaltree/realtime, signal tree, reactive state, Angular signals store, or Angular state management; when the user wants to create, read, or update a signalTree() state tree; when the user is choosing enhancers (batching, persistence, time travel, devTools) or markers (entityMap, status, stored, form); or when the user is building reactive forms, syncing realtime data, or wiring event-driven flows on top of Angular signals.
4
+ ---
5
+
6
+ # Using SignalTree
7
+
8
+ SignalTree: reactive JSON for Angular. State as shape. Signals at every path. Plain state object → `signalTree()` → every leaf = Angular signal via typed `$` proxy. No action creators, reducer functions, or selector functions — mutations live as named methods on an `Ops` service, derivations as `.derived()` tiers.
9
+
10
+ Mental model:
11
+
12
+ - Reactive JSON tree: `signalTree(state)` mirrors input shape. Leaves = `WritableSignal<T>`. Branches = typed accessors.
13
+ - `$` proxy: `tree.$.user.profile.name` = signal; `tree.$.user.profile` = group accessor.
14
+ - Read: `tree.$.count()` — subscribes reactive context.
15
+ - Write leaf: `.set(value)` / `.update(fn)`. Write branch (both forms are **deep-merge partial writes** — keys absent from the payload are preserved): `tree.$.user({ name, email })` (partial-merge object) or `tree.$.user((u) => ({ ...u, name }))` (updater function). No dispatch. There is no `tree.set(...)` — the root is callable: `tree(partial)` or `tree(updater)`.
16
+ - Enhancers: `tree.with(batching()).with(devTools())` — order-sensitive.
17
+ - Markers: `entityMap<User>()`, `entityMap<Plant>({ load: loader(fn) })` (cache-aware (single-scope) form), `status()`, `stored(key, defaultValue)`, `form<T>({ initial: T })` — placeholders; `signalTree()` replaces each with its runtime API at that path. Branches are natively callable for reads AND writes (writes are deep-merge partial updates — keys not in the payload are preserved); the `@signaltree/callable-syntax` build-time transform extends call-syntax to **leaf writes** only. Arrays in leaves are `WritableSignal<T[]>` — use `.update(arr => [...arr, x])`, NOT `.push()`.
18
+
19
+ Don't introduce actions, reducers, action creators, or selectors — they fight the design. No module registration.
20
+
21
+ **App-wide state always uses one tree.** All domains (auth, settings, tickets, feature flags, …) go into a single `signalTree()` call — never one tree per domain or one tree per service. The pattern:
22
+
23
+ - `createAppTree()` — composes all domain state factories into one `signalTree()`, wired via an `APP_TREE` `InjectionToken` and a `provideAppTree()` function. **Also export `createBaseState()`** — tests need it to build isolated trees.
24
+ - `AppStore` — a single `providedIn: 'root'` class that injects `APP_TREE`, exposes `readonly $ = this.tree.$`, and namespaces per-domain `Ops` classes under `readonly ops = { … } as const`.
25
+ - `Ops` classes — one per domain (`DriverOps`, `SettingsOps`, …), each `Injectable`, each injecting `APP_TREE` directly for writes. No business logic in `AppStore` itself.
26
+ - **Consumers (components, resolvers, interceptors, guards) inject `AppStore` only** — never an Ops class or `APP_TREE` directly. Reads go through `store.$`; writes go through `store.ops.<domain>.<method>()`.
27
+ - **Ship `provideAppTreeForTesting()` alongside `provideAppTree()` from day one.** `AppStore` is `providedIn: 'root'`, so every `TestBed` that touches it (or any consumer that touches it transitively) will fail with `NG0201: APP_TREE` until tests provide the token. See [`reference/testing.md`](reference/testing.md).
28
+
29
+ Read [`reference/patterns.md`](reference/patterns.md) for the full wiring before writing any store code.
30
+
31
+ Component-local state (a single component's ephemeral UI state) is the only case where a tree lives outside `APP_TREE`.
32
+
33
+ Install:
34
+
35
+ ```bash
36
+ npm install @signaltree/core
37
+ # pnpm: pnpm add @signaltree/core
38
+ ```
39
+
40
+ See [`reference/install.md`](reference/install.md) for optional packages and peer-dependency details.
41
+
42
+ ```ts
43
+ import { signalTree } from '@signaltree/core';
44
+ const tree = signalTree({ counter: 0, user: { name: 'Ada', email: 'ada@example.com' } });
45
+ tree.$.user.name(); // read
46
+ tree.$.counter.set(1); // write leaf
47
+ tree.$.user({ name: 'Grace', email: 'grace@example.com' }); // write branch
48
+ ```
49
+
50
+ `$` proxy exposes signals — `@if`, `@for`, `[value]`, two-way bindings work natively. See [`reference/patterns.md`](reference/patterns.md) for template idioms.
51
+
52
+ Enhancer / package decision tree — start with `@signaltree/core` alone; add only when you hit the matching problem:
53
+
54
+ - Group CD notifications → `batching()` from `@signaltree/core`.
55
+ - Persist tree to `localStorage` → `persistence({ key, autoSave, autoLoad, debounceMs })` from `@signaltree/core`. Single leaf → `stored()` marker.
56
+ - `computed()` running too often → use Angular's `computed()` — it already memoizes by reference. SignalTree no longer ships a `memoization` enhancer (removed in 9.0.1); for deep-equality cache keys, derive via your own `computed()` with explicit comparison.
57
+ - Debug history, time travel, Redux DevTools → `timeTravel()` / `devTools({ treeName })` from `@signaltree/core`.
58
+ - Large app, bulk updates slow, diff-based patching → add `@signaltree/enterprise`, compose `enterprise()`. Read [`enterprise/SKILL.md`](enterprise/SKILL.md).
59
+ - Reactive forms (validation, dirty/touched, wizards, FormGroup interop) → add `@signaltree/ng-forms`; on Angular 22+ the same package bridges to **Signal Forms** via `signalForm` from `@signaltree/ng-forms/signals`. Read [`ng-forms/SKILL.md`](ng-forms/SKILL.md).
60
+ - `tree.$.field('value')` sugar over `.set()` → add `@signaltree/callable-syntax` (build-time transform, zero runtime cost). Read [`callable-syntax/SKILL.md`](callable-syntax/SKILL.md).
61
+ - Dev-time perf budgets, memory-leak detection, anti-pattern warnings → add `@signaltree/guardrails` (dev-only; noop in production). Read [`guardrails/SKILL.md`](guardrails/SKILL.md).
62
+ - Event-driven with Zod schemas, idempotency, retries → add `@signaltree/events` (ESM-only, requires Zod). Read [`events/SKILL.md`](events/SKILL.md).
63
+ - Sync tree to Supabase / Firebase / WebSocket → add `@signaltree/realtime`, compose `supabaseRealtime()` or `realtime()`. Read [`realtime/SKILL.md`](realtime/SKILL.md).
64
+
65
+ Composing enhancers — typical production shape:
66
+
67
+ ```ts
68
+ import { signalTree, batching, devTools } from '@signaltree/core';
69
+
70
+ interface AppState {
71
+ counter: number;
72
+ ui: { theme: 'light' | 'dark' };
73
+ }
74
+
75
+ const tree = signalTree<AppState>({ counter: 0, ui: { theme: 'light' } })
76
+ .with(batching({ enabled: true, notificationDelayMs: 0 }))
77
+ .with(devTools({ treeName: 'AppStore' }));
78
+ ```
79
+
80
+ Cross-package enhancers (`enterprise()`, `guardrails()`, `formBridge()`, `supabaseRealtime(...)`) slot into same chain.
81
+
82
+ Angular Signal Forms bridge (Angular 22+, v11.6+) — not an enhancer; `signalForm` from `@signaltree/ng-forms/signals` turns a `form()` marker into a Signal Forms `FieldTree` sharing the marker's values signal (one model, no sync loops):
83
+
84
+ ```ts
85
+ import { signalForm } from '@signaltree/ng-forms/signals';
86
+ import { signalTree, form } from '@signaltree/core';
87
+
88
+ const tree = signalTree({
89
+ onboarding: { profile: form({ initial: { name: '' } }) },
90
+ });
91
+ const profile = signalForm(tree.$.onboarding.profile); // FieldTree<{ name: string }>
92
+ // template: <input [formField]="profile.name" /> — marker validators run as Signal Forms
93
+ // validators with real error kinds; built-ins emit Angular's branded errors by
94
+ // default (13.2+) — pass { nativeErrors: false } for plain { kind, message }.
95
+ // Async validation is NOT unified — pick ONE authority (marker submit() path OR validateAsync).
96
+ ```
97
+
98
+ Schema-registry overload: `signalForm<TModel>(tree, rootPath, tree.$.subtree)` after `.with(schemas(...))` auto-applies every registered schema under `rootPath`. (`markerSignalForm`/`signalFormBridge` are deprecated 11.5.0 aliases — emit `signalForm`.)
99
+
100
+ Markers — placed in initial state, replaced by `signalTree()` with fully-typed runtime API:
101
+
102
+ - `entityMap<T, K>()` — O(1) CRUD: `addOne`, `upsertOne`, `removeOne`, `setAll`, `byId`, `all`, `clear`. Predicates: `.empty()` (the `.isEmpty` alias was removed in v11). Reads: `.count()`, `.has(id)`, `.where(pred)`, `.find(pred)`. Config: `entityMap<T,K>({ selectId, sortComparer })` — v10.5+ `sortComparer` keeps `all()`/`ids()` sorted (`@ngrx/entity` parity). `byId(id).field()` is body-granular (fan-out 1). `.computed('name', all => …)` attaches a derived slice to the collection at declaration; read it as `tree.$.users.name()` — **typed on `tree.$` since v13.2**, no cast, and chainable (each name typed independently). `compute` sees only that collection's `E[]`, so cross-collection/external-state projections stay a normal `computed`/`.derived()`. **Cache-aware / self-loading form (v11.2+, scoped form v11.4+):** wrap a load function with the `loader()` helper and pass it as `load` in config, and `entityMap` gains a loader surface — **use this instead of hand-wiring `entityMap` + `status` + a loader + a load-guard** for any server-backed collection. `loader()` is a separate export from `@signaltree/core`; it's what keeps the loader machinery tree-shakeable — a plain `entityMap()` (no `load`) doesn't pay for it. Config adds: `config.load: loader(() => Observable<E[]> | Promise<E[]>, { staleTime, swr, tags, persist, equal, lazy, clearOnParamsChange })` — `staleTime` (`'30m'`/ms — skip refetch while fresh; default `0` = always stale), `swr` (serve last value while revalidating), `tags` (for `invalidateTag`), `persist` (offline-first hydrate-then-revalidate via a `StorageAdapter`). Methods added: `.load()` (guarded — no-op if fresh OR in-flight, so N callers = one fetch), `.loadOrThrow()` (same guard as `.load()`, but rejects with the loader's error instead of only surfacing it via `.error()` — for imperative await/try-catch call sites; `.load()` never rejects), `.refresh()` (force, still single-in-flight), `.invalidate()` (mark stale — next `load()` refetches). Status: `.loading()`, `.loaded()`, `.error()`, `.lastLoadedAt()`. Tag-based push invalidation: `invalidateTag(tree, 'plants')` marks every collection carrying that tag stale — the clean seam for SSE/SignalR (`@signaltree/realtime`). Auto-loads on first `tree.$` access (unless `loader()`'s `lazy: true`). **Scoped form (v11.4+):** a wrapped loader function that declares a param makes the collection scoped: `entityMap<E, K, P>({ load: loader((p) => api.list$(p)) })` — call `load(params)` with the scope (region/customer/tenant); freshness (`staleTime`) is tracked per scope via `loader()`'s `equal?: (a, b) => boolean` option (default: structural value compare), so a scope change refetches even if the old scope was fresh, and a different scope requested mid-fetch supersedes (last-request-wins). `params()` exposes the loaded scope (typed `Signal<P | undefined>`); `refresh(params?)` forces a reload; `loader()`'s `clearOnParamsChange` option blanks rows on scope change. Scoped collections are always lazy. Single-scope cache (no multi-key LRU yet). (Auto-load is deferred off the render pass, so a non-lazy collection read in a template is NG0600-safe.) There is no separate `entityCollection` marker — a plain `entityMap<T, K>()` (no `load`) is unaffected.
103
+ - `status()` — async op state. Write methods: `setLoading()` / `setLoaded()` / `setError(err)` / `setNotLoaded()` / `reset()`. v10.2 Promise-vocabulary aliases also work: `.start()` (= setLoading), `.setSuccess()` / `.succeed()` (= setLoaded), `.fail(err)` (= setError). Read predicates (canonical, bare names): `.loading()`, `.loaded()`, `.notLoaded()`, `.hasError()`, `.idle()`, `.settled()` — all callable signals, invoke them. (The `is`-prefix aliases were removed in v11.) `.settled()` = `loaded() || hasError()` ("done, stop the spinner"). **In a guard/resolver use `.idle()`** (= `!loading() && !loaded()`, covers NotLoaded AND Error) as the "should I (re)fetch?" predicate — `.notLoaded()` is false in the Error state, so a `.notLoaded()`-gated fetch silently never retries after an error. Prefer `.idle()` over `state() === …` enum comparisons.
104
+ - `asyncSource<T>(config)` — load-and-expose (preferred over `status()` + manual try/catch). Auto-derives `.loading()`, `.error()`, `.data` accessor, `.refresh()` reload.
105
+ - `asyncQuery<TInput, TResult>(config)` — input-driven debounced query with built-in switchMap + dedup pipeline. Drive via `.input.set(value)`; read current result on `.results` / `.data`; status on `.loading()` / `.error()`. Lifecycle: `.rerun()` re-fires with the current input (bypasses dedup), `.reset()` clears state. No `.refresh()` — that's on `asyncSource` only.
106
+ - **Streaming/LLM token output — no exported marker.** An `asyncStream` marker exists in-repo but is experimental and NOT exported from `@signaltree/core` as of 11.x — do not emit `asyncStream` or `createAsyncStreamSignal` (the import fails). `asyncSource`/`asyncQuery` REPLACE per emission (wrong tool for token deltas) — accumulate into a plain leaf instead: `tree.$.reply.update((t) => t + chunk)` inside a `for await` over the SDK's stream. There is no `@signaltree/ai` package — wire your AI SDK in directly. Don't model tokens as a pushed array.
107
+ - `stored(key, default)` — single signal backed by `localStorage`.
108
+ - `form<T>(config: FormConfig<T>)` — tree-integrated form marker, **exported from `@signaltree/core`** (`@signaltree/ng-forms` is a separate FormGroup bridge, not the marker source). Config requires `{ initial: T, validators?, asyncValidators?, wizard? }`. Read the value by calling the marker (`tree.$.profile()`) or via the v10.4 alias `tree.$.profile.data()` — both return `T`. Bare-name accessors: `.dirty`, `.valid`, `.touched`, `.submitting` (NOT `.pristine`, NOT `.isDirty()`); value accessors: `.errors`, `.errorList`. Methods: `.validate()`, `.validateField(field)`, `.touch(field)`, `.submit(handler)` (handles touchAll + validate + submitting toggle + error trap internally).
109
+
110
+ Audit trail — `createAuditTracker(tree, auditLog, config?)`, **exported from `@signaltree/core`** (v13; `@signaltree/ng-forms/audit` is now a `@deprecated` back-compat re-export — import from core). Framework-agnostic and tree-shakeable (only bundled if imported); not an Angular-forms concern, works on any `signalTree()`. Pushes an `AuditEntry<T>` (`{ timestamp, changes, previousValues?, metadata? }`) into your own `auditLog: AuditEntry<T>[]` array on every state change, and returns an unsubscribe `() => void`. Config: `{ includePreviousValues?, getMetadata?: () => AuditMetadata, filter?: (changes) => boolean, maxEntries? }` (`maxEntries` bounds the log via a ring-buffer trim; `0` = unlimited). Uses `tree.subscribe()` when the tree has one; core `signalTree()` doesn't, so it falls back to ~100ms polling automatically — no extra wiring needed either way. `createAuditCallback(auditLog, getMetadata?)` returns a bare `(previous, current) => void` diff callback for when you already have your own `tree.subscribe()` call site instead.
111
+
112
+ Full signatures: [`reference/core.md`](reference/core.md).
113
+
114
+ Deep dives:
115
+
116
+ - [`reference/core.md`](reference/core.md) — `signalTree()` signatures, `$` proxy, markers, enhancer composition, reads/writes.
117
+ - [`reference/patterns.md`](reference/patterns.md) — idiomatic creation, templates, `computed()`/`effect()` interop, bulk updates, `derivedFrom`, **hybrid migration via legacy facade adapters**, lifetime caveats for root-provided Ops.
118
+ - [`reference/testing.md`](reference/testing.md) — `provideAppTreeForTesting()` recipe, mocking matrix (tree vs ops vs facade), common test-bed pitfalls.
119
+ - [`reference/anti-patterns.md`](reference/anti-patterns.md) — what not to do.
120
+ - [`reference/install.md`](reference/install.md) — Angular version requirement, install commands.
121
+ - [`reference/migration-from-ngrx-signals.md`](reference/migration-from-ngrx-signals.md) — mechanical mapping guide when porting an existing `@ngrx/signals` codebase. Only relevant for `@ngrx/signals` (`signalStore`, `withState`, `rxMethod`) — not classic `@ngrx/store`.
122
+ - [`reference/migration-from-ngrx-store.md`](reference/migration-from-ngrx-store.md) — mechanical mapping guide when porting a **classic `@ngrx/store`** codebase (`createAction`/`createReducer`/`createSelector`/`createEffect`, `@ngrx/entity`, `StoreModule`/`provideStore`). Actions → `Ops` methods, reducers → signal writes, selectors → `computed()`/derived tiers, effects → `asyncSource`/`Ops` observables. Shares the target architecture with the `@ngrx/signals` guide.
123
+ - [`reference/optimal-implementation.md`](reference/optimal-implementation.md) — prescribed file/folder layout, pattern defaults (`entityMap`, multi-tier derived, enhancer baseline), and the migration definition-of-done checklist. **Read this before beginning any non-trivial migration.**
124
+ - [`reference/orchestrating-a-migration.md`](reference/orchestrating-a-migration.md) — process playbook for an orchestrator agent driving one or more implementer subagents through a phased SignalTree adoption. Applies to NgRx Signal Store migration (default), classic NgRx / `BehaviorSubject` / `@Injectable` state migration (with adapted Phase 1 greps), and greenfield adoption (Phase 1 is a no-op). Load when the work spans more than ~5 consumer files, when a single implementer is likely to exhaust its context window, or when the user asks for a phased / supervised rollout.
125
+
126
+ Sub-skills:
127
+
128
+ - [`ng-forms/SKILL.md`](ng-forms/SKILL.md)
129
+ - [`enterprise/SKILL.md`](enterprise/SKILL.md)
130
+ - [`callable-syntax/SKILL.md`](callable-syntax/SKILL.md)
131
+ - [`guardrails/SKILL.md`](guardrails/SKILL.md)
132
+ - [`events/SKILL.md`](events/SKILL.md)
133
+ - [`realtime/SKILL.md`](realtime/SKILL.md)
134
+
135
+ Operating rules:
136
+
137
+ - Prefer `@signaltree/core` first. Add optional packages only for concrete problems.
138
+ - Never instruct consumers to install `@signaltree/shared`, `@signaltree/types`, or `@signaltree/utils` — private, bundled at build time.
139
+ - Never write to a signal inside `computed()` — read-only; side effects in `effect()` or event handlers.
140
+ - Custom markers: call `registerMarkerProcessor()` before any `signalTree()` call that relies on them.
141
+ - Don't reintroduce Redux-style patterns.
142
+ - When unsure of an API, read `packages/<pkg>/src/` — repo is source of truth.
143
+ - **One tree per application.** If the codebase has multiple existing stores (ngrx, services, etc.), compose them all into a single `signalTree()` behind `APP_TREE`. Creating multiple `signalTree()` instances for app-wide state is always wrong.
144
+ - **A migration is not done until the test suite is green.** Build-green is necessary but not sufficient — the test suite must pass before declaring the migration complete. The most common failure is `NG0201: APP_TREE` in `TestBed`s; fix it via `provideAppTreeForTesting()` ([`reference/testing.md`](reference/testing.md)), not by mocking `AppStore`.
145
+ - **Big-bang migration is the default.** Migrate every domain, delete every legacy store, drop the legacy package from `package.json` in the same PR. The hybrid-facade pattern is a _fallback_ for when PR-size, team-coordination, or release-cadence constraints prevent big-bang — never an end-state. If you ship a hybrid facade, it must include a `// TODO(legacy-facade): remove by <date/release>` and a tracking issue. See [`reference/optimal-implementation.md`](reference/optimal-implementation.md) and [`reference/migration-from-ngrx-signals.md`](reference/migration-from-ngrx-signals.md).
146
+ - **Big-bang means deletion in the same commit.** Standing up the new `AppStore` next to the legacy `signalStore` files is _not_ a migration — it's a hybrid you forgot to finish. Before declaring done, the legacy `*.store.ts` files for every migrated domain must be `rm`-ed (along with their `*.store.spec.ts` siblings and any re-exports), and `grep -rln '@ngrx/signals' <migrated-app-src>/` must return empty. If the grep is non-empty and you are not on the explicit hybrid-fallback path, **stop and finish the deletion before continuing**.
147
+ - **Pattern defaults you must apply** unless the codebase explicitly forbids them:
148
+ - **`entityMap<T, K>()` for any keyed collection** (anything with `id`/key lookup, membership tests, or cross-references). Plain `T[]` is only correct for ordered, append-only, non-keyed lists.
149
+ - **Per-domain state files** (`tree/state/<domain>.state.ts`) once the tree has more than two domains. Don't keep all state in one `app-tree.ts`.
150
+ - **Multi-tier `.derived()` chains in named files** (`tree/derived/tier-<concern>.derived.ts`) once you have ≥ 3 derived concerns or any cross-tier dependency. See [`reference/patterns.md`](reference/patterns.md#splitting-derived-tiers-into-separate-files).
151
+ - **Enhancer baseline for production trees**: `devTools({ treeName }) + batching() + timeTravel()` minimum. Tests skip enhancers; production does not. (`memoization` was removed in 9.0.1 — use Angular `computed()`.)
152
+ - **Cross-domain orchestration belongs on `AppStore`**, not on any one Ops class. Single-domain methods belong on Ops; methods that touch ≥ 2 domains belong on `AppStore`.
153
+ - **Definition of done for a migration:** (1) zero imports of the legacy package in the migrated app, (2) legacy package removed from `package.json` (or, if other apps still use it, a tracking ticket exists for removal), (3) test suite green, (4) DevTools shows the new tree under the chosen `treeName`. The full checklist is in [`reference/optimal-implementation.md`](reference/optimal-implementation.md#definition-of-done).
@@ -0,0 +1,95 @@
1
+ ---
2
+ name: signaltree-enterprise
3
+ description: Guides AI agents applying @signaltree/enterprise to large SignalTree state for diff-based bulk updates, optimized partial writes, and path-index monitoring. Triggers on @signaltree/enterprise, updateOptimized, bulk updates, large state tree, diff engine, path index, getPathIndex, 500+ signals, enterprise enhancer.
4
+ ---
5
+
6
+ # Using @signaltree/enterprise
7
+
8
+ Use when the tree has 100s of signals and a single action replaces a large state slice (API hydration, tick-driven snapshots, subtree migrations). Diffs `Partial<T>` and only writes signals whose values changed — 2–5× faster than naive `tree.update()` for large collections. Skip for small/mostly-static trees.
9
+
10
+ **License: BSL-1.1** (not MIT). Surface this when user is building for production commercial deployment.
11
+
12
+ Install:
13
+
14
+ ```bash
15
+ npm install @signaltree/core @signaltree/enterprise
16
+ ```
17
+
18
+ Peer: `@angular/core ^20`, `@signaltree/core ^9`. No additional runtime deps.
19
+
20
+ Apply the enhancer:
21
+
22
+ ```ts
23
+ import { signalTree } from '@signaltree/core';
24
+ import { enterprise } from '@signaltree/enterprise';
25
+
26
+ const tree = signalTree(initial).with(enterprise());
27
+ ```
28
+
29
+ Bulk replace with `updateOptimized`:
30
+
31
+ ```ts
32
+ import { signalTree } from '@signaltree/core';
33
+ import { enterprise } from '@signaltree/enterprise';
34
+
35
+ interface State { entities: { id: number }[]; lastSyncAt: string }
36
+ declare const entities: State['entities'];
37
+ const tree = signalTree<State>({ entities: [], lastSyncAt: '' }).with(enterprise());
38
+
39
+ const result = tree.updateOptimized({
40
+ entities,
41
+ lastSyncAt: new Date().toISOString(),
42
+ });
43
+ console.log(`${result.changedPaths.length} paths changed in ${result.duration}ms`);
44
+ ```
45
+
46
+ `updateOptimized` takes `Partial<T>` (not an updater fn). Only diffed keys are touched; other branches are completely untouched.
47
+
48
+ Diff options:
49
+
50
+ ```ts
51
+ import { signalTree } from '@signaltree/core';
52
+ import { enterprise } from '@signaltree/enterprise';
53
+
54
+ interface State { entities: { id: number }[] }
55
+ declare const nextEntities: State['entities'];
56
+ const tree = signalTree<State>({ entities: [] }).with(enterprise());
57
+
58
+ tree.updateOptimized(
59
+ { entities: nextEntities },
60
+ {
61
+ ignoreArrayOrder: true, // treat arrays as unordered sets — only use when item identity is stable
62
+ maxDepth: 6, // stop diffing below this depth; replace whole subtree instead
63
+ equalityFn: (a, b) =>
64
+ a === b || (a instanceof Date && b instanceof Date && a.getTime() === b.getTime()),
65
+ autoBatch: true,
66
+ batchSize: 500, // chunk writes to keep microtasks short
67
+ }
68
+ );
69
+ ```
70
+
71
+ `UpdateResult` shape: `{ changed: boolean, duration: number (ms), changedPaths: string[], stats? }`.
72
+
73
+ `getPathIndex()` — returns `PathIndex` (dotted paths of every leaf) or `null` before first `updateOptimized` call:
74
+
75
+ ```ts
76
+ import { signalTree } from '@signaltree/core';
77
+ import { enterprise } from '@signaltree/enterprise';
78
+
79
+ const tree = signalTree({ count: 0 }).with(enterprise());
80
+ const idx = tree.getPathIndex();
81
+ if (idx) { /* use idx with result.changedPaths for heatmaps, etc. */ }
82
+ ```
83
+
84
+ Key contracts:
85
+ - `undefined` at a path = no change. Use `null` (or empty sentinel) to clear explicitly.
86
+ - `getPathIndex()` is `null` until first `updateOptimized` call. Not live between calls — rebuilt on next optimized write.
87
+ - Dynamic paths (added beyond initial shape) are not auto-indexed; force rebuild via `updateOptimized({})`.
88
+ - Keep using `tree.update(...)` for small targeted writes; `updateOptimized` for large `Partial<T>` replacements only.
89
+
90
+ Gotchas:
91
+ - `ignoreArrayOrder: true` treats arrays as sets — reorders silently missed if item identity isn't stable.
92
+ - `updateOptimized` returns `changed: false` when nothing differed — don't assume it always mutates.
93
+ - `maxDepth` stops recursion at that depth; subtree written as unit. `getPathIndex()` rebuilt after plain `update()` on next optimized write — consistent but not live.
94
+
95
+ Related: `using-signaltree` (root), `spec-auditing`, `compression`
@@ -0,0 +1,266 @@
1
+ # Anti-patterns
2
+
3
+ Things not to do with SignalTree, and what to do instead. Each item shows a wrong example and a right one.
4
+
5
+ > **Note for editors:** `scripts/lint-skills.mjs` type-checks every fenced
6
+ > `ts` / `typescript` / `tsx` block in this directory against the real built
7
+ > `@signaltree/*` packages. To mark a block as an intentional anti-pattern
8
+ > that must not be type-checked (syntax is still parsed), add a
9
+ > `// @skip-lint` comment as the first non-blank line of the block, or add
10
+ > `wrong` / `bad` / `skip` to the fence info string (e.g. ` ```ts wrong `).
11
+
12
+ ## Do not reintroduce actions / reducers / selectors
13
+
14
+ SignalTree replaces dispatch-plus-reducer pipelines with direct reactive JSON. If you're designing actions and reducers on top of it, you're fighting the design.
15
+
16
+ ```ts
17
+ // ✗ Wrong — Redux-style layer on top of SignalTree
18
+ type Action = { type: 'inc' } | { type: 'reset' };
19
+
20
+ function reduce(state: { count: number }, action: Action) {
21
+ switch (action.type) {
22
+ case 'inc':
23
+ return { ...state, count: state.count + 1 };
24
+ case 'reset':
25
+ return { ...state, count: 0 };
26
+ }
27
+ }
28
+ ```
29
+
30
+ ```ts
31
+ // ✓ Right — mutate the tree directly
32
+ import { signalTree } from '@signaltree/core';
33
+
34
+ const tree = signalTree({ count: 0 });
35
+
36
+ function inc() {
37
+ tree.$.count.update((n) => n + 1);
38
+ }
39
+ function reset() {
40
+ tree.$.count.set(0);
41
+ }
42
+ ```
43
+
44
+ If you want undo/redo, add `timeTravel()`. If you want DevTools, add `devTools()`. You don't need action types.
45
+
46
+ ## Do not write inside `computed()`
47
+
48
+ `computed()` must be a pure read. Writing to a signal inside it causes infinite re-computation loops and detached reactivity.
49
+
50
+ ```ts
51
+ // ✗ Wrong — writing inside computed()
52
+ import { computed } from '@angular/core';
53
+ import { signalTree } from '@signaltree/core';
54
+
55
+ const tree = signalTree({ count: 0, doubled: 0 });
56
+
57
+ const doubled = computed(() => {
58
+ const n = tree.$.count() * 2;
59
+ tree.$.doubled.set(n); // ← never do this
60
+ return n;
61
+ });
62
+ ```
63
+
64
+ ```ts
65
+ // ✓ Right — derive with computed(), or mirror with effect()
66
+ import { computed, effect } from '@angular/core';
67
+ import { signalTree } from '@signaltree/core';
68
+
69
+ const tree = signalTree({ count: 0 });
70
+
71
+ // pure derivation
72
+ const doubled = computed(() => tree.$.count() * 2);
73
+
74
+ // or, if a mirrored leaf is genuinely needed:
75
+ const mirror = signalTree({ doubled: 0 });
76
+ effect(() => mirror.$.doubled.set(tree.$.count() * 2));
77
+ ```
78
+
79
+ ## Do not install `@signaltree/shared`, `types`, or `utils`
80
+
81
+ These are **private, bundled packages** consumed internally by the public `@signaltree/*` packages. Adding them to `dependencies` leads to duplicate module graphs, version skew, and broken type inference.
82
+
83
+ ```jsonc
84
+ // ✗ Wrong — package.json dependencies
85
+ {
86
+ "dependencies": {
87
+ "@signaltree/core": "^9.0.0",
88
+ "@signaltree/shared": "^9.0.0", // ← don't
89
+ "@signaltree/types": "^9.0.0", // ← don't
90
+ "@signaltree/utils": "^9.0.0" // ← don't
91
+ }
92
+ }
93
+ ```
94
+
95
+ ```jsonc
96
+ // ✓ Right — only the public packages you actually use
97
+ {
98
+ "dependencies": {
99
+ "@signaltree/core": "^9.0.0",
100
+ "@signaltree/ng-forms": "^9.0.0"
101
+ }
102
+ }
103
+ ```
104
+
105
+ ## Do not register marker processors after tree creation
106
+
107
+ `registerMarkerProcessor()` must run **before** any `signalTree()` call that relies on the custom marker, otherwise the marker stays in the state object as an unprocessed placeholder.
108
+
109
+ ```ts wrong
110
+ // ✗ Wrong — registration happens too late.
111
+ // Intentional anti-pattern; skipped by the skill lint.
112
+ import { signalTree } from '@signaltree/core';
113
+ import { registerMarkerProcessor } from '@signaltree/core/authoring';
114
+ import { myMarker, MY_MARKER, myProcessor } from './my-marker';
115
+
116
+ const tree = signalTree({ slot: myMarker() }); // ← processor not registered yet
117
+ registerMarkerProcessor(MY_MARKER, myProcessor);
118
+ ```
119
+
120
+ ```ts
121
+ // ✓ Right — register first, then create trees
122
+ import { signalTree } from '@signaltree/core';
123
+ import { registerMarkerProcessor } from '@signaltree/core/authoring';
124
+ import { myMarker, MY_MARKER, myProcessor } from './my-marker';
125
+
126
+ registerMarkerProcessor(MY_MARKER, myProcessor);
127
+
128
+ const tree = signalTree({ slot: myMarker() });
129
+ ```
130
+
131
+ ## Do not hold stale node references across tree recreations
132
+
133
+ If you replace a subtree wholesale (`tree.$.user(newObj)`) or rebuild the tree itself, any previously captured node reference may no longer point at the live signal.
134
+
135
+ ```ts
136
+ // ✗ Wrong — capturing a branch accessor then replacing the subtree
137
+ import { signalTree } from '@signaltree/core';
138
+
139
+ const tree = signalTree({ user: { name: 'Ada' } });
140
+ const userNode = tree.$.user; // captured
141
+ tree.$.user({ name: 'Grace' }); // subtree replaced
142
+ // userNode may be stale now — re-read from tree.$ instead
143
+ ```
144
+
145
+ ```ts
146
+ // ✓ Right — re-read through tree.$ when you need the current node
147
+ import { signalTree } from '@signaltree/core';
148
+
149
+ const tree = signalTree({ user: { name: 'Ada' } });
150
+ tree.$.user({ name: 'Grace' });
151
+ const currentName = tree.$.user.name(); // always reads the live signal
152
+ ```
153
+
154
+ As a rule: pass the `tree` (or a narrow factory-exposed API) rather than long-lived raw node references.
155
+
156
+ ## Do not use branch replacement on subtrees that contain markers
157
+
158
+ The callable branch form `tree.$.domain(newObj)` replaces the entire subtree with
159
+ a plain object. Any `status()`, `entityMap()`, or custom marker nodes inside that
160
+ subtree are **overwritten** — the marker-backed methods (`setLoading`, `upsertOne`,
161
+ etc.) disappear and the plain value is written in their place.
162
+
163
+ ```ts wrong
164
+ import { signalTree, status, entityMap } from '@signaltree/core';
165
+
166
+ interface Item {
167
+ id: number;
168
+ }
169
+ const tree = signalTree({
170
+ items: { entities: entityMap<Item>(), loading: status() },
171
+ });
172
+
173
+ // ✗ Wrong — overwrites the marker nodes with plain values
174
+ tree.$.items({ entities: [], loading: { state: 'idle', error: null } });
175
+ // tree.$.items.loading.setLoading is now undefined
176
+ ```
177
+
178
+ ```ts
179
+ import { signalTree, status, entityMap } from '@signaltree/core';
180
+
181
+ interface Item {
182
+ id: number;
183
+ }
184
+ const tree = signalTree({
185
+ items: { entities: entityMap<Item>(), loading: status() },
186
+ });
187
+
188
+ // ✓ Right — update each writable leaf individually
189
+ tree.$.items.entities.clear();
190
+ tree.$.items.loading.setLoading();
191
+ ```
192
+
193
+ This applies to any subtree that contains markers — `status()`, `entityMap()`,
194
+ or custom markers registered via `registerMarkerProcessor`. As a practical rule:
195
+ if a domain slice has markers in it, reset it by calling its individual writable
196
+ leaves rather than replacing the whole branch.
197
+
198
+ ## Do not service-wrap a component-local tree
199
+
200
+ SignalTree trees can live on a component or in a plain factory function.
201
+ Wrapping a component-local tree in a `providedIn: 'root'` service adds ceremony
202
+ without reactivity benefit and turns a per-component counter into accidental
203
+ global state.
204
+
205
+ ```ts wrong
206
+ // ✗ Wrong — a root service for a component-local counter
207
+ import { Injectable } from '@angular/core';
208
+ import { signalTree } from '@signaltree/core';
209
+
210
+ @Injectable({ providedIn: 'root' })
211
+ class CounterService {
212
+ readonly tree = signalTree({ count: 0 });
213
+ }
214
+ ```
215
+
216
+ ```ts
217
+ // ✓ Right — component-local is fine
218
+ import { Component } from '@angular/core';
219
+ import { signalTree } from '@signaltree/core';
220
+
221
+ @Component({ selector: 'app-counter', template: '' })
222
+ class CounterComponent {
223
+ readonly tree = signalTree({ count: 0 });
224
+ }
225
+ ```
226
+
227
+ ## Do not leave the legacy `signalStore` on disk after migrating
228
+
229
+ This is the single most common failure mode when migrating from `@ngrx/signals`. An agent (or a hurried human) stands up the new `AppStore` + `APP_TREE` + per-domain `Ops`, migrates the consumers, and then **stops** — leaving `driver.store.ts`, `feature-flag.store.ts`, etc. on disk, still importing `@ngrx/signals`, still calling `signalStore({ providedIn: 'root' }, ...)`. That is not a migration. It is a half-finished hybrid that will rot: the legacy stores are still constructed at app boot, still register with DevTools, still hold state, and they silently diverge from the new tree.
230
+
231
+ **The legacy `*.store.ts` files for every migrated domain must be deleted in the same commit that adds the new `AppStore` shape.** This includes the `.store.spec.ts` sibling and any barrel re-exports that point at the deleted file.
232
+
233
+ ```ts skip
234
+ // ✗ Wrong — new AppStore committed alongside untouched legacy stores
235
+ // src/app/store/app-store.ts ← new
236
+ // src/app/store/tree/app-tree.ts ← new
237
+ // src/app/<legacy-path>/driver.store.ts ← still there, still signalStore(...)
238
+ // src/app/<legacy-path>/feature-flag.store.ts ← still there, still signalStore(...)
239
+ // src/app/<legacy-path>/plant.store.ts ← still there, still signalStore(...)
240
+ ```
241
+
242
+ ```bash
243
+ # ✓ Right — verify deletion before declaring done.
244
+ # All three must return empty (no output) inside the migrated app:
245
+ grep -rln "from '@ngrx/signals'" <app-src>/
246
+ grep -rln 'signalStore(' <app-src>/
247
+ find <app-src> -name '*.store.ts' -not -path '*/node_modules/*'
248
+ ```
249
+
250
+ If any of those greps returns output and you are not on the explicit hybrid-fallback path documented in [`optimal-implementation.md`](optimal-implementation.md#when-the-hybrid-pattern-is-acceptable), **stop and finish the deletion before committing**. Do not write the migration report, do not push, do not declare success.
251
+
252
+ The hybrid-facade pattern in [`patterns.md`](patterns.md#hybrid-migration-legacy-facade-adapters-fallback) is the _only_ exception, and it requires a `// TODO(legacy-facade): remove by <date/release>` plus a tracking issue.
253
+
254
+ ### When a shared service IS correct
255
+
256
+ A shared service is exactly the right shape when the tree is **genuinely**
257
+ shared across components, routes, or the whole app — for example, a single
258
+ application-wide store, an auth/identity store, or a store holding data
259
+ fetched once and reused. That case has its own canonical recipe (and is also
260
+ what migrations from `@ngrx/signals signalStore({ providedIn: 'root' })` map
261
+ onto); see the "Shared service (`providedIn: 'root'`)" and
262
+ "Prefer a single global store" sections in
263
+ [`patterns.md`](patterns.md).
264
+
265
+ The rule is: let scope drive the decision. Local state → component field.
266
+ Shared state → a service (or a single app-wide store).