Pre-1.0: a minor version may rename or remove. When it does, the migration is one line here.
0.1.4 (2026-09-19)
A third external review pass over 0.1.3, correctness and safety again, most of it
in the .xlsx codec.
- Adding a sheet could redirect an existing one to an empty part. A workbook
that quoted its relationship attributes with
'read fine, but the next relationship id was allocated by aId="rId..."regex that saw none of them, so a new sheet claimedrId1on top of one already in use and the old tab resolved to the new, empty worksheet. The id, and the next sheet id beside it, are read through the parser now, so the same file the reader accepts is the file they allocate against. - calcChain cleanup missed a valid spelling. Removing the part left its
relationship and content-type override behind when the declaration used
whitespace around its
=or a separate closing tag rather than a self-closing one. Removal is one spelling-agnostic pass now, tolerant of a namespace prefix, either quote style, that whitespace and either closing form, and the same pass removes a deleted sheet's<sheet>element. - A namespace-prefixed styles part was only half-detected. The guard checked
the root
<styleSheet>alone, so a file whose root was unprefixed but whose<fonts>,<fills>or<cellXfs>lists were prefixed slipped through and had a second, unprefixed list appended with an index no cell could resolve. The lists are checked too now, and such a part is read-only, refused with:unsupported. - A shared or inline string that looked like an escape read back wrong. The
SpreadsheetML
_xHHHH_escaping (a control character, or a literal underscore written_x005F_) is not XML escaping and the parser does not undo it; a literal_x0041_came back as the encoded_x005F_x0041_and a carriage return as_x000D_. It is decoded on read, in one pass so_x005F_x0041_is the literal_x0041_and notA, and encoded to match on write. - A values read over a file ignored a formula's cached result.
read_rows/2returned the formula rather than the value a spreadsheet cached beside it, so aTableorLogfield over.xlsxcast the formula and came back nil. It gives the computed value now, the way Google's values read does, with a cached error as its text;read_cells/2still returns the formula, with the result in its metadata. - A number cast into a text column lost precision or raised. Fixed 15 decimals
rounded a value just above
1.0e-15to fewer digits than it had, and raised outright on a magnitude like1.0e308. The shortest round-tripping form is written whenever the fixed one would not read back the same, so every finite float round-trips and the lenient cast stays lenient. - A log cursor read truncated columns a whole read kept. The header and the
rows from the cursor were both bounded to the schema's width, so a column added
to the tab by hand pushed a real column out of range and the read failed with
:missing_columnwhere a whole read found it by name. The header is read whole now, and a tab wider than its schema costs a second request rather than dropping the columns.
0.1.3 (2026-09-18)
A second external review pass over 0.1.2, all correctness or safety, all in the
.xlsx codec unless said otherwise.
- A built-in elapsed format (46,
[h]:mm:ss) still lost whole days. 0.1.2 fixed the custom-format route but the built-in table kept a hardcoded:time; both routes now read the format code through one classifier, so 36 hours stays the number1.5rather than a 12-hourTime. - An ISO time-only cell (
t="d"holding12:30:00, fromiso_dates) read as nil and was erased on the next write; it now reads as aTime. - A standalone error cell is preserved on write. 0.1.2 stopped the crash but
blanked the cell on an unrelated edit; the
t="e"cell is now written back as it was read. Sheetshow.Store.Localcreated its temporary file, wrote the bytes, then made it private, leaving a window where the new contents were readable at the process umask; it is now made private while empty, before the bytes, and removed on every failure path.- calcChain cleanup removed the part but left a namespace-prefixed
(
<r:Relationship>) or single-quoted relationship or content-type override behind; removal now matches every spelling its discovery does. - Header names are canonicalized one way. A schema column with surrounding
space built a table its own read then rejected as
:missing_column, and a padded reserved name (:" id ") slipped past the constructor; validation and indexing now share one trim-and-downcase rule, and a blank column name is refused. - Adding a style to a namespace-prefixed styles part wrote an index the file
could not resolve; such a part is now read-only and the write is refused with
:unsupported, the way a prefixed worksheet already is. - Deleting the last sheet produced a workbook no reader could open; a plan that
would leave no sheets is refused (
:invalid_xlsx), checked on the final state so delete-then-add in one batch still works. - Google whole-sheet reads sent a bare tab name (
Costs), which Google can resolve to a same-named named range in preference to the sheet; whole-sheet reads now quote the name.
0.1.2 (2026-09-18)
Fixes from an external review of 0.1.1. Correctness, and a more honest account of what the concurrency story does and does not cover.
.xlsxreads that lost data. An ISO-date cell (t="d", from a workbook saved withiso_dates) read as nil and, since it read as nothing, was erased on the next write; it now reads as aDateorNaiveDateTime. An elapsed duration ([h]:mm:ss) read as aTime, throwing away whole days (36 hours became 12); it keeps its number and its format now. A float smaller than1.0e-15(such as1.0e-20) was written as0; the shortest round-tripping form is written instead. An error cell (#DIV/0!) with no formula crashed any write to its sheet; the error now reads into the cell's metadata, the same place a formula's error goes, so the write succeeds. (Writing such a cell back verbatim is still to come; an untouched error cell rewrites empty for now.).xlsxwrites that corrupted the file. A shared-string table spelled with a namespace prefix (<x:sst>) silently dropped a newly added string, so the cell read back nil; mutations are namespace-aware now. RemovingcalcChain.xmlleft its relationship and content-type override behind, pointing at a part that was gone; both go with it.- A local write no longer loosens a file's permissions. Replacing a
0600file produced a0644one, because the temporary file took the process umask. The destination's mode is now preserved, a new file is created0600, and the temporary file is created exclusively. Sheetshow.Table.compact/1no longer deletes a live row when a tombstone shares its id after a refresh: an ambiguous tombstone is left for a fresh read to resolve.- Reserved and colliding columns are refused.
Sheetshow.Table.new/2andSheetshow.Log.new/2reject a schema with anidordeletedcolumn, or two columns that differ only by case or spacing; a tab whose header names a needed column twice reads as%Sheetshow.Error{reason: :duplicate_column}rather than silently taking the last. Sheetshow.Schema.cast/2turns a serial number too large to convert into a:casterror, rather than raising.- WebDAV: a
PUTthat returns noETagleaves the version:unknowninstead of adopting one from a follow-upHEAD, which could be a racing writer's. - Google: a batch that adds a tab and then deletes it in one plan no longer leaves the tab in the remembered metadata; adds and deletes are applied in order.
- What Sheetshow can promise, corrected. A conditional-write store makes each
run/2on a file atomic against a racing writer, but does not yet tie aTablewrite to the snapshot it was planned against, becauserun/2re-reads the file at execution. The guide, theSheetshow.Tabledocs and the README said this gap was closed; they now say it is not, and the README's retry note carries the same caveat the guide always has.
0.1.1 (2026-09-17)
Fixes from a QA pass over 0.1.0, all in the .xlsx codec unless said otherwise:
- A column of formulas Excel filled down (one
<f t="shared">and pointers under it) read as empty formulas and was written back as<f></f>, taking the formulas out of the file. Each cell now reads as the shared formula moved to it. - Conditional formatting's fonts and fills (under
<dxfs>) were counted with the lists a cell indexes into, so a new style pointed past the end of<fonts>. - Deleting a sheet whose name a writer had escaped its own way (
Q1's, as LibreOffice does) left the<sheet>element behind, pointing at a part that was gone. - Adding a string rewrote the shared string table from the text alone, flattening rich text in every other cell of the workbook. The table is spliced now.
- A worksheet whose elements carry a namespace prefix (
<x:sheetData>) is read but refused a write, rather than written back with twosheetDataelements. Sheetshow.Table.refresh/2: an id the snapshot had read twice, one of whose rows had since gone, came back as two rows on one sheet row with the flag cleared. Both stay flagged until a fresh read.Sheetshow.records/3raised onheader: false.- A colour such as
"#FF8800\n"passedSheetshow.Style.validate/1and failed in the Google encoder; a sheet name or reference with a trailing newline parsed. Sheetshow.Schema.cast/2: a:jsoncolumn accepts a number or boolean a person typed, and a:booleancolumn accepts1.0and0.0.- Every function that takes options now refuses an option it does not know, as
Sheetshow.plan/2already did: the layout builders,Sheetshow.Client.new/2,Sheetshow.Workbook.xlsx/2,Sheetshow.Token,Sheetshow.Log.Event.new/2,Sheetshow.Table.insert/2anddelete/2,Sheetshow.OAuth,Sheetshow.ServiceAccount.assertion/2,Sheetshow.UserAccount.new/3andSheetshow.Store.webdav/2.
0.1.0 (2026-09-16)
The first release. What is in it:
- Cells and layout.
Sheetshow.Cell,Sheetshow.Coord,Sheetshow.RangeandSheetshow.A1, giving 0-indexed coordinates, inclusive ranges, quoted sheet names and open-ended ranges, withSheetshow.Value(numbers, text, booleans, formulas, dates and times as serial numbers) andSheetshow.Style(a plain map). Layout onSheetshow:row,col,rows,records,to_rows,stack,beside,pad_below,pad_right,shift,put_sheet,put_style,max_rowandmax_col. - Plans.
Sheetshow.plan/2turns cells into backend-neutral ops (AddSheet,DeleteSheet,PutCells,AppendRows,DeleteRows,SetDimensions), onePutCellsper run so a write never clears a neighbour, andSheetshow.run/2carries a plan out in one request.Sheetshow.read_cells/2reads cells with everything about them;read_rows/2reads computed values, one or several ranges in one request. - Backends.
Sheetshow.Workbookover Google Sheets (Sheetshow.Google, OTP's:httpc, TLS verified explicitly, the quota as:rate_limited), over an in-memory spreadsheet that doubles as the test double, and over.xlsxfiles, read one tab at a time and written back with every untouched part copied across compressed, on a local file or a WebDAV server, whereIf-Matchmakes a write conditional.Sheetshow.Backend.capabilities/1says what each promises. - A database on a tab.
Sheetshow.Schemaas a keyword list;Sheetshow.Log, an append-only tab whose state is the fold over its rows, with a cursor read and a view tab;Sheetshow.Table, mutable rows found again by id, with soft delete,refresh/2and a planner that orders hard deletes bottom-up;Sheetshow.ULIDfor client-generated ids. - Credentials.
Sheetshow.ServiceAccountandSheetshow.UserAccount, both reduced to oneSheetshow.Token;Sheetshow.OAuthbuilds the consent URL and reads the answer, with PKCE,access_type=offlineandprompt=consentas the defaults. - Guides that run. Two quick starts, against Google and against an
.xlsxfile; setting up Google; a cookbook; the case for Sheets instead of Postgres; and what Sheetshow can promise. Everyelixirblock in them is executed by the test suite. A cheatsheet generated from the compiled modules, andusage-rules.mdfor an agent's context. - No dependencies, no processes, no macros.