@quark-fw/entity 0.1.15 → 0.1.16

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.
@@ -77,7 +77,7 @@ export function createEntityContext(entityKey) {
77
77
  createdAt: now,
78
78
  ...data,
79
79
  };
80
- const old = completeList;
80
+ shiftCount(1);
81
81
  setList((prev) => (prev ? [...prev, item] : [item]));
82
82
  return withMutation(async () => {
83
83
  try {
@@ -85,7 +85,10 @@ export function createEntityContext(entityKey) {
85
85
  }
86
86
  catch (error) {
87
87
  reportError(error, entityKey);
88
- setList(old ?? []);
88
+ // откат точечный, а не снимком списка: снимок, снятый до
89
+ // мутации, стёр бы параллельные create/delete
90
+ setList((prev) => (prev ?? []).filter((e) => e.id !== item.id));
91
+ shiftCount(-1);
89
92
  return null;
90
93
  }
91
94
  });
@@ -121,7 +124,11 @@ export function createEntityContext(entityKey) {
121
124
  });
122
125
  };
123
126
  const deleteById = async (id) => {
124
- const old = completeList;
127
+ const current = completeList ?? fresh;
128
+ const index = current.findIndex((e) => e.id === id);
129
+ const removed = index === -1 ? undefined : current[index];
130
+ if (removed)
131
+ shiftCount(-1);
125
132
  setList((prev) => (prev ?? []).filter((e) => e.id !== id));
126
133
  await withMutation(async () => {
127
134
  try {
@@ -129,7 +136,18 @@ export function createEntityContext(entityKey) {
129
136
  }
130
137
  catch (error) {
131
138
  reportError(error, entityKey);
132
- setList(old ?? []);
139
+ if (!removed)
140
+ return;
141
+ // возвращаем только удалённую запись и на её место —
142
+ // снимок списка стёр бы параллельные мутации
143
+ setList((prev) => {
144
+ const arr = [...(prev ?? [])];
145
+ if (arr.some((e) => e.id === id))
146
+ return arr;
147
+ arr.splice(Math.min(index, arr.length), 0, removed);
148
+ return arr;
149
+ });
150
+ shiftCount(1);
133
151
  }
134
152
  });
135
153
  };
@@ -167,7 +185,26 @@ export function createEntityContext(entityKey) {
167
185
  // их состав: иначе эффект ниже перезапускается на каждый рендер
168
186
  const serverArgs = useMemo(() => JSON.parse(argsKey), [argsKey]);
169
187
  const fresh = tinyCaller.list.mutate(serverArgs);
170
- const count = tinyCaller.count.mutate(filter ? { where: filter } : {});
188
+ const countArgs = filter ? { where: filter } : {};
189
+ const count = tinyCaller.count.mutate(countArgs);
190
+ /**
191
+ * Сдвиг закэшированного count вслед за оптимистичной мутацией: иначе
192
+ * total застывал на значении первой загрузки. Вне рендера mutate
193
+ * отдаёт значение из кэша; если его там нет (ещё грузится) — бросает,
194
+ * и сдвигать нечего.
195
+ */
196
+ const shiftCount = (delta) => {
197
+ let current;
198
+ try {
199
+ current = tinyCaller.count.mutate(countArgs);
200
+ }
201
+ catch {
202
+ return;
203
+ }
204
+ if (typeof current === "number") {
205
+ tinyCaller.count.setCache(countArgs)(current + delta);
206
+ }
207
+ };
171
208
  const loadMore = async () => {
172
209
  const nextPage = loadedPages.current + 1;
173
210
  const rows = await withMutation(() => trpcCaller.list.mutate({ ...serverArgs, page: nextPage }));
@@ -183,6 +220,14 @@ export function createEntityContext(entityKey) {
183
220
  });
184
221
  return rows.length;
185
222
  };
223
+ // Другой фильтр/сортировка/лимит — другой список, снова с первой
224
+ // страницы. Без сброса догруженные страницы старого запроса навсегда
225
+ // выключали синхронизацию. Эффект объявлен до эффекта синхронизации:
226
+ // в одном коммите они выполняются по порядку.
227
+ const baseKey = JSON.stringify([filter, sort, limit]);
228
+ useEffect(() => {
229
+ loadedPages.current = 1;
230
+ }, [baseKey]);
186
231
  // мутации обновляют кэш store по тем же аргументам, что и чтение
187
232
  useEffect(() => {
188
233
  // ref читаем до подписки: к моменту очистки .current уже может
@@ -263,13 +308,17 @@ export function createEntityContext(entityKey) {
263
308
  const entities = useEntities();
264
309
  const controller = new Proxy({}, {
265
310
  get(_target, method) {
266
- return (data) => trpcCaller.controller.mutate({
267
- id,
268
- // ключ прокси может быть символом, а по сети уходит
269
- // имя метода — берём только строковые
270
- method: String(method),
271
- data,
272
- });
311
+ // Не методы контроллера: символы и служебные имена, которые
312
+ // трогают await (then), JSON.stringify (toJSON) и React/
313
+ // devtools ($$typeof) — иначе любое такое обращение, например
314
+ // `await controller`, уходило бы сетевым вызовом.
315
+ if (typeof method !== "string" ||
316
+ method === "then" ||
317
+ method === "toJSON" ||
318
+ method.startsWith("$$")) {
319
+ return undefined;
320
+ }
321
+ return (data) => trpcCaller.controller.mutate({ id, method, data });
273
322
  },
274
323
  });
275
324
  const value = {
@@ -36,6 +36,9 @@ export type EntityTinyCaller<Row> = {
36
36
  mutate(args: {
37
37
  where?: Record<string, unknown>;
38
38
  }): number;
39
+ setCache(args: {
40
+ where?: Record<string, unknown>;
41
+ }): (n: number) => void;
39
42
  };
40
43
  getById: {
41
44
  mutate(id: string): Row | null;
@@ -143,7 +143,16 @@ export function createEntity(args) {
143
143
  const proc = args.procedure ?? publicProcedure;
144
144
  // TRPCUser — пустой интерфейс-шов, его поля объявляет плагин
145
145
  // авторизации, поэтому id читаем структурно
146
- const ownerId = (ctx) => ctx.user?.id;
146
+ //
147
+ // Без пользователя — UNAUTHORIZED, а не undefined: в Prisma ключ
148
+ // where со значением undefined фильтром не считается, и скоуп
149
+ // { userId: undefined } на publicProcedure отдал бы все строки.
150
+ const ownerId = (ctx) => {
151
+ const id = ctx.user?.id;
152
+ if (!id)
153
+ throw new TRPCError({ code: "UNAUTHORIZED" });
154
+ return id;
155
+ };
147
156
  // where-скоуп владельца: {} если authField не задан
148
157
  const scope = (ctx) => args.authField ? { [args.authField]: ownerId(ctx) } : {};
149
158
  /**
@@ -188,6 +197,31 @@ export function createEntity(args) {
188
197
  }
189
198
  }
190
199
  };
200
+ /**
201
+ * Обновление по запросу клиента: отсев защищённых полей, проверка
202
+ * родителей и скоуп во where. Общее для процедуры update и
203
+ * controller.update — иначе через контроллер клиент обходил бы отсев
204
+ * и переписывал владельца записи.
205
+ */
206
+ const updateFromClient = async (id, input, ctx) => {
207
+ const data = stripProtected(input, ["id"]);
208
+ await checkRelations(data, ctx);
209
+ // скоуп во where: чужую запись обновить нельзя
210
+ const where = { id, ...scope(ctx) };
211
+ // после отсева могло не остаться ни одного поля —
212
+ // отвечаем самой записью, а не ложным NOT_FOUND
213
+ if (!Object.keys(data).length) {
214
+ const row = await methods.getOne(where);
215
+ if (!row) {
216
+ throw new TRPCError({
217
+ code: "NOT_FOUND",
218
+ message: `${args.model} not found`,
219
+ });
220
+ }
221
+ return row;
222
+ }
223
+ return methods.updateWhere(where, data);
224
+ };
191
225
  const custom = args.extendMethods?.({ model: getModel() }) ?? {};
192
226
  const customProcedures = Object.fromEntries(Object.entries(custom).map(([name, m]) => [
193
227
  name,
@@ -220,25 +254,7 @@ export function createEntity(args) {
220
254
  id: z.string(),
221
255
  data: z.record(z.string(), z.any()),
222
256
  }))
223
- .mutation(async ({ input, ctx }) => {
224
- const data = stripProtected(input.data, ["id"]);
225
- await checkRelations(data, ctx);
226
- // скоуп во where: чужую запись обновить нельзя
227
- const where = { id: input.id, ...scope(ctx) };
228
- // после отсева могло не остаться ни одного поля —
229
- // отвечаем самой записью, а не ложным NOT_FOUND
230
- if (!Object.keys(data).length) {
231
- const row = await methods.getOne(where);
232
- if (!row) {
233
- throw new TRPCError({
234
- code: "NOT_FOUND",
235
- message: `${args.model} not found`,
236
- });
237
- }
238
- return row;
239
- }
240
- return methods.updateWhere(where, data);
241
- }),
257
+ .mutation(({ input, ctx }) => updateFromClient(input.id, input.data, ctx)),
242
258
  delete: proc
243
259
  .input(z.object({ id: z.string() }))
244
260
  .mutation(({ input, ctx }) => methods.deleteWhere({ id: input.id, ...scope(ctx) })),
@@ -262,7 +278,18 @@ export function createEntity(args) {
262
278
  }).first());
263
279
  if (!row)
264
280
  throw new TRPCError({ code: "NOT_FOUND" });
265
- const controller = buildController(row);
281
+ // встроенный update подменяется клиентской версией;
282
+ // кастомные методы extendController по-прежнему главнее
283
+ const controller = {
284
+ ...baseController(row),
285
+ update: {
286
+ input: z.record(z.string(), z.any()),
287
+ handler: (data) => updateFromClient(row.id,
288
+ // input выше гарантирует объект
289
+ data, ctx),
290
+ },
291
+ ...(args.extendController?.({ entity: row }) ?? {}),
292
+ };
266
293
  // Только собственные методы контроллера: без этой проверки
267
294
  // имя вроде "constructor" или "toString" дотягивается до
268
295
  // прототипа объекта и вызывается как метод сущности.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quark-fw/entity",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Entity CRUD layer over tRPC and Prisma for the Quark framework",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,13 +31,13 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@quark-fw/plugin-trpc": "0.1.15",
35
- "@quark-fw/store": "0.1.15",
34
+ "@quark-fw/plugin-trpc": "0.1.16",
35
+ "@quark-fw/store": "0.1.16",
36
36
  "@trpc/server": "^11.0.0",
37
37
  "zod": "^4.0.0"
38
38
  },
39
39
  "peerDependencies": {
40
- "@quark-fw/core": "0.1.15",
40
+ "@quark-fw/core": "0.1.16",
41
41
  "react": "^19.2.4"
42
42
  },
43
43
  "devDependencies": {