diff --git a/packages/rest/src/export-business-timezone.test.ts b/packages/rest/src/export-business-timezone.test.ts index c0a7be43c3..cde9ea5b44 100644 --- a/packages/rest/src/export-business-timezone.test.ts +++ b/packages/rest/src/export-business-timezone.test.ts @@ -31,13 +31,13 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import ExcelJS from 'exceljs'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server.js'; import { formatCellValue, formatRowCells, formatRowForJson } from './export-format.js'; import type { ExportFieldMeta } from './export-format.js'; +import { loadXlsxWorkbook } from './xlsx-test-loader.js'; // The instant at the heart of the report: 2026-08-01 06:00 in +08 is // 2026-07-31 22:00 in UTC — a different day, month and quarter-of-year. @@ -238,8 +238,7 @@ async function xlsxRow(timezone?: string): Promise { const route = await boot(timezone); const { res, getBuffer } = makeBinRes(); await route.handler({ params: { object: 'shift' }, query: { format: 'xlsx' } } as any, res); - const wb = new ExcelJS.Workbook(); - await wb.xlsx.load(getBuffer() as any); + const wb = await loadXlsxWorkbook(getBuffer()); return (wb.worksheets[0].getRow(2).values as any[]).slice(1).map((v) => String(v)); } diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index 3673495f35..10660367b8 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -33,12 +33,12 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import ExcelJS from 'exceljs'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { maskFieldValue } from '@objectstack/plugin-security'; import { RestServer } from './rest-server'; +import { loadXlsxWorkbook } from './xlsx-test-loader.js'; // --------------------------------------------------------------------------- // The real backend: better-sqlite3 `:memory:`, constructed the canonical way @@ -211,8 +211,7 @@ describe('export route — real engine + protocol integration', () => { expect(headers['Content-Type']).toBe( 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ); - const wb = new ExcelJS.Workbook(); - await wb.xlsx.load(getBuffer() as any); + const wb = await loadXlsxWorkbook(getBuffer()); const ws = wb.worksheets[0]; const header = (ws.getRow(1).values as any[]).slice(1).map((v) => String(v)); expect(header).toEqual(['ID', '标题', '完成', '优先级', '截止', '负责人']); @@ -227,8 +226,7 @@ describe('export route — real engine + protocol integration', () => { // Default limit (10000) is within the style cap, so colours are applied. expect(headers['X-Export-Styles']).toBe('applied'); - const wb = new ExcelJS.Workbook(); - await wb.xlsx.load(getBuffer() as any); + const wb = await loadXlsxWorkbook(getBuffer()); const ws = wb.worksheets[0]; // priority is column 4 (ID, 标题, 完成, 优先级, ...). const highCell = ws.getRow(2).getCell(4); // '高' → #e11d48 @@ -247,8 +245,7 @@ describe('export route — real engine + protocol integration', () => { ); expect(headers['X-Export-Styles']).toBe('dropped'); - const wb = new ExcelJS.Workbook(); - await wb.xlsx.load(getBuffer() as any); + const wb = await loadXlsxWorkbook(getBuffer()); const ws = wb.worksheets[0]; // Data is intact... const r1 = (ws.getRow(2).values as any[]).slice(1).map((v) => String(v)); @@ -528,8 +525,7 @@ describe('export route — FLS column projection via getReadableFields (#3547)', }); const { res, getBuffer } = makeBinRes(); await route.handler({ params: { object: 'task' }, query: { format: 'xlsx' } } as any, res); - const wb = new ExcelJS.Workbook(); - await wb.xlsx.load(getBuffer() as any); + const wb = await loadXlsxWorkbook(getBuffer()); const ws = wb.worksheets[0]; expect((ws.getRow(1).values as any[]).slice(1)).toEqual(['ID', '完成']); expect(ws.rowCount).toBe(1); // header only @@ -610,8 +606,7 @@ describe('export route — search', () => { { params: { object: 'task' }, query: { format: 'xlsx', search: '代码' } } as any, res, ); - const wb = new ExcelJS.Workbook(); - await wb.xlsx.load(getBuffer() as any); + const wb = await loadXlsxWorkbook(getBuffer()); expect(wb.worksheets[0].rowCount).toBe(2); // header + one match }); }); diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 4e2fe9ae11..10020c00d8 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1,11 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import ExcelJS from 'exceljs'; import { RouteManager } from './route-manager'; import { RestServer, mapDataError } from './rest-server'; import { createRestApiPlugin } from './rest-api-plugin'; import type { RestApiPluginConfig } from './rest-api-plugin'; +import { loadXlsxWorkbook } from './xlsx-test-loader.js'; // --------------------------------------------------------------------------- // Mocks & Helpers @@ -1263,8 +1263,7 @@ describe('RestServer', () => { // xlsx is a zip — verify the PK signature, then round-trip the content. expect(buf.subarray(0, 2).toString('latin1')).toBe('PK'); - const wb = new ExcelJS.Workbook(); - await wb.xlsx.load(buf); + const wb = await loadXlsxWorkbook(buf); const ws = wb.getWorksheet('Export'); expect(ws).toBeDefined(); // row.values is 1-indexed (values[0] is empty). diff --git a/packages/rest/src/xlsx-test-loader.ts b/packages/rest/src/xlsx-test-loader.ts new file mode 100644 index 0000000000..f3ce5889bf --- /dev/null +++ b/packages/rest/src/xlsx-test-loader.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The one place this package asserts around `exceljs`'s broken `load` signature + * (#13378). Test layer only — nothing in `src/index.ts` reaches it, so tsup + * (entry: `src/index.ts`) never emits it into `dist` and it is not published. + * + * ## Why the assertion below is unavoidable, in the dependency's own bytes + * + * `exceljs@4.4.0/index.d.ts` opens, at line 1, with: + * + * ```ts + * declare interface Buffer extends ArrayBuffer { } + * ``` + * + * That file carries 106 top-level `export` declarations, so it **is a module** — + * which makes this `Buffer` module-local, and it therefore SHADOWS Node's global + * `Buffer` inside every exceljs signature. The one that matters here is the + * `Xlsx.load` at `index.d.ts:1490`: + * + * ```ts + * load(buffer: Buffer, options?: Partial): Promise; + * ``` + * + * ⇒ `Workbook.xlsx.load` does not ask for a Node `Buffer`. It asks for something + * structurally identical to `ArrayBuffer`. A Node `Buffer` is a `Uint8Array`, so + * it is not assignable, and tsc says so precisely: + * + * ``` + * src/rest.test.ts(1267,26): error TS2345: Argument of type 'Buffer' is not assignable to parameter of type 'Buffer'. + * The types of 'slice(...)[Symbol.toStringTag]' are incompatible between these types. + * Type '"Uint8Array"' is not assignable to type '"ArrayBuffer"'. + * ``` + * + * ⭐ There is NO Node `Buffer` value that satisfies that parameter. The defect is + * in the published declaration, not at any call site — so this is not laziness, + * and no amount of care at a call site can remove it. What a call site CAN do is + * not restate it: before #13378 the package paid this at 6 anonymous `as any`s + * and left a 7th site as a ledgered `TS2345`. Now it is stated once, here. + * + * ## Why option C (upgrade) is not the answer — measured 2026-08-30 + * + * `exceljs` `dist-tags.latest` IS 4.4.0 (published 2023-10-19). The only publish + * after it in the package's whole 166-version history is `4.4.1-prerelease.0` + * (2024-12-20), and its `index.d.ts` carries the identical declaration at the + * identical lines: shim at line 1, 106 exports, `load(buffer: Buffer, …)` at + * 1490. The registry's `time.modified` is that same date. There is no later line + * to pin to. (4.3.0 ships the shim too, so this is not a 4.4.0 regression that a + * bump could undo.) + * + * ## Why not a declaration override (option B) + * + * The `Buffer` above is module-local, so it cannot be reached by interface + * augmentation from outside; an override would have to redeclare the module, + * replacing the package's entire typing surface. Far more surface than the one + * assertion it would remove. + * + * ## Runtime is untouched + * + * exceljs's `load` has always accepted the Node `Buffer` these tests hand it — + * that is what every one of those 6 `as any` sites was doing, and passing. This + * helper changes only what tsc is told; it does not convert, copy or reshape the + * bytes. + */ + +import ExcelJS from 'exceljs'; +import type { Workbook, Xlsx } from 'exceljs'; + +/** + * The parameter type `Xlsx.load` actually declares, read off the dependency's + * own signature instead of spelled by hand. Naming it this way means that if + * exceljs ever drops the shim, this alias resolves to Node's `Buffer` and the + * assertion below quietly becomes a no-op rather than a lie. + */ +type XlsxLoadInput = Parameters[0]; + +/** + * Load .xlsx bytes into a fresh {@link Workbook}. + * + * The single site in this package where the exceljs declaration defect above is + * asserted away. Callers pass the Node `Buffer` their fixture produced and get + * back a workbook; no call site needs to know about any of this. + */ +export async function loadXlsxWorkbook(bytes: Buffer): Promise { + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(bytes as unknown as XlsxLoadInput); + return wb; +} diff --git a/packages/rest/test-typecheck-debt.json b/packages/rest/test-typecheck-debt.json index cf3fc4cf3c..62004836f6 100644 --- a/packages/rest/test-typecheck-debt.json +++ b/packages/rest/test-typecheck-debt.json @@ -1,9 +1,9 @@ { "_comment": "Per-file tsc error debt of the @objectstack/rest TEST layer (#5286). `tsconfig.test.json` compiles `src/**/*.test.ts` — which `tsconfig.json` excludes and therefore no gate ever read — and every file below still carries errors from before that gate existed. THIS FIELD IS GENERATED: every regeneration rewrites it from scripts/check-test-typecheck.mts, and the EXACT ratchet below requires a regeneration on every repair — so an edit made here is gone by the next one. Anything true of THIS package goes in the sibling `_note` field, which is authored, is preserved verbatim, and is never written by the generator (#12624). This comment states NO cause for the errors, deliberately: the classes differ per package and per file, they move as the debt is paid down, and a cause written here is rewritten verbatim into every ledger by every regeneration — so it outlives its own repair and cannot be corrected in the file where it is read. Measure instead, before repairing anything: `tsc --noEmit --pretty false -p tsconfig.test.json` in the package prints the real classes with their TS codes. EXACT ratchet, judged by re-running tsc: a file that gains errors is red, a file that loses them is red until its number is re-recorded, a file that reaches zero is red until its entry is deleted, and a file NOT listed here may have no errors at all. Regenerate with: pnpm --filter @objectstack/rest gen:test-typecheck-debt", - "_note": "Everything still recorded here is held by its own card, and none of it is an annotation repair. #13377 holds the request literals this package builds by hand against IHttpRequest, which omit members that interface requires. #13378 holds the exceljs call whose parameter type resolves to that package's own module-local Buffer declaration rather than Node's. Repairing either one here would mean changing a fixture's data, or adding an assertion this package's paydown rules out, so these entries shrink when those cards land and not before.", + "_note": "Everything still recorded here is held by its own card, and none of it is an annotation repair. #13377 holds every entry that remains: the request literals this package builds by hand against IHttpRequest, which omit members that interface requires. Repairing them here would mean changing a fixture's data, so these entries shrink when that card lands and not before. The exceljs call that #13378 held is no longer in this ledger: that dependency declares its own module-local Buffer, which shadows Node's inside every exceljs signature, so no Node Buffer can be passed to Workbook.xlsx.load — the assertion that costs is now stated once, in src/xlsx-test-loader.ts, and every xlsx-reading test in this package goes through it.", "entries": { "src/meta-public-book-grant.test.ts": 1, "src/rest-batch-size-cap.test.ts": 1, - "src/rest.test.ts": 3 + "src/rest.test.ts": 2 } }