@nixxie-cms/core 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/CHANGES-1.1.md +134 -0
  2. package/context/dist/nixxie-cms-core-context.cjs.js +4 -3
  3. package/context/dist/nixxie-cms-core-context.esm.js +3 -2
  4. package/dist/declarations/src/access.d.ts +2 -2
  5. package/dist/declarations/src/access.d.ts.map +1 -1
  6. package/dist/declarations/src/admin-ui/components/Navigation.d.ts +2 -2
  7. package/dist/declarations/src/admin-ui/components/Navigation.d.ts.map +1 -1
  8. package/dist/declarations/src/admin-ui/context.d.ts +6 -6
  9. package/dist/declarations/src/admin-ui/context.d.ts.map +1 -1
  10. package/dist/declarations/src/admin-ui/utils/Fields.d.ts +3 -3
  11. package/dist/declarations/src/admin-ui/utils/Fields.d.ts.map +1 -1
  12. package/dist/declarations/src/admin-ui/utils/filters.d.ts +5 -5
  13. package/dist/declarations/src/admin-ui/utils/filters.d.ts.map +1 -1
  14. package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts +3 -3
  15. package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts.map +1 -1
  16. package/dist/declarations/src/admin-ui/utils/utils.d.ts +2 -2
  17. package/dist/declarations/src/admin-ui/utils/utils.d.ts.map +1 -1
  18. package/dist/declarations/src/context.d.ts +1 -1
  19. package/dist/declarations/src/context.d.ts.map +1 -1
  20. package/dist/declarations/src/fields/types/bigInt/index.d.ts +3 -3
  21. package/dist/declarations/src/fields/types/bigInt/index.d.ts.map +1 -1
  22. package/dist/declarations/src/fields/types/bytes/index.d.ts +3 -3
  23. package/dist/declarations/src/fields/types/bytes/index.d.ts.map +1 -1
  24. package/dist/declarations/src/fields/types/calendarDay/index.d.ts +3 -3
  25. package/dist/declarations/src/fields/types/calendarDay/index.d.ts.map +1 -1
  26. package/dist/declarations/src/fields/types/checkbox/index.d.ts +3 -3
  27. package/dist/declarations/src/fields/types/checkbox/index.d.ts.map +1 -1
  28. package/dist/declarations/src/fields/types/decimal/index.d.ts +3 -3
  29. package/dist/declarations/src/fields/types/decimal/index.d.ts.map +1 -1
  30. package/dist/declarations/src/fields/types/file/index.d.ts +4 -4
  31. package/dist/declarations/src/fields/types/file/index.d.ts.map +1 -1
  32. package/dist/declarations/src/fields/types/float/index.d.ts +3 -3
  33. package/dist/declarations/src/fields/types/float/index.d.ts.map +1 -1
  34. package/dist/declarations/src/fields/types/image/index.d.ts +4 -4
  35. package/dist/declarations/src/fields/types/image/index.d.ts.map +1 -1
  36. package/dist/declarations/src/fields/types/integer/index.d.ts +3 -3
  37. package/dist/declarations/src/fields/types/integer/index.d.ts.map +1 -1
  38. package/dist/declarations/src/fields/types/json/index.d.ts +3 -3
  39. package/dist/declarations/src/fields/types/json/index.d.ts.map +1 -1
  40. package/dist/declarations/src/fields/types/multiselect/index.d.ts +3 -3
  41. package/dist/declarations/src/fields/types/multiselect/index.d.ts.map +1 -1
  42. package/dist/declarations/src/fields/types/multiselect/views/index.d.ts.map +1 -1
  43. package/dist/declarations/src/fields/types/password/index.d.ts +3 -3
  44. package/dist/declarations/src/fields/types/password/index.d.ts.map +1 -1
  45. package/dist/declarations/src/fields/types/relationship/index.d.ts +8 -8
  46. package/dist/declarations/src/fields/types/relationship/index.d.ts.map +1 -1
  47. package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts +3 -3
  48. package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts.map +1 -1
  49. package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts +3 -3
  50. package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts.map +1 -1
  51. package/dist/declarations/src/fields/types/relationship/views/index.d.ts +3 -3
  52. package/dist/declarations/src/fields/types/relationship/views/index.d.ts.map +1 -1
  53. package/dist/declarations/src/fields/types/relationship/views/types.d.ts +3 -3
  54. package/dist/declarations/src/fields/types/relationship/views/types.d.ts.map +1 -1
  55. package/dist/declarations/src/fields/types/select/index.d.ts +3 -3
  56. package/dist/declarations/src/fields/types/select/index.d.ts.map +1 -1
  57. package/dist/declarations/src/fields/types/text/index.d.ts +3 -3
  58. package/dist/declarations/src/fields/types/text/index.d.ts.map +1 -1
  59. package/dist/declarations/src/fields/types/timestamp/index.d.ts +3 -3
  60. package/dist/declarations/src/fields/types/timestamp/index.d.ts.map +1 -1
  61. package/dist/declarations/src/fields/types/virtual/index.d.ts +7 -7
  62. package/dist/declarations/src/fields/types/virtual/index.d.ts.map +1 -1
  63. package/dist/declarations/src/helpers.d.ts +249 -13
  64. package/dist/declarations/src/helpers.d.ts.map +1 -1
  65. package/dist/declarations/src/index.d.ts +9 -4
  66. package/dist/declarations/src/index.d.ts.map +1 -1
  67. package/dist/declarations/src/internal-unstable/admin-ui/pages/ListPage/index.d.ts.map +1 -1
  68. package/dist/declarations/src/lib/admin-meta.d.ts +11 -11
  69. package/dist/declarations/src/lib/admin-meta.d.ts.map +1 -1
  70. package/dist/declarations/src/lib/core/access-control.d.ts +18 -18
  71. package/dist/declarations/src/lib/core/access-control.d.ts.map +1 -1
  72. package/dist/declarations/src/lib/core/cascade.d.ts +47 -0
  73. package/dist/declarations/src/lib/core/cascade.d.ts.map +1 -0
  74. package/dist/declarations/src/lib/core/initialise-lists.d.ts +27 -24
  75. package/dist/declarations/src/lib/core/initialise-lists.d.ts.map +1 -1
  76. package/dist/declarations/src/lib/env.d.ts +9 -0
  77. package/dist/declarations/src/lib/env.d.ts.map +1 -0
  78. package/dist/declarations/src/lib/system.d.ts +1 -1
  79. package/dist/declarations/src/lib/system.d.ts.map +1 -1
  80. package/dist/declarations/src/list-features.d.ts +162 -0
  81. package/dist/declarations/src/list-features.d.ts.map +1 -0
  82. package/dist/declarations/src/schema.d.ts +24 -23
  83. package/dist/declarations/src/schema.d.ts.map +1 -1
  84. package/dist/declarations/src/session.d.ts +75 -0
  85. package/dist/declarations/src/session.d.ts.map +1 -1
  86. package/dist/declarations/src/types/admin-meta.d.ts +11 -11
  87. package/dist/declarations/src/types/admin-meta.d.ts.map +1 -1
  88. package/dist/declarations/src/types/config/access-control.d.ts +42 -42
  89. package/dist/declarations/src/types/config/access-control.d.ts.map +1 -1
  90. package/dist/declarations/src/types/config/fields.d.ts +19 -19
  91. package/dist/declarations/src/types/config/fields.d.ts.map +1 -1
  92. package/dist/declarations/src/types/config/hooks.d.ts +131 -131
  93. package/dist/declarations/src/types/config/hooks.d.ts.map +1 -1
  94. package/dist/declarations/src/types/config/index.d.ts +171 -8
  95. package/dist/declarations/src/types/config/index.d.ts.map +1 -1
  96. package/dist/declarations/src/types/config/lists.d.ts +146 -108
  97. package/dist/declarations/src/types/config/lists.d.ts.map +1 -1
  98. package/dist/declarations/src/types/context.d.ts +349 -47
  99. package/dist/declarations/src/types/context.d.ts.map +1 -1
  100. package/dist/declarations/src/types/next-fields.d.ts +28 -28
  101. package/dist/declarations/src/types/next-fields.d.ts.map +1 -1
  102. package/dist/declarations/src/types/type-info.d.ts +3 -3
  103. package/dist/declarations/src/types/type-info.d.ts.map +1 -1
  104. package/dist/{express-7559ca2d.esm.js → express-0abbce07.esm.js} +6 -6
  105. package/dist/{express-455ae20c.cjs.js → express-7ca6f76a.cjs.js} +6 -6
  106. package/dist/{index-15c8f81e.esm.js → index-5d8b0b4e.esm.js} +363 -183
  107. package/dist/index-6055753b.cjs.js +393 -0
  108. package/dist/{index-42045902.cjs.js → index-ac29f382.cjs.js} +363 -185
  109. package/dist/index-f1703b7b.esm.js +386 -0
  110. package/dist/nixxie-cms-core.cjs.js +1387 -30
  111. package/dist/nixxie-cms-core.esm.js +1361 -24
  112. package/dist/{non-null-graphql-add6bb3d.cjs.js → non-null-graphql-4a44c122.cjs.js} +1 -1
  113. package/dist/{non-null-graphql-a84ed64d.esm.js → non-null-graphql-8c5feaae.esm.js} +1 -1
  114. package/dist/{resolve-hooks-165a9ce2.cjs.js → resolve-hooks-10a5f84c.cjs.js} +240 -6
  115. package/dist/{resolve-hooks-6813a045.esm.js → resolve-hooks-9e676794.esm.js} +238 -7
  116. package/dist/{system-03e49e4f.esm.js → system-4d2a2648.esm.js} +32 -7
  117. package/dist/{system-a321642d.cjs.js → system-69e1a285.cjs.js} +32 -7
  118. package/fields/dist/nixxie-cms-core-fields.cjs.js +29 -576
  119. package/fields/dist/nixxie-cms-core-fields.esm.js +18 -565
  120. package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.cjs.js +4 -2
  121. package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.esm.js +4 -2
  122. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.cjs.js +1 -6
  123. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.esm.js +1 -6
  124. package/fields/types/password/dist/nixxie-cms-core-fields-types-password.cjs.js +4 -2
  125. package/fields/types/password/dist/nixxie-cms-core-fields-types-password.esm.js +4 -2
  126. package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.cjs.js +4 -3
  127. package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.esm.js +4 -3
  128. package/package.json +4 -4
  129. package/scripts/cli/dist/nixxie-cms-core-scripts-cli.cjs.js +4 -3
  130. package/scripts/cli/dist/nixxie-cms-core-scripts-cli.esm.js +4 -3
  131. package/scripts/dist/nixxie-cms-core-scripts.cjs.js +4 -3
  132. package/scripts/dist/nixxie-cms-core-scripts.esm.js +4 -3
  133. package/session/dist/nixxie-cms-core-session.cjs.js +286 -0
  134. package/session/dist/nixxie-cms-core-session.esm.js +279 -1
  135. package/src/access.ts +25 -25
  136. package/src/admin-ui/admin-meta-graphql.ts +5 -5
  137. package/src/admin-ui/components/CreateButtonLink.tsx +46 -46
  138. package/src/admin-ui/components/Navigation.tsx +3 -3
  139. package/src/admin-ui/context.tsx +6 -6
  140. package/src/admin-ui/utils/Fields.tsx +241 -241
  141. package/src/admin-ui/utils/actionData.ts +36 -36
  142. package/src/admin-ui/utils/filters.ts +148 -148
  143. package/src/admin-ui/utils/useCreateItem.ts +171 -171
  144. package/src/admin-ui/utils/utils.tsx +127 -127
  145. package/src/context.ts +1 -1
  146. package/src/fields/non-null-graphql.ts +115 -115
  147. package/src/fields/types/bigInt/index.ts +6 -6
  148. package/src/fields/types/bytes/index.ts +6 -6
  149. package/src/fields/types/calendarDay/index.ts +18 -19
  150. package/src/fields/types/checkbox/index.ts +6 -6
  151. package/src/fields/types/decimal/index.ts +6 -6
  152. package/src/fields/types/file/index.ts +8 -8
  153. package/src/fields/types/float/index.ts +6 -6
  154. package/src/fields/types/image/index.ts +8 -8
  155. package/src/fields/types/integer/index.ts +6 -6
  156. package/src/fields/types/json/index.ts +5 -5
  157. package/src/fields/types/multiselect/index.ts +7 -7
  158. package/src/fields/types/multiselect/views/index.tsx +149 -151
  159. package/src/fields/types/password/index.ts +6 -6
  160. package/src/fields/types/relationship/index.ts +13 -13
  161. package/src/fields/types/relationship/views/ComboboxMany.tsx +110 -110
  162. package/src/fields/types/relationship/views/ComboboxSingle.tsx +115 -115
  163. package/src/fields/types/relationship/views/ContextualActions.tsx +139 -139
  164. package/src/fields/types/relationship/views/index.tsx +492 -492
  165. package/src/fields/types/relationship/views/types.ts +46 -46
  166. package/src/fields/types/relationship/views/useApolloQuery.ts +185 -185
  167. package/src/fields/types/relationship/views/useFilter.tsx +109 -109
  168. package/src/fields/types/select/index.ts +6 -6
  169. package/src/fields/types/text/index.ts +6 -6
  170. package/src/fields/types/timestamp/index.ts +23 -21
  171. package/src/fields/types/virtual/index.ts +11 -11
  172. package/src/helpers.ts +773 -42
  173. package/src/index.ts +66 -24
  174. package/src/internal-unstable/admin-ui/pages/ItemPage/common.tsx +4 -4
  175. package/src/internal-unstable/admin-ui/pages/ItemPage/index.tsx +5 -5
  176. package/src/internal-unstable/admin-ui/pages/ListPage/index.tsx +8 -8
  177. package/src/lib/admin-meta.ts +369 -369
  178. package/src/lib/context/createContext.ts +5 -0
  179. package/src/lib/core/access-control.ts +434 -434
  180. package/src/lib/core/cascade.ts +236 -0
  181. package/src/lib/core/initialise-lists.ts +49 -33
  182. package/src/lib/core/mutations/index.ts +7 -0
  183. package/src/lib/core/mutations/nested-mutation-many-input-resolvers.ts +145 -145
  184. package/src/lib/core/mutations/nested-mutation-one-input-resolvers.ts +71 -71
  185. package/src/lib/core/queries/output-field.ts +178 -178
  186. package/src/lib/env.ts +50 -0
  187. package/src/lib/id-field.ts +2 -2
  188. package/src/lib/system.ts +221 -207
  189. package/src/lib/typescript-schema-printer.ts +227 -227
  190. package/src/list-features.ts +476 -0
  191. package/src/schema.ts +91 -22
  192. package/src/session.ts +225 -0
  193. package/src/types/admin-meta.ts +218 -218
  194. package/src/types/config/access-control.ts +186 -186
  195. package/src/types/config/fields.ts +96 -96
  196. package/src/types/config/hooks.ts +529 -529
  197. package/src/types/config/index.ts +185 -7
  198. package/src/types/config/lists.ts +606 -565
  199. package/src/types/context.ts +426 -55
  200. package/src/types/next-fields.ts +31 -31
  201. package/src/types/type-info.ts +38 -38
  202. package/src/types/type-tests.ts +21 -21
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Declarative collection features compiled into hooks + access-control filters by
3
+ * `defineCollection()`: computed fields, constraints, default filters, state machines,
4
+ * policies, events, search indexing and version snapshots.
5
+ *
6
+ * Everything here builds on the public hook/access surface — no resolver internals —
7
+ * so the features compose with mixins and user hooks via the same merge pipeline.
8
+ */
9
+ import type { CollectionHooks } from './types'
10
+ import type { MaybePromise } from './types/utils'
11
+
12
+ type AnyArgs = any
13
+
14
+ // ── Option types ──
15
+
16
+ /**
17
+ * Persisted derived fields, recalculated on every create/update after all other
18
+ * `resolveInput` hooks have run. The target keys must be real fields on the collection
19
+ * (unlike `virtual()` fields, computed values are stored — so they can be filtered and
20
+ * sorted). `merged` is the item's prospective state (existing values + this write).
21
+ *
22
+ * @example
23
+ * computed: { total: ({ merged }) => merged.price * merged.quantity }
24
+ */
25
+ export type ComputedConfig = Record<
26
+ string,
27
+ (args: {
28
+ operation: 'create' | 'update'
29
+ resolvedData: Record<string, any>
30
+ /** Existing item values overlaid with this operation's resolved data. */
31
+ merged: Record<string, any>
32
+ item: Record<string, any> | undefined
33
+ context: any
34
+ }) => MaybePromise<unknown>
35
+ >
36
+
37
+ /**
38
+ * Data-integrity rules checked before every create/update.
39
+ *
40
+ * - `uniqueTogether`: compound uniqueness over scalar fields (e.g. `[['tenant', 'slug']]`).
41
+ * Checked with a query, so add a DB index for hot paths.
42
+ * - `checks`: named cross-field rules; return an error message to reject the write.
43
+ *
44
+ * @example
45
+ * constraints: {
46
+ * uniqueTogether: [['translationKey', 'locale']],
47
+ * checks: { dates: ({ merged }) => merged.endDate <= merged.startDate ? 'endDate must be after startDate' : undefined },
48
+ * }
49
+ */
50
+ export type ConstraintsConfig = {
51
+ uniqueTogether?: string[][]
52
+ checks?: Record<
53
+ string,
54
+ (args: {
55
+ operation: 'create' | 'update'
56
+ resolvedData: Record<string, any>
57
+ merged: Record<string, any>
58
+ item: Record<string, any> | undefined
59
+ context: any
60
+ }) => MaybePromise<string | undefined | void>
61
+ >
62
+ }
63
+
64
+ /**
65
+ * A where-filter automatically ANDed into every query/update/delete — including the
66
+ * Admin UI, which respects access filters. This is what makes `withSoftDelete()` real:
67
+ *
68
+ * @example
69
+ * defaultFilter: { deletedAt: null }
70
+ * @example
71
+ * defaultFilter: ({ session }) => session?.data?.isAdmin ? true : { status: { equals: 'published' } }
72
+ */
73
+ export type DefaultFilterConfig =
74
+ | Record<string, unknown>
75
+ | ((args: { session?: any; context: any }) => MaybePromise<boolean | Record<string, unknown>>)
76
+
77
+ /**
78
+ * Enforce legal status transitions on a select field at the mutation layer.
79
+ *
80
+ * @example
81
+ * stateMachine: {
82
+ * field: 'status',
83
+ * transitions: { draft: ['review'], review: ['published', 'draft'], published: ['archived'] },
84
+ * guards: { 'review->published': ({ session }) => session?.data?.role === 'editor' || 'Only editors can publish' },
85
+ * }
86
+ */
87
+ export type StateMachineConfig = {
88
+ /** The (select) field holding the state. */
89
+ field: string
90
+ /** Allowed transitions: from-state → list of reachable states. */
91
+ transitions: Record<string, string[]>
92
+ /**
93
+ * Optional guards keyed `"from->to"`. Return `false` to block (generic message)
94
+ * or a string to block with that message.
95
+ */
96
+ guards?: Record<
97
+ string,
98
+ (args: {
99
+ item: Record<string, any>
100
+ resolvedData: Record<string, any>
101
+ context: any
102
+ session?: any
103
+ }) => MaybePromise<boolean | string>
104
+ >
105
+ /** States allowed on create. Omit to allow any state on create. */
106
+ initial?: string | string[]
107
+ }
108
+
109
+ /** One row-level access rule: the first rule whose `when` matches decides the filter. */
110
+ export type PolicyRule = {
111
+ /** Does this rule apply to the session? (e.g. `session => session?.data?.role === 'editor'`) */
112
+ when: (session: any) => boolean
113
+ /** `true` = unrestricted, `false` = no access, object = where-filter, function = computed filter. */
114
+ filter:
115
+ | boolean
116
+ | Record<string, unknown>
117
+ | ((session: any) => boolean | Record<string, unknown>)
118
+ }
119
+
120
+ /**
121
+ * Declarative row-level access: per operation, rules are evaluated in order and the
122
+ * first matching `when` supplies the filter; no match means no access. ANDed with any
123
+ * explicit `access.filter` you also configure. Pairs naturally with @nixxie-cms/rbac.
124
+ *
125
+ * @example
126
+ * policies: {
127
+ * query: [
128
+ * { when: s => s?.data?.role === 'admin', filter: true },
129
+ * { when: s => !!s, filter: s => ({ author: { id: { equals: s.itemId } } }) },
130
+ * ],
131
+ * }
132
+ */
133
+ export type PoliciesConfig = Partial<Record<'query' | 'update' | 'delete', PolicyRule[]>>
134
+
135
+ /**
136
+ * Emit lifecycle events through `context.services.webhooks` after every write:
137
+ * `<prefix>.created` / `.updated` / `.deleted` (prefix defaults to the list key).
138
+ * No-op when the webhooks service is not configured.
139
+ */
140
+ export type EventsConfig =
141
+ | true
142
+ | {
143
+ /** Event name prefix. @default the list key */
144
+ prefix?: string
145
+ operations?: ('create' | 'update' | 'delete')[]
146
+ /** Customise the event payload. @default { listKey, id, item } */
147
+ payload?: (args: AnyArgs) => unknown
148
+ }
149
+
150
+ /**
151
+ * Keep a search index in sync through `context.services.search`: documents are indexed
152
+ * after create/update and removed after delete. No-op when search is not configured.
153
+ */
154
+ export type SearchableConfig =
155
+ | true
156
+ | {
157
+ /** Index name. @default the list key lowercased */
158
+ index?: string
159
+ /** Item fields copied into the document. @default all scalar values on the item */
160
+ fields?: string[]
161
+ }
162
+
163
+ /**
164
+ * Snapshot every create/update into `context.services.versioning` (resource = list key).
165
+ * No-op when versioning is not configured.
166
+ */
167
+ export type VersionedConfig =
168
+ | true
169
+ | {
170
+ /** Optional label for each snapshot. */
171
+ label?: (args: AnyArgs) => string | undefined
172
+ }
173
+
174
+ export type ListFeaturesConfig = {
175
+ computed?: ComputedConfig
176
+ constraints?: ConstraintsConfig
177
+ defaultFilter?: DefaultFilterConfig
178
+ stateMachine?: StateMachineConfig
179
+ policies?: PoliciesConfig
180
+ events?: EventsConfig
181
+ searchable?: SearchableConfig
182
+ versioned?: VersionedConfig
183
+ }
184
+
185
+ // ── Compilation to hooks ──
186
+
187
+ const mergedView = (args: AnyArgs): Record<string, any> => ({
188
+ ...(args.item ?? {}),
189
+ ...(args.resolvedData ?? {}),
190
+ })
191
+
192
+ function computedHooks(computed: ComputedConfig): Partial<CollectionHooks<any>> {
193
+ return {
194
+ resolveInput: async (args: AnyArgs) => {
195
+ const out = { ...args.resolvedData }
196
+ for (const [field, fn] of Object.entries(computed)) {
197
+ out[field] = await fn({
198
+ operation: args.operation,
199
+ resolvedData: out,
200
+ merged: { ...(args.item ?? {}), ...out },
201
+ item: args.item,
202
+ context: args.context,
203
+ })
204
+ }
205
+ return out
206
+ },
207
+ }
208
+ }
209
+
210
+ function constraintsHooks(constraints: ConstraintsConfig): Partial<CollectionHooks<any>> {
211
+ return {
212
+ validate: async (args: AnyArgs) => {
213
+ const { operation, addValidationError } = args
214
+ if (operation === 'delete') return
215
+ const merged = mergedView(args)
216
+
217
+ for (const tuple of constraints.uniqueTogether ?? []) {
218
+ const values = tuple.map(field => merged[field])
219
+ // Incomplete tuples (a NULL member) are not checked — same semantics as SQL unique indexes.
220
+ if (values.some(value => value === undefined || value === null)) continue
221
+ const where: Record<string, any> = {}
222
+ tuple.forEach((field, i) => (where[field] = { equals: values[i] }))
223
+ try {
224
+ const clashes = await args.context.sudo().db[args.listKey].findMany({ where, take: 2 })
225
+ const other = clashes.find(
226
+ (clash: any) => !args.item || String(clash.id) !== String(args.item.id)
227
+ )
228
+ if (other) {
229
+ addValidationError(`Another item already exists with the same ${tuple.join(' + ')}`)
230
+ }
231
+ } catch (err) {
232
+ // A malformed tuple (unknown/non-scalar field) should fail loudly, not silently pass.
233
+ addValidationError(
234
+ `uniqueTogether check for (${tuple.join(', ')}) failed: ${err instanceof Error ? err.message : err}`
235
+ )
236
+ }
237
+ }
238
+
239
+ for (const check of Object.values(constraints.checks ?? {})) {
240
+ const message = await check({
241
+ operation: args.operation,
242
+ resolvedData: args.resolvedData,
243
+ merged,
244
+ item: args.item,
245
+ context: args.context,
246
+ })
247
+ if (typeof message === 'string' && message.length > 0) addValidationError(message)
248
+ }
249
+ },
250
+ }
251
+ }
252
+
253
+ function stateMachineHooks(machine: StateMachineConfig): Partial<CollectionHooks<any>> {
254
+ const { field, transitions, guards = {} } = machine
255
+ const initial =
256
+ machine.initial === undefined
257
+ ? undefined
258
+ : Array.isArray(machine.initial)
259
+ ? machine.initial
260
+ : [machine.initial]
261
+ return {
262
+ validate: async (args: AnyArgs) => {
263
+ const { operation, resolvedData, item, addValidationError } = args
264
+ if (operation === 'delete') return
265
+ const next = resolvedData?.[field]
266
+ if (next === undefined) return
267
+
268
+ if (operation === 'create') {
269
+ if (initial && next != null && !initial.includes(next)) {
270
+ addValidationError(
271
+ `"${field}" cannot start as "${next}" (allowed: ${initial.join(', ')})`
272
+ )
273
+ }
274
+ return
275
+ }
276
+
277
+ const previous = item?.[field]
278
+ if (next === previous) return
279
+ const allowed = transitions[previous] ?? []
280
+ if (!allowed.includes(next)) {
281
+ addValidationError(
282
+ `"${field}" cannot change from "${previous}" to "${next}"` +
283
+ (allowed.length ? ` (allowed: ${allowed.join(', ')})` : ' (no transitions from this state)')
284
+ )
285
+ return
286
+ }
287
+ const guard = guards[`${previous}->${next}`]
288
+ if (guard) {
289
+ const verdict = await guard({
290
+ item,
291
+ resolvedData,
292
+ context: args.context,
293
+ session: args.context?.session,
294
+ })
295
+ if (verdict !== true) {
296
+ addValidationError(
297
+ typeof verdict === 'string'
298
+ ? verdict
299
+ : `"${field}" transition "${previous}" → "${next}" was blocked`
300
+ )
301
+ }
302
+ }
303
+ },
304
+ }
305
+ }
306
+
307
+ const pastTense = { create: 'created', update: 'updated', delete: 'deleted' } as const
308
+
309
+ function eventsHooks(events: EventsConfig): Partial<CollectionHooks<any>> {
310
+ const options = events === true ? {} : events
311
+ const operations = options.operations ?? ['create', 'update', 'delete']
312
+ return {
313
+ afterOperation: async (args: AnyArgs) => {
314
+ if (!operations.includes(args.operation)) return
315
+ const webhooks = args.context?.services?.webhooks
316
+ if (!webhooks) return
317
+ try {
318
+ const subject = args.item ?? args.originalItem
319
+ const event = `${options.prefix ?? args.listKey}.${pastTense[args.operation as keyof typeof pastTense]}`
320
+ const payload = options.payload
321
+ ? await options.payload(args)
322
+ : { listKey: args.listKey, id: subject?.id != null ? String(subject.id) : undefined, item: subject }
323
+ await webhooks.trigger(event, payload)
324
+ } catch (err) {
325
+ console.error(`[nixxie] events: failed to emit for ${args.listKey}:`, err)
326
+ }
327
+ },
328
+ }
329
+ }
330
+
331
+ /** Copy the indexable scalar values off an item (used when `fields` is not specified). */
332
+ function scalarDocument(item: Record<string, any>): Record<string, unknown> {
333
+ const doc: Record<string, unknown> = {}
334
+ for (const [key, value] of Object.entries(item)) {
335
+ if (key.startsWith('__')) continue
336
+ if (value === null) doc[key] = null
337
+ else if (value instanceof Date) doc[key] = value.toISOString()
338
+ else if (['string', 'number', 'boolean'].includes(typeof value)) doc[key] = value
339
+ }
340
+ return doc
341
+ }
342
+
343
+ function searchableHooks(searchable: SearchableConfig): Partial<CollectionHooks<any>> {
344
+ const options = searchable === true ? {} : searchable
345
+ return {
346
+ afterOperation: async (args: AnyArgs) => {
347
+ const search = args.context?.services?.search
348
+ if (!search) return
349
+ const indexName = options.index ?? String(args.listKey).toLowerCase()
350
+ try {
351
+ if (args.operation === 'delete') {
352
+ await search.remove(indexName, String(args.originalItem.id))
353
+ return
354
+ }
355
+ const item = args.item
356
+ const picked = options.fields
357
+ ? Object.fromEntries(
358
+ options.fields.map(field => [
359
+ field,
360
+ item[field] instanceof Date ? item[field].toISOString() : item[field],
361
+ ])
362
+ )
363
+ : scalarDocument(item)
364
+ await search.index(indexName, { ...picked, id: String(item.id) })
365
+ } catch (err) {
366
+ console.error(`[nixxie] searchable: failed to sync index "${indexName}":`, err)
367
+ }
368
+ },
369
+ }
370
+ }
371
+
372
+ function versionedHooks(versioned: VersionedConfig): Partial<CollectionHooks<any>> {
373
+ const options = versioned === true ? {} : versioned
374
+ return {
375
+ afterOperation: async (args: AnyArgs) => {
376
+ if (args.operation === 'delete') return
377
+ const versioning = args.context?.services?.versioning
378
+ if (!versioning) return
379
+ try {
380
+ const session = args.context?.session
381
+ await versioning.snapshot({
382
+ resource: args.listKey,
383
+ resourceId: String(args.item.id),
384
+ data: scalarSafe(args.item),
385
+ label: options.label?.(args),
386
+ actor: session?.itemId != null ? { id: String(session.itemId) } : undefined,
387
+ })
388
+ } catch (err) {
389
+ console.error(`[nixxie] versioned: failed to snapshot ${args.listKey}:`, err)
390
+ }
391
+ },
392
+ }
393
+ }
394
+
395
+ /** Snapshot-safe clone: keeps JSON-representable values, ISO-stringifies dates. */
396
+ function scalarSafe(item: Record<string, any>): Record<string, unknown> {
397
+ const out: Record<string, unknown> = {}
398
+ for (const [key, value] of Object.entries(item)) {
399
+ if (value instanceof Date) out[key] = value.toISOString()
400
+ else if (typeof value === 'bigint') out[key] = value.toString()
401
+ else if (typeof value !== 'function' && typeof value !== 'symbol') out[key] = value
402
+ }
403
+ return out
404
+ }
405
+
406
+ /** Compile the declarative features into hook fragments, in a deliberate order. */
407
+ export function compileFeatureHooks(features: ListFeaturesConfig): Array<Partial<CollectionHooks<any>>> {
408
+ const hooks: Array<Partial<CollectionHooks<any>>> = []
409
+ if (features.computed) hooks.push(computedHooks(features.computed))
410
+ if (features.constraints) hooks.push(constraintsHooks(features.constraints))
411
+ if (features.stateMachine) hooks.push(stateMachineHooks(features.stateMachine))
412
+ if (features.events) hooks.push(eventsHooks(features.events))
413
+ if (features.searchable) hooks.push(searchableHooks(features.searchable))
414
+ if (features.versioned) hooks.push(versionedHooks(features.versioned))
415
+ return hooks
416
+ }
417
+
418
+ // ── Compilation to access filters ──
419
+
420
+ type FilterValue = boolean | Record<string, unknown>
421
+ type FilterLike = FilterValue | ((args: any) => MaybePromise<FilterValue>)
422
+
423
+ /** AND two access filters; `true`/missing means unrestricted, `false` wins outright. */
424
+ function andFilters(a: FilterLike | undefined, b: FilterLike): FilterLike {
425
+ if (a === undefined) return b
426
+ return async (args: any) => {
427
+ const ra = typeof a === 'function' ? await a(args) : a
428
+ const rb = typeof b === 'function' ? await b(args) : b
429
+ if (ra === false || rb === false) return false
430
+ if (ra === true) return rb
431
+ if (rb === true) return ra
432
+ return { AND: [ra, rb] }
433
+ }
434
+ }
435
+
436
+ function policyFilter(rules: PolicyRule[]): FilterLike {
437
+ return ({ session }: any) => {
438
+ for (const rule of rules) {
439
+ if (!rule.when(session)) continue
440
+ return typeof rule.filter === 'function' ? rule.filter(session) : rule.filter
441
+ }
442
+ return false
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Merge `defaultFilter` and `policies` into an access-control object. Existing filters
448
+ * are preserved and ANDed with the feature filters.
449
+ */
450
+ export function applyAccessFeatures(
451
+ access: any,
452
+ features: Pick<ListFeaturesConfig, 'defaultFilter' | 'policies'>
453
+ ): any {
454
+ const { defaultFilter, policies } = features
455
+ if (!defaultFilter && !policies) return access
456
+
457
+ const filter = { ...(access?.filter ?? {}) }
458
+ for (const operation of ['query', 'update', 'delete'] as const) {
459
+ let merged: FilterLike | undefined = filter[operation]
460
+ if (defaultFilter) {
461
+ merged = andFilters(
462
+ merged,
463
+ typeof defaultFilter === 'function'
464
+ ? (args: any) => defaultFilter({ session: args.session, context: args.context })
465
+ : (defaultFilter as Record<string, unknown>)
466
+ )
467
+ }
468
+ const rules = policies?.[operation]
469
+ if (rules?.length) {
470
+ merged = andFilters(merged, policyFilter(rules))
471
+ }
472
+ if (merged !== undefined) filter[operation] = merged
473
+ }
474
+
475
+ return { ...(access ?? {}), filter }
476
+ }
package/src/schema.ts CHANGED
@@ -1,26 +1,80 @@
1
1
  import type { ListenOptions } from 'node:net'
2
2
 
3
+ import { validateEnv } from './lib/env'
3
4
  import { idFieldType } from './lib/id-field'
4
5
  import type {
5
6
  Action,
6
7
  ActionArgsConfig,
7
8
  BaseFields,
8
9
  BaseNixxieTypeInfo,
9
- BaseListTypeInfo,
10
+ BaseCollectionTypeInfo,
10
11
  DeclaredAction,
11
12
  IdFieldConfig,
12
13
  NixxieConfig,
13
14
  NixxieConfigPre,
14
15
  NixxieContext,
15
- ListConfig,
16
+ CollectionConfig,
16
17
  MaybeItemFunctionWithFilter,
17
18
  MaybeSessionFunction,
18
19
  MaybeSessionFunctionWithFilter,
19
20
  } from './types'
20
21
 
22
+ /**
23
+ * Fold `config.plugins` into the config: merge plugin collections (key conflicts throw),
24
+ * run each plugin's `extendConfig`, and chain plugin `onConnect`s before `db.onConnect`.
25
+ */
26
+ function applyPlugins<TypeInfo extends BaseNixxieTypeInfo>(
27
+ config: NixxieConfigPre<TypeInfo>
28
+ ): NixxieConfigPre<TypeInfo> {
29
+ const plugins = config.plugins ?? []
30
+ if (!plugins.length) return config
31
+
32
+ const seen = new Set<string>()
33
+ for (const plugin of plugins) {
34
+ if (!plugin.name) throw new Error('Every plugin must have a `name`')
35
+ if (seen.has(plugin.name)) throw new Error(`Duplicate plugin name "${plugin.name}"`)
36
+ seen.add(plugin.name)
37
+ }
38
+
39
+ let next: NixxieConfigPre<TypeInfo> = { ...config, collections: { ...config.collections } }
40
+ for (const plugin of plugins) {
41
+ for (const [key, collection] of Object.entries(plugin.collections ?? {})) {
42
+ if (key in next.collections) {
43
+ throw new Error(
44
+ `Plugin "${plugin.name}" adds the collection "${key}", but a collection with that key already exists`
45
+ )
46
+ }
47
+ next.collections[key] = collection
48
+ }
49
+ if (plugin.extendConfig) {
50
+ next = plugin.extendConfig(next)
51
+ if (!next) {
52
+ throw new Error(`Plugin "${plugin.name}".extendConfig must return the config`)
53
+ }
54
+ }
55
+ }
56
+
57
+ const onConnects = plugins.flatMap(plugin => (plugin.onConnect ? [plugin.onConnect] : []))
58
+ if (onConnects.length) {
59
+ const userOnConnect = next.db.onConnect
60
+ next = {
61
+ ...next,
62
+ db: {
63
+ ...next.db,
64
+ onConnect: async context => {
65
+ for (const fn of onConnects) await fn(context)
66
+ await userOnConnect?.(context)
67
+ },
68
+ },
69
+ }
70
+ }
71
+
72
+ return next
73
+ }
74
+
21
75
  function listsWithDefaults(config: NixxieConfigPre, defaultIdField: IdFieldConfig) {
22
76
  // some error checking
23
- for (const [listKey, list] of Object.entries(config.lists)) {
77
+ for (const [listKey, list] of Object.entries(config.collections)) {
24
78
  if (list.fields.id) {
25
79
  throw new Error(
26
80
  `"fields.id" is reserved by Nixxie, use "db.idField" for the "${listKey}" list`
@@ -34,7 +88,7 @@ function listsWithDefaults(config: NixxieConfigPre, defaultIdField: IdFieldConfi
34
88
 
35
89
  return Object.fromEntries([
36
90
  ...(function* () {
37
- for (const [listKey, list] of Object.entries(config.lists)) {
91
+ for (const [listKey, list] of Object.entries(config.collections)) {
38
92
  yield [
39
93
  listKey,
40
94
  {
@@ -42,6 +96,7 @@ function listsWithDefaults(config: NixxieConfigPre, defaultIdField: IdFieldConfi
42
96
  defaultIsFilterable: true, // TODO: move to access control?
43
97
  defaultIsOrderable: true, // TODO: move to access control?
44
98
  isSingleton: false,
99
+ cascade: [],
45
100
  ...list,
46
101
  db: {
47
102
  ...list.db,
@@ -86,9 +141,12 @@ function identity<T>(x: T) {
86
141
  return x
87
142
  }
88
143
 
89
- export function config<TypeInfo extends BaseNixxieTypeInfo>(
144
+ export function buildConfig<TypeInfo extends BaseNixxieTypeInfo>(
90
145
  config: NixxieConfigPre<TypeInfo>
91
146
  ): NixxieConfig<TypeInfo> {
147
+ if (config.env) validateEnv(config.env)
148
+ config = applyPlugins(config)
149
+
92
150
  if (!['postgresql', 'sqlite', 'mysql'].includes(config.db.provider)) {
93
151
  throw new TypeError(`"db.provider" only supports "sqlite", "postgresql" or "mysql"`)
94
152
  }
@@ -162,6 +220,11 @@ export function config<TypeInfo extends BaseNixxieTypeInfo>(
162
220
  search: config.search,
163
221
  notifications: config.notifications,
164
222
  ai: config.ai,
223
+ versioning: config.versioning,
224
+ workflow: config.workflow,
225
+ apiKeys: config.apiKeys,
226
+ logger: config.logger,
227
+ backup: config.backup,
165
228
  telemetry: config.telemetry ?? true,
166
229
  ui: {
167
230
  ...config.ui,
@@ -177,34 +240,38 @@ export function config<TypeInfo extends BaseNixxieTypeInfo>(
177
240
 
178
241
  let i = 0
179
242
 
180
- export type GroupInfo<ListTypeInfo extends BaseListTypeInfo> = {
243
+ export type GroupInfo<CollectionTypeInfo extends BaseCollectionTypeInfo> = {
181
244
  fields: string[]
182
245
  label: string
183
246
  description: string
184
- ui: GroupUIConfig<ListTypeInfo> | undefined
247
+ ui: GroupUIConfig<CollectionTypeInfo> | undefined
185
248
  }
186
249
 
187
- type GroupUIConfig<ListTypeInfo extends BaseListTypeInfo> = {
250
+ type GroupUIConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = {
188
251
  createView?: {
189
- defaultFieldMode?: MaybeSessionFunctionWithFilter<'hidden' | 'edit', 'hidden', ListTypeInfo>
252
+ defaultFieldMode?: MaybeSessionFunctionWithFilter<
253
+ 'hidden' | 'edit',
254
+ 'hidden',
255
+ CollectionTypeInfo
256
+ >
190
257
  }
191
258
  itemView?: {
192
259
  defaultFieldMode?: MaybeItemFunctionWithFilter<
193
260
  'edit' | 'hidden' | 'read',
194
261
  'hidden' | 'read',
195
- ListTypeInfo
262
+ CollectionTypeInfo
196
263
  >
197
264
  }
198
265
  listView?: {
199
- defaultFieldMode?: MaybeSessionFunction<'read' | 'hidden', ListTypeInfo>
266
+ defaultFieldMode?: MaybeSessionFunction<'read' | 'hidden', CollectionTypeInfo>
200
267
  }
201
268
  }
202
269
 
203
- export function group<ListTypeInfo extends BaseListTypeInfo>(config: {
270
+ export function fieldGroup<CollectionTypeInfo extends BaseCollectionTypeInfo>(config: {
204
271
  label: string
205
272
  description?: string
206
- ui?: GroupUIConfig<ListTypeInfo>
207
- fields: BaseFields<ListTypeInfo>
273
+ ui?: GroupUIConfig<CollectionTypeInfo>
274
+ fields: BaseFields<CollectionTypeInfo>
208
275
  }) {
209
276
  const keys = Object.keys(config.fields)
210
277
  if (keys.some(key => key.startsWith('__group'))) {
@@ -217,21 +284,23 @@ export function group<ListTypeInfo extends BaseListTypeInfo>(config: {
217
284
  label: config.label,
218
285
  description: config.description ?? '',
219
286
  ui: config.ui,
220
- } satisfies GroupInfo<ListTypeInfo>,
287
+ } satisfies GroupInfo<CollectionTypeInfo>,
221
288
  ...config.fields,
222
- } as BaseFields<ListTypeInfo> // TODO: FIXME, see initialise-lists.ts:getListsWithInitialisedFields
289
+ } as BaseFields<CollectionTypeInfo> // TODO: FIXME, see initialise-lists.ts:getListsWithInitialisedFields
223
290
  }
224
291
 
225
- export function list<ListTypeInfo extends BaseListTypeInfo>(listConfig: ListConfig<ListTypeInfo>) {
292
+ export function collection<CollectionTypeInfo extends BaseCollectionTypeInfo>(
293
+ listConfig: CollectionConfig<CollectionTypeInfo>
294
+ ) {
226
295
  return { ...listConfig }
227
296
  }
228
297
 
229
- export function action<
230
- ListTypeInfo extends BaseListTypeInfo,
231
- Args extends ActionArgsConfig<ListTypeInfo> | undefined = undefined,
232
- >(action: Action<ListTypeInfo, Args>): DeclaredAction<ListTypeInfo> {
298
+ export function createAction<
299
+ CollectionTypeInfo extends BaseCollectionTypeInfo,
300
+ Args extends ActionArgsConfig<CollectionTypeInfo> | undefined = undefined,
301
+ >(action: Action<CollectionTypeInfo, Args>): DeclaredAction<CollectionTypeInfo> {
233
302
  return {
234
303
  ...action,
235
304
  ___defineActionsWithActionFunction: true,
236
- } as unknown as DeclaredAction<ListTypeInfo>
305
+ } as unknown as DeclaredAction<CollectionTypeInfo>
237
306
  }