Skip to content

Commit ec3dfd7

Browse files
fix(client): derive data.find's canonical-key predicate from QueryOptionsV2 (#6322) (#6480)
`data.find()` picked its canonical-vs-legacy branch with a hand-written condition naming four keys. That condition restated what `QueryOptionsV2` declares, and had fallen behind it twice: `limit` was absent, so `find('task', { limit: 20 })` fell to the legacy branch and reached the server with an empty query string (default page size, HTTP 200, no warning), while its twin `{ offset: 5 }` worked; and `expand` was absent AND unmapped on both branches, so it never reached the wire at all. The predicate is now `Exclude<keyof QueryOptionsV2, keyof QueryOptions>` held as a `Record<…, true>` — a new canonical key is a compile error until listed. `expand` maps to `?expand=<comma-separated names>`, the spelling `HttpFindQueryParamsSchema` declares for the GET list route; a nested per-relation query, which has no GET spelling, is refused rather than silently trimmed. Both `find` copies (ObjectStackClient / ScopedProjectClient) read the one shared predicate and mapping, and are driven through one expectation table in the tests so a future fork goes red. Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1818998 commit ec3dfd7

3 files changed

Lines changed: 361 additions & 40 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
fix(client): `data.find({ limit })` reached the server as an empty query, and `QueryOptionsV2.expand` reached it as nothing at all (#6322)
6+
7+
`data.find()` accepts two vocabularies — the canonical `QueryOptionsV2`
8+
(`where` / `fields` / `orderBy` / `limit` / `offset` / `expand`) and the legacy
9+
`QueryOptions` (`filter` / `select` / `sort` / `top` / `skip`) — and picked the
10+
branch with a hand-written condition that named four keys:
11+
`'where' in options || 'fields' in options || 'orderBy' in options || 'offset' in options`.
12+
That condition was a second, independent statement of what `QueryOptionsV2`
13+
declares, and it had fallen behind the interface twice.
14+
15+
**`limit` was missing from it.** `client.data.find('task', { limit: 20 })` — a
16+
canonical key as the only key, and the most natural spelling of "first 20" —
17+
was not recognised as canonical, fell to the legacy branch, and that branch
18+
reads only `top` / `skip` / `sort` / `select` / `filter` / `filters` /
19+
`aggregations` / `groupBy`. Nothing there reads `limit`, so the value was
20+
dropped between the call and the wire: the request went out with an **empty
21+
query string**, the caller got the server's default page size, HTTP 200, no
22+
warning. Its pagination twin `{ offset: 5 }` worked correctly, because `offset`
23+
happened to be one of the four listed keys — one interface, two pagination
24+
keys, opposite behaviour.
25+
26+
**`expand` was missing too, and had no mapping either.** It is declared on
27+
`QueryOptionsV2`, documented as the replacement for a legacy `populate` that
28+
`QueryOptions` never had, and was carried by neither branch — not one character
29+
of it reached the wire, on either of the two `find` implementations.
30+
31+
**What changed.** The branch predicate is now derived from the interface rather
32+
than restated beside it: the canonical-only key set is
33+
`Exclude<keyof QueryOptionsV2, keyof QueryOptions>`, held as a
34+
`Record<…, true>` that TypeScript rejects when a key is missing or extra. A key
35+
added to `QueryOptionsV2` from now on is a compile error until it is listed, so
36+
the next canonical key is covered on the day it is declared. Appending `limit`
37+
to the old list would have been the third round of the same mistake.
38+
39+
`expand` now maps onto the spelling the server actually accepts:
40+
`?expand=<comma-separated relation names>`, which
41+
`HttpFindQueryParamsSchema` declares for the GET list route and the protocol
42+
normalizer splits on commas before folding each name into the engine's expand
43+
map. The `Record` form contributes its keys — the same relation names the
44+
server derives from the comma list. A **nested** per-relation query inside
45+
`expand` has no spelling on a GET, so it is now refused with an error naming
46+
the relation and the keys it could not carry, rather than trimmed away
47+
silently; `data.query()` carries a QueryAST body and is where nested expand
48+
detail belongs.
49+
50+
Both `find` implementations — `ObjectStackClient.data.find` and
51+
`ScopedProjectClient.data.find`, which were byte-identical copies of the same
52+
defect — read the one shared predicate and the one shared `expand` mapping.
53+
54+
No change to the five paired keys: canonical and legacy spellings of the same
55+
query still produce byte-identical transport parameters, and that parity is now
56+
pinned by a test table both implementations are driven through.

packages/client/src/client.test.ts

Lines changed: 169 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, it, expect, vi } from 'vitest';
55
// entered a tsc program (#5449).
66
import { WELL_KNOWN_CAPABILITY_KEYS } from '@objectstack/spec/api';
77
import { ObjectStackClient, createQuery, createFilter } from './index';
8+
import type { QueryOptions, QueryOptionsV2 } from './index';
89

910
/** Helper: create a client with mocked fetch that returns the given response body */
1011
function createMockClient(body: any, status = 200) {
@@ -1226,50 +1227,181 @@ describe('ObjectStackClient.automation', () => {
12261227
// QueryOptionsV2 (Canonical Query Syntax) Tests
12271228
// ==========================================
12281229

1229-
describe('QueryOptionsV2 — canonical find()', () => {
1230-
it('should accept canonical field names (where, fields, orderBy, limit, offset)', async () => {
1231-
const { client, fetchMock } = createMockClient({
1232-
success: true,
1233-
data: { object: 'account', records: [], total: 0 }
1234-
});
1230+
/**
1231+
* `data.find()` transport parameters — ONE expectation table, BOTH copies.
1232+
*
1233+
* `find` is implemented twice: `ObjectStackClient.data.find` and
1234+
* `ScopedProjectClient.data.find`. They are two faces of ONE wire contract
1235+
* (the scoped one differs only in the URL prefix) and were byte-identical
1236+
* copies of the same normalization — including the same defect. Every row
1237+
* below is therefore driven through BOTH and compared against the SAME
1238+
* expected query string, so a future edit that lands on only one of them goes
1239+
* red here instead of shipping a fork.
1240+
*
1241+
* The expectations are EXACT full query strings, not `toContain` substrings:
1242+
* the defect this suite was written for (#6322) was a param that never
1243+
* appeared at all, and a substring assertion on the params that DID appear
1244+
* stays green through exactly that.
1245+
*
1246+
* Seeded from the measurements in #6322.
1247+
*/
1248+
describe('data.find() — canonical/legacy transport parameters (both copies)', () => {
1249+
/** The query string a call put on the wire (`''` when it sent none). */
1250+
function queryOf(url: string): string {
1251+
const q = url.indexOf('?');
1252+
return q === -1 ? '' : url.slice(q + 1);
1253+
}
12351254

1236-
await client.data.find('account', {
1237-
where: { status: 'active' },
1238-
fields: ['name', 'email'],
1239-
orderBy: ['-created_at'],
1240-
limit: 10,
1241-
offset: 5,
1255+
/** Drive the SAME options through both `find` implementations. */
1256+
async function driveBoth(options: QueryOptions | QueryOptionsV2): Promise<{ direct: string; scoped: string }> {
1257+
const body = { success: true, data: { object: 'task', records: [], total: 0 } };
1258+
1259+
const a = createMockClient(body);
1260+
await a.client.data.find('task', options);
1261+
const direct = queryOf(a.fetchMock.mock.calls[0][0] as string);
1262+
1263+
const b = createMockClient(body);
1264+
await b.client.project('env-1').data.find('task', options);
1265+
const scoped = queryOf(b.fetchMock.mock.calls[0][0] as string);
1266+
1267+
return { direct, scoped };
1268+
}
1269+
1270+
interface TransportRow {
1271+
options: QueryOptions | QueryOptionsV2;
1272+
/** The exact query string both copies must emit. */
1273+
wire: string;
1274+
}
1275+
1276+
/**
1277+
* A key declared on `QueryOptionsV2` and on NO legacy `QueryOptions`
1278+
* spelling — recomputed here from the two exported interfaces rather than
1279+
* restated, so it tracks them.
1280+
*/
1281+
type CanonicalOnlyKey = Exclude<keyof QueryOptionsV2, keyof QueryOptions>;
1282+
1283+
/**
1284+
* ONE ROW PER CANONICAL-ONLY KEY, DRIVEN ALONE. This is the shape of the
1285+
* #6322 defect: `find('task', { limit: 20 })` — a canonical key as the
1286+
* ONLY key — was not recognised as canonical vocabulary, fell to the
1287+
* legacy branch, which reads no `limit`, and reached the server with an
1288+
* EMPTY query string. HTTP 200, server default page size, no warning.
1289+
* Its pagination twin `{ offset: 5 }` worked, because `offset` happened to
1290+
* be in the hand-written sniff list and `limit` did not.
1291+
*
1292+
* `Record<CanonicalOnlyKey, …>` is the anti-rot device: a new key added to
1293+
* `QueryOptionsV2` makes this object a COMPILE error until someone states
1294+
* what that key puts on the wire — which is the question the old
1295+
* hand-maintained sniff list let two keys (`limit`, `expand`) slip past.
1296+
*/
1297+
const SINGLE_CANONICAL_KEY: Record<CanonicalOnlyKey, TransportRow> = {
1298+
where: { options: { where: { contact_id: 'c1' } }, wire: 'contact_id=c1' },
1299+
fields: { options: { fields: ['id', 'amount'] }, wire: 'select=id%2Camount' },
1300+
orderBy: { options: { orderBy: ['-created_at'] }, wire: 'sort=-created_at' },
1301+
limit: { options: { limit: 20 }, wire: 'top=20' },
1302+
offset: { options: { offset: 5 }, wire: 'skip=5' },
1303+
expand: { options: { expand: ['contact'] }, wire: 'expand=contact' },
1304+
};
1305+
1306+
/** The five paired keys, canonical spelling. */
1307+
const CANONICAL_FULL: QueryOptionsV2 = {
1308+
where: { contact_id: 'c1' },
1309+
fields: ['id', 'amount'],
1310+
orderBy: ['-created_at'],
1311+
limit: 20,
1312+
offset: 5,
1313+
};
1314+
1315+
/** The same query, legacy spelling. Must reach the wire byte-identically. */
1316+
const LEGACY_FULL: QueryOptions = {
1317+
filter: { contact_id: 'c1' },
1318+
select: ['id', 'amount'],
1319+
sort: ['-created_at'],
1320+
top: 20,
1321+
skip: 5,
1322+
};
1323+
1324+
const ROWS: Record<string, TransportRow> = {
1325+
'canonical: where + fields + orderBy + limit + offset': {
1326+
options: CANONICAL_FULL,
1327+
wire: 'top=20&skip=5&sort=-created_at&select=id%2Camount&contact_id=c1',
1328+
},
1329+
'legacy: filter + select + sort + top + skip': {
1330+
options: LEGACY_FULL,
1331+
wire: 'top=20&skip=5&sort=-created_at&select=id%2Camount&contact_id=c1',
1332+
},
1333+
...Object.fromEntries(
1334+
Object.entries(SINGLE_CANONICAL_KEY).map(([key, row]) => [`canonical single key: { ${key} }`, row]),
1335+
),
1336+
// The legacy twins of the two pagination keys — pinned alongside so a
1337+
// change that fixes one vocabulary by breaking the other goes red.
1338+
'legacy single key: { top }': { options: { top: 20 }, wire: 'top=20' },
1339+
'legacy single key: { skip }': { options: { skip: 5 }, wire: 'skip=5' },
1340+
'canonical: where + limit': {
1341+
options: { where: { contact_id: 'c1' }, limit: 20 },
1342+
wire: 'top=20&contact_id=c1',
1343+
},
1344+
'canonical: where + expand': {
1345+
options: { where: { contact_id: 'c1' }, expand: ['contact'] },
1346+
wire: 'contact_id=c1&expand=contact',
1347+
},
1348+
// `expand` reaches the wire as `expand=<comma-separated relation
1349+
// names>` — the one spelling `HttpFindQueryParamsSchema` declares for
1350+
// the GET list route and the protocol normalizer splits on commas.
1351+
'canonical: expand as a name array': {
1352+
options: { expand: ['contact', 'owner'] },
1353+
wire: 'expand=contact%2Cowner',
1354+
},
1355+
// The `Record` form contributes its KEYS — the same relation names the
1356+
// server derives from the comma list.
1357+
'canonical: expand as a relation map': {
1358+
options: { expand: { contact: {}, owner: {} } },
1359+
wire: 'expand=contact%2Cowner',
1360+
},
1361+
'no options at all': { options: {}, wire: '' },
1362+
};
1363+
1364+
for (const [name, row] of Object.entries(ROWS)) {
1365+
it(`${name} → \`${row.wire}\` on both copies`, async () => {
1366+
const { direct, scoped } = await driveBoth(row.options);
1367+
expect(direct).toBe(row.wire);
1368+
expect(scoped).toBe(row.wire);
12421369
});
1370+
}
12431371

1244-
const url = fetchMock.mock.calls[0][0] as string;
1245-
// V2 canonical options are normalized to HTTP transport params
1246-
expect(url).toContain('top=10');
1247-
expect(url).toContain('skip=5');
1248-
expect(url).toContain('select=name%2Cemail');
1249-
expect(url).toContain('sort=-created_at');
1250-
// where → filter as JSON
1251-
expect(url).toContain('status=active');
1372+
it('canonical and legacy spellings of one query are byte-identical on the wire', async () => {
1373+
const canonical = await driveBoth(CANONICAL_FULL);
1374+
const legacy = await driveBoth(LEGACY_FULL);
1375+
expect(canonical.direct).toBe(legacy.direct);
1376+
expect(canonical.scoped).toBe(legacy.scoped);
12521377
});
12531378

1254-
it('should still accept legacy field names (filter, select, sort, top, skip)', async () => {
1255-
const { client, fetchMock } = createMockClient({
1256-
success: true,
1257-
data: { object: 'account', records: [], total: 0 }
1258-
});
1379+
/**
1380+
* A nested per-relation query inside `expand` has no spelling on a GET, so
1381+
* it is REFUSED rather than trimmed away — trimming would send a wider
1382+
* read than the caller asked for and say nothing.
1383+
*
1384+
* This is a client-side pre-flight guard, not an HTTP rejection, so there
1385+
* is no ADR-0112 `code`/`status` envelope to assert. The two independent
1386+
* bits asserted instead: the message names the offending relation AND the
1387+
* nested keys it could not carry, and NO request was issued — so a
1388+
* "refusal" that fired after the read had already gone out, or one that
1389+
* resolved instead of throwing, both go red.
1390+
*/
1391+
it('refuses a nested per-relation expand on both copies, before any request', async () => {
1392+
const nested: QueryOptionsV2 = { expand: { contact: { fields: ['name'] } } };
12591393

1260-
await client.data.find('account', {
1261-
filter: { industry: 'Tech' },
1262-
select: ['name'],
1263-
sort: ['-revenue'],
1264-
top: 20,
1265-
skip: 0,
1266-
});
1394+
const a = createMockClient({ success: true, data: { object: 'task', records: [] } });
1395+
await expect(a.client.data.find('task', nested)).rejects.toThrow(
1396+
/expand\['contact'\] carries a nested query \(fields\)/,
1397+
);
1398+
expect(a.fetchMock).not.toHaveBeenCalled();
12671399

1268-
const url = fetchMock.mock.calls[0][0] as string;
1269-
expect(url).toContain('top=20');
1270-
expect(url).toContain('select=name');
1271-
expect(url).toContain('sort=-revenue');
1272-
expect(url).toContain('industry=Tech');
1400+
const b = createMockClient({ success: true, data: { object: 'task', records: [] } });
1401+
await expect(b.client.project('env-1').data.find('task', nested)).rejects.toThrow(
1402+
/expand\['contact'\] carries a nested query \(fields\)/,
1403+
);
1404+
expect(b.fetchMock).not.toHaveBeenCalled();
12731405
});
12741406
});
12751407

0 commit comments

Comments
 (0)