Skip to content

Commit 9ce90ee

Browse files
committed
Merge origin/main into claude/issue-5488-allowruntimecreate-false
Merge-lap onto current main. One conflict, in the shared ADR-0087 retirement registry (`packages/spec/src/migrations/registry.ts`): main's `import-run-automations-declared-default-corrected` (#6190) and this branch's `api-runtime-create-withdrawn` (#5488) both append a SemanticMigration into step 17's array, sharing the brace above and below. Resolved UNION-KEEP — neither entry may be lost (#6526). Recounted from the merged file: 43 step-17 semantic entries, with both #42 (`api-runtime-create-withdrawn`) and #43 (`import-run-automations-declared-default-corrected`) present. The two generated artifacts were NOT text-merged (the os-regen merge driver refuses to): `spec-changes.json` and `docs/protocol-upgrade-guide.md` were regenerated from the merged tree, and both entries are present in each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
2 parents 1785fad + 623d008 commit 9ce90ee

65 files changed

Lines changed: 4402 additions & 262 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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
refactor(spec,cli): `--database-driver` 的可选值从共享驱动表推导,删掉 CLI 里的第二份词表 (#6969)
7+
8+
**无行为变更**`os start --database-driver` / `os dev --database-driver` 接受的取值集合
9+
与改动前**逐字相同**`memory``sqlite``sqlite-wasm``postgres``mysql``mongodb`
10+
`turso` 七个,一个不多一个不少)。唯一可见的差别是 `--help` 里这七个值的**枚举顺序**
11+
说明见下。
12+
13+
#6345 把平台的驱动词表收敛成 `@objectstack/spec` 的一张表之后,CLI 里仍留着它的副本:
14+
两条命令各自用手写字面量数组声明 oclif 的 `options:`(一份强制白名单),并且各自在
15+
`description:` 的散文里把同样的 id **再抄一遍**。四份副本,一张表,正是 #6535
16+
`IMPORT_JOB_MAX_ROWS` 两处定义)的形状挪了个包。
17+
18+
现在 `@objectstack/spec` 导出 `DATABASE_DRIVER_SELECTION_IDS`——**选择面**
19+
`DriverVocabularyEntry.aliases`)收敛到规范拼写后的投影——两条命令连同 help 散文里的
20+
枚举都从它派生,CLI 内不再有任何手写驱动 id 列表。
21+
22+
取的是选择面而**不是**配置契约面(`DRIVER_ID_ALIASES` / `resolveDriverId`):后者按设计
23+
包含 `contractOnlyAliases``sqlite3``better-sqlite3``mariadb``inmemory`)——它们能
24+
解析出一份存量 datasource 的 config 契约,但两个启动宿主从来都不接受它们作为启动选择。
25+
把它们摆上 flag 会是一次**放宽**,只是穿了重构的外衣。新增用例驱动 oclif 真实 parser,
26+
证明这四个拼写仍在 parse 阶段被拒。
27+
28+
这不是在修一个用户会撞到的缺陷:`database-driver-allowlist.pin.test.ts`#6860)已经在钉
29+
「白名单 ↔ `resolveStorageDefinition` 能解析出的驱动种类」这条一致性,而且 #6345 落地当天
30+
就抓到过一次真回归。本次改动是结构性的——第二份定义没有了,钉子守的那条一致性也就无法
31+
再由「改了一个文件忘了另一个」打破。该钉子**未被改动**,改后依旧全绿。
32+
33+
**`--help` 顺序**:枚举顺序从 CLI 手写的 `sqlite | sqlite-wasm | turso | postgres | mysql |
34+
mongodb | memory` 变为共享表的行序 `memory | sqlite | sqlite-wasm | postgres | mysql |
35+
mongodb | turso`。同一份 CLI 在你拼错驱动名时打印的 “Supported drivers: …” 早就用的是行序,
36+
所以改后 `--help` 与它自己的拒绝信息终于按同一个顺序列举驱动。要保住旧顺序,就必须在
37+
`packages/cli` 里留下一份手写的顺序列表——恰恰是本卡要删掉的东西。
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): `bulkCreate` and `upsert` re-seed a stale autonumber counter instead of burning the whole batch (#6943)
6+
7+
#5495 taught `create()` to re-seed a stale autonumber counter and retry instead
8+
of burning one number per failed insert. `bulkCreate()` and `upsert()` call the
9+
same `fillAutoNumberFields` and did not get that fix. They are not, however, the
10+
same defect as each other — measured on `main` @ `c8ff269`, on a fresh database
11+
with seeded rows above the counter (the one-time-storm repro constraint #5495
12+
established):
13+
14+
**`upsert` is `create()`'s old shape exactly.** Single row, so a stale counter
15+
costs it one burned number per call: `last_value` walked 1 → 2 → 3 across two
16+
refused upserts. Its `ON CONFLICT (mergeKeys) DO UPDATE` absorbs a conflict on
17+
the merge key only; the tenanted autonumber lives under a *different* unique
18+
index, so that violation is still raised and still reaches the caller.
19+
20+
**`bulkCreate` is worse.** Each row reserves its number in its own committed
21+
transaction and the batch then goes in as ONE insert, so a single colliding row
22+
burns *every* number the batch reserved and fails the whole request:
23+
24+
| 3-row `bulkCreate`, counter at 10, rows 11–39 already present | before | after |
25+
|:---|:---|:---|
26+
| caller-visible failures | both calls threw | **0** |
27+
| rows written | **0** | 3 |
28+
| `last_value` | 10 → 13, then 13 → 16 | 10 → 42, by one re-seed |
29+
30+
And it is the worst path to leave without recovery: framework#2678 made
31+
`bulkCreate` the common case for seed/import, and seed/import is exactly what
32+
*creates* the staleness — an `isSystem` replay or a `preserveAudit` import keeps
33+
its explicit numbers and never enters `fillAutoNumberFields` (#5495/#5503).
34+
35+
Both paths now reuse #5495's machinery unchanged — `collidingAutoNumberReservations`
36+
for the three-state routing, `autoNumberValueExists` for the data-based
37+
discriminator (the conflicting column is never determinable for a tenanted
38+
autonumber), and the forward-only `resyncSequenceToDataMax`. A collision that is
39+
not provably this counter's is still rethrown untouched, so a duplicate on a
40+
value the caller supplied still reaches them as its own error.
41+
42+
**Batch semantics are unchanged, and that is a measurement rather than a
43+
choice.** `insert(rows[])` is a single statement, so the batch was already
44+
all-or-nothing — the failed batch above left the table exactly as it found it.
45+
Re-issuing and retrying the whole batch therefore preserves the existing
46+
contract: no partial success is introduced, no transaction is opened, and no
47+
"does a failed row roll back its siblings" question arises, because siblings
48+
already fail together. Per-row retry inside the batch was rejected for the
49+
opposite reason — it would have had to split the one statement into N and invent
50+
partial success where none existed.
51+
52+
One thing the batch may not borrow from `create()`: `create()` keeps a
53+
reservation that did not collide, to avoid burning a second number. A batch
54+
cannot. One that straddles the seeded range has its low rows collide and its
55+
high rows not, and re-issuing only the collided ones would hand them numbers
56+
*above* the kept ones — an intra-batch duplicate the driver would have
57+
manufactured itself. Re-issue is therefore per counter: every row drawn from a
58+
counter that went stale is re-issued, and counters that did not go stale keep
59+
their values, so a co-tenant's rows in the same batch are undisturbed.
60+
61+
As with #5495, retrying is confined to the no-caller-transaction case. Inside a
62+
caller's transaction the sequence `UPDATE` rolls back with the refused `INSERT`,
63+
so nothing is burned and there is nothing to repair (measured on both paths), and
64+
on Postgres a constraint failure aborts the transaction outright. The caller owns
65+
that retry.
66+
67+
`TursoDriver` (local/replica) and `SqliteWasmDriver` inherit both fixes, each
68+
pinned by its own test rather than assumed from the base class — Turso
69+
*overrides* `bulkCreate`/`upsert` to route remote traffic away, so inheritance
70+
there is a routing fact, not a class fact. Turso's remote transport builds its
71+
own INSERT and generates no autonumber at all, so it neither has this defect nor
72+
receives this fix (that gap is #6944).
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
---
5+
6+
feat(spec,service-automation): a flow variable can declare a `defaultValue`, so "declared" means "bound" (#4697)
7+
8+
Declaring a flow variable used to guarantee nothing at run time. The engine bound
9+
an `isInput` variable **only** when the caller actually supplied it
10+
(`params[name] !== undefined`), so every path that omitted the parameter left the
11+
name unbound — and a flow condition is strict CEL, where an unbound name does not
12+
read as `false`, it **aborts the predicate and stops the run**. The declaration was
13+
documentation, not a guarantee, and there was no metadata form that said "this
14+
variable always has a value".
15+
16+
`FlowVariableSchema` now takes an optional `defaultValue`, and the engine binds it
17+
whenever no parameter supplies one:
18+
19+
```typescript
20+
variables: [
21+
{ name: 'createOpportunity', type: 'boolean', isInput: true, defaultValue: false },
22+
]
23+
```
24+
25+
The rules:
26+
27+
- **A supplied parameter always wins**, including a falsy one — the boundary is
28+
`!== undefined`, so `false`, `null`, `0` and `''` are answers rather than
29+
absences, and only a genuinely missing parameter falls through to the default.
30+
- **A non-input declaration takes its default too.** `isInput: false` means no
31+
parameter can reach the name, so the default is the only thing that can bind it.
32+
- **A declared variable shadows a trigger-record field of the same name**, whether
33+
it was bound from a parameter or from its default — the rule a parameter already
34+
followed. A name cannot resolve out of a different source depending on whether
35+
the caller passed it.
36+
37+
Both run entry points seed from one shared site, so the retry path behaves
38+
identically to the first attempt.
39+
40+
**Additive and opt-in.** A declaration without `defaultValue` behaves exactly as
41+
before, so existing flows parse and run unchanged. The value is not cross-checked
42+
against the declared `type``type` is an open string with no vocabulary to check
43+
against, the same posture as every other `defaultValue` on the authoring surface.
44+
45+
The case this closes came from a screen flow (hotcrm#643): a screen collects an
46+
optional checkbox, the client returns only the fields the user actually touched,
47+
so on the untouched path the variable was never bound, the outgoing edge aborted,
48+
and a lead conversion persisted nothing. The workaround was an `assignment` node
49+
before every screen mirroring the screen field's own `defaultValue`; a declared
50+
default replaces that ceremony.
51+
52+
The docs half of the same gap is now written down too
53+
(`content/docs/automation/flows.mdx`): under strict CEL the guard an author
54+
reaches for first — `has(X.f)`**aborts** on an unbound `X`, the very case it is
55+
written for. Only the `vars.`-scoped `has(vars.X)` tests bindedness. That truth
56+
table is measured against the live evaluator in
57+
`service-automation/src/flow-variable-default.test.ts` rather than asserted, so a
58+
prescription nothing executes cannot quietly stop being true. Prefer
59+
`defaultValue` over either guard: a guard encodes "unanswered means no" into the
60+
predicate and leaves the graph defect in place.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
fix(spec)!: `ImportRequest.runAutomations` declares the default the import route actually applies (#6704, ADR-0049)
6+
7+
`POST /api/v1/data/:object/import` — and its async twin `POST /api/v1/data/:object/import/jobs`
8+
has fired triggers and hooks for an omitted `runAutomations` since #2922. The server
9+
decides with `body?.runAutomations !== false`: automations always ran on import
10+
historically, so opting **out** was made the explicit act, matching platform
11+
convention (Salesforce fires triggers on import by default).
12+
13+
The schema declared the opposite, and said so twice. `.default(false)` shipped in
14+
`@objectstack/spec`'s JSON Schema, and the `describe` prose — "off by default for
15+
bulk" — rendered into the published reference tables for **both** defs
16+
(`ImportRequest` and `CreateImportJobRequest`). Both are now corrected to the
17+
runtime: `.default(true)`, with prose that states automations run by default and
18+
that opt-out must be explicit.
19+
20+
**Runtime behaviour is deliberately UNCHANGED.** `packages/rest/src/import-prepare.ts`
21+
is untouched by this change. Nothing starts being refused, and no request that
22+
worked before behaves differently on the wire.
23+
24+
### Why a wrong declaration was reachable at all
25+
26+
Nothing in the repo reconciled the two halves, which is why no gate could see the
27+
divergence: no request path parses an import body through this schema. The route
28+
reads the raw body, and the single reference to `CreateImportJobRequestSchema` is
29+
the declarative `ImportJobApiContracts` catalog entry — a declaration, not a parse.
30+
Each half was internally consistent; only their disagreement was wrong.
31+
32+
### Migration: FROM → TO
33+
34+
| FROM | TO |
35+
| :--- | :--- |
36+
| omitting `runAutomations` and expecting no triggers, because the schema said so | send `runAutomations: false` — the only spelling the server has ever read |
37+
| omitting it and expecting triggers | change nothing; that is what you already got, and now what is declared |
38+
| reading `ImportRequestParsed.runAutomations` after parsing a body without the key | it now yields `true` instead of `false` — the value the server would have applied anyway |
39+
40+
**Who is actually affected:** a client or SDK that validates its request through the
41+
published schema and sends the **parsed** object. It materialised
42+
`runAutomations: false` from the declared default and sent it explicitly, and the
43+
server honoured that — so identical request bodies produced opposite behaviour
44+
depending on whether the caller validated before sending, with the validating
45+
caller silently losing its triggers. Those bulk loads ran with automations off and
46+
will now run with them on, which is what an unvalidated caller always got. A caller
47+
that never parsed its own request body is unaffected in every direction.
48+
49+
`dryRun` is untouched and still runs **no** automations whatever this flag says
50+
(#6037).
51+
52+
Maintainer ruling 2026-08-09 (#6704), disposition A — the spec follows the runtime:
53+
54+
> **Maintainer ruling (2026-08-09): disposition A — the spec follows the runtime.** `ImportRequest.runAutomations` becomes `.default(true)` with corrected describe prose (state that automations run by default and opt-out must be explicit, per the #2922 rationale); the generated reference tables follow. Runtime behaviour unchanged. [...] Changeset notes the declared-default flip of a published schema (a correction toward the actual shipped behaviour, not a behaviour change).
55+
56+
The declared move itself is recorded per key in `DEFAULT_CHANGES_BY_MAJOR[17]`, whose
57+
`from`/`to` fingerprints are re-derived on every build, so the declaration cannot
58+
outlive the fact it describes.
59+
60+
<!-- adr-0087: registered import-run-automations-declared-default-corrected -->
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
feat(rest): `PUT /api/v1/meta/:type/:name` 要求 `manage_metadata` 能力 (#6603)
6+
7+
**这是一次访问面收紧,线上可见。** 保存单个元数据项的这条路由此前只有
8+
`enforceAuth` —— 任何已认证会话都能写任意元数据项。现在它与隔壁的
9+
`POST /api/v1/meta/_migrate-stored` 用同一道门、同一套机制:调用方必须持有
10+
ADR-0066 D1 的 `manage_metadata` 能力,`isSystem` 照例放行。
11+
12+
## 谁开始吃 403,需要什么
13+
14+
**任何不持 `manage_metadata` 的已认证调用方**,对这条路由的 `PUT` 一律
15+
403 `FORBIDDEN`(匿名调用方仍先吃 `/meta` 伞下的 401,门是第二层)。
16+
平台自带的 `admin_full_access` 权限集本就带 `manage_metadata`,所以
17+
Studio / Setup 里的管理员与 CLI 的 dev admin **不受影响**;受影响的是
18+
自建集成、自建权限集,以及只持 `setup.access``organization_admin`
19+
20+
**要恢复写入:给该调用方的权限集加上 `manage_metadata`**(Setup →
21+
Permission Sets → `systemPermissions`),而不是绕过这条路由。
22+
23+
## 为什么必须收紧
24+
25+
ADR-0106 D1 会把调用方不可读的字段**整个**从服务出的对象 schema 里摘掉,
26+
而这条路由原样持久化收到的 body。于是一次最普通的
27+
GET → 改个 label → PUT,就把调用方**从来没被允许看见的字段删掉了**,
28+
整个交互过程中没有任何东西提示。GET-改-PUT 正是 AI agent 编写元数据的
29+
标准动作,原先这个动作会静默销毁它看不见的字段;现在它在写入时得到一个
30+
**响亮的 403**
31+
32+
同时这也关掉一个与掩码无关、更早就存在的洞:任何已认证会话都能覆写
33+
任意 schema。
34+
35+
## 尚未关闭的部分
36+
37+
本次只收紧这一条路由。同形的 `PUT /meta/:type/:section/:name`(复合名)
38+
与运行时 dispatcher 自己的 `/meta` PUT 仍无能力门,同一次往返丢失仍可经
39+
它们复现 —— 已另立 #7019 跟踪,不在本次范围内。
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
fix(rest): a public form that declares no fields now REFUSES the submit instead of accepting every key the caller sent (#6920)
6+
7+
`POST /api/v1/forms/:slug/submit` narrows a visitor's suppliable keys to the
8+
fields the matched FormView's `sections` declare — and the filter read
9+
`allowedFields.size === 0 || allowedFields.has(k)`. For a form with no declared
10+
fields that limb degenerated, and not into "every field of the object": it
11+
accepted **every key the caller sent**, minus the `#3022` server-managed anchors
12+
and the three prototype keys. Measured on the real registered handler,
13+
anonymously, against a `sections: []` form:
14+
15+
submit accepted = ["email","internal_margin","internal_tier",
16+
"not_even_a_field","status","subject"]
17+
18+
`not_even_a_field` is not declared on the target object at all. So an anonymous
19+
visitor could set `status`, a workflow stage, an internal tier — anything, on
20+
the one object the form targets. `publicFormGrant` (ADR-0056) keeps the insert
21+
scoped to that object, so this was never a cross-object hole; it was an
22+
unbounded **column** surface on one. The way in is an ordinary authoring
23+
mid-state: the author creates the public form and wires its sections later.
24+
25+
**What changes.** A form whose sections declare no fields now answers
26+
`400 VALIDATION_ERROR` and inserts nothing. The message names the empty
27+
declaration and gives the author's fix ("wire the fields it collects into the
28+
form's sections"); it names no object, field or slug, because this reply is
29+
readable by anyone on the internet. The three authoring shapes that reach it —
30+
`sections: []`, sections present but declaring no fields, and `sections` omitted
31+
— are treated identically, and the refusal keys off the **declaration**, not the
32+
body, so an empty POST is refused too rather than inserting a blank row.
33+
34+
`VALIDATION_ERROR` is the standard ADR-0112 catalog's generic validation
35+
failure, and what `HttpStatusErrorCodeMap[400]` already names a bare 400. It is
36+
deliberately not a newly minted `FORM_*` synonym of a condition the catalog
37+
already covers.
38+
39+
**Why a refusal and not a silent drop.** Dropping the keys would have kept the
40+
`201` and changed no wire status, but it would swallow data the caller believes
41+
it wrote — a visitor is told their support ticket was filed and an empty row is
42+
stored. Loud is also the only answer that reaches the author, who is the one who
43+
can fix it.
44+
45+
**This is a behaviour change on a shipped success path.** A deployment that
46+
today collects submissions through a section-less public form starts getting
47+
`400`s. That form's read side already publishes nothing (`fields: {}`) since
48+
`#6601`, so it cannot render either — the two planes now enforce the same rule,
49+
"the form declares what it collects", on both. **Fix: declare the fields in the
50+
form's `sections`.** Forms that already declare sections are entirely
51+
unaffected — that path never consulted the removed limb.
52+
53+
`#3022`'s anchor guarantee is preserved unchanged: `owner_id`,
54+
`organization_id`, `id` and the audit columns remain unsuppliable on this
55+
surface, including when a FormView mis-declares one in a section.

0 commit comments

Comments
 (0)