@docupace/ihub-config 1.1.25 → 1.1.26-sync.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 +72 -1
- 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 +31 -4
- package/dist/config-service.d.ts.map +1 -1
- package/dist/config-service.js +513 -24
- package/dist/config-service.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/named-array-merge.d.ts +29 -0
- package/dist/lib/named-array-merge.d.ts.map +1 -0
- package/dist/lib/named-array-merge.js +158 -0
- package/dist/lib/named-array-merge.js.map +1 -0
- package/dist/mappers/config-mapper.d.ts.map +1 -1
- package/dist/mappers/config-mapper.js +2 -0
- package/dist/mappers/config-mapper.js.map +1 -1
- package/dist/model/graphs/config/fields.graphql +45 -0
- package/dist/model/graphs/config/menu.graphql +6 -0
- package/dist/model/graphs/config/page.graphql +8 -1
- package/dist/model/graphs/config/panel.graphql +7 -0
- package/dist/model/graphs/config/query.graphql +9 -1
- package/dist/model/graphs/config/selectOptions.graphql +1 -0
- package/dist/model/graphs/config/table.graphql +26 -0
- package/dist/model/graphs/config/ui-components.graphql +1 -0
- package/dist/providers/index.d.ts +4 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +5 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/screen-layout-patch.d.ts +6 -0
- package/dist/providers/screen-layout-patch.d.ts.map +1 -0
- package/dist/providers/screen-layout-patch.js +57 -0
- package/dist/providers/screen-layout-patch.js.map +1 -0
- package/dist/providers/whole-patch.d.ts +6 -0
- package/dist/providers/whole-patch.d.ts.map +1 -0
- package/dist/providers/whole-patch.js +2 -0
- package/dist/providers/whole-patch.js.map +1 -0
- package/dist/types/graphql.d.ts +4 -2
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/graphql.js.map +1 -1
- package/model/graphs/config/fields.graphql +45 -0
- package/model/graphs/config/menu.graphql +6 -0
- package/model/graphs/config/page.graphql +8 -1
- package/model/graphs/config/panel.graphql +7 -0
- package/model/graphs/config/query.graphql +9 -1
- package/model/graphs/config/selectOptions.graphql +1 -0
- package/model/graphs/config/table.graphql +26 -0
- package/model/graphs/config/ui-components.graphql +1 -0
- package/package.json +5 -3
package/dist/config-service.js
CHANGED
|
@@ -7,12 +7,299 @@ import { fileExists } from './lib/file-system.js';
|
|
|
7
7
|
import replaceDotsOutsideExpressions from './lib/replace-dots-outside-expressions.js';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { injectConfigValue, } from './injector-resolvers/config-injector.js';
|
|
10
|
-
import { builtInProviders } from './providers/index.js';
|
|
10
|
+
import { builtInProviders, builtInWholePatchMergers, } 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 { isNamedObjectArray, mergeNamedObjectArray, } from './lib/named-array-merge.js';
|
|
15
|
+
import jp from 'jsonpath';
|
|
14
16
|
const FULL_OBJECT_EXPRESSION_PATTERN = /^\${(.*?)}$/;
|
|
15
17
|
const INLINE_OBJECT_EXPRESSION_PATTERN = /\${(.*?)}/g;
|
|
18
|
+
// An override element counts as "self-contained" when it carries enough of
|
|
19
|
+
// its own definition to render on its own — a real attribute path (or paths)
|
|
20
|
+
// or a computed expression — as opposed to a thin `{ name, visible }`-style
|
|
21
|
+
// reference that only makes sense resolved against a base/predefined
|
|
22
|
+
// counterpart of the same name. `paths` (plural) is `Column`'s shape;
|
|
23
|
+
// `path` (singular) is `TableCriteria`'s — both must count, or a resolved
|
|
24
|
+
// filter-criterion `$ref` (which only ever has a singular `path`) is wrongly
|
|
25
|
+
// treated as a dangling reference and silently dropped, even though it just
|
|
26
|
+
// resolved successfully.
|
|
27
|
+
function isSelfContainedOverrideElement(item) {
|
|
28
|
+
const paths = item.paths;
|
|
29
|
+
return ((Array.isArray(paths) && paths.length > 0) ||
|
|
30
|
+
(typeof item.path === 'string' && item.path.length > 0) ||
|
|
31
|
+
(typeof item.expression === 'string' && item.expression.length > 0));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Merge semantics for a config sub-path override against the value already at that
|
|
35
|
+
* JSONPath. Generic — used for any typename/sub-path the configPath/subPath mechanism
|
|
36
|
+
* resolves, not just `Table.columns` — a thin wrapper over the shared
|
|
37
|
+
* `mergeNamedObjectArray` (see `named-array-merge.ts` for the full algorithm), fixed to
|
|
38
|
+
* this call site's own definition of "self-contained" (`isSelfContainedOverrideElement`).
|
|
39
|
+
* Any other shape (scalars, plain objects, arrays without a `name` key) is returned
|
|
40
|
+
* untouched, so the caller's plain whole-value replace applies exactly as it did before
|
|
41
|
+
* this merge existed.
|
|
42
|
+
*/
|
|
43
|
+
function mergeSubPathOverrideValue(base, override) {
|
|
44
|
+
if (!isNamedObjectArray(override) || !isNamedObjectArray(base)) {
|
|
45
|
+
return override;
|
|
46
|
+
}
|
|
47
|
+
return mergeNamedObjectArray(base, override, isSelfContainedOverrideElement);
|
|
48
|
+
}
|
|
49
|
+
// Marks each element of a fully-folded named-object array with whether it
|
|
50
|
+
// would still show up — present *and* not hidden — without the layer stored
|
|
51
|
+
// at the identity's own scope tier (`withoutOwnScope`, the same
|
|
52
|
+
// broadest-to-narrowest fold as `mergedValue` minus that one layer; see
|
|
53
|
+
// `applyConfigPathOverride`, where both folds are computed).
|
|
54
|
+
//
|
|
55
|
+
// This drives two decisions in the editing client, both about what its own
|
|
56
|
+
// scope's override has to say explicitly to get the effect the user asked
|
|
57
|
+
// for:
|
|
58
|
+
// - Removing an entry: `inherited` means hiding it (`visible: false`) is
|
|
59
|
+
// required, since deleting it from this scope's override would just have
|
|
60
|
+
// the next resolution fold bring it back from a broader layer. Not
|
|
61
|
+
// inherited means a plain delete suffices.
|
|
62
|
+
// - Adding/re-showing an entry: not inherited means the client must pin
|
|
63
|
+
// `visible: true` on it, since the broader fold either doesn't have it at
|
|
64
|
+
// all or has it hidden — and per-attribute merge semantics would otherwise
|
|
65
|
+
// inherit that `visible: false` straight through this scope's own
|
|
66
|
+
// override, leaving the entry invisible no matter how it's re-added.
|
|
67
|
+
// Hidden-ness is folded into `inherited` (rather than reported as a separate
|
|
68
|
+
// flag) precisely because both decisions only ever care whether the entry
|
|
69
|
+
// would be *there*, and a broader-hidden entry is exactly as absent from the
|
|
70
|
+
// user's view as a missing one.
|
|
71
|
+
//
|
|
72
|
+
// Only stamps when both values are named-object arrays; any other shape
|
|
73
|
+
// (scalar, plain object, unnamed array) is returned unchanged, mirroring
|
|
74
|
+
// `mergeSubPathOverrideValue`'s own shape guard.
|
|
75
|
+
function stampInherited(mergedValue, withoutOwnScope) {
|
|
76
|
+
if (!isNamedObjectArray(mergedValue) ||
|
|
77
|
+
!isNamedObjectArray(withoutOwnScope)) {
|
|
78
|
+
return mergedValue;
|
|
79
|
+
}
|
|
80
|
+
const inheritedByName = new Map(withoutOwnScope.map((item) => [item.name, item]));
|
|
81
|
+
return mergedValue.map((item) => {
|
|
82
|
+
const counterpart = inheritedByName.get(item.name);
|
|
83
|
+
return {
|
|
84
|
+
...item,
|
|
85
|
+
inherited: !!counterpart && counterpart.visible !== false,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function isRefElement(item) {
|
|
90
|
+
return (item !== null &&
|
|
91
|
+
typeof item === 'object' &&
|
|
92
|
+
typeof item.$ref === 'string');
|
|
93
|
+
}
|
|
94
|
+
// Resolves a config-path override array element's fragment-only `$ref`
|
|
95
|
+
// (e.g. `"#$.predefinedColumns[?(@.name=='email')]"`) against `refRoot` —
|
|
96
|
+
// the config-path's own sibling fields, not another file. This is
|
|
97
|
+
// deliberately narrower than `template`'s YAML `$ref` (processed by
|
|
98
|
+
// `processReferencedObject`/`getRefConfig`, which loads another config
|
|
99
|
+
// file): an override-array `$ref` only ever supports the `#<jsonpath>`
|
|
100
|
+
// self-reference form, resolved with the already-vendored `jsonpath`
|
|
101
|
+
// package (the same one `applyConfigPathOverride` uses for `subPath`
|
|
102
|
+
// strings). A ref that doesn't start with `#`, resolves to nothing, or
|
|
103
|
+
// resolves to a non-object is treated the same as any other unresolved
|
|
104
|
+
// override reference elsewhere in this module — silently dropped rather
|
|
105
|
+
// than thrown, so a stale reference just doesn't round-trip into the next
|
|
106
|
+
// save. Multiple matches take the first, in JSONPath document order.
|
|
107
|
+
//
|
|
108
|
+
// The resolved object keeps the original fragment string under `ref` (not
|
|
109
|
+
// `$ref` — GraphQL field names can't start with `$`) rather than dropping
|
|
110
|
+
// it once resolved. Without this, a client re-saving the array unchanged
|
|
111
|
+
// (e.g. reordering a different column) would have no way to tell this
|
|
112
|
+
// element apart from a plain inline column and would flatten it back into
|
|
113
|
+
// a frozen copy on that save — exactly the staleness problem `$ref` exists
|
|
114
|
+
// to avoid. `Column.ref` (the GraphQL field) surfaces it back to the client
|
|
115
|
+
// so `docupace-ui-main` can keep re-emitting the same `$ref` indefinitely.
|
|
116
|
+
function resolveOverrideElementRef(item, refRoot) {
|
|
117
|
+
const ref = item.$ref;
|
|
118
|
+
if (!ref.startsWith('#')) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const [match] = jp.query(refRoot, ref.slice(1));
|
|
122
|
+
if (match === null || typeof match !== 'object') {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const { $ref: _unusedRef, ...localOverrides } = item;
|
|
126
|
+
return {
|
|
127
|
+
...match,
|
|
128
|
+
...localOverrides,
|
|
129
|
+
ref,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Expands any `$ref` elements of a config-path override array into the
|
|
133
|
+
// full object they point at (merged with whatever local attributes the
|
|
134
|
+
// element carries alongside `$ref`, e.g. `visible`) before the array is
|
|
135
|
+
// handed to `mergeSubPathOverrideValue`. Runs first so that a `{ $ref,
|
|
136
|
+
// visible }` stub — which has no `name` of its own — always arrives at the
|
|
137
|
+
// name-keyed merge as a proper named object; `mergeSubPathOverrideValue`
|
|
138
|
+
// itself needs no `$ref` awareness.
|
|
139
|
+
function expandRefOverrideElements(value, refRoot) {
|
|
140
|
+
if (!Array.isArray(value)) {
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
return value
|
|
144
|
+
.map((item) => isRefElement(item) ? resolveOverrideElementRef(item, refRoot) : item)
|
|
145
|
+
.filter((item) => item !== undefined);
|
|
146
|
+
}
|
|
147
|
+
// Builds the object a config-path override's `$ref` fragments resolve
|
|
148
|
+
// against: the config being overridden, overlaid with the *raw* (not yet
|
|
149
|
+
// merged) override value of every other simple top-level subPath in this
|
|
150
|
+
// same patch (e.g. `$.predefinedColumns`). Using the raw override value
|
|
151
|
+
// rather than depending on loop order elsewhere in
|
|
152
|
+
// `applyConfigPathOverride` means a `$.columns` override's `$ref` into
|
|
153
|
+
// `$.predefinedColumns` resolves correctly regardless of which subPath
|
|
154
|
+
// happens to be processed first. Only single-segment paths (`$.foo`) are
|
|
155
|
+
// overlaid — nested subPaths (`$.filter.criteria`) fall back to whatever
|
|
156
|
+
// value is already on `config`, which is fine since nothing currently
|
|
157
|
+
// needs to `$ref` into a nested subPath.
|
|
158
|
+
//
|
|
159
|
+
// `subPathPatch` (the `all` half of `SubPathOverrideLayers`) carries an
|
|
160
|
+
// *ordered* array of layers per jsonPath
|
|
161
|
+
// (broadest to narrowest — see `ConfigurationOverrideResult.subPaths`), not
|
|
162
|
+
// a single already-resolved value. The overlay uses only the narrowest
|
|
163
|
+
// (last) layer, same approximation as before cascading existed: a catalog
|
|
164
|
+
// like `$.predefinedColumns`/`$.predefinedFilters` is always saved as a full
|
|
165
|
+
// replacement at a single scope in practice (never a partial patch spread
|
|
166
|
+
// across tiers), so the narrowest present layer already *is* effectively
|
|
167
|
+
// the whole value — exactly what this overlay used before this function
|
|
168
|
+
// took an array at all.
|
|
169
|
+
function buildRefResolutionRoot(config, subPathPatch) {
|
|
170
|
+
const overlay = {};
|
|
171
|
+
for (const [jsonPath, layers] of Object.entries(subPathPatch)) {
|
|
172
|
+
const topLevelMatch = /^\$\.([^.[]+)$/.exec(jsonPath);
|
|
173
|
+
if (topLevelMatch && layers.length) {
|
|
174
|
+
overlay[topLevelMatch[1]] = layers[layers.length - 1];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { ...config, ...overlay };
|
|
178
|
+
}
|
|
179
|
+
// Normalizes a provider's resolve() result into the pair of folds
|
|
180
|
+
// `applyConfigPathOverride` works with. `inherited` stays `null` (rather
|
|
181
|
+
// than defaulting to `{}`) when the provider doesn't report it, so the
|
|
182
|
+
// fallback there stays distinguishable from "reported, but this path has no
|
|
183
|
+
// broader layer".
|
|
184
|
+
function toSubPathOverrideLayers(override) {
|
|
185
|
+
if (!override.subPaths) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
all: override.subPaths,
|
|
190
|
+
inherited: override.inheritedSubPaths ?? null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
// Folds one jsonPath's override layers onto the value currently at that
|
|
194
|
+
// location in `target`, and writes the result back in place.
|
|
195
|
+
//
|
|
196
|
+
// `layers` is ordered broadest to narrowest (general, tenant, role, user —
|
|
197
|
+
// see `ConfigurationOverrideResult.subPaths`), so folding them in order onto
|
|
198
|
+
// the current value cascades correctly: each layer only patches what it
|
|
199
|
+
// mentions, inheriting everything else from the fold so far.
|
|
200
|
+
//
|
|
201
|
+
// `inherited` drives the second fold — the same one minus the identity's
|
|
202
|
+
// own-scope layer, i.e. "what this identity would see with no override of
|
|
203
|
+
// its own", which is what `inherited` is stamped against. The provider
|
|
204
|
+
// reports those layers explicitly (`inheritedSubPaths`) because the tier a
|
|
205
|
+
// layer is stored at isn't recoverable from the values alone; dropping the
|
|
206
|
+
// last layer is only a fallback for a provider that doesn't, and is wrong
|
|
207
|
+
// whenever the identity has no row of its own yet (the layer dropped is then
|
|
208
|
+
// a broader one that very much still applies).
|
|
209
|
+
//
|
|
210
|
+
// Throws when `jsonPath` doesn't resolve to a settable location; the caller
|
|
211
|
+
// decides what to do with that.
|
|
212
|
+
function applySubPathOverrideLayers(target, jsonPath, layers, inherited, refRoot) {
|
|
213
|
+
const currentValue = jp.value(target, jsonPath);
|
|
214
|
+
const mergedValue = layers
|
|
215
|
+
.map((layer) => expandRefOverrideElements(layer, refRoot))
|
|
216
|
+
.reduce((base, override) => mergeSubPathOverrideValue(base, override), currentValue);
|
|
217
|
+
const inheritedLayers = inherited
|
|
218
|
+
? (inherited[jsonPath] ?? [])
|
|
219
|
+
: layers.slice(0, -1);
|
|
220
|
+
const withoutOwnScope = inheritedLayers
|
|
221
|
+
.map((layer) => expandRefOverrideElements(layer, refRoot))
|
|
222
|
+
.reduce((base, override) => mergeSubPathOverrideValue(base, override), currentValue);
|
|
223
|
+
const finalValue = stampInherited(mergedValue, withoutOwnScope);
|
|
224
|
+
const applied = jp.value(target, jsonPath, finalValue);
|
|
225
|
+
if (applied === undefined) {
|
|
226
|
+
throw new Error(`does not resolve to a settable location: "${jsonPath}"`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Deep-sets every stored sub-path override of `subPathPatch` onto a clone of
|
|
230
|
+
// `config`, returning the clone. `config` itself is never mutated.
|
|
231
|
+
function applySubPathOverrides(config, subPathPatch) {
|
|
232
|
+
// Built from the pre-clone config on purpose: `$ref` fragments resolve
|
|
233
|
+
// against the config as it was handed in, not against whatever earlier
|
|
234
|
+
// jsonPaths in this same loop have already written.
|
|
235
|
+
const refRoot = buildRefResolutionRoot(config, subPathPatch.all);
|
|
236
|
+
// Deep-clone once, up front — `jp.value` mutates its target in place, and
|
|
237
|
+
// a shallow `{ ...config }` per jsonPath only protects the top level. A
|
|
238
|
+
// nested subPath (e.g. `$.filter.criteria`) would otherwise still mutate
|
|
239
|
+
// `config.filter`, which is the *same* object reference as the original,
|
|
240
|
+
// potentially-shared config (e.g. reused across tables via
|
|
241
|
+
// `extend`/fragment composition). Config values are always plain JSON
|
|
242
|
+
// (from YAML/domain storage), so a JSON round-trip clone is sufficient and
|
|
243
|
+
// avoids `structuredClone`'s cross-realm `instanceof` pitfalls (its output
|
|
244
|
+
// fails `instanceof Object` checks — e.g. inside the `jsonpath` package —
|
|
245
|
+
// when the calling code runs in a different VM realm/context, as test
|
|
246
|
+
// sandboxes commonly do).
|
|
247
|
+
const result = JSON.parse(JSON.stringify(config));
|
|
248
|
+
for (const [jsonPath, layers] of Object.entries(subPathPatch.all)) {
|
|
249
|
+
// A single malformed override (an unparseable `$ref` JSONPath, or a
|
|
250
|
+
// jsonPath that doesn't resolve to a settable location) must not abort
|
|
251
|
+
// resolution of the rest of this config tree — that would take down the
|
|
252
|
+
// page for every user over one bad admin-authored row. Log and skip just
|
|
253
|
+
// this jsonPath instead.
|
|
254
|
+
try {
|
|
255
|
+
applySubPathOverrideLayers(result, jsonPath, layers, subPathPatch.inherited, refRoot);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
log.warn(`Skipping configuration override for "${result.configPath}" at "${jsonPath}": ${error.message}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
// Domain 735 rows scoped to a Role sync under a `roles/<roleName>/**` tree
|
|
264
|
+
// that mirrors the existing `global/**` tree, instead of a filename suffix —
|
|
265
|
+
// keeps the sibling sub-path convention below identical regardless of scope.
|
|
266
|
+
// Keyed by the role's name rather than its id so the path is human-readable
|
|
267
|
+
// in the site repo; callers are responsible for keeping writes (Config
|
|
268
|
+
// Sync) and reads (`configuration-override-service.ts`'s `resolve()`)
|
|
269
|
+
// pointed at the same role name — renaming a role orphans its previously
|
|
270
|
+
// synced file, and two roles sharing a name would collide on this path.
|
|
271
|
+
function sanitizeSubPathSegment(subPath) {
|
|
272
|
+
return subPath.replace(/[^A-Za-z0-9_.$()[\]'@=<> -]/g, '_');
|
|
273
|
+
}
|
|
274
|
+
// Role names are admin-entered free text (unlike a role id) — strip
|
|
275
|
+
// anything that isn't safe as a single flat directory segment (notably
|
|
276
|
+
// `/`, which would otherwise nest into subdirectories).
|
|
277
|
+
function sanitizeRoleNameSegment(roleName) {
|
|
278
|
+
return roleName.replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
279
|
+
}
|
|
280
|
+
// The sub-path sibling-file convention: a JSONPath override (e.g. `$.columns`)
|
|
281
|
+
// on `dataTypes/IContact/tabs/employmentTab/fields` lives at
|
|
282
|
+
// `dataTypes/IContact/tabs/employmentTab/fields.$.columns`, next to the base
|
|
283
|
+
// config it patches, rather than inside it.
|
|
284
|
+
function siblingConfigPath(basePath, subPath) {
|
|
285
|
+
const dir = path.dirname(basePath);
|
|
286
|
+
const siblingName = `${path.basename(basePath)}.${sanitizeSubPathSegment(subPath)}`;
|
|
287
|
+
return dir === '.' ? siblingName : path.join(dir, siblingName);
|
|
288
|
+
}
|
|
289
|
+
// Builds the repo-relative path a (configPath, roleName?, subPath?) tuple
|
|
290
|
+
// resolves to under a `ConfigFileStore`'s root — `global/<configPath>` (or
|
|
291
|
+
// its `roles/<roleName>/<configPath>` sibling), with the sub-path suffix
|
|
292
|
+
// applied first when present. Exported as a pure function so callers that
|
|
293
|
+
// only need the path (e.g. computing a sync target path for a diff) don't
|
|
294
|
+
// need a `ConfigFileStore` instance to do the math.
|
|
295
|
+
export function buildScopedConfigPath(configPath, { roleName, subPath } = {}) {
|
|
296
|
+
const effectivePath = subPath
|
|
297
|
+
? siblingConfigPath(configPath, subPath)
|
|
298
|
+
: configPath;
|
|
299
|
+
return roleName
|
|
300
|
+
? path.join('roles', sanitizeRoleNameSegment(roleName), effectivePath)
|
|
301
|
+
: path.join('global', effectivePath);
|
|
302
|
+
}
|
|
16
303
|
export class ConfigFileStore {
|
|
17
304
|
localPath;
|
|
18
305
|
remotePath;
|
|
@@ -109,8 +396,43 @@ export class ConfigFileStore {
|
|
|
109
396
|
const fullPath = path.join(this.localPath, configPath);
|
|
110
397
|
const finalPath = await this.getFinalPath(fullPath);
|
|
111
398
|
const yamlOutput = dump(config);
|
|
399
|
+
// A role-scoped (`roles/<roleName>/...`) or sub-path sibling file may be
|
|
400
|
+
// the first file ever written under its directory — unlike the
|
|
401
|
+
// pre-existing `global/**` tree, that directory isn't guaranteed to
|
|
402
|
+
// exist yet.
|
|
403
|
+
await fsPromises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
112
404
|
await fsPromises.writeFile(finalPath, yamlOutput, 'utf8');
|
|
113
405
|
}
|
|
406
|
+
// Role-aware, sub-path-aware lookup on top of this store's plain
|
|
407
|
+
// `hasConfig`/`getConfig`/`updateConfig` — resolves a role-scoped file
|
|
408
|
+
// first when `roleName` is given, falling back to the global path when no
|
|
409
|
+
// role-scoped file exists yet (mirrors domain 735's GENERAL→ROLE cascade).
|
|
410
|
+
async hasScopedConfig(configPath, lookup = {}) {
|
|
411
|
+
if (lookup.roleName) {
|
|
412
|
+
const rolePath = buildScopedConfigPath(configPath, lookup);
|
|
413
|
+
if (await this.hasConfig(rolePath)) {
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return this.hasConfig(buildScopedConfigPath(configPath, { subPath: lookup.subPath }));
|
|
418
|
+
}
|
|
419
|
+
async getScopedConfig(configPath, lookup = {}) {
|
|
420
|
+
if (lookup.roleName) {
|
|
421
|
+
const rolePath = buildScopedConfigPath(configPath, lookup);
|
|
422
|
+
if (await this.hasConfig(rolePath)) {
|
|
423
|
+
return this.getConfig(rolePath);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const globalPath = buildScopedConfigPath(configPath, {
|
|
427
|
+
subPath: lookup.subPath,
|
|
428
|
+
});
|
|
429
|
+
return (await this.hasConfig(globalPath))
|
|
430
|
+
? this.getConfig(globalPath)
|
|
431
|
+
: undefined;
|
|
432
|
+
}
|
|
433
|
+
async updateScopedConfig(configPath, config, lookup = {}) {
|
|
434
|
+
await this.updateConfig(buildScopedConfigPath(configPath, lookup), config);
|
|
435
|
+
}
|
|
114
436
|
async init() {
|
|
115
437
|
if (this.remotePath) {
|
|
116
438
|
try {
|
|
@@ -154,7 +476,9 @@ class ConfigService {
|
|
|
154
476
|
model;
|
|
155
477
|
configInjectorResolvers;
|
|
156
478
|
providers;
|
|
479
|
+
wholePatchMergers;
|
|
157
480
|
resolvers;
|
|
481
|
+
configurationOverrideProvider;
|
|
158
482
|
constructor(model, configStores, extensionOptions = {}) {
|
|
159
483
|
this.configStores = configStores;
|
|
160
484
|
this.model = model;
|
|
@@ -164,9 +488,14 @@ class ConfigService {
|
|
|
164
488
|
this.providers = extensionOptions.providers
|
|
165
489
|
? { ...builtInProviders, ...extensionOptions.providers }
|
|
166
490
|
: { ...builtInProviders };
|
|
491
|
+
this.wholePatchMergers = extensionOptions.wholePatchMergers
|
|
492
|
+
? { ...builtInWholePatchMergers, ...extensionOptions.wholePatchMergers }
|
|
493
|
+
: { ...builtInWholePatchMergers };
|
|
167
494
|
this.resolvers = extensionOptions.resolvers
|
|
168
495
|
? { ...extensionOptions.resolvers }
|
|
169
496
|
: {};
|
|
497
|
+
this.configurationOverrideProvider =
|
|
498
|
+
extensionOptions.configurationOverrideProvider;
|
|
170
499
|
if (this.model) {
|
|
171
500
|
this.generateResolvers();
|
|
172
501
|
}
|
|
@@ -232,12 +561,34 @@ class ConfigService {
|
|
|
232
561
|
}
|
|
233
562
|
return processedData;
|
|
234
563
|
}
|
|
564
|
+
applyReferenceExtensions(processedData, referenceConfig) {
|
|
565
|
+
if (typeof referenceConfig?.extend !== 'object') {
|
|
566
|
+
return processedData;
|
|
567
|
+
}
|
|
568
|
+
const { extend } = referenceConfig;
|
|
569
|
+
for (const key in extend) {
|
|
570
|
+
const extensionValue = extend[key];
|
|
571
|
+
if (!Array.isArray(extensionValue)) {
|
|
572
|
+
throw new GraphQLError(`$ref.extend.${key} must be an array; received ${typeof extensionValue}`);
|
|
573
|
+
}
|
|
574
|
+
const baseValue = processedData[key];
|
|
575
|
+
if (baseValue === undefined) {
|
|
576
|
+
processedData[key] = extensionValue;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (!Array.isArray(baseValue)) {
|
|
580
|
+
throw new GraphQLError(`$ref.extend.${key} can only extend an array property; "${key}" resolved to ${typeof baseValue}`);
|
|
581
|
+
}
|
|
582
|
+
processedData[key] = [...baseValue, ...extensionValue];
|
|
583
|
+
}
|
|
584
|
+
return processedData;
|
|
585
|
+
}
|
|
235
586
|
async processReferencedObject(configPath, obj, context, values) {
|
|
236
587
|
const referenceConfig = obj.$ref;
|
|
237
588
|
const refConfigPath = typeof referenceConfig === 'object' && referenceConfig.refPath
|
|
238
589
|
? referenceConfig.refPath
|
|
239
590
|
: referenceConfig;
|
|
240
|
-
const imageData = await this.processImageReference(refConfigPath);
|
|
591
|
+
const imageData = await this.processImageReference(refConfigPath, context);
|
|
241
592
|
if (imageData) {
|
|
242
593
|
return imageData;
|
|
243
594
|
}
|
|
@@ -246,6 +597,7 @@ class ConfigService {
|
|
|
246
597
|
? this.updatePathValues(referencedData, referenceConfig.path)
|
|
247
598
|
: referencedData;
|
|
248
599
|
processedData = this.applyReferenceOverrides(processedData, referenceConfig);
|
|
600
|
+
processedData = this.applyReferenceExtensions(processedData, referenceConfig);
|
|
249
601
|
processedData = await this.processObjectExpressions(processedData, values, context);
|
|
250
602
|
return this.processObject(configPath, processedData, context, values);
|
|
251
603
|
}
|
|
@@ -262,7 +614,16 @@ class ConfigService {
|
|
|
262
614
|
const builtInQueryResolvers = {
|
|
263
615
|
pages: () => ({
|
|
264
616
|
getPageConfig: async (obj, context) => {
|
|
265
|
-
|
|
617
|
+
// `impersonateRoleId`/`impersonateGeneral` are a plain
|
|
618
|
+
// pass-through onto context here — this engine never inspects
|
|
619
|
+
// identity itself; the host's resolveCurrentIdentity is what
|
|
620
|
+
// interprets them and enforces the admin-only restriction.
|
|
621
|
+
const impersonation = obj.impersonateRoleId
|
|
622
|
+
? { impersonateRoleId: obj.impersonateRoleId }
|
|
623
|
+
: obj.impersonateGeneral
|
|
624
|
+
? { impersonateGeneral: true }
|
|
625
|
+
: null;
|
|
626
|
+
const pageConfig = await this.getPageConfig(obj.path, impersonation ? { ...context, ...impersonation } : context, true, obj.context);
|
|
266
627
|
return pageConfig;
|
|
267
628
|
},
|
|
268
629
|
getAIConfig: async (obj, context) => {
|
|
@@ -346,29 +707,37 @@ class ConfigService {
|
|
|
346
707
|
return deepMergeResolvers(builtInMutationResolvers, hostMutationResolvers);
|
|
347
708
|
};
|
|
348
709
|
}
|
|
349
|
-
|
|
710
|
+
// Applies any stored config-path sub-path overrides across the whole
|
|
711
|
+
// config tree before processConfig's own recursive walk runs, so that
|
|
712
|
+
// overridden values (which may introduce new sub-config) still go
|
|
713
|
+
// through provider injection/__typename dispatch below.
|
|
714
|
+
//
|
|
715
|
+
// rootSubPathOverrides lets a caller that already resolved overrides for
|
|
716
|
+
// the root config (e.g. getConfigFromStore, which fetches the whole-object
|
|
717
|
+
// and sub-path overrides together in one round trip) hand them in directly
|
|
718
|
+
// instead of having the root node re-resolved a second time below; nested
|
|
719
|
+
// nodes discovered deeper in the tree are still resolved individually,
|
|
720
|
+
// since their configPaths aren't known until the tree has been loaded.
|
|
721
|
+
async processConfig(config, context, processProviders, rootSubPathOverrides) {
|
|
722
|
+
const overriddenConfig = await this.applyConfigPathOverride(config, context, processProviders, rootSubPathOverrides);
|
|
723
|
+
return this.processConfigTree(overriddenConfig, context, processProviders);
|
|
724
|
+
}
|
|
725
|
+
async processConfigTree(config, context, processProviders) {
|
|
350
726
|
if (!config) {
|
|
351
727
|
return config;
|
|
352
728
|
}
|
|
353
729
|
// Handle arrays
|
|
354
730
|
if (Array.isArray(config)) {
|
|
355
|
-
return Promise.all(config.map((item) => this.
|
|
731
|
+
return Promise.all(config.map((item) => this.processConfigTree(item, context, processProviders)));
|
|
356
732
|
}
|
|
357
733
|
// Handle objects
|
|
358
734
|
if (typeof config === 'object') {
|
|
359
735
|
// Recursively process all properties of config
|
|
360
736
|
let processedConfig = { ...config };
|
|
361
737
|
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
|
-
}
|
|
738
|
+
processedConfig[key] = await this.processConfigTree(processedConfig[key], context, processProviders);
|
|
371
739
|
}
|
|
740
|
+
processedConfig = await this.applyTypenameProvider(processedConfig, context, processProviders);
|
|
372
741
|
return processedConfig;
|
|
373
742
|
}
|
|
374
743
|
if (processProviders) {
|
|
@@ -376,6 +745,59 @@ class ConfigService {
|
|
|
376
745
|
}
|
|
377
746
|
return config;
|
|
378
747
|
}
|
|
748
|
+
// Walks the whole config tree up front (pre-order) and, for every object
|
|
749
|
+
// that declares a configPath, deep-sets any stored sub-path overrides
|
|
750
|
+
// (JSONPath-addressed, e.g. "$.columns" or "$.filter.criteria").
|
|
751
|
+
//
|
|
752
|
+
// preFetchedSubPaths is only honored for the root node of the walk (the
|
|
753
|
+
// one being called with a defined third argument); nested calls always
|
|
754
|
+
// omit it so their own configPath gets resolved fresh.
|
|
755
|
+
async applyConfigPathOverride(config, context, processProviders, preFetchedSubPaths) {
|
|
756
|
+
if (!config) {
|
|
757
|
+
return config;
|
|
758
|
+
}
|
|
759
|
+
if (Array.isArray(config)) {
|
|
760
|
+
return Promise.all(config.map((item) => this.applyConfigPathOverride(item, context, processProviders)));
|
|
761
|
+
}
|
|
762
|
+
if (typeof config !== 'object') {
|
|
763
|
+
return config;
|
|
764
|
+
}
|
|
765
|
+
let result = config;
|
|
766
|
+
if (processProviders) {
|
|
767
|
+
const subPathPatch = await this.resolveSubPathOverrideLayers(result, context, preFetchedSubPaths);
|
|
768
|
+
if (subPathPatch) {
|
|
769
|
+
result = applySubPathOverrides(result, subPathPatch);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const overriddenEntries = await Promise.all(Object.entries(result).map(async ([key, value]) => [
|
|
773
|
+
key,
|
|
774
|
+
await this.applyConfigPathOverride(value, context, processProviders),
|
|
775
|
+
]));
|
|
776
|
+
return Object.fromEntries(overriddenEntries);
|
|
777
|
+
}
|
|
778
|
+
// Resolves the sub-path override layers that apply to `config`, unless the
|
|
779
|
+
// caller already fetched them (`preFetchedSubPaths`, honored for the root
|
|
780
|
+
// node of a walk — including when it is explicitly `null`).
|
|
781
|
+
async resolveSubPathOverrideLayers(config, context, preFetchedSubPaths) {
|
|
782
|
+
if (preFetchedSubPaths !== undefined) {
|
|
783
|
+
return preFetchedSubPaths;
|
|
784
|
+
}
|
|
785
|
+
if (!config.configPath || !this.configurationOverrideProvider) {
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
return toSubPathOverrideLayers(await this.configurationOverrideProvider.resolve(config.configPath, context));
|
|
789
|
+
}
|
|
790
|
+
async applyTypenameProvider(processedConfig, context, processProviders) {
|
|
791
|
+
if (!processedConfig?.__typename || !processProviders) {
|
|
792
|
+
return processedConfig;
|
|
793
|
+
}
|
|
794
|
+
const providerClass = this.providers[processedConfig.__typename];
|
|
795
|
+
if (!providerClass) {
|
|
796
|
+
return processedConfig;
|
|
797
|
+
}
|
|
798
|
+
const provider = new providerClass();
|
|
799
|
+
return await provider.processConfig(processedConfig, this, context);
|
|
800
|
+
}
|
|
379
801
|
async getRefConfig(configPath, refConfigPath, context, values) {
|
|
380
802
|
let fullPath;
|
|
381
803
|
if (refConfigPath.startsWith('.')) {
|
|
@@ -462,13 +884,25 @@ class ConfigService {
|
|
|
462
884
|
};
|
|
463
885
|
return await processValue(object);
|
|
464
886
|
}
|
|
465
|
-
async processImageReference(refConfigPath) {
|
|
887
|
+
async processImageReference(refConfigPath, context) {
|
|
466
888
|
const SUPPORTED_IMAGE_FORMATS = ['.jpg', '.jpeg', '.png', '.svg'];
|
|
467
889
|
// Check if the reference is an image file
|
|
468
890
|
const ext = path.extname(refConfigPath).toLowerCase();
|
|
469
891
|
if (!SUPPORTED_IMAGE_FORMATS.includes(ext)) {
|
|
470
892
|
return null;
|
|
471
893
|
}
|
|
894
|
+
const override = await this.configurationOverrideProvider?.resolve(refConfigPath, context);
|
|
895
|
+
if (override?.whole?.contentType) {
|
|
896
|
+
// A real-MIME-type asset override (image/svg+xml, etc.) is always base64 text, never the
|
|
897
|
+
// JSON-shaped value the CLOB/JSON attribute mismatch affects — see the screen-layout-patch
|
|
898
|
+
// branch below for that case.
|
|
899
|
+
const data = override.whole.value;
|
|
900
|
+
return {
|
|
901
|
+
data,
|
|
902
|
+
mimeType: override.whole.contentType,
|
|
903
|
+
hash: createHash('sha256').update(data).digest('hex'),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
472
906
|
let standardStore;
|
|
473
907
|
for (const store of this.configStores) {
|
|
474
908
|
if (store instanceof ConfigFileStore) {
|
|
@@ -520,19 +954,37 @@ class ConfigService {
|
|
|
520
954
|
return 'application/octet-stream';
|
|
521
955
|
}
|
|
522
956
|
}
|
|
523
|
-
|
|
957
|
+
// Resolves the whole-object override and any sub-path overrides for
|
|
958
|
+
// configPath together, in one provider round trip, instead of fetching
|
|
959
|
+
// them separately at different points of the processing pipeline. The
|
|
960
|
+
// sub-path overrides are handed back alongside the config so callers can
|
|
961
|
+
// thread them into processConfig rather than re-resolving them later; the
|
|
962
|
+
// whole override is handed back too so a screen-layout-patch (a whole
|
|
963
|
+
// override distinguished by its contentType) can be applied later in the
|
|
964
|
+
// pipeline without a second resolve() call.
|
|
965
|
+
async getConfigFromStore(configPath, context) {
|
|
966
|
+
const override = await this.configurationOverrideProvider?.resolve(configPath, context);
|
|
967
|
+
const subPathOverrides = override
|
|
968
|
+
? toSubPathOverrideLayers(override)
|
|
969
|
+
: null;
|
|
970
|
+
const whole = override?.whole ?? null;
|
|
971
|
+
if (whole && !whole.contentType) {
|
|
972
|
+
return { config: load(whole.value), subPathOverrides, whole };
|
|
973
|
+
}
|
|
524
974
|
const exceptions = [];
|
|
525
975
|
for (const [index, configStore] of this.configStores.entries()) {
|
|
526
976
|
try {
|
|
527
|
-
|
|
977
|
+
const config = await configStore.getConfig(configPath);
|
|
978
|
+
return { config, subPathOverrides, whole };
|
|
528
979
|
}
|
|
529
980
|
catch (e) {
|
|
530
981
|
exceptions.push(e);
|
|
531
982
|
if (index == this.configStores.length - 1) {
|
|
532
|
-
throw new GraphQLError(`Config not found in path in any store: ${
|
|
983
|
+
throw new GraphQLError(`Config not found in path in any store: ${configPath}`, { originalError: exceptions });
|
|
533
984
|
}
|
|
534
985
|
}
|
|
535
986
|
}
|
|
987
|
+
return { config: undefined, subPathOverrides, whole };
|
|
536
988
|
}
|
|
537
989
|
async hasConfig(configPath) {
|
|
538
990
|
return (await Promise.all(this.configStores.map((configStore) => configStore.hasConfig(configPath)))).some(Boolean);
|
|
@@ -542,10 +994,40 @@ class ConfigService {
|
|
|
542
994
|
return this.getDirectConfig(globalPath, context, values, processProviders);
|
|
543
995
|
}
|
|
544
996
|
async getDirectConfig(configPath, context, values, processProviders) {
|
|
545
|
-
const obj = await this.getConfigFromStore(configPath);
|
|
997
|
+
const { config: obj, subPathOverrides, whole, } = await this.getConfigFromStore(configPath, context);
|
|
546
998
|
const fullObj = await this.processObject(configPath, obj, context, values);
|
|
547
|
-
const processedObj = await this.processConfig(fullObj, context, processProviders);
|
|
548
|
-
|
|
999
|
+
const processedObj = await this.processConfig(fullObj, context, processProviders, subPathOverrides);
|
|
1000
|
+
// A whole-object patch override needs the fully $ref/rules-resolved config to merge
|
|
1001
|
+
// onto (its own document is name-keyed against that resolved shape), so it's applied
|
|
1002
|
+
// last — after the whole-file-replacement branch in getConfigFromStore (which this is
|
|
1003
|
+
// mutually exclusive with; that branch short-circuits before $ref resolution) and
|
|
1004
|
+
// after processConfig, not folded into either.
|
|
1005
|
+
return this.applyWholePatchIfPresent(processedObj, whole);
|
|
1006
|
+
}
|
|
1007
|
+
// Shared by getDirectConfig and getPageConfig — both resolve a fully $ref/rules-processed
|
|
1008
|
+
// config that a whole-object patch override (if any exists for this path) needs to merge
|
|
1009
|
+
// onto, so both need this as their final step. `whole` is the same whole-override already
|
|
1010
|
+
// resolved once by getConfigFromStore — no second resolve() call needed.
|
|
1011
|
+
//
|
|
1012
|
+
// This has no knowledge of what any particular contentType's patch document looks like —
|
|
1013
|
+
// it only knows how to look up a WholePatchMerger by contentType and delegate to it. A
|
|
1014
|
+
// `whole` override with no contentType is a full-file replacement, already substituted in
|
|
1015
|
+
// by getConfigFromStore, so there's nothing left to merge here; a contentType with no
|
|
1016
|
+
// registered merger is left untouched rather than guessed at.
|
|
1017
|
+
applyWholePatchIfPresent(processedObj, whole) {
|
|
1018
|
+
const merger = whole?.contentType
|
|
1019
|
+
? this.wholePatchMergers[whole.contentType]
|
|
1020
|
+
: undefined;
|
|
1021
|
+
if (!merger) {
|
|
1022
|
+
return processedObj;
|
|
1023
|
+
}
|
|
1024
|
+
// `Configuration.value` is typed CLOB, but the underlying attribute is JSON — for a
|
|
1025
|
+
// JSON-shaped value like a whole patch, it can come back already parsed as an object
|
|
1026
|
+
// rather than as the JSON string it was saved as. Handle both shapes.
|
|
1027
|
+
const patch = whole.value && typeof whole.value === 'object'
|
|
1028
|
+
? whole.value
|
|
1029
|
+
: JSON.parse(whole.value);
|
|
1030
|
+
return merger.merge(processedObj, patch);
|
|
549
1031
|
}
|
|
550
1032
|
async listDirectConfigs(configPath) {
|
|
551
1033
|
const configs = await Promise.all(this.configStores.map((configStore) => configStore.listConfigs(configPath)));
|
|
@@ -895,21 +1377,28 @@ class ConfigService {
|
|
|
895
1377
|
}
|
|
896
1378
|
async getPageConfig(pagePath, context, processConfig, rulesContext) {
|
|
897
1379
|
const globalPath = path.join('global', 'pages', pagePath);
|
|
898
|
-
const obj = await this.getConfigFromStore(globalPath);
|
|
1380
|
+
const { config: obj, subPathOverrides, whole, } = await this.getConfigFromStore(globalPath, context);
|
|
899
1381
|
const fullObj = await this.processObject(globalPath, obj, context, null);
|
|
900
1382
|
const matchingRuleGroups = await this.getRuleGroups(rulesContext, context);
|
|
901
1383
|
const configToProcess = this.applyFieldRuleOverrides(fullObj, matchingRuleGroups);
|
|
902
|
-
|
|
1384
|
+
const processedObj = await this.processConfig(configToProcess, context, processConfig, subPathOverrides);
|
|
1385
|
+
// Keyed by globalPath — the same `global/pages/<pagePath>` path a whole-patch save
|
|
1386
|
+
// (e.g. the Screen Designer's, see toPageConfigOverridePath in docupace-ui-main) writes
|
|
1387
|
+
// under, so a patch saved against a route like `contacts/{id}/details` is found here
|
|
1388
|
+
// too, not just in getDirectConfig.
|
|
1389
|
+
return this.applyWholePatchIfPresent(processedObj, whole);
|
|
903
1390
|
}
|
|
904
1391
|
async init() {
|
|
905
1392
|
return Promise.all(this.configStores.map((configStore) => configStore.init()));
|
|
906
1393
|
}
|
|
907
1394
|
}
|
|
908
|
-
export const defaultConfigService = ({ model, configStores, configInjectorResolvers, providers, resolvers, }) => {
|
|
1395
|
+
export const defaultConfigService = ({ model, configStores, configInjectorResolvers, providers, wholePatchMergers, resolvers, configurationOverrideProvider, }) => {
|
|
909
1396
|
const extensionOptions = {
|
|
910
1397
|
configInjectorResolvers,
|
|
911
1398
|
providers,
|
|
1399
|
+
wholePatchMergers,
|
|
912
1400
|
resolvers,
|
|
1401
|
+
configurationOverrideProvider,
|
|
913
1402
|
};
|
|
914
1403
|
return new ConfigService(model, configStores.map((configStore) => new ConfigFileStore(configStore)), extensionOptions);
|
|
915
1404
|
};
|