@nixxie-cms/core 1.0.3 → 2.0.0

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.
Files changed (203) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/CHANGES-1.1.md +134 -0
  3. package/context/dist/nixxie-cms-core-context.cjs.js +4 -3
  4. package/context/dist/nixxie-cms-core-context.esm.js +3 -2
  5. package/dist/declarations/src/access.d.ts +2 -2
  6. package/dist/declarations/src/access.d.ts.map +1 -1
  7. package/dist/declarations/src/admin-ui/components/Navigation.d.ts +2 -2
  8. package/dist/declarations/src/admin-ui/components/Navigation.d.ts.map +1 -1
  9. package/dist/declarations/src/admin-ui/context.d.ts +6 -6
  10. package/dist/declarations/src/admin-ui/context.d.ts.map +1 -1
  11. package/dist/declarations/src/admin-ui/utils/Fields.d.ts +3 -3
  12. package/dist/declarations/src/admin-ui/utils/Fields.d.ts.map +1 -1
  13. package/dist/declarations/src/admin-ui/utils/filters.d.ts +5 -5
  14. package/dist/declarations/src/admin-ui/utils/filters.d.ts.map +1 -1
  15. package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts +3 -3
  16. package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts.map +1 -1
  17. package/dist/declarations/src/admin-ui/utils/utils.d.ts +2 -2
  18. package/dist/declarations/src/admin-ui/utils/utils.d.ts.map +1 -1
  19. package/dist/declarations/src/context.d.ts +1 -1
  20. package/dist/declarations/src/context.d.ts.map +1 -1
  21. package/dist/declarations/src/fields/types/bigInt/index.d.ts +3 -3
  22. package/dist/declarations/src/fields/types/bigInt/index.d.ts.map +1 -1
  23. package/dist/declarations/src/fields/types/bytes/index.d.ts +3 -3
  24. package/dist/declarations/src/fields/types/bytes/index.d.ts.map +1 -1
  25. package/dist/declarations/src/fields/types/calendarDay/index.d.ts +3 -3
  26. package/dist/declarations/src/fields/types/calendarDay/index.d.ts.map +1 -1
  27. package/dist/declarations/src/fields/types/checkbox/index.d.ts +3 -3
  28. package/dist/declarations/src/fields/types/checkbox/index.d.ts.map +1 -1
  29. package/dist/declarations/src/fields/types/decimal/index.d.ts +3 -3
  30. package/dist/declarations/src/fields/types/decimal/index.d.ts.map +1 -1
  31. package/dist/declarations/src/fields/types/file/index.d.ts +4 -4
  32. package/dist/declarations/src/fields/types/file/index.d.ts.map +1 -1
  33. package/dist/declarations/src/fields/types/float/index.d.ts +3 -3
  34. package/dist/declarations/src/fields/types/float/index.d.ts.map +1 -1
  35. package/dist/declarations/src/fields/types/image/index.d.ts +4 -4
  36. package/dist/declarations/src/fields/types/image/index.d.ts.map +1 -1
  37. package/dist/declarations/src/fields/types/integer/index.d.ts +3 -3
  38. package/dist/declarations/src/fields/types/integer/index.d.ts.map +1 -1
  39. package/dist/declarations/src/fields/types/json/index.d.ts +3 -3
  40. package/dist/declarations/src/fields/types/json/index.d.ts.map +1 -1
  41. package/dist/declarations/src/fields/types/multiselect/index.d.ts +3 -3
  42. package/dist/declarations/src/fields/types/multiselect/index.d.ts.map +1 -1
  43. package/dist/declarations/src/fields/types/multiselect/views/index.d.ts.map +1 -1
  44. package/dist/declarations/src/fields/types/password/index.d.ts +3 -3
  45. package/dist/declarations/src/fields/types/password/index.d.ts.map +1 -1
  46. package/dist/declarations/src/fields/types/relationship/index.d.ts +8 -8
  47. package/dist/declarations/src/fields/types/relationship/index.d.ts.map +1 -1
  48. package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts +3 -3
  49. package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts.map +1 -1
  50. package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts +3 -3
  51. package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts.map +1 -1
  52. package/dist/declarations/src/fields/types/relationship/views/index.d.ts +3 -3
  53. package/dist/declarations/src/fields/types/relationship/views/index.d.ts.map +1 -1
  54. package/dist/declarations/src/fields/types/relationship/views/types.d.ts +3 -3
  55. package/dist/declarations/src/fields/types/relationship/views/types.d.ts.map +1 -1
  56. package/dist/declarations/src/fields/types/select/index.d.ts +3 -3
  57. package/dist/declarations/src/fields/types/select/index.d.ts.map +1 -1
  58. package/dist/declarations/src/fields/types/text/index.d.ts +3 -3
  59. package/dist/declarations/src/fields/types/text/index.d.ts.map +1 -1
  60. package/dist/declarations/src/fields/types/timestamp/index.d.ts +3 -3
  61. package/dist/declarations/src/fields/types/timestamp/index.d.ts.map +1 -1
  62. package/dist/declarations/src/fields/types/virtual/index.d.ts +7 -7
  63. package/dist/declarations/src/fields/types/virtual/index.d.ts.map +1 -1
  64. package/dist/declarations/src/helpers.d.ts +249 -13
  65. package/dist/declarations/src/helpers.d.ts.map +1 -1
  66. package/dist/declarations/src/index.d.ts +9 -4
  67. package/dist/declarations/src/index.d.ts.map +1 -1
  68. package/dist/declarations/src/internal-unstable/admin-ui/pages/ListPage/index.d.ts.map +1 -1
  69. package/dist/declarations/src/lib/admin-meta.d.ts +11 -11
  70. package/dist/declarations/src/lib/admin-meta.d.ts.map +1 -1
  71. package/dist/declarations/src/lib/core/access-control.d.ts +18 -18
  72. package/dist/declarations/src/lib/core/access-control.d.ts.map +1 -1
  73. package/dist/declarations/src/lib/core/cascade.d.ts +47 -0
  74. package/dist/declarations/src/lib/core/cascade.d.ts.map +1 -0
  75. package/dist/declarations/src/lib/core/initialise-lists.d.ts +27 -24
  76. package/dist/declarations/src/lib/core/initialise-lists.d.ts.map +1 -1
  77. package/dist/declarations/src/lib/env.d.ts +9 -0
  78. package/dist/declarations/src/lib/env.d.ts.map +1 -0
  79. package/dist/declarations/src/lib/system.d.ts +1 -1
  80. package/dist/declarations/src/lib/system.d.ts.map +1 -1
  81. package/dist/declarations/src/list-features.d.ts +162 -0
  82. package/dist/declarations/src/list-features.d.ts.map +1 -0
  83. package/dist/declarations/src/schema.d.ts +24 -23
  84. package/dist/declarations/src/schema.d.ts.map +1 -1
  85. package/dist/declarations/src/session.d.ts +75 -0
  86. package/dist/declarations/src/session.d.ts.map +1 -1
  87. package/dist/declarations/src/types/admin-meta.d.ts +11 -11
  88. package/dist/declarations/src/types/admin-meta.d.ts.map +1 -1
  89. package/dist/declarations/src/types/config/access-control.d.ts +42 -42
  90. package/dist/declarations/src/types/config/access-control.d.ts.map +1 -1
  91. package/dist/declarations/src/types/config/fields.d.ts +19 -19
  92. package/dist/declarations/src/types/config/fields.d.ts.map +1 -1
  93. package/dist/declarations/src/types/config/hooks.d.ts +131 -131
  94. package/dist/declarations/src/types/config/hooks.d.ts.map +1 -1
  95. package/dist/declarations/src/types/config/index.d.ts +190 -8
  96. package/dist/declarations/src/types/config/index.d.ts.map +1 -1
  97. package/dist/declarations/src/types/config/lists.d.ts +146 -108
  98. package/dist/declarations/src/types/config/lists.d.ts.map +1 -1
  99. package/dist/declarations/src/types/context.d.ts +507 -47
  100. package/dist/declarations/src/types/context.d.ts.map +1 -1
  101. package/dist/declarations/src/types/next-fields.d.ts +28 -28
  102. package/dist/declarations/src/types/next-fields.d.ts.map +1 -1
  103. package/dist/declarations/src/types/type-info.d.ts +3 -3
  104. package/dist/declarations/src/types/type-info.d.ts.map +1 -1
  105. package/dist/{express-455ae20c.cjs.js → express-84d534c2.cjs.js} +6 -6
  106. package/dist/{express-7559ca2d.esm.js → express-d0a4ce99.esm.js} +6 -6
  107. package/dist/{index-15c8f81e.esm.js → index-5d8b0b4e.esm.js} +363 -183
  108. package/dist/index-6055753b.cjs.js +393 -0
  109. package/dist/{index-42045902.cjs.js → index-ac29f382.cjs.js} +363 -185
  110. package/dist/index-f1703b7b.esm.js +386 -0
  111. package/dist/nixxie-cms-core.cjs.js +1388 -30
  112. package/dist/nixxie-cms-core.esm.js +1362 -24
  113. package/dist/{non-null-graphql-add6bb3d.cjs.js → non-null-graphql-4a44c122.cjs.js} +1 -1
  114. package/dist/{non-null-graphql-a84ed64d.esm.js → non-null-graphql-8c5feaae.esm.js} +1 -1
  115. package/dist/{resolve-hooks-165a9ce2.cjs.js → resolve-hooks-10a5f84c.cjs.js} +240 -6
  116. package/dist/{resolve-hooks-6813a045.esm.js → resolve-hooks-9e676794.esm.js} +238 -7
  117. package/dist/{system-a321642d.cjs.js → system-6b37a5f8.cjs.js} +33 -7
  118. package/dist/{system-03e49e4f.esm.js → system-e591d821.esm.js} +33 -7
  119. package/fields/dist/nixxie-cms-core-fields.cjs.js +29 -576
  120. package/fields/dist/nixxie-cms-core-fields.esm.js +18 -565
  121. package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.cjs.js +4 -2
  122. package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.esm.js +4 -2
  123. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.cjs.js +1 -6
  124. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.esm.js +1 -6
  125. package/fields/types/password/dist/nixxie-cms-core-fields-types-password.cjs.js +4 -2
  126. package/fields/types/password/dist/nixxie-cms-core-fields-types-password.esm.js +4 -2
  127. package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.cjs.js +4 -3
  128. package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.esm.js +4 -3
  129. package/package.json +4 -4
  130. package/scripts/cli/dist/nixxie-cms-core-scripts-cli.cjs.js +4 -3
  131. package/scripts/cli/dist/nixxie-cms-core-scripts-cli.esm.js +4 -3
  132. package/scripts/dist/nixxie-cms-core-scripts.cjs.js +4 -3
  133. package/scripts/dist/nixxie-cms-core-scripts.esm.js +4 -3
  134. package/session/dist/nixxie-cms-core-session.cjs.js +286 -0
  135. package/session/dist/nixxie-cms-core-session.esm.js +279 -1
  136. package/src/access.ts +25 -25
  137. package/src/admin-ui/admin-meta-graphql.ts +5 -5
  138. package/src/admin-ui/components/CreateButtonLink.tsx +46 -46
  139. package/src/admin-ui/components/Navigation.tsx +3 -3
  140. package/src/admin-ui/context.tsx +6 -6
  141. package/src/admin-ui/utils/Fields.tsx +241 -241
  142. package/src/admin-ui/utils/actionData.ts +36 -36
  143. package/src/admin-ui/utils/filters.ts +148 -148
  144. package/src/admin-ui/utils/useCreateItem.ts +171 -171
  145. package/src/admin-ui/utils/utils.tsx +127 -127
  146. package/src/context.ts +1 -1
  147. package/src/fields/non-null-graphql.ts +115 -115
  148. package/src/fields/types/bigInt/index.ts +6 -6
  149. package/src/fields/types/bytes/index.ts +6 -6
  150. package/src/fields/types/calendarDay/index.ts +18 -19
  151. package/src/fields/types/checkbox/index.ts +6 -6
  152. package/src/fields/types/decimal/index.ts +6 -6
  153. package/src/fields/types/file/index.ts +8 -8
  154. package/src/fields/types/float/index.ts +6 -6
  155. package/src/fields/types/image/index.ts +8 -8
  156. package/src/fields/types/integer/index.ts +6 -6
  157. package/src/fields/types/json/index.ts +5 -5
  158. package/src/fields/types/multiselect/index.ts +7 -7
  159. package/src/fields/types/multiselect/views/index.tsx +149 -151
  160. package/src/fields/types/password/index.ts +6 -6
  161. package/src/fields/types/relationship/index.ts +13 -13
  162. package/src/fields/types/relationship/views/ComboboxMany.tsx +110 -110
  163. package/src/fields/types/relationship/views/ComboboxSingle.tsx +115 -115
  164. package/src/fields/types/relationship/views/ContextualActions.tsx +139 -139
  165. package/src/fields/types/relationship/views/index.tsx +492 -492
  166. package/src/fields/types/relationship/views/types.ts +46 -46
  167. package/src/fields/types/relationship/views/useApolloQuery.ts +185 -185
  168. package/src/fields/types/relationship/views/useFilter.tsx +109 -109
  169. package/src/fields/types/select/index.ts +6 -6
  170. package/src/fields/types/text/index.ts +6 -6
  171. package/src/fields/types/timestamp/index.ts +23 -21
  172. package/src/fields/types/virtual/index.ts +11 -11
  173. package/src/helpers.ts +773 -42
  174. package/src/index.ts +66 -24
  175. package/src/internal-unstable/admin-ui/pages/ItemPage/common.tsx +4 -4
  176. package/src/internal-unstable/admin-ui/pages/ItemPage/index.tsx +5 -5
  177. package/src/internal-unstable/admin-ui/pages/ListPage/index.tsx +8 -8
  178. package/src/lib/admin-meta.ts +369 -369
  179. package/src/lib/context/createContext.ts +6 -0
  180. package/src/lib/core/access-control.ts +434 -434
  181. package/src/lib/core/cascade.ts +236 -0
  182. package/src/lib/core/initialise-lists.ts +49 -33
  183. package/src/lib/core/mutations/index.ts +7 -0
  184. package/src/lib/core/mutations/nested-mutation-many-input-resolvers.ts +145 -145
  185. package/src/lib/core/mutations/nested-mutation-one-input-resolvers.ts +71 -71
  186. package/src/lib/core/queries/output-field.ts +178 -178
  187. package/src/lib/env.ts +50 -0
  188. package/src/lib/id-field.ts +2 -2
  189. package/src/lib/system.ts +221 -207
  190. package/src/lib/typescript-schema-printer.ts +227 -227
  191. package/src/list-features.ts +476 -0
  192. package/src/schema.ts +92 -22
  193. package/src/session.ts +225 -0
  194. package/src/types/admin-meta.ts +218 -218
  195. package/src/types/config/access-control.ts +186 -186
  196. package/src/types/config/fields.ts +96 -96
  197. package/src/types/config/hooks.ts +529 -529
  198. package/src/types/config/index.ts +206 -7
  199. package/src/types/config/lists.ts +606 -565
  200. package/src/types/context.ts +592 -55
  201. package/src/types/next-fields.ts +31 -31
  202. package/src/types/type-info.ts +38 -38
  203. package/src/types/type-tests.ts +21 -21
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var resolveHooks = require('./resolve-hooks-165a9ce2.cjs.js');
3
+ var resolveHooks = require('./resolve-hooks-10a5f84c.cjs.js');
4
4
 
5
5
  function resolveDbNullable(validation, db) {
6
6
  if ((db === null || db === void 0 ? void 0 : db.isNullable) === false) return false;
@@ -1,4 +1,4 @@
1
- import { m as merge } from './resolve-hooks-6813a045.esm.js';
1
+ import { m as merge } from './resolve-hooks-9e676794.esm.js';
2
2
 
3
3
  function resolveDbNullable(validation, db) {
4
4
  if ((db === null || db === void 0 ? void 0 : db.isNullable) === false) return false;
@@ -5,6 +5,7 @@ var nextFields = require('./next-fields-49c025ef.cjs.js');
5
5
  require('@graphql-ts/schema');
6
6
  require('@graphql-ts/extend');
7
7
  var graphql = require('graphql');
8
+ var node_async_hooks = require('node:async_hooks');
8
9
 
9
10
  const userInputError = msg => new graphql.GraphQLError(`Input error: ${msg}`, {
10
11
  extensions: {
@@ -316,6 +317,236 @@ function idFieldType(config) {
316
317
  };
317
318
  }
318
319
 
320
+ /**
321
+ * Cascade deletion: enacts a collection's `cascade` rules inside the delete pipeline.
322
+ *
323
+ * Cascaded operations go through the normal mutation APIs (`context.sudo().db`), so the
324
+ * target collections' hooks, access events and their OWN cascade rules all run — deletes
325
+ * recurse naturally. A visited-set carried through AsyncLocalStorage guards against
326
+ * cycles (A → B → A) across the recursive mutation calls, which derive fresh context
327
+ * objects and therefore can't carry the state themselves.
328
+ *
329
+ * Note on atomicity: each cascaded operation commits independently (the hook pipeline
330
+ * is not transactional). `restrict` rules are checked up front, before anything is
331
+ * deleted, so refusals are always clean; a mid-cascade failure aborts the remaining
332
+ * cascade and the root delete, but already-cascaded operations stay committed.
333
+ */
334
+ const cascadeStack = new node_async_hooks.AsyncLocalStorage();
335
+
336
+ /** Page size for fetching related records. */
337
+ const PAGE = 100;
338
+ /** Safety cap on preview tree size. */
339
+ const PREVIEW_LIMIT = 1000;
340
+ const visitKey = (listKey, id) => `${listKey}:${id}`;
341
+
342
+ /** Where-filter matching the target collection's records that point at `itemId`. */
343
+ function matchWhere(rule, itemId) {
344
+ return rule.many ? {
345
+ [rule.field]: {
346
+ some: {
347
+ id: {
348
+ equals: itemId
349
+ }
350
+ }
351
+ }
352
+ } : {
353
+ [rule.field]: {
354
+ id: {
355
+ equals: itemId
356
+ }
357
+ }
358
+ };
359
+ }
360
+ function ruleAction(rule) {
361
+ var _rule$action;
362
+ return (_rule$action = rule.action) !== null && _rule$action !== void 0 ? _rule$action : 'delete';
363
+ }
364
+
365
+ /**
366
+ * Check `restrict` rules for an item about to be deleted. Runs before any cascade work,
367
+ * so a refused delete leaves the database untouched. Records that are already part of
368
+ * the in-flight cascade (visited) don't count — they are being deleted too.
369
+ */
370
+ async function enforceCascadeRestrictions(list, context, item) {
371
+ var _list$cascade;
372
+ const rules = ((_list$cascade = list.cascade) !== null && _list$cascade !== void 0 ? _list$cascade : []).filter(rule => ruleAction(rule) === 'restrict');
373
+ if (!rules.length) return;
374
+ const visited = cascadeStack.getStore();
375
+ const sudo = context.sudo();
376
+ for (const rule of rules) {
377
+ const matches = await sudo.db[rule.collection].findMany({
378
+ where: matchWhere(rule, String(item.id)),
379
+ take: PAGE
380
+ });
381
+ const blocking = visited ? matches.filter(match => !visited.has(visitKey(rule.collection, match.id))) : matches;
382
+ if (blocking.length) {
383
+ throw new Error(`Cannot delete this ${list.listKey} item: ${blocking.length}${matches.length === PAGE ? '+' : ''} related ` + `${rule.collection} record(s) reference it through "${rule.field}" (cascade rule: restrict). ` + `Delete or reassign them first.`);
384
+ }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Enact the non-restrict cascade rules for an item that is about to be deleted.
390
+ * Called from the delete pipeline after hooks, immediately before the database delete.
391
+ */
392
+ async function runCascade(list, context, item) {
393
+ var _list$cascade2, _cascadeStack$getStor;
394
+ const rules = ((_list$cascade2 = list.cascade) !== null && _list$cascade2 !== void 0 ? _list$cascade2 : []).filter(rule => ruleAction(rule) !== 'restrict');
395
+ // Mark the root as visited even when it has no rules itself — an ancestor cascade may
396
+ // be in flight, and other branches must know this item is already being handled.
397
+ const visited = (_cascadeStack$getStor = cascadeStack.getStore()) !== null && _cascadeStack$getStor !== void 0 ? _cascadeStack$getStor : new Set();
398
+ visited.add(visitKey(list.listKey, item.id));
399
+ if (!rules.length) return;
400
+ await cascadeStack.run(visited, async () => {
401
+ const sudo = context.sudo();
402
+ const itemId = String(item.id);
403
+ for (const rule of rules) {
404
+ var _rule$softDeleteField;
405
+ const action = ruleAction(rule);
406
+ if (!sudo.db[rule.collection]) {
407
+ throw new Error(`Cascade rule on ${list.listKey} points at unknown collection "${rule.collection}"`);
408
+ }
409
+
410
+ // softDelete'd records still reference the deleted item, so the match query must
411
+ // exclude already-stamped rows or this loop would never drain.
412
+ const softDeleteField = (_rule$softDeleteField = rule.softDeleteField) !== null && _rule$softDeleteField !== void 0 ? _rule$softDeleteField : 'deletedAt';
413
+ const where = action === 'softDelete' ? {
414
+ ...matchWhere(rule, itemId),
415
+ [softDeleteField]: null
416
+ } : matchWhere(rule, itemId);
417
+ for (;;) {
418
+ const matches = await sudo.db[rule.collection].findMany({
419
+ where: where,
420
+ take: PAGE
421
+ });
422
+ const pending = matches.filter(match => !visited.has(visitKey(rule.collection, match.id)));
423
+ if (!pending.length) break;
424
+ for (const match of pending) {
425
+ const matchId = String(match.id);
426
+ if (action === 'delete') {
427
+ // Recurses into the target's own pipeline (hooks + its cascade rules).
428
+ await sudo.db[rule.collection].deleteOne({
429
+ where: {
430
+ id: matchId
431
+ }
432
+ });
433
+ } else if (action === 'setNull') {
434
+ visited.add(visitKey(rule.collection, match.id));
435
+ await sudo.db[rule.collection].updateOne({
436
+ where: {
437
+ id: matchId
438
+ },
439
+ data: {
440
+ [rule.field]: rule.many ? {
441
+ disconnect: [{
442
+ id: itemId
443
+ }]
444
+ } : {
445
+ disconnect: true
446
+ }
447
+ }
448
+ });
449
+ } else if (action === 'softDelete') {
450
+ visited.add(visitKey(rule.collection, match.id));
451
+ await sudo.db[rule.collection].updateOne({
452
+ where: {
453
+ id: matchId
454
+ },
455
+ data: {
456
+ [softDeleteField]: new Date().toISOString()
457
+ }
458
+ });
459
+ }
460
+ }
461
+ if (matches.length < PAGE) break;
462
+ }
463
+ }
464
+ });
465
+ }
466
+
467
+ // ── Dry-run preview ──
468
+
469
+ /**
470
+ * Compute — without changing anything — the full tree of records a delete would touch,
471
+ * following cascade rules recursively. Surface this in delete confirmations:
472
+ * "This will delete 1 Post, 47 Comments and disconnect 3 Drafts."
473
+ *
474
+ * @example
475
+ * const preview = await previewDelete(context, 'Post', postId)
476
+ * console.log(preview.totals) // { Comment: { delete: 47 }, Draft: { setNull: 3 } }
477
+ */
478
+ async function previewDelete(context, listKey, itemId) {
479
+ const lists = context.__internal.lists;
480
+ if (!lists[listKey]) throw new Error(`previewDelete: unknown collection "${listKey}"`);
481
+ const sudo = context.sudo();
482
+ const totals = {};
483
+ const visited = new Set();
484
+ let nodes = 0;
485
+ let truncated = false;
486
+ const count = (collection, action) => {
487
+ var _totals$collection, _slot$action;
488
+ const slot = (_totals$collection = totals[collection]) !== null && _totals$collection !== void 0 ? _totals$collection : totals[collection] = {};
489
+ slot[action] = ((_slot$action = slot[action]) !== null && _slot$action !== void 0 ? _slot$action : 0) + 1;
490
+ };
491
+ async function walk(currentKey, id, action) {
492
+ visited.add(visitKey(currentKey, id));
493
+ const list = lists[currentKey];
494
+ const node = {
495
+ collection: currentKey,
496
+ id,
497
+ action,
498
+ children: []
499
+ };
500
+ const labelField = list.ui.labelField;
501
+ try {
502
+ const item = await sudo.db[currentKey].findOne({
503
+ where: {
504
+ id
505
+ }
506
+ });
507
+ const label = item === null || item === void 0 ? void 0 : item[labelField];
508
+ if (label != null && labelField !== 'id') node.label = String(label);
509
+ } catch {
510
+ // label is cosmetic — never fail the preview over it
511
+ }
512
+
513
+ // Only deletes propagate further cascades.
514
+ if (action !== 'delete') return node;
515
+ for (const rule of (_list$cascade3 = list.cascade) !== null && _list$cascade3 !== void 0 ? _list$cascade3 : []) {
516
+ var _list$cascade3;
517
+ const childAction = ruleAction(rule);
518
+ if (!lists[rule.collection]) continue;
519
+ let skip = 0;
520
+ for (;;) {
521
+ const matches = await sudo.db[rule.collection].findMany({
522
+ where: matchWhere(rule, id),
523
+ take: PAGE,
524
+ skip
525
+ });
526
+ for (const match of matches) {
527
+ if (visited.has(visitKey(rule.collection, match.id))) continue;
528
+ if (nodes >= PREVIEW_LIMIT) {
529
+ truncated = true;
530
+ return node;
531
+ }
532
+ nodes++;
533
+ count(rule.collection, childAction);
534
+ node.children.push(await walk(rule.collection, String(match.id), childAction));
535
+ }
536
+ if (matches.length < PAGE) break;
537
+ skip += PAGE;
538
+ }
539
+ }
540
+ return node;
541
+ }
542
+ const root = await walk(listKey, itemId, 'delete');
543
+ return {
544
+ root,
545
+ totals,
546
+ truncated
547
+ };
548
+ }
549
+
319
550
  // yes, these two types have the fields but they're semantically different types
320
551
  // (even though, yes, having EnumFilter by defined as EnumNullableFilter<Enum>, would be the same type but names would show up differently in editors for example)
321
552
 
@@ -876,7 +1107,7 @@ const BigIntFilter$2 = nextFields.g.inputObject({
876
1107
  })
877
1108
  })
878
1109
  });
879
- const String$2 = {
1110
+ const String$3 = {
880
1111
  optional: StringNullableFilter$2,
881
1112
  required: StringFilter$2
882
1113
  };
@@ -907,7 +1138,7 @@ const BigInt$3 = {
907
1138
 
908
1139
  var postgresql = /*#__PURE__*/Object.freeze({
909
1140
  __proto__: null,
910
- String: String$2,
1141
+ String: String$3,
911
1142
  Boolean: Boolean$2,
912
1143
  Int: Int$2,
913
1144
  Float: Float$2,
@@ -1404,7 +1635,7 @@ const BigIntFilter$1 = nextFields.g.inputObject({
1404
1635
  })
1405
1636
  })
1406
1637
  });
1407
- const String$1 = {
1638
+ const String$2 = {
1408
1639
  optional: StringNullableFilter$1,
1409
1640
  required: StringFilter$1
1410
1641
  };
@@ -1435,7 +1666,7 @@ const BigInt$2 = {
1435
1666
 
1436
1667
  var sqlite = /*#__PURE__*/Object.freeze({
1437
1668
  __proto__: null,
1438
- String: String$1,
1669
+ String: String$2,
1439
1670
  Boolean: Boolean$1,
1440
1671
  Int: Int$1,
1441
1672
  Float: Float$1,
@@ -1944,7 +2175,7 @@ const BigIntFilter = nextFields.g.inputObject({
1944
2175
  })
1945
2176
  })
1946
2177
  });
1947
- const String = {
2178
+ const String$1 = {
1948
2179
  optional: StringNullableFilter,
1949
2180
  required: StringFilter
1950
2181
  };
@@ -1975,7 +2206,7 @@ const BigInt$1 = {
1975
2206
 
1976
2207
  var mysql = /*#__PURE__*/Object.freeze({
1977
2208
  __proto__: null,
1978
- String: String,
2209
+ String: String$1,
1979
2210
  Boolean: Boolean,
1980
2211
  Int: Int,
1981
2212
  Float: Float,
@@ -2027,6 +2258,7 @@ function expandVoidHooks(hooks) {
2027
2258
 
2028
2259
  exports.accessDeniedError = accessDeniedError;
2029
2260
  exports.accessReturnError = accessReturnError;
2261
+ exports.enforceCascadeRestrictions = enforceCascadeRestrictions;
2030
2262
  exports.expandVoidHooks = expandVoidHooks;
2031
2263
  exports.extensionError = extensionError;
2032
2264
  exports.filterAccessError = filterAccessError;
@@ -2036,8 +2268,10 @@ exports.limitsExceededError = limitsExceededError;
2036
2268
  exports.merge = merge;
2037
2269
  exports.mysql = mysql;
2038
2270
  exports.postgresql = postgresql;
2271
+ exports.previewDelete = previewDelete;
2039
2272
  exports.relationshipError = relationshipError;
2040
2273
  exports.resolverError = resolverError;
2274
+ exports.runCascade = runCascade;
2041
2275
  exports.sqlite = sqlite;
2042
2276
  exports.userInputError = userInputError;
2043
2277
  exports.validationFailureError = validationFailureError;
@@ -3,6 +3,7 @@ import { g, f as fieldType, o as orderDirectionEnum, Q as QueryMode } from './ne
3
3
  import '@graphql-ts/schema';
4
4
  import '@graphql-ts/extend';
5
5
  import { GraphQLError } from 'graphql';
6
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
7
 
7
8
  const userInputError = msg => new GraphQLError(`Input error: ${msg}`, {
8
9
  extensions: {
@@ -314,6 +315,236 @@ function idFieldType(config) {
314
315
  };
315
316
  }
316
317
 
318
+ /**
319
+ * Cascade deletion: enacts a collection's `cascade` rules inside the delete pipeline.
320
+ *
321
+ * Cascaded operations go through the normal mutation APIs (`context.sudo().db`), so the
322
+ * target collections' hooks, access events and their OWN cascade rules all run — deletes
323
+ * recurse naturally. A visited-set carried through AsyncLocalStorage guards against
324
+ * cycles (A → B → A) across the recursive mutation calls, which derive fresh context
325
+ * objects and therefore can't carry the state themselves.
326
+ *
327
+ * Note on atomicity: each cascaded operation commits independently (the hook pipeline
328
+ * is not transactional). `restrict` rules are checked up front, before anything is
329
+ * deleted, so refusals are always clean; a mid-cascade failure aborts the remaining
330
+ * cascade and the root delete, but already-cascaded operations stay committed.
331
+ */
332
+ const cascadeStack = new AsyncLocalStorage();
333
+
334
+ /** Page size for fetching related records. */
335
+ const PAGE = 100;
336
+ /** Safety cap on preview tree size. */
337
+ const PREVIEW_LIMIT = 1000;
338
+ const visitKey = (listKey, id) => `${listKey}:${id}`;
339
+
340
+ /** Where-filter matching the target collection's records that point at `itemId`. */
341
+ function matchWhere(rule, itemId) {
342
+ return rule.many ? {
343
+ [rule.field]: {
344
+ some: {
345
+ id: {
346
+ equals: itemId
347
+ }
348
+ }
349
+ }
350
+ } : {
351
+ [rule.field]: {
352
+ id: {
353
+ equals: itemId
354
+ }
355
+ }
356
+ };
357
+ }
358
+ function ruleAction(rule) {
359
+ var _rule$action;
360
+ return (_rule$action = rule.action) !== null && _rule$action !== void 0 ? _rule$action : 'delete';
361
+ }
362
+
363
+ /**
364
+ * Check `restrict` rules for an item about to be deleted. Runs before any cascade work,
365
+ * so a refused delete leaves the database untouched. Records that are already part of
366
+ * the in-flight cascade (visited) don't count — they are being deleted too.
367
+ */
368
+ async function enforceCascadeRestrictions(list, context, item) {
369
+ var _list$cascade;
370
+ const rules = ((_list$cascade = list.cascade) !== null && _list$cascade !== void 0 ? _list$cascade : []).filter(rule => ruleAction(rule) === 'restrict');
371
+ if (!rules.length) return;
372
+ const visited = cascadeStack.getStore();
373
+ const sudo = context.sudo();
374
+ for (const rule of rules) {
375
+ const matches = await sudo.db[rule.collection].findMany({
376
+ where: matchWhere(rule, String(item.id)),
377
+ take: PAGE
378
+ });
379
+ const blocking = visited ? matches.filter(match => !visited.has(visitKey(rule.collection, match.id))) : matches;
380
+ if (blocking.length) {
381
+ throw new Error(`Cannot delete this ${list.listKey} item: ${blocking.length}${matches.length === PAGE ? '+' : ''} related ` + `${rule.collection} record(s) reference it through "${rule.field}" (cascade rule: restrict). ` + `Delete or reassign them first.`);
382
+ }
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Enact the non-restrict cascade rules for an item that is about to be deleted.
388
+ * Called from the delete pipeline after hooks, immediately before the database delete.
389
+ */
390
+ async function runCascade(list, context, item) {
391
+ var _list$cascade2, _cascadeStack$getStor;
392
+ const rules = ((_list$cascade2 = list.cascade) !== null && _list$cascade2 !== void 0 ? _list$cascade2 : []).filter(rule => ruleAction(rule) !== 'restrict');
393
+ // Mark the root as visited even when it has no rules itself — an ancestor cascade may
394
+ // be in flight, and other branches must know this item is already being handled.
395
+ const visited = (_cascadeStack$getStor = cascadeStack.getStore()) !== null && _cascadeStack$getStor !== void 0 ? _cascadeStack$getStor : new Set();
396
+ visited.add(visitKey(list.listKey, item.id));
397
+ if (!rules.length) return;
398
+ await cascadeStack.run(visited, async () => {
399
+ const sudo = context.sudo();
400
+ const itemId = String(item.id);
401
+ for (const rule of rules) {
402
+ var _rule$softDeleteField;
403
+ const action = ruleAction(rule);
404
+ if (!sudo.db[rule.collection]) {
405
+ throw new Error(`Cascade rule on ${list.listKey} points at unknown collection "${rule.collection}"`);
406
+ }
407
+
408
+ // softDelete'd records still reference the deleted item, so the match query must
409
+ // exclude already-stamped rows or this loop would never drain.
410
+ const softDeleteField = (_rule$softDeleteField = rule.softDeleteField) !== null && _rule$softDeleteField !== void 0 ? _rule$softDeleteField : 'deletedAt';
411
+ const where = action === 'softDelete' ? {
412
+ ...matchWhere(rule, itemId),
413
+ [softDeleteField]: null
414
+ } : matchWhere(rule, itemId);
415
+ for (;;) {
416
+ const matches = await sudo.db[rule.collection].findMany({
417
+ where: where,
418
+ take: PAGE
419
+ });
420
+ const pending = matches.filter(match => !visited.has(visitKey(rule.collection, match.id)));
421
+ if (!pending.length) break;
422
+ for (const match of pending) {
423
+ const matchId = String(match.id);
424
+ if (action === 'delete') {
425
+ // Recurses into the target's own pipeline (hooks + its cascade rules).
426
+ await sudo.db[rule.collection].deleteOne({
427
+ where: {
428
+ id: matchId
429
+ }
430
+ });
431
+ } else if (action === 'setNull') {
432
+ visited.add(visitKey(rule.collection, match.id));
433
+ await sudo.db[rule.collection].updateOne({
434
+ where: {
435
+ id: matchId
436
+ },
437
+ data: {
438
+ [rule.field]: rule.many ? {
439
+ disconnect: [{
440
+ id: itemId
441
+ }]
442
+ } : {
443
+ disconnect: true
444
+ }
445
+ }
446
+ });
447
+ } else if (action === 'softDelete') {
448
+ visited.add(visitKey(rule.collection, match.id));
449
+ await sudo.db[rule.collection].updateOne({
450
+ where: {
451
+ id: matchId
452
+ },
453
+ data: {
454
+ [softDeleteField]: new Date().toISOString()
455
+ }
456
+ });
457
+ }
458
+ }
459
+ if (matches.length < PAGE) break;
460
+ }
461
+ }
462
+ });
463
+ }
464
+
465
+ // ── Dry-run preview ──
466
+
467
+ /**
468
+ * Compute — without changing anything — the full tree of records a delete would touch,
469
+ * following cascade rules recursively. Surface this in delete confirmations:
470
+ * "This will delete 1 Post, 47 Comments and disconnect 3 Drafts."
471
+ *
472
+ * @example
473
+ * const preview = await previewDelete(context, 'Post', postId)
474
+ * console.log(preview.totals) // { Comment: { delete: 47 }, Draft: { setNull: 3 } }
475
+ */
476
+ async function previewDelete(context, listKey, itemId) {
477
+ const lists = context.__internal.lists;
478
+ if (!lists[listKey]) throw new Error(`previewDelete: unknown collection "${listKey}"`);
479
+ const sudo = context.sudo();
480
+ const totals = {};
481
+ const visited = new Set();
482
+ let nodes = 0;
483
+ let truncated = false;
484
+ const count = (collection, action) => {
485
+ var _totals$collection, _slot$action;
486
+ const slot = (_totals$collection = totals[collection]) !== null && _totals$collection !== void 0 ? _totals$collection : totals[collection] = {};
487
+ slot[action] = ((_slot$action = slot[action]) !== null && _slot$action !== void 0 ? _slot$action : 0) + 1;
488
+ };
489
+ async function walk(currentKey, id, action) {
490
+ visited.add(visitKey(currentKey, id));
491
+ const list = lists[currentKey];
492
+ const node = {
493
+ collection: currentKey,
494
+ id,
495
+ action,
496
+ children: []
497
+ };
498
+ const labelField = list.ui.labelField;
499
+ try {
500
+ const item = await sudo.db[currentKey].findOne({
501
+ where: {
502
+ id
503
+ }
504
+ });
505
+ const label = item === null || item === void 0 ? void 0 : item[labelField];
506
+ if (label != null && labelField !== 'id') node.label = String(label);
507
+ } catch {
508
+ // label is cosmetic — never fail the preview over it
509
+ }
510
+
511
+ // Only deletes propagate further cascades.
512
+ if (action !== 'delete') return node;
513
+ for (const rule of (_list$cascade3 = list.cascade) !== null && _list$cascade3 !== void 0 ? _list$cascade3 : []) {
514
+ var _list$cascade3;
515
+ const childAction = ruleAction(rule);
516
+ if (!lists[rule.collection]) continue;
517
+ let skip = 0;
518
+ for (;;) {
519
+ const matches = await sudo.db[rule.collection].findMany({
520
+ where: matchWhere(rule, id),
521
+ take: PAGE,
522
+ skip
523
+ });
524
+ for (const match of matches) {
525
+ if (visited.has(visitKey(rule.collection, match.id))) continue;
526
+ if (nodes >= PREVIEW_LIMIT) {
527
+ truncated = true;
528
+ return node;
529
+ }
530
+ nodes++;
531
+ count(rule.collection, childAction);
532
+ node.children.push(await walk(rule.collection, String(match.id), childAction));
533
+ }
534
+ if (matches.length < PAGE) break;
535
+ skip += PAGE;
536
+ }
537
+ }
538
+ return node;
539
+ }
540
+ const root = await walk(listKey, itemId, 'delete');
541
+ return {
542
+ root,
543
+ totals,
544
+ truncated
545
+ };
546
+ }
547
+
317
548
  // yes, these two types have the fields but they're semantically different types
318
549
  // (even though, yes, having EnumFilter by defined as EnumNullableFilter<Enum>, would be the same type but names would show up differently in editors for example)
319
550
 
@@ -874,7 +1105,7 @@ const BigIntFilter$2 = g.inputObject({
874
1105
  })
875
1106
  })
876
1107
  });
877
- const String$2 = {
1108
+ const String$3 = {
878
1109
  optional: StringNullableFilter$2,
879
1110
  required: StringFilter$2
880
1111
  };
@@ -905,7 +1136,7 @@ const BigInt$3 = {
905
1136
 
906
1137
  var postgresql = /*#__PURE__*/Object.freeze({
907
1138
  __proto__: null,
908
- String: String$2,
1139
+ String: String$3,
909
1140
  Boolean: Boolean$2,
910
1141
  Int: Int$2,
911
1142
  Float: Float$2,
@@ -1402,7 +1633,7 @@ const BigIntFilter$1 = g.inputObject({
1402
1633
  })
1403
1634
  })
1404
1635
  });
1405
- const String$1 = {
1636
+ const String$2 = {
1406
1637
  optional: StringNullableFilter$1,
1407
1638
  required: StringFilter$1
1408
1639
  };
@@ -1433,7 +1664,7 @@ const BigInt$2 = {
1433
1664
 
1434
1665
  var sqlite = /*#__PURE__*/Object.freeze({
1435
1666
  __proto__: null,
1436
- String: String$1,
1667
+ String: String$2,
1437
1668
  Boolean: Boolean$1,
1438
1669
  Int: Int$1,
1439
1670
  Float: Float$1,
@@ -1942,7 +2173,7 @@ const BigIntFilter = g.inputObject({
1942
2173
  })
1943
2174
  })
1944
2175
  });
1945
- const String = {
2176
+ const String$1 = {
1946
2177
  optional: StringNullableFilter,
1947
2178
  required: StringFilter
1948
2179
  };
@@ -1973,7 +2204,7 @@ const BigInt$1 = {
1973
2204
 
1974
2205
  var mysql = /*#__PURE__*/Object.freeze({
1975
2206
  __proto__: null,
1976
- String: String,
2207
+ String: String$1,
1977
2208
  Boolean: Boolean,
1978
2209
  Int: Int,
1979
2210
  Float: Float,
@@ -2023,4 +2254,4 @@ function expandVoidHooks(hooks) {
2023
2254
  };
2024
2255
  }
2025
2256
 
2026
- export { mysql as a, accessReturnError as b, accessDeniedError as c, filterAccessError as d, extensionError as e, formatKeys as f, expandVoidHooks as g, relationshipError as h, idFieldType as i, limitsExceededError as l, merge as m, postgresql as p, resolverError as r, sqlite as s, userInputError as u, validationFailureError as v };
2257
+ export { postgresql as a, mysql as b, accessReturnError as c, accessDeniedError as d, extensionError as e, formatKeys as f, filterAccessError as g, expandVoidHooks as h, idFieldType as i, enforceCascadeRestrictions as j, resolverError as k, limitsExceededError as l, merge as m, relationshipError as n, previewDelete as p, runCascade as r, sqlite as s, userInputError as u, validationFailureError as v };