Skip to content

Commit 56f7747

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19938-fields-value-slot
# Conflicts: # packages/spec/dropped-refinements.baseline.json
2 parents 7af0516 + 805af4f commit 56f7747

46 files changed

Lines changed: 3106 additions & 110 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/19888-analytics-implicit-array.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@ Ruling 乙 of #19757 refuses a list in the equality slot at the shared comparand
2727

2828
Each list is now handed to the shared face's equality arm before any node is built. Both spellings of one condition therefore get the same refusal, with the same wording, path and `$in` prescription. The draft-data preview runs the same gate, so a drafted chart refuses what the published chart refuses. Before, it compared each row against the list's string form.
2929

30-
Who is affected: nothing in this repository's examples, seeds or docs authors the shape (measured over `examples/**`, `packages/**` and the fenced code in the docs). Stored datasets, dashboard widget filters, report runtime filters and measure filters in a deployment were NOT measured. In this release the authoring schema refuses the shape too, when such a document is saved (a separate change in `@objectstack/spec`, ADR-0087 entry `filter-equality-array-comparand-refused-at-save`). One position is judged only here: a list inside a nested relation, which this door flattens to a dotted member. A document carrying that still publishes, and it is refused when it is charted. The refusal names the field and the path. `$ne` with a list is not part of the ruling and is not judged here. The list operators (`$in`, `$nin`, `$between`) keep their lists, and every scalar, `null` included, compiles as before.
30+
Who is affected: nothing in this repository's examples, seeds or docs authors the shape (measured over `examples/**`, `packages/**` and the fenced code in the docs). Stored datasets, dashboard widget filters, report runtime filters and measure filters in a deployment were NOT measured. In this release the authoring schema refuses the shape too, when such a document is saved (a separate change in `@objectstack/spec`, ADR-0087 entry `filter-equality-array-comparand-refused-at-save`). One position is judged here and not by the shared authoring schema: a list inside a nested relation, which this door flattens to a dotted member. A dataset `filter` or measure `filter` carrying one is refused when it is saved, in the same words (a separate change in `@objectstack/spec`, ADR-0087 entry `dataset-filter-nested-relation-equality-array-refused-at-save`). Any other `where` that reaches this door with one, such as a caller `where` or a dataset selection's `runtimeFilter`, is refused only when it is charted. The refusal names the field and the path. `$ne` with a list is not part of the ruling and is not judged here. The list operators (`$in`, `$nin`, `$between`) keep their lists, and every scalar, `null` included, compiles as before.

‎.changeset/19889-filter-schema-door-array-equality.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Two request doors parse these carriers, and they now answer before the analytics
2121
## What does NOT change
2222

2323
- **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored document keeps loading, and its next save is refused. Such a filter has failed every query since the equality-slot change, so the refusal is a repair.
24-
- **The reach is the face's, and no wider.** A field spec with no `$` key, such as the nested-relation condition `{ account: { region: ['a'] } }`, is not judged, because the face does not judge it either. The analytics `where` door does refuse that shape when a dataset or measure filter is charted.
24+
- **The reach is the face's, and no wider.** A field spec with no `$` key, such as the nested-relation condition `{ account: { region: ['a'] } }`, is not judged by `FilterConditionSchema`, because the face does not judge it either. The analytics `where` door does refuse that shape, because it flattens the relation to a dotted member. So the two carriers that door charts, the dataset `filter` and the measure `filter`, refuse it on save as well, in the same words. That is a separate change in this release, ADR-0087 entry `dataset-filter-nested-relation-equality-array-refused-at-save`.
2525
- **The data-engine calls' `where` option still parses.** Its type is a union whose first arm is an open record. The face refuses the shape when the call runs.
2626
- **`$ne` carrying an array is not judged.**
2727
- The list operators keep their arrays, `$in: []` and `$nin: []` included. Every scalar, `null`, a `Date` and a `{ $field }` reference pass as before.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: a flattened list view overlay's legacy `options` bag is judged at the view write door, so an out-of-contract `options.KIND` key is refused by name exactly as the direct spelling is (#20051)
6+
7+
**BREAKING** accept-set narrowing on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save). It ships as `minor` under the repo's launch-window convention for breaking changes. This is the door half of ruling A on objectui#10380 (maintainer 「其他同意」).
8+
9+
Clause-②: no
10+
11+
## What was wrong
12+
13+
The flattened list overlay member of `ViewMetadataSchema` re-opens its top level with `.strip()` so the console's round-trip keys survive. That strip also dropped a top-level `options` bag from the parse without looking inside it. `saveMetaItem` stores the request body, not the parse output, and objectui's interface page forwards a stored view's `options` into the list renderer, which merges `options.KIND` under the top-level `KIND` block. Measured on `origin/main` @ `8d1f7ab` through the real save: `timeline: { metaFields: [...] }` answered `422`, while the same key written as `options: { timeline: { metaFields: [...] } }` answered `200` and the row held it as sent.
14+
15+
## What it does now
16+
17+
- **The list overlay declares `options`.** Each `options.KIND` (`kanban`, `calendar`, `gantt`, `gallery`, `timeline`, `chart`, `map`, `tree`) is judged by that kind's own block schema: the same closed key set, the same per-key schemas and the same unknown-key message. The refusal names the key, with only the `options.` prefix added to the path. The kinds are derived from the list-view shape, not listed by hand.
18+
- **Key by key.** The renderer reads the bag as a per-key underlay of the top-level block, so the block's required keys are not asked of it. `kanban: { groupByField, columns }` beside `options.kanban: { titleField }` stays legal, which is the population objectui pins.
19+
- **The bag is closed.** A key that is not a kind (`options.foo`, `options.grid`) is refused by name at `options`. It is no longer dropped.
20+
- **The form overlay pins `options` absent.** Without this, a column-less, type-less list body that the list overlay refused over its bag would be accepted by the form overlay and stored unjudged. The refusal says the bag belongs to a list view.
21+
22+
The legacy `options.map` bag that objectui pins (`locationField`, `titleField`) is still accepted, and it round-trips.
23+
24+
## FROM → TO
25+
26+
| you wrote | write instead |
27+
|:--|:--|
28+
| `options: { kanban: { groupField: 'stage' } }` | `kanban: { groupByField: 'stage', columns: [...] }`, or `options: { kanban: { groupByField: 'stage' } }` |
29+
| `options: { calendar: { dateField: 'kickoff' } }` | `calendar: { startDateField: 'kickoff' }` |
30+
| `options: { timeline: { metaFields: ['region'] } }` | delete `metaFields`: the timeline block has no such key |
31+
| `options: { chart: { xAxisField, yAxisFields } }` | the dataset-bound block, `chart: { dataset, values, dimensions }` |
32+
| `options: { foo: 1 }` | delete `foo`: the bag carries per-kind blocks only |
33+
| `options: {...}` on a form overlay (`viewKind: 'form'`) | delete `options`: a form view has no per-kind blocks |
34+
35+
**The one-line fix:** read the refusal. It names the key and the block that refuses it; move the key to the top-level block's declared spelling, or delete it.
36+
37+
## Stored-row census (ruling item 3)
38+
39+
- **objectstack** @ `8d1f7ab` (examples, dogfood, fixtures, tests): zero view bodies carry a top-level `options` bag with a kind block. Authored views go through the strict authoring shape, which has always refused `options`.
40+
- **objectui**, at the `.objectui-sha` pin `f8a9d0fb0` and at `main` `c3a26ccda`: 28 `options` bag literals, plus the finding's own probe body, judged against this change. 17 pass and 12 fail, and every failure is an out-of-contract key refused by name. None fails for a missing key.
41+
- **Bodies that model a stored or authored view** (a console-merged `listViews` entry or a named view): 8, of which 5 pass, the pinned `options.map` path among them. The 3 that fail: `options.kanban.groupField` in `plugin-view` `ObjectView.tsx`'s docblock example (write `groupByField`), `options.calendar.dateField` in `ObjectView.calendarAliasRefused-8355.test.tsx` (write `startDateField`; that test already pins the alias as refused on objectui's side), and the finding's `options.timeline.metaFields` (delete it).
42+
- **Renderer-level `ListView` props** (21), which never reach this door: 12 pass. The 9 that fail spell the legacy keys objectui's own refusal pins already retire (`groupField`, `groupBy`, `dateField`, `metaFields`, and the object-bound chart keys `xAxisField` / `yAxisFields` / `aggregation`).
43+
- **Production `sys_metadata` rows: NOT MEASURED.** No deployment's store is reachable from the repository. A stored row that fails keeps being read and served exactly as stored. It is refused only on its next save, and the refusal names the key.
44+
45+
## Not in this change
46+
47+
Persisting the parse output instead of the request body (ruling item 2) is not in this change. This change leaves the save path's storage behaviour as it was: a body the door now accepts is stored as sent, so every stored `options` bag is one the door judged.
48+
49+
<!-- adr-0087: registered view-overlay-options-bag-judged -->
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec)!: a dataset or measure filter with a list inside a nested relation is refused when it is saved, not when it is charted (#20080)
6+
7+
**BREAKING**: an accept-set narrowing of a published authoring schema, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `dataset-filter-nested-relation-equality-array-refused-at-save`.
8+
9+
## What changes
10+
11+
`DatasetSchema.filter` and `DatasetMeasureSchema.filter` now refuse an ARRAY in the equality slot of a field inside a nested-relation condition. That covers the implicit form `{ account: { region: ['a'] } }` and the explicit form `{ account: { region: { $eq: ['a'] } } }`, the empty array included, at any relation depth and under `$and` / `$or` / `$not`. So `defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a dataset at the filter's path, for example `filter.account.region` or `measures.0.filter.account.region.$eq`.
12+
13+
Before this change the dataset saved clean. The analytics `where` door, which charts both filters on every path, flattens the relation to the dotted member `account.region` and refuses the list with `INVALID_FILTER` / 400. So every chart built on the dataset failed. Measured on `origin/main` `9e7824a445`: `DatasetSchema.safeParse` answered `success: true` for both forms.
14+
15+
The refusal is the analytics door's sentence: the field, the received list, and the two operators a list in that slot stood in for. The door adds `at where.account.region`. The schema leaves that out, because the issue's `path` already says where it is.
16+
17+
## What does NOT change
18+
19+
- **The shared `FilterConditionSchema` keeps its reach.** Every other schema that carries a `FilterCondition` still accepts a list inside a nested relation, because the engine reads that spec as a deep-equality comparand.
20+
- **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored dataset keeps loading, and its next save is refused. Such a filter has failed every chart, so the refusal is a repair.
21+
- A list outside a nested relation is refused as before, once, by `FilterConditionSchema`.
22+
- `$ne` carrying an array is not judged. The list operators keep their arrays, `$in: []` and `$nin: []` included. Every scalar, `null`, a `Date` and a `{ $field }` reference inside a nested relation pass as before.
23+
24+
## FROM → TO
25+
26+
| you wrote | write instead |
27+
|:--|:--|
28+
| `{ account: { region: ['a', 'b'] } }`, meaning "one of these values" | `{ account: { region: { $in: ['a', 'b'] } } }` |
29+
| `{ account: { tags: ['a'] } }`, meaning "the stored list holds `a`" on a multi-value field | `{ account: { tags: { $contains: 'a' } } }` |
30+
| `{ account: { region: ['a'] } }`, meaning one value | `{ account: { region: 'a' } }` |
31+
| `{ account: { region: { $eq: [...] } } }` | any of the rows above |
32+
33+
## Who is affected, measured
34+
35+
Nothing shipped in this repository carries the shape. At `9e7824a445`, the dataset and measure filters of `examples/app-crm`, `examples/app-showcase`, `examples/app-todo` and the platform objects, plus the dataset examples in `content/docs` and `skills`, carry no list inside a nested relation. Deployed datasets were NOT measured. Validating each stack, or re-saving each dataset, finds every instance.
36+
37+
Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. One comparand shape in one position of two carriers, which the analytics door already refused on every chart, is now refused on save as well.
38+
39+
<!-- adr-0087: registered dataset-filter-nested-relation-equality-array-refused-at-save -->
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The currency-chain text shipped in `@objectstack/spec` states that `currencyConfig.defaultCurrency` is read only under `currencyMode: 'fixed'`, and no longer cites ADR-0053 (the date / datetime record) for currency (#20126)
6+
7+
Clause-②: no
8+
9+
A `dynamic` currency field (the default mode) has no currency of its own. Its amounts display in the tenant default currency (`localization.currency`), or as a plain number when none is set, and its `defaultCurrency`, including the parse default `CNY`, is not read. Each reader does this: the analytics relay (`AnalyticsServicePlugin`'s `sourceFieldMeta`), objectui's `resolveFieldCurrency`, and the spec's own precision refinement in `CurrencyConfigSchema`. Several published texts still described an unconditioned `defaultCurrency` step, or called the chain "ADR-0053":
10+
11+
- **`CurrencyConfigSchema.defaultCurrency` describe** (also regenerated into `content/docs/references/data/field.mdx`):
12+
- FROM: `Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)`
13+
- TO: ``The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency.``
14+
- **`AnalyticsResultResponseSchema` `data.fields[].currency` describe**:
15+
- FROM: ``Resolved ISO 4217 code for a MONETARY measure (explicit measure `currency`, then source-field default, then tenant default). Absent on non-monetary columns, which must never render a symbol.``
16+
- TO: ``Resolved ISO 4217 code for a MONETARY measure (explicit measure `currency`, then the source field's fixed currency — its `currencyConfig.defaultCurrency`, read only under `currencyMode: 'fixed'` — then the tenant default). Absent on non-monetary columns, which must never render a symbol.``
17+
- **TSDoc**: `AnalyticsResult.fields[].currency` (`contracts`), the `AnalyticsResultResponseSchema` docblock and the percent-scale module header name the chain "the currency chain" and state its middle step as the field's fixed currency.
18+
- **Liveness ledger** (`liveness/dataset.json` `currency`, `liveness/field.json` `currencyConfig`): the evidence is re-worded and re-anchored to the fixed-mode readers, and `verifiedAt` is re-dated. Both verdicts stay `live`.
19+
20+
⛔ No behaviour changes. No type, schema, accept set, default, authorable key or export moves. Only describe strings, TSDoc and ledger text change, and they ship: `dist` carries the describes and the emitted TSDoc, `src/**/*.zod.ts` carries the two edited schema files as source, and `liveness` carries both ledger files.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
`IObjectQLEngine` gains an optional judge-only member, `judgeFilter(objectName, where, { operation?, context? })`, and `ObjectQL` implements it (#20157, #19995 ruling C). It answers "can this filter run against this object?" without running anything: `{ ok: true }`, or `{ ok: false, code, status, message }` with the same diagnostic execution would raise.
7+
8+
- **The engine's own admission, not a copy.** The judge calls the two stage functions every verb that takes a `where` already runs, in their order. First the lowering doors: the shape gate, the list-comparand shape, the virtual-field and dotted-path refusals, the text operator over a non-text field, the uninterpretable temporal comparand and the comparand-type door. Then the filter-placeholder resolver. A new door on that pipeline is judged the day it lands.
9+
- **Nothing executes.** No driver is resolved or called, and no hook or middleware runs. The member is synchronous, so a door that needs I/O cannot join it without a contract change. Driver-level refusals and the predicates middleware composes later (RLS, sharing, tenant scope) are not judged.
10+
- **Placeholders resolve against `context`**, exactly as execution resolves them. A context placeholder the context cannot answer (`{current_user_id}` with no user) is refused with `FILTER_TOKEN_UNRESOLVED`, never resolved to `null`.
11+
- **`operation`** (default `'find'`) names the verb the caller will run, so the message carries that verb's prefix. The verdict is the same on every verb.
12+
- **The message is not redacted.** It names fields, operators and comparands. A caller judging a filter it must not disclose, such as a read-scope policy, withholds the message itself.
13+
14+
Optional by the ruling. A caller probes for it (`typeof ql.judgeFilter === 'function'`) and keeps its current behaviour on an engine without it. Existing engine doubles and foreign engines need no change. Execution is unchanged for every CRUD caller: same diagnostics, same order.
15+
16+
Clause-②: yes

0 commit comments

Comments
 (0)