@quark-fw/entity 0.1.26 → 0.1.28
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/README.md +6 -0
- package/dist/client/createEntityContext.d.ts +7 -1
- package/dist/client/createEntityContext.js +45 -1
- package/dist/client/subscribeChanges.d.ts +15 -0
- package/dist/client/subscribeChanges.js +29 -0
- package/dist/client/types.d.ts +15 -0
- package/dist/client.d.ts +1 -0
- package/dist/createEntity.d.ts +34 -3
- package/dist/createEntity.js +111 -20
- package/dist/global.d.ts +8 -0
- package/dist/helpers.js +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/types.d.ts +8 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -18,6 +18,12 @@ import { createEntity } from "@quark-fw/entity";
|
|
|
18
18
|
export const taskEntity = createEntity({
|
|
19
19
|
model: "task", // key of your EntityModels augmentation
|
|
20
20
|
defaultSort: { field: "createdAt", dir: "desc" },
|
|
21
|
+
// scope beyond the owner: a where fragment from the context (team, org); the
|
|
22
|
+
// client cannot set these fields, create() stamps them
|
|
23
|
+
scope: (ctx) => ({ orgId: ctx.user.orgId }),
|
|
24
|
+
// allowlists for the client: other columns are server-side only (methods)
|
|
25
|
+
writable: ["title", "done"], // create / update / controller.update
|
|
26
|
+
filterable: ["title", "done", "createdAt"], // list/count where keys and sort field
|
|
21
27
|
methods: {
|
|
22
28
|
// custom method: plain function or { input: zodSchema, handler }
|
|
23
29
|
complete: { input: z.object({ id: z.string() }), handler: ({ id }) => /* ... */ },
|
|
@@ -3,7 +3,7 @@ import type { Serialize } from "@quark-fw/store/src/serialize.js";
|
|
|
3
3
|
import type { EntitiesContextType, EntityContextType } from "./types.js";
|
|
4
4
|
export declare function createEntityContext<TRow extends {
|
|
5
5
|
id: string;
|
|
6
|
-
}>(entityKey: string): readonly [({ children, filter, limit, sort, }: {
|
|
6
|
+
}>(entityKey: string): readonly [({ children, filter, limit, sort, live, }: {
|
|
7
7
|
children: ReactNode;
|
|
8
8
|
/** серверный фильтр списка (общий для всех потребителей) */
|
|
9
9
|
filter?: Record<string, unknown>;
|
|
@@ -12,6 +12,12 @@ export declare function createEntityContext<TRow extends {
|
|
|
12
12
|
field: string;
|
|
13
13
|
dir?: "asc" | "desc";
|
|
14
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* Живые обновления: подписка на изменения сущности (SSE) и
|
|
17
|
+
* перечитывание списка при каждом событии — правки других
|
|
18
|
+
* пользователей и вкладок появляются без перезагрузки.
|
|
19
|
+
*/
|
|
20
|
+
live?: boolean;
|
|
15
21
|
}) => import("react/jsx-runtime").JSX.Element, ({ children, id, }: {
|
|
16
22
|
children: ReactNode;
|
|
17
23
|
id: string;
|
|
@@ -5,6 +5,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
5
5
|
// ВАЖНО: клиентский модуль — не импортировать серверный ../index.js.
|
|
6
6
|
import { createContext, startTransition, useEffect, useMemo, useRef, useState, } from "react";
|
|
7
7
|
import { useStore, useTrpc } from "@quark-fw/store";
|
|
8
|
+
import { subscribeChanges } from "./subscribeChanges.js";
|
|
8
9
|
import { reportError } from "@quark-fw/store/src/errors.js";
|
|
9
10
|
import { useEntityForm } from "../useEntityForm.js";
|
|
10
11
|
import { uuidv7 } from "../uuidv7.js";
|
|
@@ -14,7 +15,7 @@ export function createEntityContext(entityKey) {
|
|
|
14
15
|
const EntityContext = createContext(null);
|
|
15
16
|
const useEntities = useSafeContext(`${entityKey} entities context was not found in tree`, EntitiesContext);
|
|
16
17
|
const useEntity = useSafeContext(`${entityKey} entity context was not found in tree`, EntityContext);
|
|
17
|
-
const EntitiesProvider = ({ children, filter, limit = 100, sort, }) => {
|
|
18
|
+
const EntitiesProvider = ({ children, filter, limit = 100, sort, live = false, }) => {
|
|
18
19
|
const store = useStore();
|
|
19
20
|
// Имя сущности приходит параметром, поэтому ветку роутера нельзя
|
|
20
21
|
// вывести статически — но набор процедур у всех сущностей общий и
|
|
@@ -262,6 +263,49 @@ export function createEntityContext(entityKey) {
|
|
|
262
263
|
// рендер, а сам пишет состояние — с ним эффект зациклится
|
|
263
264
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
264
265
|
}, [fresh]);
|
|
266
|
+
// live: по событию перечитываем список и счётчик по сети и кладём в
|
|
267
|
+
// состояние тем же путём, что и серверные данные. Пачка событий
|
|
268
|
+
// (перестановка карточек) схлопывается в один запрос; собственная
|
|
269
|
+
// мутация в полёте важнее — перечитаем после неё.
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (!live)
|
|
272
|
+
return;
|
|
273
|
+
let timer;
|
|
274
|
+
let cancelled = false;
|
|
275
|
+
const refresh = async () => {
|
|
276
|
+
if (cancelled || loadedPages.current > 1)
|
|
277
|
+
return;
|
|
278
|
+
if (mutationsInFlight.current > 0) {
|
|
279
|
+
timer = setTimeout(() => void refresh(), 300);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const [list, total] = await Promise.all([
|
|
284
|
+
trpcCaller.list.mutate(serverArgs),
|
|
285
|
+
trpcCaller.count.mutate(countArgs),
|
|
286
|
+
]);
|
|
287
|
+
if (cancelled)
|
|
288
|
+
return;
|
|
289
|
+
setList(list);
|
|
290
|
+
tinyCaller.count.setCache(countArgs)(total);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
reportError(error, entityKey);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
const off = subscribeChanges(entityKey, (handlers) => trpcCaller.changes.subscribe(undefined, handlers), () => {
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
timer = setTimeout(() => void refresh(), 150);
|
|
299
|
+
}, (error) => reportError(error, entityKey));
|
|
300
|
+
return () => {
|
|
301
|
+
cancelled = true;
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
off();
|
|
304
|
+
};
|
|
305
|
+
// setList/trpcCaller пересоздаются каждый рендер — зависим от
|
|
306
|
+
// ключа аргументов, как эффект синхронизации выше
|
|
307
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
308
|
+
}, [live, argsKey]);
|
|
265
309
|
const rows = completeList ?? fresh;
|
|
266
310
|
const value = {
|
|
267
311
|
list: ({ filter: extraFilter } = {}) => [
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EntityChange } from "../types.js";
|
|
2
|
+
type Subscription = {
|
|
3
|
+
unsubscribe(): void;
|
|
4
|
+
};
|
|
5
|
+
type Subscribe = (handlers: {
|
|
6
|
+
onData: (change: EntityChange) => void;
|
|
7
|
+
onError?: (error: unknown) => void;
|
|
8
|
+
}) => Subscription;
|
|
9
|
+
/**
|
|
10
|
+
* Подписка на изменения сущности, разделяемая всеми провайдерами с тем же
|
|
11
|
+
* ключом. Возвращает функцию отписки; соединение закрывается, когда отписался
|
|
12
|
+
* последний.
|
|
13
|
+
*/
|
|
14
|
+
export declare function subscribeChanges(key: string, subscribe: Subscribe, listener: (change: EntityChange) => void, onError: (error: unknown) => void): () => void;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// одна SSE-подписка на сущность на вкладку: провайдеров одной сущности на
|
|
2
|
+
// странице много (колонка доски — провайдер), а браузер держит к хосту лишь
|
|
3
|
+
// шесть HTTP/1.1-соединений; по подписке на провайдер доска их исчерпала бы
|
|
4
|
+
const shared = new Map();
|
|
5
|
+
/**
|
|
6
|
+
* Подписка на изменения сущности, разделяемая всеми провайдерами с тем же
|
|
7
|
+
* ключом. Возвращает функцию отписки; соединение закрывается, когда отписался
|
|
8
|
+
* последний.
|
|
9
|
+
*/
|
|
10
|
+
export function subscribeChanges(key, subscribe, listener, onError) {
|
|
11
|
+
let entry = shared.get(key);
|
|
12
|
+
if (!entry) {
|
|
13
|
+
const listeners = new Set();
|
|
14
|
+
const subscription = subscribe({
|
|
15
|
+
onData: (change) => listeners.forEach((l) => l(change)),
|
|
16
|
+
onError,
|
|
17
|
+
});
|
|
18
|
+
entry = { subscription, listeners };
|
|
19
|
+
shared.set(key, entry);
|
|
20
|
+
}
|
|
21
|
+
entry.listeners.add(listener);
|
|
22
|
+
return () => {
|
|
23
|
+
entry.listeners.delete(listener);
|
|
24
|
+
if (entry.listeners.size === 0) {
|
|
25
|
+
entry.subscription.unsubscribe();
|
|
26
|
+
shared.delete(key);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
package/dist/client/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { useEntityForm } from "../useEntityForm.js";
|
|
2
|
+
import type { EntityChange } from "../types.js";
|
|
2
3
|
export type EntitiesListParams = {
|
|
3
4
|
/**
|
|
4
5
|
* Фильтр КОНКРЕТНОГО потребителя (например колонки доски). Применяется
|
|
@@ -49,6 +50,11 @@ export type EntityTrpcCaller<Row> = {
|
|
|
49
50
|
list: {
|
|
50
51
|
mutate(args: EntityListArgs): Promise<Row[]>;
|
|
51
52
|
};
|
|
53
|
+
count: {
|
|
54
|
+
mutate(args: {
|
|
55
|
+
where?: Record<string, unknown>;
|
|
56
|
+
}): Promise<number>;
|
|
57
|
+
};
|
|
52
58
|
create: {
|
|
53
59
|
mutate(args: {
|
|
54
60
|
data: Record<string, unknown>;
|
|
@@ -72,6 +78,15 @@ export type EntityTrpcCaller<Row> = {
|
|
|
72
78
|
data?: unknown;
|
|
73
79
|
}): Promise<unknown>;
|
|
74
80
|
};
|
|
81
|
+
/** SSE-поток изменений сущности (см. live у EntitiesProvider) */
|
|
82
|
+
changes: {
|
|
83
|
+
subscribe(input: undefined, handlers: {
|
|
84
|
+
onData: (change: EntityChange) => void;
|
|
85
|
+
onError?: (error: unknown) => void;
|
|
86
|
+
}): {
|
|
87
|
+
unsubscribe(): void;
|
|
88
|
+
};
|
|
89
|
+
};
|
|
75
90
|
};
|
|
76
91
|
export type TinyBranch<Row> = {
|
|
77
92
|
entity: Record<string, EntityTinyCaller<Row>>;
|
package/dist/client.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { createEntityContext } from "./client/createEntityContext.js";
|
|
2
2
|
export type { EntitiesContextType, EntitiesListParams, EntityContextType, EntityListArgs, EntityTinyCaller, EntityTrpcCaller, } from "./client/types.js";
|
|
3
|
+
export type { EntityChange } from "./types.js";
|
package/dist/createEntity.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z, ZodType } from "zod";
|
|
2
2
|
import { publicProcedure, type TRPCContext } from "@quark-fw/plugin-trpc/src/trpc.js";
|
|
3
|
-
import type { EntityField, EntityModelName, EntityRow } from "./types.js";
|
|
3
|
+
import type { EntityChange, EntityField, EntityModelName, EntityRow } from "./types.js";
|
|
4
4
|
export type CustomMethodFn = (input?: unknown) => unknown;
|
|
5
5
|
export type CustomMethodValidated<TSchema extends ZodType = ZodType> = {
|
|
6
6
|
input: TSchema;
|
|
@@ -79,12 +79,26 @@ export type EntityRelation<TModel extends string = EntityModelName> = {
|
|
|
79
79
|
/** колонка родителя, с которой сопоставляется ссылка (по умолчанию id) */
|
|
80
80
|
parentField?: EntityField<TParent>;
|
|
81
81
|
/** колонка владельца на родителе, например "userId" */
|
|
82
|
-
authField
|
|
82
|
+
authField?: EntityField<TParent>;
|
|
83
|
+
/** либо произвольный скоуп родителя (см. scope у createEntity) */
|
|
84
|
+
scope?: EntityScope;
|
|
83
85
|
};
|
|
84
86
|
}[EntityModelName];
|
|
87
|
+
/**
|
|
88
|
+
* where-фрагмент, ограничивающий видимость и запись для вызывающего:
|
|
89
|
+
* { orgId: ctx.user.orgId }, { teamId: await teamOf(ctx) }. Только равенства:
|
|
90
|
+
* тот же фрагмент дописывается в create, чтобы запись сразу попала в скоуп.
|
|
91
|
+
*/
|
|
92
|
+
export type EntityScope = (ctx: TRPCContext) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
85
93
|
export type CreateEntityArgs<TModel extends EntityModelName> = {
|
|
86
94
|
model: TModel;
|
|
87
95
|
authField?: EntityField<TModel>;
|
|
96
|
+
/**
|
|
97
|
+
* Произвольный скоуп поверх (или вместо) владельца: команда, организация,
|
|
98
|
+
* роль. Применяется ко всем процедурам роутера как и authField; поля
|
|
99
|
+
* скоупа клиент задавать не может — они берутся из контекста.
|
|
100
|
+
*/
|
|
101
|
+
scope?: EntityScope;
|
|
88
102
|
procedure?: EntityProcedure;
|
|
89
103
|
defaultSort?: {
|
|
90
104
|
field: EntityField<TModel>;
|
|
@@ -98,6 +112,18 @@ export type CreateEntityArgs<TModel extends EntityModelName> = {
|
|
|
98
112
|
* сразу по нескольким колонкам выражается только отдельными запросами.
|
|
99
113
|
*/
|
|
100
114
|
searchField?: EntityField<TModel>;
|
|
115
|
+
/**
|
|
116
|
+
* Поля, которые клиент вправе задавать в create/update. Без списка
|
|
117
|
+
* принимается любая колонка — клиент пишет и в служебные поля (массовое
|
|
118
|
+
* присваивание). Колонка владельца и id отсекаются в любом случае.
|
|
119
|
+
*/
|
|
120
|
+
writable?: EntityField<TModel>[];
|
|
121
|
+
/**
|
|
122
|
+
* Поля, по которым клиент вправе фильтровать (where) и сортировать (sort)
|
|
123
|
+
* в list/count. Без списка — любая колонка, в том числе та, что наружу не
|
|
124
|
+
* отдаётся: сортировкой по ней значение подбирается посимвольно.
|
|
125
|
+
*/
|
|
126
|
+
filterable?: EntityField<TModel>[];
|
|
101
127
|
relations?: EntityRelation<TModel>[];
|
|
102
128
|
extendMethods?: (args: {
|
|
103
129
|
model: EntityModelHandle;
|
|
@@ -143,7 +169,7 @@ export declare function createEntity<TModel extends EntityModelName>(args: Creat
|
|
|
143
169
|
createRouter: () => import("@trpc/server").TRPCBuiltRouter<{
|
|
144
170
|
ctx: TRPCContext;
|
|
145
171
|
meta: object;
|
|
146
|
-
errorShape: import("@trpc/
|
|
172
|
+
errorShape: import("@quark-fw/plugin-trpc/src/errorFormatter.js").ErrorShape;
|
|
147
173
|
transformer: false;
|
|
148
174
|
}, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
|
|
149
175
|
list: import("@trpc/server").TRPCMutationProcedure<{
|
|
@@ -195,6 +221,11 @@ export declare function createEntity<TModel extends EntityModelName>(args: Creat
|
|
|
195
221
|
output: {};
|
|
196
222
|
meta: object;
|
|
197
223
|
}>;
|
|
224
|
+
changes: import("@trpc/server").TRPCSubscriptionProcedure<{
|
|
225
|
+
input: void;
|
|
226
|
+
output: AsyncIterable<EntityChange, void, any>;
|
|
227
|
+
meta: object;
|
|
228
|
+
}>;
|
|
198
229
|
count: import("@trpc/server").TRPCMutationProcedure<{
|
|
199
230
|
input: {
|
|
200
231
|
where?: Record<string, any> | undefined;
|
package/dist/createEntity.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { TRPCError } from "@trpc/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { publicProcedure, router, } from "@quark-fw/plugin-trpc/src/trpc.js";
|
|
4
|
+
import { subscribeEvent } from "@quark-fw/plugin-trpc/src/subscribeEvent.js";
|
|
4
5
|
import { invoke, modelByName } from "./helpers.js";
|
|
5
6
|
// Сериализуемая сортировка: orderBy в Prisma 8 принимает СЕЛЕКТОР-функцию
|
|
6
7
|
// (f) => f.field.asc(), поэтому по сети передаём {field, dir}, а функцию
|
|
@@ -87,6 +88,22 @@ export function createEntity(args) {
|
|
|
87
88
|
...baseController(row),
|
|
88
89
|
...(args.extendController?.({ entity: row }) ?? {}),
|
|
89
90
|
});
|
|
91
|
+
// Событие после записи: на нём держатся подписки клиента (changes) и
|
|
92
|
+
// любая серверная реакция; через plugin-threads уходит во все инстансы.
|
|
93
|
+
// Владелец в событии — чтобы подписка не раздавала чужие изменения.
|
|
94
|
+
const emitChange = (op, row) => {
|
|
95
|
+
if (row && typeof row === "object" && "id" in row) {
|
|
96
|
+
const r = row;
|
|
97
|
+
const owner = args.authField ? r[args.authField] : undefined;
|
|
98
|
+
quark.ee.value.emit("entity:changed", {
|
|
99
|
+
model: args.model,
|
|
100
|
+
op,
|
|
101
|
+
id: String(r.id),
|
|
102
|
+
...(typeof owner === "string" ? { owner } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return row;
|
|
106
|
+
};
|
|
90
107
|
const withController = (row) => {
|
|
91
108
|
if (!row || typeof row !== "object")
|
|
92
109
|
return row;
|
|
@@ -104,8 +121,8 @@ export function createEntity(args) {
|
|
|
104
121
|
getById: async (id) => withController((await chain({ id }).first())),
|
|
105
122
|
/** как getById, но по произвольному where (используется для скоупинга) */
|
|
106
123
|
getOne: async (where) => withController((await chain(where).first())),
|
|
107
|
-
create: async (data) => withController((await getModel().create(data))),
|
|
108
|
-
update: async (id, data) => withController((await chain({ id }).update(data))),
|
|
124
|
+
create: async (data) => withController(emitChange("create", (await getModel().create(data)))),
|
|
125
|
+
update: async (id, data) => withController(emitChange("update", (await chain({ id }).update(data)))),
|
|
109
126
|
// Prisma 8 при непопадании во where возвращает null, а не бросает.
|
|
110
127
|
// Молчаливый null скрыл бы и опечатку в id, и попытку тронуть чужую
|
|
111
128
|
// запись — отвечаем NOT_FOUND (существование чужой не подтверждаем).
|
|
@@ -117,9 +134,9 @@ export function createEntity(args) {
|
|
|
117
134
|
message: `${args.model} not found`,
|
|
118
135
|
});
|
|
119
136
|
}
|
|
120
|
-
return withController(row);
|
|
137
|
+
return withController(emitChange("update", row));
|
|
121
138
|
},
|
|
122
|
-
deleteById: (id) => chain({ id }).delete(),
|
|
139
|
+
deleteById: async (id) => emitChange("delete", await chain({ id }).delete()),
|
|
123
140
|
deleteWhere: async (where) => {
|
|
124
141
|
const row = await chain(where).delete();
|
|
125
142
|
if (!row) {
|
|
@@ -128,7 +145,7 @@ export function createEntity(args) {
|
|
|
128
145
|
message: `${args.model} not found`,
|
|
129
146
|
});
|
|
130
147
|
}
|
|
131
|
-
return row;
|
|
148
|
+
return emitChange("delete", row);
|
|
132
149
|
},
|
|
133
150
|
// count() билдера доступен только внутри include-рефайнментов —
|
|
134
151
|
// счёт делается через aggregate
|
|
@@ -153,8 +170,12 @@ export function createEntity(args) {
|
|
|
153
170
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
154
171
|
return id;
|
|
155
172
|
};
|
|
156
|
-
// where-скоуп
|
|
157
|
-
|
|
173
|
+
// where-скоуп вызывающего: владелец (authField) и/или произвольный
|
|
174
|
+
// фрагмент из scope; {} если не задано ничего
|
|
175
|
+
const scope = async (ctx) => ({
|
|
176
|
+
...(args.authField ? { [args.authField]: ownerId(ctx) } : {}),
|
|
177
|
+
...(args.scope ? await args.scope(ctx) : {}),
|
|
178
|
+
});
|
|
158
179
|
/**
|
|
159
180
|
* Убирает из присланных данных поля, которые клиент задавать не вправе.
|
|
160
181
|
*
|
|
@@ -174,6 +195,26 @@ export function createEntity(args) {
|
|
|
174
195
|
delete clean[field];
|
|
175
196
|
return clean;
|
|
176
197
|
};
|
|
198
|
+
// Поля вне списка — ошибкой, а не молчаливым отсевом: отсев скрыл бы
|
|
199
|
+
// опечатку в клиенте так же, как и попытку записать лишнее
|
|
200
|
+
const assertFields = (keys, allowed, kind) => {
|
|
201
|
+
if (!allowed)
|
|
202
|
+
return;
|
|
203
|
+
for (const key of keys) {
|
|
204
|
+
if (!allowed.includes(key)) {
|
|
205
|
+
throw new TRPCError({
|
|
206
|
+
code: "BAD_REQUEST",
|
|
207
|
+
message: `Field "${key}" of ${args.model} is not ${kind}`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const assertFilter = (input) => {
|
|
213
|
+
assertFields(Object.keys(input?.where ?? {}), args.filterable, "filterable");
|
|
214
|
+
if (input?.sort) {
|
|
215
|
+
assertFields([input.sort.field], args.filterable, "filterable");
|
|
216
|
+
}
|
|
217
|
+
};
|
|
177
218
|
// порт checkRelation: записать ссылку на родителя можно, только если
|
|
178
219
|
// этот родитель принадлежит вызывающему (иначе чужой id «подсунули»)
|
|
179
220
|
const checkRelations = async (data, ctx) => {
|
|
@@ -184,7 +225,10 @@ export function createEntity(args) {
|
|
|
184
225
|
const parent = await modelByName(relation.model)
|
|
185
226
|
.where({
|
|
186
227
|
[relation.parentField ?? "id"]: value,
|
|
187
|
-
|
|
228
|
+
...(relation.authField
|
|
229
|
+
? { [relation.authField]: ownerId(ctx) }
|
|
230
|
+
: {}),
|
|
231
|
+
...(relation.scope ? await relation.scope(ctx) : {}),
|
|
188
232
|
})
|
|
189
233
|
.first();
|
|
190
234
|
if (!parent) {
|
|
@@ -204,10 +248,14 @@ export function createEntity(args) {
|
|
|
204
248
|
* и переписывал владельца записи.
|
|
205
249
|
*/
|
|
206
250
|
const updateFromClient = async (id, input, ctx) => {
|
|
207
|
-
const
|
|
251
|
+
const current = await scope(ctx);
|
|
252
|
+
// поля скоупа тоже защищены: иначе запись переносится в чужую
|
|
253
|
+
// команду/организацию так же, как раньше — к чужому владельцу
|
|
254
|
+
const data = stripProtected(input, ["id", ...Object.keys(current)]);
|
|
255
|
+
assertFields(Object.keys(data), args.writable, "writable");
|
|
208
256
|
await checkRelations(data, ctx);
|
|
209
257
|
// скоуп во where: чужую запись обновить нельзя
|
|
210
|
-
const where = { id, ...
|
|
258
|
+
const where = { id, ...current };
|
|
211
259
|
// после отсева могло не остаться ни одного поля —
|
|
212
260
|
// отвечаем самой записью, а не ложным NOT_FOUND
|
|
213
261
|
if (!Object.keys(data).length) {
|
|
@@ -235,19 +283,29 @@ export function createEntity(args) {
|
|
|
235
283
|
]));
|
|
236
284
|
return router({
|
|
237
285
|
...customProcedures,
|
|
238
|
-
list: proc
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
286
|
+
list: proc
|
|
287
|
+
.input(listSchema.optional())
|
|
288
|
+
.mutation(async ({ input, ctx }) => {
|
|
289
|
+
assertFilter(input);
|
|
290
|
+
return methods.list({
|
|
291
|
+
...(input ?? {}),
|
|
292
|
+
where: {
|
|
293
|
+
...(input?.where ?? {}),
|
|
294
|
+
...(await scope(ctx)),
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}),
|
|
242
298
|
getById: proc
|
|
243
299
|
.input(z.string())
|
|
244
|
-
.mutation(({ input, ctx }) => methods.getOne({ id: input, ...scope(ctx) })),
|
|
300
|
+
.mutation(async ({ input, ctx }) => methods.getOne({ id: input, ...(await scope(ctx)) })),
|
|
245
301
|
create: proc
|
|
246
302
|
.input(z.object({ data: z.record(z.string(), z.any()) }))
|
|
247
303
|
.mutation(async ({ input, ctx }) => {
|
|
248
|
-
const
|
|
304
|
+
const current = await scope(ctx);
|
|
305
|
+
const data = stripProtected(input.data, Object.keys(current));
|
|
306
|
+
assertFields(Object.keys(data), args.writable, "writable");
|
|
249
307
|
await checkRelations(data, ctx);
|
|
250
|
-
return methods.create({ ...data, ...
|
|
308
|
+
return methods.create({ ...data, ...current });
|
|
251
309
|
}),
|
|
252
310
|
update: proc
|
|
253
311
|
.input(z.object({
|
|
@@ -257,14 +315,47 @@ export function createEntity(args) {
|
|
|
257
315
|
.mutation(({ input, ctx }) => updateFromClient(input.id, input.data, ctx)),
|
|
258
316
|
delete: proc
|
|
259
317
|
.input(z.object({ id: z.string() }))
|
|
260
|
-
.mutation(({ input, ctx }) => methods.deleteWhere({
|
|
318
|
+
.mutation(async ({ input, ctx }) => methods.deleteWhere({
|
|
319
|
+
id: input.id,
|
|
320
|
+
...(await scope(ctx)),
|
|
321
|
+
})),
|
|
322
|
+
// Поток изменений этой сущности для клиента (SSE). Скоуп тот же,
|
|
323
|
+
// что у list: с authField уходят только изменения строк владельца.
|
|
324
|
+
// Произвольный scope проверяется запросом за строкой (владелец
|
|
325
|
+
// событий известен, скоуп — нет); удаление проверить нечем — оно
|
|
326
|
+
// уходит всем подписчикам модели, в нём только id.
|
|
327
|
+
changes: proc.subscription(async function* ({ ctx, signal }) {
|
|
328
|
+
const owner = args.authField ? ownerId(ctx) : undefined;
|
|
329
|
+
const current = args.scope ? await args.scope(ctx) : undefined;
|
|
330
|
+
for await (const [change] of subscribeEvent("entity:changed", signal)) {
|
|
331
|
+
if (change.model !== args.model)
|
|
332
|
+
continue;
|
|
333
|
+
if (owner !== undefined && change.owner !== owner)
|
|
334
|
+
continue;
|
|
335
|
+
if (current && change.op !== "delete") {
|
|
336
|
+
const row = await methods.getOne({
|
|
337
|
+
id: change.id,
|
|
338
|
+
...current,
|
|
339
|
+
});
|
|
340
|
+
if (!row)
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
yield change;
|
|
344
|
+
}
|
|
345
|
+
}),
|
|
261
346
|
count: proc
|
|
262
347
|
.input(z
|
|
263
348
|
.object({
|
|
264
349
|
where: z.record(z.string(), z.any()).optional(),
|
|
265
350
|
})
|
|
266
351
|
.optional())
|
|
267
|
-
.mutation(({ input, ctx }) =>
|
|
352
|
+
.mutation(async ({ input, ctx }) => {
|
|
353
|
+
assertFilter(input);
|
|
354
|
+
return methods.count({
|
|
355
|
+
...(input?.where ?? {}),
|
|
356
|
+
...(await scope(ctx)),
|
|
357
|
+
});
|
|
358
|
+
}),
|
|
268
359
|
controller: proc
|
|
269
360
|
.input(z.object({
|
|
270
361
|
id: z.string(),
|
|
@@ -274,7 +365,7 @@ export function createEntity(args) {
|
|
|
274
365
|
.mutation(async ({ input, ctx }) => {
|
|
275
366
|
const row = (await chain({
|
|
276
367
|
id: input.id,
|
|
277
|
-
...scope(ctx),
|
|
368
|
+
...(await scope(ctx)),
|
|
278
369
|
}).first());
|
|
279
370
|
if (!row)
|
|
280
371
|
throw new TRPCError({ code: "NOT_FOUND" });
|
package/dist/global.d.ts
CHANGED
package/dist/helpers.js
CHANGED
|
@@ -11,7 +11,7 @@ export const ormModels = () => {
|
|
|
11
11
|
const { db } = quark;
|
|
12
12
|
const models = db?.orm?.public;
|
|
13
13
|
if (!models) {
|
|
14
|
-
throw new Error("@quark-fw/entity: quark.db.orm.public недоступен — не подключён @quark/plugin-prisma?");
|
|
14
|
+
throw new Error("@quark-fw/entity: quark.db.orm.public недоступен — не подключён @quark-fw/plugin-prisma?");
|
|
15
15
|
}
|
|
16
16
|
return models;
|
|
17
17
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
1
|
+
/// <reference path="global.d.ts" />
|
|
2
|
+
export type { EntityModels, EntityModelName, EntityRow, EntityField, EntityChange, } from "./types.js";
|
|
3
|
+
export { createEntity, sortSchema, type CreateEntityArgs, type CustomMethod, type CustomMethodFn, type CustomMethodValidated, type EntityModelHandle, type EntityProcedure, type EntityRelation, type EntityScope, type EntitySort, } from "./createEntity.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -30,4 +30,12 @@ export type EntityModelName = ModelsDeclared extends true ? keyof EntityModels &
|
|
|
30
30
|
export type EntityRow<TModel extends string> = TModel extends keyof EntityModels ? EntityModels[TModel] : Record<string, unknown>;
|
|
31
31
|
/** имя колонки модели */
|
|
32
32
|
export type EntityField<TModel extends string> = TModel extends keyof EntityModels ? keyof EntityModels[TModel] & string : string;
|
|
33
|
+
/** событие entity:changed — после create/update/delete через методы сущности */
|
|
34
|
+
export type EntityChange = {
|
|
35
|
+
model: string;
|
|
36
|
+
op: "create" | "update" | "delete";
|
|
37
|
+
id: string;
|
|
38
|
+
/** значение колонки владельца (authField), если она задана */
|
|
39
|
+
owner?: string;
|
|
40
|
+
};
|
|
33
41
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quark-fw/entity",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "Entity CRUD layer over tRPC and Prisma for the Quark framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
21
|
"dev": "tsx watch src/index.ts",
|
|
22
|
-
"build": "tsc -p tsconfig.build.json && node ../../scripts/postbuild.mjs",
|
|
22
|
+
"build": "node ../../node_modules/tsgo/bin/tsc -p tsconfig.build.json && node ../../scripts/postbuild.mjs",
|
|
23
23
|
"test": "vitest run"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|
|
@@ -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.28",
|
|
35
|
+
"@quark-fw/store": "0.1.28",
|
|
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.28",
|
|
41
41
|
"react": "^19.2.4"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|