Skip to content

Commit 477fbac

Browse files
waleedlatif1claude
andcommitted
feat(tables): add export, import column creation, infinite row pagination
- Add `/api/table/[tableId]/export` route streaming CSV/JSON downloads - Rename `/import-csv` route to `/import` and extend to auto-create new columns from unmapped CSV headers via `createColumns` form field - Switch table view to `useInfiniteQuery` so tables larger than 1000 rows fully load; reconcile created rows into the paginated cache so "New row" past 1000 no longer reverts on invalidate - Wire scroll-driven prefetch (600px from bottom) and pre-drain pages before append to keep new-row position consistent - Polish import-csv dialog flow and add Export action to header Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 220f8c8 commit 477fbac

16 files changed

Lines changed: 1598 additions & 620 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
4+
import { generateRequestId } from '@/lib/core/utils/request'
5+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { queryRows } from '@/lib/table/service'
7+
import { accessError, checkAccess } from '@/app/api/table/utils'
8+
9+
const logger = createLogger('TableExport')
10+
11+
const EXPORT_BATCH_SIZE = 1000
12+
const SUPPORTED_FORMATS = ['csv', 'json'] as const
13+
type ExportFormat = (typeof SUPPORTED_FORMATS)[number]
14+
15+
interface RouteParams {
16+
params: Promise<{ tableId: string }>
17+
}
18+
19+
/** GET /api/table/[tableId]/export - Streams the full table contents as CSV or JSON. */
20+
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
21+
const requestId = generateRequestId()
22+
const { tableId } = await params
23+
24+
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
25+
if (!auth.success || !auth.userId) {
26+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
27+
}
28+
29+
const { searchParams } = new URL(request.url)
30+
const rawFormat = (searchParams.get('format') ?? 'csv').toLowerCase()
31+
if (!SUPPORTED_FORMATS.includes(rawFormat as ExportFormat)) {
32+
return NextResponse.json(
33+
{ error: `Unsupported format. Use one of: ${SUPPORTED_FORMATS.join(', ')}` },
34+
{ status: 400 }
35+
)
36+
}
37+
const format = rawFormat as ExportFormat
38+
39+
const access = await checkAccess(tableId, auth.userId, 'read')
40+
if (!access.ok) return accessError(access, requestId, tableId)
41+
const { table } = access
42+
43+
const columns = table.schema.columns
44+
const safeName = sanitizeFilename(table.name)
45+
const filename = `${safeName}.${format}`
46+
47+
const stream = new ReadableStream<Uint8Array>({
48+
async start(controller) {
49+
const encoder = new TextEncoder()
50+
try {
51+
if (format === 'csv') {
52+
controller.enqueue(encoder.encode(`${toCsvRow(columns.map((c) => c.name))}\n`))
53+
} else {
54+
controller.enqueue(encoder.encode('['))
55+
}
56+
57+
let offset = 0
58+
let firstJsonRow = true
59+
while (true) {
60+
const result = await queryRows(
61+
tableId,
62+
table.workspaceId,
63+
{ limit: EXPORT_BATCH_SIZE, offset, includeTotal: false },
64+
requestId
65+
)
66+
67+
for (const row of result.rows) {
68+
if (format === 'csv') {
69+
const values = columns.map((c) => formatCsvValue(row.data[c.name]))
70+
controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`))
71+
} else {
72+
const prefix = firstJsonRow ? '' : ','
73+
firstJsonRow = false
74+
controller.enqueue(encoder.encode(prefix + JSON.stringify({ ...row.data })))
75+
}
76+
}
77+
78+
if (result.rows.length < EXPORT_BATCH_SIZE) break
79+
offset += result.rows.length
80+
}
81+
82+
if (format === 'json') controller.enqueue(encoder.encode(']'))
83+
controller.close()
84+
85+
logger.info(`[${requestId}] Exported table ${tableId}`, {
86+
format,
87+
rowCount: table.rowCount,
88+
})
89+
} catch (err) {
90+
logger.error(`[${requestId}] Export failed for table ${tableId}`, err)
91+
controller.error(err)
92+
}
93+
},
94+
})
95+
96+
return new NextResponse(stream, {
97+
status: 200,
98+
headers: {
99+
'Content-Type': format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json',
100+
'Content-Disposition': `attachment; filename="${filename}"`,
101+
'Cache-Control': 'no-store',
102+
},
103+
})
104+
})
105+
106+
function sanitizeFilename(name: string): string {
107+
const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
108+
return cleaned || 'table'
109+
}
110+
111+
function formatCsvValue(value: unknown): string {
112+
if (value === null || value === undefined) return ''
113+
if (value instanceof Date) return value.toISOString()
114+
if (typeof value === 'object') return JSON.stringify(value)
115+
return String(value)
116+
}
117+
118+
function toCsvRow(values: string[]): string {
119+
return values.map(escapeCsvField).join(',')
120+
}
121+
122+
function escapeCsvField(field: string): string {
123+
if (/[",\n\r]/.test(field)) {
124+
return `"${field.replace(/"/g, '""')}"`
125+
}
126+
return field
127+
}

apps/sim/app/api/table/[tableId]/import-csv/route.ts

Lines changed: 0 additions & 268 deletions
This file was deleted.

0 commit comments

Comments
 (0)