@quark-fw/entity 0.1.15 → 0.1.17
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/createEntityContext.js +61 -12
- package/dist/client/types.d.ts +3 -0
- package/dist/createEntity.js +48 -21
- package/package.json +4 -4
|
@@ -77,7 +77,7 @@ export function createEntityContext(entityKey) {
|
|
|
77
77
|
createdAt: now,
|
|
78
78
|
...data,
|
|
79
79
|
};
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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 = {
|
package/dist/client/types.d.ts
CHANGED
package/dist/createEntity.js
CHANGED
|
@@ -143,7 +143,16 @@ export function createEntity(args) {
|
|
|
143
143
|
const proc = args.procedure ?? publicProcedure;
|
|
144
144
|
// TRPCUser — пустой интерфейс-шов, его поля объявляет плагин
|
|
145
145
|
// авторизации, поэтому id читаем структурно
|
|
146
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.17",
|
|
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.
|
|
35
|
-
"@quark-fw/store": "0.1.
|
|
34
|
+
"@quark-fw/plugin-trpc": "0.1.17",
|
|
35
|
+
"@quark-fw/store": "0.1.17",
|
|
36
36
|
"@trpc/server": "^11.0.0",
|
|
37
37
|
"zod": "^4.0.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@quark-fw/core": "0.1.
|
|
40
|
+
"@quark-fw/core": "0.1.17",
|
|
41
41
|
"react": "^19.2.4"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|