Skip to content

Commit 5941e9a

Browse files
committed
Merge commit 'refs/os-dev-20249/main' into claude/issue-20249-install-request-wrapped-strict
2 parents 469f5e3 + a9fb83e commit 5941e9a

38 files changed

Lines changed: 2483 additions & 245 deletions

‎.changeset/15293-non-array-packages-refusal.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ Clause-②: no
99
`packages` is declared as an array of package entries (`ObjectStackDefinitionSchema.packages: z.array(ArtifactPackageSchema).optional()`), and the rule is now written down once, beside `AssembledPackageBodySchema` in `@objectstack/spec`: an absent `packages` means a single-package artifact, and any other non-array value is malformed and refused. `resolveArtifactPackageOrder` in `@objectstack/core` already refused it, and so did the i18n detector in `@objectstack/plugin-dev` and the default-permission-set reader in `@objectstack/plugin-security`.
1010

1111
- **What changes**: `AppPlugin` reads its collections in `start()`, and `start()` now raises the same refusal `init()` already raised through the kernel's `manifest` service. Under `os dev`, `DevPlugin`'s child-`start()` loop logs it on its `error` line, where before the app started on its top-level collections alone. `createStandaloneStack` now refuses such an artifact while it builds the stack. Before, the refusal came later, when the app registered with the `manifest` service. `loadArtifactBundle`'s runtime-module merge reports it through its existing `warn` line and skips the merge, as it already does for a malformed `packages[]` entry. `resolveProjectDatabaseUrl` no longer reads a default datasource out of such an artifact: it declines, as it already does for any artifact it cannot read, and moves on to the next rung (the unified default database). The boot that loads the artifact then refuses it.
12-
- **What does not change**: an absent `packages`, and `packages: null`, still return the caller's own object by identity. A well-formed `packages[]` resolves exactly as before.
12+
- **What does not change**: an absent `packages` still returns the caller's own object by identity. A well-formed `packages[]` resolves exactly as before. `packages: null` is not absent: it is malformed, and it is refused the same way (#19926).
1313
- **Fix**: remove the `packages` key for a single-package artifact, or make it an array of `{ manifest: … }` entries.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/plugin-dev': minor
5+
'@objectstack/plugin-security': minor
6+
---
7+
8+
fix(core,runtime,plugin-dev,plugin-security): a release artifact whose `packages` is `null` is refused as malformed, never read as absent (#19926)
9+
10+
Clause-②: no (narrowing)
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, Zod schema, export, config field or stored metadata shape is added, removed, renamed or re-spelled. ObjectStackDefinitionSchema already refused packages null, and so did composeStacks with two or more inputs, so an authored stack that passed its schema reads exactly as before; what narrows is the runtime readers' behaviour on an artifact that bypassed that parse, and objectstack migrate meta has no document to rewrite for it. -->
13+
14+
**BREAKING** — an accept-set narrowing on a value the schema already refuses, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level).
15+
16+
`ObjectStackDefinitionSchema.packages` is `z.array(ArtifactPackageSchema).optional()`, and `.optional()` admits `undefined`, not `null`. The schema refused `packages: null` (`invalid_type`), and `composeStacks` refused it with two or more inputs (`STACK_SCHEMA_INVALID`, `status: 422`). The runtime readers below read it as absent instead: a single-package artifact whose own top level is the one package body. Those readers now follow the declaration. An absent `packages` is `undefined` and nothing else; `null` is one of the present, non-array values the rule beside `AssembledPackageBodySchema` calls malformed, like `{}`, `0` or `'x'`, and it is refused with the same envelope: `INVALID_ARTIFACT_PACKAGES`, `status: 422`. No error code is added.
17+
18+
- **`@objectstack/core`**: `resolveArtifactPackageOrder` refuses `packages: null` where it returned `[artifact]`. The refusal message names the value `null`, not `object`. The resolver's callers that hand it the whole artifact raise the refusal: the kernel `manifest` service's `register()` (`ObjectQLPlugin`) and `@objectstack/verify`'s collection reader for a collection the stack's top level does not carry.
19+
- **`@objectstack/runtime`**: `resolveArtifactCollections` drops `null` from its absent branch, so `AppPlugin`, `createStandaloneStack`, `loadArtifactBundle`'s runtime-module merge and `resolveProjectDatabaseUrl` answer a `packages: null` artifact exactly as they already answer `packages: {}`. `carriedPackageIds`, and `resolveArtifactGrantBinding` for an artifact whose `grantedPermissions` is a record, read the package list through the core resolver and raise its refusal too.
20+
- **`@objectstack/plugin-dev`**: the i18n detector's private absent guard moves in lockstep with the resolver's absent branch, so `devI18nPluginOptions` reaches the resolver and raises its refusal when the `i18n` config (on the stack or its `manifest`), a non-empty `manifest.translations` and a non-empty top-level `translations` do not answer first. `DevPlugin` keeps its posture: it reports the metadata defect on its `error` line and boots on the in-memory i18n fallback.
21+
- **`@objectstack/plugin-security`**: `appSecurityPluginOptions` has no guard of its own and raises the resolver's refusal for `packages: null`.
22+
- **What does not change**: the schema; an absent `packages` (no key, or an explicit `undefined`), which still returns the caller's own object by identity; a well-formed `packages[]`; and `composeStacks` with a single input, which still returns that input by identity.
23+
24+
No in-repo producer writes `packages: null`, and `os build` and `os validate` refuse it at the schema before any reader runs. For a single-package artifact, leave the `packages` key out.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec)!: a filter carrying a comparand the query faces refuse is refused when it is saved (#20116)
6+
7+
**BREAKING** — an accept-set narrowing of published authoring schemas, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The save door narrows to exactly what the query faces already refuse. The hand-migration prescription is registered under protocol major 18 as `filter-query-face-comparands-refused-at-save`.
8+
9+
## What changes
10+
11+
`FilterConditionSchema` now refuses, at parse, every comparand slot the query faces refuse:
12+
13+
- a `$null` or `$exists` flag that is not a boolean — `"false"`, `"x"`, `null`, `1`;
14+
- a `null` comparand of `$gt` / `$gte` / `$lt` / `$lte`;
15+
- an `$in` or `$nin` comparand that is not a list, or a list holding `null`;
16+
- a `$between` comparand that is not a two-element list, or whose endpoint is `null`, blank (`""`) or a `{ $field }` reference;
17+
- an array under `$ne`.
18+
19+
It judges the field entries of a condition and of every `$and` / `$or` / `$not` member. The dataset `filter` and measure `filter` also judge the same slots INSIDE a nested-relation condition, through the nested-relation walk they already carry, because the analytics `where` door flattens a relation and judges its entries. The shared comparand-shape face (`assertListComparandShapes`) is called read-only as the judge for each slot, so the save door refuses exactly what that face refuses on every query. The two boolean flags, which that face does not judge, are refused on the predicate every flag face uses (`driver-sql`, `driver-memory`, `driver-mongodb`, the read-scope compiler and the analytics `where` door): the comparand is not a boolean.
20+
21+
Measured on `origin/main` `af32cf9a` before the change: a dataset `filter`, a dataset measure `filter`, a dashboard widget `filter` and a report `runtimeFilter` each parsed with `success: true` for one instance of every shape above. The comparand-shape face refused each one but the flags with `INVALID_FILTER` / 400, and the analytics `where` door refused all of them, inside a nested relation too. So such a document published clean and then failed every chart built on it.
22+
23+
Every schema that carries a `FilterCondition` refuses on parse. That covers the dataset `filter` and measure `filter`, the dashboard widget `filter` and options-source `filter`, the report and joined-report-block `runtimeFilter`, the field `relatedListFilter` and rollup `summaryOperations.filter`, the solution-blueprint summary `filter`, the analytics query `where`, the dataset selection `runtimeFilter`, the query `where` and `having`, the data-engine aggregate call's `having`, the aggregation `filter`, and the query-filter `where`. So `defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a document at the slot's path, for example `filter.stage.$null` or `measures.0.filter.amount.$between.0`.
24+
25+
The words are the query face's. For an array under `$ne`, a non-list `$in` / `$nin` and a malformed `$between`, the refusal is the face's sentence without its location clause (`at where.<field>.<op>`), because the issue's path carries the location. For a `null` ordering comparand, a `null` list member or endpoint, and a blank or `{ $field }` endpoint, it is the sentence the enforced operator slot (`FieldOperatorsSchema`) already prints for the same comparand. A non-boolean flag gets the query faces' sentence: `Operator "$null" on field "stage" requires a boolean comparand (true or false).`, then the received value and the prescription.
26+
27+
Two request doors parse these carriers, and they now answer before the analytics compiler does. The REST dataset selection (its `runtimeFilter`, parsed against `DatasetSelectionSchema`) and the analytics query body (its `where`, parsed against `AnalyticsQueryRequestSchema`) answer `VALIDATION_FAILED` / 400 with the sentence at the field, for a top-level or combinator slot. Before, the compiler answered `INVALID_FILTER` / 400 for the same filter.
28+
29+
This also changes the `$ne` note of the equality-slot change earlier in this release: an array under `$ne` is now refused on save too, in the sentence `FieldOperatorsSchema.$ne` and the face print.
30+
31+
## What does NOT change
32+
33+
- **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 runtime refusal of its shape, so the refusal is a repair.
34+
- **The shared reach is the face's, and no wider.** On every carrier but the two dataset ones, a field spec with no `$` key, such as the nested-relation condition `{ account: { region: { $in: ["a", null] } } }`, is not judged, because neither the face nor the drivers' flag checks descend one. A dashboard widget `filter` and a report `runtimeFilter` therefore still save that shape, and the analytics `where` door refuses it when they are charted.
35+
- **What the face passes still passes:** `$eq: null` and `$ne: null` (the null predicate), a `{ $field }` reference as the whole comparand of a scalar comparison, `$in: []` and `$nin: []`, a whitespace-only `$between` endpoint, and falsy endpoints such as `[0, 0]`.
36+
- **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.
37+
- **No key, export or JSON Schema changes.** The published JSON Schema cannot state a refinement, and `FilterCondition`'s already could not state the equality-slot one.
38+
39+
## FROM → TO
40+
41+
| you wrote | write instead |
42+
|:--|:--|
43+
| `{ stage: { $null: "true" } }`, `{ stage: { $null: 1 } }` | `{ stage: { $null: true } }` ("has no value") |
44+
| `{ stage: { $exists: "false" } }`, `{ stage: { $null: null } }` | the boolean you meant: `$exists: false` is "has no value", `$null: false` is "has a value" |
45+
| `{ amount: { $gt: null } }` | `{ amount: { $eq: null } }` ("has no value") or `{ amount: { $ne: null } }` ("has a value") |
46+
| `{ stage: { $in: "won" } }` | `{ stage: { $in: ["won"] } }` or `{ stage: "won" }` |
47+
| `{ stage: { $in: ["won", null] } }` | `{ $or: [{ stage: { $in: ["won"] } }, { stage: { $null: true } }] }` |
48+
| `{ stage: { $nin: ["lost", null] } }`, meaning "has a value and is not `lost`" | `{ stage: { $nin: ["lost"], $null: false } }` |
49+
| `{ stage: { $in: [null, ""] } }`, a filter builder's "is empty" | `{ $or: [{ stage: { $null: true } }, { stage: "" }] }` |
50+
| `{ stage: { $nin: [null, ""] } }`, a filter builder's "is not empty" | `{ stage: { $null: false, $ne: "" } }` |
51+
| `{ amount: { $between: [null, 5] } }`, `{ amount: { $between: ["", 5] } }` | `{ amount: { $lte: 5 } }`, or the bound you meant |
52+
| `{ amount: { $between: [{ $field: "floor" }, 5] } }` | `{ amount: { $gte: { $field: "floor" }, $lte: 5 } }` |
53+
| `{ amount: { $between: 5 } }`, `{ amount: { $between: [1] } }` | `{ amount: { $between: [1, 5] } }` |
54+
| `{ stage: { $ne: ["won", "lost"] } }` | `{ stage: { $nin: ["won", "lost"] } }` |
55+
56+
### FROM → TO at the HTTP doors
57+
58+
Same status, different code: the refusal now comes from the route's schema door, located on the member, instead of from the analytics filter normalizer.
59+
60+
| request | before | after |
61+
|:--|:--|:--|
62+
| `POST /analytics/dataset/query` with `selection.runtimeFilter: { amount: { $between: [10] } }` | `400 INVALID_FILTER` from the analytics normalizer, in the comparand-shape face's sentence | `400 VALIDATION_FAILED`, `details.fields[]` entry `selection.runtimeFilter.amount.$between` with the sentence `Operator "$between" on field "amount" requires a [min, max] value array. Received array ([10]). …` |
63+
| the same route, any other slot above in `selection.runtimeFilter` (top level or in `$and` / `$or` / `$not`) | `400 INVALID_FILTER` | `400 VALIDATION_FAILED`, located on the slot, with that slot's sentence |
64+
| `POST /analytics/query` (`AnalyticsQueryRequestSchema`) with the same shape in `where` | `400 INVALID_FILTER` | refused by the request schema at `where.amount.$between`, answered `400 VALIDATION_FAILED` |
65+
66+
A client that branches on `INVALID_FILTER` for these shapes reads `VALIDATION_FAILED` instead. Both are 400 and both name the field.
67+
68+
## Who is affected, measured
69+
70+
A literal-comparand scan of every member shape, with a lit control per shape, over `examples/**` and the non-test `packages/**` of this repository at `af32cf9a`, the console repository at its pinned commit `f8a9d0fb05`, and the cloud repository's `main` at `48d70663ab`, found authored filters carrying one in one place. The console's filter-condition widget writes "is empty" as `{ field: { $in: [null, ""] } }` and "is not empty" as `{ field: { $nin: [null, ""] } }`. That widget edits a field's `relatedListFilter` and a rollup's `summaryOperations.filter` in the Studio field designer, and a sharing rule's criteria. Both shapes carry a `null` list member, which the face has refused on every query since the 2026-08-31 ruling, so a filter saved that way has been failing its related list or rollup since then. After this change, the Studio save is refused instead, with the `$or` / `$null` prescription. Every other hit is prose, a type table or a test fixture. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists.
71+
72+
Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. Comparand shapes that every query face already refused are now refused on save as well.
73+
74+
<!-- adr-0087: registered filter-query-face-comparands-refused-at-save -->
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": minor
4+
"@objectstack/lint": minor
5+
---
6+
7+
feat(spec,rest,lint): an import mapping target may name a declared part of a compound field (`mailing_address.street`), and the importer assembles the parts into one value (#20149)
8+
9+
Clause-②: yes
10+
11+
**What was missing.** A mapping could write each source column to one flat
12+
field only, so nothing could build an `address` value from the separate
13+
street / city / state / postal code / country columns a spreadsheet carries.
14+
A dotted target such as `mailing_address.street` named no field and was
15+
refused at `objectstack validate`, on the dry run and on the commit.
16+
17+
**What changes.**
18+
19+
- `@objectstack/spec`: `ImportFieldMappingSchema.target` declares the part
20+
path. A target may name `field.part` when `field` is a declared field whose
21+
stored value schema is a closed object of optional strings (today:
22+
`address`), and `part` is a key that schema declares: `street`, `city`,
23+
`state`, `postalCode`, `country`, `countryCode`, `formatted`. The part names
24+
are read from the value schema, never listed by hand. The one verdict,
25+
`judgeImportMappingTarget`, answers the new `{ kind: 'part', field, part }`;
26+
`indexImportMappingTargets` carries each compound field's parts on
27+
`parts`; `unknownImportMappingTargets` gives each refused target a `reason`
28+
(`unknown` or `collides`) and, for a dotted target, what its `head` names.
29+
`location` is not compound for import: its parts are required numbers, so a
30+
value assembled from text cells would be the wrong type.
31+
- `@objectstack/rest`: `applyMappingToRows` assembles every part target of a
32+
row into one value under the field's key, before the engine sees the row,
33+
whatever transform produced the part (`none`, `map`, `constant`, `join`,
34+
each element of a `split`). A blank part cell (empty, whitespace or a
35+
`nullValues` token) is left out, string parts are trimmed under
36+
`trimWhitespace`, and a row whose parts are all blank leaves the field
37+
unset, as a blank flat cell does. On an update the assembled value replaces
38+
the stored one. The dry run and the commit judge the same assembled row.
39+
- `@objectstack/lint`: `mapping-target-field-unknown` accepts a declared part
40+
and reports what stays refused, naming the legal parts each time.
41+
42+
**Still refused, at `objectstack validate`, on the dry run and on the commit
43+
(`400 INVALID_FIELD`, before any row):**
44+
45+
- a part the value does not declare (`mailing_address.stret`); the refusal
46+
lists the declared parts;
47+
- a dotted path on a field with no parts (`full_name.first`). A dotted target
48+
never traverses a reference (`account.name`): map the column to the
49+
reference field with transform `lookup`;
50+
- a mapping that writes a field both whole and by part (`mailing_address` and
51+
`mailing_address.street`): one row carries one value for the field. Map it
52+
whole or by its parts, not both.
53+
54+
**What to do.** Nothing, unless you want the capability: point each address
55+
column at `field.part`, for example `{ source: 'Zip', target:
56+
'mailing_address.postalCode' }`.

‎content/docs/data-modeling/import-mappings.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Each `fieldMapping` entry:
104104
| Key | Type | What it does |
105105
|:---|:---|:---|
106106
| `source` | `string \| string[]` | Source column header(s). An array is only meaningful for `join`. |
107-
| `target` | `string \| string[]` | Target field name(s). An array is only meaningful for `split`. |
107+
| `target` | `string \| string[]` | Target field name(s), or `field.part` for a declared part of an address field (`mailing_address.street`; the parts are `street`, `city`, `state`, `postalCode`, `country`, `countryCode`, `formatted`). The parts one row maps are assembled into the field's one value: a blank part cell is left out, a row whose parts are all blank leaves the field unset, and on an update the assembled value replaces the stored one. A mapping writes a field whole or by its parts, never both. An array is only meaningful for `split`. |
108108
| `transform` | `TransformType` | Defaults to `'none'`. |
109109
| `params` | object | Configuration for the transform — `value`, `valueMap`, `separator`. |
110110

0 commit comments

Comments
 (0)