Skip to content

Commit 137010a

Browse files
committed
fix(rest,spec): type-check the transport declaration's consumers, keep the id out of customer prose
Two CI reds, both this branch's own: - `packages/rest` `typecheck` was never run: its `test` script covers only the `local` vitest project, and the type check is a separate script. The test layer held four errors, every one of them the declared `FindDataRequest['query'].where` INPUT union gaining the `FilterArray` arm the door already serves. Narrowed at the three read sites -- by REFUSING the other arm, not by casting past it -- and pinned the JSON-encoded filter string as the refusal the transport table deliberately makes it. - `check:doc-authoring` refused an internal issue id inside `.describe()` prose, which is printed at the customer and resolves to nothing there. Moved to an adjacent `//` comment; regenerated the two reference pages the sentence projects into. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
1 parent fa423f5 commit 137010a

6 files changed

Lines changed: 42 additions & 9 deletions

File tree

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -875,7 +875,7 @@ Enable package response
875875
| :--- | :--- | :--- | :--- |
876876
| **object** | `string` | ✅ | Object name (e.g. account) |
877877
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. |
878-
| **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced (#5158 ruling C). |
878+
| **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced. |
879879
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration |
880880
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) |
881881
| **orderBy** | `{ field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |

‎content/docs/references/data/data-engine.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1280,7 +1280,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo
12801280
| :--- | :--- | :--- | :--- |
12811281
| **object** | `string` | ✅ | Object name (e.g. account) |
12821282
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. |
1283-
| **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced (#5158 ruling C). |
1283+
| **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced. |
12841284
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration |
12851285
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) |
12861286
| **orderBy** | `{ field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |

‎packages/rest/src/import-runner-bulk.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,23 @@ type FindArgs = Parameters<ImportProtocolLike['findData']>[0];
2323
type CreateArgs = Parameters<ImportProtocolLike['createData']>[0];
2424
import type { ExportFieldMeta } from './export-format.js';
2525

26+
/**
27+
* The CANONICAL object `where` out of the slot's declared input union.
28+
*
29+
* `FindDataRequest['query'].where` admits the input-only `FilterArray` sugar as
30+
* well, because the transport door serves it on every spelling of that slot.
31+
* `runImport` builds only the object form, so this narrows by REFUSING the
32+
* other arm rather than by casting past it: a runner that started emitting the
33+
* array sugar fails here loudly instead of type-checking into silence.
34+
*/
35+
function canonicalWhere(args: FindArgs): Record<string, any> {
36+
const where = args.query?.where;
37+
if (where == null || Array.isArray(where)) {
38+
throw new Error(`runImport must send a canonical object \`where\`; got ${JSON.stringify(where)}`);
39+
}
40+
return where;
41+
}
42+
2643
const metaMap = new Map<string, ExportFieldMeta>([
2744
['name', { name: 'name', type: 'text' }],
2845
]);
@@ -162,7 +179,7 @@ describe('runImport — bulk create batching (framework#2678)', () => {
162179
// Row 1 ('existing') matches an existing record → update; the rest are creates.
163180
// [#16638] Reads the CANONICAL `where` the runner sends, not `$filter`.
164181
const findData = vi.fn(async (args: FindArgs) =>
165-
(args.query!.where!.name === 'existing' ? [{ id: 'existing_id', name: 'existing' }] : []));
182+
(canonicalWhere(args).name === 'existing' ? [{ id: 'existing_id', name: 'existing' }] : []));
166183
const p: ImportProtocolLike = { findData, createData: vi.fn(), updateData, createManyData };
167184

168185
const summary = await runImport({

‎packages/rest/src/import-runner-idempotency.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,15 @@ describe('runImport — idempotent retry with natural keys (framework#3149)', ()
187187
expectEveryProbeNarrowed(findData.mock.calls, appliedFilters);
188188
const probes = findData.mock.calls.map(([a]) => a.query!);
189189
expect(probes).toHaveLength(1);
190-
expect(Object.keys(probes[0].where!)).toEqual(['id']);
191-
expect([...(probes[0].where!.id as { $in: string[] }).$in].sort()).toEqual(store.map((r) => r.id).sort());
190+
// The slot's declared INPUT also admits the `FilterArray` sugar the
191+
// transport door serves; the runner builds only the object form, so the
192+
// other arm is REFUSED here rather than cast past.
193+
const probeWhere = probes[0].where;
194+
if (probeWhere == null || Array.isArray(probeWhere)) {
195+
throw new Error(`the recheck must send a canonical object \`where\`; got ${JSON.stringify(probeWhere)}`);
196+
}
197+
expect(Object.keys(probeWhere)).toEqual(['id']);
198+
expect([...(probeWhere.id as { $in: string[] }).$in].sort()).toEqual(store.map((r) => r.id).sort());
192199
expect(probes[0].limit).toBe(store.length);
193200
});
194201

‎packages/rest/src/rest-server-canonical-query-ast.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ describe('[#16337] §2 the declared `FindDataRequest[\'query\']` contract', () =
394394
// the AST. So these three are assignments, not `@ts-expect-error`s.
395395
const dollarTop: Query = { object: 'x', $top: 5 };
396396
const dollarFilter: Query = { object: 'x', $filter: { id: '1' } };
397-
const wireFilters: Query = { object: 'x', filters: '{"id":"1"}' };
397+
const wireFilters: Query = { object: 'x', filters: { id: '1' } };
398398
// ⭐ …and a bag mixing the two spellings, which is what a boundary that
399399
// merges a caller's parameters with its own actually assembles.
400400
const mixed: Query = { object: 'x', where: { status: 'queued' }, $top: 5 };
@@ -417,7 +417,9 @@ describe('[#16337] §2 the declared `FindDataRequest[\'query\']` contract', () =
417417
const noObject: Query = { limit: 1 };
418418
// @ts-expect-error `$sort` is not a spelling the transport table names — declaring the dialect did not open `$*`
419419
const unnamedDollar: Query = { object: 'x', $sort: 'name' };
420-
expect([dollarTop, dollarFilter, wireFilters, mixed, wireSelect, wireSort, recordSort, commaExpand, noObject, unnamedDollar]).toHaveLength(10);
420+
// @ts-expect-error a JSON-ENCODED filter string is declared on NO filter spelling — lowering one means running a second parser beside the door's, which is the one thing the transport declaration refuses to do
421+
const jsonEncodedFilter: Query = { object: 'x', filters: '{"id":"1"}' };
422+
expect([dollarTop, dollarFilter, wireFilters, mixed, wireSelect, wireSort, recordSort, commaExpand, noObject, unnamedDollar, jsonEncodedFilter]).toHaveLength(11);
421423
});
422424
});
423425

@@ -472,7 +474,11 @@ describe('[#16952] §2 the declared `ImportProtocolLike` parameter contract', ()
472474
// ⭐ The whole point: leave the parameter unannotated and the contract
473475
// types it. This is the shape `plugin-auth`'s hand-written implementor
474476
// could not have while the declaration said `any`.
475-
const probes: Array<Record<string, unknown> | undefined> = [];
477+
// Typed FROM the declaration, like every alias in this block — the slot
478+
// admits the `FilterArray` sugar as well as the object form, and a
479+
// hand-written `Record<string, unknown>` here would be a fourth
480+
// restatement of the dialect, which is what this section exists to stop.
481+
const probes: Array<Query['where']> = [];
476482
const p: ImportProtocolLike = {
477483
findData: async (args) => { probes.push(args.query?.where); return { records: [] }; },
478484
createData: async (args) => ({ id: String(args.data.name) }),

‎packages/spec/src/data/data-engine.zod.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1191,10 +1191,13 @@ export const QueryWithTransportSchema = lazySchema(
11911191
.extend({
11921192
...(QueryTransportParamsSchema as unknown as z.ZodObject<z.ZodRawShape>).shape,
11931193
expand: describedCanonicalExpand(),
1194+
// The lowering sink the sentence below names is the one maintainer
1195+
// ruling C on #5158 fixed. The id lives in this comment and not in the
1196+
// prose, which is printed AT the customer, where it resolves to nothing.
11941197
where: TransportFilterValueSchema.optional().describe(
11951198
'Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` '
11961199
+ "sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` "
1197-
+ 'before the query is produced (#5158 ruling C).'
1200+
+ 'before the query is produced.'
11981201
),
11991202
})
12001203
.transform((bag, ctx) => foldQueryTransportBag(bag as Record<string, unknown>, ctx)),

0 commit comments

Comments
 (0)