Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

690 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared CSV import helpers for user-defined tables.
*
* Used by:
* - `POST /api/table/import-csv` (create new table from CSV — streams via {@link createCsvParser})
* - `POST /api/table/[tableId]/import` (append/replace into existing table)
* - Copilot `user-table` tool (`create_from_file`, `import_file` — buffers via {@link parseCsvBuffer})
*
* Keeping a single implementation avoids drift between HTTP and agent code paths.
* Both the buffered ({@link parseCsvBuffer}) and streaming ({@link createCsvParser})
* parsers share {@link csvParseOptions} so their behavior can't drift.
*/
import { type Options as CsvParseOptions, type Parser, parse as parseCsvStream } from 'csv-parse'
import { getColumnId } from '@/lib/table/column-keys'
import type { ColumnType } from '@/lib/table/column-types'
import { parseCurrencyInput } from '@/lib/table/currency'
import { type NormalizeDateCellOptions, normalizeDateCellValue } from '@/lib/table/dates'
import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types'
/**
* Field separators we sniff for, in tie-break priority order. Semicolon files are
* the standard CSV export of European-locale Excel; pipe shows up in log exports.
*/
export const CSV_DELIMITER_CANDIDATES = [',', ';', '\t', '|'] as const
export type CsvDelimiter = (typeof CSV_DELIMITER_CANDIDATES)[number]
/**
* Bytes inspected when sniffing the delimiter. Read from the head of the file on
* every path (client preview, streamed upload, background worker) so all of them
* observe the same prefix and therefore agree on the result.
*/
export const CSV_DELIMITER_SNIFF_BYTES = 64 * 1024
/**
* Single source of truth for the `csv-parse` options used by both the buffered
* sync parser and the streaming parser.
*
* `columns` is a function rather than `true` so the *actual header row* is
* captured. With `relax_column_count`, a record shorter than the header simply
* omits the trailing keys, so `Object.keys(records[0])` under-reports the schema
* whenever the first data row is ragged — the header callback is authoritative.
*/
export function csvParseOptions(
delimiter = ',',
onHeaders?: (headers: string[]) => void
): CsvParseOptions {
return {
columns: (header: string[]) => {
// Deliver headers deduped to match the record keys: csv-parse collapses duplicate
// column names into a single object key (last value wins), so the consumer's schema
// inference and mapping must see the same unique set — not the raw duplicates, which
// would invent phantom columns that never receive a value. csv-parse still keys on the
// raw array we return here, so returning it unchanged preserves that collapsing.
onHeaders?.(dedupeHeaders(header))
return header
},
skip_empty_lines: true,
trim: true,
relax_column_count: true,
relax_quotes: true,
skip_records_with_error: true,
cast: false,
bom: true,
delimiter,
}
}
/**
* Returns a streaming `csv-parse` parser (a `Transform`/async-iterable). Pipe a
* file stream into it and iterate records with `for await`; backpressure flows
* back to the source while each record is processed. Use this for HTTP uploads
* so the file is never fully buffered in memory.
*
* `onHeaders` fires once, before the first record, with the full header row.
*/
export function createCsvParser(delimiter = ',', onHeaders?: (headers: string[]) => void): Parser {
return parseCsvStream(csvParseOptions(delimiter, onHeaders))
}
/**
* Drops later exact-duplicate header names, preserving first-occurrence order. Mirrors how
* `csv-parse` collapses duplicate column names into a single record key (last value wins), so
* the header set stays in lockstep with the object keys the parser actually emits.
*/
export function dedupeHeaders(headers: string[]): string[] {
const seen = new Set<string>()
const unique: string[] = []
for (const header of headers) {
if (seen.has(header)) continue
seen.add(header)
unique.push(header)
}
return unique
}
/** Decodes CSV bytes as UTF-8, passing strings through unchanged. */
export function decodeCsvText(input: Buffer | Uint8Array | string): string {
if (typeof input === 'string') return input
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) return input.toString('utf-8')
return new TextDecoder('utf-8').decode(input as Uint8Array)
}
/**
* Sniffs the field separator by trial-parsing the sample with each candidate and
* keeping the one whose column count is most *consistent* across rows.
*
* A real parse (rather than counting raw characters) is what makes this safe on
* files whose quoted cells contain other candidates — `alarms.csv` exports a
* semicolon-separated file whose `raw_text` cells are full of commas and
* newlines, and a naive frequency count picks the comma.
*
* Each candidate is scored `modalWidth × consistency` — the modal (most common)
* row width times the fraction of rows at that width. This balances the two
* failure modes: ranking on width alone lets a delimiter that merely widens the
* header win (a comma splitting a semicolon file's unquoted header), while
* ranking on consistency alone lets a separator that appears uniformly *inside*
* values win over a real delimiter whose rows are legitimately ragged (a pipe
* embedded once per row beating a semicolon that yields 2- and 3-column rows).
* The product rewards a split that is both wide and uniform; ties break toward
* the wider split, then toward candidate order. Using the modal width (not the
* first row's) keeps one stray ragged row from distorting the score, and a
* single-column file (no candidate reaches two columns) falls back to `fallback`.
*
* Files that stay exactly tied on both score and width are genuinely ambiguous —
* e.g. `name;value,unit` splits into two columns under either `;` or `,` — so the
* global-default candidate order (comma first) decides, and both readings still
* produce a valid table.
*
* All callers funnel through here, so the sample is prepared identically for
* every import path: when the sample is a prefix sliced out of a larger file, a
* possibly-partial trailing line is dropped before parsing so a mid-record cut
* can't skew the counts. Pass `complete: true` when the sample is the entire file
* (nothing was sliced off) so a final row with no trailing newline still counts —
* dropping it would silently discard the only distinguishing data row of a tiny
* file and let a header-widening separator win.
*/
export async function detectCsvDelimiter(
input: Buffer | Uint8Array | string,
fallback: CsvDelimiter = ',',
{ complete = false }: { complete?: boolean } = {}
): Promise<CsvDelimiter> {
const { parse } = await import('csv-parse/sync')
const decoded = decodeCsvText(input)
// Drop a partial final line only when the sample is a truncated prefix; keep every row when
// the sample is the whole file (or a single line) so a trailing-newline-less last row counts.
const lastNewline = decoded.lastIndexOf('\n')
const text = !complete && lastNewline > 0 ? decoded.slice(0, lastNewline + 1) : decoded
if (text.trim() === '') return fallback
let best: { delimiter: CsvDelimiter; fields: number; score: number } | null = null
for (const delimiter of CSV_DELIMITER_CANDIDATES) {
let records: string[][]
try {
records = parse(text, {
columns: false,
skip_empty_lines: true,
relax_column_count: true,
relax_quotes: true,
skip_records_with_error: true,
bom: true,
delimiter,
}) as string[][]
} catch {
continue
}
if (records.length === 0) continue
// Modal row width: the most frequent column count, tie-broken toward the wider one.
const widthCounts = new Map<number, number>()
for (const record of records) {
widthCounts.set(record.length, (widthCounts.get(record.length) ?? 0) + 1)
}
let fields = 0
let modalFreq = 0
for (const [width, freq] of widthCounts) {
if (freq > modalFreq || (freq === modalFreq && width > fields)) {
fields = width
modalFreq = freq
}
}
if (fields < 2) continue
// Reward a split that is both wide and uniform; ties break toward the wider split.
const score = fields * (modalFreq / records.length)
if (!best || score > best.score || (score === best.score && fields > best.fields)) {
best = { delimiter, fields, score }
}
}
return best?.delimiter ?? fallback
}
/**
* Column types the CSV path coerces. Derived from the registry rather than
* restated, so a new type is covered automatically.
*/
export type CsvColumnType = ColumnType
/**
* The subset {@link inferColumnType} can return. Every other type either needs
* configuration inference cannot supply (`select`'s options, `currency`'s
* code) or would swallow ordinary text (`json`).
*/
type InferredCsvColumnType = Extract<ColumnType, 'string' | 'number' | 'boolean' | 'date'>
/** Number of CSV rows sampled when inferring column types for a new table. */
export const CSV_SCHEMA_SAMPLE_SIZE = 100
/**
* Maximum rows inserted per import batch. Each batch is one `INSERT … VALUES` statement, and
* Postgres caps bind parameters at 65,535 — at 9 params per row that's a hard ceiling of ~7,200
* rows, so 5,000 keeps a margin while cutting per-batch overhead (validation, unique-constraint
* check, ownership heartbeat) 5× vs the old 1,000.
*/
export const CSV_MAX_BATCH_SIZE = 5000
/** Maximum CSV/TSV file size accepted by import routes (25 MB). */
export const CSV_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024
/**
* Error thrown when the user-supplied mapping or CSV does not line up with the
* target table. Callers should translate this into a 400 response.
*/
export class CsvImportValidationError extends Error {
readonly code = 'CSV_IMPORT_VALIDATION' as const
readonly details: {
missingRequired?: string[]
duplicateTargets?: string[]
unknownColumns?: string[]
unknownHeaders?: string[]
}
constructor(
message: string,
details: {
missingRequired?: string[]
duplicateTargets?: string[]
unknownColumns?: string[]
unknownHeaders?: string[]
} = {}
) {
super(message)
this.name = 'CsvImportValidationError'
this.details = details
}
}
/**
* Parses a CSV/TSV payload using `csv-parse/sync`. Accepts a Node `Buffer`,
* browser-friendly `Uint8Array`, or already-decoded string. A leading UTF-8 BOM
* is stripped by csv-parse (`bom: true` in {@link csvParseOptions}).
*
* For HTTP uploads prefer {@link createCsvParser} so the file isn't buffered.
*/
export async function parseCsvBuffer(
input: Buffer | Uint8Array | string,
delimiter = ','
): Promise<{ headers: string[]; rows: Record<string, unknown>[] }> {
const { parse } = await import('csv-parse/sync')
const text = decodeCsvText(input)
let headers: string[] = []
const options = csvParseOptions(delimiter, (h) => {
headers = h
})
// double-cast-allowed: shared csvParseOptions() loses the `columns` literal that drives
// csv-parse's record-vs-string[][] overload, but `columns` is always set so records are objects
const parsed = parse(text, options) as unknown as Record<string, unknown>[]
if (parsed.length === 0) {
throw new Error('CSV file has no data rows')
}
if (headers.length === 0) {
throw new Error('CSV file has no headers')
}
return { headers, rows: parsed }
}
/**
* Infers a column type from a sample of non-empty values. Order matters: we
* prefer narrower types (number > boolean > ISO date) and fall back to string.
* JSON and currency are never inferred automatically — currency because the
* column would also have to guess an ISO code from a symbol, and guessing wrong
* mislabels every amount in the column.
*/
export function inferColumnType(values: unknown[]): InferredCsvColumnType {
const nonEmpty = values.filter((v) => v !== null && v !== undefined && v !== '')
if (nonEmpty.length === 0) return 'string'
const allNumber = nonEmpty.every((v) => {
const n = Number(v)
return !Number.isNaN(n) && String(v).trim() !== ''
})
if (allNumber) return 'number'
const allBoolean = nonEmpty.every((v) => {
const s = String(v).toLowerCase()
return s === 'true' || s === 'false'
})
if (allBoolean) return 'boolean'
const isoDatePattern = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
const allDate = nonEmpty.every((v) => {
const s = String(v)
return isoDatePattern.test(s) && !Number.isNaN(Date.parse(s))
})
if (allDate) return 'date'
return 'string'
}
/**
* Sanitizes a raw header into a valid column/table name. Strips disallowed
* characters, collapses runs of underscores, and ensures the first character
* is a letter or underscore (prefixing with `fallbackPrefix` otherwise).
*/
export function sanitizeName(raw: string, fallbackPrefix = 'col'): string {
let name = raw
.trim()
.replace(/[^a-zA-Z0-9_]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
if (!name || /^\d/.test(name)) {
name = `${fallbackPrefix}_${name}`
}
return name
}
/**
* Returns column definitions inferred from CSV headers + sample rows. Duplicate
* sanitized names are suffixed with `_2`, `_3`, etc. Also returns the header ->
* column-name mapping used when coercing row values.
*/
export function inferSchemaFromCsv(
headers: string[],
rows: Record<string, unknown>[]
): { columns: ColumnDefinition[]; headerToColumn: Map<string, string> } {
const sample = rows.slice(0, CSV_SCHEMA_SAMPLE_SIZE)
const seen = new Set<string>()
const headerToColumn = new Map<string, string>()
const columns = headers.map((header) => {
const base = sanitizeName(header)
let colName = base
let suffix = 2
while (seen.has(colName.toLowerCase())) {
colName = `${base}_${suffix}`
suffix++
}
seen.add(colName.toLowerCase())
headerToColumn.set(header, colName)
return {
name: colName,
type: inferColumnType(sample.map((r) => r[header])),
} satisfies ColumnDefinition
})
return { columns, headerToColumn }
}
/**
* Coerces a single value to the requested column type. Returns `null` for
* empty inputs or values that cannot be parsed (numbers/booleans). Dates fall
* back to the original string when unparseable so that schema validation can
* reject it with context rather than silently inserting `null`.
*
* Deliberately NOT routed through the column-type registry's `coerce`, despite
* covering the same types. The registry's contract is "coerced or rejected",
* which the write path turns into `null`; an import instead wants an
* unparseable date or JSON blob to survive as its raw string so the row-level
* validation error names the offending value. Unifying the two would silently
* swap a descriptive import error for a blanked cell.
*/
export function coerceValue(
value: unknown,
colType: CsvColumnType,
options?: NormalizeDateCellOptions & { currencyCode?: string }
): string | number | boolean | null | Record<string, unknown> | unknown[] {
if (value === null || value === undefined || value === '') return null
switch (colType) {
case 'number': {
const n = Number(value)
return Number.isNaN(n) ? null : n
}
// Importing into an existing currency column: the file carries the
// formatted amount (`$1,234.56`) but the cell stores a bare number. The
// column's currency is forwarded because it decides how a lone separator
// reads — a three-decimal currency's `0,500` is a half, not five hundred.
case 'currency':
return parseCurrencyInput(value, options?.currencyCode)
case 'boolean': {
const s = String(value).toLowerCase()
if (s === 'true') return true
if (s === 'false') return false
return null
}
case 'date': {
return normalizeDateCellValue(String(value), options) ?? String(value)
}
case 'json': {
if (typeof value === 'object') return value as Record<string, unknown> | unknown[]
try {
return JSON.parse(String(value))
} catch {
return String(value)
}
}
default:
return String(value)
}
}
/**
* Mapping from raw CSV header to target column name, with `null` indicating
* "do not import this column".
*/
export type CsvHeaderMapping = Record<string, string | null>
export interface CsvMappingValidationResult {
/** Columns present in the CSV that landed on a real table column. */
mappedHeaders: string[]
/** Columns in the CSV that the user/client chose to skip. */
skippedHeaders: string[]
/** Target column names that ended up unmapped (resolved from the mapping). */
unmappedColumns: string[]
/** Effective header -> column map (after dropping unknown / null targets). */
effectiveMap: Map<string, string>
}
/**
* Validates a user-supplied mapping against the target table schema. Rejects
* unknown target columns, duplicate targets, and required table columns that
* are not covered by the CSV. Returns the normalized header -> column map.
*/
export function validateMapping(params: {
csvHeaders: string[]
mapping: CsvHeaderMapping
tableSchema: TableSchema
}): CsvMappingValidationResult {
const { csvHeaders, mapping, tableSchema } = params
const columnByName = new Map(tableSchema.columns.map((c) => [c.name, c]))
const unknownHeaders = Object.keys(mapping).filter((h) => !csvHeaders.includes(h))
if (unknownHeaders.length > 0) {
throw new CsvImportValidationError(
`Mapping references unknown CSV headers: ${unknownHeaders.join(', ')}`,
{ unknownHeaders }
)
}
const invalidTargets = Object.entries(mapping).filter(
([, target]) => target !== null && typeof target !== 'string'
)
if (invalidTargets.length > 0) {
throw new CsvImportValidationError(
`Mapping values must be a column name (string) or null, got: ${invalidTargets
.map(([header]) => header)
.join(', ')}`
)
}
const targetsSeen = new Map<string, string[]>()
const unknownColumns: string[] = []
const effectiveMap = new Map<string, string>()
const skippedHeaders: string[] = []
for (const header of csvHeaders) {
const target = header in mapping ? mapping[header] : undefined
if (target === null || target === undefined) {
skippedHeaders.push(header)
continue
}
if (!columnByName.has(target)) {
unknownColumns.push(target)
continue
}
const existing = targetsSeen.get(target) ?? []
existing.push(header)
targetsSeen.set(target, existing)
effectiveMap.set(header, target)
}
if (unknownColumns.length > 0) {
throw new CsvImportValidationError(
`Mapping references columns that do not exist on the table: ${unknownColumns.join(', ')}`,
{ unknownColumns }
)
}
const duplicateTargets = [...targetsSeen.entries()]
.filter(([, headers]) => headers.length > 1)
.map(([col]) => col)
if (duplicateTargets.length > 0) {
throw new CsvImportValidationError(
`Multiple CSV headers map to the same column(s): ${duplicateTargets.join(', ')}`,
{ duplicateTargets }
)
}
const mappedTargets = new Set(effectiveMap.values())
const unmappedColumns = tableSchema.columns
.filter((c) => !mappedTargets.has(c.name))
.map((c) => c.name)
const missingRequired = tableSchema.columns
.filter((c) => c.required && !mappedTargets.has(c.name))
.map((c) => c.name)
if (missingRequired.length > 0) {
throw new CsvImportValidationError(
`CSV is missing required columns: ${missingRequired.join(', ')}`,
{ missingRequired }
)
}
return {
mappedHeaders: [...effectiveMap.keys()],
skippedHeaders,
unmappedColumns,
effectiveMap,
}
}
/**
* Builds an auto-mapping from CSV headers to table columns: prefers exact
* sanitized-name matches and falls back to a case- and punctuation-insensitive
* comparison. Unmapped headers are set to `null`.
*/
export function buildAutoMapping(csvHeaders: string[], tableSchema: TableSchema): CsvHeaderMapping {
const mapping: CsvHeaderMapping = {}
const columns = tableSchema.columns
const exactByName = new Map(columns.map((c) => [c.name, c.name]))
const loose = new Map<string, string>()
for (const col of columns) {
loose.set(col.name.toLowerCase().replace(/[^a-z0-9]/g, ''), col.name)
}
const usedTargets = new Set<string>()
for (const header of csvHeaders) {
const sanitized = sanitizeName(header)
const exact = exactByName.get(sanitized)
if (exact && !usedTargets.has(exact)) {
mapping[header] = exact
usedTargets.add(exact)
continue
}
const key = header.toLowerCase().replace(/[^a-z0-9]/g, '')
const fuzzy = loose.get(key)
if (fuzzy && !usedTargets.has(fuzzy)) {
mapping[header] = fuzzy
usedTargets.add(fuzzy)
continue
}
mapping[header] = null
}
return mapping
}
/**
* Coerces parsed CSV rows into `RowData` objects keyed by the target column's
* **stable id** (the row-data storage key), applying the column types declared in
* `tableSchema`. Headers not present in `headerToColumn` are dropped. Missing
* table columns remain unset (schema validation decides whether that's
* acceptable). Pass the schema returned by `createTable` so ids are resolved.
*/
export function coerceRowsForTable(
rows: Record<string, unknown>[],
tableSchema: TableSchema,
headerToColumn: Map<string, string>,
options?: NormalizeDateCellOptions
): RowData[] {
const colByName = new Map(tableSchema.columns.map((c) => [c.name, c]))
return rows.map((row) => {
const coerced: RowData = {}
for (const [header, value] of Object.entries(row)) {
const colName = headerToColumn.get(header)
if (!colName) continue
const col = colByName.get(colName)
if (!col) continue
const colType = (col.type as CsvColumnType) ?? 'string'
coerced[getColumnId(col)] = coerceValue(value, colType, {
...options,
...(col.currencyCode !== undefined ? { currencyCode: col.currencyCode } : {}),
}) as RowData[string]
}
return coerced
})
}
/**
* Sanitizes raw JSON keys so they conform to the same column-name rules as CSV
* headers, letting `inferSchemaFromCsv` and `coerceRowsForTable` be reused for
* JSON imports. Collisions after sanitization are disambiguated with a trailing
* underscore. Returns the headers and rows untouched when no key needs renaming.
*/
export function sanitizeJsonHeaders(
headers: string[],
rows: Record<string, unknown>[]
): { headers: string[]; rows: Record<string, unknown>[] } {
const renamed = new Map<string, string>()
const seen = new Set<string>()
for (const raw of headers) {
let safe = sanitizeName(raw)
while (seen.has(safe)) safe = `${safe}_`
seen.add(safe)
renamed.set(raw, safe)
}
const noChange = headers.every((h) => renamed.get(h) === h)
if (noChange) return { headers, rows }
return {
headers: headers.map((h) => renamed.get(h)!),
rows: rows.map((row) => {
const out: Record<string, unknown> = {}
for (const [raw, safe] of renamed) {
if (raw in row) out[safe] = row[raw]
}
return out
}),
}
}
/**
* Parses a JSON payload that must be an array of plain objects into the same
* `{ headers, rows }` shape produced by `parseCsvBuffer`. The header set is the
* union of all object keys, sanitized via {@link sanitizeJsonHeaders}.
*/
export function parseJsonRows(buffer: Buffer | string): {
headers: string[]
rows: Record<string, unknown>[]
} {
const text = typeof buffer === 'string' ? buffer : buffer.toString('utf-8')
const parsed = JSON.parse(text)
if (!Array.isArray(parsed)) {
throw new Error('JSON file must contain an array of objects')
}
if (parsed.length === 0) {
throw new Error('JSON file contains an empty array')
}
const headerSet = new Set<string>()
for (const row of parsed) {
if (typeof row !== 'object' || row === null || Array.isArray(row)) {
throw new Error('Each element in the JSON array must be a plain object')
}
for (const key of Object.keys(row)) headerSet.add(key)
}
return sanitizeJsonHeaders([...headerSet], parsed)
}
/**
* Parses a tabular upload (CSV, TSV, or JSON array-of-objects) into a uniform
* `{ headers, rows }` shape, dispatching on file extension and falling back to
* the MIME content type. Throws on unsupported formats so callers fail fast.
*/
export async function parseFileRows(
buffer: Buffer,
fileName: string,
contentType?: string
): Promise<{ headers: string[]; rows: Record<string, unknown>[] }> {
const ext = fileName.split('.').pop()?.toLowerCase()
if (ext === 'json' || contentType === 'application/json') {
return parseJsonRows(buffer)
}
if (ext === 'csv' || ext === 'tsv' || contentType === 'text/csv') {
const delimiter = await detectCsvDelimiter(
buffer.subarray(0, CSV_DELIMITER_SNIFF_BYTES),
ext === 'tsv' ? '\t' : ',',
{ complete: buffer.length <= CSV_DELIMITER_SNIFF_BYTES }
)
return parseCsvBuffer(buffer, delimiter)
}
throw new Error(`Unsupported file format: "${ext ?? fileName}". Supported: csv, tsv, json`)
}