@nixxie-cms/core 1.0.3 → 1.1.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 (202) hide show
  1. package/CHANGES-1.1.md +134 -0
  2. package/context/dist/nixxie-cms-core-context.cjs.js +4 -3
  3. package/context/dist/nixxie-cms-core-context.esm.js +3 -2
  4. package/dist/declarations/src/access.d.ts +2 -2
  5. package/dist/declarations/src/access.d.ts.map +1 -1
  6. package/dist/declarations/src/admin-ui/components/Navigation.d.ts +2 -2
  7. package/dist/declarations/src/admin-ui/components/Navigation.d.ts.map +1 -1
  8. package/dist/declarations/src/admin-ui/context.d.ts +6 -6
  9. package/dist/declarations/src/admin-ui/context.d.ts.map +1 -1
  10. package/dist/declarations/src/admin-ui/utils/Fields.d.ts +3 -3
  11. package/dist/declarations/src/admin-ui/utils/Fields.d.ts.map +1 -1
  12. package/dist/declarations/src/admin-ui/utils/filters.d.ts +5 -5
  13. package/dist/declarations/src/admin-ui/utils/filters.d.ts.map +1 -1
  14. package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts +3 -3
  15. package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts.map +1 -1
  16. package/dist/declarations/src/admin-ui/utils/utils.d.ts +2 -2
  17. package/dist/declarations/src/admin-ui/utils/utils.d.ts.map +1 -1
  18. package/dist/declarations/src/context.d.ts +1 -1
  19. package/dist/declarations/src/context.d.ts.map +1 -1
  20. package/dist/declarations/src/fields/types/bigInt/index.d.ts +3 -3
  21. package/dist/declarations/src/fields/types/bigInt/index.d.ts.map +1 -1
  22. package/dist/declarations/src/fields/types/bytes/index.d.ts +3 -3
  23. package/dist/declarations/src/fields/types/bytes/index.d.ts.map +1 -1
  24. package/dist/declarations/src/fields/types/calendarDay/index.d.ts +3 -3
  25. package/dist/declarations/src/fields/types/calendarDay/index.d.ts.map +1 -1
  26. package/dist/declarations/src/fields/types/checkbox/index.d.ts +3 -3
  27. package/dist/declarations/src/fields/types/checkbox/index.d.ts.map +1 -1
  28. package/dist/declarations/src/fields/types/decimal/index.d.ts +3 -3
  29. package/dist/declarations/src/fields/types/decimal/index.d.ts.map +1 -1
  30. package/dist/declarations/src/fields/types/file/index.d.ts +4 -4
  31. package/dist/declarations/src/fields/types/file/index.d.ts.map +1 -1
  32. package/dist/declarations/src/fields/types/float/index.d.ts +3 -3
  33. package/dist/declarations/src/fields/types/float/index.d.ts.map +1 -1
  34. package/dist/declarations/src/fields/types/image/index.d.ts +4 -4
  35. package/dist/declarations/src/fields/types/image/index.d.ts.map +1 -1
  36. package/dist/declarations/src/fields/types/integer/index.d.ts +3 -3
  37. package/dist/declarations/src/fields/types/integer/index.d.ts.map +1 -1
  38. package/dist/declarations/src/fields/types/json/index.d.ts +3 -3
  39. package/dist/declarations/src/fields/types/json/index.d.ts.map +1 -1
  40. package/dist/declarations/src/fields/types/multiselect/index.d.ts +3 -3
  41. package/dist/declarations/src/fields/types/multiselect/index.d.ts.map +1 -1
  42. package/dist/declarations/src/fields/types/multiselect/views/index.d.ts.map +1 -1
  43. package/dist/declarations/src/fields/types/password/index.d.ts +3 -3
  44. package/dist/declarations/src/fields/types/password/index.d.ts.map +1 -1
  45. package/dist/declarations/src/fields/types/relationship/index.d.ts +8 -8
  46. package/dist/declarations/src/fields/types/relationship/index.d.ts.map +1 -1
  47. package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts +3 -3
  48. package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts.map +1 -1
  49. package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts +3 -3
  50. package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts.map +1 -1
  51. package/dist/declarations/src/fields/types/relationship/views/index.d.ts +3 -3
  52. package/dist/declarations/src/fields/types/relationship/views/index.d.ts.map +1 -1
  53. package/dist/declarations/src/fields/types/relationship/views/types.d.ts +3 -3
  54. package/dist/declarations/src/fields/types/relationship/views/types.d.ts.map +1 -1
  55. package/dist/declarations/src/fields/types/select/index.d.ts +3 -3
  56. package/dist/declarations/src/fields/types/select/index.d.ts.map +1 -1
  57. package/dist/declarations/src/fields/types/text/index.d.ts +3 -3
  58. package/dist/declarations/src/fields/types/text/index.d.ts.map +1 -1
  59. package/dist/declarations/src/fields/types/timestamp/index.d.ts +3 -3
  60. package/dist/declarations/src/fields/types/timestamp/index.d.ts.map +1 -1
  61. package/dist/declarations/src/fields/types/virtual/index.d.ts +7 -7
  62. package/dist/declarations/src/fields/types/virtual/index.d.ts.map +1 -1
  63. package/dist/declarations/src/helpers.d.ts +249 -13
  64. package/dist/declarations/src/helpers.d.ts.map +1 -1
  65. package/dist/declarations/src/index.d.ts +9 -4
  66. package/dist/declarations/src/index.d.ts.map +1 -1
  67. package/dist/declarations/src/internal-unstable/admin-ui/pages/ListPage/index.d.ts.map +1 -1
  68. package/dist/declarations/src/lib/admin-meta.d.ts +11 -11
  69. package/dist/declarations/src/lib/admin-meta.d.ts.map +1 -1
  70. package/dist/declarations/src/lib/core/access-control.d.ts +18 -18
  71. package/dist/declarations/src/lib/core/access-control.d.ts.map +1 -1
  72. package/dist/declarations/src/lib/core/cascade.d.ts +47 -0
  73. package/dist/declarations/src/lib/core/cascade.d.ts.map +1 -0
  74. package/dist/declarations/src/lib/core/initialise-lists.d.ts +27 -24
  75. package/dist/declarations/src/lib/core/initialise-lists.d.ts.map +1 -1
  76. package/dist/declarations/src/lib/env.d.ts +9 -0
  77. package/dist/declarations/src/lib/env.d.ts.map +1 -0
  78. package/dist/declarations/src/lib/system.d.ts +1 -1
  79. package/dist/declarations/src/lib/system.d.ts.map +1 -1
  80. package/dist/declarations/src/list-features.d.ts +162 -0
  81. package/dist/declarations/src/list-features.d.ts.map +1 -0
  82. package/dist/declarations/src/schema.d.ts +24 -23
  83. package/dist/declarations/src/schema.d.ts.map +1 -1
  84. package/dist/declarations/src/session.d.ts +75 -0
  85. package/dist/declarations/src/session.d.ts.map +1 -1
  86. package/dist/declarations/src/types/admin-meta.d.ts +11 -11
  87. package/dist/declarations/src/types/admin-meta.d.ts.map +1 -1
  88. package/dist/declarations/src/types/config/access-control.d.ts +42 -42
  89. package/dist/declarations/src/types/config/access-control.d.ts.map +1 -1
  90. package/dist/declarations/src/types/config/fields.d.ts +19 -19
  91. package/dist/declarations/src/types/config/fields.d.ts.map +1 -1
  92. package/dist/declarations/src/types/config/hooks.d.ts +131 -131
  93. package/dist/declarations/src/types/config/hooks.d.ts.map +1 -1
  94. package/dist/declarations/src/types/config/index.d.ts +171 -8
  95. package/dist/declarations/src/types/config/index.d.ts.map +1 -1
  96. package/dist/declarations/src/types/config/lists.d.ts +146 -108
  97. package/dist/declarations/src/types/config/lists.d.ts.map +1 -1
  98. package/dist/declarations/src/types/context.d.ts +349 -47
  99. package/dist/declarations/src/types/context.d.ts.map +1 -1
  100. package/dist/declarations/src/types/next-fields.d.ts +28 -28
  101. package/dist/declarations/src/types/next-fields.d.ts.map +1 -1
  102. package/dist/declarations/src/types/type-info.d.ts +3 -3
  103. package/dist/declarations/src/types/type-info.d.ts.map +1 -1
  104. package/dist/{express-7559ca2d.esm.js → express-0abbce07.esm.js} +6 -6
  105. package/dist/{express-455ae20c.cjs.js → express-7ca6f76a.cjs.js} +6 -6
  106. package/dist/{index-15c8f81e.esm.js → index-5d8b0b4e.esm.js} +363 -183
  107. package/dist/index-6055753b.cjs.js +393 -0
  108. package/dist/{index-42045902.cjs.js → index-ac29f382.cjs.js} +363 -185
  109. package/dist/index-f1703b7b.esm.js +386 -0
  110. package/dist/nixxie-cms-core.cjs.js +1387 -30
  111. package/dist/nixxie-cms-core.esm.js +1361 -24
  112. package/dist/{non-null-graphql-add6bb3d.cjs.js → non-null-graphql-4a44c122.cjs.js} +1 -1
  113. package/dist/{non-null-graphql-a84ed64d.esm.js → non-null-graphql-8c5feaae.esm.js} +1 -1
  114. package/dist/{resolve-hooks-165a9ce2.cjs.js → resolve-hooks-10a5f84c.cjs.js} +240 -6
  115. package/dist/{resolve-hooks-6813a045.esm.js → resolve-hooks-9e676794.esm.js} +238 -7
  116. package/dist/{system-03e49e4f.esm.js → system-4d2a2648.esm.js} +32 -7
  117. package/dist/{system-a321642d.cjs.js → system-69e1a285.cjs.js} +32 -7
  118. package/fields/dist/nixxie-cms-core-fields.cjs.js +29 -576
  119. package/fields/dist/nixxie-cms-core-fields.esm.js +18 -565
  120. package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.cjs.js +4 -2
  121. package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.esm.js +4 -2
  122. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.cjs.js +1 -6
  123. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.esm.js +1 -6
  124. package/fields/types/password/dist/nixxie-cms-core-fields-types-password.cjs.js +4 -2
  125. package/fields/types/password/dist/nixxie-cms-core-fields-types-password.esm.js +4 -2
  126. package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.cjs.js +4 -3
  127. package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.esm.js +4 -3
  128. package/package.json +4 -4
  129. package/scripts/cli/dist/nixxie-cms-core-scripts-cli.cjs.js +4 -3
  130. package/scripts/cli/dist/nixxie-cms-core-scripts-cli.esm.js +4 -3
  131. package/scripts/dist/nixxie-cms-core-scripts.cjs.js +4 -3
  132. package/scripts/dist/nixxie-cms-core-scripts.esm.js +4 -3
  133. package/session/dist/nixxie-cms-core-session.cjs.js +286 -0
  134. package/session/dist/nixxie-cms-core-session.esm.js +279 -1
  135. package/src/access.ts +25 -25
  136. package/src/admin-ui/admin-meta-graphql.ts +5 -5
  137. package/src/admin-ui/components/CreateButtonLink.tsx +46 -46
  138. package/src/admin-ui/components/Navigation.tsx +3 -3
  139. package/src/admin-ui/context.tsx +6 -6
  140. package/src/admin-ui/utils/Fields.tsx +241 -241
  141. package/src/admin-ui/utils/actionData.ts +36 -36
  142. package/src/admin-ui/utils/filters.ts +148 -148
  143. package/src/admin-ui/utils/useCreateItem.ts +171 -171
  144. package/src/admin-ui/utils/utils.tsx +127 -127
  145. package/src/context.ts +1 -1
  146. package/src/fields/non-null-graphql.ts +115 -115
  147. package/src/fields/types/bigInt/index.ts +6 -6
  148. package/src/fields/types/bytes/index.ts +6 -6
  149. package/src/fields/types/calendarDay/index.ts +18 -19
  150. package/src/fields/types/checkbox/index.ts +6 -6
  151. package/src/fields/types/decimal/index.ts +6 -6
  152. package/src/fields/types/file/index.ts +8 -8
  153. package/src/fields/types/float/index.ts +6 -6
  154. package/src/fields/types/image/index.ts +8 -8
  155. package/src/fields/types/integer/index.ts +6 -6
  156. package/src/fields/types/json/index.ts +5 -5
  157. package/src/fields/types/multiselect/index.ts +7 -7
  158. package/src/fields/types/multiselect/views/index.tsx +149 -151
  159. package/src/fields/types/password/index.ts +6 -6
  160. package/src/fields/types/relationship/index.ts +13 -13
  161. package/src/fields/types/relationship/views/ComboboxMany.tsx +110 -110
  162. package/src/fields/types/relationship/views/ComboboxSingle.tsx +115 -115
  163. package/src/fields/types/relationship/views/ContextualActions.tsx +139 -139
  164. package/src/fields/types/relationship/views/index.tsx +492 -492
  165. package/src/fields/types/relationship/views/types.ts +46 -46
  166. package/src/fields/types/relationship/views/useApolloQuery.ts +185 -185
  167. package/src/fields/types/relationship/views/useFilter.tsx +109 -109
  168. package/src/fields/types/select/index.ts +6 -6
  169. package/src/fields/types/text/index.ts +6 -6
  170. package/src/fields/types/timestamp/index.ts +23 -21
  171. package/src/fields/types/virtual/index.ts +11 -11
  172. package/src/helpers.ts +773 -42
  173. package/src/index.ts +66 -24
  174. package/src/internal-unstable/admin-ui/pages/ItemPage/common.tsx +4 -4
  175. package/src/internal-unstable/admin-ui/pages/ItemPage/index.tsx +5 -5
  176. package/src/internal-unstable/admin-ui/pages/ListPage/index.tsx +8 -8
  177. package/src/lib/admin-meta.ts +369 -369
  178. package/src/lib/context/createContext.ts +5 -0
  179. package/src/lib/core/access-control.ts +434 -434
  180. package/src/lib/core/cascade.ts +236 -0
  181. package/src/lib/core/initialise-lists.ts +49 -33
  182. package/src/lib/core/mutations/index.ts +7 -0
  183. package/src/lib/core/mutations/nested-mutation-many-input-resolvers.ts +145 -145
  184. package/src/lib/core/mutations/nested-mutation-one-input-resolvers.ts +71 -71
  185. package/src/lib/core/queries/output-field.ts +178 -178
  186. package/src/lib/env.ts +50 -0
  187. package/src/lib/id-field.ts +2 -2
  188. package/src/lib/system.ts +221 -207
  189. package/src/lib/typescript-schema-printer.ts +227 -227
  190. package/src/list-features.ts +476 -0
  191. package/src/schema.ts +91 -22
  192. package/src/session.ts +225 -0
  193. package/src/types/admin-meta.ts +218 -218
  194. package/src/types/config/access-control.ts +186 -186
  195. package/src/types/config/fields.ts +96 -96
  196. package/src/types/config/hooks.ts +529 -529
  197. package/src/types/config/index.ts +185 -7
  198. package/src/types/config/lists.ts +606 -565
  199. package/src/types/context.ts +426 -55
  200. package/src/types/next-fields.ts +31 -31
  201. package/src/types/type-info.ts +38 -38
  202. package/src/types/type-tests.ts +21 -21
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Cascade deletion: enacts a collection's `cascade` rules inside the delete pipeline.
3
+ *
4
+ * Cascaded operations go through the normal mutation APIs (`context.sudo().db`), so the
5
+ * target collections' hooks, access events and their OWN cascade rules all run — deletes
6
+ * recurse naturally. A visited-set carried through AsyncLocalStorage guards against
7
+ * cycles (A → B → A) across the recursive mutation calls, which derive fresh context
8
+ * objects and therefore can't carry the state themselves.
9
+ *
10
+ * Note on atomicity: each cascaded operation commits independently (the hook pipeline
11
+ * is not transactional). `restrict` rules are checked up front, before anything is
12
+ * deleted, so refusals are always clean; a mid-cascade failure aborts the remaining
13
+ * cascade and the root delete, but already-cascaded operations stay committed.
14
+ */
15
+ import { AsyncLocalStorage } from 'node:async_hooks'
16
+ import type { NixxieContext } from '../../types'
17
+ import type { CascadeAction, CascadeRule } from '../../types/config/lists'
18
+ import type { InitialisedList } from './initialise-lists'
19
+
20
+ const cascadeStack = new AsyncLocalStorage<Set<string>>()
21
+
22
+ /** Page size for fetching related records. */
23
+ const PAGE = 100
24
+ /** Safety cap on preview tree size. */
25
+ const PREVIEW_LIMIT = 1000
26
+
27
+ const visitKey = (listKey: string, id: unknown) => `${listKey}:${id}`
28
+
29
+ /** Where-filter matching the target collection's records that point at `itemId`. */
30
+ function matchWhere(rule: CascadeRule, itemId: string): Record<string, unknown> {
31
+ return rule.many
32
+ ? { [rule.field]: { some: { id: { equals: itemId } } } }
33
+ : { [rule.field]: { id: { equals: itemId } } }
34
+ }
35
+
36
+ function ruleAction(rule: CascadeRule): CascadeAction {
37
+ return rule.action ?? 'delete'
38
+ }
39
+
40
+ /**
41
+ * Check `restrict` rules for an item about to be deleted. Runs before any cascade work,
42
+ * so a refused delete leaves the database untouched. Records that are already part of
43
+ * the in-flight cascade (visited) don't count — they are being deleted too.
44
+ */
45
+ export async function enforceCascadeRestrictions(
46
+ list: InitialisedList,
47
+ context: NixxieContext,
48
+ item: { id: unknown; [key: string]: unknown }
49
+ ): Promise<void> {
50
+ const rules = (list.cascade ?? []).filter(rule => ruleAction(rule) === 'restrict')
51
+ if (!rules.length) return
52
+
53
+ const visited = cascadeStack.getStore()
54
+ const sudo = context.sudo()
55
+ for (const rule of rules) {
56
+ const matches = await sudo.db[rule.collection].findMany({
57
+ where: matchWhere(rule, String(item.id)) as any,
58
+ take: PAGE,
59
+ })
60
+ const blocking = visited
61
+ ? matches.filter(match => !visited.has(visitKey(rule.collection, match.id)))
62
+ : matches
63
+ if (blocking.length) {
64
+ throw new Error(
65
+ `Cannot delete this ${list.listKey} item: ${blocking.length}${matches.length === PAGE ? '+' : ''} related ` +
66
+ `${rule.collection} record(s) reference it through "${rule.field}" (cascade rule: restrict). ` +
67
+ `Delete or reassign them first.`
68
+ )
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Enact the non-restrict cascade rules for an item that is about to be deleted.
75
+ * Called from the delete pipeline after hooks, immediately before the database delete.
76
+ */
77
+ export async function runCascade(
78
+ list: InitialisedList,
79
+ context: NixxieContext,
80
+ item: { id: unknown; [key: string]: unknown }
81
+ ): Promise<void> {
82
+ const rules = (list.cascade ?? []).filter(rule => ruleAction(rule) !== 'restrict')
83
+ // Mark the root as visited even when it has no rules itself — an ancestor cascade may
84
+ // be in flight, and other branches must know this item is already being handled.
85
+ const visited = cascadeStack.getStore() ?? new Set<string>()
86
+ visited.add(visitKey(list.listKey, item.id))
87
+ if (!rules.length) return
88
+
89
+ await cascadeStack.run(visited, async () => {
90
+ const sudo = context.sudo()
91
+ const itemId = String(item.id)
92
+
93
+ for (const rule of rules) {
94
+ const action = ruleAction(rule)
95
+ if (!sudo.db[rule.collection]) {
96
+ throw new Error(
97
+ `Cascade rule on ${list.listKey} points at unknown collection "${rule.collection}"`
98
+ )
99
+ }
100
+
101
+ // softDelete'd records still reference the deleted item, so the match query must
102
+ // exclude already-stamped rows or this loop would never drain.
103
+ const softDeleteField = rule.softDeleteField ?? 'deletedAt'
104
+ const where =
105
+ action === 'softDelete'
106
+ ? { ...matchWhere(rule, itemId), [softDeleteField]: null }
107
+ : matchWhere(rule, itemId)
108
+
109
+ for (;;) {
110
+ const matches = await sudo.db[rule.collection].findMany({ where: where as any, take: PAGE })
111
+ const pending = matches.filter(match => !visited.has(visitKey(rule.collection, match.id)))
112
+ if (!pending.length) break
113
+
114
+ for (const match of pending) {
115
+ const matchId = String(match.id)
116
+ if (action === 'delete') {
117
+ // Recurses into the target's own pipeline (hooks + its cascade rules).
118
+ await sudo.db[rule.collection].deleteOne({ where: { id: matchId } })
119
+ } else if (action === 'setNull') {
120
+ visited.add(visitKey(rule.collection, match.id))
121
+ await sudo.db[rule.collection].updateOne({
122
+ where: { id: matchId },
123
+ data: {
124
+ [rule.field]: rule.many ? { disconnect: [{ id: itemId }] } : { disconnect: true },
125
+ } as any,
126
+ })
127
+ } else if (action === 'softDelete') {
128
+ visited.add(visitKey(rule.collection, match.id))
129
+ await sudo.db[rule.collection].updateOne({
130
+ where: { id: matchId },
131
+ data: { [softDeleteField]: new Date().toISOString() } as any,
132
+ })
133
+ }
134
+ }
135
+
136
+ if (matches.length < PAGE) break
137
+ }
138
+ }
139
+ })
140
+ }
141
+
142
+ // ── Dry-run preview ──
143
+
144
+ export type CascadePreviewNode = {
145
+ collection: string
146
+ id: string
147
+ /** The item's label-field value, when readable. */
148
+ label?: string
149
+ /** What would happen to this record. */
150
+ action: CascadeAction
151
+ children: CascadePreviewNode[]
152
+ }
153
+
154
+ export type CascadePreview = {
155
+ root: CascadePreviewNode
156
+ /** Per collection, how many records each action would touch (excluding the root). */
157
+ totals: Record<string, Partial<Record<CascadeAction, number>>>
158
+ /** True when the walk stopped early at the safety cap — totals are then lower bounds. */
159
+ truncated: boolean
160
+ }
161
+
162
+ /**
163
+ * Compute — without changing anything — the full tree of records a delete would touch,
164
+ * following cascade rules recursively. Surface this in delete confirmations:
165
+ * "This will delete 1 Post, 47 Comments and disconnect 3 Drafts."
166
+ *
167
+ * @example
168
+ * const preview = await previewDelete(context, 'Post', postId)
169
+ * console.log(preview.totals) // { Comment: { delete: 47 }, Draft: { setNull: 3 } }
170
+ */
171
+ export async function previewDelete(
172
+ context: NixxieContext,
173
+ listKey: string,
174
+ itemId: string
175
+ ): Promise<CascadePreview> {
176
+ const lists = context.__internal.lists
177
+ if (!lists[listKey]) throw new Error(`previewDelete: unknown collection "${listKey}"`)
178
+
179
+ const sudo = context.sudo()
180
+ const totals: CascadePreview['totals'] = {}
181
+ const visited = new Set<string>()
182
+ let nodes = 0
183
+ let truncated = false
184
+
185
+ const count = (collection: string, action: CascadeAction) => {
186
+ const slot = (totals[collection] ??= {})
187
+ slot[action] = (slot[action] ?? 0) + 1
188
+ }
189
+
190
+ async function walk(currentKey: string, id: string, action: CascadeAction): Promise<CascadePreviewNode> {
191
+ visited.add(visitKey(currentKey, id))
192
+ const list = lists[currentKey]
193
+ const node: CascadePreviewNode = { collection: currentKey, id, action, children: [] }
194
+
195
+ const labelField = list.ui.labelField
196
+ try {
197
+ const item: any = await sudo.db[currentKey].findOne({ where: { id } })
198
+ const label = item?.[labelField]
199
+ if (label != null && labelField !== 'id') node.label = String(label)
200
+ } catch {
201
+ // label is cosmetic — never fail the preview over it
202
+ }
203
+
204
+ // Only deletes propagate further cascades.
205
+ if (action !== 'delete') return node
206
+
207
+ for (const rule of list.cascade ?? []) {
208
+ const childAction = ruleAction(rule)
209
+ if (!lists[rule.collection]) continue
210
+ let skip = 0
211
+ for (;;) {
212
+ const matches = await sudo.db[rule.collection].findMany({
213
+ where: matchWhere(rule, id) as any,
214
+ take: PAGE,
215
+ skip,
216
+ })
217
+ for (const match of matches) {
218
+ if (visited.has(visitKey(rule.collection, match.id))) continue
219
+ if (nodes >= PREVIEW_LIMIT) {
220
+ truncated = true
221
+ return node
222
+ }
223
+ nodes++
224
+ count(rule.collection, childAction)
225
+ node.children.push(await walk(rule.collection, String(match.id), childAction))
226
+ }
227
+ if (matches.length < PAGE) break
228
+ skip += PAGE
229
+ }
230
+ }
231
+ return node
232
+ }
233
+
234
+ const root = await walk(listKey, itemId, 'delete')
235
+ return { root, totals, truncated }
236
+ }
@@ -1,5 +1,6 @@
1
1
  import type { CacheHint } from '@apollo/cache-control-types'
2
- import { GInputObjectType, type GArg, type GInputType } from '@graphql-ts/schema'
2
+ import type { GInputObjectType } from '@graphql-ts/schema'
3
+ import { type GArg, type GInputType } from '@graphql-ts/schema'
3
4
  import { GNonNull } from '@graphql-ts/schema'
4
5
  import {
5
6
  GraphQLList,
@@ -17,21 +18,26 @@ import type {
17
18
  ActionMeta,
18
19
  BaseFieldTypeInfo,
19
20
  BaseItem,
20
- BaseListTypeInfo,
21
+ BaseCollectionTypeInfo,
21
22
  CacheHintArgs,
22
23
  FieldTypeFunc,
23
24
  FindManyArgs,
24
- GraphQLTypesForList,
25
+ GraphQLTypesForCollection,
25
26
  NixxieConfig,
26
- ListGraphQLTypes,
27
- ListHooks,
27
+ CollectionGraphQLTypes,
28
+ CollectionHooks,
28
29
  MaybeFieldFunction,
29
30
  NextFieldType,
30
31
  } from '../../types'
31
32
  import { QueryMode } from '../../types'
32
- import type { FieldHooks, ResolvedFieldHooks, ResolvedListHooks } from '../../types/config/hooks'
33
+ import type {
34
+ FieldHooks,
35
+ ResolvedFieldHooks,
36
+ ResolvedCollectionHooks,
37
+ } from '../../types/config/hooks'
33
38
  import type {
34
39
  BaseActions,
40
+ CascadeRule,
35
41
  MaybeBooleanItemFunctionWithFilter,
36
42
  MaybeBooleanSessionFunctionWithFilter,
37
43
  MaybeItemActionFunctionWithFilter,
@@ -44,7 +50,7 @@ import { type GraphQLNames, __getNames } from '../../types/utils'
44
50
  import {
45
51
  type ResolvedActionAccessControl,
46
52
  type ResolvedFieldAccessControl,
47
- type ResolvedListAccessControl,
53
+ type ResolvedCollectionAccessControl,
48
54
  parseFieldAccessControl,
49
55
  parseListAccessControl,
50
56
  } from './access-control'
@@ -57,7 +63,7 @@ export type InitialisedAction = {
57
63
  actionKey: string
58
64
 
59
65
  access: ResolvedActionAccessControl
60
- resolve: BaseActions<BaseListTypeInfo>[keyof BaseActions<BaseListTypeInfo>]['resolve']
66
+ resolve: BaseActions<BaseCollectionTypeInfo>[keyof BaseActions<BaseCollectionTypeInfo>]['resolve']
61
67
  graphql: {
62
68
  arguments: { name: string; type: string; source: { itemField: string } | null }[]
63
69
  names: {
@@ -79,14 +85,14 @@ export type InitialisedAction = {
79
85
  actionMode: MaybeItemActionFunctionWithFilter<
80
86
  'enabled' | 'disabled' | 'hidden',
81
87
  'disabled' | 'hidden',
82
- BaseListTypeInfo
88
+ BaseCollectionTypeInfo
83
89
  >
84
90
  }
85
91
  listView: {
86
92
  actionMode: MaybeSessionFunctionWithFilter<
87
93
  'enabled' | 'disabled' | 'hidden',
88
94
  'disabled' | 'hidden',
89
- BaseListTypeInfo
95
+ BaseCollectionTypeInfo
90
96
  >
91
97
  }
92
98
  argSources: Record<string, { itemField: string }>
@@ -116,14 +122,14 @@ export type InitialisedField = {
116
122
 
117
123
  access: ResolvedFieldAccessControl
118
124
  dbField: ResolvedDBField
119
- hooks: ResolvedFieldHooks<BaseListTypeInfo, BaseFieldTypeInfo>
125
+ hooks: ResolvedFieldHooks<BaseCollectionTypeInfo, BaseFieldTypeInfo>
120
126
  graphql: {
121
127
  isEnabled: {
122
128
  read: boolean
123
129
  create: boolean
124
130
  update: boolean
125
- filter: MaybeFieldFunction<BaseListTypeInfo>
126
- orderBy: MaybeFieldFunction<BaseListTypeInfo>
131
+ filter: MaybeFieldFunction<BaseCollectionTypeInfo>
132
+ orderBy: MaybeFieldFunction<BaseCollectionTypeInfo>
127
133
  }
128
134
  isNonNull: {
129
135
  read: boolean
@@ -137,21 +143,25 @@ export type InitialisedField = {
137
143
  description: string
138
144
  views: string | null
139
145
  createView: {
140
- fieldMode: MaybeSessionFunctionWithFilter<'edit' | 'hidden', 'hidden', BaseListTypeInfo>
141
- isRequired: MaybeBooleanSessionFunctionWithFilter<BaseListTypeInfo>
146
+ fieldMode: MaybeSessionFunctionWithFilter<'edit' | 'hidden', 'hidden', BaseCollectionTypeInfo>
147
+ isRequired: MaybeBooleanSessionFunctionWithFilter<BaseCollectionTypeInfo>
142
148
  }
143
149
  itemView: {
144
150
  fieldMode: MaybeItemFieldFunctionWithFilter<
145
151
  'read' | 'edit' | 'hidden',
146
152
  'read' | 'hidden',
147
- BaseListTypeInfo,
153
+ BaseCollectionTypeInfo,
154
+ BaseFieldTypeInfo
155
+ >
156
+ fieldPosition: MaybeItemFieldFunction<
157
+ 'form' | 'sidebar',
158
+ BaseCollectionTypeInfo,
148
159
  BaseFieldTypeInfo
149
160
  >
150
- fieldPosition: MaybeItemFieldFunction<'form' | 'sidebar', BaseListTypeInfo, BaseFieldTypeInfo>
151
- isRequired: MaybeBooleanItemFunctionWithFilter<BaseListTypeInfo, BaseFieldTypeInfo>
161
+ isRequired: MaybeBooleanItemFunctionWithFilter<BaseCollectionTypeInfo, BaseFieldTypeInfo>
152
162
  }
153
163
  listView: {
154
- fieldMode: MaybeSessionFunction<'read' | 'hidden', BaseListTypeInfo>
164
+ fieldMode: MaybeSessionFunction<'read' | 'hidden', BaseCollectionTypeInfo>
155
165
  }
156
166
  }
157
167
  } & Pick<
@@ -168,20 +178,23 @@ export type InitialisedField = {
168
178
  export type InitialisedList = {
169
179
  listKey: string
170
180
 
171
- access: ResolvedListAccessControl
181
+ access: ResolvedCollectionAccessControl
182
+
183
+ /** Referential-integrity rules enacted on delete (see CascadeRule). */
184
+ cascade: CascadeRule[]
172
185
 
173
186
  fields: Record<string, InitialisedField>
174
187
  actions: InitialisedAction[]
175
- groups: GroupInfo<BaseListTypeInfo>[]
188
+ groups: GroupInfo<BaseCollectionTypeInfo>[]
176
189
 
177
- hooks: ResolvedListHooks<BaseListTypeInfo>
190
+ hooks: ResolvedCollectionHooks<BaseCollectionTypeInfo>
178
191
 
179
192
  /** This will include the opposites to one-sided relationships */
180
193
  resolvedDbFields: Record<string, ResolvedDBField>
181
194
  lists: Record<string, InitialisedList>
182
195
 
183
196
  graphql: {
184
- types: GraphQLTypesForList
197
+ types: GraphQLTypesForCollection
185
198
  names: GraphQLNames
186
199
  isEnabled: {
187
200
  type: boolean
@@ -189,8 +202,8 @@ export type InitialisedList = {
189
202
  create: boolean
190
203
  update: boolean
191
204
  delete: boolean
192
- filter: MaybeFieldFunction<BaseListTypeInfo>
193
- orderBy: MaybeFieldFunction<BaseListTypeInfo>
205
+ filter: MaybeFieldFunction<BaseCollectionTypeInfo>
206
+ orderBy: MaybeFieldFunction<BaseCollectionTypeInfo>
194
207
  }
195
208
  }
196
209
 
@@ -210,7 +223,7 @@ export type InitialisedList = {
210
223
  }
211
224
 
212
225
  isSingleton: boolean
213
- cacheHint: ((args: CacheHintArgs<BaseListTypeInfo>) => CacheHint) | undefined
226
+ cacheHint: ((args: CacheHintArgs<BaseCollectionTypeInfo>) => CacheHint) | undefined
214
227
  }
215
228
 
216
229
  function throwIfNotAFilter(x: unknown, listKey: string, fieldKey: string) {
@@ -312,7 +325,9 @@ function defaultListHooksResolveInput({ resolvedData }: { resolvedData: any }) {
312
325
  return resolvedData
313
326
  }
314
327
 
315
- function parseListHooks(hooks: ListHooks<BaseListTypeInfo>): ResolvedListHooks<BaseListTypeInfo> {
328
+ function parseListHooks(
329
+ hooks: CollectionHooks<BaseCollectionTypeInfo>
330
+ ): ResolvedCollectionHooks<BaseCollectionTypeInfo> {
316
331
  return {
317
332
  resolveInput: {
318
333
  create:
@@ -341,8 +356,8 @@ function defaultFieldHooksResolveInput({
341
356
  }
342
357
 
343
358
  function parseFieldHooks(
344
- hooks: FieldHooks<BaseListTypeInfo, BaseFieldTypeInfo>
345
- ): ResolvedFieldHooks<BaseListTypeInfo, BaseFieldTypeInfo> {
359
+ hooks: FieldHooks<BaseCollectionTypeInfo, BaseFieldTypeInfo>
360
+ ): ResolvedFieldHooks<BaseCollectionTypeInfo, BaseFieldTypeInfo> {
346
361
  return {
347
362
  resolveInput: {
348
363
  create:
@@ -379,7 +394,7 @@ function getListsWithInitialisedFields(
379
394
  ])
380
395
  )
381
396
 
382
- const listGraphqlTypes: Record<string, ListGraphQLTypes<BaseListTypeInfo>> = {}
397
+ const listGraphqlTypes: Record<string, CollectionGraphQLTypes<BaseCollectionTypeInfo>> = {}
383
398
 
384
399
  for (const listConfig of Object.values(listsConfig)) {
385
400
  const { listKey } = listConfig
@@ -466,7 +481,7 @@ function getListsWithInitialisedFields(
466
481
  },
467
482
  })
468
483
 
469
- const where: GraphQLTypesForList['where'] = g.inputObject({
484
+ const where: GraphQLTypesForCollection['where'] = g.inputObject({
470
485
  name: names.whereInputName,
471
486
  fields: () => {
472
487
  const { fields } = listsRef[listKey]
@@ -667,10 +682,10 @@ function getListsWithInitialisedFields(
667
682
  const { listKey } = listConfig
668
683
  const intermediateList = intermediateLists[listKey]
669
684
  const resultFields: Record<string, InitialisedField> = {}
670
- const groups: GroupInfo<BaseListTypeInfo>[] = []
685
+ const groups: GroupInfo<BaseCollectionTypeInfo>[] = []
671
686
  const fieldKeys = Object.keys(listConfig.fields)
672
687
 
673
- const fieldKeysToGroup: Record<string, GroupInfo<BaseListTypeInfo>> = {}
688
+ const fieldKeysToGroup: Record<string, GroupInfo<BaseCollectionTypeInfo>> = {}
674
689
  for (const [idx, [fieldKey, fieldFunc]] of Object.entries(listConfig.fields).entries()) {
675
690
  if (fieldKey.startsWith('__group')) {
676
691
  const group__ = fieldFunc as any
@@ -798,6 +813,7 @@ function getListsWithInitialisedFields(
798
813
  const names = __getNames(listKey, listConfig)
799
814
  result[listKey] = {
800
815
  access: parseListAccessControl(listConfig.access),
816
+ cascade: listConfig.cascade ?? [],
801
817
 
802
818
  fields: resultFields,
803
819
  groups,
@@ -20,6 +20,7 @@ import {
20
20
  relationshipError,
21
21
  resolverError,
22
22
  } from '../graphql-errors'
23
+ import { enforceCascadeRestrictions, runCascade } from '../cascade'
23
24
  import { runSideEffectOnlyHook, validate } from '../hooks'
24
25
  import type { InitialisedAction, InitialisedList } from '../initialise-lists'
25
26
  import { mapUniqueWhereToWhere, traverse } from '../queries/resolvers'
@@ -218,9 +219,15 @@ async function deleteSingle__(
218
219
  // hooks
219
220
  await validate({ list, hookArgs })
220
221
 
222
+ // cascade rules: check `restrict` rules before any side effects
223
+ await enforceCascadeRestrictions(list, context, item)
224
+
221
225
  // before operation
222
226
  await runSideEffectOnlyHook(list, 'beforeOperation', hookArgs)
223
227
 
228
+ // cascade rules: delete / disconnect / soft-delete related records
229
+ await runCascade(list, context, item)
230
+
224
231
  // operation
225
232
  const result = await context.prisma[list.listKey].delete({ where: { id: item.id } })
226
233
  span.setAttribute('nixxie.result.id', result?.id ?? '')