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 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
+ [![npm](https://img.shields.io/npm/v/signalkin.svg)](https://www.npmjs.com/package/signalkin)
14
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
15
+ [![Angular 22](https://img.shields.io/badge/Angular-22-dd0031.svg)](https://angular.dev)
16
+ [![@ngrx/signals 21](https://img.shields.io/badge/%40ngrx%2Fsignals-21-purple.svg)](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
+ ![Transit diagram: Station, Segment, and Trip entities with the autogenerated segmentsByStations and tripBySegment lookup maps](https://raw.githubusercontent.com/ShiraJoseph/signalkin/main/assets/transit.png)
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
+ ![Quartet diagram: Musician, Ensemble, and Performance entities with the autogenerated ensemblesByMusicians and performancesByEnsemble lookup maps, plus the transitive performancesByMusician lookup](https://raw.githubusercontent.com/ShiraJoseph/signalkin/main/assets/quartet.png)
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
+ ![Hospital diagram: the Hospital, Room, Bed, Patient, Doctor, and Medication entities with five autogenerated lookup maps and four transitive lookups](https://raw.githubusercontent.com/ShiraJoseph/signalkin/main/assets/hospital.png)
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