signalkin 0.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/LICENSE +21 -0
- package/README.md +458 -0
- package/fesm2022/signalkin.mjs +598 -0
- package/fesm2022/signalkin.mjs.map +1 -0
- package/package.json +25 -0
- package/types/signalkin.d.ts +401 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shira Joseph
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
AUTOGENERATED - DO NOT EDIT.
|
|
3
|
+
This file is a copy of the repository root README.md, written by `npm run sync:readme` (which runs as part of `npm run build`).
|
|
4
|
+
Edit the root README.md instead; any changes made here will be overwritten on the next build.
|
|
5
|
+
-->
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="https://raw.githubusercontent.com/ShiraJoseph/signalkin/main/assets/logo.png" alt="signalkin" width="112">
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
# signalkin
|
|
12
|
+
|
|
13
|
+
[](https://www.npmjs.com/package/signalkin)
|
|
14
|
+
[](LICENSE)
|
|
15
|
+
[](https://angular.dev)
|
|
16
|
+
[](https://ngrx.io/guide/signals)
|
|
17
|
+
|
|
18
|
+
Performant relationship management (1:many, 2:1) and collection methods for [`@ngrx/signals`](https://ngrx.io/guide/signals) entities.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Contents
|
|
23
|
+
|
|
24
|
+
- [Install](#install)
|
|
25
|
+
- [Usage](#usage)
|
|
26
|
+
- [`withEntityAccessors`](#method-withentityaccessorsconfigs)
|
|
27
|
+
- [`withEntityRelationship`](#method-withentityrelationshipfrom-to-options)
|
|
28
|
+
- [`withTransitiveRelationship`](#method-withtransitiverelationship-from-to-through--)
|
|
29
|
+
- [Customization](#customization)
|
|
30
|
+
- [Recipes](#recipes)
|
|
31
|
+
- [Transit map](#transit-map-the-segment-between-two-stations)
|
|
32
|
+
- [Quartets](#quartets-an-ensemble-keyed-by-its-players-plus-a-transitive-walk)
|
|
33
|
+
- [Hospital](#hospital-a-deep-hierarchy--many-to-many-loaded-from-an-api)
|
|
34
|
+
- [Contributing](#contributing)
|
|
35
|
+
- [License](#license)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
npm install signalkin
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Peer dependencies: `@angular/core` and `@ngrx/signals`
|
|
45
|
+
|
|
46
|
+
_See the [NgRx SignalStore entity management guide](https://ngrx.io/guide/signals/signal-store/entity-management) to learn about entity signals and how to use them!_
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
### Method: `withEntityAccessors(...configs)`
|
|
53
|
+
- Reduces boilerplate around entity signals (no need for `patchState`).
|
|
54
|
+
|
|
55
|
+
#### 1. Add to signal store
|
|
56
|
+
```ts
|
|
57
|
+
...
|
|
58
|
+
import { withEntityAccessors } from 'signalkin'; // <-- import
|
|
59
|
+
|
|
60
|
+
interface Task { id: string; }
|
|
61
|
+
|
|
62
|
+
export const TaskStore = signalStore(
|
|
63
|
+
...
|
|
64
|
+
withEntities(entityConfig({ entity: type<Task>(), collection: 'task'})),
|
|
65
|
+
withEntityAccessors() // <--- Add this
|
|
66
|
+
);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
#### 2. Receive a bunch of getters/setters
|
|
70
|
+
```ts
|
|
71
|
+
@Component({
|
|
72
|
+
selector: 'app-root'
|
|
73
|
+
})
|
|
74
|
+
export class App {
|
|
75
|
+
constructor(){
|
|
76
|
+
store = inject(TaskStore);
|
|
77
|
+
|
|
78
|
+
// <-- you get free access to these signals:
|
|
79
|
+
store.tasks(); // Array<Task>
|
|
80
|
+
store.taskMap(); // Record<string, Task>
|
|
81
|
+
store.taskCount(); // number
|
|
82
|
+
store.taskById(id); // Task
|
|
83
|
+
store.hasTask(id); // boolean
|
|
84
|
+
|
|
85
|
+
// <-- as well as these signal setters:
|
|
86
|
+
store.addTask(task);
|
|
87
|
+
store.addTasks(tasks);
|
|
88
|
+
store.prependTask(task);
|
|
89
|
+
store.prependTasks(tasks);
|
|
90
|
+
store.setTask(task);
|
|
91
|
+
store.setTasks(tasks);
|
|
92
|
+
store.setAllTasks(tasks);
|
|
93
|
+
store.upsertTask(task);
|
|
94
|
+
store.upsertTasks(tasks);
|
|
95
|
+
store.updateTask(id, changes);
|
|
96
|
+
store.updateTasks(ids, changes);
|
|
97
|
+
store.updateAllTasks(changes)
|
|
98
|
+
store.removeTask(id);
|
|
99
|
+
store.removeTasks(ids);
|
|
100
|
+
store.removeAllTasks();
|
|
101
|
+
|
|
102
|
+
// <-- and entity order modifiers
|
|
103
|
+
store.swapTasks(taskA, taskB);
|
|
104
|
+
store.reorderTasks(orderedTasks);
|
|
105
|
+
store.moveTaskToIndex(task, index);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
### Method: `withEntityRelationship(from, to, options?)`
|
|
113
|
+
- Lets you link two collections with primary and foreign keys - for frontend "database" management without the O(n^2) lookups
|
|
114
|
+
|
|
115
|
+
#### 1. Add to signalStore
|
|
116
|
+
```ts
|
|
117
|
+
import { withEntityRelationship } from 'signalkin'; // <-- import
|
|
118
|
+
|
|
119
|
+
interface Author { id: string; }
|
|
120
|
+
interface Editor { id: string; }
|
|
121
|
+
interface Book { id: string; authorId: string; editorIds: string[] } // <-- ids for the related entities
|
|
122
|
+
|
|
123
|
+
export const LibraryStore = signalStore(
|
|
124
|
+
...
|
|
125
|
+
withEntities(entityConfig({ entity: type<Book>(), collection: 'book' })),
|
|
126
|
+
withEntities(entityConfig({ entity: type<Author>(), collection: 'author' })),
|
|
127
|
+
withEntities(entityConfig({ entity: type<Editor>(), collection: 'editor' })),
|
|
128
|
+
|
|
129
|
+
// <-- Add these after your entities to describe their relationships:
|
|
130
|
+
withEntityRelationship(
|
|
131
|
+
{ collection: 'author', count: 1 },
|
|
132
|
+
{ collection: 'book', count: 'many' }
|
|
133
|
+
), // use any number or 'many' as the count
|
|
134
|
+
withEntityRelationship(
|
|
135
|
+
{ collection: 'book', count: 'many' },
|
|
136
|
+
{ collection: 'editor', count: 2 } // two editors per book
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
#### 2. Receive cross-entity signals, methods and auto-updates to ids
|
|
142
|
+
```ts
|
|
143
|
+
@Component({
|
|
144
|
+
selector: 'app-root'
|
|
145
|
+
})
|
|
146
|
+
export class App {
|
|
147
|
+
constructor(){
|
|
148
|
+
store = inject(LibraryStore);
|
|
149
|
+
|
|
150
|
+
// <-- You get free access to these signal getters:
|
|
151
|
+
store.bookHasAuthor(book, author);
|
|
152
|
+
store.authorHasBook(author, book);
|
|
153
|
+
store.bookCountByAuthor(author);
|
|
154
|
+
store.authorCountByBook(book); // (in a 1:many relationship this will always be 1)
|
|
155
|
+
|
|
156
|
+
store.booksByAuthor(author);
|
|
157
|
+
store.bookIdsByAuthor(author);
|
|
158
|
+
store.authorByBook(book);
|
|
159
|
+
store.authorIdByBook(book);
|
|
160
|
+
|
|
161
|
+
// These getters are mostly useful only when dealing with >1 counts like 2:many
|
|
162
|
+
// They require ALL books be passed in to identify an author; if you need the author of one book just use `store.bookById(id).authorId`
|
|
163
|
+
store.authorByBooks(...books);
|
|
164
|
+
store.authorIdByBooks(...books);
|
|
165
|
+
|
|
166
|
+
// <-- as well as these signal setters, which will update foreign key values for all entities involved:
|
|
167
|
+
store.addBookToAuthor(authorId, bookId);
|
|
168
|
+
store.removeBookFromAuthor(authorId, bookId);
|
|
169
|
+
store.moveBookToAuthor(bookId, authorId);
|
|
170
|
+
|
|
171
|
+
// <-- and entity order modifiers
|
|
172
|
+
store.indexOfAuthorBook(book);
|
|
173
|
+
store.swapAuthorBooks(bookA, bookB);
|
|
174
|
+
store.reorderAuthorBooks(orderedBooks);
|
|
175
|
+
store.moveAuthorBookToIndex(book, index);
|
|
176
|
+
|
|
177
|
+
// <-- with 2+ owners (a book has 2 editors):
|
|
178
|
+
store.indexOfEditorByBook(editor, book);
|
|
179
|
+
store.swapEditorsByBook(editorA, editorB, book);
|
|
180
|
+
store.reorderEditorsByBook(orderedEditors, book);
|
|
181
|
+
store.moveEditorByBookToIndex(editor, index, book);
|
|
182
|
+
store.booksByEditorAtIndex(editor, 0);
|
|
183
|
+
store.bookIdsByEditorAtIndex(editor, 0);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### Method: `withTransitiveRelationship({ from, to, through, ... })`
|
|
191
|
+
|
|
192
|
+
- Lookups between two collections that walk through intermediary collection(s)
|
|
193
|
+
|
|
194
|
+
#### 1. Add to signalStore
|
|
195
|
+
```ts
|
|
196
|
+
...
|
|
197
|
+
import { ..., withTransitiveRelationship } from 'signalkin'; // <-- import
|
|
198
|
+
|
|
199
|
+
interface Team { id: string; }
|
|
200
|
+
interface Project { id: string; teamId: string } // <-- add an id to the child for the parent entity
|
|
201
|
+
interface Task {id: string; projectId: string} // <-- add an id to the child for the parent entity (no need for the "grandparent" id)
|
|
202
|
+
|
|
203
|
+
export const TaskStore = signalStore(
|
|
204
|
+
...
|
|
205
|
+
withEntities(entityConfig({ entity: type<Team>(), collection: 'team' })),
|
|
206
|
+
withEntities(entityConfig({ entity: type<Project>(), collection: 'project' })),
|
|
207
|
+
withEntities(entityConfig({ entity: type<Task>(), collection: 'task' })),
|
|
208
|
+
|
|
209
|
+
// Define 2 or more relationships
|
|
210
|
+
withEntityRelationship({ collection: 'team', count: 1 }, { collection: 'project', count: 'many' }),
|
|
211
|
+
withEntityRelationship({ collection: 'project', count: 1 }, { collection: 'task', count: 'many' }),
|
|
212
|
+
|
|
213
|
+
withTransitiveRelationship({ from: 'team', to: 'task', through: 'project' }), // <-- Add this to get a "grandparent" relationship
|
|
214
|
+
);
|
|
215
|
+
```
|
|
216
|
+
#### 2. Receive grandparent-to-grandchild signals getters:
|
|
217
|
+
```ts
|
|
218
|
+
@Component({
|
|
219
|
+
selector: 'app-root'
|
|
220
|
+
})
|
|
221
|
+
export class App {
|
|
222
|
+
constructor(){
|
|
223
|
+
store = inject(TaskStore);
|
|
224
|
+
|
|
225
|
+
// <-- Now you get access to these getters:
|
|
226
|
+
store.tasksByTeam(team);
|
|
227
|
+
store.taskIdsByTeam(team);
|
|
228
|
+
|
|
229
|
+
// <-- As well as the reverse direction:
|
|
230
|
+
store.teamsByTask(task);
|
|
231
|
+
store.teamIdsByTask(task);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
### Customization
|
|
239
|
+
|
|
240
|
+
#### Custom id property
|
|
241
|
+
- Set `selectId` / `selectForeignId` for a collection that keys on something other than `id` or `<foreignCollection>Id`:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
withEntityRelationship(
|
|
245
|
+
{ collection: 'team', count: 1, selectId: 'id2' }, // <-- Bypass the default that looks for the primary key `id` on each team
|
|
246
|
+
{ collection: 'project', count: 'many', selectId: 'id2', selectForeignId: 'teamId2' }, // <-- Bypass the default that looks for the foreign key `teamId` (or teamIds for a 'many' relationship) on each project
|
|
247
|
+
// `selectForeignId` also takes a callback, but doing so will prevent signalkin from cleaning up foreign keys for you
|
|
248
|
+
|
|
249
|
+
)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
#### Pluralization
|
|
253
|
+
- Default pluralization adds an `s` to the collection name. Set `plural` / `fromPlural` / `toPlural` for an irregular noun:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
|
|
257
|
+
withEntityAccessors({ ...libraryConfig, plural: 'libraries' }); // <-- plural
|
|
258
|
+
withEntityRelationship({ collection: 'library', count: 'many', plural: 'libraries' }, ...); // <-- plural
|
|
259
|
+
withTransitiveRelationship({from: 'library', to: 'shelf', through: ['row'], fromPlural: 'libraries', toPlural: 'shelves'}); // <-- fromPlural, toPlural
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
#### Disable auto-updates
|
|
263
|
+
- If you don't want signalkin to update the store on your behalf you can turn it off, one relationship at a time:
|
|
264
|
+
```ts
|
|
265
|
+
withEntityRelationship(
|
|
266
|
+
{ collection: 'team', count: 1},
|
|
267
|
+
{ collection: 'project', count: 'many'},
|
|
268
|
+
{
|
|
269
|
+
autoIndex: false, // <-- stop signalkin from automatically building lookup tables from foreign keys like {'authorId1': 'bookId1'}
|
|
270
|
+
syncForeignKeys: false, // <-- stop signalkin from clearing a project's teamId when you delete its team
|
|
271
|
+
compositeLookups: false // <-- stop signalkin from building many-to-one lookup tables like {'editor1editor2': 'bookId1'}
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
```
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Recipes
|
|
278
|
+
|
|
279
|
+
### Transit map: the segment between two stations
|
|
280
|
+
|
|
281
|
+
- **Description:** Exactly two stations are joined by one track `Segment`; a station touches many segments; a segment carries many trips. A `count: 2` side makes the pair of station ids a composite key, so you can look the segment up from its two endpoints.
|
|
282
|
+
|
|
283
|
+

|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
interface Station { id: string; name: string; zone: number; }
|
|
287
|
+
interface Segment { id: string; stationIds: [string, string]; distanceKm: number; }
|
|
288
|
+
interface Trip { id: string; segmentId: string; departsAt: string; }
|
|
289
|
+
|
|
290
|
+
export const NetworkStore = signalStore(
|
|
291
|
+
withEntities(entityConfig({ entity: type<Station>(), collection: 'station' })),
|
|
292
|
+
withEntities(entityConfig({ entity: type<Segment>(), collection: 'segment' })),
|
|
293
|
+
withEntities(entityConfig({ entity: type<Trip>(), collection: 'trip' })),
|
|
294
|
+
withEntityAccessors(),
|
|
295
|
+
withEntityRelationship({ collection: 'station', count: 2 }, { collection: 'segment', count: 'many' }),
|
|
296
|
+
withEntityRelationship({ collection: 'segment', count: 1 }, { collection: 'trip', count: 'many' }),
|
|
297
|
+
);
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
@Component({
|
|
302
|
+
template: `
|
|
303
|
+
@if (store.segmentByStations(from(), to()); as segment) {
|
|
304
|
+
<p>{{ segment.distanceKm }} km | {{ store.tripCountBySegment(segment.id) }} trips/day</p>
|
|
305
|
+
}
|
|
306
|
+
`,
|
|
307
|
+
})
|
|
308
|
+
export class RouteInfo {
|
|
309
|
+
store = inject(NetworkStore);
|
|
310
|
+
from = input.required<string>();
|
|
311
|
+
to = input.required<string>();
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
### Quartets: an ensemble keyed by its players, plus a transitive walk
|
|
318
|
+
|
|
319
|
+
- **Description:** A string quartet is defined by its four players; a musician plays in many ensembles; an ensemble gives many performances. `count: 4` makes the set of four musician ids a composite key, and a transitive lookup walks a musician through their ensembles to every performance.
|
|
320
|
+
|
|
321
|
+

|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
interface Musician { id: string; name: string; instrument: string; }
|
|
325
|
+
interface Ensemble { id: string; name: string; musicianIds: [string, string, string, string]; }
|
|
326
|
+
interface Performance { id: string; ensembleId: string; venue: string; date: string; }
|
|
327
|
+
|
|
328
|
+
export const ConcertStore = signalStore(
|
|
329
|
+
withEntities(entityConfig({ entity: type<Musician>(), collection: 'musician' })),
|
|
330
|
+
withEntities(entityConfig({ entity: type<Ensemble>(), collection: 'ensemble' })),
|
|
331
|
+
withEntities(entityConfig({ entity: type<Performance>(), collection: 'performance' })),
|
|
332
|
+
withEntityAccessors(),
|
|
333
|
+
withEntityRelationship({ collection: 'musician', count: 4 }, { collection: 'ensemble', count: 'many' }),
|
|
334
|
+
withEntityRelationship({ collection: 'ensemble', count: 1 }, { collection: 'performance', count: 'many' }),
|
|
335
|
+
withTransitiveRelationship({ from: 'musician', to: 'performance', through: 'ensemble' }),
|
|
336
|
+
);
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
@Component({
|
|
341
|
+
template: `
|
|
342
|
+
@if (store.ensembleByMusicians(violin1(), violin2(), viola(), cello()); as ensemble) {
|
|
343
|
+
<p>{{ ensemble.name }} - {{ store.performancesByMusician(violin1()).length }} performances</p>
|
|
344
|
+
}
|
|
345
|
+
`,
|
|
346
|
+
})
|
|
347
|
+
export class QuartetCard {
|
|
348
|
+
store = inject(ConcertStore);
|
|
349
|
+
violin1 = input.required<string>();
|
|
350
|
+
violin2 = input.required<string>();
|
|
351
|
+
viola = input.required<string>();
|
|
352
|
+
cello = input.required<string>();
|
|
353
|
+
musicians = []
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
### Hospital: a deep hierarchy + many-to-many, loaded from an API
|
|
360
|
+
|
|
361
|
+
- **Description:** A hospital has rooms, rooms have beds, a bed holds a patient; a patient is linked to many doctors and many medications. Transitive lookups walk the whole chain - `medicationsByHospital` even crosses the patient→medication many-to-many - and `rxMethod` loads the graph from an API into the store, where every index builds itself.
|
|
362
|
+
|
|
363
|
+

|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
interface Hospital { id: string; name: string; }
|
|
367
|
+
interface Room { id: string; hospitalId: string; number: string; }
|
|
368
|
+
interface Bed { id: string; roomId: string; label: string; }
|
|
369
|
+
interface Patient { id: string; bedId: string; mrn: string; doctorIds: string[]; medicationIds: string[]; }
|
|
370
|
+
interface Doctor { id: string; name: string; specialty: string; }
|
|
371
|
+
interface Medication { id: string; name: string; dose: string; }
|
|
372
|
+
interface HospitalGraph { hospitals: Hospital[]; rooms: Room[]; beds: Bed[]; patients: Patient[]; doctors: Doctor[]; medications: Medication[]; }
|
|
373
|
+
|
|
374
|
+
export const HospitalStore = signalStore(
|
|
375
|
+
withEntities(entityConfig({ entity: type<Hospital>(), collection: 'hospital' })),
|
|
376
|
+
withEntities(entityConfig({ entity: type<Room>(), collection: 'room' })),
|
|
377
|
+
withEntities(entityConfig({ entity: type<Bed>(), collection: 'bed' })),
|
|
378
|
+
withEntities(entityConfig({ entity: type<Patient>(), collection: 'patient' })),
|
|
379
|
+
withEntities(entityConfig({ entity: type<Doctor>(), collection: 'doctor' })),
|
|
380
|
+
withEntities(entityConfig({ entity: type<Medication>(), collection: 'medication' })),
|
|
381
|
+
withEntityAccessors(),
|
|
382
|
+
withEntityRelationship({ collection: 'hospital', count: 1 }, { collection: 'room', count: 'many' }),
|
|
383
|
+
withEntityRelationship({ collection: 'room', count: 1 }, { collection: 'bed', count: 'many' }),
|
|
384
|
+
withEntityRelationship({ collection: 'bed', count: 1 }, { collection: 'patient', count: 'many' }),
|
|
385
|
+
withEntityRelationship({ collection: 'doctor', count: 'many' }, { collection: 'patient', count: 'many' }),
|
|
386
|
+
withEntityRelationship({ collection: 'patient', count: 'many' }, { collection: 'medication', count: 'many' }),
|
|
387
|
+
withTransitiveRelationship({ from: 'hospital', to: 'patient', through: ['room', 'bed'] }),
|
|
388
|
+
withTransitiveRelationship({ from: 'hospital', to: 'medication', through: ['room', 'bed', 'patient'] }),
|
|
389
|
+
withTransitiveRelationship({ from: 'hospital', to: 'doctor', through: ['room', 'bed', 'patient'] }),
|
|
390
|
+
withTransitiveRelationship({ from: 'room', to: 'medication', through: ['bed', 'patient'] }),
|
|
391
|
+
withMethods((store, http = inject(HttpClient)) => ({
|
|
392
|
+
load: rxMethod<string>(
|
|
393
|
+
pipe(
|
|
394
|
+
switchMap((hospitalId) => http.get<HospitalGraph>(`/api/hospitals/${hospitalId}/graph`)),
|
|
395
|
+
tap((graph) => {
|
|
396
|
+
store.setAllHospitals(graph.hospitals);
|
|
397
|
+
store.setAllRooms(graph.rooms);
|
|
398
|
+
store.setAllBeds(graph.beds);
|
|
399
|
+
store.setAllPatients(graph.patients);
|
|
400
|
+
store.setAllDoctors(graph.doctors);
|
|
401
|
+
store.setAllMedications(graph.medications);
|
|
402
|
+
}),
|
|
403
|
+
),
|
|
404
|
+
),
|
|
405
|
+
})),
|
|
406
|
+
);
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
@Component({
|
|
411
|
+
template: `
|
|
412
|
+
<h3>{{ store.hospitalById(hospitalId())?.name }}</h3>
|
|
413
|
+
<p>
|
|
414
|
+
{{ store.patientsByHospital(hospitalId()).length }} patients,
|
|
415
|
+
{{ store.medicationsByHospital(hospitalId()).length }} medications in use
|
|
416
|
+
</p>
|
|
417
|
+
`,
|
|
418
|
+
})
|
|
419
|
+
export class HospitalDashboard {
|
|
420
|
+
store = inject(HospitalStore);
|
|
421
|
+
hospitalId = input.required<string>();
|
|
422
|
+
|
|
423
|
+
constructor() {
|
|
424
|
+
this.store.load(this.hospitalId); // rxMethod takes the signal and reloads when it changes
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
## Contributing
|
|
431
|
+
|
|
432
|
+
Contributions are welcome!
|
|
433
|
+
|
|
434
|
+
### Project structure
|
|
435
|
+
|
|
436
|
+
This repo is an Angular workspace with two projects:
|
|
437
|
+
- `projects/signalkin` (the published library, source in `src/lib`) and
|
|
438
|
+
- `projects/sandbox` (a throwaway app whose `demo.store.typecheck.ts` asserts the generated types resolve through the built package).
|
|
439
|
+
|
|
440
|
+
Everything is a plain `signalStoreFeature` built only on public `@angular/core` + `@ngrx/signals` APIs, with no state duplication for FK-derived links (indexes are `computed`) and no effects or watchers.
|
|
441
|
+
|
|
442
|
+
_For AI Agents: [`AGENTS.md`](AGENTS.md)._
|
|
443
|
+
|
|
444
|
+
### Submitting
|
|
445
|
+
1. Add tests, including a sandbox type assertion for any type-level change.
|
|
446
|
+
2. Make sure all three pass:
|
|
447
|
+
```
|
|
448
|
+
npm run build # sync the README, then build the library to dist/signalkin (build this before the sandbox)
|
|
449
|
+
npx ng build sandbox # type-check the sandbox against the built package
|
|
450
|
+
npx ng test signalkin # vitest suite
|
|
451
|
+
```
|
|
452
|
+
3. Create a feature branch with your name and short description and open a PR on `main`.
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## License
|
|
457
|
+
|
|
458
|
+
MIT
|