@octabits-io/intake
Spreadsheet import as a reviewable proposal. A person uploads a CSV or an
.xlsx file; what comes back is not “the import ran” but a
Proposal: a create for every new row, field updates for
every record that changes, each value cited to its row and column in the
file, and every row that was left out named with its reason. A reviewer sees
“142 new, 9 changed, 3 rejected”, accepts or rejects row by row, and the
host applies what was accepted as one ledger entry — so the whole import
reverts as one.
pnpm add @octabits-io/intake @octabits-io/proposal zod # + read-excel-file for ./xlsx
| Subpath | Runs on | Deps | Contents |
|---|---|---|---|
. |
any runtime | @octabits-io/result; @octabits-io/proposal and zod peers |
the table and its limits, the cell schemas, the column mapping, validateRows, planImport |
./csv |
any runtime | — | readCsv, parseCsv, detectDelimiter |
./xlsx |
any runtime | read-excel-file (optional peer, its universal build) |
readXlsx |
No node:* anywhere: the readers take bytes (ArrayBuffer, Uint8Array,
Blob), never a path, so the same code reads an upload in a route handler,
a worker or a browser.
The pipeline
import { readXlsx } from '@octabits-io/intake/xlsx';
import { importKeys, planImport, suggestMapping, textCell, numberCell, validateRows, type IntakeTarget } from '@octabits-io/intake';
const contacts: IntakeTarget = {
collection: 'contact',
key: 'email', // a row whose e-mail you have becomes field updates
fields: {
email: { label: 'E-Mail', aliases: ['Mail'], required: true, schema: textCell() },
name: { label: 'Name', schema: textCell({ maxLength: 200 }) },
revenue: { label: 'Umsatz', description: 'yearly revenue in euro', schema: numberCell({ decimal: ',' }) },
},
};
const table = await readXlsx(upload, { name: upload.name }); // Result: refused over a limit, never truncated
if (!table.ok) return table;
let mapping = suggestMapping(table.value.headers, contacts); // by name, label and alias
// … completeMapping for what is left, then a person confirms: mapping = mapColumn(mapping, 3, 'revenue')
const rows = validateRows(table.value, mapping, contacts); // refused when a required field has no column
if (!rows.ok) return rows;
const existing = await findContactsByEmail(importKeys(rows.value, contacts)); // your query: Map<key, { id, label?, values }>
const { proposal, summary } = planImport({
scope: `org:${orgId}`,
target: contacts,
rows: rows.value,
source: table.value.source,
sourceId: storedUpload.id, // the file as you keep it, so a citation resolves
existing,
});
await pendingProposals.record({ // reviewed like any other proposal
kind: 'contact-import',
proposal,
scopeKey: `org:${orgId}`,
summary: `${summary.created} new, ${summary.updated} changed, ${summary.rejected} rejected`,
});
From there it is the ordinary loop, unchanged: the review card shows
proposal.operations grouped new / changed with proposal.skipped
beside them, the decision comes back, and the host applies it —
resolveDecision, detectDrift on the updates’ guards, the writes, one
ledger.record({ operations, created }). Reverting that entry deletes what
the import created and writes back what it changed (invertOperations).
Limits
An upload is untrusted. Every limit refuses the file; none truncates it or splits it, because one import is one proposal and one revert.
| Limit | Default | Checked |
|---|---|---|
maxBytes |
10 MiB | before reading; on a Blob, before its bytes are loaded |
maxRows |
5,000 data rows | while parsing; a CSV stops reading at the first row over |
maxColumns |
200 | on the table |
maxUnzippedBytes |
100 MiB | for a workbook, from the zip’s central directory before anything is unpacked; ZIP64 is refused |
Pass limits: { … } to a reader to change them. The unzipped-size check is
a real bound, not a hint: read-excel-file’s universal build unzips with
fflate, which inflates each entry into a buffer of its declared size, so an
entry that lies about its size is cut off rather than grown.
Reading
CSV (readCsv): RFC 4180 quoting, any line ending, a byte-order mark
dropped. The separator is detected from the header line (; is what Excel
writes in German-speaking locales) unless you pass delimiter. Bytes that are
not valid UTF-8 are read as Windows-1252, Excel’s other CSV encoding. Every
cell is text.
xlsx (readXlsx): one sheet, by name, by 1-based position, or the
first. Cells come as the workbook stores them: numbers, booleans, text
trimmed, dates as ISO strings (a date without a time as YYYY-MM-DD). A
formula cell is the value the spreadsheet application last computed;
nothing is evaluated.
Both return an IntakeTable: the header (the first row with anything in it),
the data rows with the row number a person sees in their spreadsheet,
blank rows dropped, and source with the file name, the sheet and the
sha256: digest of the exact bytes read. Another parser plugs in through
tableFromGrid(grid, source) and gets the same shape and limits.
Mapping, and what a model sees
suggestMapping matches each header against every field’s name, label
and aliases, ignoring case, accents, ß/ss and punctuation (E-Mail,
email, e mail are one). Each field maps once, leftmost column first.
What is left can go to a model. mappingRequest(mapping, target) builds the
whole payload: the headers nothing matched and the fields still open — no
cell, ever. That is a property of the function, not of a prompt.
completeMapping sends it through your function, which is where your
disclosure gate goes:
import { guardModelCall } from '@octabits-io/disclosure';
import { completeMapping, formatMappingPrompt, mappingAnswerSchema } from '@octabits-io/intake';
const suggest = guardModelCall(
async (request) => (await generateObject({ model, schema: mappingAnswerSchema, prompt: formatMappingPrompt(request) })).object,
{
model: { provider: 'mistral', modelId: 'small' }, destination, class: 'internal', redact, audit,
structuralKeys: ['fields'], // your target's field list: written by your code, not by the file
},
);
const completed = await completeMapping(mapping, contacts, suggest); // a denial comes back as the error, mapping unchanged
The headers came from the file, so they go through your redactor; an answer
about a header the redactor rewrote names a header the file does not have,
and is dropped like any other unknown one. The answer is checked, not trusted: an unknown header or field, a field
already taken, or one two headers claim is dropped. Columns the model mapped
say by: 'model'; mapColumn records a person’s choice as by: 'person'
and moves a field off any other column. Show the mapping before
validateRows — the reviewer of the rows should not be the first person to
learn that “Umsatz” was read as the customer number.
Rows
validateRows runs every mapped cell through its field’s Zod schema. A row
passes whole or is rejected whole, with every issue named (row 6: Umsatz: not a number). A blank cell is left out, never null: an import fills
and changes values, it never clears one; a blank required cell rejects the
row.
The cell schemas in the root take what spreadsheets actually contain:
| Schema | Takes |
|---|---|
textCell({ maxLength? }) |
anything, as trimmed text |
numberCell({ decimal? }) |
workbook numbers, 1,234.5, and with decimal: ',' 1.234,5 |
integerCell({ decimal? }) |
the same, whole numbers only |
booleanCell() |
true/false, yes/no, ja/nein, 1/0, x for a ticked box |
dateCell() |
ISO dates and timestamps, 31.12.2026, 1.2.26; 12/31 is ambiguous and refused |
choiceCell(['Active', 'Paused']) |
one of a set, case-insensitive, returned in the set’s spelling |
A field’s schema may be any z.ZodType<JsonValue, IntakeCell>; refine
these or write your own.
The proposal
planImport is pure over data. With a key, it looks each row’s key up in
the existing map you pass (importKeys gives you the keys, once each):
- no record →
createincollection,ref: row-<n>, groupnew; - a record →
proposeFieldsagainst its current values, oneupdateper field that differs, each with a drift guard over the current value, groupchanged; a row that changes nothing counts asunchangedand proposes nothing; - a key an earlier row had →
skippedwithduplicate_key; the first row wins; - a rejected row →
skippedwithinvalid_rowand its issues.
Without a key, every valid row is a create. Every operation’s
derivedFrom.citations points at the file: source is your sourceId,
digest the bytes’ SHA-256, and locator the place in A1 notation — Kunden!12:12 for a new row, Kunden!C12 for an updated cell — which Excel’s name box jumps to in any language.
The proposal’s workflowType is intake, so a host can give it a renderer
of its own; summary has the counts for the card’s headline.
The words are the host’s to choose: rowLabel(row) names a new row for the
reviewer (Row 12 by default — a product name reads better), and
describeSkipped(skip) words a row left out in the reviewer’s language from
structured data (invalid_row with its issues, each required or invalid;
duplicate_key with the key and the first row that had it). describeSkip
is the English default.
What it does not do
- Write anything. The host applies the decision and owns every query.
- Clear values. A blank cell never becomes
null. - Delete what the file does not mention. A sync that removes records is a different operation with a different review.
- Split a large file. Over a limit is a refusal; raise the limit or split the file deliberately.
- Send a cell to a model. Only headers and field descriptions, through your function.
Stability
0.x: minors may break and say so; patches never do.
From @octabits-io/intake@0.1.2 in the platform repository.