From 8fda00bbbfcc27b4c264da5b37c7ab4ef843b051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Tue, 30 Jun 2026 21:51:41 -0700 Subject: [PATCH 1/2] feat(rest): streaming list export route (csv/xlsx/json), locale-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /data/:object/export streams CSV, XLSX (exceljs WorkbookWriter) or JSON with type-aware, human-readable cells: lookup/user/master_detail → referenced record name, select/radio → option label, multiselect → joined labels, boolean → 是/否, dates formatted. Reference fields are auto-$expanded (batched $in inside findData, no N+1). Header row uses localized field labels (Accept-Language / ?locale=) via translateMetaItem so the exported sheet matches the console column headers instead of leaking raw labels. Closes #2481 --- .../app-crm/src/views/opportunity.view.ts | 2 + packages/rest/package.json | 4 + packages/rest/src/export-format.ts | 199 ++++++++ packages/rest/src/export-integration.test.ts | 280 +++++++++++ packages/rest/src/rest-server.ts | 177 +++++-- packages/rest/src/rest.test.ts | 136 ++++++ packages/rest/tsconfig.json | 3 +- pnpm-lock.yaml | 434 +++++++++++++++++- 8 files changed, 1199 insertions(+), 36 deletions(-) create mode 100644 packages/rest/src/export-format.ts create mode 100644 packages/rest/src/export-integration.test.ts diff --git a/examples/app-crm/src/views/opportunity.view.ts b/examples/app-crm/src/views/opportunity.view.ts index 1921a06617..999cef5740 100644 --- a/examples/app-crm/src/views/opportunity.view.ts +++ b/examples/app-crm/src/views/opportunity.view.ts @@ -16,6 +16,7 @@ export const OpportunityViews = defineView({ { field: 'expected_revenue' }, { field: 'close_date' }, ], + exportOptions: ['csv', 'xlsx', 'json'], }, listViews: { all: { @@ -31,6 +32,7 @@ export const OpportunityViews = defineView({ { field: 'expected_revenue' }, { field: 'close_date' }, ], + exportOptions: ['csv', 'xlsx', 'json'], }, pipeline: { label: 'Pipeline (Kanban)', diff --git a/packages/rest/package.json b/packages/rest/package.json index de75806d45..dd9cb590de 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -22,9 +22,13 @@ "@objectstack/core": "workspace:*", "@objectstack/service-package": "workspace:*", "@objectstack/spec": "workspace:*", + "exceljs": "^4.4.0", "zod": "^4.4.3" }, "devDependencies": { + "@objectstack/metadata-protocol": "workspace:*", + "@objectstack/objectql": "workspace:*", + "@types/node": "^26.0.1", "typescript": "^6.0.3", "vitest": "^4.1.9" }, diff --git a/packages/rest/src/export-format.ts b/packages/rest/src/export-format.ts new file mode 100644 index 0000000000..c367fa4f80 --- /dev/null +++ b/packages/rest/src/export-format.ts @@ -0,0 +1,199 @@ +/** + * Type-aware value formatting for the streaming data export route + * (`GET /data/:object/export`). + * + * The raw rows returned by `findData` carry *storage* values: lookup / user + * fields hold ids (or, when `$expand`-ed, nested records), select fields hold + * option codes, booleans hold true/false, dates hold ISO strings. None of those + * read well in a spreadsheet. These helpers turn each value into a human + * readable cell using the object's field metadata. + * + * Contract: when no field metadata is available (schema lookup failed or carried + * no fields) every helper is a pass-through, so the export stays byte-for-byte + * identical to the un-formatted behaviour. + */ + +export interface ExportFieldMeta { + name: string; + type?: string; + label?: string; + options?: Array<{ label?: string; value?: unknown }>; + /** Target object for lookup / master_detail / user fields. */ + reference?: string; + /** Field on the referenced record to show as its label. */ + displayField?: string; +} + +/** Field types whose stored value points at another record. */ +const REFERENCE_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']); + +/** Field types whose stored value maps to a static option label. */ +const OPTION_TYPES = new Set(['select', 'radio']); +const MULTI_OPTION_TYPES = new Set(['multiselect', 'checkboxes', 'tags']); + +/** + * Keys tried, in order, to derive a referenced record's display value when the + * field carries no explicit `displayField`. + */ +const NAME_KEY_FALLBACKS = [ + 'name', 'title', 'label', 'full_name', 'fullName', 'display_name', 'username', 'email', +]; + +/** + * Build a field-name → metadata map from an object schema (best-effort). + * + * Accepts both shapes `fields` appears in across the stack: the runtime + * `ObjectSchema.fields` is a `Record` object map + * (the form served by the engine registry / `getMetaItem`), while some callers + * and fixtures hand back a plain `FieldDefinition[]` array. A field's name is + * taken from its own `name`, falling back to the map key. + */ +export function buildFieldMetaMap(schema: unknown): Map { + const map = new Map(); + const fields = (schema as { fields?: unknown })?.fields; + + // Normalize either shape to a list of [name, definition] entries. + let entries: Array<[string, any]>; + if (Array.isArray(fields)) { + entries = fields + .filter((f) => f && typeof f === 'object') + .map((f) => [typeof f.name === 'string' ? f.name : '', f] as [string, any]); + } else if (fields && typeof fields === 'object') { + entries = Object.entries(fields as Record).map( + ([key, def]) => [ + def && typeof def === 'object' && typeof def.name === 'string' ? def.name : key, + def, + ] as [string, any], + ); + } else { + return map; + } + + for (const [name, f] of entries) { + if (!name || !f || typeof f !== 'object') continue; + map.set(name, { + name, + type: typeof f.type === 'string' ? f.type : undefined, + label: typeof f.label === 'string' ? f.label : undefined, + options: Array.isArray(f.options) ? f.options : undefined, + reference: typeof f.reference === 'string' ? f.reference : undefined, + displayField: typeof f.displayField === 'string' ? f.displayField : undefined, + }); + } + return map; +} + +/** + * Reference-typed field names that should be `$expand`-ed so their stored ids + * resolve to the referenced record (and thus to a readable name). + */ +export function referenceFieldNames(metaMap: Map): string[] { + const out: string[] = []; + for (const meta of metaMap.values()) { + if (meta.type && REFERENCE_TYPES.has(meta.type) && meta.reference) out.push(meta.name); + } + return out; +} + +/** Header label for a column: schema label when present, else the field name. */ +export function headerLabel(field: string, metaMap: Map): string { + return metaMap.get(field)?.label || field; +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +function toDate(value: unknown): Date | null { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + if (typeof value === 'number' || typeof value === 'string') { + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; + } + return null; +} + +/** `YYYY-MM-DD` (date) or `YYYY-MM-DD HH:mm:ss` (datetime), in UTC. */ +function formatDate(value: unknown, withTime: boolean): unknown { + const d = toDate(value); + if (!d) return value; + const ymd = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; + if (!withTime) return ymd; + return `${ymd} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`; +} + +function optionLabel(value: unknown, options?: Array<{ label?: string; value?: unknown }>): unknown { + if (!options) return value; + const hit = options.find((o) => o && o.value === value); + return hit?.label ?? value; +} + +function displayFromRecord(rec: Record, displayField?: string): string { + if (displayField && rec[displayField] != null) return String(rec[displayField]); + for (const k of NAME_KEY_FALLBACKS) { + const v = rec[k]; + if (v != null && typeof v !== 'object') return String(v); + } + if (rec.id != null) return String(rec.id); + try { return JSON.stringify(rec); } catch { return String(rec); } +} + +function formatReference(value: unknown, displayField?: string): unknown { + const one = (v: unknown): unknown => + v && typeof v === 'object' ? displayFromRecord(v as Record, displayField) : v; + if (Array.isArray(value)) return value.map(one).join(', '); + return one(value); +} + +/** Format one storage value into a display value using its field metadata. */ +export function formatCellValue(value: unknown, meta?: ExportFieldMeta): unknown { + if (value === null || value === undefined) return value; + if (!meta || !meta.type) return value; + const t = meta.type; + if (t === 'boolean' || t === 'toggle') { + if (value === true || value === 'true' || value === 1) return '是'; + if (value === false || value === 'false' || value === 0) return '否'; + return value; + } + if (OPTION_TYPES.has(t)) return optionLabel(value, meta.options); + if (MULTI_OPTION_TYPES.has(t)) { + const arr = Array.isArray(value) ? value : [value]; + return arr.map((v) => optionLabel(v, meta.options)).join(', '); + } + if (t === 'date') return formatDate(value, false); + if (t === 'datetime') return formatDate(value, true); + if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField); + return value; +} + +/** Ordered display cells for one row — the CSV / XLSX column path. */ +export function formatRowCells( + row: Record, + fields: string[], + metaMap: Map, +): unknown[] { + return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f))); +} + +/** + * Format a row for JSON output: readable values for known fields, every other + * key left untouched. Returns the original object reference when nothing needs + * formatting so the stream stays byte-identical to the un-formatted path. + */ +export function formatRowForJson( + row: Record, + metaMap: Map, +): Record { + if (metaMap.size === 0 || !row || typeof row !== 'object') return row; + let copy: Record | null = null; + for (const key of Object.keys(row)) { + const meta = metaMap.get(key); + if (!meta) continue; + const formatted = formatCellValue(row[key], meta); + if (formatted !== row[key]) { + if (!copy) copy = { ...row }; + copy[key] = formatted; + } + } + return copy ?? row; +} diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts new file mode 100644 index 0000000000..073162db08 --- /dev/null +++ b/packages/rest/src/export-integration.test.ts @@ -0,0 +1,280 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * End-to-end export integration: the REAL streaming export route driven by a + * REAL {@link ObjectQL} engine + {@link ObjectStackProtocolImplementation}, + * an in-memory driver, and real registered objects — no protocol mocks. + * + * This is the test the mocked `rest.test.ts` export suite could not be: those + * stubbed `getObjectSchema` (a method with no real implementation) and pre-shaped + * `findData` to return `{ data }` with an already-`$expand`-ed `owner`. That + * green masked three production bugs: + * 1. the route called the dead `getObjectSchema` hook → no field metadata in + * production → zero formatting; + * 2. `buildFieldMetaMap` only understood the array `fields` shape, not the + * object-map the engine registry actually serves; + * 3. the route read `result.data`, but real `findData` returns `{ records }` + * → every production export streamed ZERO rows (an empty file). + * + * Here the readable cells (完成→是, 优先级→高, 负责人→张三) are produced by the + * real metadata accessor (`getMetaItem`) and a real `$expand` that resolves the + * lookup id `u1` to its record — exactly the path a deployed server runs. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import ExcelJS from 'exceljs'; +import { ObjectQL, ObjectStackProtocolImplementation } from '@objectstack/objectql'; +import { RestServer } from './rest-server'; + +// --------------------------------------------------------------------------- +// In-memory driver — equality + `$in` (the latter is what `$expand` issues when +// it batch-fetches referenced records: `where: { id: { $in: [...] } }`). +// --------------------------------------------------------------------------- +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + let nextId = 0; + const matchOne = (cell: unknown, cond: unknown): boolean => { + if (cond && typeof cond === 'object' && !Array.isArray(cond)) { + const c = cond as Record; + if ('$in' in c) return Array.isArray(c.$in) && c.$in.some((x) => (cell ?? null) === (x ?? null)); + if ('$eq' in c) return (cell ?? null) === ((c.$eq as unknown) ?? null); + if ('$ne' in c) return (cell ?? null) !== ((c.$ne as unknown) ?? null); + } + return (cell ?? null) === ((cond as unknown) ?? null); + }; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + if (!matchOne(row[k], v)) return false; + } + return true; + }; + const sortRows = (rows: Record[], orderBy: any): Record[] => { + if (!orderBy) return rows; + // Accept {field:'asc'|'desc'} | [['field','asc']] | ['field'] + const specs: Array<[string, 'asc' | 'desc']> = []; + if (Array.isArray(orderBy)) { + for (const o of orderBy) { + if (Array.isArray(o)) specs.push([String(o[0]), o[1] === 'desc' ? 'desc' : 'asc']); + else if (typeof o === 'string') specs.push([o, 'asc']); + } + } else if (typeof orderBy === 'object') { + for (const [f, d] of Object.entries(orderBy)) specs.push([f, d === 'desc' ? 'desc' : 'asc']); + } + if (specs.length === 0) return rows; + return [...rows].sort((a, b) => { + for (const [f, d] of specs) { + const av = a[f] as any, bv = b[f] as any; + if (av === bv) continue; + const cmp = av < bv ? -1 : 1; + return d === 'desc' ? -cmp : cmp; + } + return 0; + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(o: string, ast: any) { + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + const sorted = sortRows(rows, ast?.orderBy ?? ast?.sort ?? ast?.order); + const skip = Number(ast?.skip ?? ast?.offset ?? 0) || 0; + const limit = ast?.limit ?? ast?.top; + const sliced = limit != null ? sorted.slice(skip, skip + Number(limit)) : sorted.slice(skip); + return sliced; + }, + findStream() { throw new Error('ns'); }, + async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; }, + async create(o: string, data: Record) { + nextId += 1; const id = (data.id as string) ?? `r_${nextId}`; const row = { ...data, id }; storeFor(o).set(id, row); return row; + }, + async update(o: string, id: string, data: Record) { + const s = storeFor(o); const cur = s.get(id); if (!cur) throw new Error(`nf ${o}/${id}`); + const up = { ...cur, ...data, id }; s.set(id, up); return up; + }, + async upsert(o: string, data: Record) { const id = data.id as string | undefined; return id && storeFor(o).has(id) ? this.update(o, id, data) : this.create(o, data); }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string, ast: any) { return (await this.find(o, ast)).length; }, + async bulkCreate(o: string, rows: Record[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +// --------------------------------------------------------------------------- +// Objects — object-map `fields` (the engine's real shape), mixed value types. +// systemFields:false keeps the column set deterministic (just our fields). +// --------------------------------------------------------------------------- +const USER = { + name: 'user', + label: 'User', + systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const, label: '姓名' }, + }, +}; + +const TASK = { + name: 'task', + label: 'Task', + systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + title: { name: 'title', type: 'text' as const, label: '标题' }, + done: { name: 'done', type: 'boolean' as const, label: '完成' }, + priority: { + name: 'priority', type: 'select' as const, label: '优先级', + options: [{ label: '高', value: 'high' }, { label: '低', value: 'low' }], + }, + due: { name: 'due', type: 'date' as const, label: '截止' }, + owner: { name: 'owner', type: 'lookup' as const, label: '负责人', reference: 'user', displayField: 'name' }, + }, +}; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const chunks: string[] = []; + const headers: Record = {}; + let status = 200; + const res: any = { + write: (s: string) => { chunks.push(typeof s === 'string' ? s : String(s)); return true; }, + end: () => {}, + header: (n: string, v: string) => { headers[n] = v; return res; }, + status: (code: number) => { status = code; return res; }, + json: (body: any) => { (res as any)._json = body; return res; }, + }; + return { res, chunks, headers, getStatus: () => status, getJson: () => (res as any)._json }; +} + +function makeBinRes() { + const chunks: Buffer[] = []; + const headers: Record = {}; + const res: any = { + write: (c: any) => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)); return true; }, + end: () => {}, + header: (n: string, v: string) => { headers[n] = v; return res; }, + status: () => res, + json: () => res, + }; + return { res, getBuffer: () => Buffer.concat(chunks), headers }; +} + +async function boot() { + const { driver } = makeMemoryDriver(); + const engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(USER as any); + engine.registry.registerObject(TASK as any); + await engine.insert('user', { id: 'u1', name: '张三' }); + await engine.insert('user', { id: 'u2', name: '李四' }); + // owner stored as a bare id — the readable name must come from a real $expand. + await engine.insert('task', { id: '1', title: '写代码', done: true, priority: 'high', due: '2026-06-30T00:00:00.000Z', owner: 'u1' }); + await engine.insert('task', { id: '2', title: '写文档', done: false, priority: 'low', due: '2026-07-01T00:00:00.000Z', owner: 'u2' }); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any); + rest.registerRoutes(); + const route = rest.getRoutes().find( + (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', + ); + return { engine, protocol, route }; +} + +function parseCsv(text: string): string[][] { + return text.split('\r\n').filter((l) => l.length > 0).map((l) => l.split(',')); +} + +describe('export route — real engine + protocol integration', () => { + let route: any; + let protocol: any; + + beforeEach(async () => { + ({ route, protocol } = await boot()); + expect(route).toBeDefined(); + }); + + it('the real metadata accessor returns the task object schema', async () => { + // Probe: proves getMetaItem (registry-first) sees a registerObject'd object, + // i.e. the accessor the route now relies on actually resolves in production. + const res = await protocol.getMetaItem({ type: 'object', name: 'task' }); + const schema = res && typeof res === 'object' && 'item' in res ? (res as any).item : res; + expect(schema).toBeTruthy(); + expect(schema.fields).toBeTruthy(); + expect(schema.fields.done?.type).toBe('boolean'); + expect(schema.fields.owner?.reference).toBe('user'); + }); + + it('CSV: formats every value type readably; owner name comes from a REAL $expand', async () => { + const { res, chunks, headers } = makeRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, res); + + expect(headers['Content-Type']).toBe('text/csv; charset=utf-8'); + const rows = parseCsv(chunks.join('')); + // Header from schema labels; column order from schema field order. + expect(rows[0]).toEqual(['ID', '标题', '完成', '优先级', '截止', '负责人']); + // boolean→是, select→高, date→YYYY-MM-DD, lookup id u1 → 张三 (via $expand). + expect(rows[1]).toEqual(['1', '写代码', '是', '高', '2026-06-30', '张三']); + expect(rows[2]).toEqual(['2', '写文档', '否', '低', '2026-07-01', '李四']); + }); + + it('CSV: is NON-EMPTY — regression for the findData `.records` vs `.data` bug', async () => { + const { res, chunks } = makeRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, res); + const dataRows = parseCsv(chunks.join('')).slice(1); // drop header + // The mocked suite returned `{ data }`; real findData returns `{ records }`. + // If the route only read `.data`, this would be 0 — an empty production file. + expect(dataRows.length).toBe(2); + }); + + it('XLSX: opens as a real workbook with formatted cells', async () => { + const { res, getBuffer, headers } = makeBinRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'xlsx' } } as any, res); + + expect(headers['Content-Type']).toBe( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(getBuffer() as any); + const ws = wb.worksheets[0]; + const header = (ws.getRow(1).values as any[]).slice(1).map((v) => String(v)); + expect(header).toEqual(['ID', '标题', '完成', '优先级', '截止', '负责人']); + const r1 = (ws.getRow(2).values as any[]).slice(1).map((v) => String(v)); + expect(r1).toEqual(['1', '写代码', '是', '高', '2026-06-30', '张三']); + }); + + it('JSON: readable values, all rows present', async () => { + const { res, chunks, headers } = makeRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'json' } } as any, res); + + expect(headers['Content-Type']).toBe('application/json; charset=utf-8'); + const arr = JSON.parse(chunks.join('')); + expect(arr).toHaveLength(2); + expect(arr[0]).toMatchObject({ title: '写代码', done: '是', priority: '高', due: '2026-06-30', owner: '张三' }); + }); + + it('filter + orderby are plumbed to the engine (only done=true, desc by id)', async () => { + const { res, chunks } = makeRes(); + await route.handler({ + params: { object: 'task' }, + query: { format: 'csv', filter: JSON.stringify({ done: true }), orderby: 'id:desc' }, + } as any, res); + + const dataRows = parseCsv(chunks.join('')).slice(1); + // Only the done=true task survives the filter. + expect(dataRows.map((r) => r[0])).toEqual(['1']); + expect(dataRows[0][2]).toBe('是'); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index ab40cf5593..ab57f8cf84 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -4,6 +4,14 @@ import { IHttpServer, resolveAuthzContext, resolveLocalizationContext, isAuthGat import { RouteManager } from './route-manager.js'; import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api'; import { ObjectStackProtocol } from '@objectstack/spec/api'; +import { + buildFieldMetaMap, + referenceFieldNames, + headerLabel, + formatRowCells, + formatRowForJson, + type ExportFieldMeta, +} from './export-format.js'; // Node-safe logger — avoids importing 'console' which is absent from ES2020 lib typings. const logError = (...args: unknown[]) => (globalThis as any).console?.error(...args); @@ -394,18 +402,67 @@ function formatCsvCell(value: any): string { } /** - * Serialise a list of rows to RFC-4180 CSV text. Caller supplies the - * ordered list of field names; unknown fields produce empty cells. + * Serialise a list of rows to RFC-4180 CSV text. Caller supplies the ordered + * list of field names and a (possibly empty) field-metadata map. With metadata, + * the header row uses field labels and cell values are formatted to readable + * display values (lookup names, select labels, 是/否, formatted dates). With an + * empty map the output is byte-identical to the raw, un-formatted behaviour. */ -function rowsToCsv(fields: string[], rows: Array>, includeHeader: boolean): string { +function rowsToCsv( + fields: string[], + rows: Array>, + includeHeader: boolean, + metaMap: Map, +): string { const lines: string[] = []; - if (includeHeader) lines.push(fields.map(formatCsvCell).join(',')); + if (includeHeader) lines.push(fields.map(f => formatCsvCell(headerLabel(f, metaMap))).join(',')); for (const row of rows) { - lines.push(fields.map(f => formatCsvCell(row?.[f])).join(',')); + lines.push(formatRowCells(row, fields, metaMap).map(formatCsvCell).join(',')); } return lines.join('\r\n') + (lines.length > 0 ? '\r\n' : ''); } +/** + * Bridge exceljs' streaming workbook writer onto the chunked HTTP response. + * + * exceljs writes to a Node stream; we pipe a PassThrough's `data` events into + * `res.write` (which the hono server encodes — strings via TextEncoder, binary + * Buffers/Uint8Arrays enqueued verbatim) so the xlsx bytes stream straight to + * the client without buffering the whole workbook in memory. `useStyles:false` + * keeps the writer lean for large (20k+ row) exports. + * + * Returns the worksheet to append rows to and a `finalize()` that commits the + * workbook and resolves once the last byte has been flushed and the response + * ended. Dynamically imported so `node:stream` / `exceljs` stay out of the + * module's static graph. + */ +async function createXlsxStream(res: any): Promise<{ + ws: any; + finalize: () => Promise; +}> { + const { PassThrough } = await import('node:stream'); + const ExcelJS: any = (await import('exceljs')).default ?? (await import('exceljs')); + + const passthrough = new PassThrough(); + const done = new Promise((resolve, reject) => { + passthrough.on('data', (chunk: Buffer) => { res.write(chunk); }); + passthrough.on('end', () => { try { res.end(); } catch { /* swallow */ } resolve(); }); + passthrough.on('error', reject); + }); + + const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles: false }); + const ws = wb.addWorksheet('Export'); + + return { + ws, + finalize: async () => { + await ws.commit(); + await wb.commit(); + await done; + }, + }; +} + /** * Structural subset of `KernelManager` that RestServer needs in order to * resolve a per-project protocol at request time. Typed locally to avoid @@ -3232,14 +3289,21 @@ export class RestServer { // GET /data/:object/export — streaming export (M10.21 / C.21) // // Query params: - // format=csv|json (default: csv. json emits a JSON array.) + // format=csv|json|xlsx (default: csv. json emits a JSON array, xlsx a workbook.) // fields=a,b,c (default: derive from object schema; falls back to keys of the first row) // filter= ($filter as URL-encoded JSON, same shape as list endpoint) // orderby=field:desc (optional ordering, mirrors $orderby semantics) + // header=false (omit the header row for csv / xlsx; default true) // limit= (default 10000, hard cap 50000) // page= (driver chunk size, default 500, max 5000) // - // Streams the response so 50k-row exports do not buffer in memory. + // Values are formatted for readability from the object schema: lookup / + // user fields resolve to a name (via injected $expand), select fields to + // their option label, booleans to 是/否, dates to YYYY-MM-DD. When the + // schema is unavailable the raw stored values stream through unchanged. + // + // Streams the response so 50k-row exports do not buffer in memory; the + // xlsx path pipes exceljs' streaming writer straight onto the response. // Filename suggests `${object}-${YYYY-MM-DD}.${ext}` for browsers. this.routeManager.register({ method: 'GET', @@ -3257,7 +3321,11 @@ export class RestServer { } if (await this.enforceApiAccess(req, res, p, environmentId, 'export')) return; const q = req.query ?? {}; - const format = (String(q.format ?? 'csv')).toLowerCase() === 'json' ? 'json' : 'csv'; + const fmtRaw = String(q.format ?? 'csv').toLowerCase(); + const format: 'csv' | 'json' | 'xlsx' = + fmtRaw === 'json' ? 'json' : fmtRaw === 'xlsx' ? 'xlsx' : 'csv'; + // Header row toggle (csv / xlsx). Default on; `header=false` omits it. + const includeHeader = String(q.header ?? 'true').toLowerCase() !== 'false'; const HARD_CAP = 50_000; const MAX_CHUNK = 5_000; const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 10_000; @@ -3297,15 +3365,43 @@ export class RestServer { } else if (Array.isArray(q.fields)) { fields = q.fields.filter((s: any) => typeof s === 'string' && s.length > 0); } - if (!fields || fields.length === 0) { - try { - const schema = await (p as any).getObjectSchema?.(objectName, environmentId); - const schemaFields = schema?.fields; - if (Array.isArray(schemaFields)) { - fields = schemaFields.map((f: any) => f.name).filter((n: any) => typeof n === 'string'); - } - } catch { /* fall back to first-row derivation */ } - } + + // Field metadata drives readable formatting (lookup names, select + // labels, 是/否, formatted dates) and the $expand that resolves + // references. Best-effort: when the schema is unavailable the export + // falls back to raw values, byte-identical to the un-formatted path. + let metaMap = new Map(); + try { + // Field metadata comes from the same place `findData` resolves + // the object: `getMetaItem` is registry-first (DB fallback), so + // it returns the live `ObjectSchema` whose `fields` is an object + // map. The read hands back an envelope `{ type, name, item }`; + // the schema document lives at `.item` (a cached/bare read may + // already be unwrapped). Legacy `getObjectSchema` is consulted + // as a last resort so existing test doubles keep working. + let schema: any = undefined; + if (typeof (p as any).getMetaItem === 'function') { + const res = await (p as any).getMetaItem({ type: 'object', name: objectName }); + schema = isMetaEnvelope(res) ? res.item : res; + } + if (!schema && typeof (p as any).getObjectSchema === 'function') { + schema = await (p as any).getObjectSchema(objectName, environmentId); + } + // Localize field labels to the request locale (Accept-Language / + // `?locale=`) the same way the metadata endpoints do, so the + // export header row matches the UI column headers instead of + // leaking the raw, untranslated `field.label` values. + schema = await this.translateMetaItem(req, 'object', environmentId, schema); + metaMap = buildFieldMetaMap(schema); + if (!fields || fields.length === 0) { + const names = [...metaMap.keys()]; + if (names.length > 0) fields = names; + } + } catch { /* fall back to first-row derivation + raw values */ } + + // Expand reference fields so lookup/user ids resolve to their record + // (and thus a name). Batched $in inside findData — no N+1. + const expandFields = referenceFieldNames(metaMap); // Prepare streaming response. Set headers BEFORE first write. const stamp = new Date().toISOString().slice(0, 10); @@ -3313,6 +3409,9 @@ export class RestServer { if (format === 'csv') { res.header('Content-Type', 'text/csv; charset=utf-8'); res.header('Content-Disposition', `attachment; filename="${safeObj}-${stamp}.csv"`); + } else if (format === 'xlsx') { + res.header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.header('Content-Disposition', `attachment; filename="${safeObj}-${stamp}.xlsx"`); } else { res.header('Content-Type', 'application/json; charset=utf-8'); res.header('Content-Disposition', `attachment; filename="${safeObj}-${stamp}.json"`); @@ -3325,6 +3424,7 @@ export class RestServer { let firstChunk = true; let skip = 0; if (format === 'json') res.write('['); + const xlsx = format === 'xlsx' ? await createXlsxStream(res) : null; while (exported < limit) { const take = Math.min(chunkSize, limit - exported); @@ -3333,6 +3433,7 @@ export class RestServer { query: { ...(filter ? { $filter: filter } : {}), ...(orderby ? { $orderby: orderby } : {}), + ...(expandFields.length > 0 ? { $expand: expandFields.join(',') } : {}), $top: take, $skip: skip, }, @@ -3340,23 +3441,35 @@ export class RestServer { ...(context ? { context } : {}), }; const result: any = await (p as any).findData(findArgs); - const rows: any[] = Array.isArray(result?.data) ? result.data - : Array.isArray(result?.rows) ? result.rows - : Array.isArray(result) ? result : []; + // `findData` returns `{ object, records, total, hasMore }`; + // accept the legacy `data` / `rows` aliases and a bare array + // so test doubles and alternate protocols keep working. + const rows: any[] = Array.isArray(result?.records) ? result.records + : Array.isArray(result?.data) ? result.data + : Array.isArray(result?.rows) ? result.rows + : Array.isArray(result) ? result : []; if (rows.length === 0) break; + // Derive fields from the first row if schema lookup failed. + if ((!fields || fields.length === 0) && firstChunk) { + fields = Object.keys(rows[0] ?? {}); + } + if (format === 'csv') { - // Derive fields from the first row if schema lookup failed. - if ((!fields || fields.length === 0) && firstChunk) { - fields = Object.keys(rows[0] ?? {}); - } - const text = rowsToCsv(fields ?? [], rows, firstChunk); + const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap); res.write(text); + } else if (format === 'xlsx') { + if (firstChunk && includeHeader) { + xlsx!.ws.addRow((fields ?? []).map((f) => headerLabel(f, metaMap))).commit(); + } + for (const row of rows) { + xlsx!.ws.addRow(formatRowCells(row, fields ?? [], metaMap)).commit(); + } } else { for (let i = 0; i < rows.length; i++) { const prefix = (firstChunk && i === 0) ? '' : ','; - res.write(prefix + JSON.stringify(rows[i])); + res.write(prefix + JSON.stringify(formatRowForJson(rows[i], metaMap))); } } firstChunk = false; @@ -3364,8 +3477,14 @@ export class RestServer { skip += rows.length; if (rows.length < take) break; } - if (format === 'json') res.write(']'); - res.end(); + if (format === 'json') { + res.write(']'); + res.end(); + } else if (format === 'xlsx') { + await xlsx!.finalize(); + } else { + res.end(); + } } catch (error: any) { logError('[REST] Unhandled error:', error); // Best-effort error envelope; if headers already sent the @@ -3375,7 +3494,7 @@ export class RestServer { } }, metadata: { - summary: 'Streaming export of object rows (CSV or JSON)', + summary: 'Streaming export of object rows (CSV, JSON, or XLSX)', tags: ['data', 'export'], }, }); diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index cedc659845..0d7c160263 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1,6 +1,7 @@ // 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, RouteGroupBuilder } from './route-manager'; import { RestServer, mapDataError } from './rest-server'; import { createRestApiPlugin } from './rest-api-plugin'; @@ -905,6 +906,141 @@ describe('RestServer', () => { expect(lines.length).toBe(50_001); }); + // Binary-aware response double for the xlsx path (chunks are Buffers). + function makeBinRes() { + const chunks: Buffer[] = []; + const headers: Record = {}; + const res: any = { + write: (c: any) => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)); return true; }, + end: vi.fn(), + header: (n: string, v: string) => { headers[n] = v; return res; }, + status: () => res, + json: vi.fn(), + }; + return { res, getBuffer: () => Buffer.concat(chunks), headers }; + } + + // Schema exercising every formatted field type. The export route reads it via + // the real metadata accessor `getMetaItem({type:'object', name})`, which + // returns an envelope whose `.item` is the schema document (see + // export-integration.test.ts for the same path against a real engine). + const TASK_SCHEMA = { + fields: [ + { name: 'id', type: 'text', label: 'ID' }, + { name: 'title', type: 'text', label: '标题' }, + { name: 'done', type: 'boolean', label: '完成' }, + { name: 'priority', type: 'select', label: '优先级', options: [ + { label: '高', value: 'high' }, { label: '低', value: 'low' }, + ] }, + { name: 'due', type: 'date', label: '截止' }, + { name: 'owner', type: 'lookup', label: '负责人', reference: 'user', displayField: 'name' }, + ], + }; + + function protocolWithSchema(rows: any[]) { + const p: any = createMockProtocol(); + // Real accessor: getMetaItem returns the `{type, name, item}` envelope. + p.getMetaItem = vi.fn().mockResolvedValue({ type: 'object', name: 'task', item: TASK_SCHEMA }); + p.findData = vi.fn() + .mockResolvedValueOnce({ data: rows }) + .mockResolvedValue({ data: [] }); + return p; + } + + // The stored row carries raw values; `owner` is an $expand-ed record. + const RAW_TASK_ROW = { + id: '1', title: '写代码', done: true, priority: 'high', + due: '2026-06-30T00:00:00.000Z', owner: { id: 'u1', name: '张三' }, + }; + + it('formats values readably in CSV using field metadata', async () => { + const p = protocolWithSchema([RAW_TASK_ROW]); + const rest = new RestServer(server as any, p as any); + rest.registerRoutes(); + const route = getExportRoute(rest); + + const { res, chunks, headers } = makeRes(); + await route!.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, res); + + expect(headers['Content-Type']).toBe('text/csv; charset=utf-8'); + const lines = chunks.join('').split('\r\n'); + // Header row uses schema labels, columns derived from the schema order. + expect(lines[0]).toBe('ID,标题,完成,优先级,截止,负责人'); + // boolean→是, select→label, date→YYYY-MM-DD, lookup→displayField name. + expect(lines[1]).toBe('1,写代码,是,高,2026-06-30,张三'); + }); + + it('omits the header row when header=false', async () => { + const p = protocolWithSchema([RAW_TASK_ROW]); + const rest = new RestServer(server as any, p as any); + rest.registerRoutes(); + const route = getExportRoute(rest); + + const { res, chunks } = makeRes(); + await route!.handler({ params: { object: 'task' }, query: { format: 'csv', header: 'false' } } as any, res); + + const lines = chunks.join('').split('\r\n').filter(Boolean); + // No label header — the first line is the data row. + expect(lines[0]).toBe('1,写代码,是,高,2026-06-30,张三'); + }); + + it('injects $expand for reference fields into the findData query', async () => { + const p = protocolWithSchema([RAW_TASK_ROW]); + const rest = new RestServer(server as any, p as any); + rest.registerRoutes(); + const route = getExportRoute(rest); + + const { res } = makeRes(); + await route!.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, res); + + expect(p.findData).toHaveBeenCalled(); + const firstQuery = p.findData.mock.calls[0][0].query; + expect(firstQuery.$expand).toBe('owner'); + }); + + it('formats values readably in JSON, leaving unknown keys untouched', async () => { + const p = protocolWithSchema([{ ...RAW_TASK_ROW, extra: 'keep' }]); + const rest = new RestServer(server as any, p as any); + rest.registerRoutes(); + const route = getExportRoute(rest); + + const { res, chunks, headers } = makeRes(); + await route!.handler({ params: { object: 'task' }, query: { format: 'json' } } as any, res); + + expect(headers['Content-Type']).toBe('application/json; charset=utf-8'); + const parsed = JSON.parse(chunks.join('')); + expect(parsed[0]).toMatchObject({ + done: '是', priority: '高', due: '2026-06-30', owner: '张三', extra: 'keep', + }); + }); + + it('streams a valid xlsx workbook with formatted cells', async () => { + const p = protocolWithSchema([RAW_TASK_ROW]); + const rest = new RestServer(server as any, p as any); + rest.registerRoutes(); + const route = getExportRoute(rest); + + const { res, getBuffer, headers } = makeBinRes(); + await route!.handler({ params: { object: 'task' }, query: { format: 'xlsx' } } as any, res); + + expect(headers['Content-Type']).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + expect(headers['Content-Disposition']).toMatch(/attachment; filename="task-\d{4}-\d{2}-\d{2}\.xlsx"/); + expect(headers['X-Export-Format']).toBe('xlsx'); + expect(res.end).toHaveBeenCalled(); + + const buf = getBuffer(); + // 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 ws = wb.getWorksheet('Export'); + expect(ws).toBeDefined(); + // row.values is 1-indexed (values[0] is empty). + expect(ws!.getRow(1).values).toEqual([undefined, 'ID', '标题', '完成', '优先级', '截止', '负责人']); + expect(ws!.getRow(2).values).toEqual([undefined, '1', '写代码', '是', '高', '2026-06-30', '张三']); + }); + // Regression: the router is first-match-wins with no specificity sorting, // so the static-literal `GET /data/:object/export` route MUST be registered // BEFORE the greedy `GET /data/:object/:id` matcher. Otherwise a request to diff --git a/packages/rest/tsconfig.json b/packages/rest/tsconfig.json index 5e3095a8a4..1294309a21 100644 --- a/packages/rest/tsconfig.json +++ b/packages/rest/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3079677d9d..b6c8214483 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1564,10 +1564,22 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../spec + exceljs: + specifier: ^4.4.0 + version: 4.4.0 zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@objectstack/metadata-protocol': + specifier: workspace:* + version: link:../metadata-protocol + '@objectstack/objectql': + specifier: workspace:* + version: link:../objectql + '@types/node': + specifier: ^26.0.1 + version: 26.0.1 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -2770,6 +2782,12 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -4218,6 +4236,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} @@ -4519,6 +4540,18 @@ packages: aproba@2.1.0: resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + are-we-there-yet@3.0.1: resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -4722,6 +4755,13 @@ packages: resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + binaryextensions@6.11.0: resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} engines: {node: '>=4'} @@ -4735,6 +4775,9 @@ packages: bl@6.1.6: resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -4770,12 +4813,20 @@ packages: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -4820,6 +4871,9 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -4953,6 +5007,10 @@ packages: commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} @@ -5000,10 +5058,22 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + croner@10.0.1: resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} engines: {node: '>=18.0'} @@ -5022,6 +5092,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -5138,6 +5211,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -5354,6 +5430,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -5382,6 +5462,10 @@ packages: resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} engines: {node: '>=12.17.0'} + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -5544,6 +5628,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + fumadocs-core@16.10.5: resolution: {integrity: sha512-e/xrZnKvQo8bF/WYMwPuym8PR3OtjZzHy0S/EIOvGwjKRgVq9z6J58zaBpi4LvYtPVZxNGsxdZVlmZXCVWq4FQ==} peerDependencies: @@ -5852,6 +5941,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -5988,6 +6080,9 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isbot@5.1.44: resolution: {integrity: sha512-PGEHtwMnKbZpeSEXW2Utx+/JWed7dp6DiH0WWg33vGSDA7RUvpUeJSVlLrVkQ1RCpvDOUc/eH9ql7VsdbBZ8pA==} engines: {node: '>=18'} @@ -6088,6 +6183,9 @@ packages: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -6142,6 +6240,10 @@ packages: resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==} engines: {node: '>=22.0.0'} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -6150,6 +6252,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -6234,6 +6339,9 @@ packages: linkify-it@5.0.1: resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -6249,15 +6357,40 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + lodash.isinteger@4.0.4: resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + lodash.isnumber@3.0.3: resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} @@ -6267,6 +6400,9 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} @@ -6276,6 +6412,12 @@ packages: lodash.truncate@4.4.2: resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -6591,6 +6733,10 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -6795,6 +6941,10 @@ packages: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + npm-package-arg@11.0.3: resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} engines: {node: ^16.14.0 || >=18.0.0} @@ -6964,6 +7114,9 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -7186,6 +7339,9 @@ packages: resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -7316,6 +7472,9 @@ packages: resolution: {integrity: sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==} engines: {node: '>=0.8'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -7324,6 +7483,9 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -7423,6 +7585,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -7452,6 +7619,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -7465,6 +7635,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -7516,6 +7690,9 @@ packages: set-cookie-parser@3.1.1: resolution: {integrity: sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -7671,6 +7848,9 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -7871,6 +8051,9 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -8028,6 +8211,9 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -8062,6 +8248,11 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -8276,6 +8467,9 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xpath@0.0.32: resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==} engines: {node: '>=0.6.0'} @@ -8335,6 +8529,10 @@ packages: zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -9139,6 +9337,25 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -10488,6 +10705,8 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@14.18.63': {} + '@types/node@26.0.1': dependencies: undici-types: 8.3.0 @@ -10839,6 +11058,42 @@ snapshots: aproba@2.1.0: optional: true + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + are-we-there-yet@3.0.1: dependencies: delegates: 1.0.0 @@ -10990,6 +11245,13 @@ snapshots: prebuild-install: 7.1.3 optional: true + big-integer@1.6.52: {} + + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + binaryextensions@6.11.0: dependencies: editions: 6.22.0 @@ -11011,6 +11273,8 @@ snapshots: inherits: 2.0.4 readable-stream: 4.7.0 + bluebird@3.4.7: {} + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -11050,6 +11314,8 @@ snapshots: '@types/node': 26.0.1 optional: true + buffer-indexof-polyfill@1.0.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -11060,6 +11326,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffers@0.1.1: {} + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -11115,6 +11383,10 @@ snapshots: chai@6.2.2: {} + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -11232,6 +11504,13 @@ snapshots: commondir@1.0.1: {} + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + compute-scroll-into-view@3.1.1: {} confbox@0.1.8: {} @@ -11262,11 +11541,20 @@ snapshots: cookie@1.1.1: optional: true + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + croner@10.0.1: {} cross-spawn@7.0.6: @@ -11287,6 +11575,8 @@ snapshots: csstype@3.2.3: {} + dayjs@1.11.21: {} + debug@4.3.4: dependencies: ms: 2.1.2 @@ -11381,6 +11671,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -11640,6 +11934,18 @@ snapshots: dependencies: eventsource-parser: 3.1.0 + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.21 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.7 + unzipper: 0.10.14 + uuid: 8.3.2 + expand-template@2.0.3: {} expect-type@1.4.0: {} @@ -11690,6 +11996,11 @@ snapshots: dependencies: pure-rand: 8.4.1 + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -11857,8 +12168,7 @@ snapshots: minipass: 3.3.6 optional: true - fs.realpath@1.0.0: - optional: true + fs.realpath@1.0.0: {} fsevents@2.3.2: optional: true @@ -11866,6 +12176,13 @@ snapshots: fsevents@2.3.3: optional: true + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + fumadocs-core@16.10.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(next@16.2.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-router@7.15.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 @@ -12039,7 +12356,6 @@ snapshots: minimatch: 10.2.3 once: 1.4.0 path-is-absolute: 1.0.1 - optional: true globby@11.1.0: dependencies: @@ -12293,6 +12609,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -12306,7 +12624,6 @@ snapshots: dependencies: once: 1.4.0 wrappy: 1.0.2 - optional: true inherits@2.0.4: {} @@ -12408,6 +12725,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isbot@5.1.44: optional: true @@ -12504,6 +12823,13 @@ snapshots: ms: 2.1.3 semver: 7.8.5 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -12555,6 +12881,10 @@ snapshots: kysely@0.29.2: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + leven@3.1.0: {} levn@0.4.1: @@ -12562,6 +12892,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -12619,6 +12953,8 @@ snapshots: dependencies: uc.micro: 2.1.0 + listenercount@1.0.1: {} + load-tsconfig@0.2.5: {} locate-character@3.0.0: @@ -12632,24 +12968,46 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + lodash.isinteger@4.0.4: {} + lodash.isnil@4.0.0: {} + lodash.isnumber@3.0.3: {} lodash.isplainobject@4.0.6: {} lodash.isstring@4.0.1: {} + lodash.isundefined@3.0.1: {} + lodash.once@4.1.1: {} lodash.startcase@4.4.0: {} lodash.truncate@4.4.2: {} + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + lodash@4.18.1: {} long@5.3.2: {} @@ -13244,6 +13602,10 @@ snapshots: mkdirp-classic@0.5.3: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mkdirp@1.0.4: optional: true @@ -13487,6 +13849,8 @@ snapshots: semver: 7.8.5 validate-npm-package-license: 3.0.4 + normalize-path@3.0.0: {} + npm-package-arg@11.0.3: dependencies: hosted-git-info: 7.0.2 @@ -13592,6 +13956,8 @@ snapshots: dependencies: quansync: 0.2.11 + pako@1.0.11: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -13633,8 +13999,7 @@ snapshots: path-expression-matcher@1.6.1: {} - path-is-absolute@1.0.1: - optional: true + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -13789,6 +14154,8 @@ snapshots: proc-log@4.2.0: {} + process-nextick-args@2.0.1: {} + process@0.11.10: {} promise-inflight@1.0.1: @@ -13926,6 +14293,16 @@ snapshots: dependencies: mute-stream: 0.0.8 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -13940,6 +14317,10 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 + readdir-glob@1.1.3: + dependencies: + minimatch: 10.2.3 + readdirp@4.1.2: {} readdirp@5.0.0: {} @@ -14081,6 +14462,10 @@ snapshots: reusify@1.1.0: {} + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -14156,6 +14541,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -14172,6 +14559,10 @@ snapshots: sax@1.6.0: {} + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} scroll-into-view-if-needed@3.1.0: @@ -14237,6 +14628,8 @@ snapshots: set-cookie-parser@3.1.1: {} + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} sharp@0.34.5: @@ -14448,6 +14841,10 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -14688,6 +15085,8 @@ snapshots: dependencies: punycode: 2.3.1 + traverse@0.3.9: {} + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -14858,6 +15257,19 @@ snapshots: until-async@3.0.2: optional: true + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -14886,6 +15298,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@8.3.2: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -15040,6 +15454,8 @@ snapshots: xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} + xpath@0.0.32: {} xpath@0.0.33: {} @@ -15086,6 +15502,12 @@ snapshots: zimmerframe@1.1.4: optional: true + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 From 9e3219cbc98d17cf28cd7935b1207fff31c0ce4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Tue, 30 Jun 2026 22:06:10 -0700 Subject: [PATCH 2/2] test(rest): lock export header row to request locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression test for the export header localization: a zh request yields 'ID,标题,完成' and the default-locale request yields 'ID,Title,Done' (the bundle's labels, never the raw schema 'RawTitle'/'RawDone'), proving the header tracks Accept-Language / ?locale= via translateMetaItem. --- packages/rest/src/rest.test.ts | 63 ++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 0d7c160263..fcdf007420 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1063,6 +1063,69 @@ describe('RestServer', () => { // First match wins → the more-specific export route must come first. expect(exportIdx).toBeLessThan(getByIdIdx); }); + + // Regression: the header row must carry the same localized field labels the + // UI renders (Accept-Language / ?locale=), not the raw `field.label` from the + // object schema. The route translates the schema via `translateMetaItem` + // before building the header, so a locale switch flips the header language. + it('localizes the export header row to the request locale', async () => { + // Schema field labels are deliberately distinct from the bundle so a + // green assertion proves the bundle was applied (not the raw labels). + const NAMED_SCHEMA = { + name: 'task', + fields: [ + { name: 'id', type: 'text', label: 'ID' }, + { name: 'title', type: 'text', label: 'RawTitle' }, + { name: 'done', type: 'boolean', label: 'RawDone' }, + ], + }; + const bundle: Record = { + en: { objects: { task: { fields: { title: { label: 'Title' }, done: { label: 'Done' } } } } }, + zh: { objects: { task: { fields: { title: { label: '标题' }, done: { label: '完成' } } } } }, + }; + const i18n = { + getLocales: () => ['en', 'zh'], + getTranslations: (l: string) => bundle[l], + getDefaultLocale: () => 'en', + }; + + const p: any = createMockProtocol(); + p.getMetaItem = vi.fn().mockResolvedValue({ type: 'object', name: 'task', item: NAMED_SCHEMA }); + // First page returns one row, subsequent pages are empty (ends the stream). + p.findData = vi.fn(async ({ query }: any) => + (query?.$skip ?? 0) === 0 ? { data: [{ id: '1', title: 'x', done: true }] } : { data: [] }, + ); + + // i18nServiceProvider is the 14th constructor arg (after server, protocol, + // config, and the 10 service providers preceding it). + const rest = new RestServer( + server as any, p as any, {}, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + async () => i18n as any, + ); + rest.registerRoutes(); + const route = getExportRoute(rest); + + // locale=zh → Chinese header; `id` has no override so it keeps its label. + const zh = makeRes(); + await route!.handler({ + params: { object: 'task' }, + query: { format: 'csv', fields: 'id,title,done', locale: 'zh' }, + headers: {}, + } as any, zh.res); + expect(zh.chunks.join('').split('\r\n')[0]).toBe('ID,标题,完成'); + + // Default locale (en, no ?locale=) → English header from the bundle, + // i.e. 'Title'/'Done', never the raw 'RawTitle'/'RawDone'. + const en = makeRes(); + await route!.handler({ + params: { object: 'task' }, + query: { format: 'csv', fields: 'id,title,done' }, + headers: {}, + } as any, en.res); + expect(en.chunks.join('').split('\r\n')[0]).toBe('ID,Title,Done'); + }); }); // -----------------------------------------------------------------------