@quark-fw/entity 0.1.7 → 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/index.js CHANGED
@@ -1,310 +1 @@
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
- }
1
+ export { createEntity, sortSchema, } from "./createEntity.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quark-fw/entity",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Entity CRUD layer over tRPC and Prisma for the Quark framework",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,13 +30,13 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@quark-fw/plugin-trpc": "0.1.7",
34
- "@quark-fw/store": "0.1.7",
33
+ "@quark-fw/plugin-trpc": "0.1.8",
34
+ "@quark-fw/store": "0.1.8",
35
35
  "@trpc/server": "^11.0.0",
36
36
  "zod": "^4.0.0"
37
37
  },
38
38
  "peerDependencies": {
39
- "@quark-fw/core": "0.1.7",
39
+ "@quark-fw/core": "0.1.8",
40
40
  "react": "^19.2.4"
41
41
  },
42
42
  "devDependencies": {