@quark-fw/entity 0.1.6 → 0.1.8

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.js CHANGED
@@ -1,295 +1,5 @@
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 с оптимистичным состоянием и откатом.
1
+ // Публичная поверхность клиентской части: реализация разложена по файлам
2
+ // src/client/ (1 функция = 1 файл), здесь — реэкспорт под прежним путём
3
+ // (@quark-fw/entity/src/client.js), на который завязаны приложения.
5
4
  // ВАЖНО: клиентский модуль — не импортировать серверный ./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
- }
5
+ export { createEntityContext } from "./client/createEntityContext.js";
@@ -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 CustomMethodFn = (input?: unknown) => unknown;
5
+ export type CustomMethodValidated<TSchema extends ZodType = ZodType> = {
6
+ input: TSchema;
7
+ handler: (input: z.output<TSchema>) => unknown;
8
+ };
9
+ export type CustomMethod = CustomMethodFn | CustomMethodValidated;
10
+ /**
11
+ * Одно поле модели в селекторах Prisma 8: `f.name.asc()`, `f.name.ilike(...)`.
12
+ * Набор операторов зависит от типа колонки, поэтому проверяется в рантайме.
13
+ */
14
+ type FieldHandle = {
15
+ [operator: string]: ((...args: string[]) => unknown) | undefined;
16
+ };
17
+ /**
18
+ * Поля модели в селекторе: `(f) => f.name.asc()`. Конкретный состав известен
19
+ * только сгенерированному контракту приложения, здесь — структура.
20
+ */
21
+ type SelectorFields = Record<string, FieldHandle | undefined>;
22
+ /** хелперы агрегатов Prisma 8: `(a) => ({ n: a.count() })` */
23
+ type AggregateHelpers = Record<string, () => unknown>;
24
+ export type EntityModelHandle = {
25
+ where(where: Record<string, unknown>): EntityModelHandle;
26
+ where(selector: (fields: SelectorFields) => unknown): EntityModelHandle;
27
+ skip(n: number): EntityModelHandle;
28
+ take(n: number): EntityModelHandle;
29
+ all(): Promise<unknown[]>;
30
+ first(): Promise<unknown | null>;
31
+ orderBy(selector: (fields: SelectorFields) => unknown): EntityModelHandle;
32
+ aggregate(selector: (a: AggregateHelpers) => unknown): Promise<unknown>;
33
+ create(data: unknown): Promise<unknown>;
34
+ update(data: unknown): Promise<unknown>;
35
+ delete(): Promise<unknown>;
36
+ };
37
+ export declare const sortSchema: z.ZodObject<{
38
+ field: z.ZodString;
39
+ dir: z.ZodDefault<z.ZodEnum<{
40
+ asc: "asc";
41
+ desc: "desc";
42
+ }>>;
43
+ }, z.core.$strip>;
44
+ export type EntitySort = z.output<typeof sortSchema>;
45
+ declare const listSchema: z.ZodObject<{
46
+ where: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
47
+ page: z.ZodDefault<z.ZodNumber>;
48
+ limit: z.ZodDefault<z.ZodNumber>;
49
+ sort: z.ZodOptional<z.ZodObject<{
50
+ field: z.ZodString;
51
+ dir: z.ZodDefault<z.ZodEnum<{
52
+ asc: "asc";
53
+ desc: "desc";
54
+ }>>;
55
+ }, z.core.$strip>>;
56
+ search: z.ZodOptional<z.ZodString>;
57
+ }, z.core.$strip>;
58
+ /**
59
+ * Билдер, на котором строится роутер сущности.
60
+ *
61
+ * userProcedure — это publicProcedure.use(...), то есть тот же билдер с
62
+ * доуточнённым контекстом; в самих процедурах сущности контекст читается
63
+ * только как `ctx.user?.id`, который есть и в базовом TRPCContext.
64
+ */
65
+ export type EntityProcedure = typeof publicProcedure;
66
+ /**
67
+ * Связь с родительской моделью.
68
+ *
69
+ * Тип раздаётся по всем моделям контракта, поэтому поля родителя
70
+ * (parentField, authField) проверяются против ИМЕННО той модели, которая
71
+ * указана в model.
72
+ */
73
+ export type EntityRelation<TModel extends string = EntityModelName> = {
74
+ [TParent in EntityModelName]: {
75
+ /** колонка-ссылка на этой модели, например "projectId" */
76
+ field: EntityField<TModel>;
77
+ /** родительская модель в контракте, например "Project" */
78
+ model: TParent;
79
+ /** колонка родителя, с которой сопоставляется ссылка (по умолчанию id) */
80
+ parentField?: EntityField<TParent>;
81
+ /** колонка владельца на родителе, например "userId" */
82
+ authField: EntityField<TParent>;
83
+ };
84
+ }[EntityModelName];
85
+ export type CreateEntityArgs<TModel extends EntityModelName> = {
86
+ model: TModel;
87
+ authField?: EntityField<TModel>;
88
+ procedure?: EntityProcedure;
89
+ defaultSort?: {
90
+ field: EntityField<TModel>;
91
+ dir?: EntitySort["dir"];
92
+ };
93
+ /**
94
+ * Поле для поиска по подстроке в list({ search }).
95
+ *
96
+ * Одно, а не список: билдер Prisma 8 умеет складывать условия только по И
97
+ * (у полей есть ilike, но ни f.or, ни .or у условия нет), поэтому поиск
98
+ * сразу по нескольким колонкам выражается только отдельными запросами.
99
+ */
100
+ searchField?: EntityField<TModel>;
101
+ relations?: EntityRelation<TModel>[];
102
+ extendMethods?: (args: {
103
+ model: EntityModelHandle;
104
+ }) => Record<string, CustomMethod>;
105
+ extendController?: (args: {
106
+ entity: EntityRow<TModel>;
107
+ }) => Record<string, CustomMethod>;
108
+ };
109
+ /**
110
+ * Сущность поверх модели контракта.
111
+ *
112
+ * Тип строки берётся из самой модели по её имени, поэтому дженерик передавать
113
+ * не нужно: `createEntity({ model: "Task", ... })`. Имена полей —
114
+ * владельца, сортировки, поиска и связей — проверяются против этой модели.
115
+ */
116
+ export declare function createEntity<TModel extends EntityModelName>(args: CreateEntityArgs<TModel>): {
117
+ modelName: TModel;
118
+ getModel: () => EntityModelHandle;
119
+ methods: {
120
+ list: (input?: z.input<typeof listSchema> | undefined) => Promise<(EntityRow<TModel> & {
121
+ id: string;
122
+ })[]>;
123
+ getById: (id: string) => Promise<(EntityRow<TModel> & {
124
+ id: string;
125
+ }) | null>;
126
+ /** как getById, но по произвольному where (используется для скоупинга) */
127
+ getOne: (where: Record<string, unknown>) => Promise<(EntityRow<TModel> & {
128
+ id: string;
129
+ }) | null>;
130
+ create: (data: Record<string, unknown>) => Promise<EntityRow<TModel> & {
131
+ id: string;
132
+ }>;
133
+ update: (id: string, data: Record<string, unknown>) => Promise<EntityRow<TModel> & {
134
+ id: string;
135
+ }>;
136
+ updateWhere: (where: Record<string, unknown>, data: Record<string, unknown>) => Promise<EntityRow<TModel> & {
137
+ id: string;
138
+ }>;
139
+ deleteById: (id: string) => Promise<unknown>;
140
+ deleteWhere: (where: Record<string, unknown>) => Promise<{}>;
141
+ count: (where?: Record<string, unknown>) => Promise<number>;
142
+ };
143
+ createRouter: () => import("@trpc/server").TRPCBuiltRouter<{
144
+ ctx: TRPCContext;
145
+ meta: object;
146
+ errorShape: import("@trpc/server").TRPCDefaultErrorShape;
147
+ transformer: false;
148
+ }, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
149
+ list: import("@trpc/server").TRPCMutationProcedure<{
150
+ input: {
151
+ where?: Record<string, any> | undefined;
152
+ page?: number | undefined;
153
+ limit?: number | undefined;
154
+ sort?: {
155
+ field: string;
156
+ dir?: "asc" | "desc" | undefined;
157
+ } | undefined;
158
+ search?: string | undefined;
159
+ } | undefined;
160
+ output: (EntityRow<TModel> & {
161
+ id: string;
162
+ })[];
163
+ meta: object;
164
+ }>;
165
+ getById: import("@trpc/server").TRPCMutationProcedure<{
166
+ input: string;
167
+ output: (EntityRow<TModel> & {
168
+ id: string;
169
+ }) | null;
170
+ meta: object;
171
+ }>;
172
+ create: import("@trpc/server").TRPCMutationProcedure<{
173
+ input: {
174
+ data: Record<string, any>;
175
+ };
176
+ output: EntityRow<TModel> & {
177
+ id: string;
178
+ };
179
+ meta: object;
180
+ }>;
181
+ update: import("@trpc/server").TRPCMutationProcedure<{
182
+ input: {
183
+ id: string;
184
+ data: Record<string, any>;
185
+ };
186
+ output: EntityRow<TModel> & {
187
+ id: string;
188
+ };
189
+ meta: object;
190
+ }>;
191
+ delete: import("@trpc/server").TRPCMutationProcedure<{
192
+ input: {
193
+ id: string;
194
+ };
195
+ output: {};
196
+ meta: object;
197
+ }>;
198
+ count: import("@trpc/server").TRPCMutationProcedure<{
199
+ input: {
200
+ where?: Record<string, any> | undefined;
201
+ } | undefined;
202
+ output: number;
203
+ meta: object;
204
+ }>;
205
+ controller: import("@trpc/server").TRPCMutationProcedure<{
206
+ input: {
207
+ id: string;
208
+ method: string;
209
+ data?: any;
210
+ };
211
+ output: unknown;
212
+ meta: object;
213
+ }>;
214
+ }>>;
215
+ };
216
+ export {};