@object-ui/data-objectstack 16.1.0 → 17.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,642 @@
1
1
  # @object-ui/data-objectstack
2
2
 
3
+ ## 17.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c5ccbd5: Stop declaring 12 `@object-ui/data-objectstack` / `@object-ui/plugin-chatbot` /
8
+ `@object-ui/plugin-list` symbols under names `@objectstack/spec` owns
9
+ (objectui#3160, objectstack#4115 batch 6). All three packages leave the ledger.
10
+
11
+ **Breaking for importers of `@object-ui/data-objectstack`** — four exported
12
+ names changed, because the spec exports the same name for a _different_ thing:
13
+
14
+ | was | now | what the spec's same-named export actually is |
15
+ | :-------------------- | :-------------------------- | :--------------------------------------------------------------------------------------- |
16
+ | `CacheStats` | `MetadataCacheStats` | the platform `ICacheService` counters (`keyCount`, `memoryUsage`) |
17
+ | `MetadataSaveOptions` | `MetadataClientSaveOptions` | options for writing a metadata item to a **file** (`format`, `path`, `indent`, `atomic`) |
18
+ | `SecurityPolicy` | `SecurityManagerPolicy` | the package supply-chain policy (`autoScan`, licences, code signing, sandbox) |
19
+ | `ValidationError` | `DataApiValidationError` | a plain `{ field, message, code? }` entry in a validation report |
20
+
21
+ Each pair is disjoint or nearly so — `MetadataSaveOptions` and `SecurityPolicy`
22
+ share not one key with the spec type whose name they wore — so none of them was
23
+ a dialect to reconcile; they were four unrelated concepts squatting on spec
24
+ names. `DataApiValidationError` follows the `<what was validated>Validation<Error|Result>`
25
+ convention registered on objectstack#4115 (`@object-ui/core` took
26
+ `SchemaNodeValidationError` in batch 4). Its **runtime** `name` deliberately
27
+ stays `'ValidationError'`: `normaliseClientError` and `@object-ui/react`'s
28
+ error-message helper both sniff `err.name`, so that string is a wire contract,
29
+ not a symbol.
30
+
31
+ **Breaking for importers of `@object-ui/plugin-chatbot`** — `PendingActionRow`
32
+ and `PendingActionStatus` are now re-exported from `@objectstack/spec/contracts`
33
+ instead of hand-transcribed, which narrows them. The copies had drifted three
34
+ ways, and each drift had **disabled a compile-time check** rather than merely
35
+ differed from one:
36
+
37
+ - `status: PendingActionStatus | string` — a union with `string` absorbs the
38
+ literals, so that annotation carried no information at all;
39
+ - `[key: string]: unknown` — the objectstack#4075 mechanism: an index signature
40
+ makes every structural comparison against the spec answer "identical", however
41
+ far the copy has drifted;
42
+ - `created_at` / `updated_at`, which the service contract does not carry and no
43
+ consumer in this repo reads.
44
+
45
+ **Breaking for importers of `@object-ui/plugin-list`** — `ViewTab` is derived from the spec's `ViewTabSchema`
46
+ — from its **input** side, because `pinned` / `isDefault` / `visible` carry
47
+ `.default()`s and this component is handed authored metadata, not parsed output.
48
+ That removes a renderer-side tolerance the copy carried: `visible` accepted
49
+ `string | boolean` and the tab bar compared it against the literal `'false'`, a
50
+ spelling no producer emits. `label` also stops being required (the spec makes it
51
+ optional; `name` is the identifier) and `filter` stops being `any`.
52
+
53
+ `ListView` and `UserFilters` keep their names as declared dialects: both are the
54
+ React **renderers** of the spec types whose names they share, and each takes that
55
+ spec type as a prop (`ListViewProps.schema`, `UserFiltersProps.config`) rather
56
+ than restating its shape. `Tool` and `MessageContent` in `plugin-chatbot` are
57
+ vendored Vercel AI Elements / Shadcn primitives — upstream's component API, not
58
+ objectui's authored surface — so the guard now skips that directory the same way
59
+ it already skips `components/src/ui/`, with a test that fails if any file there
60
+ stops carrying its vendor banner.
61
+
62
+ Scored `minor`, not `major`, per this repo's fixed-group rule — objectui's major
63
+ tracks `@objectstack`, so breaking changes of our own ship as minor with the
64
+ semantics spelled out above (see AGENTS.md §版本号策略). A `major` here would carry
65
+ all 39 packages of the fixed group to `18.0.0` and off objectstack's 17.x line.
66
+
67
+ - d3584c6: Bring the whole `@objectstack` family to `17.0.0-rc.1`, so the dependency graph resolves a
68
+ single copy of `@objectstack/spec`.
69
+
70
+ #3178 bumped **only** `@objectstack/spec` to `17.0.0-rc.1`. The rest of the family —
71
+ `client`, `core`, `formula`, `lint` (and `sdui-parser`, reached through `lint`) — stayed on
72
+ `17.0.0-rc.0`, and each of them depends on spec at an **exact** version rather than a
73
+ caret:
74
+
75
+ ```
76
+ @objectstack/client@17.0.0-rc.0 -> spec "17.0.0-rc.0"
77
+ @objectstack/core@17.0.0-rc.0 -> spec "17.0.0-rc.0"
78
+ @objectstack/formula@17.0.0-rc.0 -> spec "17.0.0-rc.0"
79
+ @objectstack/lint@17.0.0-rc.0 -> spec "17.0.0-rc.0"
80
+ ```
81
+
82
+ So `main` carried **two** spec copies: objectui's own code read `17.0.0-rc.1` while every
83
+ `@objectstack/*` package read `17.0.0-rc.0` from its own nested `node_modules`. That breaks
84
+ the single-contract invariant this repo's guards are built on, and it breaks them
85
+ _silently_ — the affected checks depend on identity, not on version strings:
86
+
87
+ - `spec-subschema-parity.test.ts` distinguishes a genuine re-export from a fork by
88
+ **reference identity** of the zod schema object. Two spec copies make every schema a
89
+ distinct object, so a real re-export starts reading as a fork (or a fork slips through,
90
+ depending on which copy each side resolved).
91
+ - `scripts/check-spec-symbol-derivation.mjs` and `spec-symbol-parity.test.ts` use
92
+ `createRequire` to resolve spec's `.d.ts` and run it through the TS checker. With two
93
+ copies installed, _which_ declaration file the checker sees is a function of resolution
94
+ order rather than of intent.
95
+
96
+ The declared ranges were already `^17.0.0-rc.0`, which technically admits rc.1 — the pin
97
+ lived in the lockfile. Raising the remaining ranges to `^17.0.0-rc.1` makes the floor
98
+ explicit and forbids a future install from silently sliding back onto a family member that
99
+ drags rc.0 along with it. The rc.1 family members pin spec at `17.0.0-rc.1` exactly, so the
100
+ graph now converges on one copy by construction, not by luck.
101
+
102
+ No product behaviour changes here. `check:spec-symbols` reconciliation was already
103
+ completed by #3178 and stays green under the unified graph; this changeset is `minor`
104
+ per the repo's fixed-group version policy.
105
+
106
+ ### Patch Changes
107
+
108
+ - Updated dependencies [4ae0ac4]
109
+ - Updated dependencies [696e3c1]
110
+ - Updated dependencies [bca45cc]
111
+ - Updated dependencies [4bf612c]
112
+ - Updated dependencies [335041c]
113
+ - Updated dependencies [b414983]
114
+ - Updated dependencies [256f8cc]
115
+ - Updated dependencies [d9668a7]
116
+ - Updated dependencies [cb82705]
117
+ - Updated dependencies [f572849]
118
+ - Updated dependencies [d3584c6]
119
+ - Updated dependencies [a8ad6c0]
120
+ - Updated dependencies [444457c]
121
+ - Updated dependencies [850033c]
122
+ - Updated dependencies [022e4c3]
123
+ - Updated dependencies [009e25d]
124
+ - Updated dependencies [726b89c]
125
+ - @object-ui/types@17.2.0
126
+ - @object-ui/core@17.2.0
127
+
128
+ ## 17.1.0
129
+
130
+ ### Minor Changes
131
+
132
+ - 9b773f9: fix(analytics): a missing analytics capability no longer renders as an empty KPI — objectstack#3891
133
+
134
+ The framework retired its degraded in-kernel analytics fallback (objectstack#3891):
135
+ it dropped the caller's RLS/tenant scope and ignored the contract filter, so it
136
+ answered `200` with over-broad numbers. `@objectstack/service-analytics` is now
137
+ the only implementation, and a deployment without it answers `404` on
138
+ `/analytics/query` (objectstack#4019 stops mounting the routes) or `501` on
139
+ `/analytics/dataset/query`.
140
+
141
+ Three things were wrong on this side of that boundary:
142
+
143
+ **① A KPI on such a deployment rendered a confident zero.** `aggregate()`'s
144
+ `catch` promises a client-side fallback, and the fallback is correct — but the
145
+ adapter never got there for the most likely failure. It now classifies the
146
+ failure (`classifyAnalyticsFailure`) instead of treating every error alike:
147
+ capability-absent (404/501) degrades to a client-side aggregate over a
148
+ **server-scoped** `find()` — same rows, same filter, RLS still applied — and
149
+ says so **once per adapter** in the console, naming the package to install,
150
+ rather than once per widget or not at all.
151
+
152
+ **② A rejected query was answered with plausible numbers.** The framework
153
+ validates `/analytics/query` at the entry now (objectstack#4010), so a `400
154
+ VALIDATION_FAILED` means _this adapter_ sent an off-contract body. Degrading
155
+ there would bury our own bug behind output from a different code path — the
156
+ misdirection objectstack#3878 documented. It now throws
157
+ `AnalyticsQueryRejectedError` and never falls back. Transient failures (5xx,
158
+ network) degrade exactly as before.
159
+
160
+ **③ The dataset preview blamed the author for a missing capability.**
161
+ `queryDataset` mapped `501`/`404` to `Dataset query failed: 501 Not Implemented
162
+ — …`; it now throws the typed `AnalyticsNotInstalledError`
163
+ (`code: 'ANALYTICS_NOT_INSTALLED'`) with a message a UI can render verbatim, and
164
+ `DatasetPreview` shows it as a "analytics capability not installed" empty state
165
+ instead of a red error banner. A real compile error (e.g. "relationship not
166
+ declared in include") keeps its server detail and its banner.
167
+
168
+ New exports from `@object-ui/data-objectstack`: `AnalyticsNotInstalledError`,
169
+ `AnalyticsQueryRejectedError`, `isAnalyticsNotInstalledError`,
170
+ `classifyAnalyticsFailure`.
171
+
172
+ - 1cf0de7: fix(detail): finish the approval-lock story, and warn on silently stripped fields (framework#3794)
173
+
174
+ The Console reported record writability wrong in both directions during an
175
+ approval, so a user had nothing to go on: what they _could_ edit said "locked",
176
+ and what they _couldn't_ said "updated successfully".
177
+
178
+ **The lock band told the truth; the Edit button did not.** objectui#2902 split
179
+ the band into "in approval · editable" vs locked, but the header **Edit** CTA
180
+ still keyed off nothing at all — on a genuinely locked record it stayed live, so
181
+ the user opened the form, filled a screen, and got `RECORD_LOCKED` back on Save.
182
+ It is now `disabled` on a locked record: visible-but-off, with the band beside it
183
+ saying why. This is the LOCK, not the mere presence of an approval — a
184
+ `lockRecord: false` node keeps Edit live, which is the point of that setting.
185
+
186
+ **And the band could still re-lock itself.** `DetailView` OR-ed the record's own
187
+ `approval_status` mirror into `isLocked` unconditionally. That mirror is written
188
+ on submit by any flow configuring an `approvalStatusField`, _regardless of_
189
+ `lockRecord` — so on a `lockRecord: false` node the host correctly resolved "not
190
+ locked" from the request's `lock_record` while the mirror dragged the band back
191
+ to "Locked for approval", with the pencils live and saves landing underneath it.
192
+ The host is now authoritative whenever it threads `approvalPending`; the mirror
193
+ is consulted only for bare/legacy `DetailView` hosts that thread nothing, where
194
+ it still reads as locked (no node granularity — the safe direction).
195
+
196
+ Recall's tooltip no longer promises to unlock a record the node never locked
197
+ (`detail.cancelApprovalTooltipUnlocked`).
198
+
199
+ **Silently stripped fields now surface on the record form's save path.** The
200
+ adapter emitted a write-warning for `create`/`update` responses carrying
201
+ `droppedFields`, but not for `batchTransaction` — which is how the record form
202
+ saves a master-detail record, i.e. the one surface where a user actually edits a
203
+ `readonlyWhen`-locked field. `batchTransaction` now emits one warning per event,
204
+ resolving each back to its operation via the response's `index`.
205
+
206
+ The toast itself was hardcoded English and called every strip "read-only". It is
207
+ now localized (`detail.writeStripped*`, ten locales) and worded by reason:
208
+ `readonly_when` says the field is not editable _in this record's current state_,
209
+ which is what actually happened — the field is editable in other states and the
210
+ form rendered it as an ordinary input, so "read-only" sent the user hunting for a
211
+ permission problem that does not exist.
212
+
213
+ **And it stopped crying wolf.** `createObjectStackUserStateAdapter` hand-stamped
214
+ the server-managed `updated_at` on every recents/favorites write, which the
215
+ server strips and reports — so the console popped "Some fields were not saved"
216
+ about a field no user ever touched, on page loads, drowning the signal the toast
217
+ exists for. It no longer sends the column; the server stamps it anyway.
218
+
219
+ - 0ded602: fix(form): a server rejection that names fields now marks those fields (objectstack#3896)
220
+
221
+ The server has always said which field it rejected. `@objectstack/objectql`'s
222
+ validators throw `VALIDATION_FAILED` with `fields[]` — one entry per offending
223
+ field, each with a human `message` — and both the REST layer and the runtime
224
+ dispatcher serve that as a 400 with the entries intact.
225
+
226
+ Every form dropped them. The submit handler caught the rejection, ran the
227
+ message through `extractWriteErrorMessage`, and showed **one undirected toast**:
228
+ the user was told something was wrong but not _what_, on a surface that already
229
+ knows how to mark an input — and already does exactly that for client-side
230
+ validation. On a long form the offending field was often off-screen, so "创建"
231
+ appeared to do nothing.
232
+
233
+ **Now the two failures behave identically, because they share one
234
+ implementation.** The per-field marking, the toast naming the fields, and the
235
+ scroll-and-focus of the first offender (#2793) were extracted from the
236
+ client-side invalid handler; the server path calls the same function. As far as
237
+ the person filling in the form is concerned these are the same event — only the
238
+ referee differs.
239
+
240
+ Three layers, each of which was dropping the detail:
241
+
242
+ - **`@object-ui/react`** — new `extractFieldErrors(err)` (exported alongside
243
+ `extractWriteErrorMessage` / `isPermissionError`) normalises the three shapes
244
+ the error can arrive in: a typed `ValidationError` from the ObjectStack
245
+ adapter, the raw `@objectstack/client` error (whose `details` falls back to the
246
+ whole response body, which is where `fields[]` lands), and a hand-rolled error
247
+ carrying `fields` directly — the server duck-types that shape identically, so
248
+ the client must not be pickier than the server. Entries with no usable `field`
249
+ are **dropped rather than guessed at**: marking an innocent input is worse than
250
+ the generic toast.
251
+ - **`@object-ui/data-objectstack`** — `normaliseClientError` now maps a 400
252
+ `VALIDATION_FAILED` onto the `ValidationError` class that has sat in
253
+ `errors.ts` since the package was written, exported and **never once
254
+ constructed**. Its `validationErrors: Array<{ field, message }>` shape was
255
+ already exactly right. `create` also now normalises at all: only `update` did,
256
+ so a rejected insert reached callers as the raw client error — and a create is
257
+ the path that most often trips required-field validation.
258
+ - **`@object-ui/components`** — the form renderer maps the entries onto
259
+ `form.setError` and takes over the failure, **but only when every rejected
260
+ field has a visible input to carry it**. If the server also rejected something
261
+ the form does not render, it falls through to the banner, whose top-level
262
+ message concatenates every field's reason — so the part the user cannot see
263
+ inline is still said out loud instead of silently dropped.
264
+
265
+ This also removes the need for the client-side predicate mirroring added in
266
+ #2962: a form no longer has to guess what the server will reject in order to
267
+ warn about it beforehand, and mirrored predicates drift.
268
+
269
+ Non-field failures (403 / permission denials / anything without `fields[]`) take
270
+ exactly the path they took before.
271
+
272
+ ### Patch Changes
273
+
274
+ - 4952edf: fix(errors): error-code branches survive the framework's ADR-0112 rename — objectstack#3841
275
+
276
+ Framework ADR-0112 renamed the whole `error.code` vocabulary from lowercase
277
+ `snake_case` to `SCREAMING_SNAKE` (`destructive_change` → `DESTRUCTIVE_CHANGE`).
278
+ Eleven places compared `err.code` against the old spelling with `===`, so against
279
+ a swept server they simply stopped matching — and nothing threw. The affordance
280
+ each branch guards just vanished and the user got the generic error toast instead:
281
+
282
+ - the destructive-change confirm dialog (resource editor, permission matrix)
283
+ - the "create a writable package first" hint
284
+ - field-scoped validation issues on embedded item saves
285
+ - the all-or-nothing publish summary naming the causal item
286
+ - unknown-object tolerance in the app header and in record search
287
+ - the marketplace's local-install messages for conflict / auth / unavailable
288
+ - `isNotFoundError` in the data layer
289
+
290
+ `RECORD_NOT_FOUND` had already been renamed a release earlier, so that branch was
291
+ already dead before this fix.
292
+
293
+ New `errorCodeIs` / `errorCodeIsAnyOf` in `@object-ui/types` compare
294
+ case-insensitively, so the console keeps working against servers on either side
295
+ of the rename — the console ships separately from the server it talks to. Every
296
+ call site now passes the catalog (SCREAMING) spelling, and `error-code.ts` is the
297
+ single file to delete once no supported server emits the old vocabulary.
298
+
299
+ - 7f0252e: fix(list,data-objectstack,types): exporting a searched list no longer downloads the unsearched superset
300
+
301
+ The server-streamed export mirrored the view's `filter` and `sort`, and the
302
+ code comment claimed that made the file match the screen:
303
+
304
+ > Mirrors the active view's filter + sort so the exported file matches what the
305
+ > user sees.
306
+
307
+ It mirrored one half. There was no way to carry the term a user had typed into
308
+ the search box — `ExportDownloadRequest` had no field for one — so exporting
309
+ during a search produced **more rows than the list showed**, in a file that
310
+ looks authoritative, with nothing indicating the difference. The client-side
311
+ fallback was always correct (it serializes the already-searched `data`); only
312
+ the server path was wrong, and it is the one that handles xlsx.
313
+
314
+ Same family as a dropped filter (objectstack#3948, objectstack#4181): a
315
+ plausible answer that is quietly broader than the one asked for.
316
+
317
+ - `ExportDownloadRequest` gains `search` / `searchFields`.
318
+ - `ObjectStackAdapter.exportDownload` sends them as `search=` / `searchFields=`,
319
+ trimming the term and omitting both when it is blank (`searchFields` alone
320
+ means nothing).
321
+ - `ListView` passes the active `searchTerm` and the view's `searchableFields`,
322
+ and both are now in the export callback's dependency array — a stale closure
323
+ would export the wrong row set.
324
+
325
+ Requires a server with objectstack#4230. Older servers ignore unknown query
326
+ params on this route, so they keep today's behaviour rather than erroring.
327
+
328
+ **Also: the filter merge is no longer written twice.** The three filter sources
329
+ (view filter, filter-panel group, per-field user filters) were merged by
330
+ verbatim copies in the data fetch and in the export — two copies that must
331
+ agree, deciding respectively what the user _sees_ and what they _download_.
332
+ Both now call `buildEffectiveFilter`. This is a pure extraction: the copies did
333
+ agree, and the four parity tests added for it pass against the old code too.
334
+ They exist to keep it that way — the adapter's duplicated filter-shape check
335
+ had already drifted apart unnoticed (#3072).
336
+
337
+ - 7d35010: fix(data-objectstack): a view's own filter no longer disappears when the user adds one
338
+
339
+ `ObjectStackAdapter` translated object-form filter entries
340
+ (`[{ field, operator, value }, ...]`) only at the **top level** of a `$filter`.
341
+ The moment a list has both a stored view filter and a user filter, it builds
342
+
343
+ ```js
344
+ [
345
+ "and",
346
+ [{ field: "stage", operator: "eq", value: "won" }],
347
+ [["amount", ">", 1]],
348
+ ];
349
+ ```
350
+
351
+ whose head is the string `and`, so the old check called the whole thing
352
+ "already AST" and shipped the rules untranslated. Both server answers to that
353
+ are wrong:
354
+
355
+ ```js
356
+ isFilterAST(above); // false — a bare rule object is not an AST child
357
+ parseFilterAST(above); // { amount: { $gt: 1 } } ← `stage = won` is GONE
358
+ ```
359
+
360
+ Since objectstack#4121 the `isFilterAST` gate turns it into a **400 and the
361
+ list fails to load**. Before it — or anywhere `parseFilterAST` is reached
362
+ without that gate — **the view's own condition is dropped without a word** and
363
+ the list returns records the view exists to exclude.
364
+
365
+ Translation is now recursive through `and`/`or` nodes and legacy flat child
366
+ arrays, so the shape reaches the server as a valid AST
367
+ (`{$and: [{stage: 'won'}, {amount: {$gt: 1}}]}`).
368
+
369
+ Three related fixes in the same code:
370
+
371
+ - **An untranslatable entry is now an error, not an omission.** Entries that
372
+ failed to translate were dropped, and dropping one conjunct of an `and`
373
+ returns a _superset_ of the rows asked for — dropping the last one sent no
374
+ `filter=` at all, so the whole table came back. `find()` now throws
375
+ `MalformedFilterError`, carrying `code: 'INVALID_FILTER'` / `httpStatus: 400`
376
+ so a failed list renders "the filter is malformed" rather than "check your
377
+ connection". A rule with a blank `field` passes `ViewFilterRuleSchema`
378
+ (`z.string()` admits `''`), so this is reachable from real stored metadata.
379
+ A _mixed_ array (`[{ field, operator, value }, ['amount', '>', 1]]`) now
380
+ keeps both halves instead of dropping the tuple — that case was a lost
381
+ condition, not a malformed one.
382
+ - **The two `find()` routes can no longer disagree.** The "is this object
383
+ form?" test existed twice — once in `translateFilterToAST`, once inline in
384
+ `convertQueryParams` — and the copies had already drifted: the inline one
385
+ omitted a `!== null` guard, so a `$filter` of `[null]` threw a `TypeError` on
386
+ the plain route while the same value was handled on the `$expand` route. One
387
+ definition now serves both.
388
+ - **Dropped an unreachable `entry.name` fallback.** `objectFilterEntryToAST`
389
+ read `entry.field ?? entry.name` while the shape check keyed on `field`
390
+ alone, so the `name` half was dead from the commit that introduced it. The
391
+ spec agrees it is not a real shape — `ViewFilterRuleSchema.field` is
392
+ required, so such a rule cannot be saved as view metadata.
393
+
394
+ Refs objectstack#3948, objectstack#4121, #2945
395
+
396
+ - c4d7b20: fix(view,list,core): a view's filter no longer disappears, or arrives as a predicate on columns that don't exist
397
+
398
+ Sweeping the other `$filter` producers after #3078 turned up two live defects in
399
+ `ObjectView`, which fetches its own data for calendar / kanban / gallery /
400
+ timeline (grid delegates to `ObjectGrid`).
401
+
402
+ **1. An object filter was dropped, and only for non-grid views.**
403
+ `table.defaultFilters` is declared `Record<string, any>`, and the merge tested
404
+ `baseFilter.length > 0` — `undefined > 0` for an object. So the filter vanished
405
+ and the view returned **every record**. `ObjectGrid` assigns the same value
406
+ straight to `params.$filter`, so one view definition filtered correctly as a
407
+ grid and returned everything as a calendar.
408
+
409
+ **2. Rule objects were spread into the `and`, not wrapped.**
410
+ `['and', ...baseFilter, ...userFilter]` is only correct when the source is an
411
+ array of AST nodes. `activeView.filter` is a spec `ViewFilterRule[]`, so
412
+ spreading put bare rule objects where the AST expects nodes:
413
+
414
+ ```js
415
+ isFilterAST([
416
+ "and",
417
+ { field: "stage", operator: "eq", value: "won" },
418
+ ["owner", "=", "me"],
419
+ ]);
420
+ // false → 400 since objectstack#4121
421
+ parseFilterAST(same);
422
+ // {$and:[{field:'stage',operator:'eq',value:'won'}, {owner:'me'}]}
423
+ ```
424
+
425
+ That second line is a predicate over three columns named `field`, `operator`
426
+ and `value` — which don't exist.
427
+
428
+ > **Correction.** The first version of this note said the spread was "reachable
429
+ > whenever a view with a filter meets a user filter value". That was wrong for
430
+ > `ObjectView`: the branch required a non-empty user filter, and nothing ever
431
+ > wrote the state it was built from, so it could never run. The shape is
432
+ > genuinely broken — a live server answers it with a 400 — and the adapter-level
433
+ > defence added alongside is still warranted for any producer that emits it, but
434
+ > **this particular site was dead code, not a live defect.** Defect 1 above was
435
+ > live: it sat on the always-taken path. The dead machinery behind the wrong
436
+ > claim is removed in a follow-up.
437
+
438
+ New in `@object-ui/core`: `toFilterNode` normalizes one source (rule array / AST
439
+ / MongoDB object) and `mergeFilterNodes` combines sources as siblings under one
440
+ `and`. `ObjectView` and `ListView.buildEffectiveFilter` both use them, so the
441
+ three filter shapes are reconciled in one place instead of by hand at each
442
+ renderer.
443
+
444
+ `ObjectStackAdapter` also now translates a bare rule object sitting directly
445
+ under a logical node — the chokepoint defence for any producer still emitting
446
+ the spread shape. Only rule-_shaped_ objects are touched; a child with no
447
+ `field` is a genuine MongoDB condition and passes through untouched.
448
+
449
+ **Correcting a comment shipped in #3078.** `buildEffectiveFilter` documented the
450
+ dropped-object case as unreachable, "nothing in this repo produces one for a
451
+ list view". That was wrong: `ObjectView` passes `mergedFilters` straight into
452
+ that schema's `filter`, and its last fallback is `table.defaultFilters`. The
453
+ case is now handled rather than explained away.
454
+
455
+ Verified with 19 tests across the four packages; reverting each source file
456
+ fails the ones that cover it. Emitted filters are asserted against the spec's
457
+ own `isFilterAST` / `parseFilterAST`, including an executable pin on what the
458
+ old spread shape produced.
459
+
460
+ - ad0183a: fix(data-objectstack,core): an object filter no longer depends on whether the query expands a lookup
461
+
462
+ #3072 single-sourced the ARRAY branch of the adapter's two `find()` routes. The
463
+ object branch was left as it was: `convertQueryParams` converted a MongoDB-style
464
+ filter to AST while `translateFilterToAST` returned it verbatim — so the same
465
+ `$filter` went out in two formats, decided by whether the query happened to
466
+ expand a lookup.
467
+
468
+ Measured across 21 operator shapes, **four diverged**. Most of the gap turned
469
+ out to be harmless — `{$and: […]}` survives the plain route as a
470
+ `['$and','=',[…]]` comparison that `parseFilterAST` reads back as a real `$and`,
471
+ and `$exists` vs `$null` is a difference the server treats identically. Two were
472
+ not harmless:
473
+
474
+ - **The unknown-operator guard only ran on one route.** `convertFiltersToAST`
475
+ throws on an unrecognised operator, with a comment saying it does so "to avoid
476
+ silent failure" — but the expanded route never called it, so a typo'd operator
477
+ threw on a plain read and shipped silently whenever a lookup was expanded.
478
+ - **`$regex` was silently rewritten to `contains`.** `$regex: 'a.c'` matches
479
+ "abc"; `contains 'a.c'` matches only those three literal characters. That is a
480
+ _different question_, not a weaker version of the same one, and neither result
481
+ looks wrong on screen. The rewrite sat behind a `console.warn`, which is not
482
+ an error channel in a deployed app — and the function's own unknown-operator
483
+ message never listed `$regex` among the supported set. The spec has no
484
+ `$regex` (`FILTER_OPERATORS`, `data/filter.zod.ts`), so there is nothing to
485
+ translate it into: it is now refused, the same treatment the neighbouring
486
+ unknown operator already got. Nothing in the repo depended on the conversion.
487
+
488
+ Both refusals now throw `FilterOperatorError`, carrying `code: 'INVALID_FILTER'`
489
+ / `httpStatus: 400`. The pre-existing unknown-operator throw was a bare `Error`,
490
+ which `classifyLoadError` classifies as a network fault — so a malformed filter
491
+ told the user to check their connection (#3066), the one thing it definitely
492
+ was not.
493
+
494
+ - a17ef09: fix(data-objectstack): a string `$orderby` reaches the server as a sort instead of a list of character indices — #3106
495
+
496
+ `QueryParams['$orderby']` declares four shapes — `string`, `string[]`,
497
+ `SortNode[]`, `Record<field, direction>`. Both of this adapter's `find()` routes
498
+ (`convertQueryParams` for a plain read, `rawFindWithPopulate` for one carrying
499
+ `$expand`/`$search`) carried their own copy of the fold that serializes it, and
500
+ both copies handled the same three. The bare string fell through to the
501
+ `Record` branch, where `Object.entries('name asc')` enumerates the string's
502
+ character indices — so the request went out as `sort=0,1,2,3,4,5,6,7`.
503
+
504
+ Since `objectstack#4226` the server refuses a sort it cannot read
505
+ (`400 INVALID_SORT`) rather than dropping it silently, so this was not a
506
+ degraded ordering but a list that failed to load outright — and `"${field}
507
+ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
508
+ `sort`, making every standalone grid with a configured sort a broken one.
509
+
510
+ Both routes now share one exported `serializeOrderBy`, for the same reason the
511
+ filter path already shares one: two copies of a fold can only agree by
512
+ inspection, and these two did not.
513
+
514
+ - Updated dependencies [62311b6]
515
+ - Updated dependencies [9e7349e]
516
+ - Updated dependencies [8864971]
517
+ - Updated dependencies [b41f401]
518
+ - Updated dependencies [19e9fa0]
519
+ - Updated dependencies [95b7214]
520
+ - Updated dependencies [7d9734d]
521
+ - Updated dependencies [6ae818e]
522
+ - Updated dependencies [746dd00]
523
+ - Updated dependencies [aebfa4f]
524
+ - Updated dependencies [38ca8be]
525
+ - Updated dependencies [4952edf]
526
+ - Updated dependencies [7f0252e]
527
+ - Updated dependencies [c4d7b20]
528
+ - Updated dependencies [7639a61]
529
+ - Updated dependencies [94e63ef]
530
+ - Updated dependencies [02aef0c]
531
+ - Updated dependencies [6f29aa5]
532
+ - Updated dependencies [c4db402]
533
+ - Updated dependencies [5319bf1]
534
+ - Updated dependencies [49e5671]
535
+ - Updated dependencies [b5b97e2]
536
+ - Updated dependencies [f59f2c1]
537
+ - Updated dependencies [4874117]
538
+ - Updated dependencies [ad0183a]
539
+ - Updated dependencies [ce08d55]
540
+ - Updated dependencies [aa1240a]
541
+ - Updated dependencies [2374a49]
542
+ - Updated dependencies [390c071]
543
+ - Updated dependencies [d10f526]
544
+ - Updated dependencies [2d5d594]
545
+ - Updated dependencies [ea7f477]
546
+ - Updated dependencies [7f23cd0]
547
+ - Updated dependencies [24e0e0a]
548
+ - Updated dependencies [3a6cf24]
549
+ - Updated dependencies [aa35561]
550
+ - Updated dependencies [03bd53b]
551
+ - Updated dependencies [3c1f321]
552
+ - Updated dependencies [a045a32]
553
+ - Updated dependencies [912496d]
554
+ - Updated dependencies [9867281]
555
+ - @object-ui/core@17.1.0
556
+ - @object-ui/types@17.1.0
557
+
558
+ ## 17.0.0
559
+
560
+ ### Minor Changes
561
+
562
+ - d62fb1f: feat(app-shell): toast when a save silently dropped read-only fields (framework #3431/#3455)
563
+
564
+ The framework now reports fields it LEGALLY stripped from a write (a non-system
565
+ caller can't seed a `readonly` field, a `readonlyWhen` predicate locked it, …)
566
+ via a `droppedFields` payload on the create/update response. Previously the
567
+ console discarded it: a value the user typed into a locked field just vanished on
568
+ save with a success toast and no explanation.
569
+
570
+ - **data-objectstack:** `ObjectStackAdapter` now emits a `WriteWarningEvent`
571
+ after a create/update whose response carried `droppedFields`, exposed through a
572
+ new `onWriteWarning(cb)` subscription (mirrors the existing `onMutation` bus).
573
+ Reads the field structurally, so an older client or a backend that never drops
574
+ is a no-op. New exported types: `WriteWarningEvent`, `WriteWarningListener`,
575
+ `DroppedFieldsEvent`.
576
+ - **app-shell:** `AdapterProvider` subscribes and raises a `toast.warning`
577
+ ("Some fields were not saved — the read-only field … could not be changed"),
578
+ so the strip is visible instead of silent. The write itself still succeeded;
579
+ status/behaviour are unchanged.
580
+
581
+ ### Patch Changes
582
+
583
+ - 8ecf5a6: Command palette (⌘K) now surfaces record search hits from the platform's global
584
+ search endpoint (`GET /api/v1/search`).
585
+
586
+ Previously the palette only ran a per-object `find({ $search })` fanout (the
587
+ metadata-driven ADR-0061 search), which misses records that only the global
588
+ search index knows about — so typing a well-known record name returned no
589
+ records even though `/api/v1/search` served them. `ObjectStackAdapter` now
590
+ exposes a `searchAll(query, { limit, objects })` method that calls the unified
591
+ endpoint, `useRecordSearch` prefers it when present (falling back to the fanout
592
+ otherwise), and the palette renders the resulting record hits grouped by object.
593
+
594
+ - 6e8fd3c: fix(charts): a fieldless `count` aggregate keyed its value column `undefined`, so the chart plotted nothing (framework#3701)
595
+
596
+ framework#3701 pinned down what an OBJECT-bound chart aggregate names its result
597
+ columns — the raw field names it was given (`groupBy` for the category, `field`
598
+ for the value; no `sum_`-style decoration, unlike a dataset measure), plus the
599
+ literal `count` when a `count` omits `field`, which is the alias the engine
600
+ projects `COUNT(*)` under. `os validate` now lints page sources against that
601
+ convention, so the paths that build these rows have to honour it exactly.
602
+
603
+ Three of the four did. The odd one out was `count` — the one function that may
604
+ legitimately omit `field` — because every row builder read `params.field`
605
+ directly:
606
+
607
+ - `aggregateRecords` / `ObjectDataSource.aggregateClientSide` emitted
608
+ `{ [groupBy]: key, [undefined]: value }`, i.e. a column literally named
609
+ `undefined` that no axis binding could ever name;
610
+ - the legacy analytics path was worse: it remapped the server's `count` measure
611
+ onto `params.field` and **deleted** the original key, so the value the server
612
+ did return was thrown away before the chart saw it.
613
+
614
+ All of them now resolve the column through one helper (`aggregateValueKey`) so a
615
+ fieldless count lands under `count`, matching the framework contract. The
616
+ comparison-overlay column is derived from the same key (`count__comparison`
617
+ instead of `undefined__comparison`), and `aggregate.field` is typed optional to
618
+ match the spec's `ChartAggregateSchema`. Charts that name a field are unchanged.
619
+
620
+ - Updated dependencies [1767124]
621
+ - Updated dependencies [8ecf5a6]
622
+ - Updated dependencies [7b35e4b]
623
+ - Updated dependencies [e16ed2d]
624
+ - Updated dependencies [f9bbddb]
625
+ - Updated dependencies [dfd3705]
626
+ - Updated dependencies [2735de6]
627
+ - Updated dependencies [6dee2cb]
628
+ - Updated dependencies [c7cff19]
629
+ - Updated dependencies [cd09a7b]
630
+ - Updated dependencies [f1abf0e]
631
+ - Updated dependencies [f05b84e]
632
+ - Updated dependencies [662bdf9]
633
+ - Updated dependencies [059a052]
634
+ - Updated dependencies [53642d4]
635
+ - Updated dependencies [8aae006]
636
+ - Updated dependencies [d147a13]
637
+ - @object-ui/types@17.0.0
638
+ - @object-ui/core@17.0.0
639
+
3
640
  ## 16.1.0
4
641
 
5
642
  ### Minor Changes
@@ -80,7 +717,7 @@
80
717
  (`visibleWhen`/`readonlyWhen`/`requiredWhen` via `fieldRules.ts`, which delegates
81
718
  to `@objectstack/formula`'s `ExpressionEngine`) now tracks the 15.x engine — and
82
719
  will pick up the framework's `dateField == today()` equality fix
83
- (objectstack-ai/framework#3205) automatically at the next 15.x release via the
720
+ (objectstack-ai/objectstack#3205) automatically at the next 15.x release via the
84
721
  caret range. Renderer/action `visible`/`disabled` predicates are unaffected (they
85
722
  use the home-grown JS evaluator — tracked separately in #2661).
86
723