Import files and pull data
importsRead CSV, XLSX, JSON and NDJSON files, map their columns with a confirmed mapping, write rows safely, and pull from outside sources on a schedule.
importsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
imports brings outside data into your app's tables: a person uploads a file, confirms a column mapping, and only then are rows written. The same engine pulls from outside sources on a schedule.
A person uploads a file, the app previews the first rows and suggests a column mapping, the person confirms it, and only then are rows written. The same engine pulls from sources such as a Google Sheet, an Airtable base or a partner's REST API on a schedule. Reach for it when customers hand you spreadsheets or when your data lives in someone else's tool.
It is named imports because import is a reserved word in JavaScript. File imports and the rest, google-sheets and airtable source drivers are built; Google Sheets and Airtable need a connections token, which is not built yet (see below).
At a glance
imports:
inline-max: 5MB
batch: 500
max-rows: 1000000
max-columns: 500
json-max: 20MB
sources:
crm-sheet: { driver: google-sheets, spreadsheet: "1AbC…", range: "Contacts!A:F", auth: connection:google, every: 15m }
partner-api:
driver: rest
url: https://api.partner.com/v1/items
auth: secret:PARTNER_TOKEN
every: 5m
full-every: 24h
deletes-guard: 50%import { imports } from "mainsdk/imports";
import { Contact } from "./schema";
const preview = await imports.preview(file, { schema: Contact, key: "email" });
const run = await imports.file(file, {
schema: Contact, key: "email", into: "contacts",
mapping: { "E-mail": "email", "Full name": "name" },
});Drivers
4 drivers. Select one to see its line in main.yaml.
imports:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-IMPORT-0020 | Checkmainsdk check, before deploy | A source under imports.sources has no imports.source() in the code, or the code defines a source main.yaml doesn't declare |
MAIN-IMPORT-0001 | RuntimeError responses and logs | The format or encoding isn't supported: not CSV, XLSX, JSON or NDJSON, not UTF-8, a JSON value that isn't an array, a legacy .xls or an encrypted workbook (415) |
MAIN-IMPORT-0002 | RuntimeError responses and logs | The file is over a limit: max-rows, max-columns, json-max or the files size (413); rows up to the limit are not written |
MAIN-IMPORT-0003 | RuntimeError responses and logs | The mapping is incomplete or unconfirmed: a required field has no column, a mapped column doesn't exist, or a field would come from a model suggestion not passed back in mapping (422) |
MAIN-IMPORT-0004 | RuntimeError responses and logs | The file isn't ready yet (still scanning) (409, retryable) |
MAIN-IMPORT-0005 | RuntimeError responses and logs | The run stopped because failed rows passed maxErrors; the report lists them (422) |
MAIN-IMPORT-0006 | RuntimeError responses and logs | An unsafe workbook: a zip entry over the size or ratio limit, a path outside the archive, or XML with a DOCTYPE or entity (422) |
MAIN-IMPORT-0010 | RuntimeError responses and logs | A pull failed: the source was unreachable or refused; the cursor is unchanged and the next run retries |
MAIN-IMPORT-0011 | RuntimeError responses and logs | The deletion guard tripped: a full pass returned fewer keys than deletes-guard, so nothing was deleted |
MAIN-IMPORT-0012 | RuntimeError responses and logs | The source rejected the stored cursor; a full pass is scheduled |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Read CSV, XLSX, JSON and NDJSON | Phase 1 | 0.4 | imports#read |
| Map columns with a confirmed mapping | Phase 1 | 0.4 | imports#mapping |
| Dry run with a row error report | Phase 1 | 0.4 | imports#dry-run-report |
| Resumable batched writes | Phase 1 | 0.4 | imports#upsert-resume |
| Large files run as a workflow with progress | Phase 1 | 0.4 | imports#workflow-progress |
| Excel workbooks with safety limits | Phase 1 | 0.4 | imports#xlsx |
| Scheduled pulls from sources | Phase 1 | 0.4 | imports#pull |
| Conflict rules and guarded deletes | Phase 1 | 0.4 | imports#conflicts-deletes |
| Google Sheets and Airtable sources | Phase 1 | 0.4 | imports#drivers-sheets-airtable |
| Checks for undeclared sources | Phase 1 | 0.4 | imports#checks |
Quick start
"use server";
import { imports } from "mainsdk";
import type { FileRef } from "mainsdk/files";
import { s } from "mainsdk/schema";
const Contact = s.object({ email: s.string(), name: s.string() });
export async function previewContacts(file: FileRef) {
return imports.preview(file, { schema: Contact, key: "email" });
}
export async function importContacts(file: FileRef, mapping: Record<string, "email" | "name" | null>) {
return imports.file(file, { schema: Contact, key: "email", into: "contacts", mapping });
}FileRef is the upload from files. The mapping you pass to imports.file is the confirmation: nothing is written until it is there.
Configuration
imports:
inline-max: 5MB # larger files, or more than 10 000 rows, run as a workflow
batch: 500 # rows per upsert batch and per workflow step
max-rows: 1000000
max-columns: 500
json-max: 20MB # a JSON array is read whole; NDJSON streams at any size
suggest: { samples: 3 } # values per column sent to the model after redaction
sources:
crm-sheet: { driver: google-sheets, spreadsheet: "1AbC…", range: "Contacts!A:F", auth: connection:google, every: 15m }
partner-api:
driver: rest
url: https://api.partner.com/v1/items
auth: secret:PARTNER_TOKEN
cursor: { param: since, next: $.next }
items: $.items
every: 5m
full-every: 24h # the only time deletions are detected
deletes-guard: 50% # a full pass returning fewer keys than this deletes nothing| Key | What it does |
|---|---|
inline-max, batch | When a file runs as a workflow, and how many rows each write takes |
max-rows, max-columns, json-max | Hard limits. A file over one is refused with MAIN-IMPORT-0002 and no rows are written |
suggest.samples | How many values per column go to the model. 0 sends headers only |
sources | Outside sources to pull from, each with a driver, auth and every |
Source credentials go in as secret: or connection: references. A source's auth: connection:google uses the person's connection from connections.
What you get
One imports object with two jobs: file imports (preview, file) and scheduled pulls from named sources (source, enable, pull). Runs, counts and failed-row reports are kept per tenant.
Calls
import { imports } from "mainsdk" (or mainsdk/imports) is built on first use from the imports section.
| Call | What it does |
|---|---|
imports.preview(file, opts) | Read the first rows and return the columns, a suggested mapping and the required fields no column maps to |
imports.file(file, opts) | Import a file with a confirmed mapping. Small files finish inline; larger ones return state: "running" and continue in a workflow |
imports.get(runId) | Read one run: its state and counts |
imports.list(opts?) | List the current tenant's runs, newest first, with cursor and limit |
imports.cancel(runId, { reason }) | Stop a run after the batch in flight. Rows already written are kept |
imports.source(name, opts) | Define a pull source in code; it must match the source in main.yaml |
imports.enable(name, opts?) | Start pulling a source on its schedule |
imports.disable(name) | Stop pulling a source. Its earlier rows stay |
imports.pull(name, opts?) | Pull a source now, outside the schedule |
imports.conflicts(name, opts?) | List rows where the source and the app both changed |
Examples
Preview, then import
import { imports } from "mainsdk";
import type { FileRef } from "mainsdk/files";
import { s } from "mainsdk/schema";
const Contact = s.object({ email: s.string(), name: s.string() });
declare const file: FileRef; // the upload
const preview = await imports.preview(file, { schema: Contact, key: "email" });
console.log(preview.columns, preview.suggested, preview.missing);
const run = await imports.file(file, {
schema: Contact, key: "email", into: "contacts",
mapping: { "E-mail": "email", "Full name": "name" },
dryRun: true, // report what would change; write nothing
});
console.log(run.counts, run.report);preview.missing lists required fields no column maps to. run.report is a CSV of failed rows, stored with files.
Pull from a source on a schedule
import { imports } from "mainsdk";
import { s } from "mainsdk/schema";
const Contact = s.object({ email: s.string(), name: s.string() });
export const crm = imports.source("crm-sheet", {
schema: Contact, key: "email", into: "contacts",
mapping: { "E-mail": "email", "Full name": "name" },
conflict: "source-wins", // source-wins | app-wins | newest | flag
deletes: "soft", // ignore | soft | hard
});
await imports.enable("crm-sheet");
await imports.pull("crm-sheet"); // run now, outside the scheduleEach source is declared once in main.yaml and defined once in code. mainsdk check fails when the two do not match (MAIN-IMPORT-0020).
Read the conflicts
import { imports } from "mainsdk";
const page = await imports.conflicts("crm-sheet", { limit: 50 });A conflict is a row where the source and the app both changed. flag leaves it for a person to decide.
What is connected, and what is not
app.imports is wired to files, tenancy, workflows, jobs, the log, http behind network, secrets, ai for suggest: "ai" (using the first non-pattern model of ai.models) and the app's db for model targets, which is only opened when a run writes a model. Run records live in db collections when the app has a db section, otherwise in process. These are not built yet, so set app.socialPorts.imports = { ... } before the first use if you have your own:
| Port | What it does | Without it |
|---|---|---|
realtime | Publishes progress events on a run's channel | Read progress with imports.get(runId) |
connections | Gives a source auth: connection:<provider> the person's token | Google Sheets and Airtable sources end as a failed run; secret: auth still works |
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-IMPORT-0001 | Format or encoding not supported (not UTF-8, legacy .xls, encrypted workbook) | Convert the file to CSV, XLSX, JSON or NDJSON in UTF-8 |
MAIN-IMPORT-0002 | The file is over a limit | Split the file, or raise the limit under imports |
MAIN-IMPORT-0003 | A required field has no column, or the mapping is unconfirmed | Map every required field and confirm the mapping |
MAIN-IMPORT-0005 | Failed rows passed maxErrors; the run stopped | Fix the rows in the report, then run again |
MAIN-IMPORT-0006 | An unsafe workbook (zip bomb, path escape, XML entity) | Save the workbook again |
MAIN-IMPORT-0020 | A source in main.yaml has no code, or the other way round | Declare it in both places |
MAIN-IMPORT-0004, 0010, 0011 and 0012 are runtime codes; see check codes.
Swapping drivers
Files are read by the built-in parsers, so there is no driver to swap for them. Each source names its driver in main.yaml: google-sheets, airtable or rest. Changing a source's driver changes only that source's block; your imports.source() code stays the same.
Testing
createTestApp builds imports over the same fakes as the rest of the app: an in-memory files store, the fake clock and the local job queue. Preview and import a small CSV in a test, and assert on run.counts and run.report. See Test your app.
Security and privacy
- Rows are written only after the mapping is confirmed.
- Values sent to a model for column suggestions are redacted first, and
suggest.samples: 0sends headers only. - A full pass that returns too few keys deletes nothing (
deletes-guard), so a broken source cannot empty your table. - Workbooks are checked for zip bombs and XML entities before any row is read.
Next
- Files: where uploads and reports live.
- Connections: give a source the person's own account.
- Workflows: the engine behind large imports.
- Import a spreadsheet guide: a first import, end to end.
Was this page helpful?