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