@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.
- package/CHANGES-1.1.md +134 -0
- package/context/dist/nixxie-cms-core-context.cjs.js +4 -3
- package/context/dist/nixxie-cms-core-context.esm.js +3 -2
- package/dist/declarations/src/access.d.ts +2 -2
- package/dist/declarations/src/access.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/components/Navigation.d.ts +2 -2
- package/dist/declarations/src/admin-ui/components/Navigation.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/context.d.ts +6 -6
- package/dist/declarations/src/admin-ui/context.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/utils/Fields.d.ts +3 -3
- package/dist/declarations/src/admin-ui/utils/Fields.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/utils/filters.d.ts +5 -5
- package/dist/declarations/src/admin-ui/utils/filters.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts +3 -3
- package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/utils/utils.d.ts +2 -2
- package/dist/declarations/src/admin-ui/utils/utils.d.ts.map +1 -1
- package/dist/declarations/src/context.d.ts +1 -1
- package/dist/declarations/src/context.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/bigInt/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/bigInt/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/bytes/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/bytes/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/calendarDay/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/calendarDay/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/checkbox/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/checkbox/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/decimal/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/decimal/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/file/index.d.ts +4 -4
- package/dist/declarations/src/fields/types/file/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/float/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/float/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/image/index.d.ts +4 -4
- package/dist/declarations/src/fields/types/image/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/integer/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/integer/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/json/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/json/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/multiselect/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/multiselect/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/multiselect/views/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/password/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/password/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/relationship/index.d.ts +8 -8
- package/dist/declarations/src/fields/types/relationship/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts +3 -3
- package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts +3 -3
- package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/relationship/views/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/relationship/views/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/relationship/views/types.d.ts +3 -3
- package/dist/declarations/src/fields/types/relationship/views/types.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/select/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/select/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/text/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/text/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/timestamp/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/timestamp/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/virtual/index.d.ts +7 -7
- package/dist/declarations/src/fields/types/virtual/index.d.ts.map +1 -1
- package/dist/declarations/src/helpers.d.ts +249 -13
- package/dist/declarations/src/helpers.d.ts.map +1 -1
- package/dist/declarations/src/index.d.ts +9 -4
- package/dist/declarations/src/index.d.ts.map +1 -1
- package/dist/declarations/src/internal-unstable/admin-ui/pages/ListPage/index.d.ts.map +1 -1
- package/dist/declarations/src/lib/admin-meta.d.ts +11 -11
- package/dist/declarations/src/lib/admin-meta.d.ts.map +1 -1
- package/dist/declarations/src/lib/core/access-control.d.ts +18 -18
- package/dist/declarations/src/lib/core/access-control.d.ts.map +1 -1
- package/dist/declarations/src/lib/core/cascade.d.ts +47 -0
- package/dist/declarations/src/lib/core/cascade.d.ts.map +1 -0
- package/dist/declarations/src/lib/core/initialise-lists.d.ts +27 -24
- package/dist/declarations/src/lib/core/initialise-lists.d.ts.map +1 -1
- package/dist/declarations/src/lib/env.d.ts +9 -0
- package/dist/declarations/src/lib/env.d.ts.map +1 -0
- package/dist/declarations/src/lib/system.d.ts +1 -1
- package/dist/declarations/src/lib/system.d.ts.map +1 -1
- package/dist/declarations/src/list-features.d.ts +162 -0
- package/dist/declarations/src/list-features.d.ts.map +1 -0
- package/dist/declarations/src/schema.d.ts +24 -23
- package/dist/declarations/src/schema.d.ts.map +1 -1
- package/dist/declarations/src/session.d.ts +75 -0
- package/dist/declarations/src/session.d.ts.map +1 -1
- package/dist/declarations/src/types/admin-meta.d.ts +11 -11
- package/dist/declarations/src/types/admin-meta.d.ts.map +1 -1
- package/dist/declarations/src/types/config/access-control.d.ts +42 -42
- package/dist/declarations/src/types/config/access-control.d.ts.map +1 -1
- package/dist/declarations/src/types/config/fields.d.ts +19 -19
- package/dist/declarations/src/types/config/fields.d.ts.map +1 -1
- package/dist/declarations/src/types/config/hooks.d.ts +131 -131
- package/dist/declarations/src/types/config/hooks.d.ts.map +1 -1
- package/dist/declarations/src/types/config/index.d.ts +171 -8
- package/dist/declarations/src/types/config/index.d.ts.map +1 -1
- package/dist/declarations/src/types/config/lists.d.ts +146 -108
- package/dist/declarations/src/types/config/lists.d.ts.map +1 -1
- package/dist/declarations/src/types/context.d.ts +349 -47
- package/dist/declarations/src/types/context.d.ts.map +1 -1
- package/dist/declarations/src/types/next-fields.d.ts +28 -28
- package/dist/declarations/src/types/next-fields.d.ts.map +1 -1
- package/dist/declarations/src/types/type-info.d.ts +3 -3
- package/dist/declarations/src/types/type-info.d.ts.map +1 -1
- package/dist/{express-7559ca2d.esm.js → express-0abbce07.esm.js} +6 -6
- package/dist/{express-455ae20c.cjs.js → express-7ca6f76a.cjs.js} +6 -6
- package/dist/{index-15c8f81e.esm.js → index-5d8b0b4e.esm.js} +363 -183
- package/dist/index-6055753b.cjs.js +393 -0
- package/dist/{index-42045902.cjs.js → index-ac29f382.cjs.js} +363 -185
- package/dist/index-f1703b7b.esm.js +386 -0
- package/dist/nixxie-cms-core.cjs.js +1387 -30
- package/dist/nixxie-cms-core.esm.js +1361 -24
- package/dist/{non-null-graphql-add6bb3d.cjs.js → non-null-graphql-4a44c122.cjs.js} +1 -1
- package/dist/{non-null-graphql-a84ed64d.esm.js → non-null-graphql-8c5feaae.esm.js} +1 -1
- package/dist/{resolve-hooks-165a9ce2.cjs.js → resolve-hooks-10a5f84c.cjs.js} +240 -6
- package/dist/{resolve-hooks-6813a045.esm.js → resolve-hooks-9e676794.esm.js} +238 -7
- package/dist/{system-03e49e4f.esm.js → system-4d2a2648.esm.js} +32 -7
- package/dist/{system-a321642d.cjs.js → system-69e1a285.cjs.js} +32 -7
- package/fields/dist/nixxie-cms-core-fields.cjs.js +29 -576
- package/fields/dist/nixxie-cms-core-fields.esm.js +18 -565
- package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.cjs.js +4 -2
- package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.esm.js +4 -2
- package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.cjs.js +1 -6
- package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.esm.js +1 -6
- package/fields/types/password/dist/nixxie-cms-core-fields-types-password.cjs.js +4 -2
- package/fields/types/password/dist/nixxie-cms-core-fields-types-password.esm.js +4 -2
- package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.cjs.js +4 -3
- package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.esm.js +4 -3
- package/package.json +4 -4
- package/scripts/cli/dist/nixxie-cms-core-scripts-cli.cjs.js +4 -3
- package/scripts/cli/dist/nixxie-cms-core-scripts-cli.esm.js +4 -3
- package/scripts/dist/nixxie-cms-core-scripts.cjs.js +4 -3
- package/scripts/dist/nixxie-cms-core-scripts.esm.js +4 -3
- package/session/dist/nixxie-cms-core-session.cjs.js +286 -0
- package/session/dist/nixxie-cms-core-session.esm.js +279 -1
- package/src/access.ts +25 -25
- package/src/admin-ui/admin-meta-graphql.ts +5 -5
- package/src/admin-ui/components/CreateButtonLink.tsx +46 -46
- package/src/admin-ui/components/Navigation.tsx +3 -3
- package/src/admin-ui/context.tsx +6 -6
- package/src/admin-ui/utils/Fields.tsx +241 -241
- package/src/admin-ui/utils/actionData.ts +36 -36
- package/src/admin-ui/utils/filters.ts +148 -148
- package/src/admin-ui/utils/useCreateItem.ts +171 -171
- package/src/admin-ui/utils/utils.tsx +127 -127
- package/src/context.ts +1 -1
- package/src/fields/non-null-graphql.ts +115 -115
- package/src/fields/types/bigInt/index.ts +6 -6
- package/src/fields/types/bytes/index.ts +6 -6
- package/src/fields/types/calendarDay/index.ts +18 -19
- package/src/fields/types/checkbox/index.ts +6 -6
- package/src/fields/types/decimal/index.ts +6 -6
- package/src/fields/types/file/index.ts +8 -8
- package/src/fields/types/float/index.ts +6 -6
- package/src/fields/types/image/index.ts +8 -8
- package/src/fields/types/integer/index.ts +6 -6
- package/src/fields/types/json/index.ts +5 -5
- package/src/fields/types/multiselect/index.ts +7 -7
- package/src/fields/types/multiselect/views/index.tsx +149 -151
- package/src/fields/types/password/index.ts +6 -6
- package/src/fields/types/relationship/index.ts +13 -13
- package/src/fields/types/relationship/views/ComboboxMany.tsx +110 -110
- package/src/fields/types/relationship/views/ComboboxSingle.tsx +115 -115
- package/src/fields/types/relationship/views/ContextualActions.tsx +139 -139
- package/src/fields/types/relationship/views/index.tsx +492 -492
- package/src/fields/types/relationship/views/types.ts +46 -46
- package/src/fields/types/relationship/views/useApolloQuery.ts +185 -185
- package/src/fields/types/relationship/views/useFilter.tsx +109 -109
- package/src/fields/types/select/index.ts +6 -6
- package/src/fields/types/text/index.ts +6 -6
- package/src/fields/types/timestamp/index.ts +23 -21
- package/src/fields/types/virtual/index.ts +11 -11
- package/src/helpers.ts +773 -42
- package/src/index.ts +66 -24
- package/src/internal-unstable/admin-ui/pages/ItemPage/common.tsx +4 -4
- package/src/internal-unstable/admin-ui/pages/ItemPage/index.tsx +5 -5
- package/src/internal-unstable/admin-ui/pages/ListPage/index.tsx +8 -8
- package/src/lib/admin-meta.ts +369 -369
- package/src/lib/context/createContext.ts +5 -0
- package/src/lib/core/access-control.ts +434 -434
- package/src/lib/core/cascade.ts +236 -0
- package/src/lib/core/initialise-lists.ts +49 -33
- package/src/lib/core/mutations/index.ts +7 -0
- package/src/lib/core/mutations/nested-mutation-many-input-resolvers.ts +145 -145
- package/src/lib/core/mutations/nested-mutation-one-input-resolvers.ts +71 -71
- package/src/lib/core/queries/output-field.ts +178 -178
- package/src/lib/env.ts +50 -0
- package/src/lib/id-field.ts +2 -2
- package/src/lib/system.ts +221 -207
- package/src/lib/typescript-schema-printer.ts +227 -227
- package/src/list-features.ts +476 -0
- package/src/schema.ts +91 -22
- package/src/session.ts +225 -0
- package/src/types/admin-meta.ts +218 -218
- package/src/types/config/access-control.ts +186 -186
- package/src/types/config/fields.ts +96 -96
- package/src/types/config/hooks.ts +529 -529
- package/src/types/config/index.ts +185 -7
- package/src/types/config/lists.ts +606 -565
- package/src/types/context.ts +426 -55
- package/src/types/next-fields.ts +31 -31
- package/src/types/type-info.ts +38 -38
- 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
|
|
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
|
-
|
|
21
|
+
BaseCollectionTypeInfo,
|
|
21
22
|
CacheHintArgs,
|
|
22
23
|
FieldTypeFunc,
|
|
23
24
|
FindManyArgs,
|
|
24
|
-
|
|
25
|
+
GraphQLTypesForCollection,
|
|
25
26
|
NixxieConfig,
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
CollectionGraphQLTypes,
|
|
28
|
+
CollectionHooks,
|
|
28
29
|
MaybeFieldFunction,
|
|
29
30
|
NextFieldType,
|
|
30
31
|
} from '../../types'
|
|
31
32
|
import { QueryMode } from '../../types'
|
|
32
|
-
import type {
|
|
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
|
|
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<
|
|
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
|
-
|
|
88
|
+
BaseCollectionTypeInfo
|
|
83
89
|
>
|
|
84
90
|
}
|
|
85
91
|
listView: {
|
|
86
92
|
actionMode: MaybeSessionFunctionWithFilter<
|
|
87
93
|
'enabled' | 'disabled' | 'hidden',
|
|
88
94
|
'disabled' | 'hidden',
|
|
89
|
-
|
|
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<
|
|
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<
|
|
126
|
-
orderBy: MaybeFieldFunction<
|
|
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',
|
|
141
|
-
isRequired: MaybeBooleanSessionFunctionWithFilter<
|
|
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
|
-
|
|
153
|
+
BaseCollectionTypeInfo,
|
|
154
|
+
BaseFieldTypeInfo
|
|
155
|
+
>
|
|
156
|
+
fieldPosition: MaybeItemFieldFunction<
|
|
157
|
+
'form' | 'sidebar',
|
|
158
|
+
BaseCollectionTypeInfo,
|
|
148
159
|
BaseFieldTypeInfo
|
|
149
160
|
>
|
|
150
|
-
|
|
151
|
-
isRequired: MaybeBooleanItemFunctionWithFilter<BaseListTypeInfo, BaseFieldTypeInfo>
|
|
161
|
+
isRequired: MaybeBooleanItemFunctionWithFilter<BaseCollectionTypeInfo, BaseFieldTypeInfo>
|
|
152
162
|
}
|
|
153
163
|
listView: {
|
|
154
|
-
fieldMode: MaybeSessionFunction<'read' | 'hidden',
|
|
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:
|
|
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<
|
|
188
|
+
groups: GroupInfo<BaseCollectionTypeInfo>[]
|
|
176
189
|
|
|
177
|
-
hooks:
|
|
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:
|
|
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<
|
|
193
|
-
orderBy: MaybeFieldFunction<
|
|
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<
|
|
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(
|
|
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<
|
|
345
|
-
): ResolvedFieldHooks<
|
|
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,
|
|
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:
|
|
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<
|
|
685
|
+
const groups: GroupInfo<BaseCollectionTypeInfo>[] = []
|
|
671
686
|
const fieldKeys = Object.keys(listConfig.fields)
|
|
672
687
|
|
|
673
|
-
const fieldKeysToGroup: Record<string, GroupInfo<
|
|
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 ?? '')
|