@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 +2 -2
- package/skills/using-signaltree/SKILL.md +153 -0
- package/skills/using-signaltree/enterprise/SKILL.md +95 -0
- package/skills/using-signaltree/reference/anti-patterns.md +266 -0
- package/skills/using-signaltree/reference/core.md +398 -0
- package/skills/using-signaltree/reference/install.md +110 -0
- package/skills/using-signaltree/reference/migration-from-ngrx-signals.md +971 -0
- package/skills/using-signaltree/reference/migration-from-ngrx-store.md +396 -0
- package/skills/using-signaltree/reference/optimal-implementation.md +211 -0
- package/skills/using-signaltree/reference/orchestrating-a-migration.md +364 -0
- package/skills/using-signaltree/reference/patterns.md +1250 -0
- package/skills/using-signaltree/reference/testing.md +282 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: signaltree-migration-from-ngrx-store
|
|
3
|
+
description: One-to-one mapping guide for porting a classic @ngrx/store codebase (createAction/createReducer/createSelector/createEffect, @ngrx/entity, StoreModule/provideStore) to SignalTree. Load this when the user is converting code that uses the @ngrx/store package family — actions, reducers, selectors, effects, feature state, entity adapters. Do NOT load for @ngrx/signals (signalStore/withState/rxMethod) — that is a different package with its own guide (migration-from-ngrx-signals.md).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Migrating from classic `@ngrx/store` to SignalTree
|
|
7
|
+
|
|
8
|
+
Quick reference for converting an existing **classic Redux-style NgRx** codebase — `@ngrx/store`, `@ngrx/effects`, `@ngrx/entity`, `@ngrx/store-devtools`, `@ngrx/router-store`. Applies to the `createAction` / `createReducer` / `createSelector` / `createEffect` architecture. **Not** for `@ngrx/signals` (`signalStore`, `withState`, `rxMethod`) — use [`migration-from-ngrx-signals.md`](./migration-from-ngrx-signals.md) for that, and **not** for `@ngrx/component-store` (its per-component `ComponentStore<T>` maps to a component-local `signalTree()`, see the note at the end).
|
|
9
|
+
|
|
10
|
+
Read the root [`SKILL.md`](../SKILL.md), [`reference/optimal-implementation.md`](./optimal-implementation.md), and [`reference/patterns.md`](./patterns.md) for full SignalTree context; use this file for the mechanical mappings.
|
|
11
|
+
|
|
12
|
+
> **This guide owns the classic-NgRx-specific mappings only.** The _target architecture_ — one `signalTree()` behind `APP_TREE`, an `AppStore` facade, per-domain `Ops` classes, `entityMap`/`status`/`stored` markers, derived tiers, and the migration Definition of Done — is identical to the Signal Store migration. Rather than duplicate it, this file links into [`migration-from-ngrx-signals.md`](./migration-from-ngrx-signals.md) for every shared section (App shape audit, Goal-state patterns #1–5, testing, DoD). Read both.
|
|
13
|
+
|
|
14
|
+
> **Driving multiple subagents through this migration?** Classic NgRx codebases are usually _larger_ than Signal Store ones (separate action/reducer/selector/effect files per feature multiply the file count). If the migration touches ≥ 2 feature reducers or ≥ 10 consumer files, read [`orchestrating-a-migration.md`](./orchestrating-a-migration.md) — its five-phase survey → audit → foundation → consumers → gate playbook explicitly covers classic `@ngrx/store` (adapt the Phase 1 greps to `createReducer(` / `createEffect(`).
|
|
15
|
+
|
|
16
|
+
## The one conceptual shift that drives every mapping
|
|
17
|
+
|
|
18
|
+
Classic NgRx is a **message bus**: a component dispatches an _action_, one or more _reducers_ compute new state from it, _selectors_ read that state back, and _effects_ listen for actions to run I/O and dispatch _more_ actions. Four moving parts, wired indirectly through a global action stream.
|
|
19
|
+
|
|
20
|
+
SignalTree deletes the bus. **A state change is just a method call that writes signals.** There is no action object, no `dispatch`, no `ofType`, no action-stream round-trip.
|
|
21
|
+
|
|
22
|
+
| Classic NgRx concept | Does it survive? | Becomes |
|
|
23
|
+
| -------------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| Action (`createAction`) | ❌ deleted | The _method name_ on an `Ops` class. `loadTickets` isn't an object you dispatch — it's `store.ops.tickets.loadTickets$()`. |
|
|
25
|
+
| Reducer (`createReducer`/`on`) | ❌ deleted | The _body_ of that `Ops` method — the signal writes it performs. |
|
|
26
|
+
| Selector (`createSelector`) | ✅ transforms | An Angular `computed()` (component-local) or a `.derived()` tier (shared). |
|
|
27
|
+
| Effect (`createEffect`) | ✅ transforms | An `asyncSource`/`asyncQuery` marker, or an `Ops` method returning `Observable<void>`. |
|
|
28
|
+
| Action-stream round-trip (`load` → effect → `loadSuccess` → reducer) | ❌ deleted | Collapses into **one** method: the effect-equivalent writes the result straight to the tree. No success/failure actions. |
|
|
29
|
+
|
|
30
|
+
**The biggest single simplification:** the `load → loadSuccess → loadFailure` triad — one action to start, an effect to do the I/O, two more actions to report back, and reducer `on()` handlers for each — collapses into a single `Ops` method whose `tap()` writes the data and whose `catchError` writes the error. Three actions, one effect, and three reducer cases become **one method**. Expect the migrated code to be dramatically smaller; that is the point, not a mistake.
|
|
31
|
+
|
|
32
|
+
## Minimum viable migration (one small feature)
|
|
33
|
+
|
|
34
|
+
If the app has exactly one feature reducer with < 5 state fields and a handful of actions, skip every pattern below and do the direct swap:
|
|
35
|
+
|
|
36
|
+
```ts skip
|
|
37
|
+
// Before — counter.actions.ts + counter.reducer.ts + counter.selectors.ts
|
|
38
|
+
export const increment = createAction('[Counter] Increment');
|
|
39
|
+
export const setBy = createAction('[Counter] Set By', props<{ by: number }>());
|
|
40
|
+
export const counterReducer = createReducer(
|
|
41
|
+
{ count: 0 },
|
|
42
|
+
on(increment, (s) => ({ ...s, count: s.count + 1 })),
|
|
43
|
+
on(setBy, (s, { by }) => ({ ...s, count: s.count + by }))
|
|
44
|
+
);
|
|
45
|
+
export const selectCount = createSelector(selectCounter, (s) => s.count);
|
|
46
|
+
export const selectDouble = createSelector(selectCount, (c) => c * 2);
|
|
47
|
+
|
|
48
|
+
// After — @signaltree/core, one file
|
|
49
|
+
import { signalTree, defineStore } from '@signaltree/core';
|
|
50
|
+
import { computed } from '@angular/core';
|
|
51
|
+
|
|
52
|
+
export const CounterStore = defineStore(() => signalTree({ count: 0 }).derived(($) => ({ double: computed(() => $.count() * 2) })), { providedIn: 'root' });
|
|
53
|
+
// dispatch(increment()) → store.$.count.update((n) => n + 1)
|
|
54
|
+
// dispatch(setBy({ by: 5 })) → store.$.count.update((n) => n + 5)
|
|
55
|
+
// store.select(selectCount) → store.$.count()
|
|
56
|
+
// store.select(selectDouble) → store.$.double()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`inject(CounterStore)` resolves to the real tree; its `destroy()` is wired to the injector's `DestroyRef`. For anything larger than one feature, use the full `AppStore` + `Ops` shape below.
|
|
60
|
+
|
|
61
|
+
## Before you pick patterns: run the shared App shape audit
|
|
62
|
+
|
|
63
|
+
The goal-state architecture (entity collections, root `selected` slice, typed error siblings, derived tiers, cross-domain orchestration) is **identical** to the Signal Store migration and **conditional** on the same four questions. Do not re-derive it here — go run it:
|
|
64
|
+
|
|
65
|
+
➡️ **[App shape audit](./migration-from-ngrx-signals.md#app-shape-audit-run-before-picking-patterns)** and **[Goal-state architectural patterns #1–5](./migration-from-ngrx-signals.md#goal-state-architectural-patterns)**.
|
|
66
|
+
|
|
67
|
+
Then come back for the classic-NgRx mechanical mappings. One classic-NgRx-specific mapping into that audit: a `createEntityAdapter<T>()` feature is _always_ a collection domain (audit Q1 = collection) → Pattern #1 (`entityMap` + root `selected`) applies by default.
|
|
68
|
+
|
|
69
|
+
## Default: big-bang migration
|
|
70
|
+
|
|
71
|
+
Same default and rationale as the [Signal Store big-bang flow](./migration-from-ngrx-signals.md#default-big-bang-migration) — one PR: stand up the tree, migrate every consumer, delete every legacy file, drop the packages, verify. The classic-NgRx specifics for each step:
|
|
72
|
+
|
|
73
|
+
0. **Orient.** Same orientation questions (source root, owning `package.json`, build/test/lint commands, monorepo?). Additionally identify **which NgRx packages are in play** — `@ngrx/store` is mandatory; `@ngrx/effects`, `@ngrx/entity`, `@ngrx/store-devtools`, `@ngrx/router-store`, `@ngrx/component-store` are each optional and each removed separately in step 5.
|
|
74
|
+
|
|
75
|
+
1. **Discover every legacy file and consumer.** Classic NgRx has no single import anchor the way `signalStore(` is — the pieces live in separate files. Use this grep set:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
grep -rln "from '@ngrx/store'" <app-src>/ # actions, reducers, selectors, Store consumers
|
|
79
|
+
grep -rln "from '@ngrx/effects'" <app-src>/ # effect classes
|
|
80
|
+
grep -rln "from '@ngrx/entity'" <app-src>/ # entity adapters
|
|
81
|
+
grep -rln 'createReducer(' <app-src>/ # reducer files → delete
|
|
82
|
+
grep -rln 'createEffect(' <app-src>/ # effect files → delete
|
|
83
|
+
grep -rln 'createSelector(' <app-src>/ # selector files → delete or fold into derived
|
|
84
|
+
grep -rln 'createAction\|createActionGroup' <app-src>/ # action files → delete
|
|
85
|
+
grep -rln 'inject(Store)\|Store<' <app-src>/ # every component/guard/resolver that reads or dispatches
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The `inject(Store)` / `Store<…>` list is your **consumer** scope (rewrite these). The `createReducer` / `createEffect` / `createSelector` / `createAction` lists are the files you will **delete**.
|
|
89
|
+
|
|
90
|
+
2. Stand up the new shape — `AppStore` + `APP_TREE` + per-domain `Ops` + `app-tree.testing.ts` — exactly as in the [Signal Store guide](./migration-from-ngrx-signals.md#critical-one-tree-for-all-domains). **One tree for all features.** Each NgRx _feature slice_ (`StoreModule.forFeature('tickets', …)`) becomes a **domain slice** in the single tree, not its own tree.
|
|
91
|
+
|
|
92
|
+
3. Migrate **every** `inject(Store)` consumer to `inject(AppStore)` (mapping table below).
|
|
93
|
+
|
|
94
|
+
4. **`rm` every action / reducer / selector / effect file in the same commit**, plus their `*.spec.ts` siblings and any barrel/index that re-exports them. A feature typically has `foo.actions.ts`, `foo.reducer.ts`, `foo.selectors.ts`, `foo.effects.ts`, `foo.state.ts`, and an `index.ts` — all go.
|
|
95
|
+
|
|
96
|
+
5. Remove the NgRx packages from `package.json` — `@ngrx/store`, and whichever of `@ngrx/effects`, `@ngrx/entity`, `@ngrx/store-devtools`, `@ngrx/router-store` were in use. Update the lockfile.
|
|
97
|
+
|
|
98
|
+
6. **Verify** with [`scripts/verify-signaltree-migration.sh`](../../../../scripts/verify-signaltree-migration.sh), passing each NgRx package via repeatable `--package` flags (the script defaults to `@ngrx/signals`, which is the _wrong_ package here — you must override):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
scripts/verify-signaltree-migration.sh \
|
|
102
|
+
--src <app-src> \
|
|
103
|
+
--build "<your build command>" \
|
|
104
|
+
--test "<your test command>" \
|
|
105
|
+
--lint "<your lint command>" \
|
|
106
|
+
--package-json <path-to-package.json> \
|
|
107
|
+
--package @ngrx/store \
|
|
108
|
+
--package @ngrx/effects \
|
|
109
|
+
--package @ngrx/entity \
|
|
110
|
+
--package @ngrx/store-devtools
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The script's source-import grep and `package.json` parse both key off the `--package` values, so listing every NgRx package you're removing makes the gate assert they're all gone. Non-zero exit means not done — do not commit, do not declare success. The `--allow-source-presence` / `--allow-dep-presence` flags work the same as in the Signal Store guide for [incremental](#incremental-per-feature-migration) and monorepo cases.
|
|
114
|
+
|
|
115
|
+
**If the script is unavailable**, the manual equivalent:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
grep -rln "from '@ngrx/store'" <app-src>/ # must be empty
|
|
119
|
+
grep -rln "from '@ngrx/effects'" <app-src>/ # must be empty
|
|
120
|
+
grep -rln 'createReducer(\|createEffect(\|createSelector(' <app-src>/ # must be empty
|
|
121
|
+
node -e "const p=require('./package.json');
|
|
122
|
+
['dependencies','peerDependencies'].forEach(k =>
|
|
123
|
+
['@ngrx/store','@ngrx/effects','@ngrx/entity','@ngrx/store-devtools'].forEach(pkg =>
|
|
124
|
+
console.assert(!p[k]?.[pkg], pkg+' still in '+k)));"
|
|
125
|
+
# then your build / test / lint commands
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Concept map
|
|
129
|
+
|
|
130
|
+
The headline reference. Left column is what you'll `grep` for; right column is what it becomes.
|
|
131
|
+
|
|
132
|
+
### Actions & dispatch
|
|
133
|
+
|
|
134
|
+
| classic `@ngrx/store` | SignalTree equivalent |
|
|
135
|
+
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
136
|
+
| `createAction('[X] Foo')` | Deleted. Becomes a **method name** on an `Ops` class. |
|
|
137
|
+
| `createAction('[X] Foo', props<{ id: number }>())` | Deleted. Becomes a **method parameter**: `fooOps.foo(id: number)`. |
|
|
138
|
+
| `createActionGroup({ source, events })` | Deleted. Each event becomes one method on the feature's `Ops` class. |
|
|
139
|
+
| `store.dispatch(foo())` | `store.ops.<domain>.foo()` |
|
|
140
|
+
| `store.dispatch(foo({ id }))` | `store.ops.<domain>.foo(id)` |
|
|
141
|
+
| One action handled by **multiple** reducers (fan-out) | **One** method that performs all the writes — on the domain `Ops` if same-domain, or an [orchestration `Ops`/`AppStore` method](./migration-from-ngrx-signals.md#3-cross-domain-orchestration--dedicated-purposeops-not-domain-ops) if it spans domains. |
|
|
142
|
+
| `load` → effect → `loadSuccess` → reducer round-trip | **One** `Ops` method (or `asyncSource` marker) — the I/O and the write live together; no success/failure actions. |
|
|
143
|
+
|
|
144
|
+
### Reducers → writes
|
|
145
|
+
|
|
146
|
+
| classic `@ngrx/store` | SignalTree equivalent |
|
|
147
|
+
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
148
|
+
| `createReducer(initialState, on(...) …)` | The `initialState` object → a domain **state factory** (`tree/state/<domain>.state.ts`); the `on()` handlers → the write logic inside `Ops` methods. |
|
|
149
|
+
| `on(setName, (s, { name }) => ({ ...s, name }))` | `this._$.name.set(name)` (single leaf) |
|
|
150
|
+
| `on(patch, (s, { a, b }) => ({ ...s, a, b }))` | `this._$.domain({ a, b })` — branch call = deep-merge partial write |
|
|
151
|
+
| `on(reset, () => initialState)` | `this._$.domain(() => initialDomainState())` — call the branch with an updater returning fresh state |
|
|
152
|
+
| Nested immutable spread `{ ...s, nested: { ...s.nested, x } }` | `this._$.domain.nested({ x })` — path-targeted partial write, no manual spreading |
|
|
153
|
+
| `ActionReducerMap` / `combineReducers` | The single `createBaseState()` object composing every domain factory. |
|
|
154
|
+
| `MetaReducer` (localStorage sync) | `stored(key, default)` markers (see [Pattern #4](./migration-from-ngrx-signals.md#4-persisted-state--stored-slices-not-constructor-localstorage-reads)) or the `persistence()` enhancer. |
|
|
155
|
+
| `MetaReducer` (logging / devtools) | `devTools({ treeName })` enhancer. |
|
|
156
|
+
|
|
157
|
+
### Selectors → reads & derived
|
|
158
|
+
|
|
159
|
+
| classic `@ngrx/store` | SignalTree equivalent |
|
|
160
|
+
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
161
|
+
| `createFeatureSelector<FooState>('foo')` | The domain slice itself: `tree.$.foo`. No registration. |
|
|
162
|
+
| `createSelector(selectFoo, (s) => s.bar)` | `tree.$.foo.bar()` — read the signal directly. |
|
|
163
|
+
| `createSelector(selectA, selectB, (a, b) => …)` (projector) | The projector body becomes a `computed()`; if reused across ≥ 2 consumers, a [`.derived()` tier](./migration-from-ngrx-signals.md#2-materialize-derivations-inside-the-tree-with-externalderived--derived); if consumer-local, a component `computed()`. |
|
|
164
|
+
| Selector composed of other selectors | A **derived tier** that reads an earlier tier (`$.<earlierTier>.…`). Tiering rule and ladder are in [Pattern #2](./migration-from-ngrx-signals.md#2-materialize-derivations-inside-the-tree-with-externalderived--derived). |
|
|
165
|
+
| `store.select(selectFoo)` (returns `Observable`) | `tree.$.foo` is a `Signal` — read `tree.$.foo()` in templates/`computed()`. Need an `Observable`? `toObservable(tree.$.foo)`. |
|
|
166
|
+
| `store.selectSignal(selectFoo)` (NgRx 16+) | `tree.$.foo` — already a signal; drop the wrapper. |
|
|
167
|
+
| Selector memoization (automatic in NgRx) | Angular `computed()` already memoizes by reference — no `memoization` enhancer (removed in 9.0.1). |
|
|
168
|
+
| Parameterized selector `(id) => createSelector(...)` | A method on the entity map — `tree.$.items.byId(id)` — or a `computed()` closing over a param signal. |
|
|
169
|
+
|
|
170
|
+
### Effects → async
|
|
171
|
+
|
|
172
|
+
| classic `@ngrx/store` | SignalTree equivalent |
|
|
173
|
+
| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
174
|
+
| `createEffect(() => actions$.pipe(ofType(load), switchMap(() => api.load$().pipe(map(loadSuccess), catchError(() => of(loadFailure))))))` | **Preferred:** an [`asyncSource`](./migration-from-ngrx-signals.md#option-a--asyncsource--asyncquery-markers-canonical-preferred) marker at the path the data lives at. **Fallback:** an `Ops` method returning `Observable<void>` whose `tap()` writes the result and `catchError` writes the error. |
|
|
175
|
+
| Debounced search effect (`debounceTime` + `switchMap`) | `asyncQuery<Input, Result>({ debounce, query })` — drive via `.input.set(q)`. |
|
|
176
|
+
| `ofType(a, b, c)` (one effect, many triggers) | Not needed — each trigger is already a distinct method; factor shared logic into a private helper. |
|
|
177
|
+
| Non-dispatching effect (`{ dispatch: false }` — e.g. navigate, toast) | A plain `void` `Ops` method, or an `effect()` in the `Ops` constructor reacting to a leaf (see [`withFeature` mapping](./migration-from-ngrx-signals.md#custom-feature-patterns-signalstorefeature)). |
|
|
178
|
+
| `this.actions$` (the action stream) | Deleted — there is no action stream. |
|
|
179
|
+
| `@Effect()` decorator (legacy pre-8 syntax) | Same as `createEffect` — becomes an `asyncSource` marker or `Ops` method. |
|
|
180
|
+
| `EffectsModule.forRoot([])` / `provideEffects()` | Deleted — `Ops` are plain `@Injectable` services wired through `AppStore`. |
|
|
181
|
+
|
|
182
|
+
### `@ngrx/entity`
|
|
183
|
+
|
|
184
|
+
`createEntityAdapter` maps almost one-to-one onto the `entityMap<T, K>()` marker. Drop `EntityState`, `EntityAdapter`, and `adapter.getSelectors()` entirely.
|
|
185
|
+
|
|
186
|
+
| `@ngrx/entity` | SignalTree `entityMap<T, K>()` |
|
|
187
|
+
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
188
|
+
| `createEntityAdapter<Foo>({ selectId })` | `entityMap<Foo, K>({ selectId })` in the domain state factory |
|
|
189
|
+
| `createEntityAdapter({ sortComparer })` | `entityMap<Foo, K>({ sortComparer })` (v10.5+ — keeps `all()`/`ids()` sorted) |
|
|
190
|
+
| `EntityState<Foo>` (`{ ids, entities }`) | Just the `entityMap` slot — no explicit shape type needed |
|
|
191
|
+
| `adapter.getInitialState({ loading: false })` | `{ items: entityMap<Foo, K>(), itemsLoad: status<string>() }` |
|
|
192
|
+
| `adapter.addOne(e, s)` | `tree.$.items.addOne(e)` |
|
|
193
|
+
| `adapter.setAll(es, s)` | `tree.$.items.setAll(es)` |
|
|
194
|
+
| `adapter.upsertOne(e, s)` / `upsertMany` | `tree.$.items.upsertOne(e)` |
|
|
195
|
+
| `adapter.updateOne({ id, changes }, s)` | `tree.$.items.updateOne(id, changes)` |
|
|
196
|
+
| `adapter.removeOne(id, s)` | `tree.$.items.removeOne(id)` |
|
|
197
|
+
| `adapter.removeAll(s)` | `tree.$.items.clear()` |
|
|
198
|
+
| `selectAll` (from `getSelectors()`) | `tree.$.items.all()` |
|
|
199
|
+
| `selectEntities` (the `Record`/dictionary) | `tree.$.items.map()` → `Signal<ReadonlyMap<K, T>>`; `entities[id]` → `map().get(id)` |
|
|
200
|
+
| `selectIds` | `tree.$.items.ids()` |
|
|
201
|
+
| `selectTotal` | `tree.$.items.count()` |
|
|
202
|
+
| `selectEntities()[id]` (single lookup) | `tree.$.items.byId(id)?.()` — `byId(id)` returns a callable `EntityNode<T> \| undefined`; invoke to read |
|
|
203
|
+
| "current entity" pattern (`currentId` + selector join) | [Pattern #1](./migration-from-ngrx-signals.md#1-currentx-xdto-ngrx-signal--entitymap--root-selectedid--derived-current): root `selected.<id>` scalar + a derived `current` |
|
|
204
|
+
|
|
205
|
+
### Module & provider wiring
|
|
206
|
+
|
|
207
|
+
| classic `@ngrx/store` bootstrap | SignalTree |
|
|
208
|
+
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
209
|
+
| `StoreModule.forRoot(reducers)` / `provideStore(reducers)` | `provideAppTree()` in `app.config.ts` |
|
|
210
|
+
| `StoreModule.forFeature('foo', fooReducer)` / `provideState(fooFeature)` | Deleted — the `foo` slice is already in the single tree |
|
|
211
|
+
| `EffectsModule.forRoot([...])` / `provideEffects(...)` | Deleted — `Ops` services need no registration |
|
|
212
|
+
| `StoreDevtoolsModule.instrument()` / `provideStoreDevtools()` | `.with(devTools({ treeName: 'AppStore' }))` on the tree |
|
|
213
|
+
| `@ngrx/router-store` (`provideRouterStore`, router selectors) | **Not replaced by SignalTree** — keep it, or read router state via `inject(ActivatedRoute)` / `Router` events and mirror what you need into a tree slice. Decide per app; don't silently drop router state. |
|
|
214
|
+
|
|
215
|
+
## Worked example: a full feature (actions + reducer + selectors + effects + entity)
|
|
216
|
+
|
|
217
|
+
This is the canonical classic-NgRx feature — the exact shape that motivates the migration. Before: **five files**. After: **two** (`state` + `ops`), plus a derived tier if the `current` join is reused.
|
|
218
|
+
|
|
219
|
+
**Before — `tickets/` (NgRx):**
|
|
220
|
+
|
|
221
|
+
```ts skip
|
|
222
|
+
// tickets.actions.ts
|
|
223
|
+
export const loadTickets = createAction('[Tickets] Load');
|
|
224
|
+
export const loadTicketsSuccess = createAction('[Tickets] Load Success', props<{ tickets: Ticket[] }>());
|
|
225
|
+
export const loadTicketsFailure = createAction('[Tickets] Load Failure', props<{ error: string }>());
|
|
226
|
+
export const selectTicket = createAction('[Tickets] Select', props<{ id: number }>());
|
|
227
|
+
export const createTicket = createAction('[Tickets] Create', props<{ ticket: Ticket }>());
|
|
228
|
+
|
|
229
|
+
// tickets.reducer.ts
|
|
230
|
+
export const adapter = createEntityAdapter<Ticket>();
|
|
231
|
+
export interface TicketsState extends EntityState<Ticket> {
|
|
232
|
+
selectedId: number | null;
|
|
233
|
+
loading: boolean;
|
|
234
|
+
error: string | null;
|
|
235
|
+
}
|
|
236
|
+
const initialState = adapter.getInitialState({ selectedId: null, loading: false, error: null });
|
|
237
|
+
export const ticketsReducer = createReducer(
|
|
238
|
+
initialState,
|
|
239
|
+
on(loadTickets, (s) => ({ ...s, loading: true, error: null })),
|
|
240
|
+
on(loadTicketsSuccess, (s, { tickets }) => adapter.setAll(tickets, { ...s, loading: false })),
|
|
241
|
+
on(loadTicketsFailure, (s, { error }) => ({ ...s, loading: false, error })),
|
|
242
|
+
on(selectTicket, (s, { id }) => ({ ...s, selectedId: id })),
|
|
243
|
+
on(createTicket, (s, { ticket }) => adapter.addOne(ticket, s))
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// tickets.selectors.ts
|
|
247
|
+
export const selectTicketsState = createFeatureSelector<TicketsState>('tickets');
|
|
248
|
+
const { selectAll, selectEntities } = adapter.getSelectors();
|
|
249
|
+
export const selectAllTickets = createSelector(selectTicketsState, selectAll);
|
|
250
|
+
export const selectTicketsLoading = createSelector(selectTicketsState, (s) => s.loading);
|
|
251
|
+
export const selectSelectedId = createSelector(selectTicketsState, (s) => s.selectedId);
|
|
252
|
+
export const selectCurrentTicket = createSelector(selectTicketsState, selectEntities, selectSelectedId, (_, entities, id) => (id != null ? entities[id] ?? null : null));
|
|
253
|
+
|
|
254
|
+
// tickets.effects.ts
|
|
255
|
+
@Injectable()
|
|
256
|
+
export class TicketsEffects {
|
|
257
|
+
private actions$ = inject(Actions);
|
|
258
|
+
private api = inject(TicketApi);
|
|
259
|
+
load$ = createEffect(() =>
|
|
260
|
+
this.actions$.pipe(
|
|
261
|
+
ofType(loadTickets),
|
|
262
|
+
switchMap(() =>
|
|
263
|
+
this.api.getAll().pipe(
|
|
264
|
+
map((tickets) => loadTicketsSuccess({ tickets })),
|
|
265
|
+
catchError((e) => of(loadTicketsFailure({ error: String(e) })))
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// component
|
|
273
|
+
this.tickets$ = this.store.select(selectAllTickets);
|
|
274
|
+
this.current$ = this.store.select(selectCurrentTicket);
|
|
275
|
+
this.store.dispatch(loadTickets());
|
|
276
|
+
this.store.dispatch(selectTicket({ id }));
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**After — SignalTree (`store/` foundation shared across all domains):**
|
|
280
|
+
|
|
281
|
+
```ts skip
|
|
282
|
+
// store/tree/state/tickets.state.ts — reducer initialState + entity adapter collapse here
|
|
283
|
+
import { entityMap, status } from '@signaltree/core';
|
|
284
|
+
|
|
285
|
+
export function ticketsState() {
|
|
286
|
+
return {
|
|
287
|
+
tickets: entityMap<Ticket, number>(), // ← createEntityAdapter<Ticket>()
|
|
288
|
+
ticketsLoad: status<string>(), // ← loading + error flags
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// store/tree/state/selection.state.ts — root selection (Pattern #1); selectedId is cross-domain
|
|
293
|
+
export function selectionState() {
|
|
294
|
+
return { ticketId: null as number | null }; // ← on(selectTicket) target
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// store/tree/derived/tier-entity-resolution.derived.ts — selectCurrentTicket becomes a derived tier
|
|
298
|
+
import { computed } from '@angular/core';
|
|
299
|
+
import { derivedFrom } from '@signaltree/core';
|
|
300
|
+
import type { AppTreeBase } from '../app-tree';
|
|
301
|
+
|
|
302
|
+
export const entityResolutionDerived = derivedFrom<AppTreeBase>()(($) => ({
|
|
303
|
+
tickets: {
|
|
304
|
+
current: computed(() => {
|
|
305
|
+
const id = $.selected.ticketId();
|
|
306
|
+
return id != null ? $.tickets.tickets.byId(id)?.() ?? null : null;
|
|
307
|
+
}),
|
|
308
|
+
},
|
|
309
|
+
}));
|
|
310
|
+
|
|
311
|
+
// store/ops/tickets.ops.ts — actions + effects + reducer writes ALL collapse into methods
|
|
312
|
+
import { inject, Injectable } from '@angular/core';
|
|
313
|
+
import { catchError, EMPTY, map, tap, type Observable } from 'rxjs';
|
|
314
|
+
import { APP_TREE } from '../tree/app-tree';
|
|
315
|
+
|
|
316
|
+
@Injectable({ providedIn: 'root' })
|
|
317
|
+
export class TicketsOps {
|
|
318
|
+
private readonly _$ = inject(APP_TREE).$.tickets;
|
|
319
|
+
private readonly _selected = inject(APP_TREE).$.selected;
|
|
320
|
+
private readonly _api = inject(TicketApi);
|
|
321
|
+
|
|
322
|
+
// loadTickets + loadTicketsSuccess + loadTicketsFailure + the effect → ONE method
|
|
323
|
+
loadTickets$(): Observable<void> {
|
|
324
|
+
this._$.ticketsLoad.setLoading();
|
|
325
|
+
return this._api.getAll().pipe(
|
|
326
|
+
tap((tickets) => {
|
|
327
|
+
this._$.tickets.setAll(tickets);
|
|
328
|
+
this._$.ticketsLoad.setLoaded();
|
|
329
|
+
}),
|
|
330
|
+
map(() => void 0),
|
|
331
|
+
catchError((e: unknown) => {
|
|
332
|
+
this._$.ticketsLoad.setError(String(e));
|
|
333
|
+
return EMPTY;
|
|
334
|
+
})
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
selectTicket(id: number): void { this._selected.ticketId.set(id); } // ← on(selectTicket)
|
|
339
|
+
createTicket(ticket: Ticket): void { this._$.tickets.addOne(ticket); } // ← on(createTicket)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// component — inject(AppStore), reads via $, writes via ops
|
|
343
|
+
private readonly _store = inject(AppStore);
|
|
344
|
+
readonly tickets = this._store.$.tickets.tickets.all; // selectAllTickets — a Signal
|
|
345
|
+
readonly current = this._store.$.tickets.current; // selectCurrentTicket — the derived tier
|
|
346
|
+
// ngOnInit / resolver:
|
|
347
|
+
this._store.ops.tickets.loadTickets$().subscribe(); // dispatch(loadTickets())
|
|
348
|
+
onSelect(id: number) { this._store.ops.tickets.selectTicket(id); } // dispatch(selectTicket({ id }))
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Even leaner: `loadTickets$` is a textbook [`asyncSource`](./migration-from-ngrx-signals.md#option-a--asyncsource--asyncquery-markers-canonical-preferred) — `tickets: asyncSource<Ticket[]>({ initial: [], load: () => api.getAll() })` — which erases the manual `setLoading`/`tap`/`catchError` wiring entirely. Reach for the `Ops` `Observable` form only when the caller needs explicit subscription control or multi-step orchestration.
|
|
352
|
+
|
|
353
|
+
Five files (actions, reducer, selectors, effects, + the feature `index.ts`) → two, and the three-action load triad → one method. That reduction _is_ the migration's value; don't recreate the ceremony you just deleted.
|
|
354
|
+
|
|
355
|
+
## Incremental per-feature migration
|
|
356
|
+
|
|
357
|
+
Same shape as the Signal Store [incremental per-domain playbook](./migration-from-ngrx-signals.md#incremental-per-domain-migration) — one feature slice per PR, foundation-first Phase 0, `--allow-source-presence --allow-dep-presence` on the verifier until the last feature lands. The one classic-NgRx wrinkle: **`@ngrx/store`'s root `StoreModule.forRoot`/`provideStore` must stay wired until the last feature reducer is gone**, because feature reducers register against it. Remove `provideStore`/`provideEffects` and the packages only in the final PR, alongside dropping the `--allow-*` flags and widening `--src` to the full app.
|
|
358
|
+
|
|
359
|
+
A still-on-NgRx feature and a migrated SignalTree domain coexist fine: a component mid-transition can `inject(Store)` for the unmigrated slice and `inject(AppStore)` for the migrated one. Clean that up in the feature's own PR.
|
|
360
|
+
|
|
361
|
+
## Gotchas specific to classic NgRx
|
|
362
|
+
|
|
363
|
+
- **Don't recreate actions as an enum or a discriminated union.** The instinct to "keep the action names" as a `type` or a command object is the Redux habit fighting the design. The method name _is_ the action. If you genuinely need an audit log of state changes, that's what `devTools()` / `timeTravel()` give you — not a hand-rolled action bus.
|
|
364
|
+
- **One action, many reducers → decide domain ownership.** A classic action can be handled by reducers in several features (e.g. a `logout` action clearing five slices). That's a [cross-domain orchestration method](./migration-from-ngrx-signals.md#3-cross-domain-orchestration--dedicated-purposeops-not-domain-ops) — a `SessionOps.logout()` that calls each domain's clear — **not** five `Ops` each listening for something. There's nothing to listen to.
|
|
365
|
+
- **Effect that dispatches into another feature.** An effect whose success action is handled by a _different_ feature's reducer becomes a single method that writes both slices (or calls the other domain's `Ops`). The cross-feature indirection disappears.
|
|
366
|
+
- **`concatLatestFrom` / `withLatestFrom(store.select(...))` in effects** — the effect read some state to do its work. In the `Ops` method, just read the signal synchronously: `const id = this._selected.ticketId();`. No `withLatestFrom`.
|
|
367
|
+
- **Selector projector with runtime args** (`selectFooById(id)`) — becomes `entityMap.byId(id)` for entity lookups, or a factory `computed` closing over an input signal for computed joins. Don't rebuild the memoized-selector-factory pattern.
|
|
368
|
+
- **`Store` is generic over the whole app state** (`Store<AppState>`). Consumers that typed against `AppState` and reached into arbitrary slices should now go through `AppStore.$.<domain>` — narrower and typed per slice. Grep for `Store<` after migrating to catch leftover typings.
|
|
369
|
+
- **RxJS effects that never dispatch** (`{ dispatch: false }`) still need a home — a `void` `Ops` method the consumer calls, or an `effect()` in the `Ops` constructor if it should fire reactively on a state change. Don't drop them; map each one.
|
|
370
|
+
- **`@ngrx/router-store`** is out of SignalTree's scope — it's router state, not app state. Keep it or bridge it deliberately; the verifier won't flag it since you won't list it in `--package`.
|
|
371
|
+
|
|
372
|
+
## `@ngrx/component-store` note (different package)
|
|
373
|
+
|
|
374
|
+
`@ngrx/component-store` (`ComponentStore<T>`, `this.select`, `this.updater`, `this.effect`) is neither classic `@ngrx/store` nor `@ngrx/signals`. It's per-component local state — the SignalTree equivalent is a **component-local `signalTree()`** (the one documented exception to the one-tree rule; see [`SKILL.md`](../SKILL.md)), not the `AppStore`/`APP_TREE` foundation. `this.select` → `computed()`, `this.updater` → a component method calling `.set()`/`.update()`, `this.effect` → an `asyncSource`/`asyncQuery` marker or a component method returning `Observable<void>`. If a `ComponentStore` has outgrown its component and become app-wide, fold it into `APP_TREE` as a domain slice instead.
|
|
375
|
+
|
|
376
|
+
## Testing
|
|
377
|
+
|
|
378
|
+
Identical to the Signal Store migration — every `TestBed` that touches `AppStore` needs `provideAppTreeForTesting()`, and specs that seeded NgRx state must seed the tree directly. See [Test bed must provide `APP_TREE`](./migration-from-ngrx-signals.md#test-bed-must-provide-app_tree) and [`reference/testing.md`](./testing.md).
|
|
379
|
+
|
|
380
|
+
Classic-NgRx-specific spec rewrites:
|
|
381
|
+
|
|
382
|
+
- **`provideMockStore({ initialState })`** → `provideAppTreeForTesting((s) => ({ ...s, ...seed }))`.
|
|
383
|
+
- **`store.overrideSelector(selectFoo, value)`** → seed the underlying tree state so the derived value computes, or (rarely) mock the `Ops`; do **not** mock the derived signal. Seed the source, read the real derivation.
|
|
384
|
+
- **`provideMockActions(actions$)` + effect marble tests** → gone. Test the `Ops` method directly: call it, subscribe, assert the tree state changed and `status()` transitioned. No `Actions` stream to mock.
|
|
385
|
+
- **Dispatching in a test** (`store.dispatch(foo())` then asserting selector output) → call `store.ops.<domain>.foo()`, then read `store.$.<domain>.<field>()`.
|
|
386
|
+
|
|
387
|
+
## Definition of done
|
|
388
|
+
|
|
389
|
+
Same gates as the [Signal Store DoD](./migration-from-ngrx-signals.md#definition-of-done) and the [architectural self-check](./migration-from-ngrx-signals.md#architectural-self-check-run-on-your-own-diff-before-declaring-done), with the package list adjusted:
|
|
390
|
+
|
|
391
|
+
- `grep -rln "from '@ngrx/store'" <app-src>/` returns nothing (repeat for `@ngrx/effects`, `@ngrx/entity` if used).
|
|
392
|
+
- `grep -rln 'createReducer(\|createEffect(\|createSelector(\|createAction' <app-src>/` returns nothing.
|
|
393
|
+
- Every NgRx package you set out to remove is gone from `package.json` (or tracked for removal if a shared lib elsewhere still uses it). `@ngrx/router-store`, if intentionally kept, is documented as a deliberate exception.
|
|
394
|
+
- Test suite green; lint clean; DevTools shows the tree under its `treeName`.
|
|
395
|
+
|
|
396
|
+
Run the [architectural self-check](./migration-from-ngrx-signals.md#architectural-self-check-run-on-your-own-diff-before-declaring-done) against your diff — selection placement, load+error siblings, derived tiers, orchestration boundary, `AppStore` injection style. Green build is necessary, not sufficient.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Optimal SignalTree implementation
|
|
2
|
+
|
|
3
|
+
The prescribed shape for any non-trivial SignalTree migration or new-app setup. Two independent reference migrations of the same codebase converged on this layout — treat it as the default, deviate only with a written reason.
|
|
4
|
+
|
|
5
|
+
> **Read this before** starting a migration or building a new app's store. The other reference docs explain individual primitives; this one tells you how they fit together at scale.
|
|
6
|
+
|
|
7
|
+
## File / folder layout
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
src/app/store/
|
|
11
|
+
├── app-store.ts # @Injectable({providedIn:'root'}) facade — exposes $ + ops
|
|
12
|
+
├── index.ts # barrel: re-exports AppStore, APP_TREE, AppTree, createBaseState
|
|
13
|
+
├── tree/
|
|
14
|
+
│ ├── app-tree.ts # APP_TREE token, createBaseState(), createAppTree(initial?)
|
|
15
|
+
│ ├── app-tree.testing.ts # provideAppTreeForTesting() — co-located, see testing.md
|
|
16
|
+
│ ├── state/
|
|
17
|
+
│ │ ├── index.ts
|
|
18
|
+
│ │ ├── tickets.state.ts # one factory per domain
|
|
19
|
+
│ │ ├── trucks.state.ts
|
|
20
|
+
│ │ └── … # one file per domain once you have >2 domains
|
|
21
|
+
│ └── derived/
|
|
22
|
+
│ ├── index.ts
|
|
23
|
+
│ ├── tier-entity-resolution.derived.ts
|
|
24
|
+
│ ├── tier-business-logic.derived.ts
|
|
25
|
+
│ └── … # named tier files, see patterns.md
|
|
26
|
+
└── ops/
|
|
27
|
+
├── index.ts
|
|
28
|
+
├── tickets.ops.ts # one Ops class per domain
|
|
29
|
+
├── tickets.ops.spec.ts # co-located spec
|
|
30
|
+
├── trucks.ops.ts
|
|
31
|
+
├── trucks.ops.spec.ts
|
|
32
|
+
└── …
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Rules:
|
|
36
|
+
|
|
37
|
+
- **Foundation lives at `src/app/store/`**, not nested under a feature folder. A foundational store is application-scoped, not feature-scoped.
|
|
38
|
+
- **One file per domain** under `tree/state/` once the tree has > 2 domains. Don't keep five domains in one `app-tree.ts`.
|
|
39
|
+
- **One Ops class per domain** under `ops/`, each with a co-located `*.ops.spec.ts`.
|
|
40
|
+
- **Derived tiers in named files** under `tree/derived/` once you have ≥ 3 derived concerns. Name by what they do (`tier-entity-resolution`), not by position (`tier-1`).
|
|
41
|
+
- **`app-tree.testing.ts` ships from day one**, co-located with `app-tree.ts`. See [`testing.md`](./testing.md).
|
|
42
|
+
|
|
43
|
+
## The `createAppTree(initial?)` factory
|
|
44
|
+
|
|
45
|
+
Take bootstrap-time inputs as a parameter when the tree needs runtime values not present in default state (a selected entity id, the active tenant, etc.). This avoids the anti-pattern of constructing the tree first and then immediately patching it.
|
|
46
|
+
|
|
47
|
+
```ts skip
|
|
48
|
+
import { signalTree, batching, devTools, timeTravel } from '@signaltree/core';
|
|
49
|
+
import { entityResolutionDerived } from './derived/tier-entity-resolution.derived';
|
|
50
|
+
import { ticketWorkflowDerived } from './derived/tier-ticket-workflow.derived';
|
|
51
|
+
import { ticketsState } from './state/tickets.state';
|
|
52
|
+
import { customersState } from './state/customers.state';
|
|
53
|
+
// …
|
|
54
|
+
|
|
55
|
+
export const STORE_NAME = 'AppTree';
|
|
56
|
+
|
|
57
|
+
export interface AppTreeBootstrap {
|
|
58
|
+
tenantId: string | null;
|
|
59
|
+
selectedTicketId: number | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DEFAULT_BOOTSTRAP: AppTreeBootstrap = { tenantId: null, selectedTicketId: null };
|
|
63
|
+
|
|
64
|
+
export function createBaseState(initial: AppTreeBootstrap = DEFAULT_BOOTSTRAP) {
|
|
65
|
+
return {
|
|
66
|
+
tickets: ticketsState(),
|
|
67
|
+
customers: customersState(),
|
|
68
|
+
selected: { tenantId: initial.tenantId, ticketId: initial.selectedTicketId },
|
|
69
|
+
// …
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createAppTree(initial: AppTreeBootstrap = DEFAULT_BOOTSTRAP) {
|
|
74
|
+
return signalTree(createBaseState(initial))
|
|
75
|
+
.with(devTools({ treeName: STORE_NAME }))
|
|
76
|
+
.with(batching())
|
|
77
|
+
.with(timeTravel())
|
|
78
|
+
.derived(entityResolutionDerived)
|
|
79
|
+
.derived(ticketWorkflowDerived);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type AppTree = ReturnType<typeof createAppTree>;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`provideAppTree()` and `provideAppTreeForTesting()` both take the same `initial` shape, so production and tests stay symmetric. Default the parameter so callers without bootstrap inputs don't break.
|
|
86
|
+
|
|
87
|
+
## Pattern defaults
|
|
88
|
+
|
|
89
|
+
The following are defaults — apply them unless you have a specific reason not to.
|
|
90
|
+
|
|
91
|
+
### `entityMap<T, K>()` for collections
|
|
92
|
+
|
|
93
|
+
Use `entityMap` for any collection where you do **any** of:
|
|
94
|
+
|
|
95
|
+
- look up by id (`.byId(n)`)
|
|
96
|
+
- check membership
|
|
97
|
+
- update individual entries
|
|
98
|
+
- cross-reference from another domain (e.g. `selectedDriverId → drivers.byId(id)`)
|
|
99
|
+
|
|
100
|
+
Plain `T[]` is correct only for **ordered, append-only, non-keyed** lists (e.g. an event log buffer). When in doubt, use `entityMap` — it costs one line in the state factory and gives you O(1) CRUD.
|
|
101
|
+
|
|
102
|
+
```ts skip
|
|
103
|
+
// ✓ keyed lookup, cross-reference, individual updates → entityMap
|
|
104
|
+
trucks: entityMap<TruckDto, number>(),
|
|
105
|
+
drivers: entityMap<DriverDto, number>(),
|
|
106
|
+
|
|
107
|
+
// ✓ ordered append-only event log → plain array
|
|
108
|
+
events: [] as EventDto[],
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Per-domain state files
|
|
112
|
+
|
|
113
|
+
Once the tree has > 2 domains, split each into its own file:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
state/tickets.state.ts: export function ticketsState() { return { entities: entityMap<TicketDto, number>(), selected: null as number | null }; }
|
|
117
|
+
state/trucks.state.ts: export function trucksState() { return entityMap<TruckDto, number>(); }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
This keeps `app-tree.ts` short (just the composition + enhancer chain) and makes each domain reviewable independently.
|
|
121
|
+
|
|
122
|
+
### Multi-tier derived chains
|
|
123
|
+
|
|
124
|
+
Once you have ≥ 3 derived concerns, or any derived value that depends on another derived value, use named tier files. See the worked example in [`patterns.md`](./patterns.md#splitting-derived-tiers-into-separate-files). Tier names describe what they do (`tier-entity-resolution`, `tier-ticket-workflow`, `tier-ui-aggregates`), not their position.
|
|
125
|
+
|
|
126
|
+
### Enhancer baseline for production
|
|
127
|
+
|
|
128
|
+
```ts skip
|
|
129
|
+
.with(devTools({ treeName: 'AppTree' })) // always
|
|
130
|
+
.with(batching()) // always
|
|
131
|
+
.with(timeTravel()) // always (cheap; turn off in prod via config if needed)
|
|
132
|
+
.with(persistence({ key, autoSave, ... })) // when you need it
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
These are not optional for non-trivial apps. Tests skip them (see [`testing.md`](./testing.md)); production doesn't. (Note: `memoization` was removed in 9.0.1; use Angular `computed()` for memoization.)
|
|
136
|
+
|
|
137
|
+
### Cross-domain orchestration on `AppStore`
|
|
138
|
+
|
|
139
|
+
If a method touches one domain, it goes on that domain's Ops class. If it touches two or more domains, it goes on `AppStore` and delegates to the relevant Ops:
|
|
140
|
+
|
|
141
|
+
```ts skip
|
|
142
|
+
// ✓ on AppStore — touches identity AND tickets
|
|
143
|
+
logout(): void {
|
|
144
|
+
this.ops.identity.clear();
|
|
145
|
+
this.ops.tickets.clearAll();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ✗ wrong — IdentityOps reaching into TicketOps
|
|
149
|
+
class IdentityOps {
|
|
150
|
+
clear() { this.tree.$.identity({ user: null }); inject(TicketOps).clearAll(); }
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
This keeps Ops classes free of cross-cutting dependencies (and circular DI).
|
|
155
|
+
|
|
156
|
+
## Definition of done
|
|
157
|
+
|
|
158
|
+
A SignalTree migration is **not done** until every box is checked.
|
|
159
|
+
|
|
160
|
+
### Code completeness
|
|
161
|
+
|
|
162
|
+
- [ ] Zero imports of `@ngrx/signals` (or whichever legacy package) anywhere in the migrated app's source tree. Verify with `grep -rln '@ngrx/signals' src/app/`.
|
|
163
|
+
- [ ] Legacy package removed from `package.json` `dependencies` (or, if a shared lib outside the migrated app still uses it, a tracking ticket exists with a target removal date).
|
|
164
|
+
- [ ] All legacy facade adapters deleted. If any remain, each must carry `// TODO(legacy-facade): remove by <date/release>` and a tracking issue.
|
|
165
|
+
- [ ] `node_modules/@ngrx` reinstalled clean (run your package manager's install after the lockfile update).
|
|
166
|
+
|
|
167
|
+
### Architecture
|
|
168
|
+
|
|
169
|
+
- [ ] Foundation lives at `src/app/store/` with the layout above.
|
|
170
|
+
- [ ] One tree, one `APP_TREE` token, one `AppStore` facade.
|
|
171
|
+
- [ ] Every keyed collection uses `entityMap<T, K>()`.
|
|
172
|
+
- [ ] Every domain has its own `*.state.ts` file (if > 2 domains) and its own `*.ops.ts` class.
|
|
173
|
+
- [ ] Derived logic split into named tier files (if ≥ 3 derived concerns).
|
|
174
|
+
- [ ] Enhancer baseline applied (`devTools + batching + timeTravel`).
|
|
175
|
+
- [ ] Cross-domain methods on `AppStore`; single-domain methods on Ops.
|
|
176
|
+
|
|
177
|
+
### Testing
|
|
178
|
+
|
|
179
|
+
- [ ] `app-tree.testing.ts` exists and exports `provideAppTreeForTesting()` (with the same `initial` parameter shape as `createAppTree`).
|
|
180
|
+
- [ ] `createBaseState()` is exported from `app-tree.ts`.
|
|
181
|
+
- [ ] Every Ops class has a co-located `*.ops.spec.ts` covering its public methods.
|
|
182
|
+
- [ ] Test suite is green. No `NG0201: APP_TREE` failures, no `Reflect.ownKeys` failures, no `vi.mock`/`jest.mock` hoisting issues.
|
|
183
|
+
- [ ] Lint is green (no new warnings introduced by the migration).
|
|
184
|
+
|
|
185
|
+
### DevX
|
|
186
|
+
|
|
187
|
+
- [ ] DevTools shows the tree under the chosen `treeName`.
|
|
188
|
+
- [ ] `tree.$.<domain>` autocompletes correctly through `AppStore.$` in the IDE.
|
|
189
|
+
|
|
190
|
+
### Sign-off
|
|
191
|
+
|
|
192
|
+
- [ ] Read [`migration-from-ngrx-signals.md`](./migration-from-ngrx-signals.md) cover-to-cover before declaring done. The "Gotchas" section covers traps that still bite even after the checklist passes.
|
|
193
|
+
|
|
194
|
+
## When the hybrid pattern is acceptable
|
|
195
|
+
|
|
196
|
+
Big-bang is the default. Hybrid (legacy facade adapter over `AppStore`) is acceptable **only** when one of these is true and documented:
|
|
197
|
+
|
|
198
|
+
1. **PR size constraints.** A 500+-file diff exceeds your team's review capacity.
|
|
199
|
+
2. **Multi-team coordination.** Multiple teams own consumers of the legacy facade and can't be flipped on the same day.
|
|
200
|
+
3. **Release cadence.** The migration takes longer than your release cycle, so prod must keep running on the in-flight hybrid state.
|
|
201
|
+
4. **Risk-averse rollback.** Regulated environment requires the ability to revert the foundation without reverting consumer code.
|
|
202
|
+
|
|
203
|
+
> **If your only constraint is "PR size" because the app has ≥3 stores**, you probably want **incremental per-domain migration**, not hybrid. Incremental migrates one store at a time, deletes each as it goes, and never ships a legacy-facade adapter. Hybrid is for *permanent coexistence* (shared base classes, multi-team cutover, regulated rollback). See [`migration-from-ngrx-signals.md` → Incremental per-domain migration](./migration-from-ngrx-signals.md#incremental-per-domain-migration).
|
|
204
|
+
|
|
205
|
+
In all four hybrid cases, the hybrid is **scaffolding with a deletion deadline**. Ship it with:
|
|
206
|
+
|
|
207
|
+
- a `// TODO(legacy-facade): remove by <date/release>` comment on every adapter,
|
|
208
|
+
- a tracking issue in your issue tracker,
|
|
209
|
+
- a date or release tag for the deletion.
|
|
210
|
+
|
|
211
|
+
A facade with no deletion plan is not a hybrid — it's a permanent second store and a maintenance burden. Don't ship one.
|