@docupace/ihub-config 1.1.24 → 1.1.25-admin.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/dist/config-extension.d.ts +58 -0
- package/dist/config-extension.d.ts.map +1 -1
- package/dist/config-extension.js +2 -0
- package/dist/config-extension.js.map +1 -1
- package/dist/config-service.d.ts +13 -4
- package/dist/config-service.d.ts.map +1 -1
- package/dist/config-service.js +406 -22
- package/dist/config-service.js.map +1 -1
- package/dist/mappers/config-mapper.d.ts.map +1 -1
- package/dist/mappers/config-mapper.js +1 -0
- package/dist/mappers/config-mapper.js.map +1 -1
- package/dist/model/graphs/config/fields.graphql +30 -0
- package/dist/model/graphs/config/menu.graphql +6 -0
- package/dist/model/graphs/config/page.graphql +8 -1
- package/dist/model/graphs/config/query.graphql +10 -1
- package/dist/model/graphs/config/selectOptions.graphql +1 -0
- package/dist/model/graphs/config/table.graphql +26 -0
- package/dist/types/graphql.d.ts +45 -15
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/graphql.js.map +1 -1
- package/model/graphs/config/fields.graphql +30 -0
- package/model/graphs/config/menu.graphql +6 -0
- package/model/graphs/config/page.graphql +8 -1
- package/model/graphs/config/query.graphql +10 -1
- package/model/graphs/config/selectOptions.graphql +1 -0
- package/model/graphs/config/table.graphql +26 -0
- package/package.json +5 -3
package/dist/config-service.js
CHANGED
|
@@ -11,8 +11,228 @@ import { builtInProviders } from './providers/index.js';
|
|
|
11
11
|
import { deepMergeResolvers, } from './resolvers/index.js';
|
|
12
12
|
import { dump, load } from 'js-yaml';
|
|
13
13
|
import { buildQuery } from '@docupace/ihub-core/query';
|
|
14
|
+
import jp from 'jsonpath';
|
|
14
15
|
const FULL_OBJECT_EXPRESSION_PATTERN = /^\${(.*?)}$/;
|
|
15
16
|
const INLINE_OBJECT_EXPRESSION_PATTERN = /\${(.*?)}/g;
|
|
17
|
+
function isNamedObjectArray(value) {
|
|
18
|
+
return (Array.isArray(value) &&
|
|
19
|
+
value.every((item) => item !== null &&
|
|
20
|
+
typeof item === 'object' &&
|
|
21
|
+
typeof item.name === 'string'));
|
|
22
|
+
}
|
|
23
|
+
// An override element counts as "self-contained" when it carries enough of
|
|
24
|
+
// its own definition to render on its own — a real attribute path (or paths)
|
|
25
|
+
// or a computed expression — as opposed to a thin `{ name, visible }`-style
|
|
26
|
+
// reference that only makes sense resolved against a base/predefined
|
|
27
|
+
// counterpart of the same name. `paths` (plural) is `Column`'s shape;
|
|
28
|
+
// `path` (singular) is `TableCriteria`'s — both must count, or a resolved
|
|
29
|
+
// filter-criterion `$ref` (which only ever has a singular `path`) is wrongly
|
|
30
|
+
// treated as a dangling reference and silently dropped, even though it just
|
|
31
|
+
// resolved successfully.
|
|
32
|
+
function isSelfContainedOverrideElement(item) {
|
|
33
|
+
const paths = item.paths;
|
|
34
|
+
return ((Array.isArray(paths) && paths.length > 0) ||
|
|
35
|
+
(typeof item.path === 'string' && item.path.length > 0) ||
|
|
36
|
+
(typeof item.expression === 'string' && item.expression.length > 0));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Merge semantics for a config sub-path override against the value already
|
|
40
|
+
* at that JSONPath. Generic — used for any typename/sub-path the
|
|
41
|
+
* configPath/subPath mechanism resolves, not just `Table.columns`.
|
|
42
|
+
*
|
|
43
|
+
* When both the current value and the override value are arrays of objects
|
|
44
|
+
* each carrying a string `name`, override elements are merged onto their
|
|
45
|
+
* base counterpart by name (override wins per-attribute, everything else
|
|
46
|
+
* inherited) instead of the override replacing the array wholesale. This is
|
|
47
|
+
* what makes a saved override non-destructive:
|
|
48
|
+
* - Reordering falls out of the override array's own element order.
|
|
49
|
+
* - "Hiding" an element is an attribute override (e.g. `visible: false`) on
|
|
50
|
+
* an element that's still present, not a deletion.
|
|
51
|
+
* - A base element the override never mentions is appended unchanged, so a
|
|
52
|
+
* base element added later still surfaces for someone with an existing
|
|
53
|
+
* override, instead of being permanently absent from their frozen array.
|
|
54
|
+
* - An override element naming something not found in the base is kept
|
|
55
|
+
* as-is when it's self-contained (has its own path(s)/expression — e.g. a
|
|
56
|
+
* hand-authored column), or silently dropped when it's a thin reference
|
|
57
|
+
* whose target (e.g. a predefined column) no longer exists — this never
|
|
58
|
+
* throws; the stale reference just doesn't round-trip into the next save.
|
|
59
|
+
*
|
|
60
|
+
* Any other shape (scalars, plain objects, arrays without a `name` key) is
|
|
61
|
+
* returned untouched, so the caller's plain whole-value replace applies
|
|
62
|
+
* exactly as it did before this merge existed.
|
|
63
|
+
*/
|
|
64
|
+
function mergeSubPathOverrideValue(base, override) {
|
|
65
|
+
if (!isNamedObjectArray(override) || !isNamedObjectArray(base)) {
|
|
66
|
+
return override;
|
|
67
|
+
}
|
|
68
|
+
const baseByName = new Map(base.map((item) => [item.name, item]));
|
|
69
|
+
const merged = [];
|
|
70
|
+
const seenNames = new Set();
|
|
71
|
+
for (const overrideItem of override) {
|
|
72
|
+
// Guard against a corrupted/hand-edited override array carrying two
|
|
73
|
+
// elements with the same name — only the first is kept, so the merged
|
|
74
|
+
// result never has duplicate names.
|
|
75
|
+
if (seenNames.has(overrideItem.name)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
seenNames.add(overrideItem.name);
|
|
79
|
+
const baseItem = baseByName.get(overrideItem.name);
|
|
80
|
+
if (baseItem) {
|
|
81
|
+
merged.push({ ...baseItem, ...overrideItem });
|
|
82
|
+
}
|
|
83
|
+
else if (isSelfContainedOverrideElement(overrideItem)) {
|
|
84
|
+
merged.push(overrideItem);
|
|
85
|
+
}
|
|
86
|
+
// else: a thin reference to a name that no longer resolves — drop it.
|
|
87
|
+
}
|
|
88
|
+
const mergedNames = new Set(merged.map((item) => item.name));
|
|
89
|
+
const untouchedBaseItems = base.filter((item) => !mergedNames.has(item.name));
|
|
90
|
+
return [...merged, ...untouchedBaseItems];
|
|
91
|
+
}
|
|
92
|
+
// Marks each element of a fully-folded named-object array with whether it
|
|
93
|
+
// would still show up — present *and* not hidden — without the layer stored
|
|
94
|
+
// at the identity's own scope tier (`withoutOwnScope`, the same
|
|
95
|
+
// broadest-to-narrowest fold as `mergedValue` minus that one layer; see
|
|
96
|
+
// `applyConfigPathOverride`, where both folds are computed).
|
|
97
|
+
//
|
|
98
|
+
// This drives two decisions in the editing client, both about what its own
|
|
99
|
+
// scope's override has to say explicitly to get the effect the user asked
|
|
100
|
+
// for:
|
|
101
|
+
// - Removing an entry: `inherited` means hiding it (`visible: false`) is
|
|
102
|
+
// required, since deleting it from this scope's override would just have
|
|
103
|
+
// the next resolution fold bring it back from a broader layer. Not
|
|
104
|
+
// inherited means a plain delete suffices.
|
|
105
|
+
// - Adding/re-showing an entry: not inherited means the client must pin
|
|
106
|
+
// `visible: true` on it, since the broader fold either doesn't have it at
|
|
107
|
+
// all or has it hidden — and per-attribute merge semantics would otherwise
|
|
108
|
+
// inherit that `visible: false` straight through this scope's own
|
|
109
|
+
// override, leaving the entry invisible no matter how it's re-added.
|
|
110
|
+
// Hidden-ness is folded into `inherited` (rather than reported as a separate
|
|
111
|
+
// flag) precisely because both decisions only ever care whether the entry
|
|
112
|
+
// would be *there*, and a broader-hidden entry is exactly as absent from the
|
|
113
|
+
// user's view as a missing one.
|
|
114
|
+
//
|
|
115
|
+
// Only stamps when both values are named-object arrays; any other shape
|
|
116
|
+
// (scalar, plain object, unnamed array) is returned unchanged, mirroring
|
|
117
|
+
// `mergeSubPathOverrideValue`'s own shape guard.
|
|
118
|
+
function stampInherited(mergedValue, withoutOwnScope) {
|
|
119
|
+
if (!isNamedObjectArray(mergedValue) ||
|
|
120
|
+
!isNamedObjectArray(withoutOwnScope)) {
|
|
121
|
+
return mergedValue;
|
|
122
|
+
}
|
|
123
|
+
const inheritedByName = new Map(withoutOwnScope.map((item) => [item.name, item]));
|
|
124
|
+
return mergedValue.map((item) => {
|
|
125
|
+
const counterpart = inheritedByName.get(item.name);
|
|
126
|
+
return {
|
|
127
|
+
...item,
|
|
128
|
+
inherited: !!counterpart && counterpart.visible !== false,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function isRefElement(item) {
|
|
133
|
+
return (item !== null &&
|
|
134
|
+
typeof item === 'object' &&
|
|
135
|
+
typeof item.$ref === 'string');
|
|
136
|
+
}
|
|
137
|
+
// Resolves a config-path override array element's fragment-only `$ref`
|
|
138
|
+
// (e.g. `"#$.predefinedColumns[?(@.name=='email')]"`) against `refRoot` —
|
|
139
|
+
// the config-path's own sibling fields, not another file. This is
|
|
140
|
+
// deliberately narrower than `template`'s YAML `$ref` (processed by
|
|
141
|
+
// `processReferencedObject`/`getRefConfig`, which loads another config
|
|
142
|
+
// file): an override-array `$ref` only ever supports the `#<jsonpath>`
|
|
143
|
+
// self-reference form, resolved with the already-vendored `jsonpath`
|
|
144
|
+
// package (the same one `applyConfigPathOverride` uses for `subPath`
|
|
145
|
+
// strings). A ref that doesn't start with `#`, resolves to nothing, or
|
|
146
|
+
// resolves to a non-object is treated the same as any other unresolved
|
|
147
|
+
// override reference elsewhere in this module — silently dropped rather
|
|
148
|
+
// than thrown, so a stale reference just doesn't round-trip into the next
|
|
149
|
+
// save. Multiple matches take the first, in JSONPath document order.
|
|
150
|
+
//
|
|
151
|
+
// The resolved object keeps the original fragment string under `ref` (not
|
|
152
|
+
// `$ref` — GraphQL field names can't start with `$`) rather than dropping
|
|
153
|
+
// it once resolved. Without this, a client re-saving the array unchanged
|
|
154
|
+
// (e.g. reordering a different column) would have no way to tell this
|
|
155
|
+
// element apart from a plain inline column and would flatten it back into
|
|
156
|
+
// a frozen copy on that save — exactly the staleness problem `$ref` exists
|
|
157
|
+
// to avoid. `Column.ref` (the GraphQL field) surfaces it back to the client
|
|
158
|
+
// so `docupace-ui-main` can keep re-emitting the same `$ref` indefinitely.
|
|
159
|
+
function resolveOverrideElementRef(item, refRoot) {
|
|
160
|
+
const ref = item.$ref;
|
|
161
|
+
if (!ref.startsWith('#')) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const [match] = jp.query(refRoot, ref.slice(1));
|
|
165
|
+
if (match === null || typeof match !== 'object') {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
const { $ref: _unusedRef, ...localOverrides } = item;
|
|
169
|
+
return {
|
|
170
|
+
...match,
|
|
171
|
+
...localOverrides,
|
|
172
|
+
ref,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// Expands any `$ref` elements of a config-path override array into the
|
|
176
|
+
// full object they point at (merged with whatever local attributes the
|
|
177
|
+
// element carries alongside `$ref`, e.g. `visible`) before the array is
|
|
178
|
+
// handed to `mergeSubPathOverrideValue`. Runs first so that a `{ $ref,
|
|
179
|
+
// visible }` stub — which has no `name` of its own — always arrives at the
|
|
180
|
+
// name-keyed merge as a proper named object; `mergeSubPathOverrideValue`
|
|
181
|
+
// itself needs no `$ref` awareness.
|
|
182
|
+
function expandRefOverrideElements(value, refRoot) {
|
|
183
|
+
if (!Array.isArray(value)) {
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
return value
|
|
187
|
+
.map((item) => isRefElement(item) ? resolveOverrideElementRef(item, refRoot) : item)
|
|
188
|
+
.filter((item) => item !== undefined);
|
|
189
|
+
}
|
|
190
|
+
// Builds the object a config-path override's `$ref` fragments resolve
|
|
191
|
+
// against: the config being overridden, overlaid with the *raw* (not yet
|
|
192
|
+
// merged) override value of every other simple top-level subPath in this
|
|
193
|
+
// same patch (e.g. `$.predefinedColumns`). Using the raw override value
|
|
194
|
+
// rather than depending on loop order elsewhere in
|
|
195
|
+
// `applyConfigPathOverride` means a `$.columns` override's `$ref` into
|
|
196
|
+
// `$.predefinedColumns` resolves correctly regardless of which subPath
|
|
197
|
+
// happens to be processed first. Only single-segment paths (`$.foo`) are
|
|
198
|
+
// overlaid — nested subPaths (`$.filter.criteria`) fall back to whatever
|
|
199
|
+
// value is already on `config`, which is fine since nothing currently
|
|
200
|
+
// needs to `$ref` into a nested subPath.
|
|
201
|
+
//
|
|
202
|
+
// `subPathPatch` (the `all` half of `SubPathOverrideLayers`) carries an
|
|
203
|
+
// *ordered* array of layers per jsonPath
|
|
204
|
+
// (broadest to narrowest — see `ConfigurationOverrideResult.subPaths`), not
|
|
205
|
+
// a single already-resolved value. The overlay uses only the narrowest
|
|
206
|
+
// (last) layer, same approximation as before cascading existed: a catalog
|
|
207
|
+
// like `$.predefinedColumns`/`$.predefinedFilters` is always saved as a full
|
|
208
|
+
// replacement at a single scope in practice (never a partial patch spread
|
|
209
|
+
// across tiers), so the narrowest present layer already *is* effectively
|
|
210
|
+
// the whole value — exactly what this overlay used before this function
|
|
211
|
+
// took an array at all.
|
|
212
|
+
function buildRefResolutionRoot(config, subPathPatch) {
|
|
213
|
+
const overlay = {};
|
|
214
|
+
for (const [jsonPath, layers] of Object.entries(subPathPatch)) {
|
|
215
|
+
const topLevelMatch = /^\$\.([^.[]+)$/.exec(jsonPath);
|
|
216
|
+
if (topLevelMatch && layers.length) {
|
|
217
|
+
overlay[topLevelMatch[1]] = layers[layers.length - 1];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { ...config, ...overlay };
|
|
221
|
+
}
|
|
222
|
+
// Normalizes a provider's resolve() result into the pair of folds
|
|
223
|
+
// `applyConfigPathOverride` works with. `inherited` stays `null` (rather
|
|
224
|
+
// than defaulting to `{}`) when the provider doesn't report it, so the
|
|
225
|
+
// fallback there stays distinguishable from "reported, but this path has no
|
|
226
|
+
// broader layer".
|
|
227
|
+
function toSubPathOverrideLayers(override) {
|
|
228
|
+
if (!override.subPaths) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
all: override.subPaths,
|
|
233
|
+
inherited: override.inheritedSubPaths ?? null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
16
236
|
export class ConfigFileStore {
|
|
17
237
|
localPath;
|
|
18
238
|
remotePath;
|
|
@@ -155,6 +375,7 @@ class ConfigService {
|
|
|
155
375
|
configInjectorResolvers;
|
|
156
376
|
providers;
|
|
157
377
|
resolvers;
|
|
378
|
+
configurationOverrideProvider;
|
|
158
379
|
constructor(model, configStores, extensionOptions = {}) {
|
|
159
380
|
this.configStores = configStores;
|
|
160
381
|
this.model = model;
|
|
@@ -167,6 +388,8 @@ class ConfigService {
|
|
|
167
388
|
this.resolvers = extensionOptions.resolvers
|
|
168
389
|
? { ...extensionOptions.resolvers }
|
|
169
390
|
: {};
|
|
391
|
+
this.configurationOverrideProvider =
|
|
392
|
+
extensionOptions.configurationOverrideProvider;
|
|
170
393
|
if (this.model) {
|
|
171
394
|
this.generateResolvers();
|
|
172
395
|
}
|
|
@@ -232,12 +455,34 @@ class ConfigService {
|
|
|
232
455
|
}
|
|
233
456
|
return processedData;
|
|
234
457
|
}
|
|
458
|
+
applyReferenceExtensions(processedData, referenceConfig) {
|
|
459
|
+
if (typeof referenceConfig?.extend !== 'object') {
|
|
460
|
+
return processedData;
|
|
461
|
+
}
|
|
462
|
+
const { extend } = referenceConfig;
|
|
463
|
+
for (const key in extend) {
|
|
464
|
+
const extensionValue = extend[key];
|
|
465
|
+
if (!Array.isArray(extensionValue)) {
|
|
466
|
+
throw new GraphQLError(`$ref.extend.${key} must be an array; received ${typeof extensionValue}`);
|
|
467
|
+
}
|
|
468
|
+
const baseValue = processedData[key];
|
|
469
|
+
if (baseValue === undefined) {
|
|
470
|
+
processedData[key] = extensionValue;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (!Array.isArray(baseValue)) {
|
|
474
|
+
throw new GraphQLError(`$ref.extend.${key} can only extend an array property; "${key}" resolved to ${typeof baseValue}`);
|
|
475
|
+
}
|
|
476
|
+
processedData[key] = [...baseValue, ...extensionValue];
|
|
477
|
+
}
|
|
478
|
+
return processedData;
|
|
479
|
+
}
|
|
235
480
|
async processReferencedObject(configPath, obj, context, values) {
|
|
236
481
|
const referenceConfig = obj.$ref;
|
|
237
482
|
const refConfigPath = typeof referenceConfig === 'object' && referenceConfig.refPath
|
|
238
483
|
? referenceConfig.refPath
|
|
239
484
|
: referenceConfig;
|
|
240
|
-
const imageData = await this.processImageReference(refConfigPath);
|
|
485
|
+
const imageData = await this.processImageReference(refConfigPath, context);
|
|
241
486
|
if (imageData) {
|
|
242
487
|
return imageData;
|
|
243
488
|
}
|
|
@@ -246,6 +491,7 @@ class ConfigService {
|
|
|
246
491
|
? this.updatePathValues(referencedData, referenceConfig.path)
|
|
247
492
|
: referencedData;
|
|
248
493
|
processedData = this.applyReferenceOverrides(processedData, referenceConfig);
|
|
494
|
+
processedData = this.applyReferenceExtensions(processedData, referenceConfig);
|
|
249
495
|
processedData = await this.processObjectExpressions(processedData, values, context);
|
|
250
496
|
return this.processObject(configPath, processedData, context, values);
|
|
251
497
|
}
|
|
@@ -262,7 +508,16 @@ class ConfigService {
|
|
|
262
508
|
const builtInQueryResolvers = {
|
|
263
509
|
pages: () => ({
|
|
264
510
|
getPageConfig: async (obj, context) => {
|
|
265
|
-
|
|
511
|
+
// `impersonateRoleId`/`impersonateGeneral` are a plain
|
|
512
|
+
// pass-through onto context here — this engine never inspects
|
|
513
|
+
// identity itself; the host's resolveCurrentIdentity is what
|
|
514
|
+
// interprets them and enforces the admin-only restriction.
|
|
515
|
+
const impersonation = obj.impersonateRoleId
|
|
516
|
+
? { impersonateRoleId: obj.impersonateRoleId }
|
|
517
|
+
: obj.impersonateGeneral
|
|
518
|
+
? { impersonateGeneral: true }
|
|
519
|
+
: null;
|
|
520
|
+
const pageConfig = await this.getPageConfig(obj.path, impersonation ? { ...context, ...impersonation } : context, true, obj.context);
|
|
266
521
|
return pageConfig;
|
|
267
522
|
},
|
|
268
523
|
getAIConfig: async (obj, context) => {
|
|
@@ -346,29 +601,37 @@ class ConfigService {
|
|
|
346
601
|
return deepMergeResolvers(builtInMutationResolvers, hostMutationResolvers);
|
|
347
602
|
};
|
|
348
603
|
}
|
|
349
|
-
|
|
604
|
+
// Applies any stored config-path sub-path overrides across the whole
|
|
605
|
+
// config tree before processConfig's own recursive walk runs, so that
|
|
606
|
+
// overridden values (which may introduce new sub-config) still go
|
|
607
|
+
// through provider injection/__typename dispatch below.
|
|
608
|
+
//
|
|
609
|
+
// rootSubPathOverrides lets a caller that already resolved overrides for
|
|
610
|
+
// the root config (e.g. getConfigFromStore, which fetches the whole-object
|
|
611
|
+
// and sub-path overrides together in one round trip) hand them in directly
|
|
612
|
+
// instead of having the root node re-resolved a second time below; nested
|
|
613
|
+
// nodes discovered deeper in the tree are still resolved individually,
|
|
614
|
+
// since their configPaths aren't known until the tree has been loaded.
|
|
615
|
+
async processConfig(config, context, processProviders, rootSubPathOverrides) {
|
|
616
|
+
const overriddenConfig = await this.applyConfigPathOverride(config, context, processProviders, rootSubPathOverrides);
|
|
617
|
+
return this.processConfigTree(overriddenConfig, context, processProviders);
|
|
618
|
+
}
|
|
619
|
+
async processConfigTree(config, context, processProviders) {
|
|
350
620
|
if (!config) {
|
|
351
621
|
return config;
|
|
352
622
|
}
|
|
353
623
|
// Handle arrays
|
|
354
624
|
if (Array.isArray(config)) {
|
|
355
|
-
return Promise.all(config.map((item) => this.
|
|
625
|
+
return Promise.all(config.map((item) => this.processConfigTree(item, context, processProviders)));
|
|
356
626
|
}
|
|
357
627
|
// Handle objects
|
|
358
628
|
if (typeof config === 'object') {
|
|
359
629
|
// Recursively process all properties of config
|
|
360
630
|
let processedConfig = { ...config };
|
|
361
631
|
for (const key in processedConfig) {
|
|
362
|
-
processedConfig[key] = await this.
|
|
363
|
-
}
|
|
364
|
-
// First process the current object if it has __typename
|
|
365
|
-
if (processedConfig?.__typename && processProviders) {
|
|
366
|
-
const providerClass = this.providers[processedConfig.__typename];
|
|
367
|
-
if (providerClass) {
|
|
368
|
-
const provider = new providerClass();
|
|
369
|
-
processedConfig = await provider.processConfig(processedConfig, this, context);
|
|
370
|
-
}
|
|
632
|
+
processedConfig[key] = await this.processConfigTree(processedConfig[key], context, processProviders);
|
|
371
633
|
}
|
|
634
|
+
processedConfig = await this.applyTypenameProvider(processedConfig, context, processProviders);
|
|
372
635
|
return processedConfig;
|
|
373
636
|
}
|
|
374
637
|
if (processProviders) {
|
|
@@ -376,6 +639,104 @@ class ConfigService {
|
|
|
376
639
|
}
|
|
377
640
|
return config;
|
|
378
641
|
}
|
|
642
|
+
// Walks the whole config tree up front (pre-order) and, for every object
|
|
643
|
+
// that declares a configPath, deep-sets any stored sub-path overrides
|
|
644
|
+
// (JSONPath-addressed, e.g. "$.columns" or "$.filter.criteria").
|
|
645
|
+
//
|
|
646
|
+
// preFetchedSubPaths is only honored for the root node of the walk (the
|
|
647
|
+
// one being called with a defined third argument); nested calls always
|
|
648
|
+
// omit it so their own configPath gets resolved fresh.
|
|
649
|
+
async applyConfigPathOverride(config, context, processProviders, preFetchedSubPaths) {
|
|
650
|
+
if (!config) {
|
|
651
|
+
return config;
|
|
652
|
+
}
|
|
653
|
+
if (Array.isArray(config)) {
|
|
654
|
+
return Promise.all(config.map((item) => this.applyConfigPathOverride(item, context, processProviders)));
|
|
655
|
+
}
|
|
656
|
+
if (typeof config !== 'object') {
|
|
657
|
+
return config;
|
|
658
|
+
}
|
|
659
|
+
let result = config;
|
|
660
|
+
if (processProviders) {
|
|
661
|
+
const subPathPatch = preFetchedSubPaths !== undefined
|
|
662
|
+
? preFetchedSubPaths
|
|
663
|
+
: result.configPath && this.configurationOverrideProvider
|
|
664
|
+
? toSubPathOverrideLayers(await this.configurationOverrideProvider.resolve(result.configPath, context))
|
|
665
|
+
: null;
|
|
666
|
+
if (subPathPatch) {
|
|
667
|
+
const refRoot = buildRefResolutionRoot(result, subPathPatch.all);
|
|
668
|
+
// Deep-clone once, up front — `jp.value` mutates its target in
|
|
669
|
+
// place, and a shallow `{ ...result }` per jsonPath only protects
|
|
670
|
+
// the top level. A nested subPath (e.g. `$.filter.criteria`) would
|
|
671
|
+
// otherwise still mutate `result.filter`, which is the *same*
|
|
672
|
+
// object reference as the original, potentially-shared config
|
|
673
|
+
// (e.g. reused across tables via `extend`/fragment composition).
|
|
674
|
+
// Config values are always plain JSON (from YAML/domain storage),
|
|
675
|
+
// so a JSON round-trip clone is sufficient and avoids
|
|
676
|
+
// `structuredClone`'s cross-realm `instanceof` pitfalls (its output
|
|
677
|
+
// fails `instanceof Object` checks — e.g. inside the `jsonpath`
|
|
678
|
+
// package — when the calling code runs in a different VM
|
|
679
|
+
// realm/context, as test sandboxes commonly do).
|
|
680
|
+
result = JSON.parse(JSON.stringify(result));
|
|
681
|
+
for (const [jsonPath, layers] of Object.entries(subPathPatch.all)) {
|
|
682
|
+
// A single malformed override (an unparseable `$ref` JSONPath, or
|
|
683
|
+
// a jsonPath that doesn't resolve to a settable location) must not
|
|
684
|
+
// abort resolution of the rest of this config tree — that would
|
|
685
|
+
// take down the page for every user over one bad admin-authored
|
|
686
|
+
// row. Log and skip just this jsonPath instead.
|
|
687
|
+
try {
|
|
688
|
+
const currentValue = jp.value(result, jsonPath);
|
|
689
|
+
const expandedLayers = layers.map((layer) => expandRefOverrideElements(layer, refRoot));
|
|
690
|
+
// `layers` is ordered broadest to narrowest (general, tenant,
|
|
691
|
+
// role, user — see `ConfigurationOverrideResult.subPaths`), so
|
|
692
|
+
// folding them in order onto `currentValue` cascades correctly:
|
|
693
|
+
// each layer only patches what it mentions, inheriting
|
|
694
|
+
// everything else from the fold so far.
|
|
695
|
+
const mergedValue = expandedLayers.reduce(mergeSubPathOverrideValue, currentValue);
|
|
696
|
+
// The same fold minus the identity's own-scope layer — "what
|
|
697
|
+
// this identity would see with no override of its own", which
|
|
698
|
+
// is what `inherited` is stamped against. The provider reports
|
|
699
|
+
// those layers explicitly (`inheritedSubPaths`) because the
|
|
700
|
+
// tier a layer is stored at isn't recoverable from the values
|
|
701
|
+
// alone; dropping the last layer is only a fallback for a
|
|
702
|
+
// provider that doesn't, and is wrong whenever the identity has
|
|
703
|
+
// no row of its own yet (the layer dropped is then a broader
|
|
704
|
+
// one that very much still applies).
|
|
705
|
+
const inheritedLayers = subPathPatch.inherited
|
|
706
|
+
? (subPathPatch.inherited[jsonPath] ?? [])
|
|
707
|
+
: layers.slice(0, -1);
|
|
708
|
+
const withoutOwnScope = inheritedLayers
|
|
709
|
+
.map((layer) => expandRefOverrideElements(layer, refRoot))
|
|
710
|
+
.reduce(mergeSubPathOverrideValue, currentValue);
|
|
711
|
+
const finalValue = stampInherited(mergedValue, withoutOwnScope);
|
|
712
|
+
const applied = jp.value(result, jsonPath, finalValue);
|
|
713
|
+
if (applied === undefined) {
|
|
714
|
+
throw new Error(`does not resolve to a settable location: "${jsonPath}"`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
log.warn(`Skipping configuration override for "${result.configPath}" at "${jsonPath}": ${error.message}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const overriddenEntries = await Promise.all(Object.entries(result).map(async ([key, value]) => [
|
|
724
|
+
key,
|
|
725
|
+
await this.applyConfigPathOverride(value, context, processProviders),
|
|
726
|
+
]));
|
|
727
|
+
return Object.fromEntries(overriddenEntries);
|
|
728
|
+
}
|
|
729
|
+
async applyTypenameProvider(processedConfig, context, processProviders) {
|
|
730
|
+
if (!processedConfig?.__typename || !processProviders) {
|
|
731
|
+
return processedConfig;
|
|
732
|
+
}
|
|
733
|
+
const providerClass = this.providers[processedConfig.__typename];
|
|
734
|
+
if (!providerClass) {
|
|
735
|
+
return processedConfig;
|
|
736
|
+
}
|
|
737
|
+
const provider = new providerClass();
|
|
738
|
+
return await provider.processConfig(processedConfig, this, context);
|
|
739
|
+
}
|
|
379
740
|
async getRefConfig(configPath, refConfigPath, context, values) {
|
|
380
741
|
let fullPath;
|
|
381
742
|
if (refConfigPath.startsWith('.')) {
|
|
@@ -462,13 +823,21 @@ class ConfigService {
|
|
|
462
823
|
};
|
|
463
824
|
return await processValue(object);
|
|
464
825
|
}
|
|
465
|
-
async processImageReference(refConfigPath) {
|
|
826
|
+
async processImageReference(refConfigPath, context) {
|
|
466
827
|
const SUPPORTED_IMAGE_FORMATS = ['.jpg', '.jpeg', '.png', '.svg'];
|
|
467
828
|
// Check if the reference is an image file
|
|
468
829
|
const ext = path.extname(refConfigPath).toLowerCase();
|
|
469
830
|
if (!SUPPORTED_IMAGE_FORMATS.includes(ext)) {
|
|
470
831
|
return null;
|
|
471
832
|
}
|
|
833
|
+
const override = await this.configurationOverrideProvider?.resolve(refConfigPath, context);
|
|
834
|
+
if (override?.whole?.contentType) {
|
|
835
|
+
return {
|
|
836
|
+
data: override.whole.value,
|
|
837
|
+
mimeType: override.whole.contentType,
|
|
838
|
+
hash: createHash('sha256').update(override.whole.value).digest('hex'),
|
|
839
|
+
};
|
|
840
|
+
}
|
|
472
841
|
let standardStore;
|
|
473
842
|
for (const store of this.configStores) {
|
|
474
843
|
if (store instanceof ConfigFileStore) {
|
|
@@ -520,19 +889,33 @@ class ConfigService {
|
|
|
520
889
|
return 'application/octet-stream';
|
|
521
890
|
}
|
|
522
891
|
}
|
|
523
|
-
|
|
892
|
+
// Resolves the whole-object override and any sub-path overrides for
|
|
893
|
+
// configPath together, in one provider round trip, instead of fetching
|
|
894
|
+
// them separately at different points of the processing pipeline. The
|
|
895
|
+
// sub-path overrides are handed back alongside the config so callers can
|
|
896
|
+
// thread them into processConfig rather than re-resolving them later.
|
|
897
|
+
async getConfigFromStore(configPath, context) {
|
|
898
|
+
const override = await this.configurationOverrideProvider?.resolve(configPath, context);
|
|
899
|
+
const subPathOverrides = override
|
|
900
|
+
? toSubPathOverrideLayers(override)
|
|
901
|
+
: null;
|
|
902
|
+
if (override?.whole && !override.whole.contentType) {
|
|
903
|
+
return { config: load(override.whole.value), subPathOverrides };
|
|
904
|
+
}
|
|
524
905
|
const exceptions = [];
|
|
525
906
|
for (const [index, configStore] of this.configStores.entries()) {
|
|
526
907
|
try {
|
|
527
|
-
|
|
908
|
+
const config = await configStore.getConfig(configPath);
|
|
909
|
+
return { config, subPathOverrides };
|
|
528
910
|
}
|
|
529
911
|
catch (e) {
|
|
530
912
|
exceptions.push(e);
|
|
531
913
|
if (index == this.configStores.length - 1) {
|
|
532
|
-
throw new GraphQLError(`Config not found in path in any store: ${
|
|
914
|
+
throw new GraphQLError(`Config not found in path in any store: ${configPath}`, { originalError: exceptions });
|
|
533
915
|
}
|
|
534
916
|
}
|
|
535
917
|
}
|
|
918
|
+
return { config: undefined, subPathOverrides };
|
|
536
919
|
}
|
|
537
920
|
async hasConfig(configPath) {
|
|
538
921
|
return (await Promise.all(this.configStores.map((configStore) => configStore.hasConfig(configPath)))).some(Boolean);
|
|
@@ -542,9 +925,9 @@ class ConfigService {
|
|
|
542
925
|
return this.getDirectConfig(globalPath, context, values, processProviders);
|
|
543
926
|
}
|
|
544
927
|
async getDirectConfig(configPath, context, values, processProviders) {
|
|
545
|
-
const obj = await this.getConfigFromStore(configPath);
|
|
928
|
+
const { config: obj, subPathOverrides } = await this.getConfigFromStore(configPath, context);
|
|
546
929
|
const fullObj = await this.processObject(configPath, obj, context, values);
|
|
547
|
-
const processedObj = await this.processConfig(fullObj, context, processProviders);
|
|
930
|
+
const processedObj = await this.processConfig(fullObj, context, processProviders, subPathOverrides);
|
|
548
931
|
return processedObj;
|
|
549
932
|
}
|
|
550
933
|
async listDirectConfigs(configPath) {
|
|
@@ -895,21 +1278,22 @@ class ConfigService {
|
|
|
895
1278
|
}
|
|
896
1279
|
async getPageConfig(pagePath, context, processConfig, rulesContext) {
|
|
897
1280
|
const globalPath = path.join('global', 'pages', pagePath);
|
|
898
|
-
const obj = await this.getConfigFromStore(globalPath);
|
|
1281
|
+
const { config: obj, subPathOverrides } = await this.getConfigFromStore(globalPath, context);
|
|
899
1282
|
const fullObj = await this.processObject(globalPath, obj, context, null);
|
|
900
1283
|
const matchingRuleGroups = await this.getRuleGroups(rulesContext, context);
|
|
901
1284
|
const configToProcess = this.applyFieldRuleOverrides(fullObj, matchingRuleGroups);
|
|
902
|
-
return await this.processConfig(configToProcess, context, processConfig);
|
|
1285
|
+
return await this.processConfig(configToProcess, context, processConfig, subPathOverrides);
|
|
903
1286
|
}
|
|
904
1287
|
async init() {
|
|
905
1288
|
return Promise.all(this.configStores.map((configStore) => configStore.init()));
|
|
906
1289
|
}
|
|
907
1290
|
}
|
|
908
|
-
export const defaultConfigService = ({ model, configStores, configInjectorResolvers, providers, resolvers, }) => {
|
|
1291
|
+
export const defaultConfigService = ({ model, configStores, configInjectorResolvers, providers, resolvers, configurationOverrideProvider, }) => {
|
|
909
1292
|
const extensionOptions = {
|
|
910
1293
|
configInjectorResolvers,
|
|
911
1294
|
providers,
|
|
912
1295
|
resolvers,
|
|
1296
|
+
configurationOverrideProvider,
|
|
913
1297
|
};
|
|
914
1298
|
return new ConfigService(model, configStores.map((configStore) => new ConfigFileStore(configStore)), extensionOptions);
|
|
915
1299
|
};
|