Skip to content

Commit d6a856b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5234-silent-empty-predicates
2 parents c8fd714 + d367f03 commit d6a856b

93 files changed

Lines changed: 1803 additions & 501 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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): 集合算子的标量比较值答 400 INVALID_FILTER 并点名期望形状,不再 500 DATABASE_ERROR
6+
7+
`FieldOperatorsSchema` 声明 `$in` / `$nin` 的比较值是数组、`$between` 是 `[min, max]` 二元组,但入口处没有任何一层强制这条声明:`isFilterAST` 只看算子,`parseFilterAST` 照单下降,于是 `['status', 'not_in', 'done']` 变成 `{ status: { $nin: 'done' } }` 一路走到驱动。
8+
9+
**行为变化(用户可见)**:此前 `driver-sql` 把标量交给 `whereIn(field, scalar)`,答 **500 `DATABASE_ERROR`** —— 用服务端故障码报告一个调用方能自己改好的过滤器,且不说明是哪个算子、哪个字段、该写成什么。现在引擎在唯一收口点拒收,答 **400 `INVALID_FILTER`**,信息点名算子(同时给出 `not_in` / `nin` / `notin` 这类作者实际书写的拼法)、字段、收到的值与位置、以及可直接粘贴的正确形状,并声明该过滤器**未被应用**。
10+
11+
覆盖两道门:直接调用引擎(`FilterArray` 下降路径)与 HTTP 面(协议层已自行下降成 `FilterCondition` 对象后再交给引擎)—— 后者正是本问题实测到的那道门。`find` / `findOne` / `count` / `aggregate` / `update` / `delete` 六个入口一致。
12+
13+
`$between` 的非二元组比较值一并收在同一处:`driver-sql` 与 `driver-memory` 各自已经拒收(措辞保持逐字一致),`driver-mongodb` 的分支则直接落空、不发射区间谓词 —— 收在收口点后三家答案一致。
14+
15+
**不变的**:`$in: []` / `$nin: []` 仍是合法谓词(分别表示「不匹配任何行」与「匹配所有行」);列表**成员**的类型不在此处复判(那是 #5234,另一个面);非集合算子的标量比较值不受影响,包括 `$gt` 的 ISO 日期字符串这类 `FieldOperatorsSchema` 声明更严、而各后端一致接受的形状。
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
---
4+
5+
fix(service-datasource): a `pool` block on a `memory` datasource is rejected, not dropped in silence (#5931)
6+
7+
#5714 made a `pool` block the driver cannot honour a loud authoring error, but
8+
its ruling was scoped to the two sqlite arms — `memory` kept dropping it. The
9+
`memory` arm hands `InMemoryDriver` nothing but `buildMemoryConfig(spec)`, which
10+
reads `spec.config` and never `spec.pool`, so a sized pool reached nothing and
11+
said nothing. Measured through the real factory:
12+
13+
```text
14+
memory + pool{min:3,max:9} driver config {"persistence":false} pool undefined
15+
sqlite + pool{min:3,max:9} rejected (since #5714)
16+
postgres + pool{min:3,max:9} knex config.pool {"min":3,"max":9} live {min:3,max:9}
17+
```
18+
19+
`memory` now joins `POOL_UNSUPPORTED_DRIVER_IDS`, so the same three doors that
20+
already rejected sqlite reject it: the Setup wizard's create/update, the
21+
boot-time auto-connect pre-pass, and the driver factory itself.
22+
23+
**Behaviour change.** A datasource declaring `driver: 'memory'` (or `inmemory` /
24+
`in-memory` / `mingo`) together with a non-empty `pool` block used to load and
25+
run; it now throws at whichever door it arrives through. The fix is the one edit
26+
the message names — delete the `pool` block. Nothing is lost by deleting it: it
27+
configured nothing before. An absent or empty `pool` is unchanged, and every
28+
`memory` datasource without one builds exactly as it did. No declaration in this
29+
repo, the example apps included, carried the combination.
30+
31+
**Its own explanation, not SQLite's.** SQLite is rejected because a second
32+
connection to `:memory:` opens a separate, empty database, so sizing the pool
33+
would split one datasource across several stores. That reasoning is false for
34+
`memory`: there is no connection at all — the store is a plain data structure in
35+
this process — so the message says that instead. Telling an author their driver
36+
picked a connection strategy for them would send them looking for a knob that
37+
does not exist. Reasons are now keyed by driver id, which makes an arm joining
38+
the set without writing one a type error.
39+
40+
Maintainer ruling 2026-08-07, which also set the default for the next sister
41+
arm: when a declared key is silently dropped on one arm and an earlier ruling
42+
already made it a loud authoring error on a sibling, the new arm joins the
43+
existing rejection set rather than queueing for a ruling of its own — unless the
44+
original rationale was measured to be arm-specific.
45+
46+
No API surface is added — `POOL_UNSUPPORTED_DRIVER_IDS`,
47+
`driverReadsDeclaredPool`, `unsupportedPoolIssue`, `unsupportedPoolMessage` and
48+
`assertDatasourcePoolSupported` keep the signatures #5714 published, and the
49+
sqlite arms' rejection text is byte-for-byte unchanged.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/driver-memory": major
3+
"@objectstack/driver-mongodb": major
4+
"@objectstack/driver-sql": major
5+
"@objectstack/driver-sqlite-wasm": major
6+
"@objectstack/driver-turso": major
7+
---
8+
9+
refactor(drivers)!: 五个驱动的 query 参数跟进 `DriverQuery`,休眠的类型谎言就此没有藏身处 (#6075)
10+
11+
#5181(PR #6076)把 `IDataDriver.find/findOne/count/updateMany/deleteMany/explain` 的 query 参数收窄为 `DriverQuery`(`Omit<QueryAST, 'object'>`),并在同一条 changeset 里写明:「把驱动签名一并迁到 `DriverQuery` 是后续的机械收尾」。这就是那次收尾。
12+
13+
在此之前,五个驱动的实现仍旧声明 `query: QueryAST`(turso 侧是 `query: any`)。**它不红,也不会红** —— 方法参数按双变比较,实现声明得比契约宽照样满足契约。但调用方现在**有权**省略 `object`,于是这些实现的类型说 `query.object` 是 `string`,运行期却可能是 `undefined`:一句休眠的谎言,没有任何门拦得住下一个照着它写代码的人。
14+
15+
收尾之后,「驱动读 `query.object`」直接变成编译错误:
16+
17+
```ts
18+
// 收窄前:编译通过,运行期可能是 undefined —— 谎言
19+
// 收窄后:error TS2339: Property 'object' does not exist on type 'DriverQuery'.
20+
const name = query.object;
21+
```
22+
23+
**零运行时改动。** 本次改的全部是类型注解:五个驱动的六个契约方法签名,以及为让类型自洽而必须跟进的少量私有辅助方法参数(mongodb 的 `buildFindOptions` / `buildSortSpec`,sql 的 `findRows` / `orderKeysFor`,turso 的 `toRemoteQuery` / `toRemoteReadQuery`,memory 的 `performAggregation`)—— 它们都只转发或读取 `where` / `orderBy` / `groupBy` 这些字段,本来就不读 `object`。turso 的几处 `query: any` 一并收紧,多拿回一批本已放弃的检查。emit 无差异,测试全绿(memory 524、mongodb 206、sql 906、sqlite-wasm 254、turso 788)。
24+
25+
**迁移面:删掉驱动调用字面量里的 `object:` 键**,与 #5181 是同一句话,只是现在也覆盖了直接按具体驱动类(`SqlDriver` / `MemoryDriver` / …)而非按 `IDataDriver` 取类型的调用方。编译器会逐处指出来(TS2353 `'object' does not exist in type 'DriverQuery'`)。本仓下游 25 个包实测零处需要改动,改动只落在五个驱动自己的测试里。
26+
27+
标 major 的依据与 #5181 一致:**源码级破坏性**(调用点内联字面量),运行时行为零变化。`check:api-surface` 只记录导出的存在与否、不记录签名,因此这条说明同样是该变更唯一的下游载体。
28+
29+
`aggregate` / `distinct` / `syncSchemasBatch` 不在本次范围内 —— 它们不是 `IDataDriver` 收窄的那六个方法,其中 `syncSchemasBatch` 的条目里 `object` 是被真实读取的必填键,`expand` 条目里的 `object` 同理命名的是关联对象,都不是冗余。
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): resume 时「存储不可达」的日志不再把驱动错误拼进 message,改走 meta (#5912)
6+
7+
`engine.ts` 的 `resumeInternal` 在读挂起态存储失败的那一支,把**我们不控制文本**的
8+
数据源驱动失败原因直接插进了 `logger.error` 的 message。`ObjectLogger.write()` 一次
9+
调用只加一个「时间戳 + 级别」记录头,所以 message 里的换行会把**一条**记录变成多个
10+
物理行,后面几行既无级别也无时间戳。在 `pretty` / `text` 格式(`os dev` / `os serve`
11+
的默认)下,文件 sink 会把它们当成独立记录存,而 `grep ERROR` 只捞得到不含任何事实
12+
的那一行 —— 恰恰是运维正在找的那条。实测:一个三行的 better-sqlite3 驱动错误把这条
13+
告警切成 **3 个物理行**,只有第 1 行带 `ERROR` 头。
14+
15+
改法与 #5048 / #5575 / #5636 / #5661 / #5737 完全同一套,零新词汇:**message 单行
16+
自足**,外来 cause 交给 `Logger` 契约(`packages/spec/src/contracts/logger.ts`)
17+
`error(message, error?, meta?)` 的**第三**参(第二参留空,否则每条记录都会带上整个栈)。
18+
19+
这是这条 resume 路径上最后一处。#5737(PR #5911)修完 `wait` 节点五处之后,同一次
20+
「resume 时存储不可达」会产生两条记录:wait 节点那条已是干净单行,engine 这条仍被
21+
切碎;本次之后两条都干净。
22+
23+
对运维可见的变化(日志形状,非行为):
24+
25+
- 这条记录恒为**一个**物理行,不论日志格式;
26+
- 原因文本从 `msg` 末尾的 `: <驱动文本>` 移到记录的 `error` 字段(`meta`),多行驱动
27+
错误由 `JSON.stringify` 转义换行后完整保留 —— 一个字节都不丢;
28+
- message 补齐了 #4632 要求的后果与修法(挂起态**未被消费**、运行仍停在原处、存储
29+
恢复后可原样重试),并指明 cause 在本记录的 meta 里。
30+
31+
刻意**不变**的两处,已各自钉上回归测试:
32+
33+
- **返回值信封** `AutomationResult.error`(`STORE_UNAVAILABLE`)仍逐字拼接驱动文本。
34+
它是给调用方读的结构化返回值,经 REST 出去是 JSON 字符串字段、不按行切分;#5636
35+
对 `degradedReason` 是同源取舍,且 PR #5911 已让 wait 节点侧把它整体放进 meta 保留。
36+
- **级别仍是 `error`**。运行在盘上而 resume 没落地,正是 #4632 定义的耐久性降级,
37+
`pnpm check:durability-log-level` 照旧覆盖。
38+
39+
按记录末尾驱动文本字面量 grep 这条记录的日志查询,需要改成读记录的 `error` 字段。

‎.claude/agents/os-dev.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,32 @@ all real:
206206
forcing the template; #4984 is the family origin — fixtures spelling
207207
rejected aliases kept the tests green while the rule was dead).
208208

209+
**Rejection-class cases assert the envelope, not the throw.** For any case whose
210+
point is that bad input is *refused*, the minimum assertion set is the error's
211+
**`code` AND `status`** (the ADR-0112 envelope). `expect(...).toThrow()` /
212+
`rejects.toThrow()` on its own is not a rejection test: it carries one bit where
213+
the defect has two, and PR #6142 (#6050) measured both ways it goes blind —
214+
opposite directions, same hole:
215+
216+
- **A bare `Error` ⇒ permanently green.** Deleting the new refusal gate turned
217+
22 of `driver-sql`'s 28 cases red, and *most* of those reds were the driver
218+
throwing knex's bare `Undefined binding(s)` — an `Error` whose `code` and
219+
`status` are both `undefined`. The unfixed driver already throws; only the
220+
envelope is missing. A throw-only assertion therefore stays **green on the
221+
very driver the issue targets**.
222+
- **A transport that never throws ⇒ red, but pointing away from the defect.**
223+
The same deletion turned 20 of `driver-turso`'s 29 remote cases red, and all
224+
20 failed by *answering* — that transport never throws. A throw-only
225+
assertion reports "the promise resolved", which names the absence of a throw
226+
and never the absence of an envelope, so it cannot separate "refused with the
227+
wrong envelope" from "did not refuse at all" — and those are exactly the two
228+
defects.
229+
230+
Where the wording is itself contract (#5240, one condition ⇒ one wording),
231+
assert the message's first sentence **on top of** `code`+`status`, never instead
232+
of them. A rejection test that cannot go red on a missing envelope reads as
233+
coverage and is not.
234+
209235
**Key-vs-value reachability criterion.** Match a fixture guard's assertion to
210236
what the rule guards. Guarding that a **key** is a real authoring surface →
211237
assert the schema reports no `unrecognized_keys` on the fixture. Guarding a

‎.claude/skills/pm-dispatch/SKILL.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,28 @@ prompt:
11951195
实现面逐层收口」这类工作上,没有第二个实现面的活(纯 UI、文档、单面脚本)这条无处可
11961196
绑,该省掉而不是改写它。
11971197

1198+
**拒收类用例的最低断言集是 `code` + `status`,不是「它抛了」—— 派发令的标准条款。**
1199+
适用判据:本单会**新增或改写拒收 / 错误类用例**(验收点里出现「应当被拒收」的活)。
1200+
满足时派发令带这一句(原话):
1201+
1202+
> 拒收类用例最低断言**错误的 `code` 与 `status`**(ADR-0112 信封)。
1203+
> `expect(...).toThrow()` / `rejects.toThrow()` 单独使用**不构成**拒收测试;措辞本身
1204+
> 是契约时(#5240「一个条件一种措辞」),首句断言**加在** `code`+`status` **之上**,
1205+
> 而不是代替它。本条约束你**新写或改写**的用例 —— 顺手回填存量套件不在本单范围内。
1206+
1207+
出处是 #6142(#6050)的反向验证实测。两种失明机制方向相反,同一个洞:
1208+
1209+
- **裸 `Error` ⇒ 恒绿。** 删掉拒收闸后 `driver-sql` 28 例红 22,**多数红在抛出 knex 的
1210+
裸 `Undefined binding(s)`** —— 一个 `code` / `status` 均为 `undefined` 的 Error。未修的
1211+
驱动本来就抛,缺的只是信封:只断言「它抛了」的用例,**在本单所针对的那个驱动上保持
1212+
绿色**。
1213+
- **从不抛的 transport ⇒ 红,但红得不指向缺陷。** 同一次删闸,`driver-turso` remote
1214+
29 例红 20,**20 个全部**红在「本该拒收却编译出了 SQL」—— 该 transport 从不抛。只断言
1215+
抛出的用例在这里报的是「promise 没有 reject」,说的是**没抛**而不是**没信封**,分不开
1216+
「拒收了但信封错」与「根本没拒收」—— 而这正是这一族的两个缺陷。
1217+
1218+
一句话:**一个在缺信封的实现上无法转红的拒收用例,读起来是覆盖,实际不是。**
1219+
11981220
**Issue 正文是线索,不是规格 —— and the dispatch wording is what makes an
11991221
honest "the premise is dead" cheap to return.** Step 1's stale-premise check
12001222
is the PM's sample; the dev's verification is the real thing, so the prompt
@@ -1478,6 +1500,12 @@ against the report's own claims:
14781500
genuinely invalid shapes are still there (step 5's two lines). #5365 slipped
14791501
through exactly this review layer and was caught by CI instead: CI does catch
14801502
it, at the price of one extra lap.
1503+
- **拒收类用例的绿,是不是「它抛了」的绿?** 判据:本单验收点含「应当被拒收」。抽查
1504+
diff 里的拒收用例有没有断言 `code` 与 `status`(ADR-0112 信封)—— 只写 `toThrow()` /
1505+
`rejects.toThrow()` 的用例,在**未修实现本来就抛裸 Error** 的那一族上恒绿(#6142
1506+
实测:`driver-sql` 删闸后 22 红中多数是裸 knex Error,`code` / `status` 均
1507+
`undefined`),于是「28 例全绿」这种报告读起来是覆盖、实际证不了拒收。缺断言判
1508+
REWORK 补齐,而不是接受绿色输出。本条是 step 5 那条标准条款在复核侧的对账。
14811509
- **Did the dev verify the issue's premise?** The report's
14821510
`premise_still_valid` field makes the answer explicit — a `false` there
14831511
reopens triage rather than failing review. A report that falsifies the

‎packages/drivers/driver-memory/src/memory-datetime-storage.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
6161

6262
it('stores every writer form as canonical UTC ISO text', async () => {
6363
await seedMixed();
64-
const raw = await driver.find('task', { object: 'task' });
64+
const raw = await driver.find('task', {});
6565
for (const row of raw) {
6666
expect(typeof (row as any).created_at, `${(row as any).id} stored form`).toBe('string');
6767
expect((row as any).created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
@@ -93,10 +93,10 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
9393

9494
it('keeps #3777/#4042 bound semantics on top of the converged storage', async () => {
9595
await seedMixed();
96-
const gte = await driver.find('task', { object: 'task', where: { created_at: { $gte: '2026-07-28' } } });
96+
const gte = await driver.find('task', { where: { created_at: { $gte: '2026-07-28' } } });
9797
expect(ids(gte)).toEqual(['d_midnight', 'd_next_day', 's_evening', 's_morning']);
9898

99-
const lt = await driver.find('task', { object: 'task', where: { created_at: { $lt: '2026-07-28' } } });
99+
const lt = await driver.find('task', { where: { created_at: { $lt: '2026-07-28' } } });
100100
expect(ids(lt)).toEqual(['d_yesterday', 's_old']);
101101

102102
const instant = await driver.find('task', {
@@ -150,7 +150,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
150150
// honest subject for an update-path assertion.
151151
await driver.create('task', { id: 'u1', due_at: '2026-04-19T10:00:00.000Z' });
152152
await driver.update('task', 'u1', { due_at: new Date('2026-07-28T09:15:00Z') });
153-
const row: any = (await driver.find('task', { object: 'task', where: { id: 'u1' } }))[0];
153+
const row: any = (await driver.find('task', { where: { id: 'u1' } }))[0];
154154
expect(row.due_at).toBe('2026-07-28T09:15:00.000Z');
155155

156156
// …and the converged value is reachable by a date window, which is the
@@ -171,7 +171,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
171171
] as const) {
172172
await driver.create('task', { id, created_on });
173173
}
174-
const all = await driver.find('task', { object: 'task' });
174+
const all = await driver.find('task', {});
175175
for (const row of all) expect(typeof (row as any).created_on).toBe('string');
176176
expect((all.find((r: any) => r.id === 'on_obj')).created_on).toBe('2026-07-28');
177177

@@ -183,7 +183,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
183183

184184
it('an undeclared object is left alone (no schema → no coercion)', async () => {
185185
await driver.create('freeform', { id: 'f1', when: new Date('2026-07-28T09:15:00Z') });
186-
const row: any = (await driver.find('freeform', { object: 'freeform' }))[0];
186+
const row: any = (await driver.find('freeform', {}))[0];
187187
expect(row.when).toBeInstanceOf(Date);
188188
});
189189
});

‎packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,13 @@ describe('InMemoryDriver — bare-day $lte covers the whole day (#4042)', () =>
5959
});
6060

6161
it('$gte / $gt / $lt keep their midnight anchoring', async () => {
62-
const gte = await driver.find('task', { object: 'task', where: { created_at: { $gte: '2026-07-28' } } });
62+
const gte = await driver.find('task', { where: { created_at: { $gte: '2026-07-28' } } });
6363
expect(ids(gte)).toEqual(['t_evening', 't_midnight', 't_morning']);
6464

65-
const gt = await driver.find('task', { object: 'task', where: { created_at: { $gt: '2026-07-28' } } });
65+
const gt = await driver.find('task', { where: { created_at: { $gt: '2026-07-28' } } });
6666
expect(ids(gt)).toEqual(['t_evening', 't_midnight', 't_morning']); // string '…T00:00' > '2026-07-28'
6767

68-
const lt = await driver.find('task', { object: 'task', where: { created_at: { $lt: '2026-07-28' } } });
68+
const lt = await driver.find('task', { where: { created_at: { $lt: '2026-07-28' } } });
6969
expect(ids(lt)).toEqual(['t_old', 't_yesterday']);
7070
});
7171

‎packages/drivers/driver-memory/src/memory-driver-document-not.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ describe('[#5324] InMemoryDriver.find compiles a document-level $not', () => {
8686
});
8787

8888
const idsFrom = async (driver: InMemoryDriver, where: unknown): Promise<string[]> => {
89-
const rows = await driver.find('deal', { object: 'deal', fields: ['id'], where: where as FilterCondition });
89+
const rows = await driver.find('deal', { fields: ['id'], where: where as FilterCondition });
9090
return (rows as Array<Record<string, unknown>>).map((r) => String(r.id)).sort();
9191
};
9292

0 commit comments

Comments
 (0)