@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 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
+ }
@@ -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";
@@ -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: EntityField<TParent>;
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/server").TRPCDefaultErrorShape;
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;
@@ -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-скоуп владельца: {} если authField не задан
157
- const scope = (ctx) => args.authField ? { [args.authField]: ownerId(ctx) } : {};
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
- [relation.authField]: ownerId(ctx),
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 data = stripProtected(input, ["id"]);
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, ...scope(ctx) };
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.input(listSchema.optional()).mutation(({ input, ctx }) => methods.list({
239
- ...(input ?? {}),
240
- where: { ...(input?.where ?? {}), ...scope(ctx) },
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 data = stripProtected(input.data);
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, ...scope(ctx) });
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({ id: input.id, ...scope(ctx) })),
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 }) => methods.count({ ...(input?.where ?? {}), ...scope(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
@@ -1,3 +1,11 @@
1
1
  import "@quark-fw/core/src/global.js";
2
+ import type { EntityChange } from "./types.js";
2
3
 
3
4
  export {};
5
+
6
+ declare global {
7
+ interface Events {
8
+ /** строка сущности создана/изменена/удалена через её методы */
9
+ "entity:changed": [EntityChange];
10
+ }
11
+ }
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
- export type { EntityModels, EntityModelName, EntityRow, EntityField, } from "./types.js";
2
- export { createEntity, sortSchema, type CreateEntityArgs, type CustomMethod, type CustomMethodFn, type CustomMethodValidated, type EntityModelHandle, type EntityProcedure, type EntityRelation, type EntitySort, } from "./createEntity.js";
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.26",
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.26",
35
- "@quark-fw/store": "0.1.26",
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.26",
40
+ "@quark-fw/core": "0.1.28",
41
41
  "react": "^19.2.4"
42
42
  },
43
43
  "devDependencies": {