Skip to content

Commit b5f9397

Browse files
os-zhuangclaude
andauthored
fix(sharing,runtime): a sort passed straight to the engine never ordered anything (#4346) (#4370)
Sweeping every in-repo engine call site that still spoke a deprecated alias turned up three that were not cosmetic. #4346 made the engine fold `filter`→`where` and `top`→`limit` on all six methods. The remaining four pairs in RPC_QUERY_ALIAS_SLOTS (select, sort, skip, populate) fold at the RPC/wire layer only — their values need shape lowering that belongs to those layers — and a DIRECT engine.find() never crosses that layer. Three call sites passed `sort` there, so it rode onto the AST untouched, every driver's `Array.isArray(query.orderBy)` guard declined to emit an ORDER BY, and the read returned an ordinary-looking, arbitrarily ordered result: share-link-routes.ts shared AI conversation messages, created_at asc runtime/domains/share-links.ts the same route, runtime-domain copy share-link-service.ts listLinks: "the 200 most recent" share links Each pairs the dropped sort with a limit — the "latest N" shape whose failure #4226 spelled out, one layer below the normalizer #4226 fixed. listLinks had no test at all, which is why it went unnoticed; it is pinned now on the option bag the engine RECEIVES, not on row order, because the failure is that the key never becomes `orderBy` and a double honouring either spelling passes either way. Verified the pin fails against the pre-fix line before keeping it. The other 27 sites are strict no-ops since #4346 folds `filter`: approvals 5, auth 2, reports 6, sharing 11, webhooks 2, plus a spec doc example teaching `filters` (a wire-only alias the engine does not fold at all, so the example taught a call that matches every row). Renaming them stops the framework depending on a spelling it asks users to migrate off. Service-level `filter` PARAMETERS — each service's own public API — are deliberately untouched. One test double had to move with them: approver-org-scope's fake engine read `opts.filter`, so it kept passing only because both sides shared the deprecated dialect. That is the mechanism that let the whole class persist. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cc60165 commit b5f9397

16 files changed

Lines changed: 116 additions & 33 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
"@objectstack/runtime": patch
4+
"@objectstack/plugin-approvals": patch
5+
"@objectstack/plugin-auth": patch
6+
"@objectstack/plugin-reports": patch
7+
"@objectstack/plugin-webhooks": patch
8+
"@objectstack/spec": patch
9+
---
10+
11+
fix(sharing,runtime): a `sort` passed straight to the engine never ordered anything; migrate every in-repo engine call to canonical QueryAST keys (#4346)
12+
13+
Two changes with different weights, from one sweep of every in-repo engine
14+
call site that still speaks a deprecated alias.
15+
16+
**The bug — three dropped sorts.** #4346 made the engine fold `filter``where`
17+
and `top``limit` on all six methods. The other four pairs in
18+
`RPC_QUERY_ALIAS_SLOTS` (`select`, `sort`, `skip`, `populate`) are folded at
19+
the RPC/wire layer only — their values need shape lowering that belongs to
20+
those layers — and a **direct `engine.find()` never crosses that layer**. Three
21+
call sites passed `sort` there, so it rode onto the AST untouched, every
22+
driver's `Array.isArray(query.orderBy)` guard declined to emit an ORDER BY, and
23+
the query returned an ordinary-looking, arbitrarily-ordered result:
24+
25+
| call site | asked for | actually got |
26+
|---|---|---|
27+
| `share-link-routes.ts` | shared AI conversation messages, `created_at asc` | messages in arbitrary order |
28+
| `runtime/domains/share-links.ts` | same route, runtime-domain copy | same |
29+
| `share-link-service.ts` `listLinks` | the 200 most recent share links | an arbitrary 200 |
30+
31+
All three combine the dropped sort with a `limit` — the "latest N" shape whose
32+
failure #4226 spelled out: an unapplied sort returns rows in arbitrary order,
33+
which `limit` then slices into an arbitrary page. #4226 fixed that in the wire
34+
normalizer; these calls sit one layer below it. `listLinks` had no test at all,
35+
which is why it went unnoticed. Now pinned — on the option bag the engine
36+
receives, not on row order, because the failure is that the key never becomes
37+
`orderBy` and a fake engine honouring either spelling would pass either way.
38+
39+
**The cleanup — 27 no-op renames.** Every remaining in-repo engine call passing
40+
`filter` now passes `where` (approvals 5, auth 2, reports 6, sharing 11,
41+
webhooks 2, plus the one `filters` in a spec doc example). These are strict
42+
no-ops since #4346 folds the alias — the point is that the framework stops
43+
depending on a spelling it asks users to migrate off, which is a prerequisite
44+
for ever retiring the aliases. Service-level `filter` PARAMETERS (each
45+
service's own public API, e.g. `listRequests(filter)`) are deliberately
46+
untouched — those are not engine option bags.
47+
48+
Two of the renamed calls were live victims of the #4346 bug rather than
49+
cosmetic: `auth-manager`'s `stampIdentitySource` read the table's first row via
50+
`findOne({filter})` and counted the whole table via `count({filter})`, so a
51+
federated sign-in never stamped `source: 'idp_provisioned'`. #4346 already
52+
corrected the behaviour; this makes the call say what it means.

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,7 +1053,7 @@ export class ApprovalService implements IApprovalService {
10531053
let rows: any[] = [];
10541054
try {
10551055
rows = await this.engine.find('sys_team_member', {
1056-
filter: { team_id: teamId },
1056+
where: { team_id: teamId },
10571057
fields: ['user_id'],
10581058
limit: 10000,
10591059
context: SYSTEM_CTX,
@@ -1096,7 +1096,7 @@ export class ApprovalService implements IApprovalService {
10961096
// Seed sanity check: skip if dept doesn't exist or is inactive within tenant.
10971097
try {
10981098
const seed = await this.engine.find('sys_business_unit', {
1099-
filter: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
1099+
where: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
11001100
fields: ['id', 'active'],
11011101
limit: 1,
11021102
context: SYSTEM_CTX,
@@ -1125,7 +1125,7 @@ export class ApprovalService implements IApprovalService {
11251125
let rows: any[] = [];
11261126
try {
11271127
rows = await this.engine.find('sys_business_unit_member', {
1128-
filter: { business_unit_id: { $in: Array.from(seen) } },
1128+
where: { business_unit_id: { $in: Array.from(seen) } },
11291129
fields: ['user_id'],
11301130
limit: 10000,
11311131
context: SYSTEM_CTX,
@@ -1184,7 +1184,7 @@ export class ApprovalService implements IApprovalService {
11841184
private async lookupManager(userId: string): Promise<string | null> {
11851185
try {
11861186
const rows = await this.engine.find('sys_user', {
1187-
filter: { id: userId }, fields: ['id', 'manager_id'], limit: 1, context: SYSTEM_CTX,
1187+
where: { id: userId }, fields: ['id', 'manager_id'], limit: 1, context: SYSTEM_CTX,
11881188
} as any);
11891189
const row: any = Array.isArray(rows) ? rows[0] : null;
11901190
return row?.manager_id ? String(row.manager_id) : null;
@@ -1240,7 +1240,7 @@ export class ApprovalService implements IApprovalService {
12401240
let rows: any[] = [];
12411241
try {
12421242
rows = await this.engine.find('sys_approval_delegation', {
1243-
filter: { delegator_id: delegatorId },
1243+
where: { delegator_id: delegatorId },
12441244
fields: ['id', 'delegator_id', 'delegate_id', 'valid_from', 'valid_until', 'reason', 'organization_id'],
12451245
limit: 50,
12461246
context: SYSTEM_CTX,

packages/plugins/plugin-approvals/src/approver-org-scope.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ function makeDeps(over: Partial<ApproverOrgScopeDeps> & { members?: Array<{ user
3535
const members = over.members ?? [];
3636
const engine = {
3737
find: vi.fn(async (object: string, opts: any) => {
38-
const f = opts?.filter ?? {};
38+
// Reads the CANONICAL key, like the engine's own post-fold AST. This
39+
// double used to read `opts.filter`, which is why it kept passing while
40+
// production spoke the deprecated spelling: both sides shared one
41+
// dialect, so nothing forced the migration (#4346).
42+
const f = opts?.where ?? {};
3943
if (object === 'sys_organization') {
4044
const rows = Object.values(ORGS).filter((o) =>
4145
(f.id === undefined || o.id === f.id) && (f.slug === undefined || o.slug === f.slug));
@@ -151,7 +155,7 @@ describe('resolveApproverDirectoryOrg — the guards', () => {
151155
const deps = makeDeps();
152156
(deps.engine.find as any) = vi.fn(async (object: string, opts: any) => {
153157
if (object !== 'sys_organization') return [];
154-
const id = opts?.filter?.id;
158+
const id = opts?.where?.id;
155159
// a → b → a
156160
if (id === 'a') return [{ id: 'a', slug: 'a', parent_organization_id: 'b' }];
157161
if (id === 'b') return [{ id: 'b', slug: 'b', parent_organization_id: 'a' }];

packages/plugins/plugin-approvals/src/approver-org-scope.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async function findOrg(
6262
): Promise<any | null> {
6363
try {
6464
const rows = await engine.find('sys_organization', {
65-
filter: where,
65+
where,
6666
fields: ['id', 'slug', 'parent_organization_id'],
6767
limit: 1,
6868
context: SYSTEM_CTX,
@@ -230,7 +230,7 @@ export async function filterApproversWhoCanRead(
230230
let members: any[] = [];
231231
try {
232232
members = await deps.engine.find('sys_member', {
233-
filter: { organization_id: requestOrg, user_id: { $in: userIds } },
233+
where: { organization_id: requestOrg, user_id: { $in: userIds } },
234234
fields: ['user_id'],
235235
limit: 10000,
236236
context: SYSTEM_CTX,

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3481,7 +3481,7 @@ export class AuthManager {
34813481
// Gained a local password → env-native. Only write if currently
34823482
// managed (avoids a no-op history row on every local signup).
34833483
const u = await engine.findOne('sys_user', {
3484-
filter: { id: userId }, fields: ['id', 'source'], context: SYSTEM_CTX,
3484+
where: { id: userId }, fields: ['id', 'source'], context: SYSTEM_CTX,
34853485
} as any);
34863486
if (u && u.source === 'idp_provisioned') {
34873487
await engine.update('sys_user', { id: userId, source: 'env_native' }, { context: SYSTEM_CTX } as any);
@@ -3491,7 +3491,7 @@ export class AuthManager {
34913491

34923492
// Federated link → managed, unless a local credential already exists.
34933493
const credentialCount = await engine.count('sys_account', {
3494-
filter: { user_id: userId, provider_id: 'credential' },
3494+
where: { user_id: userId, provider_id: 'credential' },
34953495
context: SYSTEM_CTX,
34963496
} as any);
34973497
if (typeof credentialCount === 'number' && credentialCount > 0) return;

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ export class ReportService implements IReportService {
307307
/** Raw metadata read of a saved report by id (no authz — callers gate). */
308308
private async loadReportRow(reportId: string): Promise<any | null> {
309309
const rows = await this.engine.find('sys_saved_report', {
310-
filter: { id: reportId }, limit: 1, context: SYSTEM_CTX,
310+
where: { id: reportId }, limit: 1, context: SYSTEM_CTX,
311311
});
312312
return Array.isArray(rows) && rows[0] ? rows[0] : null;
313313
}
@@ -374,7 +374,7 @@ export class ReportService implements IReportService {
374374
f.owner_id = context.userId;
375375
}
376376
const rows = await this.engine.find('sys_saved_report', {
377-
filter: f, limit: 500, orderBy: [{ field: 'updated_at', order: 'desc' }], context: SYSTEM_CTX,
377+
where: f, limit: 500, orderBy: [{ field: 'updated_at', order: 'desc' }], context: SYSTEM_CTX,
378378
});
379379
return Array.isArray(rows) ? rows.map(rowFromSaved) : [];
380380
}
@@ -397,7 +397,7 @@ export class ReportService implements IReportService {
397397
}
398398
// Cascade — drop attached schedules first.
399399
const schedules = await this.engine.find('sys_report_schedule', {
400-
filter: { report_id: reportId }, limit: 500, context: SYSTEM_CTX,
400+
where: { report_id: reportId }, limit: 500, context: SYSTEM_CTX,
401401
});
402402
for (const s of (schedules ?? [])) {
403403
await this.engine.delete('sys_report_schedule', { where: { id: (s as any).id }, context: SYSTEM_CTX });
@@ -437,7 +437,7 @@ export class ReportService implements IReportService {
437437
const q = report.query ?? {};
438438
const limit = Math.min(q.limit ?? DEFAULT_LIMIT, this.maxRows);
439439
const rows = await this.engine.find(report.object_name, {
440-
filter: q.filter,
440+
where: q.filter,
441441
fields: q.fields,
442442
orderBy: q.orderBy,
443443
limit,
@@ -544,7 +544,7 @@ export class ReportService implements IReportService {
544544
const f: any = {};
545545
if (filter?.reportId) f.report_id = filter.reportId;
546546
const rows = await this.engine.find('sys_report_schedule', {
547-
filter: f, limit: 500, orderBy: [{ field: 'next_run_at', order: 'asc' }], context: SYSTEM_CTX,
547+
where: f, limit: 500, orderBy: [{ field: 'next_run_at', order: 'asc' }], context: SYSTEM_CTX,
548548
});
549549
return Array.isArray(rows) ? rows.map(rowFromSchedule) : [];
550550
}
@@ -554,7 +554,7 @@ export class ReportService implements IReportService {
554554
async dispatchDue(now?: Date): Promise<{ fired: number; failed: number; skipped: number }> {
555555
const ts = (now ?? this.clock.now()).toISOString();
556556
const due = await this.engine.find('sys_report_schedule', {
557-
filter: { active: true },
557+
where: { active: true },
558558
limit: 200,
559559
context: SYSTEM_CTX,
560560
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ export function registerShareLinkRoutes(
301301
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
302302
const rows = await engine.find('ai_messages', {
303303
where: { conversation_id: resolved.link.record_id },
304-
sort: [{ field: 'created_at', order: 'asc' }],
304+
orderBy: [{ field: 'created_at', order: 'asc' }],
305305
limit: 500,
306306
context: SYSTEM_CTX,
307307
} as any);

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,33 @@ describe('ShareLinkService', () => {
8989
expect(engine._tables.sys_share_link).toHaveLength(1);
9090
});
9191

92+
// [#4346 follow-up] `listLinks` asked for "the 200 most recent links" with a
93+
// `sort` key. The ENGINE folds only `where`/`filter` and `limit`/`top`
94+
// (`RPC_QUERY_ALIAS_SLOTS`); `sort`→`orderBy` is folded at the RPC/wire layer,
95+
// which a direct `engine.find()` never crosses. So `sort` rode onto the AST
96+
// untouched, every driver's `Array.isArray(query.orderBy)` guard declined to
97+
// emit an ORDER BY, and the "most recent 200" was an ARBITRARY 200 — with a
98+
// perfectly ordinary-looking result over it (the #4226 failure mode, one
99+
// layer below the normalizer #4226 fixed).
100+
//
101+
// Asserted on the OPTION BAG rather than on row order, because the failure is
102+
// that the key never becomes `orderBy` — a fake engine that sorts by either
103+
// spelling would pass while the real one drops it.
104+
it('listLinks asks the engine for its ordering under the canonical key', async () => {
105+
const seen: any[] = [];
106+
const recording = {
107+
...engine,
108+
async find(object: string, options?: any) { seen.push(options); return engine.find(object, options); },
109+
};
110+
const svc = new ShareLinkService({ engine: recording as any });
111+
await svc.listLinks({}, { isSystem: true });
112+
113+
expect(seen).toHaveLength(1);
114+
expect(seen[0].orderBy, 'a `sort` key here is silently dropped by every driver')
115+
.toEqual([{ field: 'created_at', order: 'desc' }]);
116+
expect('sort' in seen[0], 'the deprecated spelling must not ride along').toBe(false);
117+
});
118+
92119
it('rejects objects that did not opt in', async () => {
93120
await expect(
94121
service.createLink(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ export class ShareLinkService implements IShareLinkService {
358358
const rows = await this.engine.find('sys_share_link', {
359359
where,
360360
limit: 200,
361-
sort: [{ field: 'created_at', order: 'desc' }],
361+
orderBy: [{ field: 'created_at', order: 'desc' }],
362362
context: context.isSystem ? SYSTEM_CTX : context,
363363
} as any);
364364
return Array.isArray(rows) ? (rows as ShareLink[]) : [];

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export function bindRuleProvenanceStamp(engine: MinimalEngine, logger?: MinimalL
5757
// current row ourselves (system ctx: this is a provenance check, not
5858
// an authorization decision).
5959
const rows = await engine.find('sys_sharing_rule', {
60-
filter: { id },
60+
where: { id },
6161
fields: ['id', 'managed_by', 'customized'],
6262
limit: 1,
6363
context: SYSTEM_CTX,

0 commit comments

Comments
 (0)