@object-ui/core 17.6.0 → 17.7.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 (134) hide show
  1. package/CHANGELOG.md +6610 -0
  2. package/README.md +101 -14
  3. package/dist/actions/ActionEngine.d.ts +4 -2
  4. package/dist/actions/ActionEngine.js +13 -8
  5. package/dist/actions/ActionRunner.d.ts +288 -27
  6. package/dist/actions/ActionRunner.js +443 -63
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/actionKeys.d.ts +9 -7
  9. package/dist/actions/actionKeys.js +57 -14
  10. package/dist/actions/actionResponse.js +2 -2
  11. package/dist/actions/index.d.ts +1 -0
  12. package/dist/actions/index.js +1 -0
  13. package/dist/actions/recordDelete.d.ts +118 -0
  14. package/dist/actions/recordDelete.js +112 -0
  15. package/dist/adapters/ValueDataSource.d.ts +6 -1
  16. package/dist/adapters/ValueDataSource.js +1090 -98
  17. package/dist/adapters/batchTransaction.d.ts +1 -1
  18. package/dist/adapters/batchTransaction.js +1 -1
  19. package/dist/builder/schema-builder.d.ts +38 -81
  20. package/dist/builder/schema-builder.js +33 -152
  21. package/dist/data-scope/ViewDataProvider.d.ts +1 -1
  22. package/dist/data-scope/ViewDataProvider.js +21 -1
  23. package/dist/data-scope/element-data-source.d.ts +261 -7
  24. package/dist/data-scope/element-data-source.js +342 -52
  25. package/dist/data-scope/index.d.ts +9 -4
  26. package/dist/data-scope/index.js +9 -4
  27. package/dist/evaluator/ExpressionEvaluator.d.ts +86 -0
  28. package/dist/evaluator/ExpressionEvaluator.js +184 -12
  29. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  30. package/dist/evaluator/FormulaFunctions.js +166 -27
  31. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  32. package/dist/evaluator/declaredPredicate.js +117 -16
  33. package/dist/evaluator/fieldRules.d.ts +88 -4
  34. package/dist/evaluator/fieldRules.js +203 -31
  35. package/dist/evaluator/index.d.ts +1 -0
  36. package/dist/evaluator/index.js +1 -0
  37. package/dist/evaluator/listConditional.d.ts +103 -34
  38. package/dist/evaluator/listConditional.js +127 -50
  39. package/dist/evaluator/optionRules.d.ts +77 -6
  40. package/dist/evaluator/optionRules.js +87 -10
  41. package/dist/evaluator/predicateInput.d.ts +15 -4
  42. package/dist/evaluator/predicateInput.js +35 -2
  43. package/dist/evaluator/rowPredicateCanon.d.ts +7 -18
  44. package/dist/evaluator/rowPredicateCanon.js +65 -115
  45. package/dist/evaluator/subjectPermissions.d.ts +76 -0
  46. package/dist/evaluator/subjectPermissions.js +44 -0
  47. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  48. package/dist/evaluator/unevaluablePredicate.js +70 -0
  49. package/dist/index.d.ts +14 -0
  50. package/dist/index.js +80 -2
  51. package/dist/protocols/index.d.ts +0 -1
  52. package/dist/protocols/index.js +9 -1
  53. package/dist/registry/Registry.d.ts +358 -31
  54. package/dist/registry/Registry.js +263 -11
  55. package/dist/registry/WidgetRegistry.js +7 -3
  56. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  57. package/dist/registry/html-tier-intrinsics.js +110 -0
  58. package/dist/registry/public-blocks.d.ts +8 -0
  59. package/dist/registry/public-blocks.js +57 -0
  60. package/dist/theme/ThemeEngine.js +1 -1
  61. package/dist/utils/chart-category-key.d.ts +82 -0
  62. package/dist/utils/chart-category-key.js +84 -0
  63. package/dist/utils/chart-measure-key.d.ts +64 -0
  64. package/dist/utils/chart-measure-key.js +65 -0
  65. package/dist/utils/chart-presentation.d.ts +95 -112
  66. package/dist/utils/chart-presentation.js +169 -104
  67. package/dist/utils/chart-series.d.ts +131 -5
  68. package/dist/utils/chart-series.js +98 -16
  69. package/dist/utils/column-sortability.d.ts +157 -0
  70. package/dist/utils/column-sortability.js +217 -0
  71. package/dist/utils/dashboard-filters.d.ts +38 -6
  72. package/dist/utils/dashboard-filters.js +107 -61
  73. package/dist/utils/dataset-format.d.ts +211 -30
  74. package/dist/utils/dataset-format.js +389 -46
  75. package/dist/utils/date-display.d.ts +354 -0
  76. package/dist/utils/date-display.js +463 -0
  77. package/dist/utils/date-macros.js +7 -0
  78. package/dist/utils/drill-down.d.ts +78 -0
  79. package/dist/utils/drill-down.js +93 -0
  80. package/dist/utils/emptiness.d.ts +96 -0
  81. package/dist/utils/emptiness.js +101 -0
  82. package/dist/utils/expand-fields.d.ts +76 -6
  83. package/dist/utils/expand-fields.js +76 -6
  84. package/dist/utils/extract-records.d.ts +65 -5
  85. package/dist/utils/extract-records.js +65 -8
  86. package/dist/utils/filter-converter.d.ts +152 -4
  87. package/dist/utils/filter-converter.js +1092 -22
  88. package/dist/utils/filter-tokens.d.ts +49 -5
  89. package/dist/utils/filter-tokens.js +105 -33
  90. package/dist/utils/grouping-fields.d.ts +61 -0
  91. package/dist/utils/grouping-fields.js +84 -0
  92. package/dist/utils/managedBy.d.ts +3 -3
  93. package/dist/utils/managedBy.js +5 -5
  94. package/dist/utils/native-date-value.d.ts +48 -0
  95. package/dist/utils/native-date-value.js +167 -0
  96. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  97. package/dist/utils/non-grid-row-ceiling.js +141 -0
  98. package/dist/utils/normalize-list-view.d.ts +121 -1
  99. package/dist/utils/normalize-list-view.js +186 -6
  100. package/dist/utils/number-display.d.ts +63 -14
  101. package/dist/utils/number-display.js +34 -12
  102. package/dist/utils/object-aggregate-query.d.ts +113 -0
  103. package/dist/utils/object-aggregate-query.js +50 -0
  104. package/dist/utils/parent-scope.d.ts +123 -0
  105. package/dist/utils/parent-scope.js +140 -0
  106. package/dist/utils/predicate-fields.d.ts +45 -12
  107. package/dist/utils/predicate-fields.js +137 -12
  108. package/dist/utils/record-color.d.ts +75 -0
  109. package/dist/utils/record-color.js +65 -0
  110. package/dist/utils/record-source.d.ts +298 -0
  111. package/dist/utils/record-source.js +331 -0
  112. package/dist/utils/record-title.d.ts +148 -8
  113. package/dist/utils/record-title.js +220 -26
  114. package/dist/utils/reference-keys.d.ts +21 -22
  115. package/dist/utils/reference-keys.js +532 -29
  116. package/dist/utils/sort-query.d.ts +115 -15
  117. package/dist/utils/sort-query.js +240 -13
  118. package/dist/utils/text-comparand.d.ts +92 -0
  119. package/dist/utils/text-comparand.js +99 -0
  120. package/dist/utils/without-denied-fields.d.ts +49 -0
  121. package/dist/utils/without-denied-fields.js +52 -0
  122. package/dist/validation/index.d.ts +8 -4
  123. package/dist/validation/index.js +8 -4
  124. package/dist/validation/schema-validator.d.ts +1 -1
  125. package/dist/validation/schema-validator.js +200 -82
  126. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  127. package/dist/validation/validators/object-validation-engine.js +14 -3
  128. package/package.json +8 -9
  129. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  130. package/dist/data-scope/DataScopeManager.js +0 -229
  131. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  132. package/dist/protocols/ResponsiveProtocol.js +0 -158
  133. package/dist/validation/validation-engine.d.ts +0 -88
  134. package/dist/validation/validation-engine.js +0 -434
@@ -1,56 +1,559 @@
1
1
  /**
2
- * ObjectUI — reference-key canonicalization
2
+ * ObjectUI — object-schema key canonicalization at ingestion
3
3
  * Copyright (c) 2024-present ObjectStack Inc.
4
4
  *
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
+ *
8
+ * ⚠️ The FILE and its exports still say `reference`; the pass no longer does
9
+ * only that. The `reference` / `reference_to` pair was the first tenant, and
10
+ * objectui#7650 added the retired-dialect arm below, which folds ANY undeclared
11
+ * snake twin of a declared `FieldSchema` key. Renaming the exports would be a
12
+ * published-export change across ~20 in-repo citations and belongs to its own
13
+ * card; the two arms are marked so the name cannot mislead a reader who got
14
+ * here from a grep.
7
15
  */
16
+ import { FIELD_KEY_GUIDANCE, FieldSchema } from '@objectstack/spec/data';
8
17
  /**
9
18
  * Backend object schemas follow the ObjectStack convention and name a
10
19
  * relational field's target object `reference`
11
- * (e.g. `{ type: 'lookup', reference: 'showcase_account' }`), while ObjectUI's
12
- * types — and most in-repo consumers — historically read `reference_to`
13
- * (some legacy configs also carry camelCase `referenceTo`). A consumer that
14
- * reads only one key silently loses the relation under the other convention:
15
- * the exact bug HeaderHighlight had (#2407 / PR #2587), where a served
16
- * `reference`-keyed lookup rendered a raw id.
17
- *
18
- * `normalizeFieldReferenceKeys` stamps BOTH snake_case keys onto the field
19
- * definition whenever any of the three spellings is present, so downstream
20
- * reads work regardless of which single key they check. It mutates the field
21
- * in place — the ObjectStack adapter caches the schema object and re-serves
22
- * it, so the one pass must stick — and is idempotent. Keys that are already
23
- * set are never overwritten.
24
- */
25
- export function normalizeFieldReferenceKeys(fieldDef) {
20
+ * (e.g. `{ type: 'lookup', reference: 'showcase_account' }`) — the only
21
+ * spelling `@objectstack/spec`'s `FieldSchema` declares. Since objectui#11070
22
+ * round 4 it is also the only spelling ObjectUI writes or reads: its types
23
+ * (`LookupFieldMetadata`, `MasterDetailFieldMetadata`, `DetailViewField`), its
24
+ * in-repo emitters, and its readers (`LookupField`, `UserField`, the lookup
25
+ * and user read cells) all spell it `reference`.
26
+ *
27
+ * `normalizeFieldReferenceKeys` FOLDS a foreign `reference_to` (or camelCase
28
+ * `referenceTo`) onto `reference` when — and only when — `reference` is
29
+ * absent, so a stored legacy def still reaches every reader. It never writes
30
+ * `reference_to`. Until round 4 it stamped BOTH snake_case keys onto every
31
+ * relational def, and that stamp was the producer that kept a second spelling
32
+ * alive inside ObjectUI: the readers read `reference_to` because the stamp and
33
+ * the emitters wrote it, and the emitters wrote it because the readers read it.
34
+ * It mutates the field in place — the ObjectStack adapter caches the schema
35
+ * object and re-serves it, so the one pass must stick — and is idempotent. A
36
+ * `reference` that is already set is never overwritten, and the foreign key is
37
+ * left on the def exactly as served: nothing here drops a key (see "Leave arm"
38
+ * below).
39
+ *
40
+ * ## ⭐ Why this file also WARNS — objectui#6837 half 2
41
+ *
42
+ * Maintainer ruling, 2026-08-31 (第 6 场总监席决裁批 #14), 原文照录:
43
+ *
44
+ * > objectui不是前端的项目吗?后端的元数据只要对,前端按协议执行就行了呀
45
+ *
46
+ * Protocol normalization belongs on the SERVER; the front end just executes
47
+ * the protocol. objectstack#13847 landed that half: a `field-reference-to-alias`
48
+ * conversion rewrites stored `reference_to` → `reference` on the serve path and
49
+ * in `os migrate meta`, so a spec-compliant backend now emits `reference` only.
50
+ * This repo's half deleted the per-reader `reference_to` fallback arms that
51
+ * existed to absorb the un-normalized shape.
52
+ *
53
+ * The ruling asked for that deletion to be AUDIBLE rather than silent, and this
54
+ * is the cheap defence-in-depth pin it named, placed here as dispatched.
55
+ * `@objectstack/spec` 17.2.0's `FieldSchema` is strict and refuses
56
+ * `reference_to`, `referenceTo` and `target` by name with `unrecognized_keys`,
57
+ * each carrying its own "did you mean → `reference`" rename (measured on the
58
+ * installed package, with `reference` as the positive control and a nonsense
59
+ * key as the negative one — the nonsense key gets no rename hint, so the hint
60
+ * is alias-table membership, not boilerplate). So a def that reaches this choke
61
+ * point spelling ONLY a legacy key came from a producer the spec would reject,
62
+ * and the producer is where it gets fixed.
63
+ *
64
+ * ## ⛔ WHAT THIS WARNING DOES NOT COVER — state it, do not overclaim it
65
+ *
66
+ * This warning fires ONLY where this file runs, and this file runs at exactly
67
+ * three production call sites, all of them ingestion choke points:
68
+ *
69
+ * packages/app-shell/src/providers/MetadataProvider.tsx (`ensureType`, metadata type `object`)
70
+ * packages/app-shell/src/providers/MetadataProvider.tsx (`getItem`, metadata type `object`)
71
+ * packages/data-objectstack/src/index.ts (ObjectStackAdapter.getObjectSchema)
72
+ *
73
+ * All three FOLD the def onto `reference`, so a def that triggers this warning
74
+ * is also a def that still resolves. ⇒ The warning fires precisely where
75
+ * nothing is broken.
76
+ *
77
+ * ⚠️ The third of those is new in objectui#7650 and the count above used to
78
+ * read TWO. The old count was true about where this file RAN and false about
79
+ * the serve surface it was cited for: `MetadataProvider` normalized on its
80
+ * LIST path only, so an object def fetched BY NAME — the published
81
+ * `useMetadataItem` hook, and any cold-cache read behind it — was served with
82
+ * whichever single spelling the producer stored. ⛔ Do not re-derive this list
83
+ * by grepping for the call: derive it from the serve paths that hand an object
84
+ * schema to a reader, and check each one calls in.
85
+ *
86
+ * ⚠️ The BREAK surface is the complement of that: `getObjectSchema` is a
87
+ * required member of the published `DataSource` interface and the readers call
88
+ * it on the generic `dataSource`, so a hand-written schema served through ANY
89
+ * other `DataSource` reaches a reader RAW — it neither passes through here nor
90
+ * warns. Since objectui#11070 round 4 no reader carries a `reference_to` leg,
91
+ * so on that path a def that spells ONLY a legacy key resolves no target: the
92
+ * picker has no object to query and the read cell names nothing. That round's
93
+ * changeset states the break for such a host. Reader-side or shared-resolver
94
+ * diagnostics, which would make it audible, remain an open question on
95
+ * objectui#6837 (options B and C of its table §5).
96
+ *
97
+ * ⛔ Do not describe this pin as making the BYO break audible. It does not.
98
+ *
99
+ * ⭐ The `reference_to` STAMP is retired (objectui#11070 round 4, the seat's
100
+ * answer A to that card's Q1 under the objectui#6837 ruling). An earlier
101
+ * revision of this header kept it as "the only thing standing between a BYO
102
+ * `DataSource` and the break". It stood there only because the readers still
103
+ * read `reference_to`, and they read it because this stamp and ObjectUI's own
104
+ * emitters wrote it; with both ends moved to `reference` it protected nothing.
105
+ * The FOLD onto `reference` is what a served legacy def needs, and it stays.
106
+ */
107
+ /**
108
+ * ## ⭐ THE RETIRED-DIALECT ARM — objectui#7650
109
+ *
110
+ * The `reference` pair above is one instance of a general problem this file is
111
+ * now the choke point for. objectui#7650 measured the shape of it:
112
+ *
113
+ * > The strict schema gates the WRITE door — `PUT /api/v1/meta/object/:name`
114
+ * > refuses a document carrying an undeclared key. It does not gate the READ
115
+ * > door.
116
+ *
117
+ * `ObjectStackAdapter.getObjectSchema` fetches the object document and returns
118
+ * it with exactly two mutations applied and NO `ObjectSchema.parse` anywhere on
119
+ * the path. So a document stored BEFORE a key was tightened is served, verbatim,
120
+ * to every consumer, forever — it cannot be re-saved through the strict door,
121
+ * but nothing ever asks it to be.
122
+ *
123
+ * That is why "no spec-compliant producer can emit this key" and "this consumer
124
+ * read can never fire" are DIFFERENT claims. Several retirement cards in this
125
+ * family (objectui#7155, #7166, #7435) narrowed consumer reads to the camelCase
126
+ * spelling on the strength of the first claim alone. This arm supplies the
127
+ * second half for them, exactly as objectui#6837 half 2 supplied it for
128
+ * `reference_to`: the legacy spelling is canonicalised ONCE, here, at ingestion,
129
+ * and never at a consumer.
130
+ *
131
+ * ## How the fold is DERIVED, and why it is not a table
132
+ *
133
+ * Maintainer route ruling (2026-09-09, objectui#7650 comment 5605081157): fold
134
+ * by the spec's OWN alias-probe rule — lowercase, strip `_`, `-` and space —
135
+ * matched EXACTLY against `FieldSchema`'s declared key set, which this repo can
136
+ * read off `FieldSchema.shape`.
137
+ *
138
+ * Two properties of that rule are the reason it was chosen over a hand-written
139
+ * three-row table:
140
+ *
141
+ * - `id_field` needs NO special case. It probes to `idfield`, which matches no
142
+ * declared key — `FieldSchema` has no `idField`, the spec's only `idField`
143
+ * sits on `InlineGridColumnSchema` — so it lands in the leave arm on its
144
+ * own. `title_format` likewise (no declared `titleFormat`).
145
+ * - A TYPO is not folded. `sortible` probes to `sortible`, which matches no
146
+ * declared key, so it is left alone. ⛔ This is why the spec's published
147
+ * `lintAuthoredRecordKeys` is NOT called here: it falls through to a
148
+ * Levenshtein matcher when no `to` row exists, and would answer "did you
149
+ * mean `sortable`?" for that typo. A serve path that silently CORRECTS a
150
+ * typo is worse than the defect it is fixing.
151
+ *
152
+ * ⚠️ "Leave arm", not "drop arm" in the destructive sense: a key this arm does
153
+ * not fold is left on the document EXACTLY as served. Nothing here removes a
154
+ * key or a value. Dropping the legacy key was weighed on objectui#7650 and
155
+ * REFUSED — a stored legacy document would lose the value instead of arriving
156
+ * canonical, and silent data loss on a serve path is the worst of the shapes.
157
+ *
158
+ * ⭐ The leave arm is no longer SILENT, which is the other half of that
159
+ * sentence: the document keeps the value, and the consumers narrowed to the
160
+ * canonical spelling by objectui#7155 / #7166 / #7435 read none of it (not
161
+ * every consumer was narrowed — the kept retired-spelling readers are named in
162
+ * {@link warnUnfoldableRetiredKey}'s docblock). See
163
+ * {@link warnUnfoldableRetiredKey} (objectui#8938) for the diagnostic maintainer
164
+ * ruling item 3 asked for, and for the two cases it deliberately stays out of.
165
+ *
166
+ * ## ⛔ The WIDTH of this arm is measured, never declared
167
+ *
168
+ * This arm accepts one spelling rule applied to `FieldSchema`'s WHOLE declared
169
+ * key set, so its width is a property of the linked `@objectstack/spec` and
170
+ * grows with it — ⛔ it is not the handful of keys the cards that asked for it
171
+ * happened to name, and ⛔ no count of it belongs in prose here or in a
172
+ * changeset (objectui#8938; AGENTS.md #9). The instrument that re-derives it is
173
+ * the pin named `the width IS the spec's declared key set, not a list anyone
174
+ * typed`, which drives every snake twin the live spec implies through this
175
+ * choke point.
176
+ *
177
+ * ## `id_field`: never folded, and the diagnostic carries the spec's own reason
178
+ *
179
+ * `@objectstack/spec`'s `FIELD_KEY_GUIDANCE` publishes an `id_field` row with a
180
+ * `why` and no `to`: a retirement, not a rename. So `id_field` is still never
181
+ * folded and nothing here reads an `idField`. What the row changes is the
182
+ * `no-declared-twin` diagnostic, which appends that `why`, read off the
183
+ * installed spec when the warning is emitted ({@link warnUnfoldableRetiredKey};
184
+ * objectui#7650). Two other ways of getting the sentence there were refused on
185
+ * the card, and neither refusal moves: quoting it from a local copy (a second
186
+ * copy of contract prose is the drift AGENTS.md #0.1 exists to stop), and
187
+ * reading it optionally with a local fallback (an invisible fallback that
188
+ * silently degrades on an older spec is this card's own defect class). The read
189
+ * is therefore unconditional: on a spec without the row it throws in dev rather
190
+ * than printing less.
191
+ *
192
+ * ## What this arm deliberately does NOT do
193
+ *
194
+ * ⛔ It never overwrites. A canonical key the producer already set keeps the
195
+ * producer's value, whatever the legacy twin says — the same rule the
196
+ * `reference` arm follows, for the same reason.
197
+ * ⛔ It never folds a key `FieldSchema` already declares: those are canonical by
198
+ * definition and skipping them is what keeps the pass idempotent.
199
+ * ⛔ It folds nothing onto an AMBIGUOUS probe. If two declared keys ever collide
200
+ * under the probe rule, that probe folds nothing rather than picking one.
201
+ * Measured on 17.3.0: 74 declared keys, zero collisions. The guard is here so a
202
+ * later spec addition cannot silently start choosing.
203
+ */
204
+ const aliasProbe = (key) => key.toLowerCase().replace(/[_\-\s]/g, '');
205
+ /** Memoized `FieldSchema` readings — see {@link fieldKeyFolds}. */
206
+ let declaredFieldKeys = null;
207
+ let probeFolds = null;
208
+ let collidingProbes = null;
209
+ /**
210
+ * `probe -> canonical declared key`, derived once from `FieldSchema.shape`.
211
+ *
212
+ * Derived rather than listed on purpose: a table would have to be edited every
213
+ * time the spec grows a camel key whose snake twin is still in stored
214
+ * documents, and the edit that does not happen is the bug.
215
+ *
216
+ * ⛔ Deliberately NOT reset-able and deliberately not exported: the key set is a
217
+ * property of the linked `@objectstack/spec`, so it cannot change while the
218
+ * process runs, and a reset hook would be public API bought for nothing. The
219
+ * collision guard above is therefore unexercised BY CONSTRUCTION today — the
220
+ * pin that keeps it honest asserts the zero-collision precondition against the
221
+ * real spec, so the day a collision appears the pin says so before the guard
222
+ * has to.
223
+ */
224
+ function fieldKeyFolds() {
225
+ if (probeFolds)
226
+ return probeFolds;
227
+ const declared = Object.keys(FieldSchema.shape);
228
+ const seen = new Map();
229
+ for (const key of declared) {
230
+ const probe = aliasProbe(key);
231
+ seen.set(probe, [...(seen.get(probe) ?? []), key]);
232
+ }
233
+ const folds = new Map();
234
+ const collisions = new Map();
235
+ // Same partition as before — a probe two declared keys share folds NOTHING —
236
+ // but the colliding keys are now kept rather than flattened to a `null`, so
237
+ // the diagnostic below can say WHICH declared keys the probe could not choose
238
+ // between instead of reporting the refusal as an unrecognised spelling
239
+ // (objectui#8938).
240
+ for (const [probe, keys] of seen) {
241
+ if (keys.length === 1)
242
+ folds.set(probe, keys[0]);
243
+ else
244
+ collisions.set(probe, keys);
245
+ }
246
+ declaredFieldKeys = new Set(declared);
247
+ collidingProbes = collisions;
248
+ probeFolds = folds;
249
+ return folds;
250
+ }
251
+ /**
252
+ * Warn once per (OBJECT, field, legacy spelling, canonical) — same four-segment
253
+ * reasoning as {@link warnedLegacyOnly}, which the docblock below sets out.
254
+ */
255
+ const warnedRetiredSpelling = new Set();
256
+ /**
257
+ * Warn once per (OBJECT, field, spelling, REASON) — see
258
+ * {@link warnUnfoldableRetiredKey}. The reason is in the key because the three
259
+ * refusals are three different fixes, and a def can hit more than one of them.
260
+ */
261
+ const warnedUnfoldableSpelling = new Set();
262
+ /**
263
+ * ## ⭐ THE DIAGNOSTIC FOR A SPELLING THIS CHOKE POINT CANNOT FOLD — objectui#8938
264
+ *
265
+ * Maintainer ruling item 3 (objectui#7650, comment 5572018999) asked the
266
+ * execution for "a loud diagnostic (not a silent drop) for a spelling the choke
267
+ * point cannot fold". The fold arm shipped with a warning for the spelling it
268
+ * CAN fold ({@link canonicalizeRetiredFieldKeys}) and nothing for the one it
269
+ * cannot, which is the half the ruling named: a key that folds still reaches
270
+ * every consumer, while a key that does not fold reaches none of the consumers
271
+ * of the declared spellings.
272
+ *
273
+ * ⛔ "Not a silent DROP" is about the reader, not about the document. Nothing
274
+ * here removes a key or a value — the leave arm is lossless and stays lossless.
275
+ * What is lost is the READ by those consumers: the retirement cards in this
276
+ * family (objectui#7155, #7166, #7435) narrowed them to the canonical spelling,
277
+ * so a value that never reaches a canonical key is a value none of them reads.
278
+ * That is the silence this makes audible.
279
+ *
280
+ * ⚠️ Not NO reader, and the line never says so (objectui#7650). Some reads of a
281
+ * retired spelling were KEPT on purpose and still see a value this arm leaves
282
+ * alone. For the two `no-declared-twin` keys: `resolveActionParam` in
283
+ * `@object-ui/app-shell` reads `id_field` and `title_format` (objectui#7435
284
+ * kept them; its pin `resolveActionParams.declaredLookupLegs-7435.test.ts`
285
+ * calls them the snake spellings that must keep working), and
286
+ * `resolveGroupByLabels` in `@object-ui/plugin-charts` reads
287
+ * `fieldDef.id_field || 'id'`. Those two are named, not counted: other kept
288
+ * snake reads exist (`deriveColumns` in `@object-ui/plugin-form` reads
289
+ * `reference_field`, for one), and nothing re-derives a complete list. So the
290
+ * line claims only what holds for every refusal: a consumer that reads only
291
+ * declared spellings will not see the value. Retiring any of those reads is a
292
+ * separate decision, not taken here.
293
+ *
294
+ * ## The three refusals, and why each is a different sentence
295
+ *
296
+ * - `no-declared-twin` — `FieldSchema` declares neither the key nor anything
297
+ * sharing its alias probe. `id_field` and `title_format` land here by the
298
+ * derived rule rather than by an exclusion, and so does a typo (`sortible`):
299
+ * ⛔ the diagnostic deliberately does NOT offer a near match, for the same
300
+ * reason the fold does not — the spec's `lintAuthoredRecordKeys` falls
301
+ * through to a Levenshtein matcher, and a serve path that suggests a
302
+ * correction is one revision away from applying it. For `id_field` alone
303
+ * the line also carries the reason the spec publishes for that exact
304
+ * spelling, `FIELD_KEY_GUIDANCE.id_field.why`, read unconditionally where
305
+ * the line is built: the contract's own sentence about the key, not a near
306
+ * match this path computed. The spec keys that row snake_case on purpose —
307
+ * a `why` row matches only the exact authored spelling.
308
+ * - `ambiguous-probe` — two or more declared keys share the probe, so the
309
+ * fold refuses to choose. Unreachable against a spec with no collision;
310
+ * the pin that exercises it substitutes a colliding `FieldSchema`.
311
+ * - `canonical-occupied` — the declared twin is already on the def carrying a
312
+ * DIFFERENT value, and the fold never overwrites a value the producer set.
313
+ * The producer's value is served under the declared key, the retired one
314
+ * reaches no consumer of the declared spelling (a kept snake read can still
315
+ * see it — see above), and NOTHING said so before this. ⛔ Same value under
316
+ * both spellings is NOT this case: that is the state this pass leaves
317
+ * behind on its own second run, and reporting it would make every
318
+ * re-normalization of a correctly folded def noisy.
319
+ *
320
+ * ## ⛔ What it does NOT fire on — the two exclusions that keep it honest
321
+ *
322
+ * - A key that DID fold. That case is {@link canonicalizeRetiredFieldKeys}'s
323
+ * own warning, and firing here as well would report a def that works
324
+ * exactly as loudly as one that does not.
325
+ * - `reference_to` / `referenceTo`, which probe onto no declared key and
326
+ * would otherwise report as `no-declared-twin` while the reference arm was
327
+ * in the middle of folding them onto `reference`. They have their own arm and their own
328
+ * diagnostic ({@link warnOnLegacyOnlyReference}, objectui#6837).
329
+ *
330
+ * Dev-only and memoised, the discipline both existing warnings already use: the
331
+ * adapter re-serves a cached schema and `MetadataProvider` re-normalizes on
332
+ * every metadata refresh, so a warning that floods is a warning that gets muted.
333
+ */
334
+ function warnUnfoldableRetiredKey(reason, key, detail, fieldName, objectName, f) {
335
+ if (!isDev())
336
+ return;
337
+ const named = fieldName ?? (typeof f.name === 'string' ? f.name : '(unnamed field)');
338
+ const owner = objectName ?? '(unknown object)';
339
+ const memo = `${owner}:${named}:${key}:${reason}`;
340
+ if (warnedUnfoldableSpelling.has(memo))
341
+ return;
342
+ // ⛔ No `?.` and no `??` here: a spec without the row throws before the memo
343
+ // is taken, rather than warning with less (objectui#7650).
344
+ const published = reason === 'no-declared-twin' && key === 'id_field'
345
+ ? ` \`@objectstack/spec\`'s \`FIELD_KEY_GUIDANCE\` gives the reason: ${FIELD_KEY_GUIDANCE.id_field.why}`
346
+ : '';
347
+ warnedUnfoldableSpelling.add(memo);
348
+ console.warn(`[ObjectUI] Object \`${owner}\`, field \`${named}\`: the ingestion choke point CANNOT ` +
349
+ `canonicalize the retired spelling \`${key}\`. ${detail}${published} The key and its value are LEFT ` +
350
+ `on the def exactly as served — nothing is dropped here — but a consumer that reads only ` +
351
+ `the spellings \`@objectstack/spec\`'s \`FieldSchema\` declares will not see this value. ` +
352
+ `Fix the PRODUCER, or migrate the stored document. (maintainer ruling item 3 on ` +
353
+ `objectui#7650; objectui#8938)`);
354
+ }
355
+ /**
356
+ * Stamp the canonical spelling for every retired-dialect key on one field def.
357
+ *
358
+ * Runs for EVERY field def, not only relational ones: `display_field` and
359
+ * friends live on fields that carry no relationship target at all, so gating
360
+ * this on the reference arm's early return would have covered almost nothing.
361
+ *
362
+ * `Object.keys` snapshots before the loop, so the canonical keys stamped inside
363
+ * it are never themselves re-examined.
364
+ */
365
+ function canonicalizeRetiredFieldKeys(f, fieldName, objectName) {
366
+ const folds = fieldKeyFolds();
367
+ for (const key of Object.keys(f)) {
368
+ if (declaredFieldKeys.has(key))
369
+ continue;
370
+ const value = f[key];
371
+ // Nothing to carry across and nothing to lose: no fold, and no diagnostic
372
+ // either — the reader is not missing a value that was never there.
373
+ if (value === undefined)
374
+ continue;
375
+ // The reference arm's own keys are handled (and warned about) below; they
376
+ // probe onto no declared key, so without this they would report here as an
377
+ // unfoldable spelling while that arm was about to fold them.
378
+ if (REFERENCE_ARM_KEYS.has(key))
379
+ continue;
380
+ const probe = aliasProbe(key);
381
+ const canonical = folds.get(probe);
382
+ if (canonical === undefined) {
383
+ const colliding = collidingProbes.get(probe);
384
+ if (colliding) {
385
+ warnUnfoldableRetiredKey('ambiguous-probe', key, `Its alias spelling is shared by MORE THAN ONE key \`FieldSchema\` declares ` +
386
+ `(${colliding.map((c) => `\`${c}\``).join(', ')}), so the fold refuses to choose ` +
387
+ `between them rather than picking one.`, fieldName, objectName, f);
388
+ }
389
+ else {
390
+ warnUnfoldableRetiredKey('no-declared-twin', key, `\`FieldSchema\` declares neither \`${key}\` nor any key sharing its alias spelling ` +
391
+ `(lowercased, with \`_\`, \`-\` and spaces removed), so there is no canonical key ` +
392
+ `to fold it onto — and this path deliberately does not guess a near match.`, fieldName, objectName, f);
393
+ }
394
+ continue;
395
+ }
396
+ if (f[canonical] !== undefined) {
397
+ // ⛔ Only when a value is actually being lost. `f[canonical] === value` is
398
+ // the state this pass itself leaves behind — it stamps by reference and
399
+ // the adapter re-serves a cached schema, so reporting it would fire on
400
+ // every re-normalization of a def that folded perfectly the first time.
401
+ // The idempotence pin in the objectui#7650 suite is what measures this.
402
+ if (f[canonical] !== value) {
403
+ warnUnfoldableRetiredKey('canonical-occupied', key, `\`FieldSchema\` declares \`${canonical}\`, and this def already carries a ` +
404
+ `DIFFERENT value under it; the producer's value stands, because this choke point ` +
405
+ `never overwrites one.`, fieldName, objectName, f);
406
+ }
407
+ continue;
408
+ }
409
+ f[canonical] = value;
410
+ if (!isDev())
411
+ continue;
412
+ const named = fieldName ?? (typeof f.name === 'string' ? f.name : '(unnamed field)');
413
+ const owner = objectName ?? '(unknown object)';
414
+ const memo = `${owner}:${named}:${key}:${canonical}`;
415
+ if (warnedRetiredSpelling.has(memo))
416
+ continue;
417
+ warnedRetiredSpelling.add(memo);
418
+ console.warn(`[ObjectUI] Object \`${owner}\`, field \`${named}\` carries the retired spelling ` +
419
+ `\`${key}\`. \`@objectstack/spec\`'s \`FieldSchema\` declares \`${canonical}\` and ` +
420
+ `does not declare \`${key}\`, so a producer emitting it would be refused at the ` +
421
+ `metadata write door. ObjectUI stamps \`${canonical}\` here so this stored def still ` +
422
+ `renders, but the consumers no longer carry a \`${key}\` fallback of their own — fix ` +
423
+ `the PRODUCER, or migrate the stored document. (objectui#7650)`);
424
+ }
425
+ }
426
+ /**
427
+ * Warn once per (OBJECT name, field name, legacy spelling, target VALUE) rather
428
+ * than once per call: the adapter re-serves a cached schema and
429
+ * `MetadataProvider` re-normalizes on every metadata refresh, and a warning
430
+ * that floods the console is a warning that gets muted.
431
+ *
432
+ * All four segments are load-bearing, and each is here because dropping it
433
+ * makes the warning name less than a whole producer site:
434
+ *
435
+ * - OBJECT — the same field name (`owner`, `account_id`) lives on many
436
+ * objects, and each is a SEPARATE place to fix. Keyed on the field alone
437
+ * the memo reports the first object and goes quiet for every other one,
438
+ * while the message names a field the reader then has to go hunting for.
439
+ * `normalizeSchemaReferenceKeys` has `schema.name` in hand, so the whole
440
+ * address is available for free.
441
+ * - FIELD and SPELLING — one line per mis-spelled key, so a producer with
442
+ * three broken fields gets three fixes, not one and a silence.
443
+ * - VALUE — a field carrying two different stale targets reports both. This
444
+ * mirrors `column-identity.ts`'s `warnedConflicts` memo, which keys on the
445
+ * value for exactly this reason.
446
+ *
447
+ * ⚠️ A def normalized OUTSIDE a schema (a bare `normalizeFieldReferenceKeys`
448
+ * call) has no object to name; it warns under a placeholder rather than being
449
+ * silently merged with a real object's entry.
450
+ */
451
+ const warnedLegacyOnly = new Set();
452
+ /** Reset the warn-once memos. Exported for tests. */
453
+ export function resetReferenceKeyWarnings() {
454
+ warnedLegacyOnly.clear();
455
+ warnedRetiredSpelling.clear();
456
+ warnedUnfoldableSpelling.clear();
457
+ }
458
+ const isDev = () => globalThis.process?.env?.NODE_ENV !==
459
+ 'production';
460
+ /** The two spellings no contract declares, in the order the fold prefers them. */
461
+ const LEGACY_REFERENCE_KEYS = ['reference_to', 'referenceTo'];
462
+ /**
463
+ * The same two, as a set the retired-dialect arm tests before it reports a key
464
+ * as unfoldable — they are handled by the reference arm below, not left
465
+ * (objectui#8938).
466
+ */
467
+ const REFERENCE_ARM_KEYS = new Set(LEGACY_REFERENCE_KEYS);
468
+ /**
469
+ * Dev-mode only: say out loud that a def arrived spelling ONLY a legacy key.
470
+ *
471
+ * Non-breaking by construction: changes no types, rejects nothing, drops no
472
+ * key, and is a no-op under `NODE_ENV=production`. The fold still runs, so the
473
+ * def still resolves its target — this only makes the producer's bug visible
474
+ * instead of absorbing it silently.
475
+ */
476
+ function warnOnLegacyOnlyReference(f, fieldName, objectName) {
477
+ if (!isDev())
478
+ return;
479
+ if (f.reference !== undefined)
480
+ return;
481
+ const spelled = LEGACY_REFERENCE_KEYS.filter((k) => f[k] !== undefined && f[k] !== '');
482
+ if (spelled.length === 0)
483
+ return;
484
+ const named = fieldName ?? (typeof f.name === 'string' ? f.name : '(unnamed field)');
485
+ const owner = objectName ?? '(unknown object)';
486
+ for (const key of spelled) {
487
+ const memo = `${owner}:${named}:${key}:${String(f[key])}`;
488
+ if (warnedLegacyOnly.has(memo))
489
+ continue;
490
+ warnedLegacyOnly.add(memo);
491
+ console.warn(`[ObjectUI] Object \`${owner}\`, field \`${named}\` declares its relationship target as ` +
492
+ `\`${key}: '${String(f[key])}'\` and does NOT carry \`reference\`. ` +
493
+ `\`reference\` is the only spelling the protocol declares: ` +
494
+ `\`@objectstack/spec\`'s \`FieldSchema\` refuses \`${key}\` by name with ` +
495
+ `\`unrecognized_keys\` ("Did you mean \`${key}\` -> \`reference\`?"). ` +
496
+ `ObjectUI stamps \`reference\` here so this def still renders, but the ` +
497
+ `readers no longer carry a \`${key}\` fallback of their own — fix the ` +
498
+ `PRODUCER to emit \`reference\`, or serve the def through a backend that ` +
499
+ `normalizes it (objectstack#13847 does this on the serve path and in ` +
500
+ `\`os migrate meta\`). Maintainer ruling 2026-08-31: protocol ` +
501
+ `normalization belongs on the server, the front end just executes the ` +
502
+ `protocol. (objectui#6837)`);
503
+ }
504
+ }
505
+ export function normalizeFieldReferenceKeys(fieldDef, fieldName, objectName) {
26
506
  if (!fieldDef || typeof fieldDef !== 'object')
27
507
  return fieldDef;
28
508
  const f = fieldDef;
29
- const target = f.reference_to ?? f.reference ?? f.referenceTo;
30
- if (target == null || target === '')
509
+ // Runs FIRST and unconditionally: the retired dialect is not relational, so
510
+ // the reference arm's early return below would skip almost every def that
511
+ // needs it (objectui#7650).
512
+ canonicalizeRetiredFieldKeys(f, fieldName, objectName);
513
+ // The reference arm is a FOLD, never a stamp (objectui#11070 round 4): a
514
+ // producer's `reference` stands, and only a def that lacks it gains one from
515
+ // a foreign spelling. `reference_to` is never written, and the foreign key is
516
+ // left where it was.
517
+ if (f.reference !== undefined)
31
518
  return fieldDef;
32
- if (f.reference_to === undefined)
33
- f.reference_to = target;
34
- if (f.reference === undefined)
35
- f.reference = target;
519
+ const legacy = f.reference_to ?? f.referenceTo;
520
+ if (legacy == null || legacy === '')
521
+ return fieldDef;
522
+ warnOnLegacyOnlyReference(f, fieldName, objectName);
523
+ f.reference = legacy;
36
524
  return fieldDef;
37
525
  }
38
526
  /**
39
527
  * Apply {@link normalizeFieldReferenceKeys} to every field of an object
40
- * schema. Accepts both field-container shapes the metadata API serves —
41
- * a `name → def` map or an array of defs — and tolerates anything else by
42
- * returning the input untouched. Mutates in place; idempotent.
528
+ * schema — BOTH arms: the `reference` fold and the retired dialect. Accepts
529
+ * both field-container shapes the metadata API serves — a `name → def` map or
530
+ * an array of defs — and tolerates anything else by returning the input
531
+ * untouched. Mutates in place; idempotent.
43
532
  *
44
533
  * This is meant to run at the choke point where object schemas enter the
45
534
  * client (`ObjectStackAdapter.getObjectSchema`, the app-shell metadata
46
- * provider) so per-consumer dual-key fallbacks can't drift.
535
+ * provider), so no consumer needs a dual-key fallback of its own.
536
+ *
537
+ * Field NAMES and the OBJECT name are forwarded so the dev-mode warning above
538
+ * can name the whole producer site rather than half of it: the map form keys
539
+ * the def by name, the array form carries it as `def.name`, and the object name
540
+ * comes off `schema.name`.
47
541
  */
48
542
  export function normalizeSchemaReferenceKeys(schema) {
49
543
  const fields = schema && typeof schema === 'object' ? schema.fields : null;
50
544
  if (!fields || typeof fields !== 'object')
51
545
  return schema;
52
- const defs = Array.isArray(fields) ? fields : Object.values(fields);
53
- for (const def of defs)
54
- normalizeFieldReferenceKeys(def);
546
+ const objectName = typeof schema.name === 'string'
547
+ ? schema.name
548
+ : undefined;
549
+ if (Array.isArray(fields)) {
550
+ for (const def of fields)
551
+ normalizeFieldReferenceKeys(def, undefined, objectName);
552
+ }
553
+ else {
554
+ for (const [name, def] of Object.entries(fields)) {
555
+ normalizeFieldReferenceKeys(def, name, objectName);
556
+ }
557
+ }
55
558
  return schema;
56
559
  }