Skip to content

Commit 82e68ce

Browse files
committed
Merge origin/main into claude/project-thread-eqmkj9
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6
2 parents 74d7cfb + 5bd615e commit 82e68ce

131 files changed

Lines changed: 8472 additions & 1709 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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
refactor(metadata-protocol): the metadata item reads and the layered read move out of `protocol.ts` through the protocol host
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
No behaviour change. The bodies of `getMetaItem`, `getMetaItemForExecution`, `readMetaItem`, `packagelessLayeredReadRefusal` and `getMetaItemLayered` now live in `protocol-meta-item-reads.ts` and `protocol-meta-layered-reads.ts`, reached through the per-instance host (`protocol-host.ts`). Each method keeps its docblock and signature on `ObjectStackProtocolImplementation`, and each moved body differs from the method body only by `this.` reading `host.`. The published `index.d.ts` (ESM and CJS) is byte-identical to the one `main` builds before this change. Nothing to migrate.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
feat(spec): a save of a connector's served form drops the leftover inline value under an undeclared `auth` key, and the save answer names each key it dropped
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
A connector row stored before the instance-auth shapes went strict can still hold a value under an `auth` key its kind does not declare, typically an inline secret such as `auth.token`. The read already withheld it. A save of the served form, however, re-grafted it from the stored row, so the cleartext stayed at rest through every edit, although no door accepts the key and nothing at runtime reads it. Now:
12+
13+
- **The redactor can mark a withheld position as not carried forward.** `MetadataRedactionResult` (`@objectstack/spec/kernel`) gains an optional `notCarriedForwardKeys`: the positions among `redactedKeys` that a save drops instead of re-grafting. It is a subset, so `redactedKeys` still names every withheld position and a reader that knows only `redactedKeys` withholds exactly what it did before. Absent means none: the `datasource` built-in, the schema-derived redactors and every registered redactor keep carrying every withheld position forward, with nothing to change.
14+
- **The connector redactor marks its undeclared `auth` keys.** Each one is named in both lists. A cleared value there (`null`, `''`, an empty map) is now withheld too, because the strict shape refuses the key whatever it holds, so serving it made a plain read then save fail with `unrecognized_keys`. Every other withheld connector position (the four header and environment maps, `authentication`, a credential URL, an undeclared top-level, `retryConfig` or `actions` key, a non-object `auth`) is carried forward exactly as before.
15+
- **The save drops it and says so.** The `/meta` save (`saveMetaItem`, draft saves included) never re-grafts a not-carried-forward position, so the stored row loses it, and the answer's `advisories` carries one `warning` per dropped position: rule `withheld-key-dropped`, `where` naming the item, and `path` where the value sat at rest (`connectors[0].auth.token`). The notice never carries the value. The key is still omitted when there is nothing to report, so a save that drops nothing answers the same bytes as before. A value the incoming body itself holds at that position is the author's word and is not a drop.
16+
17+
If a connector still needs a credential that sat under such a key, store the secret outside the metadata and name it with `auth.credentialRef`.

‎.changeset/29026-flow-condition-null-guard.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ Metadata-protocol: narrowed
1212
`os build`, `os validate`, `os lint` and the runtime publish gate now refuse a record-triggered flow (start `triggerType: 'record-…'`) whose start condition, edge `condition` or `decision` branch `expression` applies an ordering (`< <= > >=`) or arithmetic (`+ - * / %`) operator to a nullable declared field of the trigger object with no `!= null` / `== null` / `!isBlank()` test in front of it. The run's `record` and `previous` carry every declared field, nulls included, so such a comparison faults with `no such overload` on the first record where the field is empty, and the run fails there (the write that fired it still lands).
1313

1414
- **Who moves.** A flow condition such as `budget > 200000` on an optional `budget`. Fix: `budget != null && budget > 200000`, or put the `!= null` test in the flow's start condition, which gates every run, so every edge and branch after it may compare freely. `has(record.budget)` is not a guard: the key is always present.
15-
- **Bare names count.** The run spreads the record's fields to top level, so a bare `budget` is read as `record.budget`, unless the flow declares a variable or a node id of that name, or has a node that can bind names nobody declared (a `script`, `connector_action`, `wait`, `subflow` or `map`); those bare names are not judged. At the runtime publish gate bare names are not judged.
15+
- **Bare names count.** The run spreads the record's fields to top level, so a bare `budget` is read as `record.budget`, unless the flow declares a variable or a node id of that name, or has a node that can bind names nobody declared (a `script`, `connector_action`, `wait`, `subflow` or `map`); those bare names are not judged. At the runtime publish gate bare names are not judged, so a bare start-condition guard proves nothing there: a flow whose start condition is `budget != null` and whose edge compares `record.budget > 200000` passes `os validate` (where the flow's bound set is provable) and is refused by the runtime publish gate. Spell the start guard `record.budget != null` and both accept it.
1616
- **Not judged.** Flows with no record trigger (an `api` flow's request body, an action's or `map` item's record are not made total), a `screen` field's `visibleWhen`, and `config.condition` on any node but `start`.
17-
- **Fewer findings elsewhere.** A `count` or `sum` roll-up summary is now always valued for this gate (zero children is zero), on validation rules and hook conditions too; `min` / `max` / `avg` roll-ups stay nullable.
17+
- **Fewer findings elsewhere.** A `count` or `sum` roll-up summary is now always valued for this gate (zero children is zero), on validation rules and hook conditions too; `min` / `max` / `avg` roll-ups stay nullable. That presumes `os migrate summary-nulls` ran: on a database upgraded in place without it, a parent row stored before roll-ups were seeded at create time, and no child of it written since, still reads `null`.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
**BREAKING** fix(cli)!: `os validate` and `os build` refuse a flow whose top-level graph closes a cycle with no `type: 'back'` edge, as the automation engine refuses it at registration
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
Skills-docs: none — `skills/objectstack-automation/references/state-machines-and-approvals.md` already teaches that a cycle-closing edge MUST be typed `'back'` and that an unmarked cycle is refused; that stays true, and the CLI now refuses the same flow before it ships.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped. The build verbs now refuse a flow body the automation engine already refused to register and the publish doors already refuse, with the engine's own sentence, so there is nothing a migration could rewrite: the author's fix is the one the refusal names. -->
12+
13+
**BREAKING** accept-set narrowing at the CLI's author-time doors (`@objectstack/cli`).
14+
15+
- **Before.** A flow whose top-level graph had a cycle with no edge typed `back` (ADR-0044), such as `start → push → check → revise → push`, passed `os validate` and `os build` with exit 0. The engine then refused to register it at boot ("Flow contains a cycle: …"), logged a warning, and ran without it.
16+
- **Now.** Both commands refuse it, exit 1, and `os build` writes no artifact. The row is `VALIDATION_ERROR`, located at the flow (`flows[N]`, or `packages[K].manifest.flows[N]` in an assembled artifact), and its message is the engine's registration refusal, word for word. The judge is `findUndeclaredFlowCycle` from `@objectstack/metadata-core`, the one the engine's registration and the runtime publish gate call, so all three refuse exactly the same graphs. It is a CLI step, not an `@objectstack/lint` rule, since lint does not depend on metadata-core; the rule table runs first, so a flow with both a malformed region (`flow-region-shape-invalid`) and an undeclared cycle is refused for the region first, and for the cycle once the region is fixed (the engine names the cycle first).
17+
- **Unchanged.** A cycle with any of its edges typed `back` validates and builds, as before. Cycles inside a `loop` / `parallel` / `try_catch` region are judged by the region check, as before.
18+
19+
**The remedy.** None for a flow that registers today. A flow this now refuses never ran: if the loop is intended, set `type: 'back'` on the cycle-closing edge (ADR-0044); otherwise remove or redirect that edge.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a read answers a selected declared column the driver did not return as `null`, so one record reads the same on every driver (#29408)
6+
7+
Clause-②: no — nothing a request may send is refused or newly admitted; measured on base and head, every read returns the same rows, and every value a driver returned is answered as before. A record body gains a `null` key for a stored declared column the read selected and the driver returned no key for, and on driver-mongodb and driver-memory a `formula` over such a column now evaluates against that `null`, as it already did on driver-sql.
8+
Metadata-protocol: none
9+
10+
- **Before.** driver-sql answered a declared field that was never written as `null` (its table has the column), while driver-mongodb and driver-memory answered no key at all (they store only the columns a write touched). The same record read `{ title: 'x', note: null }` on SQL and `{ title: 'x' }` on mongo or memory.
11+
- **Now.** The engine answers every stored declared column the read selected as `null` when the driver returned no key for it, in `find` and `findOne`, so every door on them (`GET /data/:object/:id`, the list route and `POST /data/:object/query`, export, search, `expand` sub-reads, a hook's or a flow's in-process read) answers alike on every driver. Selected means the whole declared record when the read names no `fields`, else the declared columns its `fields` name.
12+
- **Still absent.** A column the read's `fields` do not name; a column field-level security refuses the caller (the security mask still deletes it); a `formula` field itself (virtual: never `null`-filled, it is computed after the fill); a name outside the declared set; an injected platform column a federated (`external`) object has no storage for; and, on a federated object, a `created_at` / `updated_at` it neither declares nor has injected (`systemFields: false` or `systemFields.audit: false`), which no table stores.
13+
- **Who sees it.** A client of an object served by driver-mongodb or driver-memory whose records have unwritten declared fields. Such a field now arrives as a key holding `null` instead of a missing key: a client-side `has(record.x)` is now `true` for it, as it already was on SQL, so test emptiness with `record.x != null`. A `formula` over such a field changes value on those two drivers: it used to fault on the missing key and answer `null`, and now it evaluates against `null` and answers what driver-sql answered (`record.qty != null ? record.qty * 2 : -1` was `null`, now `-1`). A write's stored row is unchanged; the record a write answers (insert or update echo) is unchanged.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `os migrate blank-single-values` rewrites the `''` still stored on optional single-value option, reference and file columns to `null`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Since #29277 the write door stores a cleared optional `select`, `radio`, `lookup`, `master_detail`, `user`, `tree`, `image`, `file`, `avatar`, `video` or `audio` value as `null` (maintainer ruling B on #29346). Rows cleared before that still hold `''`, which `$empty: true` does not find on those types. This new command is the one-time clean that ruling binds, as an `os migrate` data step of its own (server seat ruling on #29431, record 6588116815), not part of `os migrate value-shapes`, which rewrites nothing.
11+
12+
- **Columns.** Read from `blankAsNullColumns` in `@objectstack/core`, the rule the write door and the seed replay read: its optional single-value columns on the `$empty` table's `null_only` row. A `required` column, a `multiple: true` column and a text-like column are never touched.
13+
- **Dry run by default.** It counts the stored `''` per object and column and writes no row. `--apply` rewrites each affected row's `''` columns to `null` through the engine. `--object` narrows the walk and refuses a name the deployment does not declare. `--apply` refuses a SQLite file another process holds unless `--force`.
14+
- **No flag.** Nothing is gated on it. A re-run that reports zero is the verification.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/mobile': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
feat(mobile, app-shell): inside the ObjectStack mobile shell, the header's organization button opens the shell's native connection switcher, and the Console answers the shell's `connectionRemoved` event
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
The mobile shell's bridge (`window.ObjectStackNative`) gained `showConnectionSwitcher()` and the `objectstack-native:connectionRemoved` window event. The Console now uses both:
12+
13+
- **Organization button.** When the shell announces `has('showConnectionSwitcher')`, `WorkspaceSwitcher`'s button calls `showConnectionSwitcher()`, which opens the shell's own sheet of saved organizations and company servers. The web organization menu does not open. In the shell the button renders whenever there is an active organization, at one membership too, since the sheet lists the shell's connections across company servers; `CurrentOrganizationIndicator` then renders nothing, so the organization is drawn once. In a plain browser, or in a shell that does not announce the capability, both render as before.
14+
- **Removed connection.** `useNativeHostRouting` listens for `objectstack-native:connectionRemoved` while the bridge is present and calls `event.waitUntil()` with the promise its new optional `onConnectionRemoved` returns. Omitted, the promise is already resolved, so the shell is released at once. No Console path registers a device push token yet, so there is nothing to unregister today. The option is where that unregister step goes once registration exists.
15+
16+
`@object-ui/mobile` adds `showConnectionSwitcher` to `NativeHostMethod` and, as an optional method (an older shell does not carry it), to `ObjectStackNative`, and exports `nativeShowConnectionSwitcher()`, `nativeHostCan(host, method)` (the wrappers' own feature-detect), `NATIVE_CONNECTION_REMOVED_EVENT` and the `NativeConnectionRemovedEvent` type. `@object-ui/app-shell` now depends on `@object-ui/mobile`.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(objectql): a `referenceNameVisible` lookup or user field names the record it points at on `$expand` when the reader may not read that record
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
`FieldSchema.referenceNameVisible` was declared ahead of its runtime. It is now served on the expanded read:
12+
13+
- **What a refused reader gets.** On `$expand` of a `lookup` or `user` field that declares `referenceNameVisible: true`, a target the reader is refused (the target object's read gate, its required permissions or its row scope) answers `{ id, name, readRefused: true }` in place of the bare id it answered before. `id` is the id the record holds; `name` is the target's display name as ADR-0079 resolves it (`nameField`, then `displayNameField`, then the derived title, with the floor `Record #` and the id). `readRefused: true` tells a renderer to show the name as text with no link. A `multiple: true` field is answered per element.
14+
- **Nothing else of the target.** The name is read by the engine from exactly the target's id and its name field. The `$expand` request's projection, nested expand, filter and order are never applied to that read, and the field's `displayField` does not change the column it reads.
15+
- **Field-level rules on the name field still hold.** When the reader's field-level security keeps the name field from it, the field declares a `maskingRule`, or the name is a formula reading through another reference, the name served is the `Record #` floor with the id.
16+
- **Unchanged.** A target the reader can read is expanded exactly as before. An id no record has, a record of another organization, and every field without the key keep the bare id. An unexpanded read still answers the stored id for every reader. The same key covers `sys_user` names through a `user` field or a lookup to `sys_user`.
17+
18+
The key's `.describe()` drops its planned marker and the liveness ledger records it as live.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): the null-guard message names the guard that holds on an insert for a `previous.` operand
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The publish-time null-guard gate (`os build`, `os validate`, `os lint` and the runtime publish gate) reports an ordering or arithmetic operator applied to a nullable declared field with no `!= null` test in front of it, and tells the author to add one. For a `previous.FIELD` operand that advice alone faults on an insert, where `previous` is itself `null` (on a validation rule, a hook condition and a record-triggered flow's create leg alike). The message for such an operand now ends: guard it with `previous != null && previous.FIELD != null`. The shared sentence before it is unchanged and still reads word for word as the runtime's. Which predicates are refused does not change. Nothing to migrate.

0 commit comments

Comments
 (0)