# Migrating to v1
v1 is where hucre's public API becomes a stability commitment. Getting there meant fixing things that were wrong, inconsistent, or documented-but-inert — several of which could not be fixed afterwards without a major bump.
This guide lists every change that can affect existing code. Most projects will need to touch nothing; the ones that do are marked.
If a change here breaks something and the reason isn't clear, please open an issue — a migration that needs a guess is a migration that needs better docs.
## At a glance
| Change | Affects you if… |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| [`writeXlsxStream` argument order](#writexlsxstream-takes-rows-first) | you call `writeXlsxStream` |
| [`headerRow` is 0-based everywhere](#headerrow-means-one-thing-now) | you pass `headerRow` to `validateWithSchema` |
| [`hasHeaderRow` on the exporters](#hasheaderrow-on-tohtml-and-tomarkdown) | you pass `headerRow` to `toHtml` / `toMarkdown` |
| [`streamCsvRows` header handling](#streamcsvrows-matches-parsecsv) | you call `streamCsvRows({ header: true })` |
| [`readObjects` / `sheetToObjects` return shape](#readobjects-and-sheettoobjects-return-data-headers) | you call either |
| [`fromHtml` type inference](#fromhtml-reads-cell-text-the-way-parsecsv-does) | you call `fromHtml` on tables with codes, IDs, booleans or dates |
| [`HucreError`](#deftererror-is-now-hucreerror) | nothing — the old name still works |
| [`readNdjsonStream`](#readndjsonstream-is-now-streamndjsonrows) | nothing — the old name still works |
| [Sheet names are validated](#sheet-names-are-validated-on-write) | you write sheet names with `: * ? / \ [ ]`, over 31 chars, or duplicates |
| [`parseJson` on a multi-sheet document](#parsejson-rejects-a-workbook-instead-of-mangling-it) | you pass `workbookToJson` output of ≠1 sheet back into `parseJson` |
| [Removed dead API](#removed-api-that-never-did-anything) | you reference `ReadResult`, `WORKER_SAFE_FUNCTIONS`, `isoDates`, `WriteSheet.threadedComments`, … |
| [Files that were silently corrupt](#files-that-were-silently-wrong) | you round-trip workbooks, or print them |
---
## `writeXlsxStream` takes rows first
Every other writer in the library is `write*(data, options)`. This one was inverted.
```diff
- writeXlsxStream({ name: "Export", columns }, rows)
+ writeXlsxStream(rows, { name: "Export", columns })
```
TypeScript flags every call site.
## `headerRow` means one thing now
It used to mean four different things. `validateWithSchema` was the last 1-based holdout:
```diff
- validateWithSchema(rows, schema, { headerRow: 2 }) // 1-based: the second row
+ validateWithSchema(rows, schema, { headerRow: 1 }) // 0-based: the second row
```
**The subtle one.** `headerRow: 0` used to mean _"there is no header row"_ — 1-based numbering had no other way to say it, so two concepts shared one value. They are separate now:
```diff
- validateWithSchema(rows, schema, { headerRow: 0 }) // meant "no header row"
+ validateWithSchema(rows, schema, { headerRow: -1 }) // says it explicitly
```
`headerRow: 0` now means _the first row is the header_, like everywhere else in the library.
If you rely on the **default**, nothing changes: the old default of `1` and the new default of `0` both mean the first row.
The options type is now exported as `SchemaValidateOptions`, so you can build one in a typed variable.
## `hasHeaderRow` on `toHtml` and `toMarkdown`
`headerRow` was a **boolean** on these two, while it is a row index everywhere else. It is renamed:
```diff
- toHtml(sheet, { headerRow: true })
+ toHtml(sheet, { hasHeaderRow: true })
```
The old spelling still works for one major and is marked `@deprecated`.
## `streamCsvRows` matches `parseCsv`
The two took the same `CsvReadOptions` and behaved differently in four places. `header: true` was the one that changed the data:
```diff
- streamCsvRows(input, { header: true }) // dropped the header row
+ streamCsvRows(input, { header: true, skipHeaderRow: true }) // same output
```
`header: true` now only _marks_ the header row — it is still yielded, and used to name columns for `transformValue` — which is what `parseCsv` has always done. `skipHeaderRow` is the explicit way to consume it.
Also newly honoured, having previously been silently ignored: `onRow`, `transformValue`, and `fastMode`. If you passed any of those to `streamCsvRows` before, they now actually run. `fastMode` in particular changes the parsed fields, because it skips quote handling by design.
## `readObjects` and `sheetToObjects` return `{ data, headers }`
They returned a bare array while every other `*Objects` reader returned `{ data, headers }`.
```diff
- const rows = await readObjects(buffer)
+ const { data: rows } = await readObjects(buffer)
```
Three smaller behaviour changes come with `readObjects` joining the family — in each case it now matches its siblings rather than diverging:
- empty-string header keys are **kept** (only `readObjects` dropped them)
- fully empty rows are **skipped** by default
- a missing sheet **throws** `ParseError` instead of returning `[]`
## `fromHtml` reads cell text the way `parseCsv` does
`fromHtml` ran a bare `Number()` on every cell, with no way to turn it off, so a scraped table of ZIP codes or product codes came back as arithmetic. It now shares one inference implementation with `parseCsv` and takes the same two options under the same names.
```diff
- fromHtml("
").rows // [[7]]
+ fromHtml("").rows // [["007"]]
```
Every difference points the same way — less coercion, except where a value was previously left as a string that no spreadsheet would call one:
| cell text | before | now |
| ------------ | -------------- | ---------------------------------------------- |
| `007` | `7` | `"007"` — `preserveLeadingZeros`, default true |
| `0x1A` | `26` | `"0x1A"` — hex, binary and octal are not cells |
| `Infinity` | `Infinity` | `"Infinity"` |
| `1,234` | `"1,234"` | `1234` |
| `true` | `"true"` | `true` |
| `2024-01-15` | `"2024-01-15"` | a `Date` |
`typeInference` defaults to **true** here, where `parseCsv` defaults it to false. A CSV field is text and quoting can say so; an HTML table has no such convention, `fromHtml` has always coerced, and returning `"42"` for `42 | ` would restring every existing caller's data silently. Pass `typeInference: false` for cell text exactly as written.
Two additions come with it. A `` row (or a row of all ``) now sets `sheet.a11y.headerRow`, and `` text becomes `sheet.a11y.summary` — a sheet from `fromHtml` may now carry an `a11y` object where it carried none. And malformed markup no longer throws: `fromHtml` is documented as best-effort, and it now behaves that way, returning the rows it read instead of an `XmlError`. If you wrapped it in a `try`/`catch` for that, the `catch` is dead code.
## `DefterError` is now `HucreError`
`instanceof DefterError` was the documented catch-all for every error the library throws — in a package called `hucre`.
```diff
- catch (e) { if (e instanceof DefterError) … }
+ catch (e) { if (e instanceof HucreError) … }
```
**No action required.** `DefterError` is still exported and is the _same class object_, so `instanceof` behaves identically. It is marked `@deprecated`.
One visible difference: `error.name` now reports `"HucreError"` rather than `"DefterError"`. If you match on that string, update it.
Separately, the `ValidationError` **interface** — one row/column schema failure — is renamed `SchemaValidationIssue`. The `ValidationError` **class** keeps its name. The `ValidationErrorType` alias is gone.
## `readNdjsonStream` is now `streamNdjsonRows`
So every streaming reader reads `stream*Rows`. **No action required** — the old name is still exported as a deprecated alias of the same function.
## Sheet names are validated on write
Previously anything was written verbatim, producing files Excel opens with "unreadable content" and no warning. `writeXlsx`, `writeOds`, `XlsxStreamWriter`, and `writeXlsxStream` now throw `InvalidArgumentError` before producing any bytes for:
- empty names, or names longer than 31 characters
- `[ ] : * ? / \`
- a leading or trailing apostrophe (it breaks quoted range references)
- the reserved name `History`
- duplicates — compared **case-insensitively**, because Excel does
It throws rather than sanitizing on purpose: truncating a name or stripping its colons produces a workbook whose sheets are not the ones you asked for, and any range reference built against the original names would then dangle.
If you generate sheet names from user data — report titles, date ranges, file names — sanitize before calling.
## `parseJson` rejects a workbook instead of mangling it
`workbookToJson` emits a bare array for a one-sheet workbook and
`{ "Sheet1": [...], "Sheet2": [...] }` for anything else. `parseJson` only
ever unwrapped a _single_ array-valued property, so the multi-sheet shape fell
through to "treat the object as one row" and came back as one row whose cells
were JSON-stringified sheets — silently, and only once a workbook grew a second
sheet.
It now throws `ParseError`, and there are three ways forward:
```diff
- parseJson(workbookToJson(wb)) // one row of nonsense
+ jsonToWorkbook(workbookToJson(wb)) // every sheet, as a Workbook
+ parseJson(workbookToJson(wb), { rowsAt: "S1" }) // one named sheet as a table
+ parseJson(json, { rowsAt: "" }) // the old single-row reading
```
The guard is deliberately narrow: it only fires when **every** property is an
array of plain objects and there are at least two of them. `{ a: [1, 2], b: [3, 4] }`
is a row with two list-valued columns and still reads as one row.
Two additions come with it, neither of them breaking:
- **`jsonToWorkbook`** reads either shape back into a `Workbook`, so the round
trip no longer depends on the sheet count.
- **`workbookToJson(wb, { shape: "sheets" })`** always emits the keyed object.
The default `"auto"` keeps today's count-dependent shape.
## Nested JSON can be rebuilt: `unflattenRow`
`parseJson` flattens `{user: {name}}` to a `"user.name"` column by default, and
nothing reversed it, so `parseJson` → `writeJson` permanently destroyed the
nesting. There is now an inverse, and the writers take it as an **opt-in**:
```diff
- writeJson(parseJson(text).data) // { "user.name": "Ada" }
+ writeJson(parseJson(text).data, { unflatten: true }) // { user: { name: "Ada" } }
```
Opt-in rather than on by default because `writeJson` takes any flat row set,
most of which never went through `flatten` — and spreadsheet headers contain
dots routinely (`Q1.2024`, `v1.2`). Nesting those by default would be a new
silent mangling in the fix for one. `writeNdjson` and `NdjsonStreamWriter` take
the same option; `unflattenRow` / `unflattenRows` are exported directly.
Two things it does not undo, because they are not recoverable from the flat
form: a primitive array joined into `"1, 2"` stays a string, and a key that
contained a literal dot comes back nested.
## Dates come back from JSON
The CSV reader has always inferred ISO 8601 dates under `typeInference`. The
JSON reader had no equivalent, so a `Date` written by `writeJson` came back a
string. The same option name now does the same job on both:
```diff
- parseJson(writeJson([{ at: new Date() }])).data[0].at // string
+ parseJson(writeJson([{ at: new Date() }]), { typeInference: true }).data[0].at // Date
```
Off by default in both readers, and it accepts exactly the instants CSV
accepts — the rule is one function now, shared by `parseCsv`, `streamCsvRows`,
`parseJson`, `parseNdjson` and `streamNdjsonRows`. It infers **only** dates for
JSON: numbers and booleans are already typed there, so `"007"` stays a string.
## Removed API that never did anything
Each of these was exported or declared and had no effect. v1 would have frozen them permanently.
| Removed | Why |
| --------------------------------------------- | ------------------------------------------------------------ |
| `ReadResult` | no function ever returned it |
| `StreamReadOptions`, `StreamWriteOptions` | zero references anywhere |
| `WORKER_SAFE_FUNCTIONS` | 40 entries against ~125 exports; every export is worker-safe |
| `ReadOptions.headerRow`, `ReadOptions.schema` | honoured by no reader |
| `CsvReadOptions.lineSeparator`, `.encoding` | never read (`CsvWriteOptions.lineSeparator` is fine) |
| `CsvReadOptions.schema` | zero references under `src/csv/` — no CSV reader validated |
| `isoDates` on the JSON writers | see below |
| `WriteSheet.threadedComments` | typed and accepted; no writer ever produced the part |
`CsvReadOptions.schema` never validated anything: `parseCsv` returned every row exactly as parsed whatever you passed. Validate the parsed rows with `validateWithSchema`, which is what the option looked like it was doing.
`isoDates` is worth explaining because it _looked_ like it worked. `JSON.stringify` calls `Date.prototype.toJSON` **before** consulting the replacer, so a replacer testing `value instanceof Date` is never reached — `isoDates: false` produced byte-identical output. Dates still serialize as ISO strings, exactly as before.
`RoundtripWorkbook` no longer exposes `_rawEntries`, `_modifiedParts`, `_contentTypes`, or `_rootRels`. Pass the object from `openXlsx` straight to `saveXlsx`, as documented. One consequence: `saveXlsx({ ...workbook })` no longer works, because spreading drops the internal state.
## Files that were silently wrong
No action needed — but if you have workbooks produced by 0.6.x, they may be affected.
- **`openXlsx` → `saveXlsx` dropped state it understood**: split panes, page breaks, sparklines, text boxes, background images, Excel 2024 checkboxes, and **workbook protection** — a structurally locked workbook came back unlocked.
- **Setting any `pageSetup` turned off printed gridlines and row/column headings.** `showGridLines` and `showRowColHeaders` were inert in both directions and now work. `horizontalCentered` / `verticalCentered` moved to `` where ECMA-376 puts them; they were written where Excel ignores them. Files from earlier versions are still read correctly.
- **`fillTemplate` could put a function into a cell.** A placeholder named `toString`, `constructor`, `valueOf`, or `__proto__` resolved against `Object.prototype`.
- **Rich-text colours parsed differently** in `sharedStrings.xml` than in an inline `` — `"FF0000"` became `"0000"`.
- **A workbook redefining a built-in number-format id** could be read as a date and then formatted numerically.
## Reading untrusted files
Several inputs could previously hang or kill the process. If you accept uploads, these are now bounded and throw typed errors instead:
- an unbounded `` range could hang `readXlsx` **forever** from a 1.4 KB file
- two entirely legal cells at opposite corners caused a **fatal, uncatchable OOM** — readers return a dense grid, so a sheet costs its bounding box, not its cell count
- ODS `` and `number:decimal-places` could allocate gigabytes
- `read(response.body)` had **no size ceiling** at all; there is now `ReadOptions.maxInputBytes`, defaulting to 1 GiB
- the zip-bomb cap covered only 3 of 4 decompression paths
- `fromHtml` could hang on a hostile `rowspan` × `colspan`, and 175 KB of `| ` still allocated 82 million array slots; the cells a table describes are now counted against `MAX_TOTAL_CELLS` and the rowspan reservations against `MAX_SPAN_CELLS`, both throwing `ParseError`
Very large legitimate files may now hit a limit that used to be absent. Every one is a named constant in `src/limits.ts`, and the errors say which.
## Also worth knowing
- **The CLI works now.** `dist/cli.mjs` imported packages that were not runtime dependencies, so `npx hucre` failed on every clean install. It is bundled, and CI verifies the packaged tarball.
- **`hucre/xlsx` and `hucre/ods` export what the README documents.** `readXlsxObjects`, `readOdsObjects`, `streamOdsRows`, and others were root-only despite documented subpath imports.
- **Format entry points export their own types**, so `import type { WriteSheet } from "hucre/xlsx"` works without a second import from the root.
- **`streamOdsRows` takes options and a `ReadableStream`.** It previously accepted neither.
- **`toJson`'s `"arrays"` and `"columns"` formats are write-only**, and now say so. They are handoffs to a charting library or a dataframe; only `"objects"` has a reader. Export in `"objects"` if the JSON has to come back into hucre.
- **`writeCsvStream` exists** — constant-memory CSV writing, the counterpart to `writeXlsxStream`.
- **A CSV write option now has a way back in.** `escapeFormulae: true` prefixed `= + - @ | \t \r \n \0` values with `'` and nothing removed it, so a round trip through hucre turned `-5` into `'-5` permanently. `parseCsv` and `streamCsvRows` take `unescapeFormulae: true`, which drops that `'` — and only where the writer would have added one, so `'quoted'` is left alone. `nullValue` and a custom `dateFormat` remain one-way by decision; both are documented as such on the option.
- **The streaming CSV writers escape formulae too.** `escapeFormulae` was honoured by `writeCsv` alone — `CsvStreamWriter` and `writeCsvStream` ignored it silently, which for an injection escape meant the protection you asked for was simply absent. If you passed it to either, their output changes.
- **`parseCsv` honours `skipHeaderRow`.** It was implemented in `streamCsvRows` and ignored here, so the same option on the same options type behaved two ways. `parseCsv(input, { header: true, skipHeaderRow: true })` now drops the header row, and `maxRows` counts data rows in both readers.
- **`CsvWriteOptions.comment` quotes values that would read as comments.** A value starting with `#`, written bare, is deleted by a reader configured with `comment: "#"` — the whole row, silently. Pass the same character to the writer and those values are quoted instead. Off by default; the output is unchanged unless you set it.
- **ZIP64 archives are readable**, and writable via `zip64: true`.
- **`hucre/ooxml` exists.** The low-level OOXML part parsers — `parseChart`, `parsePivotTable`, `parseSlicers`, `parseThemeColors` and friends — have a home of their own, explicitly outside the v1 stability commitment. They are still exported from the root, marked deprecated, so nothing breaks.
| |