Skip to content

Commit e752089

Browse files
committed
Merge origin/main into claude/issue-20986-hop-object-resolver
Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
2 parents cff14ac + 2821e9f commit e752089

39 files changed

Lines changed: 3520 additions & 468 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli)!: `objectstack validate` and `objectstack build` refuse a `picklistExtensions` entry whose `extend` names no picklist the stack declares (#20825)
6+
7+
Clause-②: no (narrowing — `objectstack validate` / `objectstack build` newly refuse a `picklistExtensions[].extend` that names no picklist the stack declares; nothing is accepted that was refused before)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable changes spelling or type: `packages/spec` is untouched, and `picklistExtensions[].extend` stays the snake_case name it was. What changes is that two authoring commands now refuse one authored shape, an extension whose `extend` names no picklist in the stack. `objectstack migrate meta` could not rewrite that shape even in principle, because which list the author meant is not in the metadata. Nothing here judges a stored row. -->
10+
11+
**BREAKING** — an accept-set narrowing on two authoring commands, shipped as
12+
`minor` under the launch-window convention. A stack with a `picklistExtensions`
13+
entry whose `extend` names no picklist the stack declares — `extend: 'industy'`
14+
beside a `picklists: [{ name: 'industry', … }]` — used to pass `objectstack
15+
validate` and `objectstack build` (which wrote the artifact). Both now exit 1 and
16+
name the extension and the list it names (`picklist-reference-unknown`, the rule a
17+
field's dangling `picklist` already gets).
18+
**One-line fix:** correct `extend` to the picklist the entry adds options to, declare
19+
the list it names (`picklists: [{ name, label, options }]`, or a `*.picklist.ts` file
20+
the stack imports), or remove the entry.
21+
22+
**Which extensions are judged.** The ones the load path registers: the top-level
23+
`picklistExtensions` of a one-package stack, or each `packages[]` entry's own. An
24+
`extend` resolves against every picklist the stack declares, including one a sibling
25+
package in the same artifact owns.
26+
27+
**A list from a package outside the stack is reported, not refused.** When the
28+
package declaring the extension lists a `manifest.dependencies` entry the stack does
29+
not carry, the list may live there, and these commands cannot read it. That
30+
extension is an `info` notice (`picklist-reference-unverified`) in `warnings` and on
31+
the console, naming the extension, the list and the dependencies — never a failure,
32+
not even under `--strict`.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/driver-sql': patch
4+
'@objectstack/service-analytics': patch
5+
---
6+
7+
fix: the analytics native-SQL path answers a measure its response declares `number` as a number on every dialect, presented by the one rule `driver-sql`'s `aggregate()` applies, which `@objectstack/core` now exports as `AGGREGATE_ANSWER_KIND` and `presentAsNumber` (#20889)
8+
9+
Clause-②: yes (widening)
10+
11+
**New exports.** `@objectstack/core` exports two names, moved here unchanged
12+
from `@objectstack/driver-sql`, which now imports them instead of keeping them
13+
private:
14+
15+
- `AGGREGATE_ANSWER_KIND`: what each declared aggregate function answers.
16+
`count`, `count_distinct`, `sum` and `avg` answer `'number'`; `min` and `max`
17+
answer `'column'`, a value of the aggregated column.
18+
- `presentAsNumber(value)`: the `'number'` presentation. A string `Number()`
19+
reads as a number becomes that number. Any other value is returned as given:
20+
a number, `null`, a boolean, empty or blank text, or text that reads as NaN.
21+
22+
**What changed.** On PostgreSQL, `POST /api/v1/analytics/query` and
23+
`POST /api/v1/analytics/dataset/query` answered through `NativeSQLStrategy`
24+
returned count, count_distinct, sum, avg, and min / max over a numeric column
25+
as strings, such as `count: "2"` and
26+
`sum: "500.000000000000000000000000000000"`, while `fields[]` declared
27+
`number`. A dataset's `row_count` did the same, and a measure-scoped count
28+
mixed `"1"` with the number `0` in one column. SQLite answered numbers. The
29+
strategy now presents each measure column by its declared aggregate function,
30+
through the same table and presenter as `SqlDriver.aggregate()`. `min` / `max`
31+
are presented only when their column is declared numeric, so `max` over a text
32+
column, every dimension, and expression measures keep the value the database
33+
returned.
34+
35+
**Precision.** The answer is one JS number, the policy `driver-sql`'s
36+
`aggregate()` already applies. A total that needs more digits than a double
37+
holds, such as `9007199254740993`, answers the nearest double
38+
(`9007199254740992`), which is also what SQLite and the engine path answer.
39+
40+
**What did not move.** `@objectstack/driver-sql`'s behaviour is unchanged: its
41+
`aggregate()` reads the same table, and its read presenter calls the same
42+
function. The answers on SQLite are byte-identical. The arithmetic of the
43+
analytics native statement did not change either. On PostgreSQL its `sum` and
44+
`avg` still add exact decimals, so `0.1 + 0.2` answers `0.3` where the engine
45+
path answers `0.30000000000000004`.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): every refusal of an in-place edit of a packaged flow, action or permission set names that type's own sanctioned path, at every door, and a packaged action's removal names one too, not a redeploy or `OS_METADATA_WRITABLE` (#20910)
6+
7+
Clause-②: no
8+
9+
ADR-0126 puts `flow`, `action` and `permission` in Regime C. The packaged base is locked, and the refusal names the sanctioned path. Until now only a flow's package-less refusal did, and only at one of the three places that refuse such a write. The other places prescribed "Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE …" or "Set OS_METADATA_WRITABLE to enable additional types at runtime". A packaged action's removal named no path at all.
10+
11+
There is now one regime table, and each type's row names only the primitives that type has:
12+
13+
- a packaged flow: clone it under a new name with `POST /api/v1/automation/:name/clone` and `{ name, label }`, or switch it off with `POST /api/v1/automation/:name/toggle` and `{ enabled: false }`;
14+
- a packaged action: switch it off with `POST /api/v1/actions/_activation/:object/:action` and `{ enabled: false }` (`:object` is `global` for an object-less action). No clone is named, because cloning an action is not a sanctioned path;
15+
- a packaged permission set: clone it under a new name, with the "Clone" action on the permission set or `POST /api/v1/data/sys_permission_set` with a new name. This is the same wording the permission-set lock in `@objectstack/plugin-security` already uses.
16+
17+
The switches are operator-only where one install serves several organizations. Every refusal cites ADR-0126. All three places that refuse such a write read the same table:
18+
19+
- **`403 NOT_OVERRIDABLE` on an environment-scoped kernel.** This covers `PUT /api/v1/meta/:type/:name` without `?package=`, and for a flow or an action `DELETE` too.
20+
- **`403 NOT_OVERRIDABLE` where the `/meta` protocol is not environment-scoped**, for example the default local `pnpm dev` boot. The metadata repository refuses that write one layer down, and now with the same sentence.
21+
- **`403 ITEM_LOCKED` for a write that names the read-only package with `?package=`, while `OS_METADATA_WRITABLE` is not set for the type.** The sentence now opens "Cannot overlay 'TYPE' in package 'ID': that package is read-only, and its packaged base is locked against in-place edits." and then names the path.
22+
23+
A packaged flow's package-less sentence is byte-for-byte unchanged. Statuses, codes, `lockSource`, `packageId`, `docs` and which writes are refused are unchanged too. `OS_METADATA_WRITABLE` still opens the lock for a write that names no package. The `ITEM_LOCKED` refusal given while the variable IS set reads exactly as before. Removing a permission set's overlay row is still allowed, as repair. Every type with no declared regime reads exactly as before, at every door.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(objectql)!: the engine's `aggregate` asks the aggregate × field-type table for every aggregation over a declared field, so `min` / `max` / `avg` over a type the table refuses answer `INVALID_FIELD` / 400 on every driver instead of one answer per driver
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused) the pairs this change refuses are exactly the pairs AGGREGATE_FIELD_TYPE_COMPATIBILITY already refuses, and the table is not edited: every refused min / max pair is registered under protocol major 18 by the first id and every refused avg pair by the second, each with its routes (count, a sort for a first or last record, or a numeric / temporal field for a quantity stored as text or JSON). This change adds a query-time reader of the same table at the engine door; it refuses a query shape, not a stored one, and no authorable key, export or stored row moves. -->
11+
12+
**BREAKING** (`@objectstack/objectql`): this narrows what `aggregate` accepts, on every driver and for every caller that reaches the engine — the REST query door, a flow or hook, a roll-up summary's recompute, and the analytics strategy that lowers a cube query onto `engine.aggregate`. Shipped as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
13+
14+
FROM → TO, per aggregation `{ function, field }` naming a declared field:
15+
16+
- `min` / `max` over a type outside the numeric, temporal and boolean classes — the structured-JSON types (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), the multi-option types (`multiselect`, `checkboxes`, `tags`), the string family (`text`, `email`, `url`, `phone`, …), the option and reference types (`select`, `radio`, `lookup`, `master_detail`, `tree`, `user`), `autonumber`, the file family and `formula` — and over any `select`, `lookup`, `user`, `file` or `image` declared `multiple: true`: FROM whatever the driver answered (a document or an array in memory, the serialized text on SQLite, a 500 on PostgreSQL for a JSON-stored field; a collation-dependent string for a text field) TO `400 INVALID_FIELD`.
17+
- `avg` over a type outside the numeric and boolean classes — a `date`, `datetime` or `time` field included: FROM `null` in memory, a coerced number on SQLite (the average YEAR for a datetime), a 500 on PostgreSQL, TO `400 INVALID_FIELD`.
18+
- `count_distinct` is unchanged: it was already refused over the JSON-stored types, in the same words.
19+
20+
**What an author sees now.** `400 INVALID_FIELD`, naming the position (`aggregations[0].field`), what the function does and the field with its declaration (`takes the max of 'meta', a declared json field — a structured-JSON value`), saying the query was not run, and naming the types the function accepts, read off the table, inside the first 500 characters the REST door keeps. The thrown error carries `field`, `fields` (every offending aggregation), `object` and `param` (`aggregations`).
21+
22+
**Why a refusal.** `AGGREGATE_FIELD_TYPE_COMPATIBILITY` already declares which pairs every backend answers the same way, and the dataset compile and lint legs refuse the rest; the engine door asked only its `count_distinct` row. Measured through `engine.aggregate` over two rows: `max` over a `json` field answered `{ a: 1 }` in memory, the string `'{"b":1}'` on SQLite and 500 `DATABASE_ERROR` on PostgreSQL 16 (`function max(json) does not exist`); a `tags` field and a `multiple: true` select or lookup split the same way; `avg` over a `datetime` answered `null`, `2026` and a 500. One query, three answers.
23+
24+
**What to write instead.** Aggregate a field of a type the function accepts — for `min` / `max`: `number`, `currency`, `percent`, `rating`, `slider`, `progress`, `summary`, `date`, `datetime`, `time`, `boolean` or `toggle`; for `avg`: the same minus the temporal three. A question that was counting in disguise is `count` (or `count_distinct` over a scalar-stored field). A first or last record by a text value is a sort on a list, not an aggregate. A quantity stored as text or JSON belongs in a numeric or temporal field of its own, aggregated there.
25+
26+
**Who is affected.** A caller that asked `min` / `max` / `avg` of such a field on the in-memory driver or SQLite and read the answer as a real one; on PostgreSQL a JSON-stored field was already a 500. Metadata that lowers onto `engine.aggregate` takes the same verdict at run time: a roll-up summary (`summaryOperations`) whose `min` / `max` / `avg` names such a child field records a failed recompute, a grouped list view's server-side header summary is refused, and a chart or metric component's `aggregate` over such a field is refused. No example app and no published stack authors such a pair.
27+
28+
**Not judged yet: `sum`.** The `sum` row of the table is held back at this door: a published stack authors a `sum` column summary over a `formula` field, a pair the table refuses, so that row awaits its own decision. `sum` over any field reaches the driver as before.
29+
30+
**Unchanged.** Every pair the table accepts; `count` over any field, a JSON-stored one included; an aggregation that names no field; an undeclared name or a relationship path, which this door does not judge (the REST door answers an unknown name `INVALID_FIELD` before the engine is reached); a field whose declared type is outside `FieldType`. The structured-JSON `groupBy` entry of this same release lists a structured-JSON field as an aggregated `min` / `max` column as unchanged; this entry is the later word on that shape.
31+
32+
`@objectstack/spec`: the TSDoc of `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` no longer says the engine's `aggregate` door reads only the `count_distinct` row. The table itself is unchanged.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/metadata-protocol': minor
4+
---
5+
6+
The dry run and the partial-success batch insert now say which row lost which field. `ObjectQL.validate` (and `validateData`, which relays it) answers `droppedFields` on each accepted row of `results`, and `ObjectQL.insertMany` (and `insertManyData`, which passes it through) answers `droppedFields` on each `ok` outcome: the caller-supplied fields the engine legally strips from that row, one `DroppedFieldsEvent` per reason, in the engine's own reason vocabulary (`computed` for a `formula` value, `readonly` for a static `readonly` or runtime-owned field). The key is absent when nothing was taken from the row.
7+
8+
- **Recorded at the strips, never inferred from the union.** Each strip records what it takes from each row as it runs. A `beforeInsert` hook that assigns a protected key on one row keeps it there, so that row is not named, while a sibling row that supplied the same key and lost it is.
9+
- **A row the write does not complete carries none.** A preview row the verdict refuses, and an `ok: false` outcome, carry no `droppedFields`: a drop means the write completed without the field.
10+
- **The dry run and the commit agree.** On `insert` mode the preview runs the same strips the write runs, so a row's preview drops and its outcome drops are the same list. One gap is unchanged: the preview runs no hooks, so a key a `beforeInsert` hook assigns is reported by the preview and kept by the write. An `update`-mode preview does not run the `readonlyWhen` or primary-key strips, which judge a prior record the preview does not read.
11+
- **Unchanged:** the `onFieldsDropped` listener on `insert`, `insertMany` and `validate` still reports the batch-level union, one event per reason, naming no row. So does `insertManyData`'s top-level `droppedFields`. `insert(object, rows[])` still returns the records, with no per-row slot. `strictReadonlyWrites` still refuses the whole batch before any outcome is built.
12+
13+
Graded `minor` in both packages: each widens a published method's declared answer with a new optional key (`InsertManyRowOutcome` gains `droppedFields`, and so does each outcome of `insertManyData`'s return type), which is an additive widening of the public surface. Nothing is removed, renamed or refused. The keys on the wire, `ValidateDataResponseSchema.results[].droppedFields` and `ImportRowResultSchema.droppedFields`, were already declared in `@objectstack/spec`. The REST import route does not copy the per-row report onto its row results yet.

0 commit comments

Comments
 (0)