Skip to content

Commit b48f417

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20176-aggregation-temporal-storage-rule
2 parents a76b115 + 560b724 commit b48f417

41 files changed

Lines changed: 3301 additions & 98 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.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): a stored record-form `filter` at the converged rule-array doors is converted to the rule array wherever the mapping is lossless — the ADR-0087 D2 conversion `page-component-filter-record-to-rule-array` (#17321, ruling B)
6+
7+
The one-filter-orthography convergence moved every page-component `filter` door onto the
8+
`ViewFilterRule` array and refused the MongoDB-style record it used to take — but converted
9+
nothing at rest. A page saved with the old form kept rendering, and the next person to open it
10+
in the builder and press Save was refused. This release adds the mechanical half.
11+
12+
**What converts** — at `dataSource.filter` on any page component, at `properties.filter` on the
13+
`object-grid`, `object-metric`, `object-kanban`, `object-calendar`, `object-map`, `object-gantt`,
14+
`object-tree`, `object-timeline`, `element:number` and `element:record_picker` blocks, and at
15+
`properties.defaultFilters` on `object-grid`:
16+
17+
| Stored | Becomes |
18+
| --- | --- |
19+
| `{ status: 'active' }` | `[{ field: 'status', operator: 'equals', value: 'active' }]` |
20+
| `{ amount: { $gt: 100 } }` | `[{ field: 'amount', operator: 'greater_than', value: 100 }]` |
21+
| `{ amount: { $gte: 1, $lte: 9 }, owner_id: 'u1' }` | three rules — they AND |
22+
| `[['owner_id', '=', '{current_user_id}']]` | `[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` |
23+
| `{}` | `[]` |
24+
25+
Values are carried verbatim, value placeholders and date macros included. The operator comes
26+
from the two tables the doors already use — a declared FilterCondition operator (`$gt`, `$nin`,
27+
`$notContains`, …) folded through `normalizeFilterOperator`, and an AST infix spelling (`=`,
28+
`!=`, `>=`, …) lowered through `parseFilterAST` — and every produced rule must parse at the
29+
door, so a converted page re-saves cleanly.
30+
31+
**Where it runs.** On every stored-row read (`applyConversionsToStoredItem` replays it), so
32+
the metadata API already serves such a page in the rule-array form; `os migrate meta --stored --apply`
33+
persists it; `os migrate meta --from 17` lists the edits for author sources. It is retired from
34+
the authoring load path, so `defineStack` / `os validate` still refuse an author who writes the
35+
record form and teach the rule array — no accept-set change.
36+
37+
**What is left exactly as stored.** A filter carrying `$and` / `$or` / `$not` is never
38+
flattened: the rule array only ANDs, and flattening `$or` or `$not` changes which rows the page
39+
selects. So is any filter with a part that has no lossless rule spelling — a `null` value (the
40+
renderer skips that key today, where a rule would test IS NULL), `$null` / `$exists`, an AST
41+
`like` / `ilike`, an array or object comparand in equality position, an AST `and` / `or` group.
42+
Conversion is all-or-nothing per filter: converting the mappable keys and dropping the rest
43+
would widen the filter. And every filter — the binding's included — of a component whose rows
44+
are **inline** (`data: { provider: 'value', … }`, a `data` array, or `staticData`) is left as
45+
stored: the `object-map`, `object-tree`, `object-calendar` and `object-gantt` renderers match
46+
that filter against their own rows in an in-memory data source that reads the record form but
47+
excludes every row for a rule array, so a rewrite there would empty the block. The same filter
48+
on a block that queries an object converts. Such a page keeps loading and rendering unchanged and is refused at its
49+
`filter` door on its next save — and for a combinator record that refusal no longer renders the
50+
combinator as a field (`{ field: '$or', … }`); it names the combinator and says why no rule
51+
spells it. `os migrate meta --stored` does not list these rows yet: a row the conversion leaves
52+
as stored reports there as already on protocol.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/driver-mongodb": minor
3+
---
4+
5+
fix(driver-mongodb)!: `translateFilter` refuses a `{ $field }` cross-field reference instead of sending it to MongoDB as a literal value (#19949)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING**: an accept-set narrowing, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. **A filter that answered before is now refused**: any filter carrying a `{ $field }` reference gets `INVALID_FILTER` / 400 from `translateFilter`, and so from every driver door that reads a `where` (`find`, `findOne`, `count`, `updateMany`, `deleteMany`, `aggregate`, `explain`).
10+
11+
**Security fix for RLS reads on MongoDB.** `compileCelToFilter` lowers a field-to-field comparison such as `record.s != record.t` to `{ s: { $ne: { $field: 't' } } }`. This driver has no lowering for a column-to-column comparison, and `translateFilter` sent the reference to the server as a literal sub-document. The RLS `using` clause is composed into the read after the engine's comparand checks, so nothing stopped it on the way. Measured over the rows `{ s: 'a', t: 'a' }` and `{ s: 'a', t: 'b' }`, with mingo 7.2.4 as the proxy for MongoDB's query semantics:
12+
13+
- `$ne` against the reference selected both rows, including the one where `s` equals `t`, and `$eq` selected none;
14+
- `$nin: [ref]`, `$notContains: ref`, `$ne` against a reference carrying `addDays`, and `$eq` under `$not` also selected both rows;
15+
- through `ObjectQL`, `SecurityPlugin` with a `rowLevelSecurity` policy `using: 's != t'`, and `MongoDBDriver` over a mingo-backed collection, `find` returned both rows, `count` returned `2`, and `findOne` returned the `s == t` row. The policy's read restriction was lost.
16+
17+
A live `mongod` was not measured, because this fleet cannot fetch the binary.
18+
19+
**What changes.** The driver's filter walk refuses a `{ $field }` reference in every comparand position, at any depth under `$and` / `$or` / `$not`:
20+
21+
- the whole comparand of any operator: the orderings, `$eq` / `$ne`, the string operators, `$null`, `$exists`;
22+
- a member of a list: `$in` / `$nin` members, either `$between` endpoint, an array given to `$ne` or `$eq`;
23+
- the implicit-equality position: the bare `{ field: { $field: … } }` form, or a list holding a reference;
24+
- a reference carrying `addDays`, and a malformed reference whose `$field` is not a string.
25+
26+
The refusal uses the same envelope as the driver's other filter refusals. Its message names the unsupported feature (field-to-field comparison) and withholds the fields, the operator and the position, because the filter may be an access policy the caller did not write. Through the engine, an RLS read carrying such a policy is now refused with that 400, and the server is never asked. Before, it returned the unfiltered rows. A policy written `s == t`, which used to return no rows, is refused the same way.
27+
28+
**What does not change.**
29+
30+
- Every literal comparand translates to the same document as before, including literal `$in` / `$nin` / `$between` lists and `$not` around a literal.
31+
- `$ne` with an array of literal values keeps its own refusal. An array with no reference in the equality slot still passes through `translateFilter`: the shared comparand-shape face owns that slot.
32+
- Field-to-field comparison is not implemented on this driver. It is refused, not lowered to a MongoDB `$expr`. `driver-sql` and the in-memory evaluator are not touched.
33+
34+
**What an affected author does.** On a MongoDB datasource, a row-level policy or filter can compare a field only against a literal value or a `current_user` value, not against another field of the same record. A policy that needs a field-to-field comparison cannot be enforced by this driver. Before this change it returned every row.
35+
36+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing at one driver's filter-compile door: no key, Zod schema, object definition or stored representation is added, removed or renamed, and FieldReferenceSchema is unchanged. What moves is which filters driver-mongodb answers. No stored policy can be converted to keep its meaning, because this driver has no field-to-field comparison to convert it to, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. -->
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/plugin-sharing": minor
5+
"@objectstack/lint": minor
6+
"@objectstack/spec": patch
7+
---
8+
9+
A row-level or sharing-rule predicate comparing with `!=` / `==` against the bare `current_user` root is refused at the CEL lowering instead of lowering against the whole caller context object (#19959).
10+
11+
**BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-variable-root-comparand-refused`.
12+
13+
Clause-②: no (narrowing)
14+
15+
**Security fix for RLS write checks.** A policy written `record.owner_id != current_user` (or `== current_user`, or `!(record.owner_id == current_user)`) named the variable root alone, which resolved to the whole caller context, and lowered to `{ owner_id: { $ne: <that object> } }` (or the bare object, or `$not` around it). A strict compare never equals an object, so a `check` so written admitted and stored every insert and by-id update it was written to refuse, a USING-only such policy admitted every insert, and explain reported the read as narrowed with the caller's membership sets echoed in its `readFilter`. A constant comparison such as `current_user != 'guest'` folded to no restriction.
16+
17+
- `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose operand is the bare variable root (`unsupported`), in both of its modes, so the authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports it before any request. A variable that resolves to an object is refused per request; a `Date` still passes.
18+
- `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, explain answers `denies`).
19+
- `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is still skipped at bootstrap, now with reason `unsupported` instead of `unresolved-variable`.
20+
- `@objectstack/lint`: the shape is reported as `rls-predicate-unenforceable` on either RLS clause, where it was silent, and as `sharing-rule-unlowerable-condition` on a sharing condition, where it was `sharing-rule-runtime-variable-condition`.
21+
- `@objectstack/spec`: the migration registry carries the entry.
22+
23+
**What to change.** Compare against the key the predicate means: `record.owner_id != current_user` becomes `record.owner_id != current_user.id` (or `current_user.organization_id`, `current_user.email`); a membership test is `record.owner_id in current_user.org_user_ids`. Scalar keys, `in`, `null`, literals and field-to-field comparisons are unchanged.
24+
25+
<!-- adr-0087: registered cel-predicate-variable-root-comparand-refused -->
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): a view that declares no page size now shows 50 per page — `PaginationConfigSchema.pageSize` defaults to 50 (was 25)
6+
7+
The platform display page size is 50 (maintainer ruling on objectui#9853,
8+
「9853 默认页大小改为50」). It is declared in one place, the view contract's
9+
`PaginationConfigSchema.pageSize`, and the renderer reads that spec default
10+
rather than keeping a number of its own — so the change lands in the protocol
11+
first.
12+
13+
**What changes for an author who omits the page size:**
14+
15+
- A view whose `pagination` block does not set `pageSize` now parses to
16+
`pageSize: 50` where it parsed to `25`: 50 rows per page on a paged view, and
17+
a fetch ceiling of 50 on a view with no pager (kanban, gallery, timeline).
18+
- A view with no `pagination` block at all parses with none, before and after;
19+
its page size comes from the renderer, which takes this spec default (the
20+
Console does so once objectui#9853 lands).
21+
22+
**What does not change:** the accept set. `pageSize` is still a positive
23+
integer; `0`, negatives and fractions are refused exactly as before, and every
24+
page size an author wrote parses to the number they wrote.
25+
26+
### Migration: FROM → TO
27+
28+
| FROM | TO |
29+
| :--- | :--- |
30+
| a view that declares no page size and relied on 25 rows per page | write it: `pagination: { pageSize: 25 }` |
31+
| a view that declares no page size and should follow the platform default | change nothing — it now shows 50 |
32+
| reading `PaginationConfigParsed.pageSize` after parsing a `pagination` block without `pageSize` | it yields `50` where it yielded `25`; the type is unchanged |
33+
| reading the published JSON Schema's `default` for `ui/PaginationConfig` `pageSize` | it is `50` |
34+
35+
The move is declared in `DEFAULT_CHANGES_BY_MAJOR`
36+
(`packages/spec/scripts/lib/default-changes.ts`, `ui/PaginationConfig:pageSize`
37+
25 → 50) and carried on the upgrade path as the semantic entry
38+
`view-pagination-page-size-default-50`.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(lint)!: a field-level predicate that reads through a reference field is refused at `objectstack validate` (#20078)
7+
8+
<!-- adr-0087: registered field-predicate-reference-traversal-refused -->
9+
10+
**BREAKING** in the accept-set sense — a stack that validates today can fail tomorrow. Landing in
11+
the launch window as `minor` (`major` is refused by `check-changeset-no-major`); breaking-ness is
12+
carried by this banner, the `!` above and the ADR-0087 registration.
13+
14+
Clause-②: no
15+
16+
A field `requiredWhen` / `readonlyWhen`, or a select option's `visibleWhen`, that reads THROUGH a
17+
`lookup` / `master_detail` / `user` / `tree` field — `record.account.tier` — passed `objectstack
18+
validate`, `build` and `lint`. It cannot work: the field level is never hydrated, so the reference
19+
holds the related record's bare id and every read through it faults. At run time a traversing
20+
`requiredWhen` refuses every write that reaches it, a traversing `readonlyWhen` refuses every
21+
update that writes its field (ADR-0137 D2), and a traversing option predicate is never enforced
22+
(option visibility is fail-open). The authoring pass now refuses all three as
23+
`expression-invalid`, naming the slot, the reference path and the related column, before deploy.
24+
25+
What to write instead — the refusal says the same:
26+
27+
- **A `record.<reference>.<column>` read** — express the check as a `validations[]` rule of
28+
`type: 'script'`. Its `condition` is the one predicate the server reads one hop through a
29+
reference, and it states the FAILURE: for `requiredWhen: P` on `po_number`,
30+
`P && (record.po_number == null || record.po_number == '')`; for `readonlyWhen: P` on
31+
`discount`, `P && record.discount != previous.discount` with `events: ['update']`; for an
32+
option gated by `P`, that option picked while `P` does not hold (the option is then offered to
33+
everyone and refused on save). Or read a column the object itself declares.
34+
- **A `previous.<reference>.<column>` or `parent.<reference>.<column>` read** — no seam hydrates
35+
either root, a validation rule included, so read a column the bound record declares.
36+
37+
Unchanged: the same traversal inside a `validations[]` `script` rule is accepted, as is reading
38+
the reference itself (`record.account == 'acc_1'`, `record.account != null`), an object-valued
39+
field that is not a reference (`record.ship_to.city`), and an option gated on `current_user`
40+
(including `current_user.can(…)`). The runtime is untouched, and an object already stored in
41+
`sys_metadata` is not re-validated by this. `@objectstack/spec` states the rule on the three
42+
slots' `.describe()` text and registers the ADR-0087 semantic entry
43+
`field-predicate-reference-traversal-refused`.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the `currencyConfig.currencyMode` description says what the runtime does with each mode, and no longer calls `dynamic` "user selectable" (#20126)
6+
7+
Clause-②: no
8+
9+
The old text read "dynamic (user selectable) or fixed (single currency)". No key, stored value or runtime path lets anyone pick a currency per record. The value is a bare number in both modes (ADR-0104 D1), and the currency code lives in field config. What the runtime does, and what the description now says:
10+
11+
- **`fixed`**: the field has one currency, `defaultCurrency`.
12+
- **`dynamic`** (the default): the field has no currency of its own. Amounts display in the tenant default currency, which is the `localization.currency` setting. When that setting is not set, amounts display as a plain number. `defaultCurrency` is not read.
13+
14+
The field faces follow this rule, and so do analytics dataset measure columns and the publish-time precision check.
15+
16+
Only the description text changes. The accepted keys and values, the defaults (`dynamic`, `CNY`) and parse output are the same as before. The generated reference page `content/docs/references/data/field.mdx` is regenerated from the new text.

‎content/docs/api/data-api.mdx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,8 +464,16 @@ The **cube's metric/dimension definition** (`MetricSchema.label` / `MetricSchema
464464
([#6442](https://github.com/objectstack-ai/objectstack/issues/6442)). So a client that
465465
renders table headers or formats amounts takes `label` / `format` / `currency` /
466466
`percentScale` off the query result's `fields[]`: those are resolved server-side (the
467-
ADR-0053 currency chain, the percent-scale chain), and `format` is not reachable through
467+
currency chain below, the percent-scale chain), and `format` is not reachable through
468468
the metadata endpoint at all.
469+
470+
The currency chain runs on the dataset query (`POST /analytics/dataset/query`). A
471+
monetary measure column takes the measure's own `currency`; then, only when the source
472+
field is `currencyMode: 'fixed'`, that field's `currencyConfig.defaultCurrency`; then the
473+
tenant default currency (the `localization.currency` setting), if one is set. A
474+
`dynamic` field (the default mode) never lends its `defaultCurrency` — only `fixed` does —
475+
so its column shows the tenant default. The cube query on this page carries no column
476+
`currency`.
469477
</Callout>
470478

471479
### `GET /analytics/meta`

‎content/docs/data-modeling/validation-rules.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,10 @@ These properties apply to **all** field types and are validated by the base `Fie
152152
| `min` | `number` | — | Minimum monetary value |
153153
| `max` | `number` | — | Maximum monetary value |
154154
| `currencyConfig.precision` | `number` | `2` | Decimal places (0–10) |
155-
| `currencyConfig.currencyMode` | `enum` | `dynamic` | `dynamic` (user-selectable) or `fixed` (single currency) |
156-
| `currencyConfig.defaultCurrency` | `string` | `CNY` | 3-character currency code (ISO 4217 or crypto) |
155+
| `currencyConfig.currencyMode` | `enum` | `dynamic` | `fixed` pins the field to `defaultCurrency`, and an authored `precision` must agree with that currency's ISO 4217 fraction digits; `dynamic` displays the tenant default currency (the `localization.currency` setting). Not a per-record choice |
156+
| `currencyConfig.defaultCurrency` | `string` | `CNY` | 3-character currency code (ISO 4217 or crypto): the field's currency under `fixed`; not the displayed currency under `dynamic` |
157157

158-
**Default constraints:** Stored as a **bare number** (a finite numeric scalar — `valueSchemaFor` routes `currency` to `z.number().finite()`); there is no `{ value, currency }` envelope on the value path. The per-record currency **code** is a separate concern, carried by `currencyConfig` above. Precision defaults to 2 decimal places.
158+
**Default constraints:** Stored as a **bare number** (a finite numeric scalar — `valueSchemaFor` routes `currency` to `z.number().finite()`); there is no `{ value, currency }` envelope on the value path. The currency **code** is never stored per record: it is field configuration, carried by `currencyConfig` above. Precision defaults to 2 decimal places.
159159

160160
### `percent`
161161

0 commit comments

Comments
 (0)