Skip to content

Commit 2a3dec2

Browse files
fix(rest): a refused xlsx export answers the 400 envelope, not a 200 workbook stream (#27888)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DSK85qKE6pSk5Ekis1FkEnW)_ domain:server#4 · os-elon-musk · session_016WV4cyUucJoh7384vix5rh Fixes #27755 ## 维护者速读 - 改了什么:导出接口选 Excel(xlsx)格式时,先读第一页数据,再开始生成工作簿;与 #27753 对 json 的修法相同。 - 为什么改:之前工作簿一打开就把响应定成 200,查询被拒(例如按不存在的字段搜索)时用户拿到的是一个坏的或空的 Excel 文件,而不是说明错误的 400。 - 风险与回滚:改动只有导出函数里的一行挪位;成功导出的状态码、响应头和单元格内容不变。回滚即还原这一行。 - 先看:`packages/rest/src/data-action-routes.ts` 导出循环里新增的 `createXlsxStream` 那一行。 - 两分钟验证:`GET /api/v1/data/OBJECT/export?format=xlsx&search=x&searchFields=nope` 应回 400 INVALID_FIELD,与 csv 一致;不带错误参数的 xlsx 导出照常下载可打开的工作簿。 - 需要你做的:无;席位复核通过、CI 全绿后按本席落地口径落地。 ## What changes - `packages/rest/src/data-action-routes.ts`: the xlsx export opens its workbook writer (`createXlsxStream`) after the first `findData` answers, not before the read loop. The writer is opened inside the loop on the first chunk, placed before the zero-row break next to json's opening bracket (#27753), so an empty export is still a readable workbook. Nothing else in the route changes, and the async import-job region (#27795) is untouched. - `packages/rest/src/export-xlsx-refusal-envelope.test.ts` (new): pins both halves through the real `HonoHttpServer`. - `.changeset/27755-xlsx-export-read-first.md`: patch for `@objectstack/rest`. ## Why Opening the exceljs streaming writer commits the Hono adapter to a streaming 200. Opening it before the first read meant a query that read refused reached the client as a 200 workbook download, while `format=csv` (and, since #27753, `format=json`) answered the 400 envelope. Triage 6510461172: the xlsx writer opens after the first page is read 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` (`400 INVALID_FIELD`) and `?search=x&searchFields=,` (`400 VALIDATION_ERROR`) answer the same status and body for `format=xlsx` as for `format=csv`. Ablation: on the unfixed code both xlsx rows are red (`expected 200 to be 400`); with the fix, green. - Preservation: a successful xlsx export over one page, three pages (`page=50`, 120 rows) and zero rows answers 200, the workbook content type, `X-Export-Styles: applied`, the same number of reads, and a workbook that loads with the same cells (header + rows; empty for zero rows). 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. After merging `main` (which brought #27795): `@objectstack/rest` `tsc --noEmit` green and full suite green (353 files, 7027 passed, 350 skipped); the derived gate list is unchanged. 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_016WV4cyUucJoh7384vix5rh Co-authored-by: Claude <noreply@anthropic.com>
1 parent d770b15 commit 2a3dec2

3 files changed

Lines changed: 180 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=xlsx` answers the 400 envelope, not a 200 workbook stream
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The xlsx export opened its streaming workbook writer before its first read. Opening the writer 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 workbook download, while `format=csv` and `format=json` answered the 400 envelope.
11+
12+
- **Read first.** The workbook writer now opens after the first page is read, as the csv and json exports write their 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 the same status, headers and workbook cells.
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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1457,7 +1457,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
14571457
let exported = 0;
14581458
let firstChunk = true;
14591459
let skip = 0;
1460-
const xlsx = format === 'xlsx' ? await createXlsxStream(res, styled) : null;
1460+
let xlsx: Awaited<ReturnType<typeof createXlsxStream>> | null = null;
14611461

14621462
while (exported < limit) {
14631463
const take = Math.min(chunkSize, limit - exported);
@@ -1507,6 +1507,12 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
15071507
// runs at least once (`limit` is at least 1), so the
15081508
// closing `]` below always has its opener.
15091509
if (format === 'json' && firstChunk) res.write('[');
1510+
// [#27755] The xlsx workbook writer opens after the first
1511+
// read for the same reason: opening it commits the stream
1512+
// too, so a refused query answered 200 instead of the 400
1513+
// envelope. Opened before the zero-row break, so an empty
1514+
// export is still a readable workbook.
1515+
if (format === 'xlsx' && firstChunk) xlsx = await createXlsxStream(res, styled);
15101516

15111517
if (rows.length === 0) break;
15121518

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

0 commit comments

Comments
 (0)