@nixxie-cms/core 1.0.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/CHANGES-1.1.md +134 -0
  3. package/context/dist/nixxie-cms-core-context.cjs.js +4 -3
  4. package/context/dist/nixxie-cms-core-context.esm.js +3 -2
  5. package/dist/declarations/src/access.d.ts +2 -2
  6. package/dist/declarations/src/access.d.ts.map +1 -1
  7. package/dist/declarations/src/admin-ui/components/Navigation.d.ts +2 -2
  8. package/dist/declarations/src/admin-ui/components/Navigation.d.ts.map +1 -1
  9. package/dist/declarations/src/admin-ui/context.d.ts +6 -6
  10. package/dist/declarations/src/admin-ui/context.d.ts.map +1 -1
  11. package/dist/declarations/src/admin-ui/utils/Fields.d.ts +3 -3
  12. package/dist/declarations/src/admin-ui/utils/Fields.d.ts.map +1 -1
  13. package/dist/declarations/src/admin-ui/utils/filters.d.ts +5 -5
  14. package/dist/declarations/src/admin-ui/utils/filters.d.ts.map +1 -1
  15. package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts +3 -3
  16. package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts.map +1 -1
  17. package/dist/declarations/src/admin-ui/utils/utils.d.ts +2 -2
  18. package/dist/declarations/src/admin-ui/utils/utils.d.ts.map +1 -1
  19. package/dist/declarations/src/context.d.ts +1 -1
  20. package/dist/declarations/src/context.d.ts.map +1 -1
  21. package/dist/declarations/src/fields/types/bigInt/index.d.ts +3 -3
  22. package/dist/declarations/src/fields/types/bigInt/index.d.ts.map +1 -1
  23. package/dist/declarations/src/fields/types/bytes/index.d.ts +3 -3
  24. package/dist/declarations/src/fields/types/bytes/index.d.ts.map +1 -1
  25. package/dist/declarations/src/fields/types/calendarDay/index.d.ts +3 -3
  26. package/dist/declarations/src/fields/types/calendarDay/index.d.ts.map +1 -1
  27. package/dist/declarations/src/fields/types/checkbox/index.d.ts +3 -3
  28. package/dist/declarations/src/fields/types/checkbox/index.d.ts.map +1 -1
  29. package/dist/declarations/src/fields/types/decimal/index.d.ts +3 -3
  30. package/dist/declarations/src/fields/types/decimal/index.d.ts.map +1 -1
  31. package/dist/declarations/src/fields/types/file/index.d.ts +4 -4
  32. package/dist/declarations/src/fields/types/file/index.d.ts.map +1 -1
  33. package/dist/declarations/src/fields/types/float/index.d.ts +3 -3
  34. package/dist/declarations/src/fields/types/float/index.d.ts.map +1 -1
  35. package/dist/declarations/src/fields/types/image/index.d.ts +4 -4
  36. package/dist/declarations/src/fields/types/image/index.d.ts.map +1 -1
  37. package/dist/declarations/src/fields/types/integer/index.d.ts +3 -3
  38. package/dist/declarations/src/fields/types/integer/index.d.ts.map +1 -1
  39. package/dist/declarations/src/fields/types/json/index.d.ts +3 -3
  40. package/dist/declarations/src/fields/types/json/index.d.ts.map +1 -1
  41. package/dist/declarations/src/fields/types/multiselect/index.d.ts +3 -3
  42. package/dist/declarations/src/fields/types/multiselect/index.d.ts.map +1 -1
  43. package/dist/declarations/src/fields/types/multiselect/views/index.d.ts.map +1 -1
  44. package/dist/declarations/src/fields/types/password/index.d.ts +3 -3
  45. package/dist/declarations/src/fields/types/password/index.d.ts.map +1 -1
  46. package/dist/declarations/src/fields/types/relationship/index.d.ts +8 -8
  47. package/dist/declarations/src/fields/types/relationship/index.d.ts.map +1 -1
  48. package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts +3 -3
  49. package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts.map +1 -1
  50. package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts +3 -3
  51. package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts.map +1 -1
  52. package/dist/declarations/src/fields/types/relationship/views/index.d.ts +3 -3
  53. package/dist/declarations/src/fields/types/relationship/views/index.d.ts.map +1 -1
  54. package/dist/declarations/src/fields/types/relationship/views/types.d.ts +3 -3
  55. package/dist/declarations/src/fields/types/relationship/views/types.d.ts.map +1 -1
  56. package/dist/declarations/src/fields/types/select/index.d.ts +3 -3
  57. package/dist/declarations/src/fields/types/select/index.d.ts.map +1 -1
  58. package/dist/declarations/src/fields/types/text/index.d.ts +3 -3
  59. package/dist/declarations/src/fields/types/text/index.d.ts.map +1 -1
  60. package/dist/declarations/src/fields/types/timestamp/index.d.ts +3 -3
  61. package/dist/declarations/src/fields/types/timestamp/index.d.ts.map +1 -1
  62. package/dist/declarations/src/fields/types/virtual/index.d.ts +7 -7
  63. package/dist/declarations/src/fields/types/virtual/index.d.ts.map +1 -1
  64. package/dist/declarations/src/helpers.d.ts +249 -13
  65. package/dist/declarations/src/helpers.d.ts.map +1 -1
  66. package/dist/declarations/src/index.d.ts +9 -4
  67. package/dist/declarations/src/index.d.ts.map +1 -1
  68. package/dist/declarations/src/internal-unstable/admin-ui/pages/ListPage/index.d.ts.map +1 -1
  69. package/dist/declarations/src/lib/admin-meta.d.ts +11 -11
  70. package/dist/declarations/src/lib/admin-meta.d.ts.map +1 -1
  71. package/dist/declarations/src/lib/core/access-control.d.ts +18 -18
  72. package/dist/declarations/src/lib/core/access-control.d.ts.map +1 -1
  73. package/dist/declarations/src/lib/core/cascade.d.ts +47 -0
  74. package/dist/declarations/src/lib/core/cascade.d.ts.map +1 -0
  75. package/dist/declarations/src/lib/core/initialise-lists.d.ts +27 -24
  76. package/dist/declarations/src/lib/core/initialise-lists.d.ts.map +1 -1
  77. package/dist/declarations/src/lib/env.d.ts +9 -0
  78. package/dist/declarations/src/lib/env.d.ts.map +1 -0
  79. package/dist/declarations/src/lib/system.d.ts +1 -1
  80. package/dist/declarations/src/lib/system.d.ts.map +1 -1
  81. package/dist/declarations/src/list-features.d.ts +162 -0
  82. package/dist/declarations/src/list-features.d.ts.map +1 -0
  83. package/dist/declarations/src/schema.d.ts +24 -23
  84. package/dist/declarations/src/schema.d.ts.map +1 -1
  85. package/dist/declarations/src/session.d.ts +75 -0
  86. package/dist/declarations/src/session.d.ts.map +1 -1
  87. package/dist/declarations/src/types/admin-meta.d.ts +11 -11
  88. package/dist/declarations/src/types/admin-meta.d.ts.map +1 -1
  89. package/dist/declarations/src/types/config/access-control.d.ts +42 -42
  90. package/dist/declarations/src/types/config/access-control.d.ts.map +1 -1
  91. package/dist/declarations/src/types/config/fields.d.ts +19 -19
  92. package/dist/declarations/src/types/config/fields.d.ts.map +1 -1
  93. package/dist/declarations/src/types/config/hooks.d.ts +131 -131
  94. package/dist/declarations/src/types/config/hooks.d.ts.map +1 -1
  95. package/dist/declarations/src/types/config/index.d.ts +190 -8
  96. package/dist/declarations/src/types/config/index.d.ts.map +1 -1
  97. package/dist/declarations/src/types/config/lists.d.ts +146 -108
  98. package/dist/declarations/src/types/config/lists.d.ts.map +1 -1
  99. package/dist/declarations/src/types/context.d.ts +507 -47
  100. package/dist/declarations/src/types/context.d.ts.map +1 -1
  101. package/dist/declarations/src/types/next-fields.d.ts +28 -28
  102. package/dist/declarations/src/types/next-fields.d.ts.map +1 -1
  103. package/dist/declarations/src/types/type-info.d.ts +3 -3
  104. package/dist/declarations/src/types/type-info.d.ts.map +1 -1
  105. package/dist/{express-455ae20c.cjs.js → express-84d534c2.cjs.js} +6 -6
  106. package/dist/{express-7559ca2d.esm.js → express-d0a4ce99.esm.js} +6 -6
  107. package/dist/{index-15c8f81e.esm.js → index-5d8b0b4e.esm.js} +363 -183
  108. package/dist/index-6055753b.cjs.js +393 -0
  109. package/dist/{index-42045902.cjs.js → index-ac29f382.cjs.js} +363 -185
  110. package/dist/index-f1703b7b.esm.js +386 -0
  111. package/dist/nixxie-cms-core.cjs.js +1388 -30
  112. package/dist/nixxie-cms-core.esm.js +1362 -24
  113. package/dist/{non-null-graphql-add6bb3d.cjs.js → non-null-graphql-4a44c122.cjs.js} +1 -1
  114. package/dist/{non-null-graphql-a84ed64d.esm.js → non-null-graphql-8c5feaae.esm.js} +1 -1
  115. package/dist/{resolve-hooks-165a9ce2.cjs.js → resolve-hooks-10a5f84c.cjs.js} +240 -6
  116. package/dist/{resolve-hooks-6813a045.esm.js → resolve-hooks-9e676794.esm.js} +238 -7
  117. package/dist/{system-a321642d.cjs.js → system-6b37a5f8.cjs.js} +33 -7
  118. package/dist/{system-03e49e4f.esm.js → system-e591d821.esm.js} +33 -7
  119. package/fields/dist/nixxie-cms-core-fields.cjs.js +29 -576
  120. package/fields/dist/nixxie-cms-core-fields.esm.js +18 -565
  121. package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.cjs.js +4 -2
  122. package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.esm.js +4 -2
  123. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.cjs.js +1 -6
  124. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.esm.js +1 -6
  125. package/fields/types/password/dist/nixxie-cms-core-fields-types-password.cjs.js +4 -2
  126. package/fields/types/password/dist/nixxie-cms-core-fields-types-password.esm.js +4 -2
  127. package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.cjs.js +4 -3
  128. package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.esm.js +4 -3
  129. package/package.json +4 -4
  130. package/scripts/cli/dist/nixxie-cms-core-scripts-cli.cjs.js +4 -3
  131. package/scripts/cli/dist/nixxie-cms-core-scripts-cli.esm.js +4 -3
  132. package/scripts/dist/nixxie-cms-core-scripts.cjs.js +4 -3
  133. package/scripts/dist/nixxie-cms-core-scripts.esm.js +4 -3
  134. package/session/dist/nixxie-cms-core-session.cjs.js +286 -0
  135. package/session/dist/nixxie-cms-core-session.esm.js +279 -1
  136. package/src/access.ts +25 -25
  137. package/src/admin-ui/admin-meta-graphql.ts +5 -5
  138. package/src/admin-ui/components/CreateButtonLink.tsx +46 -46
  139. package/src/admin-ui/components/Navigation.tsx +3 -3
  140. package/src/admin-ui/context.tsx +6 -6
  141. package/src/admin-ui/utils/Fields.tsx +241 -241
  142. package/src/admin-ui/utils/actionData.ts +36 -36
  143. package/src/admin-ui/utils/filters.ts +148 -148
  144. package/src/admin-ui/utils/useCreateItem.ts +171 -171
  145. package/src/admin-ui/utils/utils.tsx +127 -127
  146. package/src/context.ts +1 -1
  147. package/src/fields/non-null-graphql.ts +115 -115
  148. package/src/fields/types/bigInt/index.ts +6 -6
  149. package/src/fields/types/bytes/index.ts +6 -6
  150. package/src/fields/types/calendarDay/index.ts +18 -19
  151. package/src/fields/types/checkbox/index.ts +6 -6
  152. package/src/fields/types/decimal/index.ts +6 -6
  153. package/src/fields/types/file/index.ts +8 -8
  154. package/src/fields/types/float/index.ts +6 -6
  155. package/src/fields/types/image/index.ts +8 -8
  156. package/src/fields/types/integer/index.ts +6 -6
  157. package/src/fields/types/json/index.ts +5 -5
  158. package/src/fields/types/multiselect/index.ts +7 -7
  159. package/src/fields/types/multiselect/views/index.tsx +149 -151
  160. package/src/fields/types/password/index.ts +6 -6
  161. package/src/fields/types/relationship/index.ts +13 -13
  162. package/src/fields/types/relationship/views/ComboboxMany.tsx +110 -110
  163. package/src/fields/types/relationship/views/ComboboxSingle.tsx +115 -115
  164. package/src/fields/types/relationship/views/ContextualActions.tsx +139 -139
  165. package/src/fields/types/relationship/views/index.tsx +492 -492
  166. package/src/fields/types/relationship/views/types.ts +46 -46
  167. package/src/fields/types/relationship/views/useApolloQuery.ts +185 -185
  168. package/src/fields/types/relationship/views/useFilter.tsx +109 -109
  169. package/src/fields/types/select/index.ts +6 -6
  170. package/src/fields/types/text/index.ts +6 -6
  171. package/src/fields/types/timestamp/index.ts +23 -21
  172. package/src/fields/types/virtual/index.ts +11 -11
  173. package/src/helpers.ts +773 -42
  174. package/src/index.ts +66 -24
  175. package/src/internal-unstable/admin-ui/pages/ItemPage/common.tsx +4 -4
  176. package/src/internal-unstable/admin-ui/pages/ItemPage/index.tsx +5 -5
  177. package/src/internal-unstable/admin-ui/pages/ListPage/index.tsx +8 -8
  178. package/src/lib/admin-meta.ts +369 -369
  179. package/src/lib/context/createContext.ts +6 -0
  180. package/src/lib/core/access-control.ts +434 -434
  181. package/src/lib/core/cascade.ts +236 -0
  182. package/src/lib/core/initialise-lists.ts +49 -33
  183. package/src/lib/core/mutations/index.ts +7 -0
  184. package/src/lib/core/mutations/nested-mutation-many-input-resolvers.ts +145 -145
  185. package/src/lib/core/mutations/nested-mutation-one-input-resolvers.ts +71 -71
  186. package/src/lib/core/queries/output-field.ts +178 -178
  187. package/src/lib/env.ts +50 -0
  188. package/src/lib/id-field.ts +2 -2
  189. package/src/lib/system.ts +221 -207
  190. package/src/lib/typescript-schema-printer.ts +227 -227
  191. package/src/list-features.ts +476 -0
  192. package/src/schema.ts +92 -22
  193. package/src/session.ts +225 -0
  194. package/src/types/admin-meta.ts +218 -218
  195. package/src/types/config/access-control.ts +186 -186
  196. package/src/types/config/fields.ts +96 -96
  197. package/src/types/config/hooks.ts +529 -529
  198. package/src/types/config/index.ts +206 -7
  199. package/src/types/config/lists.ts +606 -565
  200. package/src/types/context.ts +592 -55
  201. package/src/types/next-fields.ts +31 -31
  202. package/src/types/type-info.ts +38 -38
  203. package/src/types/type-tests.ts +21 -21
@@ -1,19 +1,112 @@
1
- import { i as idFieldType, m as merge } from './resolve-hooks-6813a045.esm.js';
2
- import { t as timestamp, r as relationship } from './index-15c8f81e.esm.js';
1
+ import { i as idFieldType, m as merge } from './resolve-hooks-9e676794.esm.js';
2
+ export { p as previewDelete } from './resolve-hooks-9e676794.esm.js';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { t as timestamp, a as text, j as json } from './index-f1703b7b.esm.js';
5
+ import { r as relationship, s as select, i as integer } from './index-5d8b0b4e.esm.js';
3
6
  export { g, a as gWithContext, g as graphql } from './next-fields-9bf04ed8.esm.js';
4
7
  import 'pluralize';
5
8
  import '@graphql-ts/schema';
6
9
  import '@graphql-ts/extend';
7
10
  import 'graphql';
8
- import './non-null-graphql-a84ed64d.esm.js';
11
+ import 'node:async_hooks';
12
+ import './non-null-graphql-8c5feaae.esm.js';
9
13
  import 'node:path';
10
14
  import './admin-meta-14c60fec.esm.js';
15
+ import './utils-0cc426c8.esm.js';
11
16
  import 'decimal.js';
12
17
  import 'graphql-upload/GraphQLUpload.js';
13
18
 
19
+ function normalise(spec) {
20
+ if (Array.isArray(spec)) {
21
+ return Object.fromEntries(spec.map(name => [name, {}]));
22
+ }
23
+ return spec;
24
+ }
25
+
26
+ /**
27
+ * Validate `env` (default `process.env`) against a spec. Applies `default`s to the env
28
+ * object for unset variables, then throws one aggregated error listing every missing or
29
+ * malformed variable. Called automatically when the config declares `env`, and exported
30
+ * for standalone use.
31
+ */
32
+ function validateEnv(spec, env = process.env) {
33
+ const problems = [];
34
+ for (const [name, requirement] of Object.entries(normalise(spec))) {
35
+ let value = env[name];
36
+ if ((value === undefined || value === '') && requirement.default !== undefined) {
37
+ env[name] = requirement.default;
38
+ value = requirement.default;
39
+ }
40
+ const describe = requirement.description ? ` — ${requirement.description}` : '';
41
+ if (value === undefined || value === '') {
42
+ if (requirement.required !== false) {
43
+ problems.push(` • ${name} is not set${describe}`);
44
+ }
45
+ continue;
46
+ }
47
+ if (requirement.pattern && !requirement.pattern.test(value)) {
48
+ problems.push(` • ${name} does not match ${requirement.pattern}${describe}`);
49
+ }
50
+ }
51
+ if (problems.length) {
52
+ throw new Error(`Environment validation failed (${problems.length} problem${problems.length === 1 ? '' : 's'}):\n` + problems.join('\n'));
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Fold `config.plugins` into the config: merge plugin collections (key conflicts throw),
58
+ * run each plugin's `extendConfig`, and chain plugin `onConnect`s before `db.onConnect`.
59
+ */
60
+ function applyPlugins(config) {
61
+ var _config$plugins;
62
+ const plugins = (_config$plugins = config.plugins) !== null && _config$plugins !== void 0 ? _config$plugins : [];
63
+ if (!plugins.length) return config;
64
+ const seen = new Set();
65
+ for (const plugin of plugins) {
66
+ if (!plugin.name) throw new Error('Every plugin must have a `name`');
67
+ if (seen.has(plugin.name)) throw new Error(`Duplicate plugin name "${plugin.name}"`);
68
+ seen.add(plugin.name);
69
+ }
70
+ let next = {
71
+ ...config,
72
+ collections: {
73
+ ...config.collections
74
+ }
75
+ };
76
+ for (const plugin of plugins) {
77
+ for (const [key, collection] of Object.entries((_plugin$collections = plugin.collections) !== null && _plugin$collections !== void 0 ? _plugin$collections : {})) {
78
+ var _plugin$collections;
79
+ if (key in next.collections) {
80
+ throw new Error(`Plugin "${plugin.name}" adds the collection "${key}", but a collection with that key already exists`);
81
+ }
82
+ next.collections[key] = collection;
83
+ }
84
+ if (plugin.extendConfig) {
85
+ next = plugin.extendConfig(next);
86
+ if (!next) {
87
+ throw new Error(`Plugin "${plugin.name}".extendConfig must return the config`);
88
+ }
89
+ }
90
+ }
91
+ const onConnects = plugins.flatMap(plugin => plugin.onConnect ? [plugin.onConnect] : []);
92
+ if (onConnects.length) {
93
+ const userOnConnect = next.db.onConnect;
94
+ next = {
95
+ ...next,
96
+ db: {
97
+ ...next.db,
98
+ onConnect: async context => {
99
+ for (const fn of onConnects) await fn(context);
100
+ await (userOnConnect === null || userOnConnect === void 0 ? void 0 : userOnConnect(context));
101
+ }
102
+ }
103
+ };
104
+ }
105
+ return next;
106
+ }
14
107
  function listsWithDefaults(config, defaultIdField) {
15
108
  // some error checking
16
- for (const [listKey, list] of Object.entries(config.lists)) {
109
+ for (const [listKey, list] of Object.entries(config.collections)) {
17
110
  var _list$db;
18
111
  if (list.fields.id) {
19
112
  throw new Error(`"fields.id" is reserved by Nixxie, use "db.idField" for the "${listKey}" list`);
@@ -23,7 +116,7 @@ function listsWithDefaults(config, defaultIdField) {
23
116
  }
24
117
  }
25
118
  return Object.fromEntries([...function* () {
26
- for (const [listKey, list] of Object.entries(config.lists)) {
119
+ for (const [listKey, list] of Object.entries(config.collections)) {
27
120
  var _list$db$idField, _list$db2;
28
121
  yield [listKey, {
29
122
  listKey,
@@ -32,6 +125,7 @@ function listsWithDefaults(config, defaultIdField) {
32
125
  defaultIsOrderable: true,
33
126
  // TODO: move to access control?
34
127
  isSingleton: false,
128
+ cascade: [],
35
129
  ...list,
36
130
  db: {
37
131
  ...list.db
@@ -75,8 +169,10 @@ async function noop() {}
75
169
  function identity(x) {
76
170
  return x;
77
171
  }
78
- function config(config) {
79
- var _config$db, _config$db$url, _config$db$idField, _config$server, _config$server2, _config$server$cors, _config$server3, _config$types$path, _config$types, _config$db$shadowData, _config$db2, _config$db$extendPris, _config$db3, _config$db$extendPris2, _config$db4, _config$db$onConnect, _config$db$prismaClie, _config$db5, _config$db$prismaSche, _config$db6, _config$db$idField2, _config$db7, _config$db$enableLogg, _config$graphql$path, _config$graphql, _config$graphql$playg, _config$graphql2, _config$graphql$schem, _config$graphql3, _config$graphql$exten, _config$graphql4, _config$server$maxFil, _config$server4, _config$server$extend, _config$server5, _config$server$extend2, _config$server6, _config$telemetry, _config$ui$basePath, _config$ui, _config$ui$isAccessAl, _config$ui2, _config$ui$isDisabled, _config$ui3, _config$ui$getAdditio, _config$ui4, _config$ui$pageMiddle, _config$ui5, _config$ui$publicPage, _config$ui6;
172
+ function buildConfig(config) {
173
+ var _config$db, _config$db$url, _config$db$idField, _config$server, _config$server2, _config$server$cors, _config$server3, _config, _config2, _config$types$path, _config$types, _config$db$shadowData, _config$db2, _config$db$extendPris, _config$db3, _config$db$extendPris2, _config$db4, _config$db$onConnect, _config$db$prismaClie, _config$db5, _config$db$prismaSche, _config$db6, _config$db$idField2, _config$db7, _config$db$enableLogg, _config$graphql$path, _config$graphql, _config$graphql$playg, _config$graphql2, _config$graphql$schem, _config$graphql3, _config$graphql$exten, _config$graphql4, _config$server$maxFil, _config$server4, _config$server$extend, _config$server5, _config$server$extend2, _config$server6, _config$telemetry, _config$ui$basePath, _config$ui, _config$ui$isAccessAl, _config$ui2, _config$ui$isDisabled, _config$ui3, _config$ui$getAdditio, _config$ui4, _config$ui$pageMiddle, _config$ui5, _config$ui$publicPage, _config$ui6;
174
+ if (config.env) validateEnv(config.env);
175
+ config = applyPlugins(config);
80
176
  if (!['postgresql', 'sqlite', 'mysql'].includes(config.db.provider)) {
81
177
  throw new TypeError(`"db.provider" only supports "sqlite", "postgresql" or "mysql"`);
82
178
  }
@@ -93,10 +189,10 @@ function config(config) {
93
189
  const httpOptions = {
94
190
  port: 3000
95
191
  };
96
- if (config !== null && config !== void 0 && config.server && 'port' in config.server) {
192
+ if ((_config = config) !== null && _config !== void 0 && _config.server && 'port' in config.server) {
97
193
  httpOptions.port = config.server.port;
98
194
  }
99
- if (config !== null && config !== void 0 && config.server && 'options' in config.server && config.server.options) {
195
+ if ((_config2 = config) !== null && _config2 !== void 0 && _config2.server && 'options' in config.server && config.server.options) {
100
196
  Object.assign(httpOptions, config.server.options);
101
197
  }
102
198
  return {
@@ -144,6 +240,12 @@ function config(config) {
144
240
  search: config.search,
145
241
  notifications: config.notifications,
146
242
  ai: config.ai,
243
+ aiRag: config.aiRag,
244
+ versioning: config.versioning,
245
+ workflow: config.workflow,
246
+ apiKeys: config.apiKeys,
247
+ logger: config.logger,
248
+ backup: config.backup,
147
249
  telemetry: (_config$telemetry = config.telemetry) !== null && _config$telemetry !== void 0 ? _config$telemetry : true,
148
250
  ui: {
149
251
  ...config.ui,
@@ -157,7 +259,7 @@ function config(config) {
157
259
  };
158
260
  }
159
261
  let i = 0;
160
- function group(config) {
262
+ function fieldGroup(config) {
161
263
  var _config$description;
162
264
  const keys = Object.keys(config.fields);
163
265
  if (keys.some(key => key.startsWith('__group'))) {
@@ -173,18 +275,408 @@ function group(config) {
173
275
  ...config.fields
174
276
  }; // TODO: FIXME, see initialise-lists.ts:getListsWithInitialisedFields
175
277
  }
176
- function list(listConfig) {
278
+ function collection(listConfig) {
177
279
  return {
178
280
  ...listConfig
179
281
  };
180
282
  }
181
- function action(action) {
283
+ function createAction(action) {
182
284
  return {
183
285
  ...action,
184
286
  ___defineActionsWithActionFunction: true
185
287
  };
186
288
  }
187
289
 
290
+ /**
291
+ * Declarative collection features compiled into hooks + access-control filters by
292
+ * `defineCollection()`: computed fields, constraints, default filters, state machines,
293
+ * policies, events, search indexing and version snapshots.
294
+ *
295
+ * Everything here builds on the public hook/access surface — no resolver internals —
296
+ * so the features compose with mixins and user hooks via the same merge pipeline.
297
+ */
298
+
299
+ // ── Option types ──
300
+
301
+ /**
302
+ * Persisted derived fields, recalculated on every create/update after all other
303
+ * `resolveInput` hooks have run. The target keys must be real fields on the collection
304
+ * (unlike `virtual()` fields, computed values are stored — so they can be filtered and
305
+ * sorted). `merged` is the item's prospective state (existing values + this write).
306
+ *
307
+ * @example
308
+ * computed: { total: ({ merged }) => merged.price * merged.quantity }
309
+ */
310
+
311
+ /**
312
+ * Data-integrity rules checked before every create/update.
313
+ *
314
+ * - `uniqueTogether`: compound uniqueness over scalar fields (e.g. `[['tenant', 'slug']]`).
315
+ * Checked with a query, so add a DB index for hot paths.
316
+ * - `checks`: named cross-field rules; return an error message to reject the write.
317
+ *
318
+ * @example
319
+ * constraints: {
320
+ * uniqueTogether: [['translationKey', 'locale']],
321
+ * checks: { dates: ({ merged }) => merged.endDate <= merged.startDate ? 'endDate must be after startDate' : undefined },
322
+ * }
323
+ */
324
+
325
+ /**
326
+ * A where-filter automatically ANDed into every query/update/delete — including the
327
+ * Admin UI, which respects access filters. This is what makes `withSoftDelete()` real:
328
+ *
329
+ * @example
330
+ * defaultFilter: { deletedAt: null }
331
+ * @example
332
+ * defaultFilter: ({ session }) => session?.data?.isAdmin ? true : { status: { equals: 'published' } }
333
+ */
334
+
335
+ /**
336
+ * Enforce legal status transitions on a select field at the mutation layer.
337
+ *
338
+ * @example
339
+ * stateMachine: {
340
+ * field: 'status',
341
+ * transitions: { draft: ['review'], review: ['published', 'draft'], published: ['archived'] },
342
+ * guards: { 'review->published': ({ session }) => session?.data?.role === 'editor' || 'Only editors can publish' },
343
+ * }
344
+ */
345
+
346
+ /** One row-level access rule: the first rule whose `when` matches decides the filter. */
347
+
348
+ /**
349
+ * Declarative row-level access: per operation, rules are evaluated in order and the
350
+ * first matching `when` supplies the filter; no match means no access. ANDed with any
351
+ * explicit `access.filter` you also configure. Pairs naturally with @nixxie-cms/rbac.
352
+ *
353
+ * @example
354
+ * policies: {
355
+ * query: [
356
+ * { when: s => s?.data?.role === 'admin', filter: true },
357
+ * { when: s => !!s, filter: s => ({ author: { id: { equals: s.itemId } } }) },
358
+ * ],
359
+ * }
360
+ */
361
+
362
+ /**
363
+ * Emit lifecycle events through `context.services.webhooks` after every write:
364
+ * `<prefix>.created` / `.updated` / `.deleted` (prefix defaults to the list key).
365
+ * No-op when the webhooks service is not configured.
366
+ */
367
+
368
+ /**
369
+ * Keep a search index in sync through `context.services.search`: documents are indexed
370
+ * after create/update and removed after delete. No-op when search is not configured.
371
+ */
372
+
373
+ /**
374
+ * Snapshot every create/update into `context.services.versioning` (resource = list key).
375
+ * No-op when versioning is not configured.
376
+ */
377
+
378
+ // ── Compilation to hooks ──
379
+
380
+ const mergedView = args => {
381
+ var _args$item, _args$resolvedData;
382
+ return {
383
+ ...((_args$item = args.item) !== null && _args$item !== void 0 ? _args$item : {}),
384
+ ...((_args$resolvedData = args.resolvedData) !== null && _args$resolvedData !== void 0 ? _args$resolvedData : {})
385
+ };
386
+ };
387
+ function computedHooks(computed) {
388
+ return {
389
+ resolveInput: async args => {
390
+ const out = {
391
+ ...args.resolvedData
392
+ };
393
+ for (const [field, fn] of Object.entries(computed)) {
394
+ var _args$item2;
395
+ out[field] = await fn({
396
+ operation: args.operation,
397
+ resolvedData: out,
398
+ merged: {
399
+ ...((_args$item2 = args.item) !== null && _args$item2 !== void 0 ? _args$item2 : {}),
400
+ ...out
401
+ },
402
+ item: args.item,
403
+ context: args.context
404
+ });
405
+ }
406
+ return out;
407
+ }
408
+ };
409
+ }
410
+ function constraintsHooks(constraints) {
411
+ return {
412
+ validate: async args => {
413
+ const {
414
+ operation,
415
+ addValidationError
416
+ } = args;
417
+ if (operation === 'delete') return;
418
+ const merged = mergedView(args);
419
+ for (const tuple of (_constraints$uniqueTo = constraints.uniqueTogether) !== null && _constraints$uniqueTo !== void 0 ? _constraints$uniqueTo : []) {
420
+ var _constraints$uniqueTo;
421
+ const values = tuple.map(field => merged[field]);
422
+ // Incomplete tuples (a NULL member) are not checked — same semantics as SQL unique indexes.
423
+ if (values.some(value => value === undefined || value === null)) continue;
424
+ const where = {};
425
+ tuple.forEach((field, i) => where[field] = {
426
+ equals: values[i]
427
+ });
428
+ try {
429
+ const clashes = await args.context.sudo().db[args.listKey].findMany({
430
+ where,
431
+ take: 2
432
+ });
433
+ const other = clashes.find(clash => !args.item || String(clash.id) !== String(args.item.id));
434
+ if (other) {
435
+ addValidationError(`Another item already exists with the same ${tuple.join(' + ')}`);
436
+ }
437
+ } catch (err) {
438
+ // A malformed tuple (unknown/non-scalar field) should fail loudly, not silently pass.
439
+ addValidationError(`uniqueTogether check for (${tuple.join(', ')}) failed: ${err instanceof Error ? err.message : err}`);
440
+ }
441
+ }
442
+ for (const check of Object.values((_constraints$checks = constraints.checks) !== null && _constraints$checks !== void 0 ? _constraints$checks : {})) {
443
+ var _constraints$checks;
444
+ const message = await check({
445
+ operation: args.operation,
446
+ resolvedData: args.resolvedData,
447
+ merged,
448
+ item: args.item,
449
+ context: args.context
450
+ });
451
+ if (typeof message === 'string' && message.length > 0) addValidationError(message);
452
+ }
453
+ }
454
+ };
455
+ }
456
+ function stateMachineHooks(machine) {
457
+ const {
458
+ field,
459
+ transitions,
460
+ guards = {}
461
+ } = machine;
462
+ const initial = machine.initial === undefined ? undefined : Array.isArray(machine.initial) ? machine.initial : [machine.initial];
463
+ return {
464
+ validate: async args => {
465
+ var _transitions$previous;
466
+ const {
467
+ operation,
468
+ resolvedData,
469
+ item,
470
+ addValidationError
471
+ } = args;
472
+ if (operation === 'delete') return;
473
+ const next = resolvedData === null || resolvedData === void 0 ? void 0 : resolvedData[field];
474
+ if (next === undefined) return;
475
+ if (operation === 'create') {
476
+ if (initial && next != null && !initial.includes(next)) {
477
+ addValidationError(`"${field}" cannot start as "${next}" (allowed: ${initial.join(', ')})`);
478
+ }
479
+ return;
480
+ }
481
+ const previous = item === null || item === void 0 ? void 0 : item[field];
482
+ if (next === previous) return;
483
+ const allowed = (_transitions$previous = transitions[previous]) !== null && _transitions$previous !== void 0 ? _transitions$previous : [];
484
+ if (!allowed.includes(next)) {
485
+ addValidationError(`"${field}" cannot change from "${previous}" to "${next}"` + (allowed.length ? ` (allowed: ${allowed.join(', ')})` : ' (no transitions from this state)'));
486
+ return;
487
+ }
488
+ const guard = guards[`${previous}->${next}`];
489
+ if (guard) {
490
+ var _args$context;
491
+ const verdict = await guard({
492
+ item,
493
+ resolvedData,
494
+ context: args.context,
495
+ session: (_args$context = args.context) === null || _args$context === void 0 ? void 0 : _args$context.session
496
+ });
497
+ if (verdict !== true) {
498
+ addValidationError(typeof verdict === 'string' ? verdict : `"${field}" transition "${previous}" → "${next}" was blocked`);
499
+ }
500
+ }
501
+ }
502
+ };
503
+ }
504
+ const pastTense = {
505
+ create: 'created',
506
+ update: 'updated',
507
+ delete: 'deleted'
508
+ };
509
+ function eventsHooks(events) {
510
+ var _options$operations;
511
+ const options = events === true ? {} : events;
512
+ const operations = (_options$operations = options.operations) !== null && _options$operations !== void 0 ? _options$operations : ['create', 'update', 'delete'];
513
+ return {
514
+ afterOperation: async args => {
515
+ var _args$context2;
516
+ if (!operations.includes(args.operation)) return;
517
+ const webhooks = (_args$context2 = args.context) === null || _args$context2 === void 0 || (_args$context2 = _args$context2.services) === null || _args$context2 === void 0 ? void 0 : _args$context2.webhooks;
518
+ if (!webhooks) return;
519
+ try {
520
+ var _args$item3, _options$prefix;
521
+ const subject = (_args$item3 = args.item) !== null && _args$item3 !== void 0 ? _args$item3 : args.originalItem;
522
+ const event = `${(_options$prefix = options.prefix) !== null && _options$prefix !== void 0 ? _options$prefix : args.listKey}.${pastTense[args.operation]}`;
523
+ const payload = options.payload ? await options.payload(args) : {
524
+ listKey: args.listKey,
525
+ id: (subject === null || subject === void 0 ? void 0 : subject.id) != null ? String(subject.id) : undefined,
526
+ item: subject
527
+ };
528
+ await webhooks.trigger(event, payload);
529
+ } catch (err) {
530
+ console.error(`[nixxie] events: failed to emit for ${args.listKey}:`, err);
531
+ }
532
+ }
533
+ };
534
+ }
535
+
536
+ /** Copy the indexable scalar values off an item (used when `fields` is not specified). */
537
+ function scalarDocument(item) {
538
+ const doc = {};
539
+ for (const [key, value] of Object.entries(item)) {
540
+ if (key.startsWith('__')) continue;
541
+ if (value === null) doc[key] = null;else if (value instanceof Date) doc[key] = value.toISOString();else if (['string', 'number', 'boolean'].includes(typeof value)) doc[key] = value;
542
+ }
543
+ return doc;
544
+ }
545
+ function searchableHooks(searchable) {
546
+ const options = searchable === true ? {} : searchable;
547
+ return {
548
+ afterOperation: async args => {
549
+ var _args$context3, _options$index;
550
+ const search = (_args$context3 = args.context) === null || _args$context3 === void 0 || (_args$context3 = _args$context3.services) === null || _args$context3 === void 0 ? void 0 : _args$context3.search;
551
+ if (!search) return;
552
+ const indexName = (_options$index = options.index) !== null && _options$index !== void 0 ? _options$index : String(args.listKey).toLowerCase();
553
+ try {
554
+ if (args.operation === 'delete') {
555
+ await search.remove(indexName, String(args.originalItem.id));
556
+ return;
557
+ }
558
+ const item = args.item;
559
+ const picked = options.fields ? Object.fromEntries(options.fields.map(field => [field, item[field] instanceof Date ? item[field].toISOString() : item[field]])) : scalarDocument(item);
560
+ await search.index(indexName, {
561
+ ...picked,
562
+ id: String(item.id)
563
+ });
564
+ } catch (err) {
565
+ console.error(`[nixxie] searchable: failed to sync index "${indexName}":`, err);
566
+ }
567
+ }
568
+ };
569
+ }
570
+ function versionedHooks(versioned) {
571
+ const options = versioned === true ? {} : versioned;
572
+ return {
573
+ afterOperation: async args => {
574
+ var _args$context4;
575
+ if (args.operation === 'delete') return;
576
+ const versioning = (_args$context4 = args.context) === null || _args$context4 === void 0 || (_args$context4 = _args$context4.services) === null || _args$context4 === void 0 ? void 0 : _args$context4.versioning;
577
+ if (!versioning) return;
578
+ try {
579
+ var _args$context5, _options$label;
580
+ const session = (_args$context5 = args.context) === null || _args$context5 === void 0 ? void 0 : _args$context5.session;
581
+ await versioning.snapshot({
582
+ resource: args.listKey,
583
+ resourceId: String(args.item.id),
584
+ data: scalarSafe(args.item),
585
+ label: (_options$label = options.label) === null || _options$label === void 0 ? void 0 : _options$label.call(options, args),
586
+ actor: (session === null || session === void 0 ? void 0 : session.itemId) != null ? {
587
+ id: String(session.itemId)
588
+ } : undefined
589
+ });
590
+ } catch (err) {
591
+ console.error(`[nixxie] versioned: failed to snapshot ${args.listKey}:`, err);
592
+ }
593
+ }
594
+ };
595
+ }
596
+
597
+ /** Snapshot-safe clone: keeps JSON-representable values, ISO-stringifies dates. */
598
+ function scalarSafe(item) {
599
+ const out = {};
600
+ for (const [key, value] of Object.entries(item)) {
601
+ if (value instanceof Date) out[key] = value.toISOString();else if (typeof value === 'bigint') out[key] = value.toString();else if (typeof value !== 'function' && typeof value !== 'symbol') out[key] = value;
602
+ }
603
+ return out;
604
+ }
605
+
606
+ /** Compile the declarative features into hook fragments, in a deliberate order. */
607
+ function compileFeatureHooks(features) {
608
+ const hooks = [];
609
+ if (features.computed) hooks.push(computedHooks(features.computed));
610
+ if (features.constraints) hooks.push(constraintsHooks(features.constraints));
611
+ if (features.stateMachine) hooks.push(stateMachineHooks(features.stateMachine));
612
+ if (features.events) hooks.push(eventsHooks(features.events));
613
+ if (features.searchable) hooks.push(searchableHooks(features.searchable));
614
+ if (features.versioned) hooks.push(versionedHooks(features.versioned));
615
+ return hooks;
616
+ }
617
+
618
+ // ── Compilation to access filters ──
619
+
620
+ /** AND two access filters; `true`/missing means unrestricted, `false` wins outright. */
621
+ function andFilters(a, b) {
622
+ if (a === undefined) return b;
623
+ return async args => {
624
+ const ra = typeof a === 'function' ? await a(args) : a;
625
+ const rb = typeof b === 'function' ? await b(args) : b;
626
+ if (ra === false || rb === false) return false;
627
+ if (ra === true) return rb;
628
+ if (rb === true) return ra;
629
+ return {
630
+ AND: [ra, rb]
631
+ };
632
+ };
633
+ }
634
+ function policyFilter(rules) {
635
+ return ({
636
+ session
637
+ }) => {
638
+ for (const rule of rules) {
639
+ if (!rule.when(session)) continue;
640
+ return typeof rule.filter === 'function' ? rule.filter(session) : rule.filter;
641
+ }
642
+ return false;
643
+ };
644
+ }
645
+
646
+ /**
647
+ * Merge `defaultFilter` and `policies` into an access-control object. Existing filters
648
+ * are preserved and ANDed with the feature filters.
649
+ */
650
+ function applyAccessFeatures(access, features) {
651
+ var _access$filter;
652
+ const {
653
+ defaultFilter,
654
+ policies
655
+ } = features;
656
+ if (!defaultFilter && !policies) return access;
657
+ const filter = {
658
+ ...((_access$filter = access === null || access === void 0 ? void 0 : access.filter) !== null && _access$filter !== void 0 ? _access$filter : {})
659
+ };
660
+ for (const operation of ['query', 'update', 'delete']) {
661
+ let merged = filter[operation];
662
+ if (defaultFilter) {
663
+ merged = andFilters(merged, typeof defaultFilter === 'function' ? args => defaultFilter({
664
+ session: args.session,
665
+ context: args.context
666
+ }) : defaultFilter);
667
+ }
668
+ const rules = policies === null || policies === void 0 ? void 0 : policies[operation];
669
+ if (rules !== null && rules !== void 0 && rules.length) {
670
+ merged = andFilters(merged, policyFilter(rules));
671
+ }
672
+ if (merged !== undefined) filter[operation] = merged;
673
+ }
674
+ return {
675
+ ...(access !== null && access !== void 0 ? access : {}),
676
+ filter
677
+ };
678
+ }
679
+
188
680
  // ================================================================
189
681
  // Validators — use inside field `validation` config
190
682
  // ================================================================
@@ -236,6 +728,72 @@ const validators = {
236
728
  }
237
729
  };
238
730
 
731
+ // ================================================================
732
+ // Access presets — readable shorthands for common access patterns
733
+ // ================================================================
734
+ //
735
+ // Spelling out an access-control object on every list gets repetitive. In
736
+ // practice 90% of lists fall into a handful of patterns, so we name them.
737
+ // Anything more bespoke can still drop down to a raw access object.
738
+
739
+ const isSignedIn = ({
740
+ session
741
+ }) => !!session;
742
+ const accessPresets = {
743
+ public: () => true,
744
+ authenticated: {
745
+ operation: {
746
+ query: isSignedIn,
747
+ create: isSignedIn,
748
+ update: isSignedIn,
749
+ delete: isSignedIn
750
+ }
751
+ },
752
+ publicReadAuthenticatedWrite: {
753
+ operation: {
754
+ query: () => true,
755
+ create: isSignedIn,
756
+ update: isSignedIn,
757
+ delete: isSignedIn
758
+ }
759
+ },
760
+ readOnly: {
761
+ operation: {
762
+ query: () => true,
763
+ create: () => false,
764
+ update: () => false,
765
+ delete: () => false
766
+ }
767
+ },
768
+ owner(field = 'user') {
769
+ const scopeToOwner = ({
770
+ session
771
+ }) => {
772
+ if (!(session !== null && session !== void 0 && session.itemId)) return false;
773
+ return {
774
+ [field]: {
775
+ id: {
776
+ equals: session.itemId
777
+ }
778
+ }
779
+ };
780
+ };
781
+ return {
782
+ operation: {
783
+ query: isSignedIn,
784
+ create: isSignedIn,
785
+ update: isSignedIn,
786
+ delete: isSignedIn
787
+ },
788
+ filter: {
789
+ query: scopeToOwner,
790
+ update: scopeToOwner,
791
+ delete: scopeToOwner
792
+ }
793
+ };
794
+ }
795
+ };
796
+
239
797
  // ================================================================
240
798
  // Mixin system
241
799
  // ================================================================
@@ -391,6 +949,760 @@ function withAudit(userListKey = 'User') {
391
949
  };
392
950
  }
393
951
 
952
+ /**
953
+ * Turn an arbitrary string into a URL-safe slug. Pure, dependency-free.
954
+ */
955
+ function slugify(input) {
956
+ return input.toString().normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip accents
957
+ .toLowerCase().trim().replace(/[^a-z0-9]+/g, '-') // non-alphanumerics → dashes
958
+ .replace(/^-+|-+$/g, ''); // trim leading/trailing dashes
959
+ }
960
+
961
+ /**
962
+ * Adds a URL-friendly `slug` field that is auto-generated from another field
963
+ * (e.g. `title`) when left blank, and normalised when provided.
964
+ *
965
+ * Solves a perennial papercut: there's no built-in slug field, so everyone
966
+ * re-implements the same `resolveInput` hook by hand.
967
+ *
968
+ * @example
969
+ * defineCollection({ mixins: [withSlug({ from: 'title' })], fields: { title: text() } })
970
+ */
971
+ function withSlug(opts = {
972
+ from: 'title'
973
+ }) {
974
+ var _opts$field, _opts$isIndexed;
975
+ const fieldKey = (_opts$field = opts.field) !== null && _opts$field !== void 0 ? _opts$field : 'slug';
976
+ const from = opts.from;
977
+ return {
978
+ fields: {
979
+ [fieldKey]: text({
980
+ isIndexed: (_opts$isIndexed = opts.isIndexed) !== null && _opts$isIndexed !== void 0 ? _opts$isIndexed : 'unique',
981
+ ui: {
982
+ description: `URL slug — auto-generated from "${from}" when left blank.`
983
+ }
984
+ })
985
+ },
986
+ hooks: {
987
+ resolveInput: ({
988
+ resolvedData,
989
+ inputData
990
+ }) => {
991
+ var _from;
992
+ const provided = resolvedData[fieldKey];
993
+ if (typeof provided === 'string' && provided.length > 0) {
994
+ return {
995
+ ...resolvedData,
996
+ [fieldKey]: slugify(provided)
997
+ };
998
+ }
999
+ const source = (_from = inputData === null || inputData === void 0 ? void 0 : inputData[from]) !== null && _from !== void 0 ? _from : resolvedData === null || resolvedData === void 0 ? void 0 : resolvedData[from];
1000
+ if (typeof source === 'string' && source.length > 0) {
1001
+ return {
1002
+ ...resolvedData,
1003
+ [fieldKey]: slugify(source)
1004
+ };
1005
+ }
1006
+ return resolvedData;
1007
+ }
1008
+ }
1009
+ };
1010
+ }
1011
+
1012
+ /**
1013
+ * Adds a draft → published → archived workflow: a `status` select plus a
1014
+ * `publishedAt` timestamp that is stamped automatically the first time an item
1015
+ * transitions to `published`.
1016
+ *
1017
+ * Publishing is otherwise left entirely to the developer; this bakes in the
1018
+ * common content lifecycle so a blog/news/docs list works out of the box.
1019
+ */
1020
+ function withPublishing(opts = {}) {
1021
+ var _opts$defaultStatus;
1022
+ const defaultStatus = (_opts$defaultStatus = opts.defaultStatus) !== null && _opts$defaultStatus !== void 0 ? _opts$defaultStatus : 'draft';
1023
+ return {
1024
+ fields: {
1025
+ status: select({
1026
+ type: 'enum',
1027
+ options: [{
1028
+ label: 'Draft',
1029
+ value: 'draft'
1030
+ }, {
1031
+ label: 'Published',
1032
+ value: 'published'
1033
+ }, {
1034
+ label: 'Archived',
1035
+ value: 'archived'
1036
+ }],
1037
+ defaultValue: defaultStatus,
1038
+ ui: {
1039
+ displayMode: 'segmented-control'
1040
+ }
1041
+ }),
1042
+ publishedAt: timestamp({
1043
+ ui: {
1044
+ createView: {
1045
+ fieldMode: 'hidden'
1046
+ },
1047
+ itemView: {
1048
+ fieldMode: 'read'
1049
+ },
1050
+ listView: {
1051
+ fieldMode: 'read'
1052
+ }
1053
+ }
1054
+ })
1055
+ },
1056
+ hooks: {
1057
+ resolveInput: ({
1058
+ resolvedData,
1059
+ item
1060
+ }) => {
1061
+ var _status;
1062
+ const nextStatus = (_status = resolvedData.status) !== null && _status !== void 0 ? _status : item === null || item === void 0 ? void 0 : item.status;
1063
+ const alreadyPublished = item === null || item === void 0 ? void 0 : item.publishedAt;
1064
+ const incomingPublishedAt = resolvedData.publishedAt;
1065
+ if (nextStatus === 'published' && !alreadyPublished && incomingPublishedAt == null) {
1066
+ return {
1067
+ ...resolvedData,
1068
+ publishedAt: new Date().toISOString()
1069
+ };
1070
+ }
1071
+ return resolvedData;
1072
+ }
1073
+ }
1074
+ };
1075
+ }
1076
+
1077
+ /**
1078
+ * Adds standard SEO metadata fields, grouped together in the Admin UI.
1079
+ * Drop-in for any content list that needs search/social previews.
1080
+ */
1081
+ function withSEO() {
1082
+ return {
1083
+ fields: {
1084
+ metaTitle: text({
1085
+ ui: {
1086
+ description: 'Title used by search engines and social previews.'
1087
+ }
1088
+ }),
1089
+ metaDescription: text({
1090
+ ui: {
1091
+ displayMode: 'textarea',
1092
+ description: 'Short summary (~155 characters).'
1093
+ }
1094
+ }),
1095
+ ogImage: text({
1096
+ ui: {
1097
+ description: 'Absolute URL to the social sharing image.'
1098
+ }
1099
+ })
1100
+ }
1101
+ };
1102
+ }
1103
+
1104
+ /**
1105
+ * Adds an integer `order` field for manual sorting/drag-ordering of items.
1106
+ */
1107
+ function withSortable(opts = {}) {
1108
+ var _opts$field2;
1109
+ const fieldKey = (_opts$field2 = opts.field) !== null && _opts$field2 !== void 0 ? _opts$field2 : 'order';
1110
+ return {
1111
+ fields: {
1112
+ [fieldKey]: integer({
1113
+ defaultValue: 0,
1114
+ ui: {
1115
+ description: 'Manual sort position (lower comes first).'
1116
+ }
1117
+ })
1118
+ }
1119
+ };
1120
+ }
1121
+
1122
+ /**
1123
+ * Adds self-referential `parent` / `children` relationships for tree-shaped content
1124
+ * (categories, menus, pages), with cycle prevention: an item can never be moved under
1125
+ * one of its own descendants.
1126
+ *
1127
+ * @param listKey - The key of the collection this mixin is applied to (needed for the
1128
+ * self-referencing relationship and ancestor checks).
1129
+ *
1130
+ * @example
1131
+ * defineCollection({ mixins: [withTreeStructure('Category')], fields: { name: text() } })
1132
+ */
1133
+ function withTreeStructure(listKey, opts = {}) {
1134
+ var _opts$parentField, _opts$childrenField;
1135
+ const parentField = (_opts$parentField = opts.parentField) !== null && _opts$parentField !== void 0 ? _opts$parentField : 'parent';
1136
+ const childrenField = (_opts$childrenField = opts.childrenField) !== null && _opts$childrenField !== void 0 ? _opts$childrenField : 'children';
1137
+ return {
1138
+ fields: {
1139
+ [parentField]: relationship({
1140
+ ref: `${listKey}.${childrenField}`,
1141
+ ui: {
1142
+ description: 'Parent item in the tree (leave empty for a root item).'
1143
+ }
1144
+ }),
1145
+ [childrenField]: relationship({
1146
+ ref: `${listKey}.${parentField}`,
1147
+ many: true,
1148
+ ui: {
1149
+ displayMode: 'count',
1150
+ itemView: {
1151
+ fieldMode: 'read'
1152
+ }
1153
+ }
1154
+ })
1155
+ },
1156
+ hooks: {
1157
+ validate: async args => {
1158
+ var _parentOp$connect;
1159
+ const {
1160
+ operation,
1161
+ resolvedData,
1162
+ item,
1163
+ context,
1164
+ addValidationError
1165
+ } = args;
1166
+ if (operation !== 'update') return;
1167
+ const parentOp = resolvedData === null || resolvedData === void 0 ? void 0 : resolvedData[parentField];
1168
+ const newParentId = parentOp === null || parentOp === void 0 || (_parentOp$connect = parentOp.connect) === null || _parentOp$connect === void 0 ? void 0 : _parentOp$connect.id;
1169
+ if (newParentId == null) return;
1170
+ if (String(newParentId) === String(item.id)) {
1171
+ addValidationError(`"${parentField}" cannot point at the item itself`);
1172
+ return;
1173
+ }
1174
+ // Walk up from the new parent — finding the item means we'd create a cycle.
1175
+ const sudo = context.sudo();
1176
+ let cursor = newParentId;
1177
+ for (let depth = 0; depth < 100 && cursor != null; depth++) {
1178
+ var _ancestor$parentField, _ancestor$parentField2;
1179
+ const ancestor = await sudo.query[listKey].findOne({
1180
+ where: {
1181
+ id: String(cursor)
1182
+ },
1183
+ query: `id ${parentField} { id }`
1184
+ });
1185
+ if (!ancestor) return;
1186
+ cursor = (_ancestor$parentField = (_ancestor$parentField2 = ancestor[parentField]) === null || _ancestor$parentField2 === void 0 ? void 0 : _ancestor$parentField2.id) !== null && _ancestor$parentField !== void 0 ? _ancestor$parentField : null;
1187
+ if (cursor != null && String(cursor) === String(item.id)) {
1188
+ addValidationError(`"${parentField}" would create a cycle — the new parent is a descendant of this item`);
1189
+ return;
1190
+ }
1191
+ }
1192
+ }
1193
+ }
1194
+ };
1195
+ }
1196
+
1197
+ /**
1198
+ * Multi-tenancy: adds a required `tenant` relationship that is auto-assigned from the
1199
+ * session on create. Combine with `tenantAccessFilter()` in the collection's
1200
+ * `access.filter` rules to scope every query/update/delete to the session's tenant.
1201
+ *
1202
+ * @example
1203
+ * defineCollection({
1204
+ * mixins: [withTenant({ ref: 'Tenant' })],
1205
+ * access: {
1206
+ * filter: {
1207
+ * query: tenantAccessFilter(),
1208
+ * update: tenantAccessFilter(),
1209
+ * delete: tenantAccessFilter(),
1210
+ * },
1211
+ * },
1212
+ * fields: { ... },
1213
+ * })
1214
+ */
1215
+ function withTenant(opts = {}) {
1216
+ var _opts$ref, _opts$field3, _opts$getTenantId;
1217
+ const ref = (_opts$ref = opts.ref) !== null && _opts$ref !== void 0 ? _opts$ref : 'Tenant';
1218
+ const field = (_opts$field3 = opts.field) !== null && _opts$field3 !== void 0 ? _opts$field3 : 'tenant';
1219
+ const getTenantId = (_opts$getTenantId = opts.getTenantId) !== null && _opts$getTenantId !== void 0 ? _opts$getTenantId : defaultGetTenantId;
1220
+ return {
1221
+ fields: {
1222
+ [field]: relationship({
1223
+ ref,
1224
+ ui: {
1225
+ createView: {
1226
+ fieldMode: 'hidden'
1227
+ },
1228
+ itemView: {
1229
+ fieldMode: 'read'
1230
+ }
1231
+ }
1232
+ })
1233
+ },
1234
+ hooks: {
1235
+ resolveInput: ({
1236
+ operation,
1237
+ resolvedData,
1238
+ context
1239
+ }) => {
1240
+ if (operation !== 'create') return resolvedData;
1241
+ if (resolvedData[field]) return resolvedData;
1242
+ const tenantId = getTenantId(context.session);
1243
+ if (!tenantId) return resolvedData;
1244
+ return {
1245
+ ...resolvedData,
1246
+ [field]: {
1247
+ connect: {
1248
+ id: tenantId
1249
+ }
1250
+ }
1251
+ };
1252
+ },
1253
+ validate: async args => {
1254
+ const {
1255
+ operation,
1256
+ resolvedData,
1257
+ addValidationError
1258
+ } = args;
1259
+ if (opts.optional || operation !== 'create') return;
1260
+ if (!(resolvedData !== null && resolvedData !== void 0 && resolvedData[field])) {
1261
+ addValidationError(`"${field}" is required — no tenant found on the session`);
1262
+ }
1263
+ }
1264
+ }
1265
+ };
1266
+ }
1267
+ function defaultGetTenantId(session) {
1268
+ var _session$data$tenant$, _session$data;
1269
+ return (_session$data$tenant$ = session === null || session === void 0 || (_session$data = session.data) === null || _session$data === void 0 || (_session$data = _session$data.tenant) === null || _session$data === void 0 ? void 0 : _session$data.id) !== null && _session$data$tenant$ !== void 0 ? _session$data$tenant$ : session === null || session === void 0 ? void 0 : session.tenantId;
1270
+ }
1271
+
1272
+ /**
1273
+ * Access-control filter companion to `withTenant()`: limits matched items to the
1274
+ * session's tenant. Returns `false` (no access) when the session has no tenant.
1275
+ */
1276
+ function tenantAccessFilter(opts = {}) {
1277
+ var _opts$field4, _opts$getTenantId2;
1278
+ const field = (_opts$field4 = opts.field) !== null && _opts$field4 !== void 0 ? _opts$field4 : 'tenant';
1279
+ const getTenantId = (_opts$getTenantId2 = opts.getTenantId) !== null && _opts$getTenantId2 !== void 0 ? _opts$getTenantId2 : defaultGetTenantId;
1280
+ return ({
1281
+ session
1282
+ }) => {
1283
+ const tenantId = getTenantId(session);
1284
+ if (!tenantId) return false;
1285
+ return {
1286
+ [field]: {
1287
+ id: {
1288
+ equals: tenantId
1289
+ }
1290
+ }
1291
+ };
1292
+ };
1293
+ }
1294
+
1295
+ /**
1296
+ * Adds `publishAt` / `unpublishAt` timestamps for scheduled publishing. Composes with
1297
+ * `withPublishing()` (which provides the `status` field). The schedule is enacted by
1298
+ * `applyScheduledPublishing()` — run it from a cron job:
1299
+ *
1300
+ * @example
1301
+ * jobs: createJobs({ jobs: [{
1302
+ * name: 'scheduled-publishing',
1303
+ * schedule: '* * * * *',
1304
+ * handler: async () => { await applyScheduledPublishing(getContext(), 'Post') },
1305
+ * }]})
1306
+ */
1307
+ function withScheduledPublishing() {
1308
+ return {
1309
+ fields: {
1310
+ publishAt: timestamp({
1311
+ ui: {
1312
+ description: 'Automatically publish at this time (requires the scheduled-publishing job).'
1313
+ }
1314
+ }),
1315
+ unpublishAt: timestamp({
1316
+ ui: {
1317
+ description: 'Automatically archive at this time (requires the scheduled-publishing job).'
1318
+ }
1319
+ })
1320
+ }
1321
+ };
1322
+ }
1323
+
1324
+ /**
1325
+ * Enact `withScheduledPublishing()` schedules for one collection: publishes drafts whose
1326
+ * `publishAt` has passed and archives published items whose `unpublishAt` has passed.
1327
+ * Designed to be called from a cron job (see `withScheduledPublishing`).
1328
+ */
1329
+ async function applyScheduledPublishing(context, listKey, opts = {}) {
1330
+ var _opts$statusField, _opts$draftValue, _opts$publishedValue, _opts$archivedValue;
1331
+ const statusField = (_opts$statusField = opts.statusField) !== null && _opts$statusField !== void 0 ? _opts$statusField : 'status';
1332
+ const draftValue = (_opts$draftValue = opts.draftValue) !== null && _opts$draftValue !== void 0 ? _opts$draftValue : 'draft';
1333
+ const publishedValue = (_opts$publishedValue = opts.publishedValue) !== null && _opts$publishedValue !== void 0 ? _opts$publishedValue : 'published';
1334
+ const archivedValue = (_opts$archivedValue = opts.archivedValue) !== null && _opts$archivedValue !== void 0 ? _opts$archivedValue : 'archived';
1335
+ const sudo = context.sudo();
1336
+ const now = new Date();
1337
+ const toPublish = await sudo.db[listKey].findMany({
1338
+ where: {
1339
+ [statusField]: {
1340
+ equals: draftValue
1341
+ },
1342
+ publishAt: {
1343
+ lte: now
1344
+ }
1345
+ }
1346
+ });
1347
+ for (const item of toPublish) {
1348
+ await sudo.db[listKey].updateOne({
1349
+ where: {
1350
+ id: String(item.id)
1351
+ },
1352
+ data: {
1353
+ [statusField]: publishedValue
1354
+ }
1355
+ });
1356
+ }
1357
+ const toArchive = await sudo.db[listKey].findMany({
1358
+ where: {
1359
+ [statusField]: {
1360
+ equals: publishedValue
1361
+ },
1362
+ unpublishAt: {
1363
+ lte: now
1364
+ }
1365
+ }
1366
+ });
1367
+ for (const item of toArchive) {
1368
+ await sudo.db[listKey].updateOne({
1369
+ where: {
1370
+ id: String(item.id)
1371
+ },
1372
+ data: {
1373
+ [statusField]: archivedValue
1374
+ }
1375
+ });
1376
+ }
1377
+ return {
1378
+ published: toPublish.length,
1379
+ archived: toArchive.length
1380
+ };
1381
+ }
1382
+
1383
+ /**
1384
+ * i18n: adds a `locale` select plus a shared `translationKey` that groups an item with
1385
+ * its translations (auto-generated on create when blank). Query an item's variants with
1386
+ * `{ translationKey: { equals }, locale: { not: ... } }` — and once compound uniqueness
1387
+ * is configured, pair (`translationKey`, `locale`) should be unique.
1388
+ *
1389
+ * @example
1390
+ * defineCollection({ mixins: [withLocale({ locales: ['en', 'ur', 'ar'] })], fields: { ... } })
1391
+ */
1392
+ function withLocale(opts) {
1393
+ var _opts$locales, _opts$groupField, _opts$defaultLocale;
1394
+ if (!((_opts$locales = opts.locales) !== null && _opts$locales !== void 0 && _opts$locales.length)) throw new Error('withLocale requires at least one locale');
1395
+ const groupField = (_opts$groupField = opts.groupField) !== null && _opts$groupField !== void 0 ? _opts$groupField : 'translationKey';
1396
+ const defaultLocale = (_opts$defaultLocale = opts.defaultLocale) !== null && _opts$defaultLocale !== void 0 ? _opts$defaultLocale : opts.locales[0];
1397
+ return {
1398
+ fields: {
1399
+ locale: select({
1400
+ type: 'enum',
1401
+ options: opts.locales.map(locale => ({
1402
+ label: locale,
1403
+ value: locale
1404
+ })),
1405
+ defaultValue: defaultLocale,
1406
+ validation: {
1407
+ isRequired: true
1408
+ }
1409
+ }),
1410
+ [groupField]: text({
1411
+ isIndexed: true,
1412
+ ui: {
1413
+ description: 'Shared id linking this item with its translations.',
1414
+ createView: {
1415
+ fieldMode: 'hidden'
1416
+ },
1417
+ itemView: {
1418
+ fieldMode: 'read'
1419
+ }
1420
+ }
1421
+ })
1422
+ },
1423
+ hooks: {
1424
+ resolveInput: ({
1425
+ operation,
1426
+ resolvedData
1427
+ }) => {
1428
+ if (operation !== 'create') return resolvedData;
1429
+ if (resolvedData[groupField]) return resolvedData;
1430
+ return {
1431
+ ...resolvedData,
1432
+ [groupField]: randomBytes(8).toString('hex')
1433
+ };
1434
+ }
1435
+ }
1436
+ };
1437
+ }
1438
+
1439
+ /**
1440
+ * Editorial approval: adds an approval status + a `pendingChanges` buffer where proposed
1441
+ * edits wait for review instead of going live. Drive it with `submitForApproval()`,
1442
+ * `approveChanges()` and `rejectChanges()` from custom mutations or the Admin UI.
1443
+ */
1444
+ function withApproval() {
1445
+ return {
1446
+ fields: {
1447
+ approvalStatus: select({
1448
+ type: 'enum',
1449
+ options: [{
1450
+ label: 'None',
1451
+ value: 'none'
1452
+ }, {
1453
+ label: 'Pending',
1454
+ value: 'pending'
1455
+ }, {
1456
+ label: 'Approved',
1457
+ value: 'approved'
1458
+ }, {
1459
+ label: 'Rejected',
1460
+ value: 'rejected'
1461
+ }],
1462
+ defaultValue: 'none',
1463
+ ui: {
1464
+ createView: {
1465
+ fieldMode: 'hidden'
1466
+ },
1467
+ itemView: {
1468
+ fieldMode: 'read'
1469
+ }
1470
+ }
1471
+ }),
1472
+ pendingChanges: json({
1473
+ ui: {
1474
+ createView: {
1475
+ fieldMode: 'hidden'
1476
+ },
1477
+ itemView: {
1478
+ fieldMode: 'hidden'
1479
+ },
1480
+ listView: {
1481
+ fieldMode: 'hidden'
1482
+ }
1483
+ }
1484
+ }),
1485
+ approvalSubmittedAt: timestamp({
1486
+ ui: {
1487
+ createView: {
1488
+ fieldMode: 'hidden'
1489
+ },
1490
+ itemView: {
1491
+ fieldMode: 'read'
1492
+ }
1493
+ }
1494
+ }),
1495
+ approvalReviewedAt: timestamp({
1496
+ ui: {
1497
+ createView: {
1498
+ fieldMode: 'hidden'
1499
+ },
1500
+ itemView: {
1501
+ fieldMode: 'read'
1502
+ }
1503
+ }
1504
+ })
1505
+ }
1506
+ };
1507
+ }
1508
+
1509
+ /** Park proposed changes on an item for review (see `withApproval`). */
1510
+ async function submitForApproval(context, args) {
1511
+ await context.sudo().db[args.listKey].updateOne({
1512
+ where: {
1513
+ id: args.itemId
1514
+ },
1515
+ data: {
1516
+ pendingChanges: args.changes,
1517
+ approvalStatus: 'pending',
1518
+ approvalSubmittedAt: new Date().toISOString()
1519
+ }
1520
+ });
1521
+ }
1522
+
1523
+ /**
1524
+ * Apply an item's `pendingChanges` (running normal hooks/validation) and mark it
1525
+ * approved. The buffered changes must be a valid update input for the collection.
1526
+ */
1527
+ async function approveChanges(context, args) {
1528
+ const sudo = context.sudo();
1529
+ const item = await sudo.query[args.listKey].findOne({
1530
+ where: {
1531
+ id: args.itemId
1532
+ },
1533
+ query: 'id pendingChanges approvalStatus'
1534
+ });
1535
+ if (!(item !== null && item !== void 0 && item.pendingChanges)) throw new Error('approveChanges: the item has no pending changes');
1536
+ await sudo.db[args.listKey].updateOne({
1537
+ where: {
1538
+ id: args.itemId
1539
+ },
1540
+ data: {
1541
+ ...item.pendingChanges,
1542
+ pendingChanges: null,
1543
+ approvalStatus: 'approved',
1544
+ approvalReviewedAt: new Date().toISOString()
1545
+ }
1546
+ });
1547
+ }
1548
+
1549
+ /** Discard an item's `pendingChanges` and mark it rejected. */
1550
+ async function rejectChanges(context, args) {
1551
+ await context.sudo().db[args.listKey].updateOne({
1552
+ where: {
1553
+ id: args.itemId
1554
+ },
1555
+ data: {
1556
+ pendingChanges: null,
1557
+ approvalStatus: 'rejected',
1558
+ approvalReviewedAt: new Date().toISOString()
1559
+ }
1560
+ });
1561
+ }
1562
+
1563
+ /**
1564
+ * Adds an `expiresAt` timestamp for content with a shelf life. Pair with
1565
+ * `purgeExpired()` in a cron job to physically remove (or just count) expired items.
1566
+ */
1567
+ function withExpiry(opts = {}) {
1568
+ var _opts$field5;
1569
+ const field = (_opts$field5 = opts.field) !== null && _opts$field5 !== void 0 ? _opts$field5 : 'expiresAt';
1570
+ return {
1571
+ fields: {
1572
+ [field]: timestamp({
1573
+ ui: {
1574
+ description: 'After this time the item is considered expired.'
1575
+ }
1576
+ })
1577
+ }
1578
+ };
1579
+ }
1580
+
1581
+ /**
1582
+ * Delete every item whose expiry has passed (runs normal delete hooks). Returns the
1583
+ * number deleted. Designed for a cron job; see `withExpiry`.
1584
+ */
1585
+ async function purgeExpired(context, listKey, opts = {}) {
1586
+ var _opts$field6;
1587
+ const field = (_opts$field6 = opts.field) !== null && _opts$field6 !== void 0 ? _opts$field6 : 'expiresAt';
1588
+ const sudo = context.sudo();
1589
+ const expired = await sudo.db[listKey].findMany({
1590
+ where: {
1591
+ [field]: {
1592
+ lte: new Date()
1593
+ }
1594
+ }
1595
+ });
1596
+ for (const item of expired) {
1597
+ await sudo.db[listKey].deleteOne({
1598
+ where: {
1599
+ id: String(item.id)
1600
+ }
1601
+ });
1602
+ }
1603
+ return expired.length;
1604
+ }
1605
+
1606
+ /**
1607
+ * Adds a read-only `viewCount` integer. Increment it with `recordView()` — a direct,
1608
+ * race-safe Prisma increment that bypasses hooks (a view is not an editorial change).
1609
+ */
1610
+ function withViewCount(opts = {}) {
1611
+ var _opts$field7;
1612
+ const field = (_opts$field7 = opts.field) !== null && _opts$field7 !== void 0 ? _opts$field7 : 'viewCount';
1613
+ return {
1614
+ fields: {
1615
+ [field]: integer({
1616
+ defaultValue: 0,
1617
+ ui: {
1618
+ createView: {
1619
+ fieldMode: 'hidden'
1620
+ },
1621
+ itemView: {
1622
+ fieldMode: 'read'
1623
+ }
1624
+ }
1625
+ })
1626
+ }
1627
+ };
1628
+ }
1629
+
1630
+ /** Increment an item's view counter (see `withViewCount`). */
1631
+ async function recordView(context, listKey, itemId, opts = {}) {
1632
+ var _opts$field8, _context$prisma;
1633
+ const field = (_opts$field8 = opts.field) !== null && _opts$field8 !== void 0 ? _opts$field8 : 'viewCount';
1634
+ const model = (_context$prisma = context.prisma) === null || _context$prisma === void 0 ? void 0 : _context$prisma[listKey[0].toLowerCase() + listKey.slice(1)];
1635
+ if (!model) throw new Error(`recordView: collection "${listKey}" was not found in the Prisma client`);
1636
+ await model.update({
1637
+ where: {
1638
+ id: itemId
1639
+ },
1640
+ data: {
1641
+ [field]: {
1642
+ increment: 1
1643
+ }
1644
+ }
1645
+ });
1646
+ }
1647
+
1648
+ /**
1649
+ * Companion to cascade rules: sweep records whose to-one relationship points at
1650
+ * nothing (e.g. rows that pre-date a cascade rule, or were orphaned by `setNull`).
1651
+ * Runs normal delete hooks (so cascade rules fire too). Designed for a cron job.
1652
+ *
1653
+ * @example
1654
+ * jobs: createJobs({ jobs: [{
1655
+ * name: 'orphan-cleanup',
1656
+ * schedule: '0 4 * * *',
1657
+ * handler: async () => { await cleanupOrphans(getContext(), { collection: 'Comment', field: 'post' }) },
1658
+ * }]})
1659
+ */
1660
+ async function cleanupOrphans(context, options) {
1661
+ var _options$softDeleteFi;
1662
+ const {
1663
+ collection,
1664
+ field,
1665
+ action = 'delete'
1666
+ } = options;
1667
+ const softDeleteField = (_options$softDeleteFi = options.softDeleteField) !== null && _options$softDeleteFi !== void 0 ? _options$softDeleteFi : 'deletedAt';
1668
+ const sudo = context.sudo();
1669
+ const where = action === 'softDelete' ? {
1670
+ [field]: null,
1671
+ [softDeleteField]: null
1672
+ } : {
1673
+ [field]: null
1674
+ };
1675
+ let total = 0;
1676
+ for (;;) {
1677
+ const orphans = await sudo.db[collection].findMany({
1678
+ where,
1679
+ take: 100
1680
+ });
1681
+ if (!orphans.length) break;
1682
+ for (const orphan of orphans) {
1683
+ if (action === 'delete') {
1684
+ await sudo.db[collection].deleteOne({
1685
+ where: {
1686
+ id: String(orphan.id)
1687
+ }
1688
+ });
1689
+ } else {
1690
+ await sudo.db[collection].updateOne({
1691
+ where: {
1692
+ id: String(orphan.id)
1693
+ },
1694
+ data: {
1695
+ [softDeleteField]: new Date().toISOString()
1696
+ }
1697
+ });
1698
+ }
1699
+ total++;
1700
+ }
1701
+ if (orphans.length < 100) break;
1702
+ }
1703
+ return total;
1704
+ }
1705
+
394
1706
  /**
395
1707
  * Factory for building reusable field group mixins.
396
1708
  *
@@ -409,14 +1721,14 @@ function createMixin(fields, hooks) {
409
1721
  }
410
1722
 
411
1723
  // ================================================================
412
- // defineList — a typed wrapper around list() with mixin support
1724
+ // defineCollection — a typed wrapper around list() with mixin support
413
1725
  // ================================================================
414
1726
 
415
1727
  /**
416
1728
  * Defines a list with optional mixins for reusable field groups and hooks.
417
1729
  *
418
1730
  * @example
419
- * export const Post = defineList({
1731
+ * export const Post = defineCollection({
420
1732
  * mixins: [withTimestamps()],
421
1733
  * fields: {
422
1734
  * title: text({ validation: validators.required }),
@@ -424,9 +1736,18 @@ function createMixin(fields, hooks) {
424
1736
  * },
425
1737
  * })
426
1738
  */
427
- function defineList(config) {
1739
+ function defineCollection(config) {
428
1740
  const {
429
1741
  mixins = [],
1742
+ access,
1743
+ computed,
1744
+ constraints,
1745
+ defaultFilter,
1746
+ stateMachine,
1747
+ policies,
1748
+ events,
1749
+ searchable,
1750
+ versioned,
430
1751
  ...listConfig
431
1752
  } = config;
432
1753
  const mixinFields = {};
@@ -435,9 +1756,26 @@ function defineList(config) {
435
1756
  if (mixin.fields) Object.assign(mixinFields, mixin.fields);
436
1757
  if (mixin.hooks) mixinHooksList.push(mixin.hooks);
437
1758
  }
438
- const mergedHooks = mergeMixinHooks(mixinHooksList, listConfig.hooks);
439
- return list({
1759
+
1760
+ // Feature hooks run AFTER mixin + user hooks so computed fields and constraint checks
1761
+ // observe the final resolved data, and events/search/version snapshots fire last.
1762
+ const featureHooks = compileFeatureHooks({
1763
+ computed,
1764
+ constraints,
1765
+ stateMachine,
1766
+ events,
1767
+ searchable,
1768
+ versioned
1769
+ });
1770
+ const mergedHooks = mergeMixinHooks(mixinHooksList, listConfig.hooks, featureHooks);
1771
+ const baseAccess = access !== null && access !== void 0 ? access : accessPresets.public;
1772
+ const finalAccess = applyAccessFeatures(baseAccess, {
1773
+ defaultFilter,
1774
+ policies
1775
+ });
1776
+ return collection({
440
1777
  ...listConfig,
1778
+ access: finalAccess,
441
1779
  fields: {
442
1780
  ...mixinFields,
443
1781
  ...listConfig.fields
@@ -482,13 +1820,13 @@ function mergeResolveInput(a, b) {
482
1820
  }
483
1821
 
484
1822
  /**
485
- * Merge mixin hooks with the list's own hooks. Each Keystone hook may be either a function or a
1823
+ * Merge mixin hooks with the list's own hooks. Each list hook may be either a function or a
486
1824
  * `{ create, update, delete }` object — `merge` (from resolve-hooks) handles both forms for the
487
1825
  * void hooks, and `mergeResolveInput` handles the data-threading `resolveInput` hook.
488
1826
  */
489
- function mergeMixinHooks(mixinHooks, listHooks) {
490
- if (mixinHooks.length === 0) return listHooks !== null && listHooks !== void 0 ? listHooks : {};
491
- const all = [...mixinHooks, ...(listHooks ? [listHooks] : [])];
1827
+ function mergeMixinHooks(mixinHooks, listHooks, featureHooks = []) {
1828
+ if (mixinHooks.length === 0 && featureHooks.length === 0) return listHooks !== null && listHooks !== void 0 ? listHooks : {};
1829
+ const all = [...mixinHooks, ...(listHooks ? [listHooks] : []), ...featureHooks];
492
1830
  const merged = {};
493
1831
  for (const hooks of all) {
494
1832
  merged.resolveInput = mergeResolveInput(merged.resolveInput, hooks.resolveInput);
@@ -514,7 +1852,7 @@ function mergeMixinHooks(mixinHooks, listHooks) {
514
1852
  * })
515
1853
  */
516
1854
  function defineConfig(nixxieConfig) {
517
- return config(nixxieConfig);
1855
+ return buildConfig(nixxieConfig);
518
1856
  }
519
1857
 
520
1858
  // ================================================================
@@ -525,7 +1863,7 @@ function defineConfig(nixxieConfig) {
525
1863
  * Defines a list action. Typed alias for `action()`.
526
1864
  */
527
1865
  function defineAction(actionConfig) {
528
- return action(actionConfig);
1866
+ return createAction(actionConfig);
529
1867
  }
530
1868
 
531
- export { action, config, createMixin, defineAction, defineConfig, defineList, group, list, validators, withAudit, withSoftDelete, withTimestamps };
1869
+ export { accessPresets, applyScheduledPublishing, approveChanges, cleanupOrphans, collection, createAction, createMixin, defineAction, defineCollection, defineConfig, fieldGroup, purgeExpired, recordView, rejectChanges, submitForApproval, tenantAccessFilter, validateEnv, validators, withApproval, withAudit, withExpiry, withLocale, withPublishing, withSEO, withScheduledPublishing, withSlug, withSoftDelete, withSortable, withTenant, withTimestamps, withTreeStructure, withViewCount };