Eric Bower
·
2025-06-06
table.ts
1import { createSelector } from "reselect";
2import type { AnyState, IdProp } from "../../types.js";
3import type { BaseSchema } from "../types.js";
4
5interface PropId {
6 id: IdProp;
7}
8
9interface PropIds {
10 ids: IdProp[];
11}
12
13interface PatchEntity<T> {
14 [key: string]: Partial<T[keyof T]>;
15}
16
17const excludesFalse = <T>(n?: T): n is T => Boolean(n);
18
19function mustSelectEntity<Entity extends AnyState = AnyState>(
20 defaultEntity: Entity | (() => Entity),
21) {
22 const isFn = typeof defaultEntity === "function";
23
24 return function selectEntity<S extends AnyState = AnyState>(
25 selectById: (s: S, p: PropId) => Entity | undefined,
26 ) {
27 return (state: S, { id }: PropId): Entity => {
28 if (isFn) {
29 const entity = defaultEntity as () => Entity;
30 return selectById(state, { id }) || entity();
31 }
32
33 return selectById(state, { id }) || (defaultEntity as Entity);
34 };
35 };
36}
37
38function tableSelectors<
39 Entity extends AnyState = AnyState,
40 S extends AnyState = AnyState,
41>(
42 selectTable: (s: S) => Record<IdProp, Entity>,
43 empty?: Entity | (() => Entity) | undefined,
44) {
45 const must = empty ? mustSelectEntity(empty) : null;
46 const tableAsList = (data: Record<IdProp, Entity>): Entity[] =>
47 Object.values(data).filter(excludesFalse);
48 const findById = (data: Record<IdProp, Entity>, { id }: PropId) => data[id];
49 const findByIds = (
50 data: Record<IdProp, Entity>,
51 { ids }: PropIds,
52 ): Entity[] => ids.map((id) => data[id]).filter(excludesFalse);
53 const selectById = (
54 state: S,
55 { id }: PropId,
56 ): typeof empty extends undefined ? Entity | undefined : Entity => {
57 const data = selectTable(state);
58 return findById(data, { id });
59 };
60
61 const sbi = must ? must(selectById) : selectById;
62
63 return {
64 findById: must ? must(findById) : findById,
65 findByIds,
66 tableAsList,
67 selectTable,
68 selectTableAsList: createSelector(selectTable, (data): Entity[] =>
69 tableAsList(data),
70 ),
71 selectById: sbi,
72 selectByIds: createSelector(
73 selectTable,
74 (_: S, p: PropIds) => p.ids,
75 (data, ids) => findByIds(data, { ids }),
76 ),
77 };
78}
79
80export interface TableOutput<
81 Entity extends AnyState,
82 S extends AnyState,
83 Empty extends Entity | undefined = Entity | undefined,
84> extends BaseSchema<Record<IdProp, Entity>> {
85 schema: "table";
86 initialState: Record<IdProp, Entity>;
87 empty: Empty;
88 add: (e: Record<IdProp, Entity>) => (s: S) => void;
89 set: (e: Record<IdProp, Entity>) => (s: S) => void;
90 remove: (ids: IdProp[]) => (s: S) => void;
91 patch: (e: PatchEntity<Record<IdProp, Entity>>) => (s: S) => void;
92 merge: (e: PatchEntity<Record<IdProp, Entity>>) => (s: S) => void;
93 reset: () => (s: S) => void;
94 findById: (d: Record<IdProp, Entity>, { id }: PropId) => Empty;
95 findByIds: (d: Record<IdProp, Entity>, { ids }: PropIds) => Entity[];
96 tableAsList: (d: Record<IdProp, Entity>) => Entity[];
97 selectTable: (s: S) => Record<IdProp, Entity>;
98 selectTableAsList: (state: S) => Entity[];
99 selectById: (s: S, p: PropId) => Empty;
100 selectByIds: (s: S, p: PropIds) => Entity[];
101}
102
103export function createTable<
104 Entity extends AnyState = AnyState,
105 S extends AnyState = AnyState,
106>(p: {
107 name: keyof S;
108 initialState?: Record<IdProp, Entity>;
109 empty: Entity | (() => Entity);
110}): TableOutput<Entity, S, Entity>;
111export function createTable<
112 Entity extends AnyState = AnyState,
113 S extends AnyState = AnyState,
114>(p: {
115 name: keyof S;
116 initialState?: Record<IdProp, Entity>;
117 empty?: Entity | (() => Entity);
118}): TableOutput<Entity, S, Entity | undefined>;
119export function createTable<
120 Entity extends AnyState = AnyState,
121 S extends AnyState = AnyState,
122>({
123 name,
124 empty,
125 initialState = {},
126}: {
127 name: keyof S;
128 initialState?: Record<IdProp, Entity>;
129 empty?: Entity | (() => Entity);
130}): TableOutput<Entity, S, Entity | undefined> {
131 const selectors = tableSelectors<Entity, S>((s: S) => s[name], empty);
132
133 return {
134 schema: "table",
135 name: name as string,
136 initialState,
137 empty: typeof empty === "function" ? empty() : empty,
138 add: (entities) => (s) => {
139 const state = selectors.selectTable(s);
140 Object.keys(entities).forEach((id) => {
141 state[id] = entities[id];
142 });
143 },
144 set: (entities) => (s) => {
145 // deno-lint-ignore no-explicit-any
146 (s as any)[name] = entities;
147 },
148 remove: (ids) => (s) => {
149 const state = selectors.selectTable(s);
150 ids.forEach((id) => {
151 delete state[id];
152 });
153 },
154 patch: (entities) => (s) => {
155 const state = selectors.selectTable(s);
156 Object.keys(entities).forEach((id) => {
157 state[id] = { ...state[id], ...entities[id] };
158 });
159 },
160 merge: (entities) => (s) => {
161 const state = selectors.selectTable(s);
162 Object.keys(entities).forEach((id) => {
163 const entity = entities[id];
164 Object.keys(entity).forEach((prop) => {
165 const val = entity[prop];
166 if (Array.isArray(val)) {
167 // deno-lint-ignore no-explicit-any
168 const list = val as any[];
169 // deno-lint-ignore no-explicit-any
170 (state as any)[id][prop].push(...list);
171 } else {
172 // deno-lint-ignore no-explicit-any
173 (state as any)[id][prop] = entities[id][prop];
174 }
175 });
176 });
177 },
178 reset: () => (s) => {
179 // deno-lint-ignore no-explicit-any
180 (s as any)[name] = initialState;
181 },
182 ...selectors,
183 };
184}
185
186export function table<
187 Entity extends AnyState = AnyState,
188 S extends AnyState = AnyState,
189>(p: {
190 initialState?: Record<IdProp, Entity>;
191 empty: Entity | (() => Entity);
192}): (n: string) => TableOutput<Entity, S, Entity>;
193export function table<
194 Entity extends AnyState = AnyState,
195 S extends AnyState = AnyState,
196>(p?: {
197 initialState?: Record<IdProp, Entity>;
198 empty?: Entity | (() => Entity);
199}): (n: string) => TableOutput<Entity, S, Entity | undefined>;
200export function table<
201 Entity extends AnyState = AnyState,
202 S extends AnyState = AnyState,
203>({
204 initialState,
205 empty,
206}: {
207 initialState?: Record<IdProp, Entity>;
208 empty?: Entity | (() => Entity);
209} = {}): (n: string) => TableOutput<Entity, S, Entity | undefined> {
210 return (name: string) => createTable<Entity>({ name, empty, initialState });
211}