Skip to content

Commit b4c624a

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20301-list-tabs-retired
Conflicts, each resolved by keeping both sides with main's first: packages/spec/src/conversions/registry.ts - step-18 tail packages/spec/src/migrations/registry.ts - step-18 rationale and conversionIds packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts - positive control stacked to 623 Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
2 parents b751fc7 + e4d3f2c commit b4c624a

429 files changed

Lines changed: 11538 additions & 1980 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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/lint": minor
5+
"@objectstack/metadata-protocol": minor
6+
"@objectstack/metadata-core": patch
7+
---
8+
9+
feat(automation)!: an edge-branched `decision` is exclusive — the first out-edge whose condition holds, in declaration order, wins; `mode: 'inclusive'` takes every one (#15429)
10+
11+
<!-- adr-0087: registered flow-decision-mode-inclusive-explicit -->
12+
13+
Clause-②: yes
14+
15+
**BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that
16+
declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose
17+
condition held, one after another, while its schema, the docs and the engine's own comment all
18+
called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in
19+
one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows
20+
BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every
21+
true branch is a declaration the author writes down.
22+
23+
| | before | after |
24+
|:--|:--|:--|
25+
| two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step |
26+
| `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially |
27+
| none holds | the `isDefault` edge runs | unchanged |
28+
| `mode` beside a non-empty `conditions` list, or outside `'exclusive' \| 'inclusive'` | refused by a direct parse only | refused at `registerFlow` and by `os validate`, with the schema's own sentence |
29+
30+
## Migration: FROM → TO
31+
32+
`os migrate meta --from 17` lists the mechanical edits for existing sources and applies them
33+
to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit`
34+
writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more
35+
conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it
36+
did.
37+
38+
```ts
39+
// FROM — every true out-edge ran
40+
{ id: 'verdict', type: 'decision', label: 'Verdict?' }
41+
// TO — what the conversion writes; delete the key where the conditions partition
42+
{ id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } }
43+
```
44+
45+
Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match`
46+
carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside
47+
`!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on
48+
more than one branch running for one record, and where the overlap was accidental narrow the
49+
conditions into a partition and delete the key. `os validate` reports
50+
`flow-decision-inclusive-overlap` on every decision that keeps the key with two or more
51+
conditioned out-edges, so the review list is the lint output.
52+
53+
## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429
54+
55+
A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with
56+
**no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates
57+
**first-match** after this upgrade: where it took every out-edge whose condition held, it now takes
58+
only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row
59+
— no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row
60+
says it was saved before the flip. The one-line fix, for a node that meant every branch:
61+
62+
```ts
63+
{ id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } }
64+
```
65+
66+
`os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under
67+
`decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run
68+
alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an
69+
operator can review the candidates before and after the upgrade. A node leaves the list once it
70+
declares `mode`, either member. Every such node in the measured corpus below is a partition, where
71+
the new meaning runs exactly what the old one did.
72+
73+
Authored sources and built artifacts keep the old behaviour instead, where the source's age is a
74+
fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel,
75+
the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the
76+
conversion by id — a default flip replayed there would turn a decision written today against this
77+
contract, where an omitted `mode` means exclusive, into an inclusive gateway.
78+
79+
## Reach, measured at landing
80+
81+
- Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`,
82+
2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only —
83+
`mode` has not shipped; `.changeset/19867-decision-config-mode.md` and
84+
`.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this
85+
tree. So `mode` reaches its first release together with the traversal that reads it and the
86+
conversion that writes it; no published accept set narrows, and the registration and
87+
`os validate` refusals narrow nothing that shipped.
88+
- Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`,
89+
read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and
90+
no `mode` (the conversion's positives — every one a hand-written partition, including
91+
hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned
92+
out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the
93+
exclusive traversal is scoped to `decision` with nothing else to migrate.
94+
- What the published surface gains: the D2 conversion and its D3 entry in the protocol-18
95+
chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and
96+
docblock now state the run-time semantics, and `@objectstack/lint` gains
97+
`flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory).
98+
99+
The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node
100+
type keep the every-true-edge traversal they had (none was measured to exist).
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
Clause-②: no
7+
8+
Sixteen live structured metadata keys are authorable in the metadata form: eleven on the field form — `accept`, `currencyConfig`, `dependsOn`, `lookupColumns`, `lookupFilters`, `readonlyWhen`, `relatedListColumns`, `requiredPermissions`, `requiredWhen`, `storage`, `visibleWhen` — and five on the action form — `bodyExtra`, `description`, `errorMessage`, `patch`, `requiredPermissions`. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each now has exactly one row, whose control copies a row a registered form already carries for the same node shape:
9+
10+
- `visibleWhen`, `readonlyWhen`, `requiredWhen` — `type: 'code'`, `language: 'expression'`, the object designer's per-field rows for the same three keys.
11+
- `lookupFilters` — `widget: 'json'`, the object designer's per-field row for the same key; no inline operator list, because `notIn` is not a spellable option value.
12+
- `lookupColumns`, `dependsOn` — `widget: 'json'`, **never** `string-tags`: each is an array of a union (a field name, or an object entry), and the tag widget is a chip input for strings only, which cannot show or edit a stored object entry.
13+
- `accept`, `relatedListColumns`, and both `requiredPermissions` — `widget: 'string-tags'`, the app form's `requiredPermissions` row: a chip input over a plain `string[]`.
14+
- `currencyConfig`, `storage` — a `composite` with declared sub-rows (`currencyMode` as a `dynamic` / `fixed` select, `defaultCurrency`; `notNull`), the shape of the object form's `access` row.
15+
- `patch`, `bodyExtra` — `widget: 'json'` over a string-keyed record.
16+
- `description` — `widget: 'textarea'`, the page form's `description` row, over the same `I18nLabel` node; `errorMessage` — a plain row, the twin of `successMessage`.
17+
18+
Each type-specific row is gated to the types its runtime reader serves: the media types for `accept`, `currency` for `currencyConfig`, `lookup` / `master_detail` for the picker and related-list rows, those two plus the four option types for `dependsOn`, `operation: 'update'` for `patch` (the parse refuses it anywhere else), and `type: 'api'` for `bodyExtra`. The help text states what the runtime does with each value, including what absence resolves to. The four field-name lists (`relatedListColumns`, `lookupColumns`, `lookupFilters[].field`, `dependsOn`) are free text: no authoring door judges their names today — not the schema parse, not the publish door and not `os validate` — so the help text claims no such refusal.
19+
20+
⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly<Record<string, FormView>>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles, whose 38 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills.
21+
22+
⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers only.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
'@objectstack/driver-turso': patch
4+
---
5+
6+
fix(driver-sql, driver-turso): `reclaimSpace()` returns the whole SQLite freelist, not one page per call (#20106)
7+
8+
Clause-②: no
9+
10+
`reclaimSpace()` is what the lifecycle service calls after every sweep that deleted rows (ADR-0057 §3.4). On SQLite it runs `PRAGMA incremental_vacuum`, and that statement frees one page per step. Two clients stepped it once:
11+
12+
- **`SqlDriver` on better-sqlite3, and `TursoDriver` in local mode.** knex's better-sqlite3 client runs a statement that declares no result columns with `Statement.run()`, which steps it once. A database with 300 free pages had 299 after the call, read from a second connection, and the file barely shrank. `incremental_vacuum(N)` freed one page too. The method now drives that binding through its own `exec()`, which steps the statement until SQLite reports done: 300 → 0, and the file shrinks by those pages.
13+
- **`TursoDriver` in remote mode.** The libSQL client's `execute()` stepped the statement once and left it unfinished. Over a libSQL `file:` client, the issuing connection read one page fewer, but a second connection read the freelist and the file size unchanged, and a row written after the call on the same connection never reached the file. The remote route now reads `PRAGMA freelist_count`, sends nothing more when it is `0`, and otherwise runs the vacuum through the client's `executeMultiple()`: 300 → 0 from a second connection, and the later write lands. A server that refuses either statement answers `DATABASE_ERROR` / 500, as before. What a hosted libSQL server does with either call is not measured.
14+
15+
`SqliteWasmDriver` was already complete: its dialect steps every PRAGMA to the end (300 → 0 before and after this change).
16+
17+
On a file-backed database in WAL mode (the default) the database file shrinks once a checkpoint runs, and during the call the freed pages pass through the `-wal` file, which keeps its size until the last connection closes.
18+
19+
Nothing to migrate: `reclaimSpace()` keeps its signature, and a database whose `auto_vacuum` mode is not `INCREMENTAL` still reclaims nothing, as before.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
`TursoDriver` in **remote** mode reads and writes a federated object's remote table, `external.remoteName`, the way the local and embedded-replica modes always have (#20107).
6+
7+
Clause-②: yes (widening)
8+
9+
**What was wrong.** `registerExternalObject` records a federated object's remote table (ADR-0015), and the local face reads that record for every statement. The remote face inherited the registration but never read it. Every remote data door handed `RemoteTransport` the object name, and the transport used it as the table. So an object `ext_customer` bound to the remote table `customers` was queried as a table named `ext_customer`. Measured on a remote face over a libSQL `file:` client, with a local driver over the same file answering every door from the mapped table:
10+
11+
- `find`, `findOne`, `count` and every write door failed with a bare `LibsqlError` (`SQLITE_ERROR: no such table: ext_customer`). It carried no `status`, so REST served it as an unclassified 500.
12+
- `aggregate` answered `[]`, because the transport reads "no such table" as "no rows".
13+
- `distinct` answered `DATABASE_ERROR` / 500.
14+
15+
**What changes, on the remote face only:**
16+
17+
- Every data door (`find`, `findOne`, `count`, `aggregate`, `distinct`, `create`, `update`, `upsert`, `delete`, `bulkCreate`, `bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) compiles against the table the registration recorded. That is `external.remoteName` for a federated object, and the object's own name otherwise. It is read from the same `SqlDriver` registry the local face reads, and not copied. Managed objects send the same statements as before.
18+
- A remote table name is quoted as one SQL name, with any embedded quote doubled, so a name the local face can read (`order-lines`) can be read here too.
19+
- `find`, `findOne` and `count` now end in the local face's read-exit envelope. A statement the backend refuses, for example on a remote table that really is absent, answers `DATABASE_ERROR` / 500. The libSQL error rides under a non-enumerable `cause`, the dialect text goes to the server log, and the targeted table is declared for `isMissingTableError`. Before, the bare `LibsqlError` reached the caller. The write doors keep the local face's behaviour, where a write fault is classified at the REST boundary from its message.
20+
- A federated object whose `external.columnMap` **renames** a column is refused with `NOT_IMPLEMENTED` / 501 on every remote data door, before any statement is sent. The remote transport addresses columns by field name and does not translate the map. With the table resolved, a filter on a renamed field would read a column the table does not have, and the transport answers that with an empty list rather than the matching rows. Before this change, an object whose `remoteName` differs from its name failed with `no such table` on every door but `aggregate`, which answered `[]`. A binding with no `remoteName`, or with `remoteName` equal to the object name, plus a renaming map is refused too: before, its unfiltered reads and its id-keyed writes answered, while its filtered reads were already silently wrong. A map whose entries rename nothing is served. **If this refusal fires for you:** use the local or embedded-replica transport for that object, or name its fields after the remote columns and drop the renaming entries.
21+
- `RemoteTransport` (exported from the package root) gains an optional trailing `table` argument on its 14 data methods; omitted, it defaults to the object, which is what a transport used on its own always did.

0 commit comments

Comments
 (0)