Skip to content

Commit a3ae817

Browse files
authored
Merge pull request #1772 from objectstack-ai/fix-sortnode-order-key
fix(data): sweep non-canonical sort key direction: → SortNode's order: (desc silently sorted ascending)
2 parents c802327 + f135080 commit a3ae817

13 files changed

Lines changed: 90 additions & 22 deletions

File tree

packages/cli/src/commands/explain.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ const SCHEMAS: Record<string, SchemaInfo> = {
199199
object: 'project_task',
200200
fields: ['title', 'status', 'assigned_to'],
201201
filters: [{ field: 'status', operator: 'eq', value: 'open' }],
202-
sort: [{ field: 'created_at', direction: 'desc' }],
202+
sort: [{ field: 'created_at', order: 'desc' }],
203203
limit: 50,
204204
}`,
205205
related: ['object', 'field', 'view'],

packages/cli/src/commands/generate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const ${toCamelCase(name)}ListView: UI.View = {
6262
columns: [
6363
{ field: 'name', width: 200 },
6464
],
65-
defaultSort: { field: 'name', direction: 'asc' },
65+
sort: [{ field: 'name', order: 'asc' }],
6666
pageSize: 25,
6767
},
6868
};

packages/plugins/plugin-reports/src/report-service.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,15 @@ function makeFakeEngine() {
2424
async find(object: string, options?: any) {
2525
const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
2626
if (options?.orderBy?.[0]) {
27-
const { field, direction } = options.orderBy[0];
27+
// Canonical SortNode key only (spec/data/query.zod.ts): the real
28+
// engine strips an unknown `direction:` key and defaults to asc, so
29+
// the mock must too — honoring both keys masks wrong-key sorts.
30+
const { field, order } = options.orderBy[0];
2831
rows.sort((a, b) => {
2932
const av = a[field]; const bv = b[field];
3033
if (av === bv) return 0;
3134
const cmp = av > bv ? 1 : -1;
32-
return direction === 'desc' ? -cmp : cmp;
35+
return order === 'desc' ? -cmp : cmp;
3336
});
3437
}
3538
return rows.slice(0, options?.limit ?? 1000);
@@ -163,6 +166,18 @@ describe('ReportService', () => {
163166
expect(leads[0].name).toBe('A');
164167
});
165168

169+
it('listReports: most recently updated first', async () => {
170+
// Regression: the query sorted with the non-canonical `direction: 'desc'`
171+
// key, which SortNode strips — so it sorted ascending (oldest first).
172+
engine._tables['sys_saved_report'] = [
173+
{ id: 'r_old', name: 'Old', object_name: 'lead', query_json: '{}', updated_at: '2026-01-01T00:00:00Z' },
174+
{ id: 'r_new', name: 'New', object_name: 'lead', query_json: '{}', updated_at: '2026-03-01T00:00:00Z' },
175+
{ id: 'r_mid', name: 'Mid', object_name: 'lead', query_json: '{}', updated_at: '2026-02-01T00:00:00Z' },
176+
];
177+
const rows = await svc.listReports({ object: 'lead' }, CTX);
178+
expect(rows.map(r => r.id)).toEqual(['r_new', 'r_mid', 'r_old']);
179+
});
180+
166181
it('getReport: returns null on miss', async () => {
167182
expect(await svc.getReport('nope', CTX)).toBeNull();
168183
});

packages/plugins/plugin-reports/src/report-service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ export class ReportService implements IReportService {
235235
if (filter?.object) f.object_name = filter.object;
236236
if (filter?.ownerId) f.owner_id = filter.ownerId;
237237
const rows = await this.engine.find('sys_saved_report', {
238-
filter: f, limit: 500, orderBy: [{ field: 'updated_at', direction: 'desc' }], context: SYSTEM_CTX,
238+
filter: f, limit: 500, orderBy: [{ field: 'updated_at', order: 'desc' }], context: SYSTEM_CTX,
239239
});
240240
return Array.isArray(rows) ? rows.map(rowFromSaved) : [];
241241
}
@@ -376,7 +376,7 @@ export class ReportService implements IReportService {
376376
const f: any = {};
377377
if (filter?.reportId) f.report_id = filter.reportId;
378378
const rows = await this.engine.find('sys_report_schedule', {
379-
filter: f, limit: 500, orderBy: [{ field: 'next_run_at', direction: 'asc' }], context: SYSTEM_CTX,
379+
filter: f, limit: 500, orderBy: [{ field: 'next_run_at', order: 'asc' }], context: SYSTEM_CTX,
380380
});
381381
return Array.isArray(rows) ? rows.map(rowFromSchedule) : [];
382382
}

packages/plugins/plugin-sharing/src/share-link-routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ export function registerShareLinkRoutes(
249249
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
250250
const rows = await engine.find('ai_messages', {
251251
where: { conversation_id: resolved.link.record_id },
252-
sort: [{ field: 'created_at', direction: 'asc' }],
252+
sort: [{ field: 'created_at', order: 'asc' }],
253253
limit: 500,
254254
context: SYSTEM_CTX,
255255
} as any);

packages/plugins/plugin-sharing/src/sharing-rule-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export class SharingRuleService implements ISharingRuleService {
149149
if (orgId) where.organization_id = orgId;
150150
const rows = await this.engine.find('sys_sharing_rule', {
151151
filter: where,
152-
orderBy: [{ field: 'name', direction: 'asc' }],
152+
orderBy: [{ field: 'name', order: 'asc' }],
153153
limit: 1000,
154154
context: SYSTEM_CTX,
155155
});

packages/plugins/plugin-sharing/src/sharing-service.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,20 @@ function makeFakeEngine(schemas: Record<string, any>) {
4242
async find(object: string, options?: any) {
4343
const table = ensure(object);
4444
const filter = options?.filter ?? options?.where;
45-
return table.filter(r => matches(r, filter)).slice(0, options?.limit ?? 1000);
45+
let out = table.filter(r => matches(r, filter));
46+
if (options?.orderBy?.[0]) {
47+
// Canonical SortNode key only (spec/data/query.zod.ts): the real
48+
// engine strips an unknown `direction:` key and defaults to asc, so
49+
// the mock must too — honoring both keys masks wrong-key sorts.
50+
const { field, order } = options.orderBy[0];
51+
out = [...out].sort((a, b) => {
52+
const av = a[field]; const bv = b[field];
53+
if (av === bv) return 0;
54+
const cmp = av > bv ? 1 : -1;
55+
return order === 'desc' ? -cmp : cmp;
56+
});
57+
}
58+
return out.slice(0, options?.limit ?? 1000);
4659
},
4760
async insert(object: string, data: any) {
4861
const row = { ...data };
@@ -236,6 +249,17 @@ describe('SharingService.grant / listShares / revoke', () => {
236249
expect(rows.length).toBe(2);
237250
});
238251

252+
it('listShares returns the newest grant first', async () => {
253+
// Regression: the query sorted with the non-canonical `direction: 'desc'`
254+
// key, which SortNode strips — so it sorted ascending (oldest first).
255+
engine._tables.sys_record_share = [
256+
{ id: 'shr_old', object_name: 'account', record_id: 'a1', recipient_id: 'bob', created_at: '2026-01-01T00:00:00Z' },
257+
{ id: 'shr_new', object_name: 'account', record_id: 'a1', recipient_id: 'carol', created_at: '2026-02-01T00:00:00Z' },
258+
];
259+
const rows = await svc.listShares('account', 'a1', { userId: 'admin' });
260+
expect(rows.map(r => r.id)).toEqual(['shr_new', 'shr_old']);
261+
});
262+
239263
it('revoke removes the row', async () => {
240264
const r = await svc.grant({ object: 'account', recordId: 'a1', recipientId: 'bob' }, { userId: 'admin' });
241265
await svc.revoke(r.id, { userId: 'admin' });

packages/plugins/plugin-sharing/src/sharing-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ export class SharingService implements ISharingService {
266266
): Promise<RecordShare[]> {
267267
const rows = await this.engine.find('sys_record_share', {
268268
filter: { object_name: object, record_id: recordId },
269-
orderBy: [{ field: 'created_at', direction: 'desc' }],
269+
orderBy: [{ field: 'created_at', order: 'desc' }],
270270
limit: 500,
271271
context: SYSTEM_CTX,
272272
});

packages/services/service-job/src/db-job-adapter.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ function makeFakeEngine() {
1313
? t.filter((r) => Object.entries(opts.where).every(([k, v]) => r[k] === v))
1414
: [...t];
1515
if (opts.orderBy) {
16-
for (const order of [...opts.orderBy].reverse()) {
16+
for (const ord of [...opts.orderBy].reverse()) {
17+
// Canonical SortNode key only (spec/data/query.zod.ts): the real
18+
// engine strips an unknown `direction:` key and defaults to asc,
19+
// so the mock must too — honoring both keys masks wrong-key sorts.
1720
out.sort((a, b) => {
18-
const av = a[order.field], bv = b[order.field];
21+
const av = a[ord.field], bv = b[ord.field];
1922
if (av === bv) return 0;
2023
const cmp = av > bv ? 1 : -1;
21-
return order.direction === 'desc' ? -cmp : cmp;
24+
return ord.order === 'desc' ? -cmp : cmp;
2225
});
2326
}
2427
}
@@ -118,6 +121,18 @@ describe('DbJobAdapter', () => {
118121
expect(ok[0].jobId).toBe('mix');
119122
});
120123

124+
it('listExecutionsByStatus returns the newest run first', async () => {
125+
// Regression: the query sorted with the non-canonical `direction: 'desc'`
126+
// key, which SortNode strips — so "latest run" returned the OLDEST run.
127+
engine.tables.set('sys_job_run', [
128+
{ id: '1', job_name: 'first', status: 'success', started_at: '2026-01-01T00:00:00Z' },
129+
{ id: '2', job_name: 'third', status: 'success', started_at: '2026-03-01T00:00:00Z' },
130+
{ id: '3', job_name: 'second', status: 'success', started_at: '2026-02-01T00:00:00Z' },
131+
]);
132+
const runs = await adapter.listExecutionsByStatus('success');
133+
expect(runs.map((r) => r.jobId)).toEqual(['third', 'second', 'first']);
134+
});
135+
121136
it('replay tags a synthetic run as replay trigger', async () => {
122137
await adapter.schedule('rj', { type: 'cron', expression: '* * * * *' }, async () => {});
123138
await adapter.replay('rj');

packages/services/service-job/src/db-job-adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export class DbJobAdapter implements IJobService {
138138
const rows = await this.engine.find(RUN_TABLE, {
139139
where: { status },
140140
limit: limit ?? 50,
141-
orderBy: [{ field: 'started_at', direction: 'desc' }],
141+
orderBy: [{ field: 'started_at', order: 'desc' }],
142142
context: SYSTEM_CTX,
143143
});
144144
return (rows ?? []).map((r: any) => ({

0 commit comments

Comments
 (0)