@quark-fw/entity 0.0.1
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/dist/client.d.ts +153 -0
- package/dist/client.js +295 -0
- package/dist/global.d.ts +3 -0
- package/dist/index.d.ts +216 -0
- package/dist/index.js +310 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.js +1 -0
- package/dist/useEntityForm.d.ts +34 -0
- package/dist/useEntityForm.js +67 -0
- package/dist/uuidv7.d.ts +1 -0
- package/dist/uuidv7.js +17 -0
- package/package.json +41 -0
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import type { Serialize } from "@quark-fw/store/src/serialize.js";
|
|
3
|
+
import { useEntityForm } from "./useEntityForm.js";
|
|
4
|
+
export type EntitiesListParams = {
|
|
5
|
+
/**
|
|
6
|
+
* Фильтр КОНКРЕТНОГО потребителя (например колонки доски). Применяется
|
|
7
|
+
* на клиенте к общему списку провайдера: состояние одно на провайдер,
|
|
8
|
+
* поэтому серверный запрос задаётся пропсами провайдера (filter/sort/limit),
|
|
9
|
+
* а здесь получается срез этого списка.
|
|
10
|
+
*/
|
|
11
|
+
filter?: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
/** аргументы серверного list: то, что уходит в процедуру сущности */
|
|
14
|
+
export type EntityListArgs = {
|
|
15
|
+
where?: Record<string, unknown>;
|
|
16
|
+
sort?: {
|
|
17
|
+
field: string;
|
|
18
|
+
dir?: "asc" | "desc";
|
|
19
|
+
};
|
|
20
|
+
limit?: number;
|
|
21
|
+
page?: number;
|
|
22
|
+
/** поиск по подстроке; работает, если у сущности задан searchField */
|
|
23
|
+
search?: string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* tiny-вызовы КОНКРЕТНОЙ сущности.
|
|
27
|
+
*
|
|
28
|
+
* Имя сущности известно только в рантайме (createEntityContext("project")),
|
|
29
|
+
* поэтому вывести его из роутера нельзя — но набор процедур у всех сущностей
|
|
30
|
+
* один и тот же, и он описан здесь.
|
|
31
|
+
*/
|
|
32
|
+
export type EntityTinyCaller<Row> = {
|
|
33
|
+
list: {
|
|
34
|
+
mutate(args: EntityListArgs): Row[];
|
|
35
|
+
setCache(args: EntityListArgs): (rows: Row[]) => void;
|
|
36
|
+
};
|
|
37
|
+
count: {
|
|
38
|
+
mutate(args: {
|
|
39
|
+
where?: Record<string, unknown>;
|
|
40
|
+
}): number;
|
|
41
|
+
};
|
|
42
|
+
getById: {
|
|
43
|
+
mutate(id: string): Row | null;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
/** те же процедуры, но сетевые (не через кэш стора) */
|
|
47
|
+
export type EntityTrpcCaller<Row> = {
|
|
48
|
+
list: {
|
|
49
|
+
mutate(args: EntityListArgs): Promise<Row[]>;
|
|
50
|
+
};
|
|
51
|
+
create: {
|
|
52
|
+
mutate(args: {
|
|
53
|
+
data: Record<string, unknown>;
|
|
54
|
+
}): Promise<Row>;
|
|
55
|
+
};
|
|
56
|
+
update: {
|
|
57
|
+
mutate(args: {
|
|
58
|
+
id: string;
|
|
59
|
+
data: Record<string, unknown>;
|
|
60
|
+
}): Promise<Row>;
|
|
61
|
+
};
|
|
62
|
+
delete: {
|
|
63
|
+
mutate(args: {
|
|
64
|
+
id: string;
|
|
65
|
+
}): Promise<unknown>;
|
|
66
|
+
};
|
|
67
|
+
controller: {
|
|
68
|
+
mutate(args: {
|
|
69
|
+
id: string;
|
|
70
|
+
method: string;
|
|
71
|
+
data?: unknown;
|
|
72
|
+
}): Promise<unknown>;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
export type EntitiesContextType<Row extends {
|
|
76
|
+
id: string;
|
|
77
|
+
}> = {
|
|
78
|
+
list: (params?: EntitiesListParams) => [
|
|
79
|
+
Row[],
|
|
80
|
+
{
|
|
81
|
+
page: number;
|
|
82
|
+
setPage: (page: number) => void;
|
|
83
|
+
count: number | null;
|
|
84
|
+
}
|
|
85
|
+
];
|
|
86
|
+
tiny: EntityTinyCaller<Row>;
|
|
87
|
+
/**
|
|
88
|
+
* Сахар над useEntityForm: форма, создающая запись в этой сущности.
|
|
89
|
+
*
|
|
90
|
+
* Форма значений выводится из defaultValues, поэтому `form.values.name`
|
|
91
|
+
* у потребителя — строка, а не `any`.
|
|
92
|
+
*/
|
|
93
|
+
forms: {
|
|
94
|
+
create: <Shape extends Record<string, unknown>>(args?: {
|
|
95
|
+
defaultValues?: Partial<Shape>;
|
|
96
|
+
/** доработать значения перед отправкой (подставить projectId и т.п.) */
|
|
97
|
+
transformSubmitValues?: (values: Shape) => Record<string, unknown>;
|
|
98
|
+
onSuccess?: (row: Row | null) => void;
|
|
99
|
+
}) => ReturnType<typeof useEntityForm<Shape>>;
|
|
100
|
+
};
|
|
101
|
+
create: (data: Record<string, unknown>) => Promise<Row | null>;
|
|
102
|
+
update: (id: string, data: Partial<Row>, options?: {
|
|
103
|
+
clientOnly?: boolean;
|
|
104
|
+
}) => Promise<void>;
|
|
105
|
+
deleteById: (id: string) => Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Принять запись в локальный список без обращения к серверу.
|
|
108
|
+
*
|
|
109
|
+
* Нужна, когда рядом живут несколько провайдеров одной сущности с разными
|
|
110
|
+
* серверными фильтрами — например колонки доски. Запись, уехавшая из
|
|
111
|
+
* одного фильтра в другой, для второго провайдера просто не существует:
|
|
112
|
+
* его запрос уже выполнен. Сохранение делает вызывающий, здесь только
|
|
113
|
+
* локальное состояние.
|
|
114
|
+
*/
|
|
115
|
+
adopt: (row: Row) => void;
|
|
116
|
+
/**
|
|
117
|
+
* Подгрузить следующую страницу и ДОБАВИТЬ её к списку.
|
|
118
|
+
*
|
|
119
|
+
* Обычная смена страницы список заменяет; здесь он копится — это нужно
|
|
120
|
+
* для догрузки по прокрутке. Возвращает, сколько записей приехало: ноль
|
|
121
|
+
* означает, что дальше ничего нет.
|
|
122
|
+
*/
|
|
123
|
+
loadMore: () => Promise<number>;
|
|
124
|
+
/** сколько записей всего по серверному фильтру провайдера */
|
|
125
|
+
total: number | null;
|
|
126
|
+
};
|
|
127
|
+
export type EntityContextType<Row extends {
|
|
128
|
+
id: string;
|
|
129
|
+
}> = {
|
|
130
|
+
instance: () => Row;
|
|
131
|
+
update: (data: Partial<Row>) => Promise<void>;
|
|
132
|
+
delete: () => Promise<void>;
|
|
133
|
+
controller: Record<string, (data?: unknown) => Promise<unknown>>;
|
|
134
|
+
};
|
|
135
|
+
export declare function createEntityContext<TRow extends {
|
|
136
|
+
id: string;
|
|
137
|
+
}>(entityKey: string): readonly [({ children, filter, limit, sort, }: {
|
|
138
|
+
children: ReactNode;
|
|
139
|
+
/** серверный фильтр списка (общий для всех потребителей) */
|
|
140
|
+
filter?: Record<string, unknown>;
|
|
141
|
+
limit?: number;
|
|
142
|
+
sort?: {
|
|
143
|
+
field: string;
|
|
144
|
+
dir?: "asc" | "desc";
|
|
145
|
+
};
|
|
146
|
+
}) => import("react/jsx-runtime").JSX.Element, ({ children, id, }: {
|
|
147
|
+
children: ReactNode;
|
|
148
|
+
id: string;
|
|
149
|
+
}) => import("react/jsx-runtime").JSX.Element, () => EntitiesContextType<Serialize<TRow> & {
|
|
150
|
+
id: string;
|
|
151
|
+
}>, () => EntityContextType<Serialize<TRow> & {
|
|
152
|
+
id: string;
|
|
153
|
+
}>];
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/* eslint-disable react-hooks/rules-of-hooks */
|
|
3
|
+
// Порт createEntityContext из druslee/ugliest под quark:
|
|
4
|
+
// пара React-контекстов на entity с оптимистичным состоянием и откатом.
|
|
5
|
+
// ВАЖНО: клиентский модуль — не импортировать серверный ./index.js.
|
|
6
|
+
import { createContext, startTransition, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
7
|
+
import { useStore, useTrpc } from "@quark-fw/store";
|
|
8
|
+
import { reportError } from "@quark-fw/store/src/errors.js";
|
|
9
|
+
import { useEntityForm } from "./useEntityForm.js";
|
|
10
|
+
import { uuidv7 } from "./uuidv7.js";
|
|
11
|
+
/** Условия, которые можно проверить на клиенте (простые равенства). */
|
|
12
|
+
const matchesEverything = (where) => Object.keys(where).length === 0;
|
|
13
|
+
const matchesWhere = (row, where) => Object.entries(where).every(([field, expected]) => {
|
|
14
|
+
// операторы вида { in: [...] } на клиенте не разбираем — доверяем серверу
|
|
15
|
+
if (expected && typeof expected === "object")
|
|
16
|
+
return true;
|
|
17
|
+
return row?.[field] === expected;
|
|
18
|
+
});
|
|
19
|
+
function useSafeContext(errorMessage, Ctx) {
|
|
20
|
+
return () => {
|
|
21
|
+
const ctx = useContext(Ctx);
|
|
22
|
+
if (ctx === null)
|
|
23
|
+
throw new Error(errorMessage);
|
|
24
|
+
return ctx;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function createEntityContext(entityKey) {
|
|
28
|
+
const EntitiesContext = createContext(null);
|
|
29
|
+
const EntityContext = createContext(null);
|
|
30
|
+
const useEntities = useSafeContext(`${entityKey} entities context was not found in tree`, EntitiesContext);
|
|
31
|
+
const useEntity = useSafeContext(`${entityKey} entity context was not found in tree`, EntityContext);
|
|
32
|
+
const EntitiesProvider = ({ children, filter, limit = 100, sort, }) => {
|
|
33
|
+
const store = useStore();
|
|
34
|
+
// Имя сущности приходит параметром, поэтому ветку роутера нельзя
|
|
35
|
+
// вывести статически — но набор процедур у всех сущностей общий и
|
|
36
|
+
// описан типами выше.
|
|
37
|
+
const tinyCaller = store.tiny.entity[entityKey];
|
|
38
|
+
const trpcCaller = useTrpc().entity[entityKey];
|
|
39
|
+
const [completeList, setCompleteList] = useState(null);
|
|
40
|
+
const [page, setPage] = useState(1);
|
|
41
|
+
// Сколько мутаций сейчас в полёте. Пока хоть одна не завершилась,
|
|
42
|
+
// синхронизация из серверного списка запрещена: иначе первый же
|
|
43
|
+
// эффект после монтирования затирает оптимистичное изменение,
|
|
44
|
+
// сделанное до его выполнения (гонка «создал сразу после загрузки»).
|
|
45
|
+
const mutationsInFlight = useRef(0);
|
|
46
|
+
// Наборы аргументов, с которыми список сейчас читают компоненты.
|
|
47
|
+
// Кэш store обновляем именно по ним — иначе setCache пишет под
|
|
48
|
+
// ключом, который никто не читает, и после ремонта виден старый список.
|
|
49
|
+
const liveArgs = useRef(new Map());
|
|
50
|
+
const setList = (updater) => {
|
|
51
|
+
const syncCache = (arr) => {
|
|
52
|
+
for (const args of liveArgs.current.values()) {
|
|
53
|
+
tinyCaller.list.setCache(args)(arr);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
if (Array.isArray(updater)) {
|
|
57
|
+
setCompleteList(updater);
|
|
58
|
+
syncCache(updater);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
setCompleteList((prev) => {
|
|
62
|
+
const arr = updater(prev);
|
|
63
|
+
// побочный эффект вынесен из апдейтера состояния
|
|
64
|
+
queueMicrotask(() => syncCache(arr));
|
|
65
|
+
return arr;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
/** Помечает мутацию как выполняющуюся, чтобы её не затёрли данными. */
|
|
70
|
+
const withMutation = async (fn) => {
|
|
71
|
+
mutationsInFlight.current += 1;
|
|
72
|
+
try {
|
|
73
|
+
return await fn();
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
mutationsInFlight.current -= 1;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
// при ошибке откатывает список и сообщает через reportError,
|
|
80
|
+
// возвращая null — вызывающему коду не нужен try/catch
|
|
81
|
+
const create = async (data) => {
|
|
82
|
+
const now = new Date().toISOString();
|
|
83
|
+
// клиентский UUIDv7 = финальный id записи
|
|
84
|
+
const item = {
|
|
85
|
+
id: uuidv7(),
|
|
86
|
+
createdAt: now,
|
|
87
|
+
...data,
|
|
88
|
+
};
|
|
89
|
+
const old = completeList;
|
|
90
|
+
setList((prev) => (prev ? [...prev, item] : [item]));
|
|
91
|
+
return withMutation(async () => {
|
|
92
|
+
try {
|
|
93
|
+
return await trpcCaller.create.mutate({ data: item });
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
reportError(error, entityKey);
|
|
97
|
+
setList(old ?? []);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
const update = async (id, data, { clientOnly } = {}) => {
|
|
103
|
+
const old = (completeList ?? []).find((e) => e.id === id);
|
|
104
|
+
setList((prev) => {
|
|
105
|
+
const arr = prev ? [...prev] : [];
|
|
106
|
+
const index = arr.findIndex((e) => e.id === id);
|
|
107
|
+
if (index === -1)
|
|
108
|
+
return arr;
|
|
109
|
+
arr[index] = { ...arr[index], ...data };
|
|
110
|
+
return arr;
|
|
111
|
+
});
|
|
112
|
+
await withMutation(async () => {
|
|
113
|
+
try {
|
|
114
|
+
if (!clientOnly) {
|
|
115
|
+
await trpcCaller.update.mutate({ id, data });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
reportError(error, entityKey);
|
|
120
|
+
setList((prev) => {
|
|
121
|
+
if (!prev || !old)
|
|
122
|
+
return prev ?? [];
|
|
123
|
+
const arr = [...prev];
|
|
124
|
+
const index = arr.findIndex((e) => e.id === id);
|
|
125
|
+
if (index !== -1)
|
|
126
|
+
arr[index] = old;
|
|
127
|
+
return arr;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
const deleteById = async (id) => {
|
|
133
|
+
const old = completeList;
|
|
134
|
+
setList((prev) => (prev ?? []).filter((e) => e.id !== id));
|
|
135
|
+
await withMutation(async () => {
|
|
136
|
+
try {
|
|
137
|
+
await trpcCaller.delete.mutate({ id });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
reportError(error, entityKey);
|
|
141
|
+
setList(old ?? []);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
// Сколько страниц уже добавлено в список. Пока страница одна,
|
|
146
|
+
// провайдер синхронизируется с сервером как обычно; после догрузки
|
|
147
|
+
// синхронизация выключается — иначе первая страница затрёт накопленное.
|
|
148
|
+
const loadedPages = useRef(1);
|
|
149
|
+
/**
|
|
150
|
+
* Принять запись, приехавшую из другого провайдера этой же сущности.
|
|
151
|
+
*
|
|
152
|
+
* Только локальное состояние: запись уже сохранена тем, кто её
|
|
153
|
+
* передал. Если запись с таким id уже есть — обновляем на месте.
|
|
154
|
+
*/
|
|
155
|
+
const adopt = (row) => {
|
|
156
|
+
setList((prev) => {
|
|
157
|
+
const arr = prev ? [...prev] : [];
|
|
158
|
+
const index = arr.findIndex((e) => e.id === row.id);
|
|
159
|
+
if (index === -1)
|
|
160
|
+
arr.push(row);
|
|
161
|
+
else
|
|
162
|
+
arr[index] = row;
|
|
163
|
+
return arr;
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
// ЕДИНСТВЕННЫЙ серверный запрос списка на провайдер: состояние тоже
|
|
167
|
+
// одно, поэтому несколько разных запросов рассинхронизировали бы его
|
|
168
|
+
// (колонка, синхронизировавшаяся последней, затирала бы остальные).
|
|
169
|
+
const argsKey = JSON.stringify({
|
|
170
|
+
...(filter ? { where: filter } : {}),
|
|
171
|
+
...(sort ? { sort } : {}),
|
|
172
|
+
limit,
|
|
173
|
+
page,
|
|
174
|
+
});
|
|
175
|
+
// объект аргументов должен сохранять идентичность, пока не изменился
|
|
176
|
+
// их состав: иначе эффект ниже перезапускается на каждый рендер
|
|
177
|
+
const serverArgs = useMemo(() => JSON.parse(argsKey), [argsKey]);
|
|
178
|
+
const fresh = tinyCaller.list.mutate(serverArgs);
|
|
179
|
+
const count = tinyCaller.count.mutate(filter ? { where: filter } : {});
|
|
180
|
+
const loadMore = async () => {
|
|
181
|
+
const nextPage = loadedPages.current + 1;
|
|
182
|
+
const rows = await withMutation(() => trpcCaller.list.mutate({ ...serverArgs, page: nextPage }));
|
|
183
|
+
if (!rows.length)
|
|
184
|
+
return 0;
|
|
185
|
+
loadedPages.current = nextPage;
|
|
186
|
+
setList((prev) => {
|
|
187
|
+
const seen = new Set((prev ?? []).map((row) => row.id));
|
|
188
|
+
return [
|
|
189
|
+
...(prev ?? []),
|
|
190
|
+
...rows.filter((row) => !seen.has(row.id)),
|
|
191
|
+
];
|
|
192
|
+
});
|
|
193
|
+
return rows.length;
|
|
194
|
+
};
|
|
195
|
+
// мутации обновляют кэш store по тем же аргументам, что и чтение
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
// ref читаем до подписки: к моменту очистки .current уже может
|
|
198
|
+
// указывать на другой объект
|
|
199
|
+
const live = liveArgs.current;
|
|
200
|
+
live.set(argsKey, serverArgs);
|
|
201
|
+
return () => {
|
|
202
|
+
live.delete(argsKey);
|
|
203
|
+
};
|
|
204
|
+
}, [argsKey, serverArgs]);
|
|
205
|
+
// с этой порцией серверных данных мы уже синхронизировались
|
|
206
|
+
const syncedFrom = useRef(null);
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (syncedFrom.current === fresh)
|
|
209
|
+
return;
|
|
210
|
+
// мутация в полёте — локальное состояние новее серверного
|
|
211
|
+
if (mutationsInFlight.current > 0)
|
|
212
|
+
return;
|
|
213
|
+
// подгруженные страницы: первая страница затёрла бы остальные
|
|
214
|
+
if (loadedPages.current > 1)
|
|
215
|
+
return;
|
|
216
|
+
syncedFrom.current = fresh;
|
|
217
|
+
startTransition(() => {
|
|
218
|
+
setList(fresh);
|
|
219
|
+
});
|
|
220
|
+
// setList намеренно не в зависимостях: он пересоздаётся каждый
|
|
221
|
+
// рендер, а сам пишет состояние — с ним эффект зациклится
|
|
222
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
223
|
+
}, [fresh]);
|
|
224
|
+
const rows = completeList ?? fresh;
|
|
225
|
+
const value = {
|
|
226
|
+
list: ({ filter: extraFilter } = {}) => [
|
|
227
|
+
// срез общего списка под фильтр потребителя
|
|
228
|
+
!extraFilter || matchesEverything(extraFilter)
|
|
229
|
+
? rows
|
|
230
|
+
: rows.filter((row) => matchesWhere(row, extraFilter)),
|
|
231
|
+
{ page, setPage, count: count ?? null },
|
|
232
|
+
],
|
|
233
|
+
tiny: tinyCaller,
|
|
234
|
+
forms: {
|
|
235
|
+
// хук вызывается из компонента (как list выше) — порядок
|
|
236
|
+
// вызовов стабилен, пока компонент вызывает forms.create()
|
|
237
|
+
// безусловно в теле рендера
|
|
238
|
+
create: ({ defaultValues, transformSubmitValues, onSuccess, } = {}) => useEntityForm({
|
|
239
|
+
defaultValues,
|
|
240
|
+
onSubmit: async (values) => {
|
|
241
|
+
const data = transformSubmitValues
|
|
242
|
+
? transformSubmitValues(values)
|
|
243
|
+
: values;
|
|
244
|
+
const row = await create(data);
|
|
245
|
+
onSuccess?.(row);
|
|
246
|
+
},
|
|
247
|
+
}),
|
|
248
|
+
},
|
|
249
|
+
create,
|
|
250
|
+
update,
|
|
251
|
+
deleteById,
|
|
252
|
+
adopt,
|
|
253
|
+
loadMore,
|
|
254
|
+
total: count ?? null,
|
|
255
|
+
};
|
|
256
|
+
return (_jsx(EntitiesContext.Provider, { value: value, children: children }));
|
|
257
|
+
};
|
|
258
|
+
const EntityProvider = ({ children, id, }) => {
|
|
259
|
+
if (!id)
|
|
260
|
+
throw new Error(`EntityProvider [${entityKey}] id must be provided`);
|
|
261
|
+
const store = useStore();
|
|
262
|
+
// Имя сущности приходит параметром, поэтому ветку роутера нельзя
|
|
263
|
+
// вывести статически — но набор процедур у всех сущностей общий и
|
|
264
|
+
// описан типами выше.
|
|
265
|
+
const tinyCaller = store.tiny.entity[entityKey];
|
|
266
|
+
const trpcCaller = useTrpc().entity[entityKey];
|
|
267
|
+
const entities = useEntities();
|
|
268
|
+
const controller = new Proxy({}, {
|
|
269
|
+
get(_target, method) {
|
|
270
|
+
return (data) => trpcCaller.controller.mutate({
|
|
271
|
+
id,
|
|
272
|
+
// ключ прокси может быть символом, а по сети уходит
|
|
273
|
+
// имя метода — берём только строковые
|
|
274
|
+
method: String(method),
|
|
275
|
+
data,
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const value = {
|
|
280
|
+
instance: () => {
|
|
281
|
+
const [list] = entities.list();
|
|
282
|
+
const item = list.find((e) => e.id === id) ??
|
|
283
|
+
tinyCaller.getById.mutate(id);
|
|
284
|
+
if (!item)
|
|
285
|
+
throw new Error(`${entityKey} ${id} not found`);
|
|
286
|
+
return item;
|
|
287
|
+
},
|
|
288
|
+
update: (data) => entities.update(id, data),
|
|
289
|
+
delete: () => entities.deleteById(id),
|
|
290
|
+
controller,
|
|
291
|
+
};
|
|
292
|
+
return (_jsx(EntityContext.Provider, { value: value, children: children }));
|
|
293
|
+
};
|
|
294
|
+
return [EntitiesProvider, EntityProvider, useEntities, useEntity];
|
|
295
|
+
}
|
package/dist/global.d.ts
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { z, ZodType } from "zod";
|
|
2
|
+
import { publicProcedure, type TRPCContext } from "@quark-fw/plugin-trpc/src/trpc.js";
|
|
3
|
+
import type { EntityField, EntityModelName, EntityRow } from "./types.js";
|
|
4
|
+
export type { EntityModels, EntityModelName, EntityRow, EntityField, } from "./types.js";
|
|
5
|
+
export type CustomMethodFn = (input?: unknown) => unknown;
|
|
6
|
+
export type CustomMethodValidated<TSchema extends ZodType = ZodType> = {
|
|
7
|
+
input: TSchema;
|
|
8
|
+
handler: (input: z.output<TSchema>) => unknown;
|
|
9
|
+
};
|
|
10
|
+
export type CustomMethod = CustomMethodFn | CustomMethodValidated;
|
|
11
|
+
/**
|
|
12
|
+
* Одно поле модели в селекторах Prisma 8: `f.name.asc()`, `f.name.ilike(...)`.
|
|
13
|
+
* Набор операторов зависит от типа колонки, поэтому проверяется в рантайме.
|
|
14
|
+
*/
|
|
15
|
+
type FieldHandle = {
|
|
16
|
+
[operator: string]: ((...args: string[]) => unknown) | undefined;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Поля модели в селекторе: `(f) => f.name.asc()`. Конкретный состав известен
|
|
20
|
+
* только сгенерированному контракту приложения, здесь — структура.
|
|
21
|
+
*/
|
|
22
|
+
type SelectorFields = Record<string, FieldHandle | undefined>;
|
|
23
|
+
/** хелперы агрегатов Prisma 8: `(a) => ({ n: a.count() })` */
|
|
24
|
+
type AggregateHelpers = Record<string, () => unknown>;
|
|
25
|
+
export type EntityModelHandle = {
|
|
26
|
+
where(where: Record<string, unknown>): EntityModelHandle;
|
|
27
|
+
where(selector: (fields: SelectorFields) => unknown): EntityModelHandle;
|
|
28
|
+
skip(n: number): EntityModelHandle;
|
|
29
|
+
take(n: number): EntityModelHandle;
|
|
30
|
+
all(): Promise<unknown[]>;
|
|
31
|
+
first(): Promise<unknown | null>;
|
|
32
|
+
orderBy(selector: (fields: SelectorFields) => unknown): EntityModelHandle;
|
|
33
|
+
aggregate(selector: (a: AggregateHelpers) => unknown): Promise<unknown>;
|
|
34
|
+
create(data: unknown): Promise<unknown>;
|
|
35
|
+
update(data: unknown): Promise<unknown>;
|
|
36
|
+
delete(): Promise<unknown>;
|
|
37
|
+
};
|
|
38
|
+
export declare const sortSchema: z.ZodObject<{
|
|
39
|
+
field: z.ZodString;
|
|
40
|
+
dir: z.ZodDefault<z.ZodEnum<{
|
|
41
|
+
asc: "asc";
|
|
42
|
+
desc: "desc";
|
|
43
|
+
}>>;
|
|
44
|
+
}, z.core.$strip>;
|
|
45
|
+
export type EntitySort = z.output<typeof sortSchema>;
|
|
46
|
+
declare const listSchema: z.ZodObject<{
|
|
47
|
+
where: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
|
|
48
|
+
page: z.ZodDefault<z.ZodNumber>;
|
|
49
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
50
|
+
sort: z.ZodOptional<z.ZodObject<{
|
|
51
|
+
field: z.ZodString;
|
|
52
|
+
dir: z.ZodDefault<z.ZodEnum<{
|
|
53
|
+
asc: "asc";
|
|
54
|
+
desc: "desc";
|
|
55
|
+
}>>;
|
|
56
|
+
}, z.core.$strip>>;
|
|
57
|
+
search: z.ZodOptional<z.ZodString>;
|
|
58
|
+
}, z.core.$strip>;
|
|
59
|
+
/**
|
|
60
|
+
* Билдер, на котором строится роутер сущности.
|
|
61
|
+
*
|
|
62
|
+
* userProcedure — это publicProcedure.use(...), то есть тот же билдер с
|
|
63
|
+
* доуточнённым контекстом; в самих процедурах сущности контекст читается
|
|
64
|
+
* только как `ctx.user?.id`, который есть и в базовом TRPCContext.
|
|
65
|
+
*/
|
|
66
|
+
export type EntityProcedure = typeof publicProcedure;
|
|
67
|
+
/**
|
|
68
|
+
* Связь с родительской моделью.
|
|
69
|
+
*
|
|
70
|
+
* Тип раздаётся по всем моделям контракта, поэтому поля родителя
|
|
71
|
+
* (parentField, authField) проверяются против ИМЕННО той модели, которая
|
|
72
|
+
* указана в model.
|
|
73
|
+
*/
|
|
74
|
+
export type EntityRelation<TModel extends string = EntityModelName> = {
|
|
75
|
+
[TParent in EntityModelName]: {
|
|
76
|
+
/** колонка-ссылка на этой модели, например "projectId" */
|
|
77
|
+
field: EntityField<TModel>;
|
|
78
|
+
/** родительская модель в контракте, например "Project" */
|
|
79
|
+
model: TParent;
|
|
80
|
+
/** колонка родителя, с которой сопоставляется ссылка (по умолчанию id) */
|
|
81
|
+
parentField?: EntityField<TParent>;
|
|
82
|
+
/** колонка владельца на родителе, например "userId" */
|
|
83
|
+
authField: EntityField<TParent>;
|
|
84
|
+
};
|
|
85
|
+
}[EntityModelName];
|
|
86
|
+
export type CreateEntityArgs<TModel extends EntityModelName> = {
|
|
87
|
+
model: TModel;
|
|
88
|
+
authField?: EntityField<TModel>;
|
|
89
|
+
procedure?: EntityProcedure;
|
|
90
|
+
defaultSort?: {
|
|
91
|
+
field: EntityField<TModel>;
|
|
92
|
+
dir?: EntitySort["dir"];
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Поле для поиска по подстроке в list({ search }).
|
|
96
|
+
*
|
|
97
|
+
* Одно, а не список: билдер Prisma 8 умеет складывать условия только по И
|
|
98
|
+
* (у полей есть ilike, но ни f.or, ни .or у условия нет), поэтому поиск
|
|
99
|
+
* сразу по нескольким колонкам выражается только отдельными запросами.
|
|
100
|
+
*/
|
|
101
|
+
searchField?: EntityField<TModel>;
|
|
102
|
+
relations?: EntityRelation<TModel>[];
|
|
103
|
+
extendMethods?: (args: {
|
|
104
|
+
model: EntityModelHandle;
|
|
105
|
+
}) => Record<string, CustomMethod>;
|
|
106
|
+
extendController?: (args: {
|
|
107
|
+
entity: EntityRow<TModel>;
|
|
108
|
+
}) => Record<string, CustomMethod>;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Сущность поверх модели контракта.
|
|
112
|
+
*
|
|
113
|
+
* Тип строки берётся из самой модели по её имени, поэтому дженерик передавать
|
|
114
|
+
* не нужно: `createEntity({ model: "Task", ... })`. Имена полей —
|
|
115
|
+
* владельца, сортировки, поиска и связей — проверяются против этой модели.
|
|
116
|
+
*/
|
|
117
|
+
export declare function createEntity<TModel extends EntityModelName>(args: CreateEntityArgs<TModel>): {
|
|
118
|
+
modelName: TModel;
|
|
119
|
+
getModel: () => EntityModelHandle;
|
|
120
|
+
methods: {
|
|
121
|
+
list: (input?: z.input<typeof listSchema> | undefined) => Promise<(EntityRow<TModel> & {
|
|
122
|
+
id: string;
|
|
123
|
+
})[]>;
|
|
124
|
+
getById: (id: string) => Promise<(EntityRow<TModel> & {
|
|
125
|
+
id: string;
|
|
126
|
+
}) | null>;
|
|
127
|
+
/** как getById, но по произвольному where (используется для скоупинга) */
|
|
128
|
+
getOne: (where: Record<string, unknown>) => Promise<(EntityRow<TModel> & {
|
|
129
|
+
id: string;
|
|
130
|
+
}) | null>;
|
|
131
|
+
create: (data: Record<string, unknown>) => Promise<EntityRow<TModel> & {
|
|
132
|
+
id: string;
|
|
133
|
+
}>;
|
|
134
|
+
update: (id: string, data: Record<string, unknown>) => Promise<EntityRow<TModel> & {
|
|
135
|
+
id: string;
|
|
136
|
+
}>;
|
|
137
|
+
updateWhere: (where: Record<string, unknown>, data: Record<string, unknown>) => Promise<EntityRow<TModel> & {
|
|
138
|
+
id: string;
|
|
139
|
+
}>;
|
|
140
|
+
deleteById: (id: string) => Promise<unknown>;
|
|
141
|
+
deleteWhere: (where: Record<string, unknown>) => Promise<{}>;
|
|
142
|
+
count: (where?: Record<string, unknown>) => Promise<number>;
|
|
143
|
+
};
|
|
144
|
+
createRouter: () => import("@trpc/server").TRPCBuiltRouter<{
|
|
145
|
+
ctx: TRPCContext;
|
|
146
|
+
meta: object;
|
|
147
|
+
errorShape: import("@trpc/server").TRPCDefaultErrorShape;
|
|
148
|
+
transformer: false;
|
|
149
|
+
}, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
|
|
150
|
+
list: import("@trpc/server").TRPCMutationProcedure<{
|
|
151
|
+
input: {
|
|
152
|
+
where?: Record<string, any> | undefined;
|
|
153
|
+
page?: number | undefined;
|
|
154
|
+
limit?: number | undefined;
|
|
155
|
+
sort?: {
|
|
156
|
+
field: string;
|
|
157
|
+
dir?: "asc" | "desc" | undefined;
|
|
158
|
+
} | undefined;
|
|
159
|
+
search?: string | undefined;
|
|
160
|
+
} | undefined;
|
|
161
|
+
output: (EntityRow<TModel> & {
|
|
162
|
+
id: string;
|
|
163
|
+
})[];
|
|
164
|
+
meta: object;
|
|
165
|
+
}>;
|
|
166
|
+
getById: import("@trpc/server").TRPCMutationProcedure<{
|
|
167
|
+
input: string;
|
|
168
|
+
output: (EntityRow<TModel> & {
|
|
169
|
+
id: string;
|
|
170
|
+
}) | null;
|
|
171
|
+
meta: object;
|
|
172
|
+
}>;
|
|
173
|
+
create: import("@trpc/server").TRPCMutationProcedure<{
|
|
174
|
+
input: {
|
|
175
|
+
data: Record<string, any>;
|
|
176
|
+
};
|
|
177
|
+
output: EntityRow<TModel> & {
|
|
178
|
+
id: string;
|
|
179
|
+
};
|
|
180
|
+
meta: object;
|
|
181
|
+
}>;
|
|
182
|
+
update: import("@trpc/server").TRPCMutationProcedure<{
|
|
183
|
+
input: {
|
|
184
|
+
id: string;
|
|
185
|
+
data: Record<string, any>;
|
|
186
|
+
};
|
|
187
|
+
output: EntityRow<TModel> & {
|
|
188
|
+
id: string;
|
|
189
|
+
};
|
|
190
|
+
meta: object;
|
|
191
|
+
}>;
|
|
192
|
+
delete: import("@trpc/server").TRPCMutationProcedure<{
|
|
193
|
+
input: {
|
|
194
|
+
id: string;
|
|
195
|
+
};
|
|
196
|
+
output: {};
|
|
197
|
+
meta: object;
|
|
198
|
+
}>;
|
|
199
|
+
count: import("@trpc/server").TRPCMutationProcedure<{
|
|
200
|
+
input: {
|
|
201
|
+
where?: Record<string, any> | undefined;
|
|
202
|
+
} | undefined;
|
|
203
|
+
output: number;
|
|
204
|
+
meta: object;
|
|
205
|
+
}>;
|
|
206
|
+
controller: import("@trpc/server").TRPCMutationProcedure<{
|
|
207
|
+
input: {
|
|
208
|
+
id: string;
|
|
209
|
+
method: string;
|
|
210
|
+
data?: any;
|
|
211
|
+
};
|
|
212
|
+
output: unknown;
|
|
213
|
+
meta: object;
|
|
214
|
+
}>;
|
|
215
|
+
}>>;
|
|
216
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { publicProcedure, router, } from "@quark-fw/plugin-trpc/src/trpc.js";
|
|
4
|
+
const invoke = (m, data) => typeof m === "function" ? m(data) : m.handler(m.input.parse(data));
|
|
5
|
+
// Сериализуемая сортировка: orderBy в Prisma 8 принимает СЕЛЕКТОР-функцию
|
|
6
|
+
// (f) => f.field.asc(), поэтому по сети передаём {field, dir}, а функцию
|
|
7
|
+
// собираем на сервере. Одно поле: цепочка/массив билдером не поддержаны.
|
|
8
|
+
export const sortSchema = z.object({
|
|
9
|
+
field: z.string(),
|
|
10
|
+
dir: z.enum(["asc", "desc"]).default("asc"),
|
|
11
|
+
});
|
|
12
|
+
const listSchema = z.object({
|
|
13
|
+
where: z.record(z.string(), z.any()).optional(),
|
|
14
|
+
page: z.number().int().min(1).default(1),
|
|
15
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
16
|
+
sort: sortSchema.optional(),
|
|
17
|
+
/** поиск по подстроке; поле задаётся в createEntity (searchField) */
|
|
18
|
+
search: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Модели контракта.
|
|
22
|
+
*
|
|
23
|
+
* Тип `quark.db` объявляет само приложение (аугментация Quark), поэтому
|
|
24
|
+
* пакету он не виден — но форма, на которую пакет опирается, известна и
|
|
25
|
+
* описана здесь. Заодно ловим ненастроенный prisma-плагин внятной ошибкой
|
|
26
|
+
* вместо `Cannot read properties of undefined`.
|
|
27
|
+
*/
|
|
28
|
+
const ormModels = () => {
|
|
29
|
+
const { db } = quark;
|
|
30
|
+
const models = db?.orm?.public;
|
|
31
|
+
if (!models) {
|
|
32
|
+
throw new Error("@quark-fw/entity: quark.db.orm.public недоступен — не подключён @quark/plugin-prisma?");
|
|
33
|
+
}
|
|
34
|
+
return models;
|
|
35
|
+
};
|
|
36
|
+
const modelByName = (name) => {
|
|
37
|
+
const model = ormModels()[name];
|
|
38
|
+
if (!model) {
|
|
39
|
+
throw new Error(`@quark-fw/entity: модели "${name}" нет в контракте`);
|
|
40
|
+
}
|
|
41
|
+
return model;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Сущность поверх модели контракта.
|
|
45
|
+
*
|
|
46
|
+
* Тип строки берётся из самой модели по её имени, поэтому дженерик передавать
|
|
47
|
+
* не нужно: `createEntity({ model: "Task", ... })`. Имена полей —
|
|
48
|
+
* владельца, сортировки, поиска и связей — проверяются против этой модели.
|
|
49
|
+
*/
|
|
50
|
+
export function createEntity(args) {
|
|
51
|
+
// в конфиге направление необязательно, схема сортировки требует его явно
|
|
52
|
+
const fallbackSort = args.defaultSort && {
|
|
53
|
+
field: args.defaultSort.field,
|
|
54
|
+
dir: args.defaultSort.dir ?? "asc",
|
|
55
|
+
};
|
|
56
|
+
// лениво: quark.db появляется после init prisma-плагина
|
|
57
|
+
const getModel = () => modelByName(args.model);
|
|
58
|
+
const chain = (where) => {
|
|
59
|
+
let q = getModel();
|
|
60
|
+
if (where && Object.keys(where).length)
|
|
61
|
+
q = q.where(where);
|
|
62
|
+
return q;
|
|
63
|
+
};
|
|
64
|
+
// {field, dir} -> селектор Prisma 8. Не все типы полей сортируемы
|
|
65
|
+
// (у boolean, например, нет asc/desc) — отвечаем понятной ошибкой.
|
|
66
|
+
const applySort = (q, sort) => {
|
|
67
|
+
if (!sort)
|
|
68
|
+
return q;
|
|
69
|
+
return q.orderBy((fields) => {
|
|
70
|
+
const direction = fields?.[sort.field]?.[sort.dir];
|
|
71
|
+
if (typeof direction !== "function") {
|
|
72
|
+
throw new TRPCError({
|
|
73
|
+
code: "BAD_REQUEST",
|
|
74
|
+
message: `Field "${sort.field}" of ${args.model} is not sortable`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return direction.call(fields[sort.field]);
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Поиск по подстроке, без учёта регистра (ilike).
|
|
82
|
+
*
|
|
83
|
+
* Условие добавляется отдельным where — они складываются по И, поэтому
|
|
84
|
+
* скоуп владельца и фильтры остаются в силе.
|
|
85
|
+
*/
|
|
86
|
+
const applySearch = (q, search) => {
|
|
87
|
+
const term = search?.trim();
|
|
88
|
+
if (!term || !args.searchField)
|
|
89
|
+
return q;
|
|
90
|
+
// % и _ — служебные символы шаблона; экранируем, чтобы «50%» искалось
|
|
91
|
+
// как текст, а не как «что угодно после 50»
|
|
92
|
+
const escaped = term.replace(/[\\%_]/g, "\\$&");
|
|
93
|
+
return q.where((fields) => {
|
|
94
|
+
const field = fields?.[args.searchField];
|
|
95
|
+
const ilike = field?.ilike;
|
|
96
|
+
if (typeof ilike !== "function") {
|
|
97
|
+
throw new TRPCError({
|
|
98
|
+
code: "BAD_REQUEST",
|
|
99
|
+
message: `Field "${args.searchField}" of ${args.model} is not searchable`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return ilike.call(field, `%${escaped}%`);
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
const baseController = (row) => ({
|
|
106
|
+
update: (data) => chain({ id: row.id }).update(data),
|
|
107
|
+
delete: () => chain({ id: row.id }).delete(),
|
|
108
|
+
});
|
|
109
|
+
const buildController = (row) => ({
|
|
110
|
+
...baseController(row),
|
|
111
|
+
...(args.extendController?.({ entity: row }) ?? {}),
|
|
112
|
+
});
|
|
113
|
+
const withController = (row) => {
|
|
114
|
+
if (!row || typeof row !== "object")
|
|
115
|
+
return row;
|
|
116
|
+
return Object.assign(row, buildController(row));
|
|
117
|
+
};
|
|
118
|
+
const methods = {
|
|
119
|
+
list: async (input) => {
|
|
120
|
+
const { where, page, limit, sort, search } = listSchema.parse(input ?? {});
|
|
121
|
+
const rows = (await applySort(applySearch(chain(where), search), sort ?? fallbackSort)
|
|
122
|
+
.skip(limit * (page - 1))
|
|
123
|
+
.take(limit)
|
|
124
|
+
.all());
|
|
125
|
+
return rows.map(withController);
|
|
126
|
+
},
|
|
127
|
+
getById: async (id) => withController((await chain({ id }).first())),
|
|
128
|
+
/** как getById, но по произвольному where (используется для скоупинга) */
|
|
129
|
+
getOne: async (where) => withController((await chain(where).first())),
|
|
130
|
+
create: async (data) => withController((await getModel().create(data))),
|
|
131
|
+
update: async (id, data) => withController((await chain({ id }).update(data))),
|
|
132
|
+
// Prisma 8 при непопадании во where возвращает null, а не бросает.
|
|
133
|
+
// Молчаливый null скрыл бы и опечатку в id, и попытку тронуть чужую
|
|
134
|
+
// запись — отвечаем NOT_FOUND (существование чужой не подтверждаем).
|
|
135
|
+
updateWhere: async (where, data) => {
|
|
136
|
+
const row = (await chain(where).update(data));
|
|
137
|
+
if (!row) {
|
|
138
|
+
throw new TRPCError({
|
|
139
|
+
code: "NOT_FOUND",
|
|
140
|
+
message: `${args.model} not found`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return withController(row);
|
|
144
|
+
},
|
|
145
|
+
deleteById: (id) => chain({ id }).delete(),
|
|
146
|
+
deleteWhere: async (where) => {
|
|
147
|
+
const row = await chain(where).delete();
|
|
148
|
+
if (!row) {
|
|
149
|
+
throw new TRPCError({
|
|
150
|
+
code: "NOT_FOUND",
|
|
151
|
+
message: `${args.model} not found`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return row;
|
|
155
|
+
},
|
|
156
|
+
// count() билдера доступен только внутри include-рефайнментов —
|
|
157
|
+
// счёт делается через aggregate
|
|
158
|
+
count: async (where) => {
|
|
159
|
+
const { n } = (await chain(where).aggregate((a) => ({
|
|
160
|
+
n: a.count(),
|
|
161
|
+
})));
|
|
162
|
+
return n;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
const createRouter = () => {
|
|
166
|
+
const proc = args.procedure ?? publicProcedure;
|
|
167
|
+
// TRPCUser — пустой интерфейс-шов, его поля объявляет плагин
|
|
168
|
+
// авторизации, поэтому id читаем структурно
|
|
169
|
+
const ownerId = (ctx) => ctx.user?.id;
|
|
170
|
+
// where-скоуп владельца: {} если authField не задан
|
|
171
|
+
const scope = (ctx) => args.authField ? { [args.authField]: ownerId(ctx) } : {};
|
|
172
|
+
/**
|
|
173
|
+
* Убирает из присланных данных поля, которые клиент задавать не вправе.
|
|
174
|
+
*
|
|
175
|
+
* Колонка владельца проставляется только из контекста. Иначе владельца
|
|
176
|
+
* можно переписать на чужого: скоуп во where проверяет, что ЭТА запись
|
|
177
|
+
* твоя, но не мешает после этого отдать её другому — так в чужой
|
|
178
|
+
* аккаунт подкладывается запись.
|
|
179
|
+
*
|
|
180
|
+
* id при обновлении тоже не принимаем: подмена первичного ключа рвёт
|
|
181
|
+
* ссылки на запись и не имеет смысла как операция.
|
|
182
|
+
*/
|
|
183
|
+
const stripProtected = (data, also = []) => {
|
|
184
|
+
const clean = { ...data };
|
|
185
|
+
if (args.authField)
|
|
186
|
+
delete clean[args.authField];
|
|
187
|
+
for (const field of also)
|
|
188
|
+
delete clean[field];
|
|
189
|
+
return clean;
|
|
190
|
+
};
|
|
191
|
+
// порт checkRelation: записать ссылку на родителя можно, только если
|
|
192
|
+
// этот родитель принадлежит вызывающему (иначе чужой id «подсунули»)
|
|
193
|
+
const checkRelations = async (data, ctx) => {
|
|
194
|
+
for (const relation of args.relations ?? []) {
|
|
195
|
+
const value = data[relation.field];
|
|
196
|
+
if (value === undefined || value === null)
|
|
197
|
+
continue;
|
|
198
|
+
const parent = await modelByName(relation.model)
|
|
199
|
+
.where({
|
|
200
|
+
[relation.parentField ?? "id"]: value,
|
|
201
|
+
[relation.authField]: ownerId(ctx),
|
|
202
|
+
})
|
|
203
|
+
.first();
|
|
204
|
+
if (!parent) {
|
|
205
|
+
// NOT_FOUND, а не FORBIDDEN: не подтверждаем существование
|
|
206
|
+
// чужой записи
|
|
207
|
+
throw new TRPCError({
|
|
208
|
+
code: "NOT_FOUND",
|
|
209
|
+
message: `${relation.model} ${String(value)} not found`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const custom = args.extendMethods?.({ model: getModel() }) ?? {};
|
|
215
|
+
const customProcedures = Object.fromEntries(Object.entries(custom).map(([name, m]) => [
|
|
216
|
+
name,
|
|
217
|
+
typeof m === "function"
|
|
218
|
+
? proc
|
|
219
|
+
.input(z.any().optional())
|
|
220
|
+
.mutation(({ input }) => m(input))
|
|
221
|
+
: proc
|
|
222
|
+
.input(m.input)
|
|
223
|
+
.mutation(({ input }) => m.handler(input)),
|
|
224
|
+
]));
|
|
225
|
+
return router({
|
|
226
|
+
...customProcedures,
|
|
227
|
+
list: proc.input(listSchema.optional()).mutation(({ input, ctx }) => methods.list({
|
|
228
|
+
...(input ?? {}),
|
|
229
|
+
where: { ...(input?.where ?? {}), ...scope(ctx) },
|
|
230
|
+
})),
|
|
231
|
+
getById: proc
|
|
232
|
+
.input(z.string())
|
|
233
|
+
.mutation(({ input, ctx }) => methods.getOne({ id: input, ...scope(ctx) })),
|
|
234
|
+
create: proc
|
|
235
|
+
.input(z.object({ data: z.record(z.string(), z.any()) }))
|
|
236
|
+
.mutation(async ({ input, ctx }) => {
|
|
237
|
+
const data = stripProtected(input.data);
|
|
238
|
+
await checkRelations(data, ctx);
|
|
239
|
+
return methods.create({ ...data, ...scope(ctx) });
|
|
240
|
+
}),
|
|
241
|
+
update: proc
|
|
242
|
+
.input(z.object({
|
|
243
|
+
id: z.string(),
|
|
244
|
+
data: z.record(z.string(), z.any()),
|
|
245
|
+
}))
|
|
246
|
+
.mutation(async ({ input, ctx }) => {
|
|
247
|
+
const data = stripProtected(input.data, ["id"]);
|
|
248
|
+
await checkRelations(data, ctx);
|
|
249
|
+
// скоуп во where: чужую запись обновить нельзя
|
|
250
|
+
const where = { id: input.id, ...scope(ctx) };
|
|
251
|
+
// после отсева могло не остаться ни одного поля —
|
|
252
|
+
// отвечаем самой записью, а не ложным NOT_FOUND
|
|
253
|
+
if (!Object.keys(data).length) {
|
|
254
|
+
const row = await methods.getOne(where);
|
|
255
|
+
if (!row) {
|
|
256
|
+
throw new TRPCError({
|
|
257
|
+
code: "NOT_FOUND",
|
|
258
|
+
message: `${args.model} not found`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return row;
|
|
262
|
+
}
|
|
263
|
+
return methods.updateWhere(where, data);
|
|
264
|
+
}),
|
|
265
|
+
delete: proc
|
|
266
|
+
.input(z.object({ id: z.string() }))
|
|
267
|
+
.mutation(({ input, ctx }) => methods.deleteWhere({ id: input.id, ...scope(ctx) })),
|
|
268
|
+
count: proc
|
|
269
|
+
.input(z
|
|
270
|
+
.object({
|
|
271
|
+
where: z.record(z.string(), z.any()).optional(),
|
|
272
|
+
})
|
|
273
|
+
.optional())
|
|
274
|
+
.mutation(({ input, ctx }) => methods.count({ ...(input?.where ?? {}), ...scope(ctx) })),
|
|
275
|
+
controller: proc
|
|
276
|
+
.input(z.object({
|
|
277
|
+
id: z.string(),
|
|
278
|
+
method: z.string(),
|
|
279
|
+
data: z.any().optional(),
|
|
280
|
+
}))
|
|
281
|
+
.mutation(async ({ input, ctx }) => {
|
|
282
|
+
const row = (await chain({
|
|
283
|
+
id: input.id,
|
|
284
|
+
...scope(ctx),
|
|
285
|
+
}).first());
|
|
286
|
+
if (!row)
|
|
287
|
+
throw new TRPCError({ code: "NOT_FOUND" });
|
|
288
|
+
const controller = buildController(row);
|
|
289
|
+
// Только собственные методы контроллера: без этой проверки
|
|
290
|
+
// имя вроде "constructor" или "toString" дотягивается до
|
|
291
|
+
// прототипа объекта и вызывается как метод сущности.
|
|
292
|
+
const method = Object.hasOwn(controller, input.method)
|
|
293
|
+
? controller[input.method]
|
|
294
|
+
: undefined;
|
|
295
|
+
if (!method)
|
|
296
|
+
throw new TRPCError({
|
|
297
|
+
code: "BAD_REQUEST",
|
|
298
|
+
message: `Method ${input.method} not found on ${args.model}`,
|
|
299
|
+
});
|
|
300
|
+
return invoke(method, input.data);
|
|
301
|
+
}),
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
return {
|
|
305
|
+
modelName: args.model,
|
|
306
|
+
getModel,
|
|
307
|
+
methods,
|
|
308
|
+
createRouter,
|
|
309
|
+
};
|
|
310
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Шов типизации моделей.
|
|
3
|
+
*
|
|
4
|
+
* Пакет не знает контракта приложения — приложение объявляет его само:
|
|
5
|
+
*
|
|
6
|
+
* type Models = (typeof db)["orm"]["public"];
|
|
7
|
+
*
|
|
8
|
+
* declare module "@quark-fw/entity/src/types.js" {
|
|
9
|
+
* interface EntityModels
|
|
10
|
+
* extends {
|
|
11
|
+
* [K in keyof Models]: Awaited<ReturnType<Models[K]["all"]>>[number];
|
|
12
|
+
* } {}
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* После этого createEntity выводит строку по имени модели, а имена полей
|
|
16
|
+
* (владелец, сортировка, поиск, связи) проверяются компилятором.
|
|
17
|
+
*/
|
|
18
|
+
export interface EntityModels {
|
|
19
|
+
}
|
|
20
|
+
/** объявило ли приложение свои модели */
|
|
21
|
+
type ModelsDeclared = keyof EntityModels extends never ? false : true;
|
|
22
|
+
/**
|
|
23
|
+
* Имя модели контракта.
|
|
24
|
+
*
|
|
25
|
+
* Пока приложение не заполнило шов, это любая строка: пакет должен собираться
|
|
26
|
+
* и сам по себе, без приложения рядом.
|
|
27
|
+
*/
|
|
28
|
+
export type EntityModelName = ModelsDeclared extends true ? keyof EntityModels & string : string;
|
|
29
|
+
/** строка модели по её имени */
|
|
30
|
+
export type EntityRow<TModel extends string> = TModel extends keyof EntityModels ? EntityModels[TModel] : Record<string, unknown>;
|
|
31
|
+
/** имя колонки модели */
|
|
32
|
+
export type EntityField<TModel extends string> = TModel extends keyof EntityModels ? keyof EntityModels[TModel] & string : string;
|
|
33
|
+
export {};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ChangeEvent } from "react";
|
|
2
|
+
export type EntityFormState = "idle" | "processing";
|
|
3
|
+
/**
|
|
4
|
+
* Компоненты зовут onChange по-разному: нативные — событием, часть
|
|
5
|
+
* Mantine-контролов (Select, Checkbox через onChange) — сразу значением.
|
|
6
|
+
*/
|
|
7
|
+
type FieldChange = ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> | string | number | boolean | null;
|
|
8
|
+
export declare function useEntityForm<Shape extends Record<string, unknown>>({ defaultValues, onSubmit, resetOnSuccess, }: {
|
|
9
|
+
defaultValues?: Partial<Shape>;
|
|
10
|
+
onSubmit: (values: Shape) => void | Promise<void>;
|
|
11
|
+
resetOnSuccess?: boolean;
|
|
12
|
+
}): {
|
|
13
|
+
values: Partial<Shape>;
|
|
14
|
+
state: EntityFormState;
|
|
15
|
+
error: string | null;
|
|
16
|
+
reset: () => void;
|
|
17
|
+
submit: () => Promise<void>;
|
|
18
|
+
setValue: <K extends keyof Shape>(field: K, value: Shape[K]) => void;
|
|
19
|
+
getInputProps: (field: keyof Shape) => {
|
|
20
|
+
value: string | number | readonly string[];
|
|
21
|
+
onChange: (event: FieldChange) => void;
|
|
22
|
+
};
|
|
23
|
+
getFormProps: () => {
|
|
24
|
+
onSubmit: (event?: {
|
|
25
|
+
preventDefault?: () => void;
|
|
26
|
+
}) => void;
|
|
27
|
+
};
|
|
28
|
+
getSubmitButtonProps: () => {
|
|
29
|
+
type: "submit";
|
|
30
|
+
loading: boolean;
|
|
31
|
+
disabled: boolean;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { errorMessage } from "@quark-fw/store/src/errors.js";
|
|
3
|
+
export function useEntityForm({ defaultValues, onSubmit, resetOnSuccess = true, }) {
|
|
4
|
+
const initial = defaultValues ?? {};
|
|
5
|
+
const [values, setValues] = useState(initial);
|
|
6
|
+
const [state, setState] = useState("idle");
|
|
7
|
+
const [error, setError] = useState(null);
|
|
8
|
+
const reset = () => {
|
|
9
|
+
setValues(initial);
|
|
10
|
+
setError(null);
|
|
11
|
+
setState("idle");
|
|
12
|
+
};
|
|
13
|
+
const submit = async () => {
|
|
14
|
+
setState("processing");
|
|
15
|
+
setError(null);
|
|
16
|
+
try {
|
|
17
|
+
await onSubmit(values);
|
|
18
|
+
if (resetOnSuccess)
|
|
19
|
+
setValues(initial);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
setError(errorMessage(e));
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
setState("idle");
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const setValue = (field, value) => {
|
|
29
|
+
setValues((prev) => ({ ...prev, [field]: value }));
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
values,
|
|
33
|
+
state,
|
|
34
|
+
error,
|
|
35
|
+
reset,
|
|
36
|
+
submit,
|
|
37
|
+
setValue,
|
|
38
|
+
getInputProps: (field) => ({
|
|
39
|
+
// то, что принимает value у input/textarea
|
|
40
|
+
value: (values[field] ?? ""),
|
|
41
|
+
onChange: (event) => {
|
|
42
|
+
if (event && typeof event === "object" && "target" in event) {
|
|
43
|
+
// checked есть только у input, поэтому сначала смотрим на
|
|
44
|
+
// type, а уже потом читаем нужное поле
|
|
45
|
+
const target = event.target;
|
|
46
|
+
const next = target.type === "checkbox"
|
|
47
|
+
? target.checked
|
|
48
|
+
: target.value;
|
|
49
|
+
setValue(field, next);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setValue(field, event);
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
getFormProps: () => ({
|
|
56
|
+
onSubmit: (event) => {
|
|
57
|
+
event?.preventDefault?.();
|
|
58
|
+
void submit();
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
getSubmitButtonProps: () => ({
|
|
62
|
+
type: "submit",
|
|
63
|
+
loading: state === "processing",
|
|
64
|
+
disabled: state === "processing",
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
}
|
package/dist/uuidv7.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function uuidv7(): string;
|
package/dist/uuidv7.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Клиентская генерация UUIDv7 (RFC 9562): 48 бит unix-времени в мс +
|
|
2
|
+
// версия 7 + случайные биты. Сгенерированный на клиенте id — финальный id
|
|
3
|
+
// записи (оптимистичный элемент списка и есть будущая строка БД).
|
|
4
|
+
export function uuidv7() {
|
|
5
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
6
|
+
const ts = BigInt(Date.now());
|
|
7
|
+
bytes[0] = Number((ts >> 40n) & 0xffn);
|
|
8
|
+
bytes[1] = Number((ts >> 32n) & 0xffn);
|
|
9
|
+
bytes[2] = Number((ts >> 24n) & 0xffn);
|
|
10
|
+
bytes[3] = Number((ts >> 16n) & 0xffn);
|
|
11
|
+
bytes[4] = Number((ts >> 8n) & 0xffn);
|
|
12
|
+
bytes[5] = Number(ts & 0xffn);
|
|
13
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70; // версия 7
|
|
14
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // вариант RFC
|
|
15
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
16
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quark-fw/entity",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Entity CRUD layer over tRPC and Prisma for the Quark framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json",
|
|
14
|
+
"./src/*": "./dist/*",
|
|
15
|
+
"./*": "./dist/*"
|
|
16
|
+
},
|
|
17
|
+
"files": ["dist"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "tsx watch src/index.ts",
|
|
20
|
+
"build": "tsc -p tsconfig.build.json && node ../../scripts/postbuild.mjs"
|
|
21
|
+
},
|
|
22
|
+
"keywords": ["quark"],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "ISC",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@quark-fw/plugin-trpc": "0.0.1",
|
|
30
|
+
"@quark-fw/store": "0.0.1",
|
|
31
|
+
"@trpc/server": "^11.0.0",
|
|
32
|
+
"zod": "^4.0.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@quark-fw/core": "0.0.1",
|
|
36
|
+
"react": "^19.2.4"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/react": "^19.2.14"
|
|
40
|
+
}
|
|
41
|
+
}
|