@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.
Files changed (51) hide show
  1. package/dist/config-extension.d.ts +72 -1
  2. package/dist/config-extension.d.ts.map +1 -1
  3. package/dist/config-extension.js +2 -0
  4. package/dist/config-extension.js.map +1 -1
  5. package/dist/config-service.d.ts +31 -4
  6. package/dist/config-service.d.ts.map +1 -1
  7. package/dist/config-service.js +513 -24
  8. package/dist/config-service.js.map +1 -1
  9. package/dist/index.d.ts +4 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/named-array-merge.d.ts +29 -0
  14. package/dist/lib/named-array-merge.d.ts.map +1 -0
  15. package/dist/lib/named-array-merge.js +158 -0
  16. package/dist/lib/named-array-merge.js.map +1 -0
  17. package/dist/mappers/config-mapper.d.ts.map +1 -1
  18. package/dist/mappers/config-mapper.js +2 -0
  19. package/dist/mappers/config-mapper.js.map +1 -1
  20. package/dist/model/graphs/config/fields.graphql +45 -0
  21. package/dist/model/graphs/config/menu.graphql +6 -0
  22. package/dist/model/graphs/config/page.graphql +8 -1
  23. package/dist/model/graphs/config/panel.graphql +7 -0
  24. package/dist/model/graphs/config/query.graphql +9 -1
  25. package/dist/model/graphs/config/selectOptions.graphql +1 -0
  26. package/dist/model/graphs/config/table.graphql +26 -0
  27. package/dist/model/graphs/config/ui-components.graphql +1 -0
  28. package/dist/providers/index.d.ts +4 -0
  29. package/dist/providers/index.d.ts.map +1 -1
  30. package/dist/providers/index.js +5 -0
  31. package/dist/providers/index.js.map +1 -1
  32. package/dist/providers/screen-layout-patch.d.ts +6 -0
  33. package/dist/providers/screen-layout-patch.d.ts.map +1 -0
  34. package/dist/providers/screen-layout-patch.js +57 -0
  35. package/dist/providers/screen-layout-patch.js.map +1 -0
  36. package/dist/providers/whole-patch.d.ts +6 -0
  37. package/dist/providers/whole-patch.d.ts.map +1 -0
  38. package/dist/providers/whole-patch.js +2 -0
  39. package/dist/providers/whole-patch.js.map +1 -0
  40. package/dist/types/graphql.d.ts +4 -2
  41. package/dist/types/graphql.d.ts.map +1 -1
  42. package/dist/types/graphql.js.map +1 -1
  43. package/model/graphs/config/fields.graphql +45 -0
  44. package/model/graphs/config/menu.graphql +6 -0
  45. package/model/graphs/config/page.graphql +8 -1
  46. package/model/graphs/config/panel.graphql +7 -0
  47. package/model/graphs/config/query.graphql +9 -1
  48. package/model/graphs/config/selectOptions.graphql +1 -0
  49. package/model/graphs/config/table.graphql +26 -0
  50. package/model/graphs/config/ui-components.graphql +1 -0
  51. package/package.json +5 -3
@@ -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
- const pageConfig = await this.getPageConfig(obj.path, context, true, obj.context);
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
- async processConfig(config, context, processProviders) {
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.processConfig(item, context, processProviders)));
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.processConfig(processedConfig[key], context, processProviders);
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
- async getConfigFromStore(path) {
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
- return await configStore.getConfig(path);
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: ${path}`, { originalError: exceptions });
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
- return processedObj;
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
- return await this.processConfig(configToProcess, context, processConfig);
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
  };