Skip to content

Commit 696d3c2

Browse files
committed
Merge origin/main into claude/issue-4714-driver-axis-pagination-filter
2 parents 1a651dd + 69b509f commit 696d3c2

4 files changed

Lines changed: 179 additions & 10 deletions

File tree

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` 就是那条被执行的渠道。
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)