Skip to content

Commit 01eab23

Browse files
fix(rest): a refused json export answers the 400 envelope, not a truncated 200 stream (#27753)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DM77tsfTXyZb3uxqJzawYo9)_ domain:server#3 · os-elon-musk · session_016WV4d1cSb5LKsNDntVL6VG Fixes #27682 ## What changes - `packages/rest/src/data-action-routes.ts`: the json export writes its opening `[` after the first `findData` answers, not before it. One line moved into the read loop (`if (format === 'json' && firstChunk) res.write('[')`), placed before the zero-row break so an empty export is still `[]`. Nothing else in the route changes, and the import-job region (#27711) is untouched. - `packages/rest/src/export-json-refusal-envelope.test.ts` (new): pins both halves through the real `HonoHttpServer`. - `.changeset/27682-json-export-read-first.md`: patch for `@objectstack/rest`. ## Why The first `res.write` commits the Hono adapter to a streaming 200. Writing `[` before the first read meant a query that read refused reached the client as a 200 whose body was a lone `[`, while `format=csv` answered the 400 envelope. Triage 6504427279: the json export reads its first page before it writes any byte and answers the same envelope. A refusal on a later page is not widened here. ## Verification - Pin, over a real `SqlDriver` (SQLite): `?search=x&searchFields=nope` (the card's repro, `400 INVALID_FIELD`) and `?search=x&searchFields=,` (`400 VALIDATION_ERROR`) answer the same status and body for `format=json` as for `format=csv`. Ablation: with the early write restored, both json rows are red (200); restored, green. - Byte identity: a successful json export over one page, three pages (`page=50`, 120 rows) and zero rows answers exactly `[` + rows joined by `,` + `]` (or `[]`), `Content-Type: application/json; charset=utf-8`, with the same number of reads. Green on the unfixed code too, so the success path did not move. - `dispatch-gates --ran`: 59 derived families, 58 run green, 1 NOT MEASURED (`check:dual-build-cjs-loads`, exit 3, needs a whole-tree build). `pnpm lint` green; `@objectstack/rest` typecheck and full suite green (343 files, 6702 passed) after merging `main`. Out of scope finding: `format=xlsx` has the same early commit. Its workbook writer is created before the first read, and the card's repro answers 200 there too (measured). Not fixed or pinned here; the seat can file it. Clause-②: no Metadata-protocol: none ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4d1cSb5LKsNDntVL6VG Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2cbe559 commit 01eab23

3 files changed

Lines changed: 172 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): a refused `GET /data/:object/export?format=json` answers the 400 envelope, not a truncated 200 stream
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The json export wrote its opening `[` before its first read. The first write commits the response to a streaming 200, so a query that read refused (for example `?search=x&searchFields=nope`, a search over a field the object does not have) reached the client as a 200 whose body was a lone `[`, while `format=csv` answered the 400 envelope.
11+
12+
- **Read first.** The bracket is now written after the first page is read, as csv writes its first byte. A refusal on that read answers the same status and body as the csv export.
13+
- **A successful export is unchanged.** One page, several pages and no rows produce byte-identical bodies and the same headers.
14+
- A refusal on a later page, after rows have been written, still ends the stream; that is not changed here.

‎packages/rest/src/data-action-routes.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1437,7 +1437,6 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
14371437
let exported = 0;
14381438
let firstChunk = true;
14391439
let skip = 0;
1440-
if (format === 'json') res.write('[');
14411440
const xlsx = format === 'xlsx' ? await createXlsxStream(res, styled) : null;
14421441

14431442
while (exported < limit) {
@@ -1480,6 +1479,15 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
14801479
: Array.isArray(result?.rows) ? result.rows
14811480
: Array.isArray(result) ? result : [];
14821481

1482+
// [#27682] json's opening bracket follows the FIRST read, as
1483+
// csv's first byte does: the first `res.write` commits the
1484+
// response to a streaming 200, so writing it before
1485+
// `findData` turned a refused query into a 200 carrying a
1486+
// lone `[` instead of the 400 envelope. The loop always
1487+
// runs at least once (`limit` is at least 1), so the
1488+
// closing `]` below always has its opener.
1489+
if (format === 'json' && firstChunk) res.write('[');
1490+
14831491
if (rows.length === 0) break;
14841492

14851493
// Derive fields from the first row if schema lookup failed.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27682] `GET /data/:object/export?format=json` reads its first page before
5+
* it writes a byte, so a query the read refuses answers the `400` envelope the
6+
* csv export answers — not a `200` carrying a truncated stream.
7+
*
8+
* The defect: the json branch wrote its opening `[` before the first
9+
* `findData`. The first `res.write` commits the adapter to a streaming `200`,
10+
* so a refusal raised by that read (the card's repro: `searchFields` naming a
11+
* field that does not exist) reached the client as a `200` whose body was a
12+
* lone `[`, while `format=csv` answered `400 INVALID_FIELD`.
13+
*
14+
* Both halves are pinned through the real `HonoHttpServer` (the adapter
15+
* `os serve` mounts), because the `200` was decided by the adapter's streaming
16+
* switch, which a hand-rolled response double does not have:
17+
*
18+
* - refusal: over a real `SqlDriver` (SQLite), the card's repro answers the
19+
* same status and body for json as for csv;
20+
* - preservation: a successful json export is byte-for-byte what it was —
21+
* one page, several pages, and no rows at all.
22+
*
23+
* A refusal on a LATER page (after rows have been written) is out of this pin
24+
* (triage 6504427279). So is xlsx, whose workbook writer commits the stream
25+
* before the first read the same way; it is not pinned here.
26+
*/
27+
28+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
29+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
30+
import { ObjectQL } from '@objectstack/objectql';
31+
import { SqlDriver } from '@objectstack/driver-sql';
32+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
33+
import { RestServer } from './rest-server';
34+
import { installDefaultOrganization } from './default-organization.testkit.js';
35+
36+
const OBJECT = 'rest_export_json_27682';
37+
38+
const ITEMS = {
39+
name: OBJECT,
40+
label: 'Items 27682',
41+
fields: {
42+
label: { name: 'label', type: 'text' as const },
43+
note: { name: 'note', type: 'text' as const },
44+
},
45+
};
46+
47+
/**
48+
* code · querystring: two refusals `findData` raises on the first read. The
49+
* first is the card's repro (a search over a field the object does not
50+
* have); the second is #27484's empty override.
51+
*/
52+
const REFUSED: ReadonlyArray<readonly [string, string]> = [
53+
['INVALID_FIELD', '?search=x&searchFields=nope'],
54+
['VALIDATION_ERROR', '?search=x&searchFields=,'],
55+
];
56+
57+
describe('[#27682] export: a refused first read answers the envelope for json as for csv — sqlite', () => {
58+
let engine: ObjectQL;
59+
let app: { fetch: (req: Request) => Promise<Response> };
60+
61+
beforeAll(async () => {
62+
const driver: any = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any);
63+
engine = new ObjectQL();
64+
engine.registerDriver(driver, true);
65+
await engine.init();
66+
engine.registry.registerObject(ITEMS as any);
67+
await engine.syncSchemas();
68+
await installDefaultOrganization(engine);
69+
for (const label of ['a', 'b', 'c']) await engine.insert(OBJECT, { id: `r${label}`, label, note: `n-${label}` } as any);
70+
71+
const protocol = new ObjectStackProtocolImplementation(engine as any);
72+
const server = new HonoHttpServer(0);
73+
const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any);
74+
(rest as any).resolveExecCtx = async () => ({ userId: 'u_export_json' });
75+
rest.registerRoutes();
76+
app = server.getRawApp() as any;
77+
});
78+
79+
afterAll(async () => {
80+
try { await engine?.destroy(); } catch { /* noop */ }
81+
});
82+
83+
async function exportAs(format: string, query: string) {
84+
const res = await app.fetch(new Request(`http://local/api/v1/data/${OBJECT}/export${query}&format=${format}`));
85+
// The status is read before the body: a committed `200` stream is the
86+
// defect, and its body need not ever finish.
87+
const status = res.status;
88+
if (status !== 400) {
89+
try { await res.body?.cancel(); } catch { /* noop */ }
90+
return { status, body: undefined as any };
91+
}
92+
return { status, body: JSON.parse(await res.text()) };
93+
}
94+
95+
it.each(REFUSED)('the control: csv answers 400 %s for %s', async (code, query) => {
96+
const csv = await exportAs('csv', query);
97+
expect(csv.status).toBe(400);
98+
expect(csv.body?.code).toBe(code);
99+
});
100+
101+
it.each(REFUSED)('json answers the same 400 %s as csv for %s — no truncated 200', async (_code, query) => {
102+
const csv = await exportAs('csv', query);
103+
const json = await exportAs('json', query);
104+
expect(json.status, 'json must not commit a 200 before its first read').toBe(400);
105+
expect(json.body).toEqual(csv.body);
106+
});
107+
});
108+
109+
describe('[#27682] export: a successful json export is byte-identical', () => {
110+
const TASK = {
111+
name: 'task',
112+
label: 'Task',
113+
fields: {
114+
id: { type: 'text', label: 'ID' },
115+
title: { type: 'text', label: 'Title' },
116+
},
117+
};
118+
119+
function boot(total: number) {
120+
const all = Array.from({ length: total }, (_, i) => ({ id: String(i + 1), title: `t${i + 1}` }));
121+
const findData = vi.fn(async (req: any) => {
122+
const { offset = 0, limit = all.length } = req.query ?? {};
123+
return { object: 'task', records: all.slice(offset, offset + limit) };
124+
});
125+
const protocol: any = {
126+
getMetaItems: vi.fn().mockResolvedValue({ items: [TASK] }),
127+
getMetaItem: vi.fn().mockResolvedValue({ type: 'object', name: 'task', item: TASK }),
128+
findData,
129+
};
130+
const server = new HonoHttpServer(0);
131+
const rest = new RestServer(server as any, protocol, { api: { requireAuth: false } } as any);
132+
(rest as any).resolveExecCtx = async () => ({ userId: 'u_export_json' });
133+
rest.registerRoutes();
134+
return { app: server.getRawApp(), findData, expected: `[${all.map((r) => JSON.stringify(r)).join(',')}]` };
135+
}
136+
137+
it.each([
138+
['one page', 3, '', 1],
139+
['several pages (three reads of 50, 50, 20)', 120, '&page=50', 3],
140+
['no rows', 0, '', 1],
141+
])('%s', async (_label, total, query, reads) => {
142+
const { app, findData, expected } = boot(total);
143+
const res: Response = await app.fetch(new Request(`http://local/api/v1/data/task/export?format=json${query}`));
144+
expect(res.status).toBe(200);
145+
expect(res.headers.get('content-type')).toBe('application/json; charset=utf-8');
146+
expect(await res.text()).toBe(total === 0 ? '[]' : expected);
147+
expect(findData).toHaveBeenCalledTimes(reads);
148+
});
149+
});

0 commit comments

Comments
 (0)