diff --git a/.changeset/26644-empty-projection-count-flag.md b/.changeset/26644-empty-projection-count-flag.md new file mode 100644 index 00000000000..935f6a970b3 --- /dev/null +++ b/.changeset/26644-empty-projection-count-flag.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol)!: the Data API refuses an empty projection and a count flag that is neither true nor false, instead of reading them as absent (#26644) + +Clause-②: no (narrowing) — `GET /data/:object`, `POST /data/:object/query` (both transports: `RestServer` and the runtime dispatcher) and `GET /data/:object/:id` refuse a projection that names no field, and the list doors refuse a `count` / `$count` other than true or false, where they answered 200 before. Nothing is widened and no export changes. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the shared list normalizer (`findData`) and the by-id read (`getData`), served by every Data API door that uses them. + +- **Before.** A blank or comma-only `$select` / `select` / `fields` (`?$select=`, `?$select=,`), and an empty array in a JSON body (`"fields": []`), became an empty projection, which every driver serves as no projection: all columns, under a 200. A `$count` / `count` other than `true` or `false` (`?$count=maybe`, `?$count=1`, a blank `?count=`) was left unlowered, so the `count=false` opt-out never matched and the count ran: a `total` was reported as if `$count=true` had been sent. +- **Now.** Both are refused `400 VALIDATION_ERROR` before any read, with a message naming the parameter the caller wrote and the value (`Query parameter '$select' names no field; got ""`, `Query parameter '$count' must be true or false; got "maybe"`), as a blank `$top` already is. The accepted count values are the spec's `QueryTransportParamsSchema.count`: the boolean, or the strings `true` and `false`. `GET /data/:object/:id?select=` answers with the same code; that route's catch already reports every projection refusal (an unknown field included) as `404`, and the empty projection joins that answer. +- **Unchanged.** An absent parameter keeps its default: every column, and the count runs. A named projection (`$select=a,b`, trailing commas and spaces trimmed as before), `$count=true`, `$count=false` and a JSON `null` answer exactly as before. `POST /data/:object/query` already refused a non-boolean `count` at its body schema (`400 VALIDATION_FAILED`), and still does. +- **Who sees it.** A caller that builds `$select` from a list that can be empty, or sends a mistyped `$count`. It now learns that from a 400 instead of an answer that looked normal. + +**The remedy.** Leave the parameter out to read every column or to keep the count, or send a projection naming at least one field and a count flag of `true` or `false`. diff --git a/packages/metadata-protocol/src/protocol.empty-projection-unknown-count.test.ts b/packages/metadata-protocol/src/protocol.empty-projection-unknown-count.test.ts new file mode 100644 index 00000000000..fdcd4132bb9 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.empty-projection-unknown-count.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #26644 — the list normalizer read two malformed values as the absent default. + * + * - **Projection.** A string `fields` (or its folded `select` / `$select` + * spellings) was `split(',').map(trim).filter(Boolean)`, so a blank or + * comma-only value became `[]` — and `assertProjectionFieldsExist` has + * nothing to refuse in an empty list, so an empty projection was served as + * NO projection: every column. An explicit `fields: []` took the same road. + * Absent means "every column"; an empty projection asks for none, so reading + * it as absent is the silent widening `pageWindowValue` already refuses on + * the row axis (a blank `$top`). + * - **Count.** The boolean step lowered only `'true'` / `'false'`; any other + * value stayed as it was, `countOptOut = options.count === false` was false, + * and the count ran — `?$count=maybe` reported a `total` like `?$count=true`. + * + * Both are now refused `400 VALIDATION_ERROR` naming the parameter the caller + * wrote and the value, before the engine is asked. The accepted `count` values + * are the spec's (`QueryTransportParamsSchema.count`): the boolean, or the two + * strings a querystring carries it as. + * + * `getData` (`GET /data/:object/:id`) reads `select` against the same field + * map, and its own comment promised "gated exactly as on the list path", so its + * projection takes the same refusal: one rule for both routes. + * + * ## Two assertion classes + * + * 1. **REFUSAL** — `code`, `status` and `param` on the throw, and the engine + * never reached. The unfixed normalizer never threw for these, so a bare + * `toThrow()` would be half a pin. + * 2. **PRESERVATION** — a well-formed projection reaches `engine.find` as the + * same list it always did, and `count` absent / `true` / `false` (either + * spelling, `null` as absent) counts or skips exactly as before. + * + * The door-level half (both REST list routes and the by-id route over a real + * `SqlDriver`) is `packages/rest/src/data-empty-projection-count-door.test.ts`; + * the runtime dispatcher's is + * `packages/runtime/src/domains/data-empty-projection-count.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const SCHEMA = { + name: 'invoice', + nameField: 'name', + fields: { + name: { name: 'name', type: 'text' }, + status: { name: 'status', type: 'text' }, + }, +}; + +function makeProtocol() { + const find = vi.fn(async () => [{ id: 'r1', name: 'a', status: 'open' }] as unknown[]); + const findOne = vi.fn(async (object: string, query?: EngineFindOneQueryInput) => { + assertEngineFindOnePredicate(object, query); + return { id: 'r1', name: 'a', status: 'open' }; + }); + const count = vi.fn(async () => 7); + const engine = { + registry: { getObject: (n: string) => (n === 'invoice' ? SCHEMA : undefined) }, + find, + findOne, + count, + aggregate: vi.fn(async () => [] as unknown[]), + }; + return { p: new ObjectStackProtocolImplementation(engine as any), find, findOne, count }; +} + +type Refusal = { message: string; status?: number; code?: string; param?: string }; + +async function refusal(run: (p: any) => Promise, reached: (m: ReturnType) => unknown[]): Promise { + const m = makeProtocol(); + let answered: unknown; + try { + answered = await run(m.p); + } catch (e) { + const err = e as Error & { status?: number; code?: string; param?: string }; + for (const spy of reached(m)) expect(spy, 'the engine was reached before the refusal').not.toHaveBeenCalled(); + return { message: err.message, status: err.status, code: err.code, param: err.param }; + } + throw new Error(`ACCEPTED (answered ${JSON.stringify(answered)}) instead of refused`); +} + +const listRefusal = (query: Record) => + refusal((p) => p.findData({ object: 'invoice', query }), (m) => [m.find, m.count]); +const getRefusal = (select: unknown) => + refusal((p) => p.getData({ object: 'invoice', id: 'r1', select }), (m) => [m.findOne]); + +// --------------------------------------------------------------------------- +// 1. REFUSAL +// --------------------------------------------------------------------------- + +describe('#26644 — an empty projection is refused, naming the spelling the caller wrote', () => { + it.each<[string, Record]>([ + ['$select', { $select: '' }], + ['$select', { $select: ' ' }], + ['$select', { $select: ',' }], + ['$select', { $select: ' , ,' }], + ['select', { select: '' }], + ['fields', { fields: '' }], + ['fields', { fields: [] }], + ['select', { select: [] }], + ])('list: %s in %j answers 400 VALIDATION_ERROR (was: every column)', async (param, query) => { + const err = await listRefusal(query); + + expect(err.code).toBe('VALIDATION_ERROR'); + expect(err.status).toBe(400); + expect(err.param).toBe(param); + expect(err.message).toContain(`Query parameter '${param}' names no field`); + }); + + it.each<[unknown]>([[''], [' '], [','], [[]]])('by id: select %j answers 400 VALIDATION_ERROR (was: every column)', async (select) => { + const err = await getRefusal(select); + + expect(err.code).toBe('VALIDATION_ERROR'); + expect(err.status).toBe(400); + expect(err.param).toBe('select'); + expect(err.message).toContain("Query parameter 'select' names no field"); + }); + + it('quotes the value it refused', async () => { + const err = await listRefusal({ $select: ' , ' }); + + expect(err.message).toContain('got " , "'); + }); +}); + +describe('#26644 — a count flag that is not true or false is refused', () => { + it.each<[string, Record]>([ + ['$count', { $count: 'maybe' }], + ['$count', { $count: '' }], + ['$count', { $count: '1' }], + ['$count', { $count: 'TRUE' }], + ['count', { count: 'yes' }], + ['count', { count: 0 }], + ['count', { count: 1 }], + ])('%s in %j answers 400 VALIDATION_ERROR (was: counted)', async (param, query) => { + const err = await listRefusal(query); + + expect(err.code).toBe('VALIDATION_ERROR'); + expect(err.status).toBe(400); + expect(err.param).toBe(param); + expect(err.message).toContain(`Query parameter '${param}' must be true or false`); + }); + + it('quotes the value it refused, bounded', async () => { + const err = await listRefusal({ $count: 'x'.repeat(500) }); + + expect(err.message).toContain('got "xxxx'); + expect(err.message.length).toBeLessThan(400); + }); +}); + +// --------------------------------------------------------------------------- +// 2. PRESERVATION +// --------------------------------------------------------------------------- + +describe('#26644 — a well-formed projection and count flag answer exactly as before', () => { + it.each<[Record, unknown]>([ + [{ $select: 'name,status' }, ['name', 'status']], + [{ $select: ' name , status ,' }, ['name', 'status']], + [{ select: 'name' }, ['name']], + [{ fields: ['name'] }, ['name']], + [{}, undefined], + [{ fields: null }, undefined], + ])('list: %j → fields %j', async (query, fields) => { + const { p, find } = makeProtocol(); + await p.findData({ object: 'invoice', query } as never); + + expect(find).toHaveBeenCalledTimes(1); + expect(((find.mock.calls[0] as unknown[])[1] as Record).fields).toEqual(fields); + }); + + it.each<[unknown, unknown]>([ + ['name,status', ['name', 'status']], + [['name'], ['name']], + [undefined, undefined], + [null, undefined], + ])('by id: select %j → fields %j', async (select, fields) => { + const { p, findOne } = makeProtocol(); + await p.getData({ object: 'invoice', id: 'r1', select } as never); + + expect(findOne).toHaveBeenCalledTimes(1); + expect(((findOne.mock.calls[0] as unknown[])[1] as Record).fields).toEqual(fields); + }); + + it.each<[Record, boolean]>([ + [{}, true], + [{ $count: 'true' }, true], + [{ count: true }, true], + [{ count: null }, true], + [{ $count: 'false' }, false], + [{ count: false }, false], + ])('%j with a window → counted: %s', async (query, counted) => { + const { p, count } = makeProtocol(); + // The count runs only for a paged request: unpaged, the page IS the total. + const res: any = await p.findData({ object: 'invoice', query: { $top: '1', ...query } } as never); + + expect(count).toHaveBeenCalledTimes(counted ? 1 : 0); + expect('total' in res).toBe(counted); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3b4476a1f96..e7c22d7a715 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -156,6 +156,7 @@ import { foldQueryAliasSlots, QUERY_TRANSPORT_ALIAS_SLOTS, QUERY_TRANSPORT_DOLLAR_ALIASES, QUERY_TRANSPORT_DOLLAR_PARAMS, QueryPageWindowParamSchema, + QueryTransportParamsSchema, type QueryAliasConflict, type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed, type ValidationAdvisoryEvent, @@ -4619,6 +4620,73 @@ function pageWindowValue(value: unknown, param: string): number { throw err; } +/** + * A refused query value as the refusal quotes it: JSON, so a blank or + * whitespace-only value is visible, and bounded, so a long one cannot bury + * the sentence around it. + */ +function quotedQueryValue(value: unknown): string { + // `String` for a number: `JSON.stringify(Infinity)` is `null`. + const shown = typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value); + return shown.length > 64 ? `${shown.slice(0, 64)}…` : shown; +} + +/** + * [#26644] A projection (`fields`, spelled `$select` / `select` on the wire) as + * the list of field names the engine reads, or a refusal naming the parameter + * the caller wrote. + * + * A comma string is split and trimmed as it always was; what is refused is a + * value that names NO field — a blank or comma-only string, or an empty array. + * It used to become `[]`, which `assertProjectionFieldsExist` has nothing to + * check in and every driver serves as no projection: every column, under a + * 200. Absent means "every column"; an empty projection asks for none, so + * reading it as absent is the widening {@link pageWindowValue} refuses on the + * row axis (a blank `$top`), here on the column axis. + * + * One rule for both routes that read a projection: the list's `findData` and + * the by-id `getData`. + */ +function projectionFieldsValue(value: string | readonly unknown[], param: string): unknown[] { + const fields = typeof value === 'string' + ? value.split(',').map((s) => s.trim()).filter(Boolean) + : [...value]; + if (fields.length > 0) return fields; + const err: any = new Error( + `Query parameter '${param}' names no field; got ${quotedQueryValue(value)}. The query was not run: ` + + 'an empty projection was read as no projection, which answers every column. Name at least one ' + + 'field, or leave the parameter out to read every column.', + ); + err.status = 400; + err.code = 'VALIDATION_ERROR'; + err.param = param; + throw err; +} + +/** + * [#26644] The response's total-count flag (`count`, spelled `$count` on the + * wire) as the boolean `findData` reads its opt-out from, or a refusal naming + * the parameter the caller wrote. + * + * The accept set is the spec's `QueryTransportParamsSchema.count`, parsed here + * rather than restated: the boolean, or the two strings a querystring carries + * it as (`null` is absent). Any other value used to stay as it was, so the + * `options.count === false` opt-out read it as "count anyway" — `?$count=maybe` + * reported a `total` exactly like `?$count=true`. + */ +function countFlagValue(value: unknown, param: string): boolean | null | undefined { + const flag = QueryTransportParamsSchema.shape.count.safeParse(value); + if (flag.success) return typeof flag.data === 'string' ? flag.data === 'true' : flag.data; + const err: any = new Error( + `Query parameter '${param}' must be true or false; got ${quotedQueryValue(value)}. The query was not ` + + 'run: any other value was read as absent, which counts and reports a total.', + ); + err.status = 400; + err.code = 'VALIDATION_ERROR'; + err.param = param; + throw err; +} + /** * [#7321] Refuse a repeated occurrence of any list-query parameter this * normalizer reads as single-valued, and normalise the benign one-element array @@ -12961,10 +13029,14 @@ export class ObjectStackProtocolImplementation implements // querystring param arrives as a bare string — `?fields=name` or the // folded `?select=` / `$select` spellings — which drivers' `.map()` // calls over `query.fields` would otherwise throw on. + // + // [#26644] …and a value naming no field is refused rather than becoming + // `[]`, which every driver serves as no projection at all (see + // {@link projectionFieldsValue}). const projectionKey = slotParam('fields'); - if (typeof options.fields === 'string') { - options.fields = options.fields.split(',').map((s: string) => s.trim()).filter(Boolean); - } else if (options.fields !== undefined && !Array.isArray(options.fields)) { + if (typeof options.fields === 'string' || Array.isArray(options.fields)) { + options.fields = projectionFieldsValue(options.fields, projectionKey); + } else if (options.fields !== undefined) { delete options.fields; } // [#4226] Unknown projection columns are refused rather than dropped — @@ -13138,11 +13210,12 @@ export class ObjectStackProtocolImplementation implements ); if (narrowedSearchFields) options.searchFields = narrowedSearchFields; - // Boolean fields - for (const key of ['distinct', 'count']) { - if (options[key] === 'true') options[key] = true; - else if (options[key] === 'false') options[key] = false; - } + // Boolean fields. [#26644] `count` is CHECKED, not merely lowered: a + // value that is neither true nor false is refused rather than left to + // read as "count anyway" below (see {@link countFlagValue}). + if (options.distinct === 'true') options.distinct = true; + else if (options.distinct === 'false') options.distinct = false; + if (options.count != null) options.count = countFlagValue(options.count, slotParam('count')); // [#2926 ⑩] Every supported OData-style `$` alias has been consumed and // deleted above ($top/$skip/$orderby/$select/$count/$search/ @@ -13536,9 +13609,13 @@ export class ObjectStackProtocolImplementation implements // widened projection would recreate, one route over, the very split // ("two routes, opposite answers for one input") this issue was filed // about. - if (request.select) { - queryOptions.fields = typeof request.select === 'string' - ? request.select.split(',').map((s: string) => s.trim()).filter(Boolean) + // + // [#26644] …including the empty projection: a blank `?select=` was + // falsy and skipped, a comma-only one became `[]` — both answered every + // column. Asked on any value present, as the list path asks it. + if (request.select != null) { + queryOptions.fields = typeof request.select === 'string' || Array.isArray(request.select) + ? projectionFieldsValue(request.select, 'select') : request.select; this.assertProjectionFieldsExist(request.object, queryOptions.fields, 'select'); } diff --git a/packages/rest/src/data-empty-projection-count-door.test.ts b/packages/rest/src/data-empty-projection-count-door.test.ts new file mode 100644 index 00000000000..224dfad2ee3 --- /dev/null +++ b/packages/rest/src/data-empty-projection-count-door.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26644] An empty projection and a count flag that is neither `true` nor + * `false` are refused `400 VALIDATION_ERROR`, naming the parameter the caller + * wrote, on every Data API door that hands its request to the shared normalizer + * (`metadata-protocol`'s `findData` / `getData`) — over a real `SqlDriver` + * (SQLite), three rows. + * + * Measured on the base (`0f3d068cf1`), and again on the fix: + * + * | request | base | fix | + * |:--|:--|:--| + * | `GET ?$select=`, `?$select=%20`, `?$select=,`, `?select=`, `?fields=` | 200, every column | 400 naming the spelling | + * | `GET ?$count=maybe`, `?$count=1`, `?count=` (with `$top`) | 200, `total` reported | 400 naming the spelling | + * | `POST {"select":""}`, `{"fields":[]}` | 200, every column | 400 naming the key | + * | `POST {"count":"maybe"}` | 400 `VALIDATION_FAILED` (the body schema) | unchanged | + * | `GET /:id?select=`, `?select=,` | 200, every column | `VALIDATION_ERROR` naming `select`, at this route's 404 (it rewrites every 400, as for `?select=nope`) | + * | `GET ?$select=label`, `?$count=true` / `false`, absent; `GET /:id?select=label` | 200 | unchanged | + * + * The per-spelling pin with a recording engine is `metadata-protocol`'s + * `protocol.empty-projection-unknown-count.test.ts`. This file is the + * door-level half: the status and `code` each door answers, the spelling it + * names, that the driver was never asked, and the controls answering exactly + * as before. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const OBJECT = 'rest_projection_count_26644'; + +const ITEMS = { + name: OBJECT, + label: 'Items 26644', + fields: { + label: { name: 'label', type: 'text' as const }, + note: { name: 'note', type: 'text' as const }, + }, +}; + +const LABELS = ['a', 'b', 'c']; + +/** name · the querystring `GET /data/:object` receives · the spelling the refusal names · its sentence. */ +const REFUSED_GET: ReadonlyArray, string, string]> = [ + ['$select= blank (the card)', { $select: '' }, '$select', 'names no field'], + ['$select= whitespace', { $select: ' ' }, '$select', 'names no field'], + ['$select=, (the card)', { $select: ',' }, '$select', 'names no field'], + ['select= blank (the card)', { select: '' }, 'select', 'names no field'], + ['fields= blank', { fields: '' }, 'fields', 'names no field'], + ['$count=maybe (the card)', { $top: '2', $count: 'maybe' }, '$count', 'must be true or false'], + ['$count=1', { $top: '2', $count: '1' }, '$count', 'must be true or false'], + ['count= blank', { $top: '2', count: '' }, 'count', 'must be true or false'], +]; + +/** name · the JSON body `POST /data/:object/query` receives · the key the refusal names. */ +const REFUSED_POST: ReadonlyArray, string]> = [ + ['select: ""', { select: '' }, 'select'], + ['$select: ","', { $select: ',' }, '$select'], + ['fields: []', { fields: [] }, 'fields'], +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +describe('[#26644] an empty projection and an unrecognised count flag on the Data API doors — sqlite', () => { + let engine: ObjectQL; + let driver: any; + const reads = { n: 0 }; + let list: (query: Record) => Promise<{ status: number; body: any }>; + let query: (body: Record) => Promise<{ status: number; body: any }>; + let byId: (query: Record) => Promise<{ status: number; body: any }>; + + beforeAll(async () => { + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(ITEMS as any); + await engine.syncSchemas(); + // A real `single` install's Default Organization (ADR-0131 D9, D1). + await installDefaultOrganization(engine); + for (const label of LABELS) await engine.insert(OBJECT, { id: `r${label}`, label, note: `n-${label}` } as any); + + // Reads of THIS object — the protocol's own metadata traffic is not the question. + for (const verb of ['find', 'findOne', 'count', 'aggregate'] as const) { + const real = driver[verb].bind(driver); + driver[verb] = (o: string, ...rest: unknown[]) => { if (o === OBJECT) reads.n += 1; return real(o, ...rest); }; + } + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = (method: string, path: string) => { + const found = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + expect(found, `${method} ${path}`).toBeDefined(); + return found!; + }; + const getRoute = route('GET', '/api/v1/data/:object'); + const postRoute = route('POST', '/api/v1/data/:object/query'); + const idRoute = route('GET', '/api/v1/data/:object/:id'); + list = async (q) => { + const res = makeRes(); + // What a querystring carries: strings. + await getRoute.handler({ params: { object: OBJECT }, query: { $orderby: 'label', ...q }, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + query = async (body) => { + const res = makeRes(); + // What the wire carries: JSON. + await postRoute.handler({ params: { object: OBJECT }, body: JSON.parse(JSON.stringify(body)), query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + byId = async (q) => { + const res = makeRes(); + await idRoute.handler({ params: { object: OBJECT, id: 'ra' }, query: q, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('GET list: 400 VALIDATION_ERROR naming the querystring spelling — no read', async () => { + const before = reads.n; + for (const [name, q, param, sentence] of REFUSED_GET) { + const res = await list(q); + expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(400); + expect(res.body.code, name).toBe('VALIDATION_ERROR'); + expect(res.body.error, name).toContain(`Query parameter '${param}' ${sentence}`); + } + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + + it('POST query: 400 VALIDATION_ERROR naming the body key — no read', async () => { + const before = reads.n; + for (const [name, body, param] of REFUSED_POST) { + const res = await query(body); + expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(400); + expect(res.body.code, name).toBe('VALIDATION_ERROR'); + expect(res.body.error, name).toContain(`Query parameter '${param}' names no field`); + } + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + + it("POST query: an unrecognised count is still the schema door's 400 VALIDATION_FAILED, unchanged", async () => { + const res = await query({ limit: 2, count: 'maybe' }); + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(res.body.code).toBe('VALIDATION_FAILED'); + }); + + it("GET by id: VALIDATION_ERROR naming `select`, at the status this route gives every projection refusal — no read", async () => { + // This route's catch rewrites every 400 into a 404 (`crud-routes.ts`: "a + // bad id is a miss"), so its projection refusals already answer 404 — an + // unknown `select` field included. The empty projection joins that answer + // rather than inventing a second one here; the code is what differs. + const unknownField = await byId({ select: 'nope' }); + expect(unknownField.body.code, JSON.stringify(unknownField.body)).toBe('INVALID_FIELD'); + const before = reads.n; + for (const select of ['', ',']) { + const res = await byId({ select }); + expect(res.status, `select=${JSON.stringify(select)}: ${JSON.stringify(res.body)}`).toBe(unknownField.status); + expect(res.body.code).toBe('VALIDATION_ERROR'); + expect(res.body.error).toContain("Query parameter 'select' names no field"); + } + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + + it('controls: a named projection and a true / false / absent count answer exactly as before', async () => { + const projected = await list({ $select: 'label' }); + expect(projected.status, JSON.stringify(projected.body)).toBe(200); + expect(projected.body.records.map((r: any) => r.label)).toEqual(LABELS); + expect(projected.body.records.every((r: any) => !('note' in r))).toBe(true); + + const counted = await list({ $top: '2', $count: 'true' }); + expect(counted.status, JSON.stringify(counted.body)).toBe(200); + expect(counted.body.total).toBe(3); + + const optedOut = await list({ $top: '2', $count: 'false' }); + expect(optedOut.status, JSON.stringify(optedOut.body)).toBe(200); + expect('total' in optedOut.body).toBe(false); + + const absent = await list({ $top: '2' }); + expect(absent.body.total).toBe(3); + + const posted = await query({ select: 'label', limit: 2, count: true }); + expect(posted.status, JSON.stringify(posted.body)).toBe(200); + expect(posted.body.total).toBe(3); + + const one = await byId({ select: 'label' }); + expect(one.status, JSON.stringify(one.body)).toBe(200); + expect(one.body.record.label).toBe('a'); + expect('note' in one.body.record).toBe(false); + + const whole = await byId({}); + expect(whole.body.record.note).toBe('n-a'); + }); +}); diff --git a/packages/runtime/src/domains/data-empty-projection-count.test.ts b/packages/runtime/src/domains/data-empty-projection-count.test.ts new file mode 100644 index 00000000000..5f82f10ac89 --- /dev/null +++ b/packages/runtime/src/domains/data-empty-projection-count.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26644] The runtime dispatcher's `/data` doors answer an empty projection + * and a count flag that is neither `true` nor `false` the way `RestServer` + * does: `400 VALIDATION_ERROR`, naming the parameter the caller wrote, before + * any read. + * + * The refusal lives once, in the shared normalizer both transports hand their + * request to (`metadata-protocol`'s `findData` / `getData`; the per-spelling pin + * is `protocol.empty-projection-unknown-count.test.ts`, the REST half + * `packages/rest/src/data-empty-projection-count-door.test.ts`). This file is + * the dispatcher's half: `GET /data/:object`, `POST /data/:object/query` and + * `GET /data/:object/:id`, as the host catch-all hands them over. + * + * Measured on the base (`0f3d068cf1`): every refused request below answered + * `200` — every column for the empty projections, a `total` for the count. + * + * The dispatcher's `/data` domain does not catch the normalizer's throw, so it + * leaves `dispatch()` and the HOST's exit renders it; both exits are asked here + * through their own readers (`declaredEnvelopeForThrow` for + * `@objectstack/hono`'s catch-all, `resolveThrownHttpError` for + * `dispatcher-plugin`), ⛔ not restated. + * + * The rig is `data-hook-condition-refusal.test.ts`'s: a real store + * (better-sqlite3 `:memory:`, the real `ObjectQL`, a real + * `ObjectStackProtocolImplementation`) and `HttpDispatcher.dispatch()`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { declaredEnvelopeForThrow } from '@objectstack/plugin-hono-server'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { HttpDispatcher } from '../http-dispatcher.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; + +const PREFIX = '/api/v1'; +const PACKAGE_ID = 'objectstack-test'; +const CALLER = { userId: 'u_member', isSystem: false }; +const INQUIRY = { + name: 'inquiry', label: 'Inquiry', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + note: { name: 'note', type: 'text' as const, label: 'Note' }, + }, +}; + +type Answer = { status: number; code?: string; message?: string; body?: any }; + +describe('[#26644] the dispatcher /data doors — an empty projection and an unrecognised count flag', () => { + let engine: ObjectQL; + let driver: any; + const reads = { n: 0 }; + let dispatcher: HttpDispatcher; + + /** What the request answered — the returned response, or what the host exits make of the throw. */ + const ask = async (method: 'GET' | 'POST', path: string, query: Record, body?: unknown): Promise => { + const raw = new Request(`http://localhost${PREFIX}${path}`, { + method, + ...(body !== undefined ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } : {}), + }); + let returned: any; + try { + returned = await dispatcher.dispatch(method, path, body, query, { request: raw } as any, PREFIX); + } catch (thrown) { + const envelope = declaredEnvelopeForThrow(thrown); + const resolved = resolveThrownHttpError(thrown); + // The two host exits must agree before either is believed. + expect(envelope?.status, 'catch-all status').toBe(resolved.status); + expect(envelope?.body.error.code, 'catch-all code').toBe(resolved.code); + const message = envelope?.body.error.message; + return { status: resolved.status, code: resolved.code, message: typeof message === 'string' ? message : undefined }; + } + const res = returned.response; + return { status: res.status, code: res.body?.error?.code, message: res.body?.error?.message, body: res.body }; + }; + + beforeAll(async () => { + engine = new ObjectQL(); + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(INQUIRY as any, PACKAGE_ID); + await engine.syncSchemas(); + // A real `single` install's Default Organization (ADR-0131 D9, D1). + await installDefaultOrganization(engine); + for (const id of ['r1', 'r2', 'r3']) await engine.insert('inquiry', { id, name: `n-${id}`, note: `x-${id}` } as any); + for (const verb of ['find', 'findOne', 'count', 'aggregate'] as const) { + const real = driver[verb].bind(driver); + driver[verb] = (o: string, ...rest: unknown[]) => { if (o === 'inquiry') reads.n += 1; return real(o, ...rest); }; + } + + const protocol = new ObjectStackProtocolImplementation(engine as any); + // `callData`'s exposure gate reads the kernel's `metadata` service. + const metadata = { getObject: async (name: string) => engine.registry.getObject(name) }; + const services: Record = { protocol, metadata }; + const get = (n: string) => services[n] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + dispatcher = new HttpDispatcher(kernel); + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...CALLER }); + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it.each<[string, 'GET' | 'POST', string, Record, unknown, string, string]>([ + ['GET list ?$select= (the card)', 'GET', '/data/inquiry', { $select: '' }, undefined, '$select', 'names no field'], + ['GET list ?$select=, (the card)', 'GET', '/data/inquiry', { $select: ',' }, undefined, '$select', 'names no field'], + ['GET list ?select= (the card)', 'GET', '/data/inquiry', { select: '' }, undefined, 'select', 'names no field'], + ['GET list ?$count=maybe (the card)', 'GET', '/data/inquiry', { $top: '2', $count: 'maybe' }, undefined, '$count', 'must be true or false'], + ['POST query {"select":""}', 'POST', '/data/inquiry/query', {}, { select: '' }, 'select', 'names no field'], + ['POST query {"fields":[]}', 'POST', '/data/inquiry/query', {}, { fields: [] }, 'fields', 'names no field'], + ['GET by id ?select=', 'GET', '/data/inquiry/r1', { select: '' }, undefined, 'select', 'names no field'], + ['GET by id ?select=,', 'GET', '/data/inquiry/r1', { select: ',' }, undefined, 'select', 'names no field'], + ])('%s: 400 VALIDATION_ERROR naming the spelling — no read', async (_name, method, path, query, body, param, sentence) => { + const before = reads.n; + + const answer = await ask(method, path, query, body); + + expect(answer.status, JSON.stringify(answer)).toBe(400); + expect(answer.code).toBe('VALIDATION_ERROR'); + expect(answer.message).toContain(`Query parameter '${param}' ${sentence}`); + expect(reads.n - before, 'no read of the object — the refusal precedes the driver').toBe(0); + }); + + it('CONTROL: a named projection and a true / false / absent count answer exactly as before', async () => { + const projected = await ask('GET', '/data/inquiry', { $select: 'name', $orderby: 'name' }); + expect(projected.status, JSON.stringify(projected)).toBe(200); + const records = projected.body.data.records; + expect(records.map((r: any) => r.name)).toEqual(['n-r1', 'n-r2', 'n-r3']); + expect(records.every((r: any) => !('note' in r))).toBe(true); + + const counted = await ask('GET', '/data/inquiry', { $top: '2', $count: 'true' }); + expect(counted.body.data.total).toBe(3); + const optedOut = await ask('GET', '/data/inquiry', { $top: '2', $count: 'false' }); + expect(optedOut.status, JSON.stringify(optedOut)).toBe(200); + expect('total' in optedOut.body.data).toBe(false); + const absent = await ask('GET', '/data/inquiry', { $top: '2' }); + expect(absent.body.data.total).toBe(3); + + const one = await ask('GET', '/data/inquiry/r1', { select: 'name' }); + expect(one.status, JSON.stringify(one)).toBe(200); + expect(one.body.data.record.name).toBe('n-r1'); + expect('note' in one.body.data.record).toBe(false); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index d65f93dac2c..14428d2e683 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -781,6 +781,11 @@ "verb": "update", "pinned": 3 }, + { + "file": "packages/metadata-protocol/src/protocol.empty-projection-unknown-count.test.ts", + "verb": "findOne", + "pinned": 2 + }, { "file": "packages/metadata-protocol/src/protocol.expand-target-exposure.test.ts", "verb": "findOne",