@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
package/src/helpers.ts CHANGED
@@ -1,15 +1,23 @@
1
- import { list, config, action } from './schema'
1
+ import { randomBytes } from 'node:crypto'
2
+ import { collection, buildConfig, createAction } from './schema'
3
+ import { applyAccessFeatures, compileFeatureHooks } from './list-features'
4
+ import type { ListFeaturesConfig } from './list-features'
2
5
  import { timestamp } from './fields/types/timestamp'
3
6
  import { relationship } from './fields/types/relationship'
7
+ import { text } from './fields/types/text'
8
+ import { select } from './fields/types/select'
9
+ import { integer } from './fields/types/integer'
10
+ import { json } from './fields/types/json'
4
11
  import { merge } from './fields/resolve-hooks'
5
12
  import type {
6
- BaseListTypeInfo,
13
+ BaseCollectionTypeInfo,
7
14
  BaseNixxieTypeInfo,
8
- ListConfig,
15
+ CollectionConfig,
9
16
  BaseFields,
10
17
  NixxieConfigPre,
11
18
  NixxieConfig,
12
- ListHooks,
19
+ CollectionHooks,
20
+ CollectionAccessControl,
13
21
  ActionArgsConfig,
14
22
  Action,
15
23
  DeclaredAction,
@@ -63,22 +71,98 @@ export const validators = {
63
71
  required: { isRequired: true } as const,
64
72
  } as const
65
73
 
74
+ // ================================================================
75
+ // Access presets — readable shorthands for common access patterns
76
+ // ================================================================
77
+ //
78
+ // Spelling out an access-control object on every list gets repetitive. In
79
+ // practice 90% of lists fall into a handful of patterns, so we name them.
80
+ // Anything more bespoke can still drop down to a raw access object.
81
+
82
+ type AnyAccessArgs = { session?: any; context?: any }
83
+
84
+ const isSignedIn = ({ session }: AnyAccessArgs) => !!session
85
+
86
+ export const accessPresets: {
87
+ /** Everyone can do everything (equivalent to `allowAll`). */
88
+ public: CollectionAccessControl<any>
89
+ /** Any signed-in user can read & write; anonymous requests are denied. */
90
+ authenticated: CollectionAccessControl<any>
91
+ /** Anyone can read; only signed-in users can create/update/delete. */
92
+ publicReadAuthenticatedWrite: CollectionAccessControl<any>
93
+ /** Anyone can read; nothing can be written through the API. */
94
+ readOnly: CollectionAccessControl<any>
95
+ /**
96
+ * Rows are scoped to their owner. Signed-in users may create items, and may
97
+ * only read/update/delete rows whose `<field>` relationship points at the
98
+ * current session's `itemId`.
99
+ *
100
+ * @param field the relationship field linking a row to its owning user (default `'user'`)
101
+ */
102
+ owner: (field?: string) => CollectionAccessControl<any>
103
+ } = {
104
+ public: () => true,
105
+ authenticated: {
106
+ operation: {
107
+ query: isSignedIn,
108
+ create: isSignedIn,
109
+ update: isSignedIn,
110
+ delete: isSignedIn,
111
+ },
112
+ },
113
+ publicReadAuthenticatedWrite: {
114
+ operation: {
115
+ query: () => true,
116
+ create: isSignedIn,
117
+ update: isSignedIn,
118
+ delete: isSignedIn,
119
+ },
120
+ },
121
+ readOnly: {
122
+ operation: {
123
+ query: () => true,
124
+ create: () => false,
125
+ update: () => false,
126
+ delete: () => false,
127
+ },
128
+ },
129
+ owner(field = 'user') {
130
+ const scopeToOwner = ({ session }: AnyAccessArgs) => {
131
+ if (!session?.itemId) return false
132
+ return { [field]: { id: { equals: session.itemId } } }
133
+ }
134
+ return {
135
+ operation: {
136
+ query: isSignedIn,
137
+ create: isSignedIn,
138
+ update: isSignedIn,
139
+ delete: isSignedIn,
140
+ },
141
+ filter: {
142
+ query: scopeToOwner,
143
+ update: scopeToOwner,
144
+ delete: scopeToOwner,
145
+ },
146
+ } as CollectionAccessControl<any>
147
+ },
148
+ }
149
+
66
150
  // ================================================================
67
151
  // Mixin system
68
152
  // ================================================================
69
153
 
70
- export type ListMixin<ListTypeInfo extends BaseListTypeInfo = BaseListTypeInfo> = {
154
+ export type CollectionMixin<CollectionTypeInfo extends BaseCollectionTypeInfo = BaseCollectionTypeInfo> = {
71
155
  /** Fields added by this mixin — user fields take precedence on key conflicts */
72
156
  fields?: Record<string, any>
73
157
  /** Hooks merged with existing list hooks */
74
- hooks?: Partial<ListHooks<ListTypeInfo>>
158
+ hooks?: Partial<CollectionHooks<CollectionTypeInfo>>
75
159
  }
76
160
 
77
161
  /**
78
162
  * Adds `createdAt` and `updatedAt` timestamp fields.
79
163
  * Both are hidden on create forms and read-only in item views.
80
164
  */
81
- export function withTimestamps(): ListMixin {
165
+ export function withTimestamps(): CollectionMixin {
82
166
  return {
83
167
  fields: {
84
168
  createdAt: timestamp({
@@ -107,7 +191,7 @@ export function withTimestamps(): ListMixin {
107
191
  * Items are not physically deleted — set `deletedAt` to the current time instead.
108
192
  * Note: you must manually filter `deletedAt: null` in your queries/access control.
109
193
  */
110
- export function withSoftDelete(): ListMixin {
194
+ export function withSoftDelete(): CollectionMixin {
111
195
  return {
112
196
  fields: {
113
197
  deletedAt: timestamp({
@@ -130,7 +214,7 @@ export function withSoftDelete(): ListMixin {
130
214
  *
131
215
  * @param userListKey - The list key for your user model (default: 'User')
132
216
  */
133
- export function withAudit(userListKey = 'User'): ListMixin {
217
+ export function withAudit(userListKey = 'User'): CollectionMixin {
134
218
  return {
135
219
  fields: {
136
220
  createdBy: relationship({
@@ -171,6 +255,611 @@ export function withAudit(userListKey = 'User'): ListMixin {
171
255
  }
172
256
  }
173
257
 
258
+ /**
259
+ * Turn an arbitrary string into a URL-safe slug. Pure, dependency-free.
260
+ */
261
+ function slugify(input: string): string {
262
+ return input
263
+ .toString()
264
+ .normalize('NFKD')
265
+ .replace(/[̀-ͯ]/g, '') // strip accents
266
+ .toLowerCase()
267
+ .trim()
268
+ .replace(/[^a-z0-9]+/g, '-') // non-alphanumerics → dashes
269
+ .replace(/^-+|-+$/g, '') // trim leading/trailing dashes
270
+ }
271
+
272
+ /**
273
+ * Adds a URL-friendly `slug` field that is auto-generated from another field
274
+ * (e.g. `title`) when left blank, and normalised when provided.
275
+ *
276
+ * Solves a perennial papercut: there's no built-in slug field, so everyone
277
+ * re-implements the same `resolveInput` hook by hand.
278
+ *
279
+ * @example
280
+ * defineCollection({ mixins: [withSlug({ from: 'title' })], fields: { title: text() } })
281
+ */
282
+ export function withSlug(
283
+ opts: { from: string; field?: string; isIndexed?: 'unique' | true } = { from: 'title' }
284
+ ): CollectionMixin {
285
+ const fieldKey = opts.field ?? 'slug'
286
+ const from = opts.from
287
+ return {
288
+ fields: {
289
+ [fieldKey]: text({
290
+ isIndexed: opts.isIndexed ?? 'unique',
291
+ ui: {
292
+ description: `URL slug — auto-generated from "${from}" when left blank.`,
293
+ },
294
+ }),
295
+ },
296
+ hooks: {
297
+ resolveInput: ({ resolvedData, inputData }) => {
298
+ const provided = (resolvedData as any)[fieldKey]
299
+ if (typeof provided === 'string' && provided.length > 0) {
300
+ return { ...resolvedData, [fieldKey]: slugify(provided) }
301
+ }
302
+ const source = (inputData as any)?.[from] ?? (resolvedData as any)?.[from]
303
+ if (typeof source === 'string' && source.length > 0) {
304
+ return { ...resolvedData, [fieldKey]: slugify(source) }
305
+ }
306
+ return resolvedData
307
+ },
308
+ },
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Adds a draft → published → archived workflow: a `status` select plus a
314
+ * `publishedAt` timestamp that is stamped automatically the first time an item
315
+ * transitions to `published`.
316
+ *
317
+ * Publishing is otherwise left entirely to the developer; this bakes in the
318
+ * common content lifecycle so a blog/news/docs list works out of the box.
319
+ */
320
+ export function withPublishing(
321
+ opts: { defaultStatus?: 'draft' | 'published' | 'archived' } = {}
322
+ ): CollectionMixin {
323
+ const defaultStatus = opts.defaultStatus ?? 'draft'
324
+ return {
325
+ fields: {
326
+ status: select({
327
+ type: 'enum',
328
+ options: [
329
+ { label: 'Draft', value: 'draft' },
330
+ { label: 'Published', value: 'published' },
331
+ { label: 'Archived', value: 'archived' },
332
+ ],
333
+ defaultValue: defaultStatus,
334
+ ui: { displayMode: 'segmented-control' },
335
+ }),
336
+ publishedAt: timestamp({
337
+ ui: {
338
+ createView: { fieldMode: 'hidden' },
339
+ itemView: { fieldMode: 'read' },
340
+ listView: { fieldMode: 'read' },
341
+ },
342
+ }),
343
+ },
344
+ hooks: {
345
+ resolveInput: ({ resolvedData, item }) => {
346
+ const nextStatus = (resolvedData as any).status ?? (item as any)?.status
347
+ const alreadyPublished = (item as any)?.publishedAt
348
+ const incomingPublishedAt = (resolvedData as any).publishedAt
349
+ if (nextStatus === 'published' && !alreadyPublished && incomingPublishedAt == null) {
350
+ return { ...resolvedData, publishedAt: new Date().toISOString() }
351
+ }
352
+ return resolvedData
353
+ },
354
+ },
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Adds standard SEO metadata fields, grouped together in the Admin UI.
360
+ * Drop-in for any content list that needs search/social previews.
361
+ */
362
+ export function withSEO(): CollectionMixin {
363
+ return {
364
+ fields: {
365
+ metaTitle: text({
366
+ ui: { description: 'Title used by search engines and social previews.' },
367
+ }),
368
+ metaDescription: text({
369
+ ui: { displayMode: 'textarea', description: 'Short summary (~155 characters).' },
370
+ }),
371
+ ogImage: text({
372
+ ui: { description: 'Absolute URL to the social sharing image.' },
373
+ }),
374
+ },
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Adds an integer `order` field for manual sorting/drag-ordering of items.
380
+ */
381
+ export function withSortable(opts: { field?: string } = {}): CollectionMixin {
382
+ const fieldKey = opts.field ?? 'order'
383
+ return {
384
+ fields: {
385
+ [fieldKey]: integer({
386
+ defaultValue: 0,
387
+ ui: { description: 'Manual sort position (lower comes first).' },
388
+ }),
389
+ },
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Adds self-referential `parent` / `children` relationships for tree-shaped content
395
+ * (categories, menus, pages), with cycle prevention: an item can never be moved under
396
+ * one of its own descendants.
397
+ *
398
+ * @param listKey - The key of the collection this mixin is applied to (needed for the
399
+ * self-referencing relationship and ancestor checks).
400
+ *
401
+ * @example
402
+ * defineCollection({ mixins: [withTreeStructure('Category')], fields: { name: text() } })
403
+ */
404
+ export function withTreeStructure(
405
+ listKey: string,
406
+ opts: { parentField?: string; childrenField?: string } = {}
407
+ ): CollectionMixin {
408
+ const parentField = opts.parentField ?? 'parent'
409
+ const childrenField = opts.childrenField ?? 'children'
410
+ return {
411
+ fields: {
412
+ [parentField]: relationship({
413
+ ref: `${listKey}.${childrenField}`,
414
+ ui: { description: 'Parent item in the tree (leave empty for a root item).' },
415
+ }),
416
+ [childrenField]: relationship({
417
+ ref: `${listKey}.${parentField}`,
418
+ many: true,
419
+ ui: { displayMode: 'count', itemView: { fieldMode: 'read' } },
420
+ }),
421
+ },
422
+ hooks: {
423
+ validate: async args => {
424
+ const { operation, resolvedData, item, context, addValidationError } = args as any
425
+ if (operation !== 'update') return
426
+ const parentOp = resolvedData?.[parentField]
427
+ const newParentId = parentOp?.connect?.id
428
+ if (newParentId == null) return
429
+
430
+ if (String(newParentId) === String(item.id)) {
431
+ addValidationError(`"${parentField}" cannot point at the item itself`)
432
+ return
433
+ }
434
+ // Walk up from the new parent — finding the item means we'd create a cycle.
435
+ const sudo = context.sudo()
436
+ let cursor: any = newParentId
437
+ for (let depth = 0; depth < 100 && cursor != null; depth++) {
438
+ const ancestor = await sudo.query[listKey].findOne({
439
+ where: { id: String(cursor) },
440
+ query: `id ${parentField} { id }`,
441
+ })
442
+ if (!ancestor) return
443
+ cursor = ancestor[parentField]?.id ?? null
444
+ if (cursor != null && String(cursor) === String(item.id)) {
445
+ addValidationError(
446
+ `"${parentField}" would create a cycle — the new parent is a descendant of this item`
447
+ )
448
+ return
449
+ }
450
+ }
451
+ },
452
+ },
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Multi-tenancy: adds a required `tenant` relationship that is auto-assigned from the
458
+ * session on create. Combine with `tenantAccessFilter()` in the collection's
459
+ * `access.filter` rules to scope every query/update/delete to the session's tenant.
460
+ *
461
+ * @example
462
+ * defineCollection({
463
+ * mixins: [withTenant({ ref: 'Tenant' })],
464
+ * access: {
465
+ * filter: {
466
+ * query: tenantAccessFilter(),
467
+ * update: tenantAccessFilter(),
468
+ * delete: tenantAccessFilter(),
469
+ * },
470
+ * },
471
+ * fields: { ... },
472
+ * })
473
+ */
474
+ export function withTenant(
475
+ opts: {
476
+ /** Collection that holds tenants. @default 'Tenant' */
477
+ ref?: string
478
+ /** Field key for the tenant relationship. @default 'tenant' */
479
+ field?: string
480
+ /** Extract the tenant id from the session. @default session.data?.tenant?.id ?? session.tenantId */
481
+ getTenantId?: (session: any) => string | undefined
482
+ /** Allow items without a tenant (e.g. global records). @default false */
483
+ optional?: boolean
484
+ } = {}
485
+ ): CollectionMixin {
486
+ const ref = opts.ref ?? 'Tenant'
487
+ const field = opts.field ?? 'tenant'
488
+ const getTenantId = opts.getTenantId ?? defaultGetTenantId
489
+ return {
490
+ fields: {
491
+ [field]: relationship({
492
+ ref,
493
+ ui: {
494
+ createView: { fieldMode: 'hidden' },
495
+ itemView: { fieldMode: 'read' },
496
+ },
497
+ }),
498
+ },
499
+ hooks: {
500
+ resolveInput: ({ operation, resolvedData, context }) => {
501
+ if (operation !== 'create') return resolvedData
502
+ if ((resolvedData as any)[field]) return resolvedData
503
+ const tenantId = getTenantId((context as any).session)
504
+ if (!tenantId) return resolvedData
505
+ return { ...resolvedData, [field]: { connect: { id: tenantId } } }
506
+ },
507
+ validate: async args => {
508
+ const { operation, resolvedData, addValidationError } = args as any
509
+ if (opts.optional || operation !== 'create') return
510
+ if (!resolvedData?.[field]) {
511
+ addValidationError(`"${field}" is required — no tenant found on the session`)
512
+ }
513
+ },
514
+ },
515
+ }
516
+ }
517
+
518
+ function defaultGetTenantId(session: any): string | undefined {
519
+ return session?.data?.tenant?.id ?? session?.tenantId
520
+ }
521
+
522
+ /**
523
+ * Access-control filter companion to `withTenant()`: limits matched items to the
524
+ * session's tenant. Returns `false` (no access) when the session has no tenant.
525
+ */
526
+ export function tenantAccessFilter(
527
+ opts: {
528
+ field?: string
529
+ getTenantId?: (session: any) => string | undefined
530
+ } = {}
531
+ ) {
532
+ const field = opts.field ?? 'tenant'
533
+ const getTenantId = opts.getTenantId ?? defaultGetTenantId
534
+ return ({ session }: { session?: any }) => {
535
+ const tenantId = getTenantId(session)
536
+ if (!tenantId) return false
537
+ return { [field]: { id: { equals: tenantId } } }
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Adds `publishAt` / `unpublishAt` timestamps for scheduled publishing. Composes with
543
+ * `withPublishing()` (which provides the `status` field). The schedule is enacted by
544
+ * `applyScheduledPublishing()` — run it from a cron job:
545
+ *
546
+ * @example
547
+ * jobs: createJobs({ jobs: [{
548
+ * name: 'scheduled-publishing',
549
+ * schedule: '* * * * *',
550
+ * handler: async () => { await applyScheduledPublishing(getContext(), 'Post') },
551
+ * }]})
552
+ */
553
+ export function withScheduledPublishing(): CollectionMixin {
554
+ return {
555
+ fields: {
556
+ publishAt: timestamp({
557
+ ui: { description: 'Automatically publish at this time (requires the scheduled-publishing job).' },
558
+ }),
559
+ unpublishAt: timestamp({
560
+ ui: { description: 'Automatically archive at this time (requires the scheduled-publishing job).' },
561
+ }),
562
+ },
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Enact `withScheduledPublishing()` schedules for one collection: publishes drafts whose
568
+ * `publishAt` has passed and archives published items whose `unpublishAt` has passed.
569
+ * Designed to be called from a cron job (see `withScheduledPublishing`).
570
+ */
571
+ export async function applyScheduledPublishing(
572
+ context: any,
573
+ listKey: string,
574
+ opts: {
575
+ statusField?: string
576
+ draftValue?: string
577
+ publishedValue?: string
578
+ archivedValue?: string
579
+ } = {}
580
+ ): Promise<{ published: number; archived: number }> {
581
+ const statusField = opts.statusField ?? 'status'
582
+ const draftValue = opts.draftValue ?? 'draft'
583
+ const publishedValue = opts.publishedValue ?? 'published'
584
+ const archivedValue = opts.archivedValue ?? 'archived'
585
+ const sudo = context.sudo()
586
+ const now = new Date()
587
+
588
+ const toPublish = await sudo.db[listKey].findMany({
589
+ where: { [statusField]: { equals: draftValue }, publishAt: { lte: now } },
590
+ })
591
+ for (const item of toPublish) {
592
+ await sudo.db[listKey].updateOne({
593
+ where: { id: String(item.id) },
594
+ data: { [statusField]: publishedValue },
595
+ })
596
+ }
597
+
598
+ const toArchive = await sudo.db[listKey].findMany({
599
+ where: { [statusField]: { equals: publishedValue }, unpublishAt: { lte: now } },
600
+ })
601
+ for (const item of toArchive) {
602
+ await sudo.db[listKey].updateOne({
603
+ where: { id: String(item.id) },
604
+ data: { [statusField]: archivedValue },
605
+ })
606
+ }
607
+
608
+ return { published: toPublish.length, archived: toArchive.length }
609
+ }
610
+
611
+ /**
612
+ * i18n: adds a `locale` select plus a shared `translationKey` that groups an item with
613
+ * its translations (auto-generated on create when blank). Query an item's variants with
614
+ * `{ translationKey: { equals }, locale: { not: ... } }` — and once compound uniqueness
615
+ * is configured, pair (`translationKey`, `locale`) should be unique.
616
+ *
617
+ * @example
618
+ * defineCollection({ mixins: [withLocale({ locales: ['en', 'ur', 'ar'] })], fields: { ... } })
619
+ */
620
+ export function withLocale(opts: {
621
+ locales: readonly string[]
622
+ defaultLocale?: string
623
+ /** Field key for the grouping id. @default 'translationKey' */
624
+ groupField?: string
625
+ }): CollectionMixin {
626
+ if (!opts.locales?.length) throw new Error('withLocale requires at least one locale')
627
+ const groupField = opts.groupField ?? 'translationKey'
628
+ const defaultLocale = opts.defaultLocale ?? opts.locales[0]
629
+ return {
630
+ fields: {
631
+ locale: select({
632
+ type: 'enum',
633
+ options: opts.locales.map(locale => ({ label: locale, value: locale })),
634
+ defaultValue: defaultLocale,
635
+ validation: { isRequired: true },
636
+ }),
637
+ [groupField]: text({
638
+ isIndexed: true,
639
+ ui: {
640
+ description: 'Shared id linking this item with its translations.',
641
+ createView: { fieldMode: 'hidden' },
642
+ itemView: { fieldMode: 'read' },
643
+ },
644
+ }),
645
+ },
646
+ hooks: {
647
+ resolveInput: ({ operation, resolvedData }) => {
648
+ if (operation !== 'create') return resolvedData
649
+ if ((resolvedData as any)[groupField]) return resolvedData
650
+ return { ...resolvedData, [groupField]: randomBytes(8).toString('hex') }
651
+ },
652
+ },
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Editorial approval: adds an approval status + a `pendingChanges` buffer where proposed
658
+ * edits wait for review instead of going live. Drive it with `submitForApproval()`,
659
+ * `approveChanges()` and `rejectChanges()` from custom mutations or the Admin UI.
660
+ */
661
+ export function withApproval(): CollectionMixin {
662
+ return {
663
+ fields: {
664
+ approvalStatus: select({
665
+ type: 'enum',
666
+ options: [
667
+ { label: 'None', value: 'none' },
668
+ { label: 'Pending', value: 'pending' },
669
+ { label: 'Approved', value: 'approved' },
670
+ { label: 'Rejected', value: 'rejected' },
671
+ ],
672
+ defaultValue: 'none',
673
+ ui: { createView: { fieldMode: 'hidden' }, itemView: { fieldMode: 'read' } },
674
+ }),
675
+ pendingChanges: json({
676
+ ui: {
677
+ createView: { fieldMode: 'hidden' },
678
+ itemView: { fieldMode: 'hidden' },
679
+ listView: { fieldMode: 'hidden' },
680
+ },
681
+ }),
682
+ approvalSubmittedAt: timestamp({
683
+ ui: { createView: { fieldMode: 'hidden' }, itemView: { fieldMode: 'read' } },
684
+ }),
685
+ approvalReviewedAt: timestamp({
686
+ ui: { createView: { fieldMode: 'hidden' }, itemView: { fieldMode: 'read' } },
687
+ }),
688
+ },
689
+ }
690
+ }
691
+
692
+ /** Park proposed changes on an item for review (see `withApproval`). */
693
+ export async function submitForApproval(
694
+ context: any,
695
+ args: { listKey: string; itemId: string; changes: Record<string, unknown> }
696
+ ): Promise<void> {
697
+ await context.sudo().db[args.listKey].updateOne({
698
+ where: { id: args.itemId },
699
+ data: {
700
+ pendingChanges: args.changes,
701
+ approvalStatus: 'pending',
702
+ approvalSubmittedAt: new Date().toISOString(),
703
+ },
704
+ })
705
+ }
706
+
707
+ /**
708
+ * Apply an item's `pendingChanges` (running normal hooks/validation) and mark it
709
+ * approved. The buffered changes must be a valid update input for the collection.
710
+ */
711
+ export async function approveChanges(
712
+ context: any,
713
+ args: { listKey: string; itemId: string }
714
+ ): Promise<void> {
715
+ const sudo = context.sudo()
716
+ const item = await sudo.query[args.listKey].findOne({
717
+ where: { id: args.itemId },
718
+ query: 'id pendingChanges approvalStatus',
719
+ })
720
+ if (!item?.pendingChanges) throw new Error('approveChanges: the item has no pending changes')
721
+ await sudo.db[args.listKey].updateOne({
722
+ where: { id: args.itemId },
723
+ data: {
724
+ ...item.pendingChanges,
725
+ pendingChanges: null,
726
+ approvalStatus: 'approved',
727
+ approvalReviewedAt: new Date().toISOString(),
728
+ },
729
+ })
730
+ }
731
+
732
+ /** Discard an item's `pendingChanges` and mark it rejected. */
733
+ export async function rejectChanges(
734
+ context: any,
735
+ args: { listKey: string; itemId: string }
736
+ ): Promise<void> {
737
+ await context.sudo().db[args.listKey].updateOne({
738
+ where: { id: args.itemId },
739
+ data: {
740
+ pendingChanges: null,
741
+ approvalStatus: 'rejected',
742
+ approvalReviewedAt: new Date().toISOString(),
743
+ },
744
+ })
745
+ }
746
+
747
+ /**
748
+ * Adds an `expiresAt` timestamp for content with a shelf life. Pair with
749
+ * `purgeExpired()` in a cron job to physically remove (or just count) expired items.
750
+ */
751
+ export function withExpiry(opts: { field?: string } = {}): CollectionMixin {
752
+ const field = opts.field ?? 'expiresAt'
753
+ return {
754
+ fields: {
755
+ [field]: timestamp({
756
+ ui: { description: 'After this time the item is considered expired.' },
757
+ }),
758
+ },
759
+ }
760
+ }
761
+
762
+ /**
763
+ * Delete every item whose expiry has passed (runs normal delete hooks). Returns the
764
+ * number deleted. Designed for a cron job; see `withExpiry`.
765
+ */
766
+ export async function purgeExpired(
767
+ context: any,
768
+ listKey: string,
769
+ opts: { field?: string } = {}
770
+ ): Promise<number> {
771
+ const field = opts.field ?? 'expiresAt'
772
+ const sudo = context.sudo()
773
+ const expired = await sudo.db[listKey].findMany({ where: { [field]: { lte: new Date() } } })
774
+ for (const item of expired) {
775
+ await sudo.db[listKey].deleteOne({ where: { id: String(item.id) } })
776
+ }
777
+ return expired.length
778
+ }
779
+
780
+ /**
781
+ * Adds a read-only `viewCount` integer. Increment it with `recordView()` — a direct,
782
+ * race-safe Prisma increment that bypasses hooks (a view is not an editorial change).
783
+ */
784
+ export function withViewCount(opts: { field?: string } = {}): CollectionMixin {
785
+ const field = opts.field ?? 'viewCount'
786
+ return {
787
+ fields: {
788
+ [field]: integer({
789
+ defaultValue: 0,
790
+ ui: {
791
+ createView: { fieldMode: 'hidden' },
792
+ itemView: { fieldMode: 'read' },
793
+ },
794
+ }),
795
+ },
796
+ }
797
+ }
798
+
799
+ /** Increment an item's view counter (see `withViewCount`). */
800
+ export async function recordView(
801
+ context: any,
802
+ listKey: string,
803
+ itemId: string,
804
+ opts: { field?: string } = {}
805
+ ): Promise<void> {
806
+ const field = opts.field ?? 'viewCount'
807
+ const model = (context.prisma as any)?.[listKey[0].toLowerCase() + listKey.slice(1)]
808
+ if (!model) throw new Error(`recordView: collection "${listKey}" was not found in the Prisma client`)
809
+ await model.update({ where: { id: itemId }, data: { [field]: { increment: 1 } } })
810
+ }
811
+
812
+ /**
813
+ * Companion to cascade rules: sweep records whose to-one relationship points at
814
+ * nothing (e.g. rows that pre-date a cascade rule, or were orphaned by `setNull`).
815
+ * Runs normal delete hooks (so cascade rules fire too). Designed for a cron job.
816
+ *
817
+ * @example
818
+ * jobs: createJobs({ jobs: [{
819
+ * name: 'orphan-cleanup',
820
+ * schedule: '0 4 * * *',
821
+ * handler: async () => { await cleanupOrphans(getContext(), { collection: 'Comment', field: 'post' }) },
822
+ * }]})
823
+ */
824
+ export async function cleanupOrphans(
825
+ context: any,
826
+ options: {
827
+ /** Collection to sweep. */
828
+ collection: string
829
+ /** To-one relationship field that must be connected for the record to be kept. */
830
+ field: string
831
+ /** 'delete' removes orphans; 'softDelete' stamps `softDeleteField`. @default 'delete' */
832
+ action?: 'delete' | 'softDelete'
833
+ /** @default 'deletedAt' */
834
+ softDeleteField?: string
835
+ }
836
+ ): Promise<number> {
837
+ const { collection, field, action = 'delete' } = options
838
+ const softDeleteField = options.softDeleteField ?? 'deletedAt'
839
+ const sudo = context.sudo()
840
+ const where =
841
+ action === 'softDelete' ? { [field]: null, [softDeleteField]: null } : { [field]: null }
842
+
843
+ let total = 0
844
+ for (;;) {
845
+ const orphans = await sudo.db[collection].findMany({ where, take: 100 })
846
+ if (!orphans.length) break
847
+ for (const orphan of orphans) {
848
+ if (action === 'delete') {
849
+ await sudo.db[collection].deleteOne({ where: { id: String(orphan.id) } })
850
+ } else {
851
+ await sudo.db[collection].updateOne({
852
+ where: { id: String(orphan.id) },
853
+ data: { [softDeleteField]: new Date().toISOString() },
854
+ })
855
+ }
856
+ total++
857
+ }
858
+ if (orphans.length < 100) break
859
+ }
860
+ return total
861
+ }
862
+
174
863
  /**
175
864
  * Factory for building reusable field group mixins.
176
865
  *
@@ -183,28 +872,38 @@ export function withAudit(userListKey = 'User'): ListMixin {
183
872
  */
184
873
  export function createMixin(
185
874
  fields: Record<string, any>,
186
- hooks?: Partial<ListHooks<any>>
187
- ): ListMixin {
875
+ hooks?: Partial<CollectionHooks<any>>
876
+ ): CollectionMixin {
188
877
  return { fields, hooks }
189
878
  }
190
879
 
191
880
  // ================================================================
192
- // defineList — a typed wrapper around list() with mixin support
881
+ // defineCollection — a typed wrapper around list() with mixin support
193
882
  // ================================================================
194
883
 
195
- type DefineListConfig<ListTypeInfo extends BaseListTypeInfo> = ListConfig<ListTypeInfo> & {
884
+ type DefineListConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<
885
+ CollectionConfig<CollectionTypeInfo>,
886
+ 'access'
887
+ > & {
888
+ /**
889
+ * Controls what data can be read and changed. Optional here — unlike
890
+ * the lower-level `list()`, `defineCollection` defaults to `accessPresets.public`
891
+ * so a minimal list is genuinely one line. Set an explicit preset or access
892
+ * object the moment you need real rules.
893
+ */
894
+ access?: CollectionConfig<CollectionTypeInfo>['access']
196
895
  /**
197
896
  * Mixins to apply to this list. Mixin fields are merged before user fields,
198
897
  * so user fields always take precedence on key conflicts.
199
898
  */
200
- mixins?: ListMixin[]
201
- }
899
+ mixins?: CollectionMixin[]
900
+ } & ListFeaturesConfig
202
901
 
203
902
  /**
204
903
  * Defines a list with optional mixins for reusable field groups and hooks.
205
904
  *
206
905
  * @example
207
- * export const Post = defineList({
906
+ * export const Post = defineCollection({
208
907
  * mixins: [withTimestamps()],
209
908
  * fields: {
210
909
  * title: text({ validation: validators.required }),
@@ -212,29 +911,60 @@ type DefineListConfig<ListTypeInfo extends BaseListTypeInfo> = ListConfig<ListTy
212
911
  * },
213
912
  * })
214
913
  */
215
- export function defineList<ListTypeInfo extends BaseListTypeInfo>(
216
- config: DefineListConfig<ListTypeInfo>
217
- ): ListConfig<ListTypeInfo> {
218
- const { mixins = [], ...listConfig } = config
914
+ export function defineCollection<CollectionTypeInfo extends BaseCollectionTypeInfo>(
915
+ config: DefineListConfig<CollectionTypeInfo>
916
+ ): CollectionConfig<CollectionTypeInfo> {
917
+ const {
918
+ mixins = [],
919
+ access,
920
+ computed,
921
+ constraints,
922
+ defaultFilter,
923
+ stateMachine,
924
+ policies,
925
+ events,
926
+ searchable,
927
+ versioned,
928
+ ...listConfig
929
+ } = config
219
930
 
220
931
  const mixinFields: Record<string, any> = {}
221
- const mixinHooksList: Array<Partial<ListHooks<any>>> = []
932
+ const mixinHooksList: Array<Partial<CollectionHooks<any>>> = []
222
933
 
223
934
  for (const mixin of mixins) {
224
935
  if (mixin.fields) Object.assign(mixinFields, mixin.fields)
225
936
  if (mixin.hooks) mixinHooksList.push(mixin.hooks)
226
937
  }
227
938
 
228
- const mergedHooks = mergeMixinHooks(mixinHooksList, listConfig.hooks as Partial<ListHooks<any>>)
939
+ // Feature hooks run AFTER mixin + user hooks so computed fields and constraint checks
940
+ // observe the final resolved data, and events/search/version snapshots fire last.
941
+ const featureHooks = compileFeatureHooks({
942
+ computed,
943
+ constraints,
944
+ stateMachine,
945
+ events,
946
+ searchable,
947
+ versioned,
948
+ })
949
+
950
+ const mergedHooks = mergeMixinHooks(
951
+ mixinHooksList,
952
+ listConfig.hooks as Partial<CollectionHooks<any>>,
953
+ featureHooks
954
+ )
955
+
956
+ const baseAccess = access ?? accessPresets.public
957
+ const finalAccess = applyAccessFeatures(baseAccess, { defaultFilter, policies })
229
958
 
230
- return list({
959
+ return collection({
231
960
  ...listConfig,
961
+ access: finalAccess as CollectionConfig<CollectionTypeInfo>['access'],
232
962
  fields: {
233
963
  ...mixinFields,
234
964
  ...listConfig.fields,
235
- } as BaseFields<ListTypeInfo>,
236
- hooks: mergedHooks as ListConfig<ListTypeInfo>['hooks'],
237
- } as ListConfig<ListTypeInfo>)
965
+ } as BaseFields<CollectionTypeInfo>,
966
+ hooks: mergedHooks as CollectionConfig<CollectionTypeInfo>['hooks'],
967
+ } as CollectionConfig<CollectionTypeInfo>)
238
968
  }
239
969
 
240
970
  type AnyResolveInput =
@@ -275,33 +1005,34 @@ function mergeResolveInput(
275
1005
  }
276
1006
 
277
1007
  /**
278
- * Merge mixin hooks with the list's own hooks. Each Keystone hook may be either a function or a
1008
+ * Merge mixin hooks with the list's own hooks. Each list hook may be either a function or a
279
1009
  * `{ create, update, delete }` object — `merge` (from resolve-hooks) handles both forms for the
280
1010
  * void hooks, and `mergeResolveInput` handles the data-threading `resolveInput` hook.
281
1011
  */
282
1012
  function mergeMixinHooks(
283
- mixinHooks: Array<Partial<ListHooks<any>>>,
284
- listHooks: Partial<ListHooks<any>> | undefined
285
- ): Partial<ListHooks<any>> {
286
- if (mixinHooks.length === 0) return listHooks ?? {}
1013
+ mixinHooks: Array<Partial<CollectionHooks<any>>>,
1014
+ listHooks: Partial<CollectionHooks<any>> | undefined,
1015
+ featureHooks: Array<Partial<CollectionHooks<any>>> = []
1016
+ ): Partial<CollectionHooks<any>> {
1017
+ if (mixinHooks.length === 0 && featureHooks.length === 0) return listHooks ?? {}
287
1018
 
288
- const all = [...mixinHooks, ...(listHooks ? [listHooks] : [])]
289
- const merged: Partial<ListHooks<any>> = {}
1019
+ const all = [...mixinHooks, ...(listHooks ? [listHooks] : []), ...featureHooks]
1020
+ const merged: Partial<CollectionHooks<any>> = {}
290
1021
 
291
1022
  for (const hooks of all) {
292
1023
  merged.resolveInput = mergeResolveInput(
293
1024
  merged.resolveInput as AnyResolveInput | undefined,
294
1025
  hooks.resolveInput as AnyResolveInput | undefined
295
- ) as ListHooks<any>['resolveInput']
296
- merged.validate = merge(merged.validate as any, hooks.validate as any) as ListHooks<any>['validate']
1026
+ ) as CollectionHooks<any>['resolveInput']
1027
+ merged.validate = merge(merged.validate as any, hooks.validate as any) as CollectionHooks<any>['validate']
297
1028
  merged.beforeOperation = merge(
298
1029
  merged.beforeOperation as any,
299
1030
  hooks.beforeOperation as any
300
- ) as ListHooks<any>['beforeOperation']
1031
+ ) as CollectionHooks<any>['beforeOperation']
301
1032
  merged.afterOperation = merge(
302
1033
  merged.afterOperation as any,
303
1034
  hooks.afterOperation as any
304
- ) as ListHooks<any>['afterOperation']
1035
+ ) as CollectionHooks<any>['afterOperation']
305
1036
  }
306
1037
 
307
1038
  return merged
@@ -324,7 +1055,7 @@ function mergeMixinHooks(
324
1055
  export function defineConfig<TypeInfo extends BaseNixxieTypeInfo>(
325
1056
  nixxieConfig: NixxieConfigPre<TypeInfo>
326
1057
  ): NixxieConfig<TypeInfo> {
327
- return config(nixxieConfig)
1058
+ return buildConfig(nixxieConfig)
328
1059
  }
329
1060
 
330
1061
  // ================================================================
@@ -335,8 +1066,8 @@ export function defineConfig<TypeInfo extends BaseNixxieTypeInfo>(
335
1066
  * Defines a list action. Typed alias for `action()`.
336
1067
  */
337
1068
  export function defineAction<
338
- ListTypeInfo extends BaseListTypeInfo,
339
- Args extends ActionArgsConfig<ListTypeInfo> | undefined = undefined,
340
- >(actionConfig: Action<ListTypeInfo, Args>): DeclaredAction<ListTypeInfo> {
341
- return action(actionConfig)
1069
+ CollectionTypeInfo extends BaseCollectionTypeInfo,
1070
+ Args extends ActionArgsConfig<CollectionTypeInfo> | undefined = undefined,
1071
+ >(actionConfig: Action<CollectionTypeInfo, Args>): DeclaredAction<CollectionTypeInfo> {
1072
+ return createAction(actionConfig)
342
1073
  }