Skip to content

Commit e1513f5

Browse files
feat(api): expand the public v2 tables surface
Adds 16 operations so a v2 caller can do what the internal surface can: rename/move/lock a table, restore it, manage saved views, run enrichment columns, look up rows, and import/export with observable job control. Extracts lib/table/orchestration/import.ts (performTableCsvImport, performCreateTableFromCsv) and lib/table/export-stream.ts from the first-party routes, then repoints those routes at them, so v1 and v2 cannot drift on what an import or export actually does. events/stream, metadata and dispatches stay internal — they are editor state, not public API.
1 parent 5df4c75 commit e1513f5

50 files changed

Lines changed: 10603 additions & 1310 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/openapi-v2-tables.json

Lines changed: 3709 additions & 718 deletions
Large diffs are not rendered by default.
Lines changed: 9 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,18 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2-
import { createLogger } from '@sim/logger'
32
import { type NextRequest, NextResponse } from 'next/server'
43
import { tableExportFormatSchema, tableIdParamsSchema } from '@/lib/api/contracts/tables'
54
import { getValidationErrorMessage } from '@/lib/api/server'
65
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
7-
import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
86
import { generateRequestId } from '@/lib/core/utils/request'
97
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
108
import { captureServerEvent } from '@/lib/posthog/server'
11-
import { namedRowMapper } from '@/lib/table/cell-format'
12-
import { getColumnId } from '@/lib/table/column-keys'
13-
import { formatCsvCell } from '@/lib/table/export-format'
14-
import { queryRows } from '@/lib/table/rows/service'
9+
import {
10+
createTableExportStream,
11+
exportContentType,
12+
sanitizeExportFilename,
13+
} from '@/lib/table/export-stream'
1514
import { accessError, checkAccess } from '@/app/api/table/utils'
1615

17-
const logger = createLogger('TableExport')
18-
19-
const EXPORT_BATCH_SIZE = 1000
20-
21-
type ExportFormat = 'csv' | 'json'
22-
2316
interface RouteParams {
2417
params: Promise<{ tableId: string }>
2518
}
@@ -45,19 +38,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
4538
{ status: 400 }
4639
)
4740
}
48-
const format: ExportFormat = formatValidation.data
41+
const format = formatValidation.data
4942

5043
const access = await checkAccess(tableId, auth.userId, 'read')
5144
if (!access.ok) return accessError(access, requestId, tableId)
5245
const { table } = access
5346

54-
const columns = table.schema.columns
55-
// Stored row data is id-keyed; CSV headers and JSON keys are display names, so
56-
// translate id → name on the way out (export is a name-friendly boundary).
57-
const toNamedRow = namedRowMapper(columns)
58-
const safeName = sanitizeFilename(table.name)
59-
const filename = `${safeName}.${format}`
60-
6147
// Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data.
6248
recordAudit({
6349
workspaceId: table.workspaceId ?? null,
@@ -79,80 +65,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
7965
)
8066
}
8167

82-
const stream = new ReadableStream<Uint8Array>({
83-
async start(controller) {
84-
const encoder = new TextEncoder()
85-
try {
86-
if (format === 'csv') {
87-
controller.enqueue(
88-
encoder.encode(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`)
89-
)
90-
} else {
91-
controller.enqueue(encoder.encode('['))
92-
}
93-
94-
let offset = 0
95-
let firstJsonRow = true
96-
while (true) {
97-
const result = await queryRows(
98-
table,
99-
{ limit: EXPORT_BATCH_SIZE, offset, includeTotal: false },
100-
requestId
101-
)
102-
103-
for (const row of result.rows) {
104-
if (format === 'csv') {
105-
const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)]))
106-
controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`))
107-
} else {
108-
const prefix = firstJsonRow ? '' : ','
109-
firstJsonRow = false
110-
controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data))))
111-
}
112-
}
113-
114-
// A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE,
115-
// so a short page does NOT mean the export is done — only a null cursor does.
116-
if (!result.nextCursor) break
117-
offset += result.rows.length
118-
}
119-
120-
if (format === 'json') controller.enqueue(encoder.encode(']'))
121-
controller.close()
122-
123-
logger.info(`[${requestId}] Exported table ${tableId}`, {
124-
format,
125-
rowCount: table.rowCount,
126-
})
127-
} catch (err) {
128-
logger.error(`[${requestId}] Export failed for table ${tableId}`, err)
129-
controller.error(err)
130-
}
131-
},
132-
})
133-
134-
return new NextResponse(stream, {
68+
return new NextResponse(createTableExportStream(table, format, requestId), {
13569
status: 200,
13670
headers: {
137-
'Content-Type': format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json',
138-
'Content-Disposition': `attachment; filename="${filename}"`,
71+
'Content-Type': exportContentType(format),
72+
'Content-Disposition': `attachment; filename="${sanitizeExportFilename(table.name)}.${format}"`,
13973
'Cache-Control': 'no-store',
14074
},
14175
})
14276
})
143-
144-
function sanitizeFilename(name: string): string {
145-
const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
146-
return cleaned || 'table'
147-
}
148-
149-
function toCsvRow(values: string[]): string {
150-
return values.map(escapeCsvField).join(',')
151-
}
152-
153-
function escapeCsvField(field: string): string {
154-
if (/[",\n\r]/.test(field)) {
155-
return `"${field.replace(/"/g, '""')}"`
156-
}
157-
return field
158-
}

0 commit comments

Comments
 (0)