Skip to content

Commit 612459f

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6969-flag-options-from-shared-table
2 parents 491d80e + 0f539bd commit 612459f

38 files changed

Lines changed: 2500 additions & 165 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
ADR-0094 D5-R: retire the "customize packaged permission sets through an ADR-0005 env
6+
overlay" direction (2026-07-14), and make the ADR text and the
7+
`permission-set-projection.ts` header agree with what is enforced.
8+
9+
`#6483` (PR #6608) rolled `permission` back to `allowOrgOverride: false`, so a metadata
10+
write against a **code-declared (artifact-backed)** permission set is refused with 403
11+
`NOT_OVERRIDABLE` — ADR-0005's security row ("overlays would create silent privilege
12+
drift") is enforced again. The supported channel for those sets is the one ADR-0086
13+
always named: edit the package and re-publish. Environment authoring survives on the
14+
`allowRuntimeCreate` tier, for sets whose definition lives only in `sys_metadata`
15+
(data-door creations, and package sets authored + published through the metadata door);
16+
that tier edits the single stored definition in place and is deliberately **not**
17+
described as a re-route of the retired overlay channel.
18+
19+
No behaviour change: the four production write points keep their current dispositions.
20+
The refusal is left to the producer — `plugin-security` does not re-derive
21+
artifact-backing to pre-empt it — and the two write points that catch a failed metadata
22+
write (the `restore` leg and the boot backfill) keep reporting on the durability channel.
23+
What changes is prose, plus test coverage that can now see the gate: the suite's protocol
24+
stub models ADR-0005's tier gate, so the four cases that pinned the retired direction no
25+
longer pass for want of a stub that could refuse.
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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
filter: `$icontains` 的实施状态改按实测重写(#6947)
6+
7+
`filter.zod.ts``StringOperatorSchema` 的状态段仍然写着「没有任何后端应答 `$icontains`,五个 driver 一律拒收,直到 #5702 落地」。#5702 已于 2026-08-08 关闭,该段自述的过期条件已经触发,文字与已发布的实现正好相反。
8+
9+
按 driver 逐个实测(同一条 `{ name: { $icontains: 'acme' } }` 打到同时含 `acme corp``ACME CORP` 的样本上,而不是 grep case 分支 —— grep 看不见继承编译器的那一面,会少数一个):**五个 driver 里三个应答**(`driver-sql`;`driver-sqlite-wasm` 通过继承 `SqlDriver`,在另一套 sql.js 引擎上;`driver-turso` 的 local 与 remote 两条传输都应答),**两个响亮拒收**(`driver-memory``driver-mongodb`,均为 `INVALID_FILTER` / 400)。据此改写状态段,并同步 `$icontains``.describe()`(它会渲染进 `content/docs/references/data/filter.mdx`,原文同样停留在「lowerings land with #5702」)。
10+
11+
⛔ 行为零变化:`FILTER_OPERATORS` 未动,`$icontains` 仍然刻意不在词表里 —— 该数组是运行时 allowlist,收进去会让内存 `match()`**不匹配**`true`。词表的真实闸口从此写明是 #6520(JS 求值面),不再是已经落地的 #5702
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): the dialect arm of `classifyIndexFailure` walks `cause` to the same depth the conflict arm does (#6848)
6+
7+
`classifyIndexFailure` had two arms reading two different wrap-depths. #6699
8+
moved the first arm onto `@objectstack/types`' `isUniqueViolationError`, which
9+
follows `error.cause` four levels down because pool and query-builder layers
10+
re-throw with the original attached. The second — the dialect arm — kept reading
11+
`err.message` and stopping there.
12+
13+
So a dialect refusal arriving behind a wrapper (outer prose `Write failed` or
14+
`pool query failed`, the actual `near "WHERE": syntax error` one step down
15+
`cause`) was graded `failed` instead of `unsupported`. The private
16+
`indexFailureText` helper now collects the message channel of the thrown value
17+
**and** of each `cause` below it, bounded at the same `MAX_CAUSE_DEPTH` of 4 the
18+
predicate uses and counted the same way (the thrown value is depth 0). The
19+
dialect vocabulary itself is unchanged — only the text fed to it.
20+
21+
**Why the verdict matters beyond wording.** The two consumers dispose of
22+
`unsupported` and `failed` differently. `view-definition-active-index.ts` treats
23+
them the same (keep the previous index, report at `error`; only the wording
24+
differs). But `ensureOverlayStateIndex` builds the composite **fallback lookup
25+
index** on the `unsupported` branch and on no other — offered precisely because
26+
a dialect that cannot take the partial form should still get the lookup. Under
27+
a `failed` verdict that branch never ran, so `fallback` came back
28+
`not-attempted` rather than `ensured` / `refused` and the degradation target was
29+
silently never attempted.
30+
31+
**Dormant, not a live regression.** No driver shipped today produces the wrapped
32+
shape — each hands knex's error back with the dialect text on the outer message,
33+
which is why every existing case matched on the first read. This closes an
34+
asymmetry before a wrapping raw-SQL driver can land on it; it is also not a
35+
regression from #6699, which only made the contrast visible by deepening the
36+
first arm.
37+
38+
Two details worth knowing if you touch this: the collected levels are joined
39+
with a **newline**, never a space, because two of the dialect alternatives are
40+
multi-word (`where clause`, `near "where"`) and a space would let a phrase be
41+
synthesised across a wrapper boundary that no single driver wrote. And a looping
42+
`cause` chain is **bounded rather than detected** — no visited set — which is
43+
exactly what the predicate this mirrors does.
44+
45+
Arm order is unchanged and still load-bearing: a conflict reported anywhere in
46+
the chain still beats a dialect refusal in the outer prose.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/mcp": patch
3+
---
4+
5+
docs(mcp): `diagnoseEmptyRead` 的 TSDoc 更正一句被证伪的事实 (#6724)
6+
7+
`packages/mcp/src/mcp-server-runtime.ts``diagnoseEmptyRead` 的 TSDoc(#6055
8+
由 PR #6051 落地)为"在空答案之后再跑一次仅取结论的探针,而不是把
9+
`getObject` 换成 `getDiagnosed('object', name)`"这个设计选择给出了两条理由。
10+
其中一条是事实陈述,而它是**错的**:
11+
12+
> `MetadataFacade.getObject`(objectql)返回 `registry.getObject(name)` —— a
13+
> different shape from its own `get()`,因此等价关系在一般情况下不成立。
14+
15+
`SchemaRegistry.getItem``'object'` / `'objects'` 类型直接特判回
16+
`getObject`,所以 facade 的 `get('object', n)` 走的是同一次查找;其后的
17+
`item?.content ?? item` 解包是空操作 —— 合并后的 `ServiceObject` 根本没有
18+
`content` 键。实测:命中时两个成员交回**同一个对象引用**,未命中时双方都是
19+
`undefined`。三个已发布实现由 `packages/objectql/src/
20+
metadata-service-getobject-equivalence.test.ts`(PR #6839)钉住,契约侧的
21+
`IMetadataService.getObject` 自 PR #6723(#6505)起也写明了这条等价关系。
22+
23+
同一句话在 `mcp-server-runtime.metadata-outage.test.ts` 里被复述过一次,一并
24+
更正。
25+
26+
仍然成立的那半条理由被保留:`getObject``IMetadataService` 自己的成员,
27+
#6055 当时它并**没有**被文档化的等价关系,在消费端擅自假定一条正是 Prime
28+
Directive #12 禁止的私有方言 —— 所以解析器当初没有被换掉。
29+
30+
**纯注释,零行为变化。** 这次更正****主张把解析器换成
31+
`getDiagnosed('object', name)`:那是一次独立的判断,由接手的人按其自身利弊
32+
去做,本次改动既不作出也不预设。
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
fix(client): `QueryOptionsV2`'s JSDoc no longer calls itself "the recommended interface" for a deprecated method
6+
7+
`packages/client/src/index.ts`'s `data.find()` carries `@deprecated Use
8+
data.query() with standard QueryAST parameters instead` (#986: deprecate the
9+
legacy-parameter entries, promote `data.query(AST)`), but the `QueryOptionsV2`
10+
interface — one of the two options shapes `find()` accepts — described itself
11+
as *"the recommended interface for `data.find()` queries"*. A recommended
12+
vocabulary for a method the same file marks deprecated is a self-contradiction
13+
in the published type declarations: `dist/index.d.ts` ships both claims to
14+
every consumer's editor.
15+
16+
Maintainer ruling on #6795 (2026-08-09, upholding #986): keep the
17+
`@deprecated` tag — `find` is implemented product direction and both the CLI
18+
and objectui's data adapter already call `data.query()` — and reword only the
19+
self-description. `QueryOptionsV2`'s JSDoc now says it is the vocabulary
20+
`data.find()` still accepts, not a recommendation, and points at
21+
`data.query()` for new code.
22+
23+
No behavior change: `QueryOptionsV2`'s fields, `find()`'s normalization, and
24+
the `@deprecated` tag are all unchanged. JSDoc/`.d.ts` wording only.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(data): the dotted-path `400 INVALID_SORT` hint prescribes a **stored** field, not a formula (#6924)
6+
7+
`assertSortFieldsExist` refuses a dotted `orderBy` (`?sort=account.company_name`)
8+
and then told the author how to fix it: *"Denormalise the value onto '<object>'
9+
(a formula or rollup field that copies it into a real column) and sort by that."*
10+
That prescription cannot be built. Following it lands the author back inside the
11+
exact silent degradation the refusal had just saved them from.
12+
13+
Measured on a REAL `SqlDriver` (better-sqlite3) and on `InMemoryDriver`, with a
14+
`formula` field named directly in `orderBy` (non-dotted, so this gate lets it
15+
through):
16+
17+
```
18+
control orderBy title asc -> A B C D E a real column really sorts
19+
baseline no sort -> C A E B D insertion order
20+
orderBy <formula field> asc -> C A E B D 200 insertion order
21+
orderBy <formula field> desc -> C A E B D 200 direction-blind
22+
```
23+
24+
A `formula` field is virtual — `SqlDriver.createColumn` returns early for it and
25+
no column is created (sqlite answers `no such column`), the engine evaluates the
26+
expression *after* the driver returns, and the #3821 unknown-column backstop
27+
retries WITHOUT the sort. The response is `200`, every row present, order
28+
arbitrary: the failure mode #4226/#4256 exist to stop.
29+
30+
The hint now reads: *"Denormalise the value onto '<object>' (a stored field,
31+
written when the source changes) and sort by that. Not a formula field: it is
32+
virtual, no driver materialises a column for one, and ORDER BY on it is silently
33+
dropped."* — "stored" being the same word #6673 landed for the identical
34+
correction on the search axis.
35+
36+
`rollup`/`summary` is dropped from the hint for a different reason, and the
37+
measurement is worth recording because it contradicts the reported diagnosis: a
38+
`summary` field **does** get a real, maintained column (`orderBy <summary> desc`
39+
returned `E D C B A` over values `5 4 3 2 1`), so it is not unmaterializable. It
40+
simply cannot do this job — a rollup aggregates CHILD records
41+
(`count`/`sum`/`min`/`max`/`avg`) and so cannot carry a looked-up parent's column
42+
onto the queried object.
43+
44+
**This overturns a recorded decision.** #4256 (closed `completed`) explicitly
45+
chose the "formula or rollup" wording as its remedy for dotted-path sort, and its
46+
own still-pending changeset (`sort-dotted-path-rejected.md`) describes it; that
47+
file is left as the accurate record of what #4256 shipped, and this entry
48+
supersedes its prescription. `content/docs/protocol/objectql/query-syntax.mdx`
49+
("Sorting on Related Fields") taught the same denormalization and is corrected in
50+
the same change, so code and docs stop agreeing with each other about something
51+
untrue.
52+
53+
Not fixed here, filed separately: the platform still accepts a **non-dotted**
54+
`orderBy` naming a `formula` field and answers `200` in arbitrary order. That is
55+
an engine/driver-side refusal question, not hint text.

0 commit comments

Comments
 (0)