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.
@@ -0,0 +1,598 @@
1
+ import { computed } from '@angular/core';
2
+ import { signalStoreFeature, withComputed, withMethods, patchState, withState } from '@ngrx/signals';
3
+ import { addEntity, addEntities, prependEntity, prependEntities, setEntity, setEntities, setAllEntities, upsertEntity, upsertEntities, updateEntity, updateEntities, updateAllEntities, removeEntity, removeEntities, removeAllEntities } from '@ngrx/signals/entities';
4
+
5
+ const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
6
+ const addUnique = (list, id) => list?.includes(id) ? list : [...(list ?? []), id];
7
+ /** Whether an accessor returned a usable id (keeps `0` and `''`, drops `undefined` / `null`). */
8
+ const hasId = (id) => id !== undefined && id !== null;
9
+ /** Turns a primary-id key-or-getter accessor into a getter; a string is read as a property name off the entity. */
10
+ const toIdReader = (accessor, defaultKey) => {
11
+ const resolved = accessor ?? defaultKey;
12
+ return typeof resolved === 'function' ? resolved : (entity) => entity?.[resolved];
13
+ };
14
+ /**
15
+ * Resolves a lookup argument that may be an entity OR its id down to the id: an object is read
16
+ * through `readId`, anything else (a raw id) passes through. Lets every lookup accept `Entity | EntityId`.
17
+ */
18
+ const toRefResolver = (readId) => (ref) => ref !== null && typeof ref === 'object' ? readId(ref) : ref;
19
+ /**
20
+ * A foreign-key reader that always yields an array of usable ids: a scalar id becomes a one-item
21
+ * array, an array (a plural FK like `aisleIds`) passes through, and a missing/empty key yields `[]`.
22
+ * This lets a `>1` side carry an array foreign key without the caller normalizing.
23
+ */
24
+ const toIdsReader = (accessor, defaultKey) => {
25
+ const read = typeof accessor === 'function' ? accessor : (entity) => entity?.[accessor ?? defaultKey];
26
+ return (entity) => {
27
+ const value = read(entity);
28
+ return Array.isArray(value) ? value.filter(hasId) : hasId(value) ? [value] : [];
29
+ };
30
+ };
31
+ /** Unions two adjacency maps, deduping ids per key. */
32
+ const mergeLinks = (base, extra) => {
33
+ const merged = {};
34
+ [base, extra].forEach((source) => Object.keys(source).forEach((keyId) => {
35
+ merged[keyId] = source[keyId].reduce((ids, id) => addUnique(ids, id), merged[keyId] ?? []);
36
+ }));
37
+ return merged;
38
+ };
39
+ /**
40
+ * Drops links whose source or target entity no longer exists, so lookups never
41
+ * return ids for entities that were removed from their collection.
42
+ */
43
+ const projectLinks = (links, keyEntityMap, valueEntityMap) => {
44
+ const projected = {};
45
+ Object.keys(links).forEach((keyId) => {
46
+ if (keyEntityMap[keyId] === undefined)
47
+ return;
48
+ projected[keyId] = links[keyId].filter((valueId) => valueEntityMap[valueId] !== undefined);
49
+ });
50
+ return projected;
51
+ };
52
+
53
+ /**
54
+ * Standard collection methods and convenience signals for the store's entities, named by collection — no args covers every collection, or pass configs to narrow and to honor a custom `selectId` / `plural`.
55
+ *
56
+ * @example
57
+ * signalStore(
58
+ * withEntities(bookConfig),
59
+ * withEntityAccessors(bookConfig),
60
+ * );
61
+ * // books(), bookMap(), bookCount(), bookById(id), hasBook(book | id)
62
+ * // addBook, addBooks, prependBook, setBook, setAllBooks, upsertBook
63
+ * // updateBook(book | id, changes), updateAllBooks(changes), removeBook(book | id), removeAllBooks()
64
+ * // swapBooks(a, b), reorderBooks(orderedBooks), moveBookToIndex(book | id, index) reorder the collection
65
+ */
66
+ const withEntityAccessors = (...configs) => {
67
+ const resolveCollections = (innerStore) => configs.length > 0
68
+ ? configs.map((config) => {
69
+ if (typeof innerStore[`${config.collection}EntityMap`] !== 'function') {
70
+ throw new Error(`signalkin: '${config.collection}EntityMap' is not on the store — register withEntities ` +
71
+ `for '${config.collection}' before withEntityAccessors.`);
72
+ }
73
+ return config.collection;
74
+ })
75
+ : Object.keys(innerStore)
76
+ .filter((key) => key.endsWith('EntityMap') && key !== 'EntityMap')
77
+ .map((key) => key.slice(0, -'EntityMap'.length));
78
+ const selectIdFor = (collection) => configs.find((config) => config.collection === collection)?.selectId;
79
+ const pluralFor = (collection) => configs.find((config) => config.collection === collection)?.plural ?? `${collection}s`;
80
+ const feature = signalStoreFeature(withComputed((innerStore) => {
81
+ const props = {};
82
+ resolveCollections(innerStore).forEach((collection) => {
83
+ props[pluralFor(collection)] = computed(() => innerStore[`${collection}Entities`]());
84
+ props[`${collection}Map`] = computed(() => innerStore[`${collection}EntityMap`]());
85
+ props[`${collection}Count`] = computed(() => innerStore[`${collection}Ids`]().length);
86
+ });
87
+ return props;
88
+ }), withMethods((innerStore) => {
89
+ const methods = {};
90
+ resolveCollections(innerStore).forEach((collection) => {
91
+ const capitalizedCollection = capitalize(collection);
92
+ const capitalizedPlural = capitalize(pluralFor(collection));
93
+ const entityMapKey = `${collection}EntityMap`;
94
+ const updaterConfig = { collection, selectId: selectIdFor(collection) };
95
+ const resolveRef = toRefResolver(toIdReader(selectIdFor(collection), 'id'));
96
+ const resolveTargets = (entitiesOrPredicate) => Array.isArray(entitiesOrPredicate) ? entitiesOrPredicate.map(resolveRef) : entitiesOrPredicate;
97
+ methods[`add${capitalizedCollection}`] = (entity) => patchState(innerStore, addEntity(entity, updaterConfig));
98
+ methods[`add${capitalizedPlural}`] = (entities) => patchState(innerStore, addEntities(entities, updaterConfig));
99
+ methods[`prepend${capitalizedCollection}`] = (entity) => patchState(innerStore, prependEntity(entity, updaterConfig));
100
+ methods[`prepend${capitalizedPlural}`] = (entities) => patchState(innerStore, prependEntities(entities, updaterConfig));
101
+ methods[`set${capitalizedCollection}`] = (entity) => patchState(innerStore, setEntity(entity, updaterConfig));
102
+ methods[`set${capitalizedPlural}`] = (entities) => patchState(innerStore, setEntities(entities, updaterConfig));
103
+ methods[`setAll${capitalizedPlural}`] = (entities) => patchState(innerStore, setAllEntities(entities, updaterConfig));
104
+ methods[`upsert${capitalizedCollection}`] = (entity) => patchState(innerStore, upsertEntity(entity, updaterConfig));
105
+ methods[`upsert${capitalizedPlural}`] = (entities) => patchState(innerStore, upsertEntities(entities, updaterConfig));
106
+ methods[`update${capitalizedCollection}`] = (ref, changes) => patchState(innerStore, updateEntity({ id: resolveRef(ref), changes }, updaterConfig));
107
+ methods[`update${capitalizedPlural}`] = (entitiesOrPredicate, changes) => patchState(innerStore, updateEntities((Array.isArray(entitiesOrPredicate)
108
+ ? { ids: resolveTargets(entitiesOrPredicate), changes }
109
+ : { predicate: entitiesOrPredicate, changes }), updaterConfig));
110
+ methods[`updateAll${capitalizedPlural}`] = (changes) => patchState(innerStore, updateAllEntities(changes, updaterConfig));
111
+ methods[`remove${capitalizedCollection}`] = (ref) => patchState(innerStore, removeEntity(resolveRef(ref), updaterConfig));
112
+ methods[`remove${capitalizedPlural}`] = (entitiesOrPredicate) => patchState(innerStore, removeEntities(resolveTargets(entitiesOrPredicate), updaterConfig));
113
+ methods[`removeAll${capitalizedPlural}`] = () => patchState(innerStore, removeAllEntities(updaterConfig));
114
+ // Ordering mutations over the collection's own id order (what `${collection}Entities` follows).
115
+ const idsKey = `${collection}Ids`;
116
+ const writeIds = (ids) => patchState(innerStore, { [idsKey]: ids });
117
+ methods[`swap${capitalizedPlural}`] = (aRef, bRef) => {
118
+ const ids = [...innerStore[idsKey]()];
119
+ const indexA = ids.indexOf(resolveRef(aRef));
120
+ const indexB = ids.indexOf(resolveRef(bRef));
121
+ if (indexA === -1 || indexB === -1) {
122
+ console.warn(`signalkin: swap${capitalizedPlural} — both entities must be in the '${collection}' collection; call ignored.`);
123
+ return;
124
+ }
125
+ [ids[indexA], ids[indexB]] = [ids[indexB], ids[indexA]];
126
+ writeIds(ids);
127
+ };
128
+ methods[`reorder${capitalizedPlural}`] = (orderedEntities) => {
129
+ const orderedIds = (orderedEntities ?? []).map(resolveRef).filter((id) => id !== undefined);
130
+ const current = innerStore[idsKey]();
131
+ const isFullPermutation = orderedIds.length === current.length &&
132
+ new Set(orderedIds).size === orderedIds.length &&
133
+ orderedIds.every((id) => current.includes(id));
134
+ if (!isFullPermutation) {
135
+ console.warn(`signalkin: reorder${capitalizedPlural} — pass every '${collection}' entity exactly once; call ignored.`);
136
+ return;
137
+ }
138
+ writeIds(orderedIds);
139
+ };
140
+ methods[`move${capitalizedCollection}ToIndex`] = (ref, index) => {
141
+ const ids = [...innerStore[idsKey]()];
142
+ const currentIndex = ids.indexOf(resolveRef(ref));
143
+ if (currentIndex === -1)
144
+ return;
145
+ const target = Math.max(0, Math.min(index, ids.length - 1));
146
+ const [moved] = ids.splice(currentIndex, 1);
147
+ ids.splice(target, 0, moved);
148
+ writeIds(ids);
149
+ };
150
+ methods[`${collection}ById`] = (id) => innerStore[entityMapKey]()[id];
151
+ methods[`has${capitalizedCollection}`] = (ref) => innerStore[entityMapKey]()[resolveRef(ref)] !== undefined;
152
+ });
153
+ return methods;
154
+ }));
155
+ return feature;
156
+ };
157
+
158
+ /**
159
+ * Relates two entity collections into typed cross-lookups, counts, composite set-lookups, and link mutations named by each side's `collection`; `count` (`1`, a number, or `'many'`) drives the names and shapes, and every lookup takes the entity or its id.
160
+ *
161
+ * @example
162
+ * signalStore(
163
+ * withEntities(libraryConfig),
164
+ * withEntities(bookConfig),
165
+ * withEntityRelationship({ collection: 'library', count: 1 }, { collection: 'book', count: 'many' }),
166
+ * );
167
+ * // booksByLibrary(library | id) -> Book[]
168
+ * // libraryByBook(book | id) -> Library | undefined
169
+ * // libraryHasBook, bookCountByLibrary, addBookToLibrary, removeBookFromLibrary, moveBookToLibrary
170
+ * // indexOfLibraryBook(book | id) -> number (the book's position in its library, -1 if absent)
171
+ * // swapLibraryBooks(a, b), reorderLibraryBooks(orderedBooks), moveLibraryBookToIndex(book, index) reorder a library's books
172
+ * // book side is 'many', so a set-lookup keyed by a full book-set: libraryByBooks(...books | ids) -> Library
173
+ */
174
+ const withEntityRelationship = (from, to, options) => {
175
+ const fromName = from.collection;
176
+ const toName = to.collection;
177
+ const fromPlural = from.plural ?? `${fromName}s`;
178
+ const toPlural = to.plural ?? `${toName}s`;
179
+ const fromIsSingle = from.count === 1;
180
+ const toIsSingle = to.count === 1;
181
+ const autoIndex = options?.autoIndex ?? true;
182
+ const syncForeignKeys = options?.syncForeignKeys ?? true;
183
+ const compositeLookups = options?.compositeLookups ?? true;
184
+ const readFromPrimaryId = toIdReader(from.selectId, 'id');
185
+ const readToPrimaryId = toIdReader(to.selectId, 'id');
186
+ // Default foreign-key name pluralizes when the side points at MORE THAN ONE of the other.
187
+ const fromForeignKeyDefault = toIsSingle ? `${toName}Id` : `${toName}Ids`;
188
+ const toForeignKeyDefault = fromIsSingle ? `${fromName}Id` : `${fromName}Ids`;
189
+ const readFromForeignIds = toIdsReader(from.selectForeignId, fromForeignKeyDefault);
190
+ const readToForeignIds = toIdsReader(to.selectForeignId, toForeignKeyDefault);
191
+ // The WRITABLE foreign-key property per side, for syncForeignKeys — undefined when the accessor is
192
+ // a getter, which can't be written back.
193
+ const fromForeignKey = typeof from.selectForeignId === 'function' ? undefined : (from.selectForeignId ?? fromForeignKeyDefault);
194
+ const toForeignKey = typeof to.selectForeignId === 'function' ? undefined : (to.selectForeignId ?? toForeignKeyDefault);
195
+ const forwardLinksKey = `_${fromName}${capitalize(toName)}Links`;
196
+ const reverseLinksKey = `_${toName}${capitalize(fromName)}Links`;
197
+ const forwardMapKey = `${fromName}${capitalize(toName)}Map`;
198
+ const reverseMapKey = `${toName}${capitalize(fromName)}Map`;
199
+ const fromEntityMapKey = `${fromName}EntityMap`;
200
+ const toEntityMapKey = `${toName}EntityMap`;
201
+ const feature = signalStoreFeature(withState(() => ({
202
+ [forwardLinksKey]: {},
203
+ [reverseLinksKey]: {},
204
+ })), withComputed((innerStore) => {
205
+ // Links derived from each side's foreign key, rebuilt whenever either collection changes.
206
+ const foreignLinks = computed(() => {
207
+ const forward = {};
208
+ const backward = {};
209
+ if (!autoIndex)
210
+ return { forward, backward };
211
+ // A to-side entity naming its from-side owner(s) (e.g. book.libraryId, or a book's aisleIds).
212
+ Object.values(innerStore[toEntityMapKey]()).forEach((entity) => {
213
+ const toId = readToPrimaryId(entity);
214
+ if (!hasId(toId))
215
+ return;
216
+ readToForeignIds(entity).forEach((fromId) => {
217
+ forward[fromId] = addUnique(forward[fromId], toId);
218
+ backward[toId] = addUnique(backward[toId], fromId);
219
+ });
220
+ });
221
+ // A from-side entity naming its to-side(s) (covers the reverse direction and m:n).
222
+ Object.values(innerStore[fromEntityMapKey]()).forEach((entity) => {
223
+ const fromId = readFromPrimaryId(entity);
224
+ if (!hasId(fromId))
225
+ return;
226
+ readFromForeignIds(entity).forEach((toId) => {
227
+ forward[fromId] = addUnique(forward[fromId], toId);
228
+ backward[toId] = addUnique(backward[toId], fromId);
229
+ });
230
+ });
231
+ return { forward, backward };
232
+ }, /* @ts-ignore */
233
+ ...(ngDevMode ? [{ debugName: "foreignLinks" }] : /* istanbul ignore next */ []));
234
+ return {
235
+ [forwardMapKey]: computed(() => projectLinks(mergeLinks(innerStore[forwardLinksKey](), foreignLinks().forward), innerStore[fromEntityMapKey](), innerStore[toEntityMapKey]())),
236
+ [reverseMapKey]: computed(() => projectLinks(mergeLinks(innerStore[reverseLinksKey](), foreignLinks().backward), innerStore[toEntityMapKey](), innerStore[fromEntityMapKey]())),
237
+ };
238
+ }), withMethods((innerStore) => {
239
+ const resolveFromRef = toRefResolver(readFromPrimaryId);
240
+ const resolveToRef = toRefResolver(readToPrimaryId);
241
+ const resolveEntities = (ids, entityMapKey) => (ids ?? []).map((id) => innerStore[entityMapKey]()[id]).filter((entity) => entity !== undefined);
242
+ const writeLinks = (forward, backward) => patchState(innerStore, { [forwardLinksKey]: forward, [reverseLinksKey]: backward });
243
+ const addLink = (fromId, toId) => {
244
+ const forward = { ...innerStore[forwardLinksKey]() };
245
+ const backward = { ...innerStore[reverseLinksKey]() };
246
+ forward[fromId] = addUnique(forward[fromId], toId);
247
+ backward[toId] = addUnique(backward[toId], fromId);
248
+ writeLinks(forward, backward);
249
+ };
250
+ const fromUpdaterConfig = { collection: fromName, selectId: (entity) => readFromPrimaryId(entity) };
251
+ const toUpdaterConfig = { collection: toName, selectId: (entity) => readToPrimaryId(entity) };
252
+ /**
253
+ * Writes one entity's foreign key when syncForeignKeys is on and the side has a writable key:
254
+ * the current FK ids run through `mutate`, then an array FK is replaced with the result and a
255
+ * scalar FK with its first id (`undefined` clears it). No-ops on a missing entity or unchanged result.
256
+ */
257
+ const writeForeignIds = (entityMapKey, updaterConfig, foreignKey, isArrayForeignKey, entityId, mutate) => {
258
+ if (!syncForeignKeys || foreignKey === undefined)
259
+ return;
260
+ const entity = innerStore[entityMapKey]()[entityId];
261
+ if (entity === undefined)
262
+ return;
263
+ const current = entity[foreignKey];
264
+ const currentIds = Array.isArray(current) ? current.filter(hasId) : hasId(current) ? [current] : [];
265
+ const nextIds = mutate(currentIds);
266
+ if (nextIds.length === currentIds.length && nextIds.every((id, index) => id === currentIds[index]))
267
+ return;
268
+ const nextValue = isArrayForeignKey ? nextIds : nextIds[0];
269
+ patchState(innerStore, updateEntity({ id: entityId, changes: { [foreignKey]: nextValue } }, updaterConfig));
270
+ };
271
+ /**
272
+ * Persists an owner's reordered member list into the owner's OWN array foreign key (e.g.
273
+ * `cell.optionIds`), so a swap/reorder/move survives in the entity, not just the manual link. Gated
274
+ * by syncForeignKeys, and only when the owner actually stores that key as an array — a 1:many owner
275
+ * keeps order through its members' back-references, so there is nothing to write and we never
276
+ * fabricate a key on a side that doesn't hold one.
277
+ */
278
+ const writeOwnerForeignOrder = (entityMapKey, updaterConfig, foreignKey, ownerId, orderedIds) => {
279
+ if (!syncForeignKeys || foreignKey === undefined)
280
+ return;
281
+ const owner = innerStore[entityMapKey]()[ownerId];
282
+ if (!owner || !Array.isArray(owner[foreignKey]))
283
+ return;
284
+ writeForeignIds(entityMapKey, updaterConfig, foreignKey, true, ownerId, () => orderedIds);
285
+ };
286
+ const writeFromOwnerOrder = (ownerId, orderedIds) => writeOwnerForeignOrder(fromEntityMapKey, fromUpdaterConfig, fromForeignKey, ownerId, orderedIds);
287
+ const writeToOwnerOrder = (ownerId, orderedIds) => writeOwnerForeignOrder(toEntityMapKey, toUpdaterConfig, toForeignKey, ownerId, orderedIds);
288
+ // Removes the manual link, and (with syncForeignKeys) also clears the matching id from both
289
+ // entities' foreign keys — otherwise an FK-derived link regenerates on the next read.
290
+ const removeLink = (fromId, toId) => {
291
+ const forward = { ...innerStore[forwardLinksKey]() };
292
+ const backward = { ...innerStore[reverseLinksKey]() };
293
+ forward[fromId] = (forward[fromId] ?? []).filter((id) => id !== toId);
294
+ backward[toId] = (backward[toId] ?? []).filter((id) => id !== fromId);
295
+ writeLinks(forward, backward);
296
+ writeForeignIds(toEntityMapKey, toUpdaterConfig, toForeignKey, !fromIsSingle, toId, (ids) => ids.filter((id) => id !== fromId));
297
+ writeForeignIds(fromEntityMapKey, fromUpdaterConfig, fromForeignKey, !toIsSingle, fromId, (ids) => ids.filter((id) => id !== toId));
298
+ };
299
+ const moveTo = (toId, newFromId) => {
300
+ const forward = { ...innerStore[forwardLinksKey]() };
301
+ const backward = { ...innerStore[reverseLinksKey]() };
302
+ (backward[toId] ?? []).forEach((fromId) => {
303
+ forward[fromId] = (forward[fromId] ?? []).filter((id) => id !== toId);
304
+ });
305
+ backward[toId] = [newFromId];
306
+ forward[newFromId] = addUnique(forward[newFromId], toId);
307
+ writeLinks(forward, backward);
308
+ // With syncForeignKeys: re-point the to-entity's FK, and move the to-id from every old
309
+ // from-entity's FK to the new one.
310
+ writeForeignIds(toEntityMapKey, toUpdaterConfig, toForeignKey, !fromIsSingle, toId, () => [newFromId]);
311
+ if (syncForeignKeys && fromForeignKey !== undefined) {
312
+ Object.values(innerStore[fromEntityMapKey]()).forEach((entity) => {
313
+ const fromId = readFromPrimaryId(entity);
314
+ if (!hasId(fromId) || fromId === newFromId)
315
+ return;
316
+ writeForeignIds(fromEntityMapKey, fromUpdaterConfig, fromForeignKey, !toIsSingle, fromId, (ids) => ids.filter((id) => id !== toId));
317
+ });
318
+ writeForeignIds(fromEntityMapKey, fromUpdaterConfig, fromForeignKey, !toIsSingle, newFromId, (ids) => addUnique(ids, toId));
319
+ }
320
+ };
321
+ const toEntityLead = toIsSingle ? toName : toPlural;
322
+ const toIdLead = toIsSingle ? `${toName}Id` : `${toName}Ids`;
323
+ const fromEntityLead = fromIsSingle ? fromName : fromPlural;
324
+ const fromIdLead = fromIsSingle ? `${fromName}Id` : `${fromName}Ids`;
325
+ const capitalizedFrom = capitalize(fromName);
326
+ const capitalizedTo = capitalize(toName);
327
+ const toIdsByFrom = (fromId) => hasId(fromId) ? innerStore[forwardMapKey]()[fromId] ?? [] : [];
328
+ const fromIdsByTo = (toId) => hasId(toId) ? innerStore[reverseMapKey]()[toId] ?? [] : [];
329
+ // The adjacency maps are already pruned (projectLinks), so their ids resolve to live entities.
330
+ const shapeToEntities = (ids) => toIsSingle ? resolveEntities(ids, toEntityMapKey)[0] : resolveEntities(ids, toEntityMapKey);
331
+ const shapeFromEntities = (ids) => fromIsSingle ? resolveEntities(ids, fromEntityMapKey)[0] : resolveEntities(ids, fromEntityMapKey);
332
+ const shapeToIds = (ids) => (toIsSingle ? ids[0] : ids);
333
+ const shapeFromIds = (ids) => (fromIsSingle ? ids[0] : ids);
334
+ const methods = {
335
+ [`${toEntityLead}By${capitalizedFrom}`]: (from) => shapeToEntities(toIdsByFrom(resolveFromRef(from))),
336
+ [`${toIdLead}By${capitalizedFrom}`]: (from) => shapeToIds(toIdsByFrom(resolveFromRef(from))),
337
+ [`${fromEntityLead}By${capitalizedTo}`]: (to) => shapeFromEntities(fromIdsByTo(resolveToRef(to))),
338
+ [`${fromIdLead}By${capitalizedTo}`]: (to) => shapeFromIds(fromIdsByTo(resolveToRef(to))),
339
+ [`${fromName}Has${capitalizedTo}`]: (from, to) => (innerStore[forwardMapKey]()[resolveFromRef(from)] ?? []).includes(resolveToRef(to)),
340
+ [`${toName}Has${capitalizedFrom}`]: (to, from) => (innerStore[reverseMapKey]()[resolveToRef(to)] ?? []).includes(resolveFromRef(from)),
341
+ [`${toName}CountBy${capitalizedFrom}`]: (from) => (innerStore[forwardMapKey]()[resolveFromRef(from)] ?? []).length,
342
+ [`${fromName}CountBy${capitalizedTo}`]: (to) => (innerStore[reverseMapKey]()[resolveToRef(to)] ?? []).length,
343
+ [`add${capitalizedTo}To${capitalizedFrom}`]: (from, to) => addLink(resolveFromRef(from), resolveToRef(to)),
344
+ [`remove${capitalizedTo}From${capitalizedFrom}`]: (from, to) => removeLink(resolveFromRef(from), resolveToRef(to)),
345
+ [`move${capitalizedTo}To${capitalizedFrom}`]: (to, newFrom) => moveTo(resolveToRef(to), resolveFromRef(newFrom)),
346
+ };
347
+ // A set of the input side's ids keys the other side. Collect ALL return-entities per set so the
348
+ // lookup can hand back every match; shape to single or array by the same rule as the type layer.
349
+ // JSON-encode the sorted ids so the key is collision-proof: an id that contains a plain
350
+ // delimiter (`a|b`) can't merge with another set, since every element is quoted and escaped.
351
+ const keyOf = (ids) => JSON.stringify(ids.map(String).sort());
352
+ const isFixedCount = (count) => typeof count === 'number' && count >= 2;
353
+ const isMulti = (count) => count !== 1;
354
+ const addSetLookup = (returnName, returnPlural, returnCount, inputPlural, inputCount, sourceMapKey, returnEntityMapKey, readInputId) => {
355
+ const index = computed(() => {
356
+ const built = {};
357
+ const source = innerStore[sourceMapKey]();
358
+ Object.keys(source).forEach((returnId) => {
359
+ const key = keyOf(source[returnId]);
360
+ built[key] = addUnique(built[key], returnId);
361
+ });
362
+ return built;
363
+ }, /* @ts-ignore */
364
+ ...(ngDevMode ? [{ debugName: "index" }] : /* istanbul ignore next */ []));
365
+ const resolveInputRef = toRefResolver(readInputId);
366
+ const lookupIds = (refs) => index()[keyOf(refs.map((ref) => resolveInputRef(ref)))] ?? [];
367
+ const single = isFixedCount(inputCount) || returnCount === 1;
368
+ const entityLead = single ? returnName : returnPlural;
369
+ const idLead = single ? `${returnName}Id` : `${returnName}Ids`;
370
+ const capitalizedInputPlural = capitalize(inputPlural);
371
+ methods[`${entityLead}By${capitalizedInputPlural}`] = (...refs) => {
372
+ const entities = resolveEntities(lookupIds(refs), returnEntityMapKey);
373
+ return single ? entities[0] : entities;
374
+ };
375
+ methods[`${idLead}By${capitalizedInputPlural}`] = (...refs) => {
376
+ const ids = lookupIds(refs);
377
+ return single ? ids[0] : ids;
378
+ };
379
+ };
380
+ // Ordering + indexOf over an owner's member list. The owner's ordered members are its entry in the
381
+ // manual links map; mergeLinks keeps that array's order ahead of the FK-derived links, so writing it
382
+ // sets the order the get-by lookup returns. With syncForeignKeys the same order is also written into
383
+ // the owner entity's array foreign key (when it holds one). swap/reorder/move share this core, keyed
384
+ // by owner id.
385
+ const makeOrderingCore = (linksKey, membersOf, writeOwnerOrder) => {
386
+ const writeOrder = (ownerId, orderedIds) => {
387
+ patchState(innerStore, { [linksKey]: { ...innerStore[linksKey](), [ownerId]: orderedIds } });
388
+ writeOwnerOrder(ownerId, orderedIds);
389
+ };
390
+ return {
391
+ swap: (label, ownerId, aId, bId) => {
392
+ const current = [...membersOf(ownerId)];
393
+ const indexA = current.indexOf(aId);
394
+ const indexB = current.indexOf(bId);
395
+ if (indexA === -1 || indexB === -1) {
396
+ console.warn(`signalkin: ${label} — both members must belong to that owner; call ignored.`);
397
+ return;
398
+ }
399
+ [current[indexA], current[indexB]] = [current[indexB], current[indexA]];
400
+ writeOrder(ownerId, current);
401
+ },
402
+ reorder: (label, ownerId, orderedIds) => {
403
+ const current = membersOf(ownerId);
404
+ const isFullPermutation = orderedIds.length === current.length &&
405
+ new Set(orderedIds).size === orderedIds.length &&
406
+ orderedIds.every((id) => current.includes(id));
407
+ if (!isFullPermutation) {
408
+ console.warn(`signalkin: ${label} — pass that owner's complete member set exactly once; call ignored.`);
409
+ return;
410
+ }
411
+ writeOrder(ownerId, orderedIds);
412
+ },
413
+ move: (ownerId, memberId, index) => {
414
+ const current = [...membersOf(ownerId)];
415
+ const currentIndex = current.indexOf(memberId);
416
+ if (currentIndex === -1)
417
+ return;
418
+ const target = Math.max(0, Math.min(index, current.length - 1));
419
+ current.splice(currentIndex, 1);
420
+ current.splice(target, 0, memberId);
421
+ writeOrder(ownerId, current);
422
+ },
423
+ };
424
+ };
425
+ // One-arg family: the member has exactly one owner (owner side count 1), so it is inferred and only
426
+ // the member is passed. indexOf<Owner><Member>(member) + swap/reorder/move<Owner><Member(s)>.
427
+ const installOrdering = (capitalizedOwner, capitalizedMember, capitalizedMemberPlural, linksKey, membersOf, ownerOf, resolveMember, writeOwnerOrder) => {
428
+ const core = makeOrderingCore(linksKey, membersOf, writeOwnerOrder);
429
+ methods[`indexOf${capitalizedOwner}${capitalizedMember}`] = (member) => {
430
+ const memberId = resolveMember(member);
431
+ return membersOf(ownerOf(memberId)).indexOf(memberId);
432
+ };
433
+ methods[`swap${capitalizedOwner}${capitalizedMemberPlural}`] = (memberA, memberB) => {
434
+ const aId = resolveMember(memberA);
435
+ const ownerId = ownerOf(aId);
436
+ if (!hasId(ownerId))
437
+ return;
438
+ core.swap(`swap${capitalizedOwner}${capitalizedMemberPlural}`, ownerId, aId, resolveMember(memberB));
439
+ };
440
+ methods[`reorder${capitalizedOwner}${capitalizedMemberPlural}`] = (orderedMembers) => {
441
+ const orderedIds = (orderedMembers ?? []).map(resolveMember).filter(hasId);
442
+ const ownerId = ownerOf(orderedIds[0]);
443
+ if (!hasId(ownerId))
444
+ return;
445
+ core.reorder(`reorder${capitalizedOwner}${capitalizedMemberPlural}`, ownerId, orderedIds);
446
+ };
447
+ methods[`move${capitalizedOwner}${capitalizedMember}ToIndex`] = (member, index) => {
448
+ const memberId = resolveMember(member);
449
+ const ownerId = ownerOf(memberId);
450
+ if (!hasId(ownerId))
451
+ return;
452
+ core.move(ownerId, memberId, index);
453
+ };
454
+ };
455
+ // By-family: the member has MORE THAN ONE owner (owner side not count 1) and belongs to more than
456
+ // one of each owner (member side multi — fixed 2+ or 'many'), so the owner can't be inferred and is
457
+ // named as the last argument.
458
+ const installByOrdering = (capitalizedOwner, capitalizedMember, capitalizedMemberPlural, linksKey, membersOf, resolveOwner, resolveMember, writeOwnerOrder) => {
459
+ const core = makeOrderingCore(linksKey, membersOf, writeOwnerOrder);
460
+ methods[`indexOf${capitalizedMember}By${capitalizedOwner}`] = (member, owner) => membersOf(resolveOwner(owner)).indexOf(resolveMember(member));
461
+ methods[`swap${capitalizedMemberPlural}By${capitalizedOwner}`] = (memberA, memberB, owner) => {
462
+ const ownerId = resolveOwner(owner);
463
+ if (!hasId(ownerId))
464
+ return;
465
+ core.swap(`swap${capitalizedMemberPlural}By${capitalizedOwner}`, ownerId, resolveMember(memberA), resolveMember(memberB));
466
+ };
467
+ methods[`reorder${capitalizedMemberPlural}By${capitalizedOwner}`] = (orderedMembers, owner) => {
468
+ const ownerId = resolveOwner(owner);
469
+ if (!hasId(ownerId))
470
+ return;
471
+ core.reorder(`reorder${capitalizedMemberPlural}By${capitalizedOwner}`, ownerId, (orderedMembers ?? []).map(resolveMember).filter(hasId));
472
+ };
473
+ methods[`move${capitalizedMember}By${capitalizedOwner}ToIndex`] = (member, index, owner) => {
474
+ const ownerId = resolveOwner(owner);
475
+ if (!hasId(ownerId))
476
+ return;
477
+ core.move(ownerId, resolveMember(member), index);
478
+ };
479
+ };
480
+ // Filtered directional lookup: the owners where the member sits at EXACTLY `index` in the owner's
481
+ // ordered member list. Installed in the same By-family cases as installByOrdering (member 'many',
482
+ // owner multi) — a single-owner member has no choice of slot to filter on. Returns entities and ids.
483
+ const installIndexedLookup = (ownerName, ownerPlural, capitalizedMember, ownerEntityMapKey, membersOf, ownersOf, resolveMember) => {
484
+ const ownerIdsAtIndex = (member, index) => {
485
+ const memberId = resolveMember(member);
486
+ return ownersOf(memberId).filter((ownerId) => membersOf(ownerId)[index] === memberId);
487
+ };
488
+ methods[`${ownerPlural}By${capitalizedMember}AtIndex`] = (member, index) => resolveEntities(ownerIdsAtIndex(member, index), ownerEntityMapKey);
489
+ methods[`${ownerName}IdsBy${capitalizedMember}AtIndex`] = (member, index) => ownerIdsAtIndex(member, index);
490
+ };
491
+ // Direction owner = from, members = to (e.g. a feature's options, or an option's cells). A single
492
+ // owner is inferred (one-arg installOrdering); a multi owner is named explicitly (installByOrdering),
493
+ // now for any multi member side (fixed count 2+ or 'many'), not just 'many'.
494
+ if (isMulti(to.count)) {
495
+ if (fromIsSingle) {
496
+ installOrdering(capitalizedFrom, capitalizedTo, capitalize(toPlural), forwardLinksKey, (ownerId) => toIdsByFrom(ownerId), (memberId) => fromIdsByTo(memberId)[0], resolveToRef, writeFromOwnerOrder);
497
+ }
498
+ else {
499
+ installByOrdering(capitalizedFrom, capitalizedTo, capitalize(toPlural), forwardLinksKey, (ownerId) => toIdsByFrom(ownerId), resolveFromRef, resolveToRef, writeFromOwnerOrder);
500
+ }
501
+ }
502
+ // Direction owner = to, members = from.
503
+ if (isMulti(from.count)) {
504
+ if (toIsSingle) {
505
+ installOrdering(capitalizedTo, capitalizedFrom, capitalize(fromPlural), reverseLinksKey, (ownerId) => fromIdsByTo(ownerId), (memberId) => toIdsByFrom(memberId)[0], resolveFromRef, writeToOwnerOrder);
506
+ }
507
+ else {
508
+ installByOrdering(capitalizedTo, capitalizedFrom, capitalize(fromPlural), reverseLinksKey, (ownerId) => fromIdsByTo(ownerId), resolveToRef, resolveFromRef, writeToOwnerOrder);
509
+ }
510
+ }
511
+ // AtIndex filtered lookups apply whenever BOTH sides are multi: the owner holds an ordered array of
512
+ // 2+ members and the member belongs to more than one owner, so it can sit at a different slot in
513
+ // each. This is broader than the 'many'-only by-ordering gate above — a fixed-count member (a cell's
514
+ // two optionIds, an ensemble's four musicians) still gets it. Both directions.
515
+ if (isMulti(from.count) && isMulti(to.count)) {
516
+ installIndexedLookup(fromName, fromPlural, capitalizedTo, fromEntityMapKey, (ownerId) => toIdsByFrom(ownerId), (memberId) => fromIdsByTo(memberId), resolveToRef);
517
+ installIndexedLookup(toName, toPlural, capitalizedFrom, toEntityMapKey, (ownerId) => fromIdsByTo(ownerId), (memberId) => toIdsByFrom(memberId), resolveFromRef);
518
+ }
519
+ if (compositeLookups) {
520
+ // from's id-set keys the to-side (source: to -> its froms).
521
+ if (isMulti(from.count)) {
522
+ addSetLookup(toName, toPlural, to.count, fromPlural, from.count, reverseMapKey, toEntityMapKey, readFromPrimaryId);
523
+ }
524
+ // to's id-set keys the from-side (source: from -> its tos).
525
+ if (isMulti(to.count)) {
526
+ addSetLookup(fromName, fromPlural, from.count, toPlural, to.count, forwardMapKey, fromEntityMapKey, readToPrimaryId);
527
+ }
528
+ }
529
+ return methods;
530
+ }));
531
+ return feature;
532
+ };
533
+
534
+ /**
535
+ * Read-only lookups between two collections that share no foreign key, by walking the intermediary collection(s) between them (`through` a single name or an ordered list); returns arrays both directions.
536
+ *
537
+ * @example
538
+ * signalStore(
539
+ * withEntities(libraryConfig), withEntities(shelfConfig), withEntities(bookConfig),
540
+ * withEntityRelationship({ collection: 'library', count: 1 }, { collection: 'shelf', count: 'many' }),
541
+ * withEntityRelationship({ collection: 'shelf', count: 1 }, { collection: 'book', count: 'many' }),
542
+ * withTransitiveRelationship({ from: 'library', to: 'book', through: 'shelf' }),
543
+ * );
544
+ * // booksByLibrary(library | id) -> Book[]
545
+ * // librariesByBook(book | id) -> Library[]
546
+ */
547
+ const withTransitiveRelationship = (config) => {
548
+ const { from, to, through } = config;
549
+ const fromPlural = config.fromPlural ?? `${from}s`;
550
+ const toPlural = config.toPlural ?? `${to}s`;
551
+ const path = [from, ...(Array.isArray(through) ? through : [through]), to];
552
+ // Adjacency map keys along the path, forward (from→to) and reverse (to→from).
553
+ const forwardMapKeys = path.slice(0, -1).map((hop, index) => `${hop}${capitalize(path[index + 1])}Map`);
554
+ const reverseMapKeys = path.slice(1).map((hop, index) => `${hop}${capitalize(path[index])}Map`).reverse();
555
+ const fromEntityMapKey = `${from}EntityMap`;
556
+ const toEntityMapKey = `${to}EntityMap`;
557
+ const readFromId = toIdReader(config.selectFromId, 'id');
558
+ const readToId = toIdReader(config.selectToId, 'id');
559
+ const capitalizedFrom = capitalize(from);
560
+ const capitalizedTo = capitalize(to);
561
+ const feature = signalStoreFeature(withMethods((innerStore) => {
562
+ // Walk the chain of adjacency maps, flattening + deduping ids hop by hop.
563
+ const walk = (startId, mapKeys) => hasId(startId)
564
+ ? mapKeys.reduce((ids, mapKey) => {
565
+ if (typeof innerStore[mapKey] !== 'function') {
566
+ throw new Error(`signalkin: '${mapKey}' is not on the store — a transitive relationship needs the direct ` +
567
+ `withEntityRelationship for every hop on its path registered before it (path: ${path.join(' → ')}).`);
568
+ }
569
+ const map = innerStore[mapKey]();
570
+ return Array.from(new Set(ids.flatMap((id) => map[id] ?? [])));
571
+ }, [startId])
572
+ : [];
573
+ const resolve = (ids, entityMapKey) => ids.map((id) => innerStore[entityMapKey]()[id]).filter((entity) => entity !== undefined);
574
+ const toIdsByFrom = (fromId) => walk(fromId, forwardMapKeys);
575
+ const fromIdsByTo = (toId) => walk(toId, reverseMapKeys);
576
+ const resolveFromRef = toRefResolver(readFromId);
577
+ const resolveToRef = toRefResolver(readToId);
578
+ const methods = {
579
+ [`${toPlural}By${capitalizedFrom}`]: (from) => resolve(toIdsByFrom(resolveFromRef(from)), toEntityMapKey),
580
+ [`${to}IdsBy${capitalizedFrom}`]: (from) => toIdsByFrom(resolveFromRef(from)),
581
+ [`${fromPlural}By${capitalizedTo}`]: (to) => resolve(fromIdsByTo(resolveToRef(to)), fromEntityMapKey),
582
+ [`${from}IdsBy${capitalizedTo}`]: (to) => fromIdsByTo(resolveToRef(to)),
583
+ };
584
+ return methods;
585
+ }));
586
+ return feature;
587
+ };
588
+
589
+ /*
590
+ * Public API Surface of signalkin
591
+ */
592
+
593
+ /**
594
+ * Generated bundle index. Do not edit.
595
+ */
596
+
597
+ export { withEntityAccessors, withEntityRelationship, withTransitiveRelationship };
598
+ //# sourceMappingURL=signalkin.mjs.map