@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
@@ -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,12 @@ export function config<TypeInfo extends BaseNixxieTypeInfo>(
162
220
  search: config.search,
163
221
  notifications: config.notifications,
164
222
  ai: config.ai,
223
+ aiRag: config.aiRag,
224
+ versioning: config.versioning,
225
+ workflow: config.workflow,
226
+ apiKeys: config.apiKeys,
227
+ logger: config.logger,
228
+ backup: config.backup,
165
229
  telemetry: config.telemetry ?? true,
166
230
  ui: {
167
231
  ...config.ui,
@@ -177,34 +241,38 @@ export function config<TypeInfo extends BaseNixxieTypeInfo>(
177
241
 
178
242
  let i = 0
179
243
 
180
- export type GroupInfo<ListTypeInfo extends BaseListTypeInfo> = {
244
+ export type GroupInfo<CollectionTypeInfo extends BaseCollectionTypeInfo> = {
181
245
  fields: string[]
182
246
  label: string
183
247
  description: string
184
- ui: GroupUIConfig<ListTypeInfo> | undefined
248
+ ui: GroupUIConfig<CollectionTypeInfo> | undefined
185
249
  }
186
250
 
187
- type GroupUIConfig<ListTypeInfo extends BaseListTypeInfo> = {
251
+ type GroupUIConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = {
188
252
  createView?: {
189
- defaultFieldMode?: MaybeSessionFunctionWithFilter<'hidden' | 'edit', 'hidden', ListTypeInfo>
253
+ defaultFieldMode?: MaybeSessionFunctionWithFilter<
254
+ 'hidden' | 'edit',
255
+ 'hidden',
256
+ CollectionTypeInfo
257
+ >
190
258
  }
191
259
  itemView?: {
192
260
  defaultFieldMode?: MaybeItemFunctionWithFilter<
193
261
  'edit' | 'hidden' | 'read',
194
262
  'hidden' | 'read',
195
- ListTypeInfo
263
+ CollectionTypeInfo
196
264
  >
197
265
  }
198
266
  listView?: {
199
- defaultFieldMode?: MaybeSessionFunction<'read' | 'hidden', ListTypeInfo>
267
+ defaultFieldMode?: MaybeSessionFunction<'read' | 'hidden', CollectionTypeInfo>
200
268
  }
201
269
  }
202
270
 
203
- export function group<ListTypeInfo extends BaseListTypeInfo>(config: {
271
+ export function fieldGroup<CollectionTypeInfo extends BaseCollectionTypeInfo>(config: {
204
272
  label: string
205
273
  description?: string
206
- ui?: GroupUIConfig<ListTypeInfo>
207
- fields: BaseFields<ListTypeInfo>
274
+ ui?: GroupUIConfig<CollectionTypeInfo>
275
+ fields: BaseFields<CollectionTypeInfo>
208
276
  }) {
209
277
  const keys = Object.keys(config.fields)
210
278
  if (keys.some(key => key.startsWith('__group'))) {
@@ -217,21 +285,23 @@ export function group<ListTypeInfo extends BaseListTypeInfo>(config: {
217
285
  label: config.label,
218
286
  description: config.description ?? '',
219
287
  ui: config.ui,
220
- } satisfies GroupInfo<ListTypeInfo>,
288
+ } satisfies GroupInfo<CollectionTypeInfo>,
221
289
  ...config.fields,
222
- } as BaseFields<ListTypeInfo> // TODO: FIXME, see initialise-lists.ts:getListsWithInitialisedFields
290
+ } as BaseFields<CollectionTypeInfo> // TODO: FIXME, see initialise-lists.ts:getListsWithInitialisedFields
223
291
  }
224
292
 
225
- export function list<ListTypeInfo extends BaseListTypeInfo>(listConfig: ListConfig<ListTypeInfo>) {
293
+ export function collection<CollectionTypeInfo extends BaseCollectionTypeInfo>(
294
+ listConfig: CollectionConfig<CollectionTypeInfo>
295
+ ) {
226
296
  return { ...listConfig }
227
297
  }
228
298
 
229
- export function action<
230
- ListTypeInfo extends BaseListTypeInfo,
231
- Args extends ActionArgsConfig<ListTypeInfo> | undefined = undefined,
232
- >(action: Action<ListTypeInfo, Args>): DeclaredAction<ListTypeInfo> {
299
+ export function createAction<
300
+ CollectionTypeInfo extends BaseCollectionTypeInfo,
301
+ Args extends ActionArgsConfig<CollectionTypeInfo> | undefined = undefined,
302
+ >(action: Action<CollectionTypeInfo, Args>): DeclaredAction<CollectionTypeInfo> {
233
303
  return {
234
304
  ...action,
235
305
  ___defineActionsWithActionFunction: true,
236
- } as unknown as DeclaredAction<ListTypeInfo>
306
+ } as unknown as DeclaredAction<CollectionTypeInfo>
237
307
  }