-
Notifications
You must be signed in to change notification settings - Fork 44
feat/resource/value as proxy #270
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a594d32
5a9ae26
94d370c
d9a380f
17884ab
f2e6333
9c650cf
6c53432
081e826
66892bc
3abfabf
9fe4af1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| export type Assert<T extends true> = T; | ||
| export type AssertNot<T extends false> = T; | ||
|
|
||
| export type IsEqual<T, U> = [T] extends [U] | ||
| ? [U] extends [T] | ||
| ? true | ||
| : false | ||
| : false; | ||
|
|
||
| export type Satisfies<T, U> = T extends U ? true : false; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { ResourceRef, Signal, computed, linkedSignal } from '@angular/core'; | ||
| import { ResourceRef, Signal, isSignal, linkedSignal } from '@angular/core'; | ||
| import { | ||
| SignalStoreFeature, | ||
| SignalStoreFeatureResult, | ||
|
|
@@ -56,7 +56,7 @@ import { | |
| * ); | ||
| * | ||
| * const store = TestBed.inject(Store); | ||
| * store.status(); // 'idle' | 'loading' | 'resolved' | 'error' | ||
| * store.status(); // 'idle' | 'loading' | 'resolved' | 'error' | 'local' | ||
| * store.value(); // Todo[] | ||
| * store.ids(); // EntityId[] | ||
| * store.entityMap(); // Record<EntityId, Todo> | ||
|
|
@@ -93,7 +93,7 @@ export function withEntityResources< | |
| >( | ||
| resourceFactory: ( | ||
| store: Input['props'] & Input['methods'] & StateSignals<Input['state']>, | ||
| ) => ResourceRef<readonly Entity[] | Entity[] | undefined>, | ||
| ) => ResourceRef<TypedEntityResourceValue<Entity>>, | ||
| ): SignalStoreFeature<Input, EntityResourceResult<Entity>>; | ||
|
|
||
| export function withEntityResources< | ||
|
|
@@ -107,7 +107,7 @@ export function withEntityResources< | |
|
|
||
| export function withEntityResources< | ||
| Input extends SignalStoreFeatureResult, | ||
| ResourceValue extends readonly unknown[] | unknown[] | undefined, | ||
| ResourceValue extends EntityResourceValue, | ||
| >( | ||
| entityResourceFactory: ( | ||
| store: Input['props'] & Input['methods'] & StateSignals<Input['state']>, | ||
|
|
@@ -127,52 +127,93 @@ export function withEntityResources< | |
| }; | ||
| } | ||
|
|
||
| function createUnnamedEntityResource< | ||
| R extends ResourceRef<readonly unknown[] | unknown[] | undefined>, | ||
| >(resource: R) { | ||
| type E = InferEntityFromRef<R> & { id: EntityId }; | ||
| const { idsLinked, entityMapLinked, entitiesSignal } = | ||
| createEntityDerivations<E>( | ||
| resource.value as Signal<readonly E[] | E[] | undefined>, | ||
| ); | ||
|
|
||
| /** | ||
| * We cannot use the value of `resource` directly, but | ||
| * have to use the one created through {@link withResource} | ||
| * because {@link withResource} creates a Proxy around the resource value | ||
| * to avoid the error throwing behavior of the Resource API. | ||
| */ | ||
| function createUnnamedEntityResource<E extends Entity>( | ||
| resource: ResourceRef<TypedEntityResourceValue<E>>, | ||
| ) { | ||
| return signalStoreFeature( | ||
| withResource(() => resource), | ||
| withLinkedState(() => ({ | ||
| entityMap: entityMapLinked, | ||
| ids: idsLinked, | ||
| })), | ||
| withComputed(() => ({ | ||
| entities: entitiesSignal, | ||
| withLinkedState(({ value }) => { | ||
| const { ids, entityMap } = createEntityDerivations(value); | ||
|
|
||
| return { | ||
| entityMap, | ||
| ids, | ||
| }; | ||
| }), | ||
| withComputed(({ ids, entityMap }) => ({ | ||
| entities: createComputedEntities(ids, entityMap), | ||
| })), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * See {@link createUnnamedEntityResource} for why we cannot use the value of `resource` directly. | ||
| */ | ||
| function createNamedEntityResources<Dictionary extends EntityDictionary>( | ||
| dictionary: Dictionary, | ||
| ) { | ||
| const keys = Object.keys(dictionary); | ||
|
|
||
| const linkedState: Record<string, Signal<unknown>> = {}; | ||
| const computedProps: Record<string, Signal<unknown>> = {}; | ||
| const stateFactories = keys.map((name) => { | ||
| return (store: Record<string, unknown>) => { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll burn in the TypeScript hell for what I've done here 🔥😈 |
||
| const resourceValue = store[ | ||
| `${name}Value` | ||
| ] as Signal<EntityResourceValue>; | ||
| if (!isSignal(resourceValue)) { | ||
| throw new Error(`Resource's value ${name}Value does not exist`); | ||
| } | ||
|
|
||
| const { ids, entityMap } = createEntityDerivations(resourceValue); | ||
|
|
||
| return { | ||
| [`${name}EntityMap`]: entityMap, | ||
| [`${name}Ids`]: ids, | ||
| }; | ||
| }; | ||
| }); | ||
|
|
||
| const computedFactories = keys.map((name) => { | ||
| return (store: Record<string, unknown>) => { | ||
| const ids = store[`${name}Ids`] as Signal<EntityId[]>; | ||
| const entityMap = store[`${name}EntityMap`] as Signal< | ||
| Record<EntityId, Entity> | ||
| >; | ||
|
|
||
| keys.forEach((name) => { | ||
| const ref = dictionary[name]; | ||
| type E = InferEntityFromRef<typeof ref> & { id: EntityId }; | ||
| const { idsLinked, entityMapLinked, entitiesSignal } = | ||
| createEntityDerivations<E>( | ||
| ref.value as Signal<readonly E[] | E[] | undefined>, | ||
| ); | ||
| if (!isSignal(ids)) { | ||
| throw new Error(`Entity Resource's ids ${name}Ids does not exist`); | ||
| } | ||
| if (!isSignal(entityMap)) { | ||
| throw new Error( | ||
| `Entity Resource's entityMap ${name}EntityMap does not exist`, | ||
| ); | ||
| } | ||
|
|
||
| linkedState[`${String(name)}EntityMap`] = entityMapLinked; | ||
| linkedState[`${String(name)}Ids`] = idsLinked; | ||
| computedProps[`${String(name)}Entities`] = entitiesSignal; | ||
| return { | ||
| [`${name}Entities`]: createComputedEntities(ids, entityMap), | ||
| }; | ||
| }; | ||
| }); | ||
|
|
||
| return signalStoreFeature( | ||
| withResource(() => dictionary), | ||
| withLinkedState(() => linkedState), | ||
| withComputed(() => computedProps), | ||
| withLinkedState((store) => | ||
| stateFactories.reduce( | ||
| (acc, factory) => ({ ...acc, ...factory(store) }), | ||
| {}, | ||
| ), | ||
| ), | ||
| withComputed((store) => | ||
| computedFactories.reduce( | ||
| (acc, factory) => ({ ...acc, ...factory(store) }), | ||
| {}, | ||
| ), | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -209,20 +250,19 @@ type ArrayElement<T> = T extends readonly (infer E)[] | (infer E)[] ? E : never; | |
| type InferEntityFromSignal<T> = | ||
| T extends Signal<infer V> ? ArrayElement<V> : never; | ||
|
|
||
| type InferEntityFromRef< | ||
| R extends ResourceRef<readonly unknown[] | unknown[] | undefined>, | ||
| > = R['value'] extends Signal<infer V> ? ArrayElement<V> : never; | ||
|
|
||
| type MergeUnion<U> = (U extends unknown ? (k: U) => void : never) extends ( | ||
| k: infer I, | ||
| ) => void | ||
| ? I | ||
| : never; | ||
|
|
||
| export type EntityDictionary = Record< | ||
| string, | ||
| ResourceRef<readonly unknown[] | unknown[] | undefined> | ||
| >; | ||
| type Entity = { id: EntityId }; | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. these additional type short the code above |
||
|
|
||
| type EntityResourceValue = Entity[] | (Entity[] | undefined); | ||
|
|
||
| type TypedEntityResourceValue<E extends Entity> = E[] | (E[] | undefined); | ||
|
|
||
| export type EntityDictionary = Record<string, ResourceRef<EntityResourceValue>>; | ||
|
|
||
| type MergeNamedEntityStates<T extends EntityDictionary> = MergeUnion< | ||
| { | ||
|
|
@@ -249,21 +289,20 @@ type MergeNamedEntityProps<T extends EntityDictionary> = MergeUnion< | |
| >; | ||
|
|
||
| export type NamedEntityResourceResult<T extends EntityDictionary> = { | ||
| state: NamedResourceResult<T>['state'] & MergeNamedEntityStates<T>; | ||
| props: NamedResourceResult<T>['props'] & MergeNamedEntityProps<T>; | ||
| methods: NamedResourceResult<T>['methods']; | ||
| state: NamedResourceResult<T, false>['state'] & MergeNamedEntityStates<T>; | ||
| props: NamedResourceResult<T, false>['props'] & MergeNamedEntityProps<T>; | ||
| methods: NamedResourceResult<T, false>['methods']; | ||
| }; | ||
|
|
||
| /** | ||
| * @internal | ||
| * @description | ||
| * | ||
| * Creates the three entity-related signals (`ids`, `entityMap`, `entities`) from | ||
| * Creates the two entity-related state properties (`ids`, `entityMap`) from | ||
| * a single source signal of entities. This mirrors the public contract of | ||
| * `withEntities()`: | ||
| * - `ids`: derived list of entity ids | ||
| * - `entityMap`: map of id -> entity | ||
| * - `entities`: projection of `ids` through `entityMap` | ||
| * | ||
| * Implementation details: | ||
| * - Uses `withLinkedState` + `linkedSignal` for `ids` and `entityMap` so they are | ||
|
|
@@ -280,15 +319,15 @@ export type NamedEntityResourceResult<T extends EntityDictionary> = { | |
| * derived from signals. Using linked signals keeps the data flow declarative | ||
| * and avoids imperative syncing code. | ||
| */ | ||
| function createEntityDerivations<E extends { id: EntityId }>( | ||
| source: Signal<readonly E[] | E[] | undefined>, | ||
| function createEntityDerivations<E extends Entity>( | ||
| source: Signal<TypedEntityResourceValue<E>>, | ||
| ) { | ||
| const idsLinked = linkedSignal({ | ||
| const ids = linkedSignal({ | ||
| source, | ||
| computation: (list) => (list ?? []).map((e) => e.id), | ||
| }); | ||
|
|
||
| const entityMapLinked = linkedSignal({ | ||
| const entityMap = linkedSignal({ | ||
| source, | ||
| computation: (list) => { | ||
| const map = {} as Record<EntityId, E>; | ||
|
|
@@ -299,11 +338,14 @@ function createEntityDerivations<E extends { id: EntityId }>( | |
| }, | ||
| }); | ||
|
|
||
| const entitiesSignal = computed(() => { | ||
| const ids = idsLinked(); | ||
| const map = entityMapLinked(); | ||
| return ids.map((id) => map[id]) as readonly E[]; | ||
| }); | ||
| return { ids, entityMap }; | ||
| } | ||
|
|
||
| return { idsLinked, entityMapLinked, entitiesSignal }; | ||
| function createComputedEntities<E extends Entity>( | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. was necessary to have two functions because of the later access resource's value |
||
| ids: Signal<EntityId[]>, | ||
| entityMap: Signal<Record<EntityId, E>>, | ||
| ) { | ||
| return () => { | ||
| return ids().map((id) => entityMap()[id]); | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@wolfmanfx that's is for you. I had to make some impactful changes because the version before was directly using the original resource's value and not the proxied one, which means
idsandentityMapwould throw but notvalue