Files
simstudioai--sim/apps/sim/lib/table/import.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (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 / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

512 lines
17 KiB
TypeScript
Raw 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 NormalizeDateCellOptions, normalizeDateCellValue } from '@/lib/table/dates'
import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types'
/**
* Single source of truth for the `csv-parse` options used by both the buffered
* sync parser and the streaming parser. `columns: true` emits each record as an
* object keyed by the (first-row) headers.
*/
export function csvParseOptions(delimiter = ','): CsvParseOptions {
return {
columns: true,
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.
*/
export function createCsvParser(delimiter = ','): Parser {
return parseCsvStream(csvParseOptions(delimiter))
}
/** Narrower type than `COLUMN_TYPES` used internally for coercion. */
export type CsvColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json'
/** 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')
let text: string
if (typeof input === 'string') {
text = input
} else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) {
text = input.toString('utf-8')
} else {
text = new TextDecoder('utf-8').decode(input as Uint8Array)
}
// double-cast-allowed: shared csvParseOptions() loses the `columns: true` literal that drives
// csv-parse's record-vs-string[][] overload, but `columns: true` is always set so records are objects
const parsed = parse(text, csvParseOptions(delimiter)) as unknown as Record<string, unknown>[]
if (parsed.length === 0) {
throw new Error('CSV file has no data rows')
}
const headers = Object.keys(parsed[0])
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 is never inferred automatically.
*/
export function inferColumnType(values: unknown[]): Exclude<CsvColumnType, 'json'> {
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`.
*/
export function coerceValue(
value: unknown,
colType: CsvColumnType,
options?: NormalizeDateCellOptions
): 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
}
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) 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 = ext === 'tsv' ? '\t' : ','
return parseCsvBuffer(buffer, delimiter)
}
throw new Error(`Unsupported file format: "${ext ?? fileName}". Supported: csv, tsv, json`)
}