diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 5f2e9bb236..59d3d2bf12 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -199,7 +199,7 @@ const SCHEMAS: Record = { object: 'project_task', fields: ['title', 'status', 'assigned_to'], filters: [{ field: 'status', operator: 'eq', value: 'open' }], - sort: [{ field: 'created_at', direction: 'desc' }], + sort: [{ field: 'created_at', order: 'desc' }], limit: 50, }`, related: ['object', 'field', 'view'], diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index df67d64583..78b2ed31d7 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -62,7 +62,7 @@ const ${toCamelCase(name)}ListView: UI.View = { columns: [ { field: 'name', width: 200 }, ], - defaultSort: { field: 'name', direction: 'asc' }, + sort: [{ field: 'name', order: 'asc' }], pageSize: 25, }, }; diff --git a/packages/plugins/plugin-reports/src/report-service.test.ts b/packages/plugins/plugin-reports/src/report-service.test.ts index 6ecc5ec3db..5f28299eb2 100644 --- a/packages/plugins/plugin-reports/src/report-service.test.ts +++ b/packages/plugins/plugin-reports/src/report-service.test.ts @@ -24,12 +24,15 @@ function makeFakeEngine() { async find(object: string, options?: any) { const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where)); if (options?.orderBy?.[0]) { - const { field, direction } = options.orderBy[0]; + // Canonical SortNode key only (spec/data/query.zod.ts): the real + // engine strips an unknown `direction:` key and defaults to asc, so + // the mock must too — honoring both keys masks wrong-key sorts. + const { field, order } = options.orderBy[0]; rows.sort((a, b) => { const av = a[field]; const bv = b[field]; if (av === bv) return 0; const cmp = av > bv ? 1 : -1; - return direction === 'desc' ? -cmp : cmp; + return order === 'desc' ? -cmp : cmp; }); } return rows.slice(0, options?.limit ?? 1000); @@ -163,6 +166,18 @@ describe('ReportService', () => { expect(leads[0].name).toBe('A'); }); + it('listReports: most recently updated first', async () => { + // Regression: the query sorted with the non-canonical `direction: 'desc'` + // key, which SortNode strips — so it sorted ascending (oldest first). + engine._tables['sys_saved_report'] = [ + { id: 'r_old', name: 'Old', object_name: 'lead', query_json: '{}', updated_at: '2026-01-01T00:00:00Z' }, + { id: 'r_new', name: 'New', object_name: 'lead', query_json: '{}', updated_at: '2026-03-01T00:00:00Z' }, + { id: 'r_mid', name: 'Mid', object_name: 'lead', query_json: '{}', updated_at: '2026-02-01T00:00:00Z' }, + ]; + const rows = await svc.listReports({ object: 'lead' }, CTX); + expect(rows.map(r => r.id)).toEqual(['r_new', 'r_mid', 'r_old']); + }); + it('getReport: returns null on miss', async () => { expect(await svc.getReport('nope', CTX)).toBeNull(); }); diff --git a/packages/plugins/plugin-reports/src/report-service.ts b/packages/plugins/plugin-reports/src/report-service.ts index 84b4de3c2f..a9aa24eb9c 100644 --- a/packages/plugins/plugin-reports/src/report-service.ts +++ b/packages/plugins/plugin-reports/src/report-service.ts @@ -235,7 +235,7 @@ export class ReportService implements IReportService { if (filter?.object) f.object_name = filter.object; if (filter?.ownerId) f.owner_id = filter.ownerId; const rows = await this.engine.find('sys_saved_report', { - filter: f, limit: 500, orderBy: [{ field: 'updated_at', direction: 'desc' }], context: SYSTEM_CTX, + filter: f, limit: 500, orderBy: [{ field: 'updated_at', order: 'desc' }], context: SYSTEM_CTX, }); return Array.isArray(rows) ? rows.map(rowFromSaved) : []; } @@ -376,7 +376,7 @@ export class ReportService implements IReportService { const f: any = {}; if (filter?.reportId) f.report_id = filter.reportId; const rows = await this.engine.find('sys_report_schedule', { - filter: f, limit: 500, orderBy: [{ field: 'next_run_at', direction: 'asc' }], context: SYSTEM_CTX, + filter: f, limit: 500, orderBy: [{ field: 'next_run_at', order: 'asc' }], context: SYSTEM_CTX, }); return Array.isArray(rows) ? rows.map(rowFromSchedule) : []; } diff --git a/packages/plugins/plugin-sharing/src/share-link-routes.ts b/packages/plugins/plugin-sharing/src/share-link-routes.ts index 10c0e23df4..dc70006fd2 100644 --- a/packages/plugins/plugin-sharing/src/share-link-routes.ts +++ b/packages/plugins/plugin-sharing/src/share-link-routes.ts @@ -249,7 +249,7 @@ export function registerShareLinkRoutes( const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; const rows = await engine.find('ai_messages', { where: { conversation_id: resolved.link.record_id }, - sort: [{ field: 'created_at', direction: 'asc' }], + sort: [{ field: 'created_at', order: 'asc' }], limit: 500, context: SYSTEM_CTX, } as any); diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 3ec5ce1013..90e2b87a75 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -149,7 +149,7 @@ export class SharingRuleService implements ISharingRuleService { if (orgId) where.organization_id = orgId; const rows = await this.engine.find('sys_sharing_rule', { filter: where, - orderBy: [{ field: 'name', direction: 'asc' }], + orderBy: [{ field: 'name', order: 'asc' }], limit: 1000, context: SYSTEM_CTX, }); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.test.ts b/packages/plugins/plugin-sharing/src/sharing-service.test.ts index be719ed923..a785cd22ab 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.test.ts @@ -42,7 +42,20 @@ function makeFakeEngine(schemas: Record) { async find(object: string, options?: any) { const table = ensure(object); const filter = options?.filter ?? options?.where; - return table.filter(r => matches(r, filter)).slice(0, options?.limit ?? 1000); + let out = table.filter(r => matches(r, filter)); + if (options?.orderBy?.[0]) { + // Canonical SortNode key only (spec/data/query.zod.ts): the real + // engine strips an unknown `direction:` key and defaults to asc, so + // the mock must too — honoring both keys masks wrong-key sorts. + const { field, order } = options.orderBy[0]; + out = [...out].sort((a, b) => { + const av = a[field]; const bv = b[field]; + if (av === bv) return 0; + const cmp = av > bv ? 1 : -1; + return order === 'desc' ? -cmp : cmp; + }); + } + return out.slice(0, options?.limit ?? 1000); }, async insert(object: string, data: any) { const row = { ...data }; @@ -236,6 +249,17 @@ describe('SharingService.grant / listShares / revoke', () => { expect(rows.length).toBe(2); }); + it('listShares returns the newest grant first', async () => { + // Regression: the query sorted with the non-canonical `direction: 'desc'` + // key, which SortNode strips — so it sorted ascending (oldest first). + engine._tables.sys_record_share = [ + { id: 'shr_old', object_name: 'account', record_id: 'a1', recipient_id: 'bob', created_at: '2026-01-01T00:00:00Z' }, + { id: 'shr_new', object_name: 'account', record_id: 'a1', recipient_id: 'carol', created_at: '2026-02-01T00:00:00Z' }, + ]; + const rows = await svc.listShares('account', 'a1', { userId: 'admin' }); + expect(rows.map(r => r.id)).toEqual(['shr_new', 'shr_old']); + }); + it('revoke removes the row', async () => { const r = await svc.grant({ object: 'account', recordId: 'a1', recipientId: 'bob' }, { userId: 'admin' }); await svc.revoke(r.id, { userId: 'admin' }); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index bcf255f393..b5124b4853 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -266,7 +266,7 @@ export class SharingService implements ISharingService { ): Promise { const rows = await this.engine.find('sys_record_share', { filter: { object_name: object, record_id: recordId }, - orderBy: [{ field: 'created_at', direction: 'desc' }], + orderBy: [{ field: 'created_at', order: 'desc' }], limit: 500, context: SYSTEM_CTX, }); diff --git a/packages/services/service-job/src/db-job-adapter.test.ts b/packages/services/service-job/src/db-job-adapter.test.ts index 7a148600e1..9ef48eec50 100644 --- a/packages/services/service-job/src/db-job-adapter.test.ts +++ b/packages/services/service-job/src/db-job-adapter.test.ts @@ -13,12 +13,15 @@ function makeFakeEngine() { ? t.filter((r) => Object.entries(opts.where).every(([k, v]) => r[k] === v)) : [...t]; if (opts.orderBy) { - for (const order of [...opts.orderBy].reverse()) { + for (const ord of [...opts.orderBy].reverse()) { + // Canonical SortNode key only (spec/data/query.zod.ts): the real + // engine strips an unknown `direction:` key and defaults to asc, + // so the mock must too — honoring both keys masks wrong-key sorts. out.sort((a, b) => { - const av = a[order.field], bv = b[order.field]; + const av = a[ord.field], bv = b[ord.field]; if (av === bv) return 0; const cmp = av > bv ? 1 : -1; - return order.direction === 'desc' ? -cmp : cmp; + return ord.order === 'desc' ? -cmp : cmp; }); } } @@ -118,6 +121,18 @@ describe('DbJobAdapter', () => { expect(ok[0].jobId).toBe('mix'); }); + it('listExecutionsByStatus returns the newest run first', async () => { + // Regression: the query sorted with the non-canonical `direction: 'desc'` + // key, which SortNode strips — so "latest run" returned the OLDEST run. + engine.tables.set('sys_job_run', [ + { id: '1', job_name: 'first', status: 'success', started_at: '2026-01-01T00:00:00Z' }, + { id: '2', job_name: 'third', status: 'success', started_at: '2026-03-01T00:00:00Z' }, + { id: '3', job_name: 'second', status: 'success', started_at: '2026-02-01T00:00:00Z' }, + ]); + const runs = await adapter.listExecutionsByStatus('success'); + expect(runs.map((r) => r.jobId)).toEqual(['third', 'second', 'first']); + }); + it('replay tags a synthetic run as replay trigger', async () => { await adapter.schedule('rj', { type: 'cron', expression: '* * * * *' }, async () => {}); await adapter.replay('rj'); diff --git a/packages/services/service-job/src/db-job-adapter.ts b/packages/services/service-job/src/db-job-adapter.ts index 0d6588da14..b311b5ebcd 100644 --- a/packages/services/service-job/src/db-job-adapter.ts +++ b/packages/services/service-job/src/db-job-adapter.ts @@ -138,7 +138,7 @@ export class DbJobAdapter implements IJobService { const rows = await this.engine.find(RUN_TABLE, { where: { status }, limit: limit ?? 50, - orderBy: [{ field: 'started_at', direction: 'desc' }], + orderBy: [{ field: 'started_at', order: 'desc' }], context: SYSTEM_CTX, }); return (rows ?? []).map((r: any) => ({ diff --git a/packages/services/service-queue/src/db-queue-adapter.test.ts b/packages/services/service-queue/src/db-queue-adapter.test.ts index c2b44e3e8f..d5125e0987 100644 --- a/packages/services/service-queue/src/db-queue-adapter.test.ts +++ b/packages/services/service-queue/src/db-queue-adapter.test.ts @@ -25,12 +25,15 @@ function makeFakeEngine() { const t = tables.get(table) ?? []; let out = opts.where ? t.filter((r) => matches(r, opts.where)) : [...t]; if (opts.orderBy) { - for (const order of [...opts.orderBy].reverse()) { + for (const ord of [...opts.orderBy].reverse()) { + // Canonical SortNode key only (spec/data/query.zod.ts): the real + // engine strips an unknown `direction:` key and defaults to asc, + // so the mock must too — honoring both keys masks wrong-key sorts. out.sort((a, b) => { - const av = a[order.field], bv = b[order.field]; + const av = a[ord.field], bv = b[ord.field]; if (av === bv) return 0; const cmp = av > bv ? 1 : -1; - return order.direction === 'desc' ? -cmp : cmp; + return ord.order === 'desc' ? -cmp : cmp; }); } } @@ -164,6 +167,17 @@ describe('DbQueueAdapter', () => { expect(failed[0].lastError).toContain('x'); }); + it('listFailed returns the newest message first', async () => { + // Regression: the query sorted with the non-canonical `direction: 'desc'` + // key, which SortNode strips — so it sorted ascending (oldest first). + engine.tables.set('sys_job_queue', [ + { id: 'm_old', queue: 'q', status: 'dlq', payload_json: '{}', created_at: '2026-01-01T00:00:00Z' }, + { id: 'm_new', queue: 'q', status: 'dlq', payload_json: '{}', created_at: '2026-02-01T00:00:00Z' }, + ]); + const failed = await adapter.listFailed('q'); + expect(failed.map((f) => f.id)).toEqual(['m_new', 'm_old']); + }); + it('replay resets dlq message back to pending and re-processes', async () => { let attempts = 0; await adapter.subscribe('replay-q', async () => { diff --git a/packages/services/service-queue/src/db-queue-adapter.ts b/packages/services/service-queue/src/db-queue-adapter.ts index f140e5e83c..32a22d50db 100644 --- a/packages/services/service-queue/src/db-queue-adapter.ts +++ b/packages/services/service-queue/src/db-queue-adapter.ts @@ -189,7 +189,7 @@ export class DbQueueAdapter implements IQueueService { where, limit: options?.limit ?? 100, offset: options?.offset, - orderBy: [{ field: 'created_at', direction: 'desc' }], + orderBy: [{ field: 'created_at', order: 'desc' }], context: SYSTEM_CTX, }); return (rows ?? []).map((r: any) => this.rowToRecord(r)); @@ -265,8 +265,8 @@ export class DbQueueAdapter implements IQueueService { where: { queue, status: 'pending' }, limit: max * 3, // over-fetch in case of CAS contention orderBy: [ - { field: 'priority', direction: 'asc' }, - { field: 'scheduled_for', direction: 'asc' }, + { field: 'priority', order: 'asc' }, + { field: 'scheduled_for', order: 'asc' }, ], context: SYSTEM_CTX, }); diff --git a/packages/spec/src/benchmark.bench.ts b/packages/spec/src/benchmark.bench.ts index 76208642ff..d6c7b7c241 100644 --- a/packages/spec/src/benchmark.bench.ts +++ b/packages/spec/src/benchmark.bench.ts @@ -42,7 +42,7 @@ describe('Data Domain — Schema Parse Performance', () => { object: 'benchmark_object', fields: ['name', 'status', 'priority'], filters: [['status', '=', 'active'], 'and', ['priority', '>', 0]], - sort: [{ field: 'priority', direction: 'desc' }], + orderBy: [{ field: 'priority', order: 'desc' }], top: 25, skip: 0, }; @@ -82,7 +82,7 @@ describe('UI Domain — Schema Parse Performance', () => { { field: 'name', width: 200 }, { field: 'status', width: 100 }, ], - defaultSort: { field: 'name', direction: 'asc' }, + sort: [{ field: 'name', order: 'asc' }], pageSize: 25, }, };