Skip to content

Commit 34612e3

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6339-insert-strip-caller-values
2 parents b64bbbb + 82397b6 commit 34612e3

102 files changed

Lines changed: 3945 additions & 523 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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
fix(client): `data.find()` emits `top`/`skip` on presence, so `limit: 0` reaches the server (#6485)
6+
7+
Both `find` implementations — `ObjectStackClient.data.find` and its
8+
byte-identical `ScopedProjectClient.data.find` copy — emitted the two pagination
9+
transport params on **truthiness**:
10+
11+
```ts
12+
if (normalizedOptions.top) queryParams.set('top', normalizedOptions.top.toString());
13+
if (normalizedOptions.skip) queryParams.set('skip', normalizedOptions.skip.toString());
14+
```
15+
16+
while the canonical normalizer ten lines above already tested **presence**
17+
(`if (v2.limit != null) normalizedOptions.top = v2.limit`). So `0` survived the
18+
normalizer and was then discarded by the emitter. Both now test presence, in
19+
both copies.
20+
21+
**What changes on the wire, and why that is the fix rather than a preference.**
22+
`find('task', { limit: 0 })` — and equally `{ top: 0 }` — used to reach the
23+
server with **no `top` param at all**. The GET list route has no default page
24+
size, so an absent `top` returns the *entire* match set: the caller who asked
25+
for no records received every record, under HTTP 200 with no warning.
26+
27+
The direction was measured before the change rather than assumed, because a
28+
client fix is only worth having if the server honours what it sends:
29+
30+
| layer | `top=0` |
31+
|:---|:---|
32+
| REST list route → `ObjectStackProtocolImplementation.findData` | not rejected, not ignored — folds `top` into `limit`, coerces `Number('0')`, forwards `{ limit: 0 }` to the engine; envelope reports `total: 0, hasMore: false` |
33+
| `SqlDriver.find` (the driver behind the default file-backed SQLite datasource, and every Postgres/MySQL deployment) | paginates on presence — `LIMIT 0`, **zero rows** |
34+
| `TursoRemoteTransport` | presence — `LIMIT ?` bound to `0`, zero rows |
35+
36+
So `limit: 0` now means "return no records" end to end, which is what the
37+
canonical branch already implied.
38+
39+
**`offset: 0` / `skip: 0` were dropped too, and that half is a consistency
40+
change with no behavioural consequence**`skip=0` is already the server's
41+
default, so the request means the same thing whether the param is sent or not.
42+
They are aligned because one emitter must not hold two rules for one pair, not
43+
because a wrong answer was being returned.
44+
45+
Callers passing a non-zero `limit`/`top`/`offset`/`skip`, or omitting them
46+
entirely, are unaffected — the emitted query string is byte-identical.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): `$gt`/`$gte`/`$lt`/`$lte` accept the ISO string the platform itself produces (#5685)
6+
7+
The four ordering-comparison slots declared `number | Date | FieldReference`
8+
and the platform's own producers put a **string** in them and nothing else. The
9+
declaration did not merely under-describe reality, it contradicted it:
10+
11+
- `resolveFilterTokens` (`@objectstack/core`) is the evaluator for the `{token}`
12+
grammar and **every** branch returns a string — `asYmd(…)` for a calendar day,
13+
`.toISOString()` for the sub-day tokens. Its own module example is exactly
14+
this shape: `{ close_date: { $gte: '{current_year_start}' } }` becomes
15+
`{ close_date: { $gte: '2026-01-01' } }`.
16+
- `date-macros.zod.ts` states the same rule from the other end: "the DRIVER only
17+
ever sees ISO date / timestamp strings, never `{tokens}`".
18+
- Three first-party callers send strings today — `lifecycle-service`'s retention
19+
cutoffs, `plugin-email`'s outbox sweep, and `plugin-auth`'s better-auth
20+
adapter.
21+
22+
An author — an AI author in particular — reading `number | Date` concluded that
23+
a date window must be a `Date` object or an epoch number, which is the one form
24+
the date-macro path can never hand them.
25+
26+
**This is additive and declaration-side only.** No producer, caller or driver
27+
changed. Every evaluation surface already compared strings: `driver-sql` binds
28+
`>`/`>=`/`<`/`<=`, and `formula`'s `matchesFilter` and `driver-memory`'s matcher
29+
fall through to the JS operators. Filters that validated before still validate.
30+
31+
Widened in all three places this contract is spelled: `ComparisonOperatorSchema`
32+
(documentation), `FieldOperatorsSchema` (the copy `NormalizedFilterSchema`
33+
validates against and `FieldOperators` is inferred from), and the `Filter<T>`
34+
TypeScript helper — where `T` is known, so it stays type-precise: a `Date` field
35+
now also takes the resolver's ISO string, a `string` field (a `Field.time`
36+
`'09:00'`, an autonumber code) is orderable instead of collapsing to `never`,
37+
and a `number` field stays numbers-only.
38+
39+
**The comparand form the contract guarantees** is the ISO/clock one — an ISO
40+
calendar day (`YYYY-MM-DD`), a UTC ISO-8601 instant, or a wall-clock time of day
41+
(`HH:MM[:SS[.fff]]`). Those are ASCII and fixed-width, so lexicographic order IS
42+
chronological order and every backend agrees. The union is a bare `string`
43+
rather than an ISO refinement because this schema is field-agnostic (it never
44+
sees which column the operator applies to) and because an ISO refinement would
45+
reject `Field.time`'s declared `HH:MM` form, which `SqlDriver.temporalFilterValue`
46+
canonicalises in the comparand position. Ordering **non-temporal** text is
47+
therefore permitted but not promised: the order is the backend collation's
48+
(byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the
49+
JS matchers), and those coincide only for ASCII. The `.describe()` on each slot
50+
says so.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
A hook registration can now express "global, EXCEPT these objects" — `registerHook(event, handler, { excludeObjects })`.
7+
8+
`registerHook` carried one scope face: `object`, an allow list (absent = global, `'*'` = every object). An allow list and a deny list are interchangeable only over a closed universe of object names, and this one is open — a successful `/meta` PUT registers new objects into a running engine, and `SchemaRegistry.registerObject` emits no event a plugin could subscribe to. So a registrant wanting "everything except these platform tables" had two options, both wrong: keep the skip list inside the handler as an early return, which leaves the registration global and makes the per-object gates (`hasHooksFor`, the bulk-write row-set read) answer "hooks apply" for objects the handler is about to skip; or enumerate the complement into `object`, which freezes the list at boot so an object created afterwards is silently not covered — a compliance regression for the audit plugin, and a silent one.
9+
10+
`excludeObjects?: string | string[]` is the deny half, subtracted from whatever `object` admits: `matches = allowMatches && !excludeMatches`. Absent means subtract nothing, so every registration that compiled before still behaves identically. Declared on the registration rather than left to a predicate callback, so the scope stays static, printable — the `Registered hook` debug record now reports it — and introspectable by diagnostics.
11+
12+
Two shapes are refused at registration, following the same reasoning as the empty-target ruling: an empty name (`''`, `['']`, or a blank member) would subtract nothing while reading as though it subtracted something, and `'*'` would subtract every object and leave a hook that can never fire (ADR-0078: no silently inert declaration). Both throw, naming the fix. `excludeObjects: []` is accepted — it is the honest spelling of "subtract nothing", and the natural value of a spread whose source list is empty.
13+
14+
`triggerHooks` (dispatch) and `hasHooksFor` (the bulk-write gate) were two hand-written copies of one matching semantic; adding a second scope dimension to two copies is how they drift, so both now call one shared matcher. A property test pins the direction that matters — the gate is never tighter than the dispatch, since a looser gate costs a wasted query while a tighter one silently drops hooks that were going to fire.
15+
16+
The authorable `HookSchema` is deliberately untouched: the consumer is plugin code registering in TypeScript, and no metadata author needs "global minus a list" today. The key stays off the authoring surface until real pull appears.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): 参考文档的内联形状摘要只展开一层,不再沿数组/Record/联合无预算下钻 (#6374)
6+
7+
`content/docs/references/**` 的类型单元格里,最宽的一格是 `ui/page.mdx`
8+
`Page.slots`,**1538 字符** —— 而且是在 #5340 的枚举省略已经在这一格生效 8 次
9+
之后的宽度。本次修完,这一格是 **122 字符**
10+
11+
## 机制
12+
13+
`format-type.ts` 一直只展开**一层** `{ … }` 形状:再往下的对象打印 `object`
14+
但这条预算写在键循环的三元表达式里,于是只有**直接对象子节点**受它约束。数组元素
15+
(`{ … }[]`)、`Record` 的值(`Record<string, { … }>`)、联合的变体
16+
(`{ … } | { … }[]`)这三条路径都会重新进入对象分支,而预算不在作用域内 ——
17+
单元格宽度于是等于「每层键数 × 变体数 × 每个形状的宽度」,一层一层乘上去。
18+
19+
同一个形状在同一个阅读深度上,**印全还是印 `object`,取决于作者有没有把它包在数组
20+
** —— 这是关于 Zod 写法的事实,不是关于读者怎么读的事实,和 #6225 拆掉的那种
21+
不对称完全同类。
22+
23+
新常量 `SHAPE_DEPTH_LIMIT` 把同一条预算移到对象分支本身,四条下钻路径都要过它。
24+
**阈值 1 不是选出来的,是取回来的** —— 它就是直接子节点路径上一直生效的那个值;
25+
全语料实测,任何大于 1 的取值都比不改还差(>200 字符的单元格 121 → 173+,因为
26+
提高上限必然放松那条本来就是 1 的路径)。
27+
28+
## 读者看到的变化(全语料 215 页 / 8499 个类型单元格)
29+
30+
| | 修改前 | 修改后 |
31+
|---|---|---|
32+
| >200 字符 | 121 | **42** |
33+
| >400 字符 | 9 | **1** |
34+
| >900 字符 | 1 | **0** |
35+
| p95 / p99 | 145 / 229 | **124 / 180** |
36+
| 最宽单元格 | 1538 | **656** |
37+
38+
修改后仅剩的那个 >400 单元格是 `ui/page.mdx``PageComponent.type`(656),
39+
一个落在联合变体里的顶层词表 —— #6225 有意不收它,它也**不含任何嵌套形状**
40+
也就是说:**由形状深度带来的宽度已经从语料里消失了**
41+
42+
## 省略掉的信息去哪了
43+
44+
`object` 不是截断:它对键**什么都不声称**,所以不像前缀那样会被误读成完整列表 ——
45+
这正是 #5340 定下的原则用在形状上而不是枚举成员上。它也不是这些表格里的新省略
46+
风格:嵌套形状本来就一直印 `object`。完整形状仍在原处 —— 生成器为它出页时是它
47+
自己的 `## Schema` 一节,任何情况下都在 `json-schema/` 里。
48+
49+
#5340 / #6226 的两个标记都还活着,只是有些出现位置被上游的深度预算吸收了:
50+
枚举标记 178 → 156,变体标记 16 → 9。#6226 的旗舰样本 `App.navigation` 在深度 0,
51+
逐字未变。
52+
53+
⛔ 所有 `.mdx` 均由 `gen:schema && gen:docs` 重生成,无一处手改。
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
"@objectstack/driver-sqlite-wasm": minor
4+
"@objectstack/driver-turso": minor
5+
"@objectstack/driver-memory": minor
6+
"@objectstack/driver-mongodb": minor
7+
"@objectstack/objectql": minor
8+
---
9+
10+
feat(drivers,objectql): `$regex` / `$options` are refused everywhere, and `$icontains` is implemented on the SQL family (#5702)
11+
12+
The driver half of the #4706 ruling. #5701 landed the contract (the vocabulary,
13+
the `RETIRED_FILTER_OPERATORS` prescriptions, the shared text case-set) and
14+
#5710 flipped the last live producer — `plugin-auth`'s ObjectQL adapter, which
15+
emitted `$regex` on the authentication path — so the refusal can now land
16+
without breaking sign-in.
17+
18+
**BREAKING for anyone writing `$regex` or `$options` in a filter.** Both are
19+
refused on every backend with `INVALID_FILTER` / 400 and a message that names
20+
the replacement. `$regex` was never a declared operator: `driver-sql` compiled
21+
it to a LIKE-escaped substring (so `a.b` matched only the literal `a.b`),
22+
`driver-memory` ran it as a real `RegExp` (so the same filter also matched
23+
`axb`, and an *invalid* pattern was caught and answered `false` — zero rows, in
24+
silence), and `objectql`'s `having` did the same. Write `$icontains` for the
25+
case-insensitive substring search this was almost always used for, `$contains`
26+
for a case-sensitive one; a pattern that genuinely needs a regex has no
27+
filter-level replacement.
28+
29+
**`$icontains` now runs on the SQL family**`driver-sql`, `driver-sqlite-wasm`,
30+
and both of `driver-turso`'s transports (the remote one does not go through
31+
knex, so it needed its own). It compiles to `LOWER(col) LIKE LOWER(?) ESCAPE ?`
32+
through the same `applyLike` / `pushLike` that carries the `%` / `_` / `\`
33+
escaping, as a `fold` parameter rather than a second emitter — a copied emitter
34+
is where the escape class would have been dropped, and an unescaped `%` matches
35+
every row. An empty or non-string comparand is refused on the validating walk
36+
(an empty one matches every row, which widens rather than narrows). On SQLite
37+
`lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains:
38+
'café'` does not match `CAFÉ`.
39+
40+
<!-- adr-0087: registered filter-regex-options-retired -->
41+
42+
`driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no
43+
`code` and no `status`, three lines from the helper in its own file that sets
44+
`INVALID_FILTER` / 400 — a 500-shaped body for a 400-class client mistake. It
45+
now speaks the same envelope as its three siblings.
46+
47+
Two parts of the ruling are deliberately NOT in this change and stay tracked in
48+
`scripts/check-driver-conformance.mjs`'s ledger: the `$contains` family's
49+
case-sensitivity (#4706 Q2 = A) needs SQLite's `LIKE` replaced by a case-exact
50+
construct in the driver, the RLS lowering and the analytics lowering together,
51+
or one permission rule compiles to two row sets (#6518); and `$icontains` on the
52+
JS evaluation faces needs the spec vocabulary to take the operator, which cannot
53+
happen before `driver-memory` has an arm for it (#6520).

0 commit comments

Comments
 (0)