@smartive/graphql-magic 19.3.0 → 19.3.1-next.2

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.
@@ -10,10 +10,12 @@ import { anyDateToLuxon } from '../utils';
10
10
  import { resolve } from './resolver';
11
11
  import { AliasGenerator, fetchDisplay, getTechnicalDisplay } from './utils';
12
12
 
13
- export const mutationResolver = async (_parent: any, args: any, partialCtx: Context, info: GraphQLResolveInfo) => {
14
- return await partialCtx.knex.transaction(async (knex) => {
13
+ const withTransaction = async <T extends MutationContext>(ctx: T, fn: (ctx: T) => Promise<any>) =>
14
+ await ctx.knex.transaction(async (knex) => fn({ ...ctx, knex }));
15
+
16
+ export const mutationResolver = async (_parent: any, args: any, partialCtx: Context, info: GraphQLResolveInfo) =>
17
+ withTransaction({ ...partialCtx, info, aliases: new AliasGenerator() }, async (ctx) => {
15
18
  const [, mutation, modelName] = it(info.fieldName.match(/^(create|update|delete|restore)(.+)$/));
16
- const ctx = { ...partialCtx, knex, info, aliases: new AliasGenerator() };
17
19
  switch (mutation) {
18
20
  case 'create': {
19
21
  const id = await createEntity(modelName, args.data, ctx, 'mutation');
@@ -46,85 +48,86 @@ export const mutationResolver = async (_parent: any, args: any, partialCtx: Cont
46
48
  }
47
49
  }
48
50
  });
49
- };
50
51
 
51
52
  export const createEntity = async (
52
53
  modelName: string,
53
54
  input: Entity,
54
55
  ctx: MutationContext,
55
56
  trigger: Trigger = 'direct-call',
56
- ) => {
57
- const model = ctx.models.getModel(modelName, 'entity');
58
- const normalizedInput = { ...input };
59
- if (!normalizedInput.id) {
60
- normalizedInput.id = uuid();
61
- }
62
- const id = normalizedInput.id as string;
63
- if (!normalizedInput.createdAt) {
64
- normalizedInput.createdAt = ctx.now;
65
- }
66
- if (!normalizedInput.createdById) {
67
- normalizedInput.createdById = ctx.user?.id;
68
- }
69
- if (model.parent) {
70
- normalizedInput.type = model.name;
71
- }
72
- sanitize(ctx, model, normalizedInput);
73
-
74
- await checkCanWrite(ctx, model, normalizedInput, 'CREATE');
75
- await ctx.handleUploads?.(normalizedInput);
76
-
77
- await ctx.mutationHook?.({
78
- model,
79
- action: 'create',
80
- trigger,
81
- when: 'before',
82
- data: { prev: {}, input, normalizedInput, next: normalizedInput },
83
- ctx,
84
- });
57
+ ) =>
58
+ withTransaction(ctx, async (ctx) => {
59
+ const model = ctx.models.getModel(modelName, 'entity');
60
+ const normalizedInput = { ...input };
61
+ if (!normalizedInput.id) {
62
+ normalizedInput.id = uuid();
63
+ }
64
+ const id = normalizedInput.id as string;
65
+ if (!normalizedInput.createdAt) {
66
+ normalizedInput.createdAt = ctx.now;
67
+ }
68
+ if (!normalizedInput.createdById) {
69
+ normalizedInput.createdById = ctx.user?.id;
70
+ }
71
+ if (model.parent) {
72
+ normalizedInput.type = model.name;
73
+ }
74
+ sanitize(ctx, model, normalizedInput);
85
75
 
86
- if (model.parent) {
87
- const rootInput = {};
88
- const childInput = { id };
89
- for (const field of model.fields) {
90
- const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
91
- if (columnName in normalizedInput) {
92
- if (field.inherited) {
93
- rootInput[columnName] = normalizedInput[columnName];
94
- } else {
95
- childInput[columnName] = normalizedInput[columnName];
76
+ await checkCanWrite(ctx, model, normalizedInput, 'CREATE');
77
+ await ctx.handleUploads?.(normalizedInput);
78
+
79
+ await ctx.mutationHook?.({
80
+ model,
81
+ action: 'create',
82
+ trigger,
83
+ when: 'before',
84
+ data: { prev: {}, input, normalizedInput, next: normalizedInput },
85
+ ctx,
86
+ });
87
+
88
+ if (model.parent) {
89
+ const rootInput = {};
90
+ const childInput = { id };
91
+ for (const field of model.fields) {
92
+ const columnName = field.kind === 'relation' ? `${field.name}Id` : field.name;
93
+ if (columnName in normalizedInput) {
94
+ if (field.inherited) {
95
+ rootInput[columnName] = normalizedInput[columnName];
96
+ } else {
97
+ childInput[columnName] = normalizedInput[columnName];
98
+ }
96
99
  }
97
100
  }
101
+ await ctx.knex(model.parent).insert(rootInput);
102
+ await ctx.knex(model.name).insert(childInput);
103
+ } else {
104
+ await ctx.knex(model.name).insert(normalizedInput);
98
105
  }
99
- await ctx.knex(model.parent).insert(rootInput);
100
- await ctx.knex(model.name).insert(childInput);
101
- } else {
102
- await ctx.knex(model.name).insert(normalizedInput);
103
- }
104
- await createRevision(model, normalizedInput, ctx);
105
- await ctx.mutationHook?.({
106
- model,
107
- action: 'create',
108
- trigger,
109
- when: 'after',
110
- data: { prev: {}, input, normalizedInput, next: normalizedInput },
111
- ctx,
112
- });
106
+ await createRevision(model, normalizedInput, ctx);
107
+ await ctx.mutationHook?.({
108
+ model,
109
+ action: 'create',
110
+ trigger,
111
+ when: 'after',
112
+ data: { prev: {}, input, normalizedInput, next: normalizedInput },
113
+ ctx,
114
+ });
113
115
 
114
- return normalizedInput.id as string;
115
- };
116
+ return normalizedInput.id as string;
117
+ });
116
118
 
117
119
  export const updateEntities = async (
118
120
  modelName: string,
119
121
  where: Record<string, unknown>,
120
122
  updateFields: Entity,
121
123
  ctx: MutationContext,
122
- ) => {
123
- const entities = await ctx.knex(modelName).where(where).select('id');
124
- for (const entity of entities) {
125
- await updateEntity(modelName, entity.id, updateFields, ctx);
126
- }
127
- };
124
+ ) =>
125
+ withTransaction(ctx, async (ctx) => {
126
+ const entities = await ctx.knex(modelName).where(where).select('id');
127
+ for (const entity of entities) {
128
+ await updateEntity(modelName, entity.id, updateFields, ctx);
129
+ }
130
+ });
128
131
 
129
132
  export const updateEntity = async (
130
133
  modelName: string,
@@ -132,55 +135,57 @@ export const updateEntity = async (
132
135
  input: Entity,
133
136
  ctx: MutationContext,
134
137
  trigger: Trigger = 'direct-call',
135
- ) => {
136
- const model = ctx.models.getModel(modelName, 'entity');
137
- const normalizedInput = { ...input };
138
+ ) =>
139
+ withTransaction(ctx, async (ctx) => {
140
+ const model = ctx.models.getModel(modelName, 'entity');
141
+ const normalizedInput = { ...input };
138
142
 
139
- sanitize(ctx, model, normalizedInput);
143
+ sanitize(ctx, model, normalizedInput);
140
144
 
141
- const currentEntity = await getEntityToMutate(ctx, model, { id }, 'UPDATE');
145
+ const currentEntity = await getEntityToMutate(ctx, model, { id }, 'UPDATE');
142
146
 
143
- // Remove data that wouldn't mutate given that it's irrelevant for permissions
144
- for (const key of Object.keys(normalizedInput)) {
145
- if (normalizedInput[key] === currentEntity[key]) {
146
- delete normalizedInput[key];
147
+ // Remove data that wouldn't mutate given that it's irrelevant for permissions
148
+ for (const key of Object.keys(normalizedInput)) {
149
+ if (normalizedInput[key] === currentEntity[key]) {
150
+ delete normalizedInput[key];
151
+ }
147
152
  }
148
- }
149
-
150
- if (Object.keys(normalizedInput).length > 0) {
151
- await checkCanWrite(ctx, model, normalizedInput, 'UPDATE');
152
- await ctx.handleUploads?.(normalizedInput);
153
153
 
154
- await ctx.mutationHook?.({
155
- model,
156
- action: 'update',
157
- trigger,
158
- when: 'before',
159
- data: { prev: currentEntity, input, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
160
- ctx,
161
- });
162
- await doUpdate(model, currentEntity, normalizedInput, ctx);
163
- await ctx.mutationHook?.({
164
- model,
165
- action: 'update',
166
- trigger,
167
- when: 'after',
168
- data: { prev: currentEntity, input, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
169
- ctx,
170
- });
171
- }
172
- };
154
+ if (Object.keys(normalizedInput).length > 0) {
155
+ await checkCanWrite(ctx, model, normalizedInput, 'UPDATE');
156
+ await ctx.handleUploads?.(normalizedInput);
157
+
158
+ await ctx.mutationHook?.({
159
+ model,
160
+ action: 'update',
161
+ trigger,
162
+ when: 'before',
163
+ data: { prev: currentEntity, input, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
164
+ ctx,
165
+ });
166
+ await doUpdate(model, currentEntity, normalizedInput, ctx);
167
+ await ctx.mutationHook?.({
168
+ model,
169
+ action: 'update',
170
+ trigger,
171
+ when: 'after',
172
+ data: { prev: currentEntity, input, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
173
+ ctx,
174
+ });
175
+ }
176
+ });
173
177
 
174
178
  type Callbacks = (() => Promise<void>)[];
175
179
 
176
- export const deleteEntities = async (modelName: string, where: Record<string, unknown>, ctx: MutationContext) => {
177
- const entities = await ctx.knex(modelName).where(where).select('id');
178
- for (const entity of entities) {
179
- await deleteEntity(modelName, entity.id, ctx, {
180
- trigger: 'direct-call',
181
- });
182
- }
183
- };
180
+ export const deleteEntities = async (modelName: string, where: Record<string, unknown>, ctx: MutationContext) =>
181
+ withTransaction(ctx, async (ctx) => {
182
+ const entities = await ctx.knex(modelName).where(where).select('id');
183
+ for (const entity of entities) {
184
+ await deleteEntity(modelName, entity.id, ctx, {
185
+ trigger: 'direct-call',
186
+ });
187
+ }
188
+ });
184
189
 
185
190
  export const deleteEntity = async (
186
191
  modelName: string,
@@ -193,338 +198,343 @@ export const deleteEntity = async (
193
198
  dryRun?: boolean;
194
199
  trigger?: Trigger;
195
200
  } = {},
196
- ) => {
197
- const model = ctx.models.getModel(modelName, 'entity');
198
- const rootModel = model.rootModel;
199
- const entity = await getEntityToMutate(ctx, rootModel, { id }, 'DELETE');
200
-
201
- if (entity.deleted) {
202
- throw new ForbiddenError(`${getTechnicalDisplay(model, entity)} is already deleted.`);
203
- }
201
+ ) =>
202
+ withTransaction(ctx, async (ctx) => {
203
+ const model = ctx.models.getModel(modelName, 'entity');
204
+ const rootModel = model.rootModel;
205
+ const entity = await getEntityToMutate(ctx, rootModel, { id }, 'DELETE');
206
+
207
+ if (entity.deleted) {
208
+ throw new ForbiddenError(`${getTechnicalDisplay(model, entity)} is already deleted.`);
209
+ }
204
210
 
205
- const toDelete: Record<string, Record<string, string>> = {};
206
- const toUnlink: Record<
207
- string,
208
- Record<
211
+ const toDelete: Record<string, Record<string, string>> = {};
212
+ const toUnlink: Record<
209
213
  string,
210
- {
211
- display: string;
212
- fields: string[];
213
- }
214
- >
215
- > = {};
216
- const restricted: Record<
217
- string,
218
- Record<
214
+ Record<
215
+ string,
216
+ {
217
+ display: string;
218
+ fields: string[];
219
+ }
220
+ >
221
+ > = {};
222
+ const restricted: Record<
219
223
  string,
220
- {
221
- display: string;
222
- fields: string[];
223
- }
224
- >
225
- > = {};
224
+ Record<
225
+ string,
226
+ {
227
+ display: string;
228
+ fields: string[];
229
+ }
230
+ >
231
+ > = {};
226
232
 
227
- const beforeHooks: Callbacks = [];
228
- const mutations: Callbacks = [];
229
- const afterHooks: Callbacks = [];
233
+ const beforeHooks: Callbacks = [];
234
+ const mutations: Callbacks = [];
235
+ const afterHooks: Callbacks = [];
230
236
 
231
- const mutationHook = ctx.mutationHook;
232
- const deleteCascade = async (currentModel: EntityModel, currentEntity: Entity, currentTrigger: Trigger) => {
233
- if (!(currentModel.name in toDelete)) {
234
- toDelete[currentModel.name] = {};
235
- }
236
- if ((currentEntity.id as string) in toDelete[currentModel.name]) {
237
- return;
238
- }
239
- toDelete[currentModel.name][currentEntity.id as string] = await fetchDisplay(ctx.knex, currentModel, currentEntity);
240
-
241
- if (!dryRun) {
242
- const normalizedInput = {
243
- deleted: true,
244
- deletedAt: ctx.now,
245
- deletedById: ctx.user?.id,
246
- deleteRootType: rootModel.name,
247
- deleteRootId: entity.id,
248
- };
249
- if (mutationHook) {
250
- beforeHooks.push(async () => {
251
- await mutationHook({
252
- model: currentModel,
253
- action: 'delete',
254
- trigger: currentTrigger,
255
- when: 'before',
256
- data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
257
- ctx,
258
- });
259
- });
237
+ const mutationHook = ctx.mutationHook;
238
+ const deleteCascade = async (currentModel: EntityModel, currentEntity: Entity, currentTrigger: Trigger) => {
239
+ if (!(currentModel.name in toDelete)) {
240
+ toDelete[currentModel.name] = {};
260
241
  }
261
- mutations.push(async () => {
262
- await doUpdate(currentModel, currentEntity, normalizedInput, ctx);
263
- });
264
- if (mutationHook) {
265
- afterHooks.push(async () => {
266
- await mutationHook({
267
- model: currentModel,
268
- action: 'delete',
269
- trigger: currentTrigger,
270
- when: 'after',
271
- data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
272
- ctx,
242
+ if ((currentEntity.id as string) in toDelete[currentModel.name]) {
243
+ return;
244
+ }
245
+ toDelete[currentModel.name][currentEntity.id as string] = await fetchDisplay(ctx.knex, currentModel, currentEntity);
246
+
247
+ if (!dryRun) {
248
+ const normalizedInput = {
249
+ deleted: true,
250
+ deletedAt: ctx.now,
251
+ deletedById: ctx.user?.id,
252
+ deleteRootType: rootModel.name,
253
+ deleteRootId: entity.id,
254
+ };
255
+ if (mutationHook) {
256
+ beforeHooks.push(async () => {
257
+ await mutationHook({
258
+ model: currentModel,
259
+ action: 'delete',
260
+ trigger: currentTrigger,
261
+ when: 'before',
262
+ data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
263
+ ctx,
264
+ });
273
265
  });
266
+ }
267
+ mutations.push(async () => {
268
+ await doUpdate(currentModel, currentEntity, normalizedInput, ctx);
274
269
  });
270
+ if (mutationHook) {
271
+ afterHooks.push(async () => {
272
+ await mutationHook({
273
+ model: currentModel,
274
+ action: 'delete',
275
+ trigger: currentTrigger,
276
+ when: 'after',
277
+ data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
278
+ ctx,
279
+ });
280
+ });
281
+ }
275
282
  }
276
- }
277
283
 
278
- for (const {
279
- targetModel: descendantModel,
280
- field: { name, foreignKey, onDelete },
281
- } of currentModel.reverseRelations.filter((reverseRelation) => !reverseRelation.field.inherited)) {
282
- const query = ctx.knex(descendantModel.name).where({ [foreignKey]: currentEntity.id, deleted: false });
283
- const descendants = await query;
284
- if (descendants.length) {
285
- switch (onDelete) {
286
- case 'set-null':
287
- for (const descendant of descendants) {
288
- if (dryRun) {
289
- if (!toUnlink[descendantModel.name]) {
290
- toUnlink[descendantModel.name] = {};
291
- }
292
- if (!toUnlink[descendantModel.name][descendant.id]) {
293
- toUnlink[descendantModel.name][descendant.id] = {
294
- display: await fetchDisplay(ctx.knex, descendantModel, descendant),
295
- fields: [],
296
- };
297
- }
298
- toUnlink[descendantModel.name][descendant.id].fields.push(name);
299
- } else {
300
- const normalizedInput = { [`${name}Id`]: null };
301
- if (mutationHook) {
302
- beforeHooks.push(async () => {
303
- await mutationHook({
304
- model: descendantModel,
305
- action: 'update',
306
- trigger: 'set-null',
307
- when: 'before',
308
- data: { prev: descendant, input: {}, normalizedInput, next: { ...descendant, ...normalizedInput } },
309
- ctx,
284
+ for (const {
285
+ targetModel: descendantModel,
286
+ field: { name, foreignKey, onDelete },
287
+ } of currentModel.reverseRelations.filter((reverseRelation) => !reverseRelation.field.inherited)) {
288
+ const query = ctx
289
+ .knex(descendantModel.name)
290
+ .where({ [foreignKey]: currentEntity.id, deleted: false })
291
+ .orderBy('createdAt', 'asc')
292
+ .orderBy('id', 'asc');
293
+ const descendants = await query;
294
+ if (descendants.length) {
295
+ switch (onDelete) {
296
+ case 'set-null':
297
+ for (const descendant of descendants) {
298
+ if (dryRun) {
299
+ if (!toUnlink[descendantModel.name]) {
300
+ toUnlink[descendantModel.name] = {};
301
+ }
302
+ if (!toUnlink[descendantModel.name][descendant.id]) {
303
+ toUnlink[descendantModel.name][descendant.id] = {
304
+ display: await fetchDisplay(ctx.knex, descendantModel, descendant),
305
+ fields: [],
306
+ };
307
+ }
308
+ toUnlink[descendantModel.name][descendant.id].fields.push(name);
309
+ } else {
310
+ const normalizedInput = { [`${name}Id`]: null };
311
+ if (mutationHook) {
312
+ beforeHooks.push(async () => {
313
+ await mutationHook({
314
+ model: descendantModel,
315
+ action: 'update',
316
+ trigger: 'set-null',
317
+ when: 'before',
318
+ data: { prev: descendant, input: {}, normalizedInput, next: { ...descendant, ...normalizedInput } },
319
+ ctx,
320
+ });
310
321
  });
322
+ }
323
+ mutations.push(async () => {
324
+ await doUpdate(descendantModel, descendant, normalizedInput, ctx);
311
325
  });
312
- }
313
- mutations.push(async () => {
314
- await doUpdate(descendantModel, descendant, normalizedInput, ctx);
315
- });
316
- if (mutationHook) {
317
- afterHooks.push(async () => {
318
- await mutationHook({
319
- model: descendantModel,
320
- action: 'update',
321
- trigger: 'set-null',
322
- when: 'after',
323
- data: { prev: descendant, input: {}, normalizedInput, next: { ...descendant, ...normalizedInput } },
324
- ctx,
326
+ if (mutationHook) {
327
+ afterHooks.push(async () => {
328
+ await mutationHook({
329
+ model: descendantModel,
330
+ action: 'update',
331
+ trigger: 'set-null',
332
+ when: 'after',
333
+ data: { prev: descendant, input: {}, normalizedInput, next: { ...descendant, ...normalizedInput } },
334
+ ctx,
335
+ });
325
336
  });
326
- });
337
+ }
327
338
  }
328
339
  }
329
- }
330
- break;
331
- case 'restrict':
332
- if (dryRun) {
333
- if (!restricted[descendantModel.name]) {
334
- restricted[descendantModel.name] = {};
335
- }
336
- for (const descendant of descendants) {
337
- if (!restricted[descendantModel.name][descendant.id]) {
338
- restricted[descendantModel.name][descendant.id] = {
339
- display: await fetchDisplay(ctx.knex, descendantModel, descendant),
340
- fields: [name],
341
- };
340
+ break;
341
+ case 'restrict':
342
+ if (dryRun) {
343
+ if (!restricted[descendantModel.name]) {
344
+ restricted[descendantModel.name] = {};
345
+ }
346
+ for (const descendant of descendants) {
347
+ if (!restricted[descendantModel.name][descendant.id]) {
348
+ restricted[descendantModel.name][descendant.id] = {
349
+ display: await fetchDisplay(ctx.knex, descendantModel, descendant),
350
+ fields: [name],
351
+ };
352
+ }
353
+ restricted[descendantModel.name][descendant.id].fields.push(name);
342
354
  }
343
- restricted[descendantModel.name][descendant.id].fields.push(name);
355
+ } else {
356
+ throw new ForbiddenError(
357
+ `${getTechnicalDisplay(model, entity)} cannot be deleted because it has ${getTechnicalDisplay(descendantModel, descendants[0])}${descendants.length > 1 ? ` (among others)` : ''}.`,
358
+ );
344
359
  }
345
- } else {
346
- throw new ForbiddenError(
347
- `${getTechnicalDisplay(model, entity)} cannot be deleted because it has ${getTechnicalDisplay(descendantModel, descendants[0])}${descendants.length > 1 ? ` (among others)` : ''}.`,
348
- );
349
- }
350
- break;
351
- case 'cascade':
352
- default: {
353
- if (!descendantModel.deletable) {
354
- throw new ForbiddenError(
355
- `${getTechnicalDisplay(model, entity)} depends on ${getTechnicalDisplay(descendantModel, descendants[0])}${descendants.length > 1 ? ` (among others)` : ''} which cannot be deleted.`,
356
- );
357
- }
358
- applyPermissions(ctx, descendantModel.name, descendantModel.name, query, 'DELETE');
359
- const deletableDescendants = await query;
360
- const notDeletableDescendants = descendants.filter(
361
- (descendant) => !deletableDescendants.some((d) => d.id === descendant.id),
362
- );
363
- if (notDeletableDescendants.length) {
364
- throw new ForbiddenError(
365
- `${getTechnicalDisplay(model, entity)} depends on ${descendantModel.labelPlural} which you have no permissions to delete.`,
360
+ break;
361
+ case 'cascade':
362
+ default: {
363
+ if (!descendantModel.deletable) {
364
+ throw new ForbiddenError(
365
+ `${getTechnicalDisplay(model, entity)} depends on ${getTechnicalDisplay(descendantModel, descendants[0])}${descendants.length > 1 ? ` (among others)` : ''} which cannot be deleted.`,
366
+ );
367
+ }
368
+ applyPermissions(ctx, descendantModel.name, descendantModel.name, query, 'DELETE');
369
+ const deletableDescendants = await query;
370
+ const notDeletableDescendants = descendants.filter(
371
+ (descendant) => !deletableDescendants.some((d) => d.id === descendant.id),
366
372
  );
373
+ if (notDeletableDescendants.length) {
374
+ throw new ForbiddenError(
375
+ `${getTechnicalDisplay(model, entity)} depends on ${descendantModel.labelPlural} which you have no permissions to delete.`,
376
+ );
377
+ }
378
+ for (const descendant of descendants) {
379
+ await deleteCascade(descendantModel, descendant, 'cascade');
380
+ }
381
+ break;
367
382
  }
368
- for (const descendant of descendants) {
369
- await deleteCascade(descendantModel, descendant, 'cascade');
370
- }
371
- break;
372
383
  }
373
384
  }
374
385
  }
375
- }
376
- };
386
+ };
377
387
 
378
- await deleteCascade(rootModel, entity, trigger);
388
+ await deleteCascade(rootModel, entity, trigger);
379
389
 
380
- for (const callback of [...beforeHooks, ...mutations, ...afterHooks]) {
381
- await callback();
382
- }
390
+ for (const callback of [...beforeHooks, ...mutations, ...afterHooks]) {
391
+ await callback();
392
+ }
383
393
 
384
- if (dryRun) {
385
- throw new GraphQLError(`Delete dry run:`, {
386
- code: 'DELETE_DRY_RUN',
387
- toDelete,
388
- toUnlink,
389
- restricted,
390
- });
391
- }
392
- };
394
+ if (dryRun) {
395
+ throw new GraphQLError(`Delete dry run:`, {
396
+ code: 'DELETE_DRY_RUN',
397
+ toDelete,
398
+ toUnlink,
399
+ restricted,
400
+ });
401
+ }
402
+ });
393
403
 
394
- export const restoreEntity = async (
395
- modelName: string,
396
- id: string,
397
- ctx: MutationContext,
398
- trigger: Trigger = 'direct-call',
399
- ) => {
400
- const model = ctx.models.getModel(modelName, 'entity');
401
- const rootModel = model.rootModel;
404
+ export const restoreEntity = async (modelName: string, id: string, ctx: MutationContext, trigger: Trigger = 'direct-call') =>
405
+ withTransaction(ctx, async (ctx) => {
406
+ const model = ctx.models.getModel(modelName, 'entity');
407
+ const rootModel = model.rootModel;
402
408
 
403
- const entity = await getEntityToMutate(ctx, rootModel, { id }, 'RESTORE');
409
+ const entity = await getEntityToMutate(ctx, rootModel, { id }, 'RESTORE');
404
410
 
405
- if (!entity.deleted) {
406
- throw new ForbiddenError(`${getTechnicalDisplay(model, entity)} is not deleted.`);
407
- }
411
+ if (!entity.deleted) {
412
+ throw new ForbiddenError(`${getTechnicalDisplay(model, entity)} is not deleted.`);
413
+ }
408
414
 
409
- if (entity.deleteRootId) {
410
- if (!(entity.deleteRootType === rootModel.name && entity.deleteRootId === entity.id)) {
411
- throw new ForbiddenError(
412
- `Can't restore ${getTechnicalDisplay(model, entity)} directly. To restore it, restore ${entity.deleteRootType} ${entity.deleteRootId}.`,
413
- );
415
+ if (entity.deleteRootId) {
416
+ if (!(entity.deleteRootType === rootModel.name && entity.deleteRootId === entity.id)) {
417
+ throw new ForbiddenError(
418
+ `Can't restore ${getTechnicalDisplay(model, entity)} directly. To restore it, restore ${entity.deleteRootType} ${entity.deleteRootId}.`,
419
+ );
420
+ }
414
421
  }
415
- }
416
422
 
417
- const toRestore: Record<string, Set<string>> = {};
423
+ const toRestore: Record<string, Set<string>> = {};
418
424
 
419
- const beforeHooks: Callbacks = [];
420
- const mutations: Callbacks = [];
421
- const afterHooks: Callbacks = [];
425
+ const beforeHooks: Callbacks = [];
426
+ const mutations: Callbacks = [];
427
+ const afterHooks: Callbacks = [];
422
428
 
423
- const restoreCascade = async (currentModel: EntityModel, currentEntity: Entity, currentTrigger: Trigger) => {
424
- if (entity.deleteRootId || currentEntity.deleteRootId) {
425
- if (!(currentEntity.deleteRootType === model.name && currentEntity.deleteRootId === entity.id)) {
429
+ const restoreCascade = async (currentModel: EntityModel, currentEntity: Entity, currentTrigger: Trigger) => {
430
+ if (entity.deleteRootId || currentEntity.deleteRootId) {
431
+ if (!(currentEntity.deleteRootType === model.name && currentEntity.deleteRootId === entity.id)) {
432
+ return;
433
+ }
434
+
435
+ // Legacy heuristic
436
+ } else if (
437
+ !anyDateToLuxon(currentEntity.deletedAt, ctx.timeZone)!.equals(anyDateToLuxon(entity.deletedAt, ctx.timeZone)!)
438
+ ) {
426
439
  return;
427
440
  }
428
441
 
429
- // Legacy heuristic
430
- } else if (
431
- !anyDateToLuxon(currentEntity.deletedAt, ctx.timeZone)!.equals(anyDateToLuxon(entity.deletedAt, ctx.timeZone)!)
432
- ) {
433
- return;
434
- }
435
-
436
- if (!(currentModel.name in toRestore)) {
437
- toRestore[currentModel.name] = new Set();
438
- }
439
- toRestore[currentModel.name].add(currentEntity.id as string);
440
-
441
- const normalizedInput: Entity = {
442
- deleted: false,
443
- deletedAt: null,
444
- deletedById: null,
445
- deleteRootType: null,
446
- deleteRootId: null,
447
- };
448
- if (ctx.mutationHook) {
449
- beforeHooks.push(async () => {
450
- await ctx.mutationHook!({
451
- model: currentModel,
452
- action: 'restore',
453
- trigger: currentTrigger,
454
- when: 'before',
455
- data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
456
- ctx,
442
+ if (!(currentModel.name in toRestore)) {
443
+ toRestore[currentModel.name] = new Set();
444
+ }
445
+ toRestore[currentModel.name].add(currentEntity.id as string);
446
+
447
+ const normalizedInput: Entity = {
448
+ deleted: false,
449
+ deletedAt: null,
450
+ deletedById: null,
451
+ deleteRootType: null,
452
+ deleteRootId: null,
453
+ };
454
+ if (ctx.mutationHook) {
455
+ beforeHooks.push(async () => {
456
+ await ctx.mutationHook!({
457
+ model: currentModel,
458
+ action: 'restore',
459
+ trigger: currentTrigger,
460
+ when: 'before',
461
+ data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
462
+ ctx,
463
+ });
457
464
  });
458
- });
459
- }
460
- mutations.push(async () => {
461
- for (const relation of currentModel.relations) {
462
- const parentId = currentEntity[relation.field.foreignKey] as string | undefined;
463
- if (!parentId) {
464
- continue;
465
+ }
466
+ mutations.push(async () => {
467
+ for (const relation of currentModel.relations) {
468
+ const parentId = currentEntity[relation.field.foreignKey] as string | undefined;
469
+ if (!parentId) {
470
+ continue;
471
+ }
472
+ if (toRestore[relation.targetModel.name]?.has(parentId)) {
473
+ continue;
474
+ }
475
+ const parent = await ctx.knex(relation.targetModel.name).where({ id: parentId }).first();
476
+ if (parent?.deleted) {
477
+ throw new ForbiddenError(
478
+ `Can't restore ${getTechnicalDisplay(model, entity)} because it depends on deleted ${relation.targetModel.name} ${parentId}.`,
479
+ );
480
+ }
465
481
  }
466
- if (toRestore[relation.targetModel.name]?.has(parentId)) {
467
- continue;
482
+
483
+ await doUpdate(currentModel, currentEntity, normalizedInput, ctx);
484
+ });
485
+ if (ctx.mutationHook) {
486
+ afterHooks.push(async () => {
487
+ await ctx.mutationHook!({
488
+ model: currentModel,
489
+ action: 'restore',
490
+ trigger: currentTrigger,
491
+ when: 'after',
492
+ data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
493
+ ctx,
494
+ });
495
+ });
496
+ }
497
+
498
+ for (const {
499
+ targetModel: descendantModel,
500
+ field: { foreignKey },
501
+ } of currentModel.reverseRelations
502
+ .filter((reverseRelation) => !reverseRelation.field.inherited)
503
+ .filter(({ targetModel: { deletable } }) => deletable)) {
504
+ const query = ctx
505
+ .knex(descendantModel.name)
506
+ .where({ [foreignKey]: currentEntity.id, deleted: true })
507
+ .orderBy('createdAt', 'asc')
508
+ .orderBy('id', 'asc');
509
+ if (currentEntity.deleteRootId) {
510
+ query.where({ deleteRootType: currentEntity.deleteRootType, deleteRootId: currentEntity.deleteRootId });
511
+ } else {
512
+ // Legacy heuristic
513
+ query.where({ deletedAt: currentEntity.deletedAt });
468
514
  }
469
- const parent = await ctx.knex(relation.targetModel.name).where({ id: parentId }).first();
470
- if (parent?.deleted) {
515
+ const descendantsToRestore = await query;
516
+ applyPermissions(ctx, descendantModel.name, descendantModel.name, query, 'RESTORE');
517
+ const restorableDescendants = await query;
518
+ const notRestorableDescendants = descendantsToRestore.filter(
519
+ (descendant) => !restorableDescendants.some((d) => d.id === descendant.id),
520
+ );
521
+ if (notRestorableDescendants.length) {
471
522
  throw new ForbiddenError(
472
- `Can't restore ${getTechnicalDisplay(model, entity)} because it depends on deleted ${relation.targetModel.name} ${parentId}.`,
523
+ `${getTechnicalDisplay(currentModel, currentEntity)} depends on ${descendantModel.labelPlural} which you have no permissions to restore.`,
473
524
  );
474
525
  }
526
+ for (const descendant of descendantsToRestore) {
527
+ await restoreCascade(descendantModel, descendant, 'cascade');
528
+ }
475
529
  }
530
+ };
476
531
 
477
- await doUpdate(currentModel, currentEntity, normalizedInput, ctx);
478
- });
479
- if (ctx.mutationHook) {
480
- afterHooks.push(async () => {
481
- await ctx.mutationHook!({
482
- model: currentModel,
483
- action: 'restore',
484
- trigger: currentTrigger,
485
- when: 'after',
486
- data: { prev: currentEntity, input: {}, normalizedInput, next: { ...currentEntity, ...normalizedInput } },
487
- ctx,
488
- });
489
- });
490
- }
532
+ await restoreCascade(rootModel, entity, trigger);
491
533
 
492
- for (const {
493
- targetModel: descendantModel,
494
- field: { foreignKey },
495
- } of currentModel.reverseRelations
496
- .filter((reverseRelation) => !reverseRelation.field.inherited)
497
- .filter(({ targetModel: { deletable } }) => deletable)) {
498
- const query = ctx.knex(descendantModel.name).where({ [foreignKey]: currentEntity.id, deleted: true });
499
- if (currentEntity.deleteRootId) {
500
- query.where({ deleteRootType: currentEntity.deleteRootType, deleteRootId: currentEntity.deleteRootId });
501
- } else {
502
- // Legacy heuristic
503
- query.where({ deletedAt: currentEntity.deletedAt });
504
- }
505
- const descendantsToRestore = await query;
506
- applyPermissions(ctx, descendantModel.name, descendantModel.name, query, 'RESTORE');
507
- const restorableDescendants = await query;
508
- const notRestorableDescendants = descendantsToRestore.filter(
509
- (descendant) => !restorableDescendants.some((d) => d.id === descendant.id),
510
- );
511
- if (notRestorableDescendants.length) {
512
- throw new ForbiddenError(
513
- `${getTechnicalDisplay(currentModel, currentEntity)} depends on ${descendantModel.labelPlural} which you have no permissions to restore.`,
514
- );
515
- }
516
- for (const descendant of descendantsToRestore) {
517
- await restoreCascade(descendantModel, descendant, 'cascade');
518
- }
534
+ for (const callback of [...beforeHooks, ...mutations, ...afterHooks]) {
535
+ await callback();
519
536
  }
520
- };
521
-
522
- await restoreCascade(rootModel, entity, trigger);
523
-
524
- for (const callback of [...beforeHooks, ...mutations, ...afterHooks]) {
525
- await callback();
526
- }
527
- };
537
+ });
528
538
 
529
539
  export const createRevision = async (model: EntityModel, data: Entity, ctx: MutationContext) => {
530
540
  if (model.updatable) {