Repository navigation
Core Concepts
Five ideas explain how JAZMIN stays small and reads so little: columns, chunks, statistics, indexes and the catalog. Once you know them, you can predict what a query will cost.
flowchart LR
subgraph File[".jzm file"]
direction LR
D["Data: chunks of rows<br/>(4,096 rows each by default)<br/>stored column by column,<br/>compressed, optionally encrypted"]
C["Catalog (at the end)<br/>header: tables and columns<br/>metadata: your key/values<br/>directories: where each chunk is,<br/>with min/max per column<br/>indexes: sorted and trigram<br/>keyring: secrets for sections"]
end
D --- C
-
Chunks. Rows are cut into chunks of 4,096 (
chunkRows). Each chunk is compressed, and optionally encrypted, on its own. To read row 765,432, the library reads only the chunk that holds it. - Columns. Inside a chunk, each column is stored separately, as typed binary values. A query decodes only the columns it uses: on a 300-column table, asking for 3 columns decodes 3.
-
Statistics. For every chunk and column, the catalog records the minimum, maximum and null count. A filter such as
{ amount: { gt: 1000 } }skips every chunk whose maximum is 1,000 or less, without reading it. -
Indexes. Optional, per column. A
sortedindex maps values to rows (equality, ranges, prefixes); atrigramindex finds text that contains a fragment. Large sorted indexes are stored in pages of about 64 KB, so a lookup reads a small directory and one page. - The catalog sits at the end of the file. That lets a writer stream millions of rows to disk with only one chunk in memory, and write the directories and indexes last. Opening a file reads the catalog only.
| Type | JavaScript value | .NET value | Notes |
|---|---|---|---|
bool |
boolean |
bool |
|
int |
number (bigint beyond ±2^53) |
long |
64-bit signed |
float |
number |
double |
64-bit IEEE 754 |
decimal |
string, e.g. "1520.75"
|
string in rows; decimal on your classes |
Exact, never rounded; 12.5 equals 12.50 in filters |
string |
string |
string |
UTF-8 |
datetime |
Date |
DateTime (UTC) |
Millisecond precision |
binary |
Buffer |
byte[] |
|
json |
any JSON value | JsonNode |
Nested objects and arrays, any depth |
Every column also has nullable (default true), and optionally a description and free-form attributes, such as
{ "unit": "kPa" }. They travel with the file, so the next reader knows what the data means.
import { open, write } from '@smithsoft-studios/jazmin';
const bytes = write(null, [{
ok: true, count: 42, ratio: 0.25, price: '19.990', name: 'Ann', at: new Date('2026-01-02T03:04:05Z'),
raw: Buffer.from([1, 2, 3]), extra: { tags: ['a', 'b'], address: { city: 'Cape Town' } },
}], {
columns: [
{ name: 'ok', type: 'bool' },
{ name: 'count', type: 'int' },
{ name: 'ratio', type: 'float' },
{ name: 'price', type: 'decimal', description: 'Unit price', attributes: { currency: 'ZAR' } },
{ name: 'name', type: 'string' },
{ name: 'at', type: 'datetime' },
{ name: 'raw', type: 'binary' },
{ name: 'extra', type: 'json' },
],
});
const reader = open(bytes);
const row = reader.get(0);
for (const c of reader.columns) {
const value = row[c.name];
const kind = value instanceof Date ? 'Date' : Buffer.isBuffer(value) ? 'Buffer' : typeof value;
console.log(c.type.padEnd(8), kind.padEnd(7), JSON.stringify(value));
}
console.log(reader.columns.find((c) => c.name === 'price'));bool boolean true
int number 42
float number 0.25
decimal string "19.990"
string string "Ann"
datetime Date "2026-01-02T03:04:05.000Z"
binary Buffer {"type":"Buffer","data":[1,2,3]}
json object {"tags":["a","b"],"address":{"city":"Cape Town"}}
{
name: 'price',
type: 'decimal',
nullable: true,
description: 'Unit price',
attributes: { currency: 'ZAR' }
}
Note that the decimal kept its trailing zero ("19.990"): decimals are stored exactly as given.
The next example writes 100,000 transactions in date order (sortedBy), with an index on account and a trigram
index on memo, then asks explain() how three queries will run. With { analyze: true }, explain() also runs the
query and reports what it read.
import { open, write } from '@smithsoft-studios/jazmin';
const start = Date.UTC(2026, 0, 1);
write('transactions.jzm', Array.from({ length: 100_000 }, (_, i) => ({
at: new Date(start + i * 300_000), // one every 5 minutes
account: `ACC-${String((i * 7919) % 20000).padStart(5, '0')}`, // 20,000 accounts, 5 rows each
amount: ((i * 7919) % 50000) / 100,
memo: i % 25000 === 0 ? 'refund for order' : 'card payment',
})), {
columns: [
{ name: 'at', type: 'datetime', nullable: false },
{ name: 'account', type: 'string', index: 'sorted' },
{ name: 'amount', type: 'float' },
{ name: 'memo', type: 'string', index: 'trigram' },
],
sortedBy: ['at'],
});
const show = (label, filter, options = {}) => {
const reader = open('transactions.jzm'); // a fresh reader, so the cost is the query's own
const { ms, ...plan } = reader.explain(filter, { ...options, analyze: true });
console.log(label.padEnd(30), JSON.stringify(plan));
reader.close();
};
const reader = open('transactions.jzm');
console.log(`${reader.rowCount} rows in ${reader.chunkCount} chunks; indexes:`, reader.indexes);
reader.close();
show('one day (sort order)', { at: { gte: new Date('2026-03-01'), lt: new Date('2026-03-02') } });
show('one account (sorted index)', { account: 'ACC-00042' });
show('memo contains (trigram)', { memo: { contains: 'refund' } });
show('amount > 499 (no index)', { amount: { gt: 499 } });
show('same, select amount', { amount: { gt: 499 } }, { select: ['amount'] });100000 rows in 25 chunks; indexes: [
{ column: 'account', kind: 'sorted' },
{ column: 'memo', kind: 'trigram' }
]
one day (sort order) {"strategy":"scan","chunks":25,"chunksSkipped":24,"rows":288,"bytesRead":24264,"chunksRead":1,"indexPagesRead":0,"columnsDecoded":4}
one account (sorted index) {"strategy":"index","candidateRows":5,"rows":5,"bytesRead":135086,"chunksRead":5,"indexPagesRead":2,"columnsDecoded":20}
memo contains (trigram) {"strategy":"index","candidateRows":4,"rows":4,"bytesRead":97811,"chunksRead":4,"indexPagesRead":1,"columnsDecoded":16}
amount > 499 (no index) {"strategy":"scan","chunks":25,"chunksSkipped":0,"rows":198,"bytesRead":587982,"chunksRead":25,"indexPagesRead":0,"columnsDecoded":100}
same, select amount {"strategy":"scan","chunks":25,"chunksSkipped":0,"rows":198,"bytesRead":587982,"chunksRead":25,"indexPagesRead":0,"columnsDecoded":25}
What the lines say:
-
One day: the file is sorted by
at, so chunk statistics rule out 24 of the 25 chunks. No index is needed. - One account: the sorted index names 5 rows, and only the 5 chunks holding them are read.
- Text search: the trigram index finds the 4 matching memos, and only their 4 chunks are read.
- No index, no sort order: amounts are spread through every chunk, so every chunk is read and every column of every chunk is decoded (4 × 25 = 100 column streams).
-
The same with
select: ['amount']: the chunks are still read, but only one column is decoded in each (25 column streams). Decoding is most of a query's work, so on wide tablesselectis the biggest single saving.
The fix for a query that reads too much is almost always one of three things: sort the file by the column you filter
on, add an index, or use smaller chunks. Tuning Speed and Memory shows how to choose, and
reader.advise() suggests a layout from the statistics alone.
| Index | Speeds up | Allowed on |
|---|---|---|
sorted |
eq, in, gt, gte, lt, lte, startsWith, isNull
|
bool, int, float, decimal, string, datetime |
trigram |
contains, icontains
|
string |
- Cost: indexes make the file bigger (about 20–30% for three indexed columns) and writing slower. Add them only to columns you search on.
-
No index needed on the sort column: a
sortedindex on the firstsortedBycolumn is not written, because chunk statistics already find its values. - Indexes only narrow the scan. The reader works out which chunks a scan would read, and uses an index only when it reads less than that. Results are the same either way.
-
Compact indexes (
compactIndexes: true, New in 1.2) store each key as the difference from the previous one: much smaller indexes on whole numbers and dates. Readers before 1.2 can't read them, so turn them on once every reader is on 1.2 (see Versions and Compatibility).
Pass a key (32 random bytes, as jzk1-... text with a checksum) or a password (turned into a key with
PBKDF2, 600,000 iterations). The key unlocks the catalog; the catalog holds secrets that, combined with the key, give
every chunk and index its own key. Everything is authenticated, so any change to the file is detected.
Encryption and Keys has the details.
A file holds one table by default. It can also hold:
- several tables that share keys and access rules: Several Tables;
- embedded files, such as PDFs, images or a whole web page: Embedded Files and Documents;
-
metadata, your own key/value information (source, export date, owner):
metadata: { source: 'crm' }; - access rules, so each key sees only its rows and columns: Access Control.
| Operation | Reads |
|---|---|
open() |
The catalog only |
get(rowId) |
The chunk that holds the row |
find(filter) |
Index pages that help, then only chunks that can match, and only the columns used |
find(filter, { select }) |
As above, decoding only the selected columns (and the filter's) |
count(filter) |
Often nothing but the index or chunk statistics |
rows({ offset, limit }) |
Skips whole chunks before the offset without reading them |
find() and rows() are generators: one chunk is held in memory at a time, however many rows match.
JAZMIN 1.2 · file format 1.0 · Repository · npm · NuGet · MIT license · Examples in this wiki are run and checked by tools/check-examples.mjs
Start
Using files
- Writing Files
- Nested Columns
- Reading and Querying
- Filter Language
- .NET Serializer and LINQ
- Updating and Appending
- Several Tables
- Converting JSON, CSV, XML
- Export Shapes
Security and sharing
- Encryption and Keys
- Access Control
- Masked Columns
- Time-Limited Access
- Sending Records Back
- Security Model
Documents and apps
Performance
Reference