Skip to content

Commit cfb22aa

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4556-recorded-by-nullable
2 parents 5a577ea + 072806a commit cfb22aa

6 files changed

Lines changed: 282 additions & 11 deletions

File tree

.changeset/console-785b8a5d432c.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
---
2+
"@objectstack/console": minor
3+
---
4+
5+
Console (objectui) refreshed to `785b8a5d432c` — the 2026-08-02 objectui batch reaches v17 (#4665).
6+
7+
Until this pin moves, a merged objectui fix exists only on objectui's `main`: the
8+
release pipeline clones objectui at `.objectui-sha`, so anything newer is simply not
9+
in the artifact the platform ships, and its frontend changeset never reaches the
10+
platform's release history (#3340). Four of the seven PRs merged that day changed
11+
published packages, and one of them is **breaking for authoring** — so that
12+
migration is written out here, in the layer the release notes are compiled from,
13+
rather than left implicit in a SHA.
14+
15+
## Breaking for authoring — an action param's picker target is `reference`, and only `reference` (objectui#3203)
16+
17+
`ActionParam` in `@object-ui/types` no longer declares the nine resolved-side picker
18+
keys: `referenceTo`, `displayField`, `idField`, `descriptionField`, `titleFormat`,
19+
`lookupColumns`, `lookupFilters`, `lookupPageSize`, `dependsOn`.
20+
21+
Migration:
22+
23+
- **Inline picker target** — rewrite to `reference`:
24+
FROM `{ name: 'account_id', type: 'lookup', referenceTo: 'account' }`
25+
TO `{ name: 'account_id', type: 'lookup', reference: 'account' }`
26+
- **The other eight** — make the param **field-backed** and it inherits the whole
27+
picker group from the object field: `{ field: 'account_id' }`.
28+
29+
**This removes a compile-time illusion, not a capability.** Those keys were never
30+
storable: `@objectstack/spec`'s `ActionParamSchema` is `.strict()`, its authorable key
31+
list carries `reference` and not `referenceTo`, and its alias table names
32+
`referenceto → reference` by hand — so an authored `referenceTo` has always been a
33+
hard parse rejection on the server. Only `tsc` waved it through, against objectui's
34+
public type, which meant the mistake surfaced at publish time instead of at the
35+
authoring keystroke. `ActionParam` is now derived from the spec schema
36+
(`Omit< z.input< typeof ActionParamSchema >, 'type' >`), so the authoring type and
37+
the parser can no longer disagree about a spelling, and `resolveActionParams()`
38+
additionally names any resolved-only key it meets in a dev-mode warning with the
39+
prescription above — covering params authored in plain JS or JSON, which `tsc` never
40+
sees.
41+
42+
## Also author-visible in this batch
43+
44+
- **An unrecognised dashboard date-filter value is skipped and named, not compared**
45+
(objectui#3196, `@object-ui/core` minor — the other half of #4475). A `date` /
46+
`dateRange` value that is neither a known preset nor a parseable date used to fall
47+
through to "bare string means equality on that day", so a typo
48+
(`defaultValue: 'last_7_dayz'`) reached the backend as `WHERE created_at = $1` and
49+
answered `200 OK` with zero rows — indistinguishable from "this range has no data".
50+
Such a filter is now dropped with a `console.warn` naming the filter, the offending
51+
value and the accepted spellings; the widget's numbers go from 0 to unfiltered.
52+
- **`record:activity` fetches a feed instead of rendering a permanently empty one**
53+
(objectui#3204, `@object-ui/plugin-detail`). The block's eleven declared inputs were
54+
filters over a hard-coded `items={[]}`; the feed now resolves from `items` → a
55+
mounted `DiscussionContext` → a self-fetch of `sys_activity` scoped to the bound
56+
record, and the read-side inputs actually filter. `showSubscriptionToggle` is
57+
labelled `NOT IMPLEMENTED` in its own input description rather than left looking
58+
configurable.
59+
- **A fetching activity feed says "loading", not "No activity recorded"**
60+
(objectui#3210, `@object-ui/plugin-detail` patch). The declared `loading` prop was
61+
destructured into `_loading` and never read, so the panel asserted the record had no
62+
activity for the whole duration of every fetch.
63+
- **`managedBy: 'system'``'system-data'` follow-through** (objectui#3214): the
64+
Console now speaks the vocabulary this platform's retirement left standing.
65+
66+
Full frontend range below. `fix(ci)` / CI-only commits are omitted — they release
67+
nothing and are not in the shipped bundle.
68+
69+
- fix(fields)!: FieldWidgetComponentProps stops claiming to have every key (#3221) (#3230)
70+
- fix(app-shell): inspectors read and write the expression envelope (#3218) (#3228)
71+
- fix(app-shell): flow simulator evaluates a `{ dialect, source }` edge guard (#3216) (#3217)
72+
- feat(types,core,app-shell)!: follow the `managedBy: 'system'``'system-data'` retirement (objectstack#3355) (#3214)
73+
- fix(app-shell): flow branch editor stamps an id on the edges it creates (#3202) (#3215)
74+
- fix(plugin-detail): a fetching activity feed says "loading", not "No activity recorded" (#3205) (#3210)
75+
- feat(plugin-detail): record:activity fetches a feed instead of rendering an empty one (#3165) (#3204)
76+
- fix(types,app-shell)!: `reference` 是 action param 唯一可作者化的 picker 目标 (#3174) (#3203)
77+
- fix(deps): #3184 可合并版 —— focus-scope 栈驱逐竞态补丁,解冲突 + 补丁存废说明 (#3200)
78+
- fix(core): 未知的 date filter 值改为跳过并警告,不再降级成永不命中的等值 (#3151) (#3196)
79+
- fix(types): retarget the objectstack#4171 inverted pins at their real trigger (#3177) (#3194)
80+
- fix(components,grid): a grid's search box searches the list, not the page you can see (#3118) (#3192)
81+
- feat(core): declare the 18 spec-owned action keys ActionDef absorbed silently (#3190)
82+
- fix(app-shell): actually compile `spec-symbol-parity.test.ts`'s type assertions (#3181) (#3187)
83+
- feat(app-shell): wire navigation action items to the console action runtime (framework#4509) (#3180)
84+
- feat(deps)!: upgrade to @objectstack/spec 17.0.0-rc.1 and retire the wait timeout fields (#3101) (#3178)
85+
- fix(studio,timeline,list): 表单设计器解析对象翻译;timeline 认它自己配置的日期字段 (#3134, #3129) (#3175)
86+
- feat(flow-designer)!: the script node authors a function call, and nothing else (framework#4343) (#3170)
87+
- fix(studio): stop offering the retired `action.shortcut` / `action.bulkEnabled` keys (#3154)
88+
- fix(dashboard): date 型 globalFilter 的预设名默认值应提升为区间 (objectstack#4475) (#3150)
89+
- fix(dashboard,report): honor the declared percent scale so a ratio of 1 renders as 100.0% (#3136) (#3140)
90+
- fix(charts): name the slices — pie/donut legends lost their labels to a `type` dimension (#3135) (#3138)
91+
- fix(approvals): record-header Reject fires after one dialog again (#3126) (#3128)
92+
- fix(console): binding-reach 探针少报了自己 6 个块的覆盖面,而且是静默的 (#3149) (#3153)
93+
- fix(flow-designer): the default path is the edge marker, not the branch (#3148)
94+
- fix(plugin-list,plugin-form): 在注册表路径上把 dataSource 接到 list-view / embeddable-form (#3144) (#3147)
95+
- fix(actions): one placement rule for `locations` — declare it or it renders nowhere (#3145)
96+
- fix(app-shell): datasource preview 不再报告读副本数量 (objectstack#4468) (#3143)
97+
- feat(grid): aggregate single-call mode for bulk actions — execution: 'aggregate' (#3141)
98+
- fix(form): `required` is presence, not truthiness — `false` and `0` are values (#3137)
99+
- fix(environment): localize the entitlement dialog + read cloud's nested error envelope (#3130)
100+
- fix(i18n): resolve qualified view ids (#3132)
101+
102+
objectui range: `7d9734d5e321...785b8a5d432c`
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): 元数据审计历史与全局搜索按 `order` 排序,不再按 `direction` (#4674)
6+
7+
`protocol.ts` 里两处内部 `engine.find` 调用把排序写成 `{ field, direction: 'desc' }`。QueryAST 的排序形状是 `SortNodeSchema` = `{ field, order }`,两个真实驱动都只认 `.order` 且没有 `direction` 回退——`undefined === 'desc'` 为假,于是两个查询实际都在**升序**运行。`direction``IReportService` 的词汇,是另一份契约,这正是错误拼写看起来合理的原因。
8+
9+
由于两个查询都带 `limit`,方向错误不只是把一页重排,而是**改变了哪些行会被返回**:
10+
11+
- **元数据审计历史**取到的是最旧的 `limit` 条事件——一个对象生命的开头,而永远不是它最近的变更。在长期存在的对象上,编辑者要找的东西一条也看不到。
12+
- **全局搜索**取到的是最陈旧的 `perObject` 条匹配,最近编辑过的记录恰好被 `limit` 截断掉——而那正是搜索者最可能想要的。
13+
14+
两处的 `as any` / `: any` 一并去掉:`EngineQueryOptions.orderBy``SortNodeSchema[]`,本来就会拒绝 `direction`,而类型擦除正是让它溜过去的原因。恢复类型是这次改动价值的大头,因为对内部调用方来说 `tsc` 就是那条被执行的渠道。

.objectui-sha

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
7d9734d5e3212c565d824c717354a4a2fddbfce2
1+
785b8a5d432cf009389a1a9180fdac2a8297543f
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Two internal `engine.find` calls sorted by `direction`, a key nothing on that
4+
// path reads (#4674).
5+
//
6+
// The QueryAST sort shape is `SortNodeSchema` = `{ field, order }`
7+
// (`packages/spec/src/data/query.zod.ts`), and both real drivers normalize off
8+
// `.order` with no fallback — `sql-driver` maps `item.order === 'desc'`,
9+
// `mongodb-driver` the same. With `order` absent, `undefined === 'desc'` is
10+
// false and both land on ASCENDING. `direction` is `IReportService`'s
11+
// vocabulary; it is a genuinely different contract, which is how the wrong
12+
// spelling looked plausible.
13+
//
14+
// Because both queries carry a `limit`, the wrong direction did not merely
15+
// reorder a page — it changed WHICH ROWS CAME BACK. So these tests assert on
16+
// identity, not sequence: with a limit smaller than the fixture, sorting the
17+
// wrong way returns a disjoint set. An order-only assertion would have passed
18+
// against a fake that ignored `orderBy` entirely.
19+
//
20+
// Nothing caught this because both sites erased their types (`} as any)` and
21+
// `const opts: any`), the protocol's `INVALID_SORT` normalizer does not run on
22+
// calls the protocol makes to `this.engine.find` directly, and that normalizer
23+
// rejects bad VALUES rather than unknown KEYS — the schema is not `.strict()`,
24+
// so `direction` was dropped rather than flagged.
25+
26+
import { describe, it, expect, vi } from 'vitest';
27+
import { ObjectStackProtocolImplementation } from './protocol.js';
28+
29+
/**
30+
* A `find` that honours the QueryAST contract for sort and limit, and nothing
31+
* else. Filtering is deliberately not implemented: what is under test is which
32+
* rows survive `orderBy` + `limit`, and a double that also filtered would let a
33+
* sort bug hide behind a `where` that happened to select the right rows.
34+
*
35+
* It reads `order` — the shape the drivers read. A double that read `direction`
36+
* would agree with the bug instead of catching it, which is exactly what the
37+
* publish-rollback double did until this change.
38+
*/
39+
function makeFind(rowsByObject: Record<string, any[]>) {
40+
return vi.fn(async (object: string, opts: any = {}) => {
41+
const rows = [...(rowsByObject[object] ?? [])];
42+
for (const { field, order } of [...(opts.orderBy ?? [])].reverse()) {
43+
rows.sort((a, b) => {
44+
const av = a[field], bv = b[field];
45+
if (av === bv) return 0;
46+
return (av < bv ? -1 : 1) * (order === 'desc' ? -1 : 1);
47+
});
48+
}
49+
return typeof opts.limit === 'number' ? rows.slice(0, opts.limit) : rows;
50+
});
51+
}
52+
53+
/** The options the protocol handed to `engine.find` on its first call. */
54+
const optionsFrom = (find: any) => find.mock.calls[0][1];
55+
56+
const AUDIT_ROWS = ['2024-01-01', '2024-02-01', '2024-03-01', '2024-04-01', '2024-05-01'].map(
57+
(d, i) => ({
58+
id: `a${i + 1}`,
59+
occurred_at: `${d}T00:00:00.000Z`,
60+
actor: 'someone',
61+
operation: 'save',
62+
outcome: 'allowed',
63+
code: 'OK',
64+
}),
65+
);
66+
67+
describe('auditMetaItem sorts newest-first (#4674)', () => {
68+
function makeProtocol() {
69+
const find = makeFind({ sys_metadata_audit: AUDIT_ROWS });
70+
const engine = { registry: { getObject: () => undefined }, find };
71+
return { p: new ObjectStackProtocolImplementation(engine as any), find };
72+
}
73+
74+
it('returns the NEWEST `limit` events, not the oldest', async () => {
75+
const { p } = makeProtocol();
76+
const { events } = await p.auditMetaItem({ type: 'objects', name: 'invoice', limit: 2 });
77+
78+
// The whole defect in one assertion: ascending returns a1/a2 here.
79+
expect(events.map(e => e.id)).toEqual(['a5', 'a4']);
80+
});
81+
82+
it('asks for `order`, never `direction`', async () => {
83+
const { p, find } = makeProtocol();
84+
await p.auditMetaItem({ type: 'objects', name: 'invoice', limit: 2 });
85+
86+
const sort = optionsFrom(find).orderBy;
87+
expect(sort).toEqual([{ field: 'occurred_at', order: 'desc' }]);
88+
// Named explicitly: `direction` reads as a well-formed "sort by
89+
// occurred_at, direction unspecified" and passes every existing check.
90+
expect(sort[0]).not.toHaveProperty('direction');
91+
});
92+
});
93+
94+
const SEARCH_ROWS = ['2024-01-01', '2024-02-01', '2024-03-01', '2024-04-01'].map((d, i) => ({
95+
id: `c${i + 1}`,
96+
name: `Acme ${i + 1}`,
97+
updated_at: `${d}T00:00:00.000Z`,
98+
}));
99+
100+
const CONTACT = {
101+
name: 'contact',
102+
fields: { name: { name: 'name', type: 'text', searchable: true } },
103+
};
104+
105+
describe('searchAll sorts newest-first (#4674)', () => {
106+
function makeProtocol() {
107+
const find = makeFind({ contact: SEARCH_ROWS });
108+
const engine = {
109+
registry: { getObject: (n: string) => (n === 'contact' ? CONTACT : undefined), getAllObjects: () => [CONTACT] },
110+
find,
111+
};
112+
return { p: new ObjectStackProtocolImplementation(engine as any), find };
113+
}
114+
115+
it('returns the most recently updated matches, not the stalest', async () => {
116+
const { p } = makeProtocol();
117+
const { hits } = await p.searchAll({ q: 'Acme', perObject: 2 });
118+
119+
// Ascending returned c1/c2 — the stalest rows, with the recently-edited
120+
// ones truncated away by `perObject`.
121+
expect(hits.map(h => h.id)).toEqual(['c4', 'c3']);
122+
});
123+
124+
it('asks for `order`, never `direction`', async () => {
125+
const { p, find } = makeProtocol();
126+
await p.searchAll({ q: 'Acme', perObject: 2 });
127+
128+
const sort = optionsFrom(find).orderBy;
129+
expect(sort).toEqual([{ field: 'updated_at', order: 'desc' }]);
130+
expect(sort[0]).not.toHaveProperty('direction');
131+
});
132+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS,
2626
RPC_QUERY_ALIAS_SLOTS, foldQueryAliasSlots,
2727
type QueryAliasConflict, type QueryAliasSlot,
28-
type DroppedFieldsEvent, type QueryAST,
28+
type DroppedFieldsEvent, type QueryAST, type EngineQueryOptions,
2929
} from '@objectstack/spec/data';
3030
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
3131
import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec';
@@ -3377,11 +3377,20 @@ export class ObjectStackProtocolImplementation implements
33773377
type: singular,
33783378
name: request.name,
33793379
};
3380+
// `order`, NOT `direction`: the QueryAST sort shape is
3381+
// `SortNodeSchema` = `{ field, order }`, and both drivers normalize
3382+
// off `.order` with no fallback. `direction` is `IReportService`'s
3383+
// vocabulary and is silently DROPPED here (the schema is not
3384+
// `.strict()`), which left this query running ascending — the
3385+
// OLDEST `limit` audit events, i.e. the beginning of an object's
3386+
// life and never its recent changes (#4674). The `as any` is gone
3387+
// for the same reason: `EngineQueryOptions` rejects the wrong key,
3388+
// and erasing the type is what let it through.
33803389
const rows = await this.engine.find('sys_metadata_audit', {
33813390
where,
3382-
orderBy: [{ field: 'occurred_at', direction: 'desc' }],
3391+
orderBy: [{ field: 'occurred_at', order: 'desc' }],
33833392
limit,
3384-
} as any);
3393+
});
33853394
const events = (Array.isArray(rows) ? rows : []).map((r: any) => ({
33863395
id: r.id,
33873396
occurredAt:
@@ -5164,10 +5173,15 @@ export class ObjectStackProtocolImplementation implements
51645173
const where = andClauses.length === 1 ? andClauses[0] : { $and: andClauses };
51655174

51665175
try {
5167-
const opts: any = {
5176+
// `order`, NOT `direction` — see the audit-history query above.
5177+
// Ascending here returned the STALEST `perObject` matches and
5178+
// truncated away the recently-edited records a searcher is most
5179+
// likely to want (#4674). Typed rather than `any` so the
5180+
// contract rejects the wrong key at the call site.
5181+
const opts: EngineQueryOptions = {
51685182
where,
51695183
limit: perObject,
5170-
orderBy: [{ field: 'updated_at', direction: 'desc' }],
5184+
orderBy: [{ field: 'updated_at', order: 'desc' }],
51715185
};
51725186
if (request.context !== undefined) opts.context = request.context;
51735187

packages/objectql/src/protocol-publish-rollback.test.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,21 @@ function makeStubEngine() {
8282
async find(table: string, opts: { where: Record<string, unknown> }) {
8383
if (table === 'sys_metadata_history') {
8484
const out = historyRows.filter((h) => matchesHistory(h, opts.where));
85-
if (opts && (opts as any).orderBy) {
86-
const { field, direction } = (opts as any).orderBy;
85+
// QueryAST shape: `orderBy` is an ARRAY of `{ field, order }`
86+
// (SortNodeSchema). This double used to destructure
87+
// `{ field, direction }` off the array itself, so both names
88+
// read `undefined` — it spoke the `direction` vocabulary the
89+
// engine does not read (#4674) AND, because an array has no
90+
// `.field`, sorted nothing at all. Either way a test built on
91+
// it would have ratified the broken behaviour.
92+
const orderBy = (opts as any).orderBy;
93+
if (Array.isArray(orderBy) && orderBy.length > 0) {
8794
out.sort((a: any, b: any) => {
88-
const av = a[field]; const bv = b[field];
89-
if (av < bv) return direction === 'desc' ? 1 : -1;
90-
if (av > bv) return direction === 'desc' ? -1 : 1;
95+
for (const { field, order } of orderBy) {
96+
const av = a[field]; const bv = b[field];
97+
if (av < bv) return order === 'desc' ? 1 : -1;
98+
if (av > bv) return order === 'desc' ? -1 : 1;
99+
}
91100
return 0;
92101
});
93102
}

0 commit comments

Comments
 (0)