Skip to content

JavaScript

A Node-API view of the same values the Rust core holds, with conventional JavaScript casing and protocols.

const { DataType, Field, Url } = require('yggdryl')
const assert = require('node:assert/strict')

const schema = new Field(
  'row',
  DataType.fromFields([new Field('id', 'int64', false)]),
  false,
)

assert.equal(schema.dtype.kind, 'nested')
assert.equal(String(Url.fromPath('C:/market data/trades.arrows')),
  'file:///C:/market%20data/trades.arrows')

This page documents the JavaScript boundary only: what the package adds on top of the core, and how it converts what you hand it. The behaviour itself is documented once, on the core pages.

Build from the repository

npm install --prefix node
npm run --prefix node build:debug
npm test --prefix node

npm test runs node --test tests/*.test.js and then tsc --noEmit, so the shipped .d.ts declarations are checked against the tests that use them.

What it exposes

Name Documented in
DataType datatype
Field, fields field
Expression, Bound, Statement, BoundStatement expression
Uri, Url, Urn uri
IOBase io
BatchReader, RecordOptions io, ipc, parquet
iceberg iceberg
fix fix
MimeType, MediaType, Timezone enums
codec, json, toml, yaml, Scalar text and the format pages
avro Avro schema, container, single-object, and batch media
gzip, zlib, zstd gzip, zlib, zstd
xxhash, Digest xxhash

Each coding namespace carries the whole-buffer pair - loads and dumps, plus loadsRaw and dumpsRaw on zlib - over Buffer, reading and writing exactly what node:zlib does. Their streaming reader/writer and the transparent handle wrappers stay Rust-only: both are built on Rust's Read/Write, which has no JavaScript spelling here. A handle still applies the coding its name declares without being told, and IOBase.codec is what asks it which one that is.

Digests

xxhash carries the four one-shot functions, the four resumable states, and Digest; IOBase.readDigest and Scalar.digest redirect to the same native path. XXH32 answers a number - a 32-bit value always fits one exactly - and the wider algorithms answer bigint, because they do not.

Each resumable state also exposes fillArrowBatch(root, batch, force = false). The root Field's digest metadata selects the row values, default holder cells are filled, and populated holders are preserved unless force is true. The state supplies its algorithm, seed, and secret, but bytes previously written to it are ignored and the state remains unchanged. Arrow JS batches cross this operation through copied Arrow IPC in both directions.

const assert = require('node:assert/strict')
const { Scalar, xxhash } = require('yggdryl')

const payload = Buffer.from('abc')
assert.equal(xxhash.xxh32(payload), 0x32d153ff)
assert.equal(xxhash.xxh3(payload), 0x78af5f94892f3950n)
assert.equal(xxhash.xxh3('abc'), xxhash.xxh3(new Uint8Array(payload)))

const digest = xxhash.digest(payload, 'xxh3-64')
assert.equal(digest.toString(), 'xxh3-64:78af5f94892f3950')
assert.ok(xxhash.Digest.from(digest.toString()).equals(digest))
assert.equal(Scalar.fromJs('AAPL').digest().value(), Scalar.fromJs('AAPL').stableHash())

Digest follows the convention every immutable wrapper here follows: equals, compare, stableHash, clone, toString, and toJSON, because JavaScript cannot overload equality or hashing. A Buffer or Uint8Array is hashed in place; an ArrayBuffer is narrowed to a Buffer window first, and a string is encoded as UTF-8. The streaming reader/writer pair and the Hashed<H> handle stay Rust-only, for the same Read/Write reason the codings do.

A filesystem is whatever answers seven calls

Arrow JS ships no filesystem, so where Python hands the core a pyarrow.fs.FileSystem that already exists, JavaScript supplies the vtable itself: a plain object whose methods are Arrow's own FileSystem calls in camelCase - fileInfo, list, readRange, writeFull, createDir, deleteFile, plus a typeName. IOBase.fromFs(handler, path) turns one into an ordinary handle, so a Map, node:fs, an S3 client, or a caching layer over any of them becomes storage the rest of the package can read and write. fs documents the backend and shows a complete handler.

Two things belong to this boundary rather than to the backend. Sizes cross as bigint, because a 64-bit length is what an object store reports and a JavaScript number cannot hold one exactly - an exact number is accepted, since a handler over fs.Stats already has one. And the handler is called synchronously, on the thread that supplied it: Node-API's only cross-thread call is asynchronous, while every method here has to answer in the middle of a native read, so a handler-backed handle refuses a Worker by name instead of pretending. A worker that needs its own view builds its own handler; only the location string has to travel.

using binds to open/close, which is what publishes a staged whole value - the shape every Arrow filesystem writes in.

Inference at the boundary

Every constructor accepts the obvious JavaScript spelling of its argument and converts once, in Rust. Prefer the generic from entry points: they dispatch on what they were handed.

const { DataType, Field, MediaType, MimeType, Uri, Url } = require('yggdryl')
const assert = require('node:assert/strict')

// A datatype expression is a datatype.
assert.equal(String(new Field('id', 'int64', false).dtype), 'int64')
assert.equal(DataType.from('list<int32>').kind, 'nested')

// A media type is its canonical name.
assert.equal(String(MimeType.from('application/json')), 'application/json')
assert.equal(String(MediaType.from('application/json')), 'application/json')

// A path is a location.
assert.equal(String(Url.fromPath('C:/tmp/a.json')), 'file:///C:/tmp/a.json')
assert.equal(
  String(Uri.fromString('s3://warehouse/db').joinPath('trades', 'data.parquet')),
  's3://warehouse/db/trades/data.parquet',
)

There is no JavaScript-side parser: DataType.from and its siblings call the matching native constructor. JavaScript has no object / operator protocol, so generic URI path composition uses variadic Uri.joinPath; every component is normalized by the same core joinpath implementation as Rust's and Python's / idiom.

DataType.fromRegex(pattern, autotype) redirects named-capture inference to that core. TextOptions.withRownum is bigint | null: bigint preserves the whole signed 64-bit range, while a JavaScript number is rejected rather than silently narrowed.

Bytes and ranges

readRangeBytes and appendBytes are the core's read_range_bytes and append_bytes, camelCased at the boundary and nothing more; io states what they do. Over each sits one inferring entry point: readRange chooses the answer's type from its options, and append chooses how to read the byte source it was handed.

const assert = require('node:assert/strict')
const { IOBase } = require('yggdryl')

const handle = IOBase.fromBytes(Buffer.from('symbol,price\n'))

// `{ text: true }` selects the answer's type; omitting it answers a Buffer.
assert.deepEqual(handle.readRange(0, 6), Buffer.from('symbol'))
assert.equal(handle.readRange(0, 6, { text: true }), 'symbol')

// `append` takes a string, any view, and an ArrayBuffer as well as a Buffer.
assert.equal(handle.append('AAPL,1\n'), 13)
assert.equal(handle.append(new Uint8Array([77, 83, 70, 84, 10])), 20)
assert.equal(handle.append(new DataView(Uint8Array.from([78, 86, 68, 65, 10]).buffer)), 25)
assert.equal(handle.append(Uint8Array.from([73, 78, 84, 67, 10]).buffer), 30)
assert.equal(handle.readRange(13, 7, { text: true }), 'AAPL,1\n')

// A range it cannot decode is refused, not silently substituted.
assert.throws(() => IOBase.fromBytes(Buffer.from([0xff])).readRange(0, 1, { text: true }))

readRange rejects an unknown option and a non-boolean text, the way readScalar rejects its own, and checks length as strictly as offset rather than rounding it. append reads any typed array or DataView over its own window - a Buffer is one - an ArrayBuffer, and a string, encoding text as UTF-8 exactly as writeText does, and returns the byte offset it landed at.

One native field from a class or value

JavaScript follows the canonical field-conversion contract with intoField(value, name?) and the class-level intoStructField spelling. The canonical class form is an actual static getter; intoField validates its native non-null Struct result and memoizes that result per class. Other class member forms are rejected.

const assert = require('node:assert/strict')
const { fields, intoField } = require('yggdryl')

let builds = 0
class Quote {
  static get intoStructField() {
    builds += 1
    return fields.struct('Quote', [fields.int64('id')], { nullable: false })
  }
}

const root = intoField(Quote)
assert.strictEqual(intoField(new Quote()), root)
assert.equal(builds, 1)

const renamed = intoField(Quote, 'quote')
assert.equal(renamed.name, 'quote')
assert.equal(root.name, 'Quote')

The converter also accepts a native Field or field expression. Invalid roots are rejected before they cross into a table operation: intoStructField must resolve to a native Struct field whose root is non-null.

Scalars cross as their natural shape

A JavaScript value becomes the nearest native scalar, and comes back as the nearest JavaScript value to that. No class name travels beside the data, so a shape the core does not have arrives as the shape it was lowered to.

const { yaml } = require('yggdryl')
const assert = require('node:assert/strict')

const decoded = yaml.loads(yaml.dumps({
  venues: new Set(['XPAR', 'XNAS']),
  book: new Map([[1, 'bid']]),
  source: new URL('https://example.com/feed'),
  match: /a\/b/giu,
  raw: Buffer.from([0, 255]),
  id: 2n ** 100n,
}))

assert.deepEqual(decoded.venues, ['XPAR', 'XNAS'])        // a Set is a list
assert.ok(decoded.book instanceof Map)                    // a non-text key keeps the Map
assert.equal(decoded.source, 'https://example.com/feed')  // a URL is its href
assert.equal(decoded.match, '/a\\/b/giu')                 // a RegExp is its literal
assert.deepEqual(decoded.raw, Buffer.from([0, 255]))
assert.equal(decoded.id, 2n ** 100n)
You write It is stored as It reads back as
undefined, null null null
boolean, string boolean, string the same
number 64-bit integer or float number
bigint exact 64- or 128-bit integer number inside the safe range, bigint outside
Buffer, Uint8Array, Uint8ClampedArray, ArrayBuffer bytes Buffer
every other typed array sequence Array
Array, Set sequence Array
Map mapping Map when some key is not text, plain object when every key is
plain object, class instance sorted Record plain object
Date DateTime64(ms, UTC) Date
URL its href string string
RegExp its literal string, flags included string
DataType, Field core structural mapping plain object
Uri, Url, Urn canonical string string
Scalar itself Date when one holds it exactly, otherwise Scalar

These are the losses, and they are deliberate: a Set comes back a list, a URL and a RegExp come back strings, a class instance comes back a plain object, a Map of text keys comes back a plain object, and undefined comes back null. Reconstructing any of them takes one line of your own code and needs no cooperation from the codec:

const { yaml } = require('yggdryl')
const assert = require('node:assert/strict')

class Order {
  constructor(id) {
    this.id = id
  }
}

const decoded = yaml.loads(yaml.dumps({ order: new Order(7), venues: new Set(['XPAR']) }))
assert.deepEqual(decoded, { order: { id: 7 }, venues: ['XPAR'] })

const order = Object.assign(new Order(0), decoded.order)
assert.ok(order instanceof Order)
assert.deepEqual(new Set(decoded.venues), new Set(['XPAR']))

Nothing in a document can make this binding name a class, look one up, or run a constructor, because there is no name in the document to look up. A bigint wider than 128 bits has no exact native integer and is refused rather than rounded, and reading back a Date, a Buffer, or a Map never depends on a method the caller can replace: the encoder reads those off the intrinsic prototypes.

Scalar families

Scalar.float(value, width = 64) selects 16, 32, or 64 bits. decimal(coefficient, scale = 0) selects the narrowest exact decimal. Date and time widths follow the unit; duration follows the count; Arrow datetime is always 64-bit. Exact kind values survive field-directed transport.

Temporal factories use (count, unit, timezone). date defaults to days; an omitted timezone becomes NAIVE. Time and duration accept only NAIVE, while datetime also accepts a timezone name or Timezone. Schemaless text writes natural ISO strings; loads(..., { field }) restores the declared exact type.

const { Timezone, Scalar, json } = require('yggdryl')
const assert = require('node:assert/strict')

const price = Scalar.decimal(-(2n ** 200n), 7)
assert.equal(price.kind, 'd256')
assert.equal(price.scale, 7)
assert.ok(Scalar.decimal(150n, 2).equals(Scalar.decimal(15n, 1)))

const at = Scalar.datetime(1700000000123456n, 'us', 'UTC')
assert.equal(json.loads(json.dumps(at)), '2023-11-14T22:13:20.123456Z')
assert.equal(Scalar.datetime(0n, 'ms').zone, 'NAIVE')
assert.equal(Scalar.time(1n, 'us', Timezone.from('NAIVE')).zone, 'NAIVE')
assert.ok(
  Scalar.fromJs(new Date('2026-08-15T12:30:00.000Z'))
    .equals(Scalar.datetime(1786797000000n, 'ms', 'UTC')),
)

kind, count, unit, zone, unscaled, and scale expose the matching payload. asBytes/asUtf8 borrow scalar content; asJsonBytes and asJsonUtf8 use the core natural JSON writer.

Native value protocols and checked arithmetic

Immutable native values expose equals, compare, stableHash, and clone when the Rust core has a complete value identity. This includes Scalar, Expression, Statement, avro.Schema, iceberg.PartitionSpec, iceberg.DataFile, and iceberg.ScanPlan; other domain wrappers expose the same methods wherever their core identity is complete. compare uses the core's total order, and equal values always return the same deterministic stableHash() bigint. JavaScript has no object hash protocol, so stableHash() is explicit and === remains reference identity. Operational readers, iterators, and handles do not invent a value identity from hidden state.

Scalar equality, order, and hashing normalize equivalent decimal and temporal resolutions. Avro schema identity retains logical types, defaults, aliases, and extension attributes; fingerprint() alone follows Parsing Canonical Form. iceberg.IcebergOptions has the same four methods over its current explicit configuration, so a detached clone stops comparing equal after either copy is mutated.

JavaScript cannot overload arithmetic operators, so Scalar exposes the checked core operations as add, subtract, multiply, divide, remainder, negate, and absolute. Each binary method accepts either a native Scalar or a JavaScript value inferred once through Scalar.fromJs, and returns a native Scalar. Numeric operations preserve widths and promote only as the core defines; exact decimal division uses the smallest terminating scale. Supported datetime/duration pairs use the same checked path. Text and containers are never silently concatenated or coerced.

Expression exposes add, subtract, multiply, divide, remainder, and negate as lazy tree builders. An Expression operand stays native, a string is parsed as expression text, and any other JavaScript value is inferred once as a literal.

const assert = require('node:assert/strict')
const { Expression, Scalar } = require('yggdryl')

const half = Scalar.decimal(1n).divide(Scalar.decimal(2n))
assert.ok(half.equals(Scalar.decimal(5n, 1)))
assert.equal(half.clone().compare(half), 0)
assert.equal(typeof half.stableHash(), 'bigint')

const size = Expression.column('size').add(1)
assert.equal(size.toString(), 'size + 1')
assert.ok(size.clone().equals(size))

assert.throws(
  () => Scalar.fromJs(1).divide(0),
  (error) => error instanceof RangeError &&
    error.code === 'ERR_YGGDRYL_DIVISION_BY_ZERO',
)

Invalid operand kinds throw TypeError with ERR_YGGDRYL_INVALID_ARITHMETIC. Overflow, division by zero, and non-terminating exact decimal division throw RangeError with distinct ERR_YGGDRYL_ARITHMETIC_OVERFLOW, ERR_YGGDRYL_DIVISION_BY_ZERO, and ERR_YGGDRYL_INEXACT_ARITHMETIC codes.

fromJs and asJs

Scalar.fromJs and Scalar.prototype.asJs are the conversion pair. Every load and dump crosses them - dumps is fromJs with bytes on the far side, loads is asJs - so calling them directly is how you see what a value becomes before any format is involved.

const { Scalar, json } = require('yggdryl')
const assert = require('node:assert/strict')

assert.equal(Scalar.fromJs(new Set([1, 2])).kind, 'sequence')
assert.deepEqual(Scalar.fromJs(new Set([1, 2])).asJs(), [1, 2])
assert.equal(Scalar.fromJs(new Map([['id', 1]])).kind, 'mapping')

const value = { id: 1, tags: new Set(['a']) }
assert.deepEqual(json.loads(json.dumps(value)), Scalar.fromJs(value).asJs())

const tree = Scalar.fromJs({ legs: [{ id: 1 }] })
assert.equal(tree.get('legs').at(0).get('id').asJs(), 1)
assert.equal(tree.set('venue', 'XNAS').get('venue').asUtf8(), 'XNAS')

length, iteration, at, get, has, and path return exact native child values; set and remove return a rebuilt value without mutating the source. fromJs and asJs accept nullable { maxDepth } for language traversal in the inclusive range 1 to 48. Input-byte, node, and document limits apply to codec loads, where a text parser actually runs.

Field metadata is a Map

Field implements the Map protocol over its metadata, so ordinary idioms work and the ordering is the native one.

const { Field } = require('yggdryl')
const assert = require('node:assert/strict')

const field = new Field('trade', 'int64', false, { source: 'book' })
field.set('venue', 'XPAR')

assert.equal(field.get('source'), 'book')
assert.ok(field.has('venue'))
assert.equal(field.size, 2)
assert.deepEqual([...field.keys()].sort(), ['source', 'venue'])

field.delete('venue')
assert.ok(!field.has('venue'))

Typed identifiers and typed HTTP values (dictionaryId, contentType, etag, and the rest) are accessors rather than map keys, because they are validated. Rust splits that list by whose vocabulary it is - the http: headers live on field.as_http() there, while parquet_field_id and the straight alias/comment/display/location keys stay on Field - so a JavaScript name that reads contentType is as_http().content_type() on the other side.

One protocol's properties are a Map of their own, and it is a live view of the same field rather than a copy of part of it.

const { Field } = require('yggdryl')
const assert = require('node:assert/strict')

const field = new Field('price', 'int64', false)
field.iceberg.set('doc', 'closing price')
field.postgres.update({ type: 'numeric' })
field.digest.set('role', 'component')
field.identity.update({ role: 'primary', nulls: 'distinct' })
field.partition.update({ transform: 'bucket[16]', order: '0' })

assert.equal(field.iceberg.get('doc'), 'closing price')
assert.deepEqual([...field.postgres], [['type', 'numeric']])
assert.equal(field.iceberg.size, 1)
assert.equal(field.postgres.has('doc'), false)
assert.equal(field.digest.get('role'), 'component')
assert.equal(field.identity.get('role'), 'primary')
assert.equal(field.partition.get('transform'), 'bucket[16]')

// The bare name is all the view needs; the full key is what the field stores.
assert.equal(field.iceberg.key('doc'), 'iceberg:doc')
assert.equal(field.get('iceberg:doc'), 'closing price')
assert.equal(field.size, 7)

assert.equal(field.iceberg.delete('doc'), true)
assert.equal(field.iceberg.size, 0)

Every well-known protocol is a getter - digest, identity, partition, iceberg, postgres, http, arrow, spark, s3, and the rest - and field.protocol(name) takes one that is only known at runtime. identity and partition accept arbitrary inert string metadata; they are not restricted to one key flag. The digest role accepts only 'holder' or 'component'. There is no https getter, because HTTPS shares the canonical http: namespace. Rust spells the same accessors as_iceberg() / as_iceberg_mut(), and two of them differ by more than the prefix: arrow is as_arrow_properties and fieldProperties is as_field_properties, because as_arrow and as_field already mean something else on a Rust field.

Rust-only

Rust's per-protocol view types - HttpField, IcebergField, DigestField, IdentityField, PartitionField, and the sixteen others, each carrying its protocol's typed vocabulary, and each dereferencing to the whole Field it borrows - have no JavaScript counterpart yet. field.iceberg answers the generic property Map above, and the validated HTTP values remain accessors on the field itself.

A Struct field's direct children define a row digest. Explicit component roles select the exact set; without one, every child except a holder contributes. Both rules retain declaration order.

const { DataType, Field } = require('yggdryl')
const assert = require('node:assert/strict')

const identifier = new Field('id', 'int64', false)
const price = new Field('price', 'int64', false)
const holder = new Field('row_digest', 'uint64', false)
holder.digest.set('role', 'holder')

const fallback = new Field(
  'row', DataType.fromFields([identifier, price, holder]), false,
)
assert.deepEqual(fallback.digestFieldNames(), ['id', 'price'])
assert.equal(fallback.hasDigestComponents, false)

identifier.digest.set('role', 'component')
const explicit = new Field(
  'row', DataType.fromFields([identifier, price, holder]), false,
)
assert.deepEqual(explicit.digestFieldNames(), ['id'])
assert.equal(explicit.digestFieldLen, 1)
assert.equal(explicit.onlyDigestFields().dtype.length, 1)

Digest holders accept int32/uint32 for XXH32 and int64/uint64 for the 64-bit algorithms. Signed Arrow vectors expose high-bit results as negative number or bigint values while retaining the complete digest bits; field.castArrowArrayBits(...) performs the same explicit, reversible representation cast outside holder filling.

A schema also says which of its columns a path spells out, which is what a partitioned write and an Iceberg spec both read.

const { DataType, Field } = require('yggdryl')
const assert = require('node:assert/strict')

const schema = new Field(
  'row',
  DataType.fromFields([
    new Field('year', 'int32', false),
    new Field('price', 'int64', false),
  ]),
  false,
).withPartitionFields(['year'])

assert.deepEqual(schema.partitionFieldNames(), ['year'])
assert.equal(schema.dtype.getFieldByPath('year').isPartition, true)
assert.equal(schema.withoutPartitionFields().dtype.length, 1)

Arrow

Apache Arrow JS values cross the boundary as copied IPC. The package is explicit about that: this is not a zero-copy bridge.

const { DataType } = require('yggdryl')
const assert = require('node:assert/strict')

const scalar = DataType.from('int64').defaultArrowScalar()
assert.equal(String(scalar), '0')

defaultJSValue, defaultJSHint, and defaultArrowScalar are schema-directed projections of the native default planner; the JavaScript layer caches identity but never decides what a default is.

Records: explicit intent and representation

BatchReader is the primitive record shape: readArrowReader returns one and each overwriteArrowReader/appendArrowReader/mergeArrowReader call consumes one, exactly as IOMedia does in Rust. BatchReader.from accepts whatever a caller already holds - another reader, an Apache Arrow JS Table or RecordBatch, an array of batches, or Arrow IPC bytes - and iterating one yields Arrow JS record batches.

const assert = require('node:assert/strict')
const arrow = require('apache-arrow')
const { BatchReader, IOBase, MimeType } = require('yggdryl')

const table = new arrow.Table({
  id: arrow.vectorFromArray([1n, 2n], new arrow.Int64()),
})

// An in-memory handle says what it holds; a named one reads it off its name.
const handle = IOBase.fromBytes()
handle.mediaType = MimeType.ARROW_STREAM
handle.overwriteArrowReader(BatchReader.from(table))

const reader = handle.readArrowReader()
assert.equal(reader.field.name, 'row')
assert.equal([...reader].reduce((rows, batch) => rows + batch.numRows, 0), 2)
assert.equal(handle.isIo(), true)
assert.equal(handle.rowSize, 2)
assert.equal(handle.columnSize, 1)

// A stream is read once, and says so rather than reading as empty.
assert.ok(reader.consumed)

Each batch crosses as its own self-contained Arrow IPC stream, so its schema travels with it and Arrow JS needs no separate handshake. That per-batch header is what a copied boundary costs, and it is stated here rather than hidden. intoIpc drains the reader into one stream and intoTable into one Arrow JS table, for the cases where a caller does want everything at once.

isIo() asks the core whether the handle exposes either its byte or record surface; a container holding neither returns false. rowSize and columnSize describe the whole logical media, not the last projection, filter, or row limit used to read it. They are lazy metadata getters: formats with a cheap row count answer without decoding rows, successful values are cached while open() holds the media metadata, a write through the handle invalidates them, and close() makes the next access compute fresh. JavaScript stores no parallel schema or count. Counts beyond the exact integer range saturate at Number.MAX_SAFE_INTEGER.

The encoding is never named by a call: recordOptions() derives it from the handle's media type, and RecordOptions carries the shared settings plus the Parquet-only ones, which read as null on an encoding that has none.

RecordOptions exposes equals, compare, stableHash, and clone by delegating to the complete core value. Identity includes the encoding variant, every shared setting, and every Avro, Parquet, IPC, or Text-specific setting. For Text, declared regex source and extractor settings participate; compiled regex caches and derived fields do not. A clone is detached, so changing it does not mutate or re-hash the source options. JavaScript === remains object identity and stableHash() is the explicit deterministic bigint.

const assert = require('node:assert/strict')
const { Field, RecordOptions, fields } = require('yggdryl')

const parquet = RecordOptions.from('trades.parquet')
assert.equal(String(parquet.mimeType), 'application/vnd.apache.parquet')
assert.equal(parquet.compression, 'zstd(1)')
assert.equal(parquet.withCompression('snappy').compression, 'snappy')

const clone = parquet.clone()
assert.ok(clone.equals(parquet))
assert.equal(clone.compare(parquet), 0)
assert.equal(clone.stableHash(), parquet.stableHash())
clone.compression = 'snappy'
assert.ok(!clone.equals(parquet))

const root = fields.struct('row', [Field.from('id: int64')], { nullable: false })
const declared = parquet.withField(root)
assert.ok(declared.field.equals(root))

// A setting one encoding has is absent on the others rather than invented.
const stream = RecordOptions.from('trades.arrows')
assert.equal(stream.compression, null)
assert.equal(stream.maxRowGroupSize, null)
assert.equal(stream.commitRowSize, null)
assert.equal(stream.withCommitRowSize(10_000).commitRowSize, 10_000)

Pick the shape you have and the intent you mean

The explicit method name carries both facts:

Input Overwrite Append Key-matched merge
native BatchReader overwriteArrowReader appendArrowReader mergeArrowReader
Arrow JS Table overwriteArrowTable appendArrowTable mergeArrowTable
Arrow JS RecordBatch overwriteArrowBatch appendArrowBatch mergeArrowBatch
plain objects or field-class instances overwriteRecords appendRecords mergeRecords

When mode comes from configuration, each shape also has one dispatcher. The canonical order is input, required mode, then the one optional settings value:

writeArrowReader(reader, mode, options?)
writeArrowTable(table, mode, options?)
writeArrowBatch(batch, mode, options?)
writeRecords(records, mode, options?)

mode is 'overwrite', 'append', or 'merge'. It is validated before a one-shot reader, Arrow exporter, synchronous iterator, or asynchronous iterator is touched. The dispatcher does not guess either fact: the method still names the representation and the argument explicitly names intent.

The reader method accepts only a native BatchReader; use BatchReader.from(value) when you want to explicitly convert Arrow IPC bytes or another supported Arrow representation. Table and batch methods validate their named shape, infer a native Field, and redirect to the matching reader primitive. Arrow JS has no C Data consumer, so those two adapters copy one IPC stream at the boundary. That is the only materialization they add.

const assert = require('node:assert/strict')
const arrow = require('apache-arrow')
const { BatchReader, IOBase, MimeType } = require('yggdryl')

const first = new arrow.Table({
  id: arrow.vectorFromArray([1n, 2n], new arrow.Int64()),
  venue: arrow.vectorFromArray(['XNAS', 'XNYS'], new arrow.Utf8()),
})
const later = new arrow.Table({
  id: arrow.vectorFromArray([2n, 4n], new arrow.Int64()),
  venue: arrow.vectorFromArray(['XPAR', 'XTKS'], new arrow.Utf8()),
})
const extra = new arrow.Table({
  id: arrow.vectorFromArray([3n], new arrow.Int64()),
  venue: arrow.vectorFromArray(['XLON'], new arrow.Utf8()),
})

const handle = IOBase.fromBytes()
handle.mediaType = MimeType.ARROW_STREAM
handle.overwriteArrowTable(first)
handle.appendArrowReader(BatchReader.from(extra))

const merging = handle.recordOptions().withMergeByNames(['id'])
handle.mergeArrowTable(later, merging)
assert.equal(handle.readArrowReader().intoTable().numRows, 4)

// A configured mode reaches the same native dispatcher.
handle.writeArrowTable(extra, 'append')

Intent is authoritative. mergeByNames supplies keys only: overwrite and append reject it, while merge requires it to be non-empty. This validation happens before a one-shot reader is consumed, before a table or batch is IPC-encoded, and before a record iterable is pulled.

Records and field classes

Plain objects infer one struct field through Arrow JS. A class instance whose constructor exposes a static intoStructField getter uses that native field; the accessor result is validated and cached once per class. Static methods and stored static Fields are rejected. An explicit options.field always wins. Records are pulled in bounded chunks of options.batchRowSize rows, or 1,024 rows when it is unset. When commitRowSize is set, chunks also end exactly at the next publication boundary and at a global maxRowSize boundary. The first chunk fixes the Arrow JS physical schema and later chunks use those same column types.

const { Field, IOBase, MimeType, fields } = require('yggdryl')

class Trade {
  constructor(row) {
    Object.assign(this, row)
  }

  static get intoStructField() {
    return fields.struct(
      'trade',
      [Field.from('id: int64'), Field.from('venue: utf8')],
      { nullable: false },
    )
  }
}

const handle = IOBase.fromBytes()
handle.mediaType = MimeType.ARROW_STREAM
handle.overwriteRecords(new Trade({ id: 1n, venue: 'XNAS' }))
handle.appendRecords([new Trade({ id: 2n, venue: 'XNYS' })])
const typed = [...handle.readRecords(Trade)]

For a synchronous iterable, the native BatchReader pulls one copied IPC chunk only when the Rust write asks for it, so the whole source is never held. With no commitRowSize, one logical write publishes once at end of input. A synchronous Rust write cannot await an async JavaScript iterator, so an unbounded async write spools the same bounded copied IPC chunks to one private temporary file, replays that file through one native reader, and removes it on success or failure. This bounds RAM without claiming zero-copy.

With a positive commitRowSize, an async record write does not spool. It alternates one awaited IPC chunk with one synchronous push into an opaque Rust session. The session owns the global row/byte limits, fixed leaf/folder/table target, and incomplete cadence; overwrite publishes its first cadence as overwrite and later cadences as append, while append and merge retain their intent. A failure after a complete cadence leaves that prefix visible and drops only the incomplete one. A non-dividing pair such as batchRowSize = 1024 and commitRowSize = 1500 pulls 1024 then 476 records, so record 1501 is never converted before the 1500-row prefix is published.

maxRowSize = 0 or maxByteSize = 0 is a deterministic synchronous exception: append is a no-op, overwrite publishes an explicitly declared empty options.field, and merge with a limit is rejected. None of these paths inspects the ignored source. Otherwise async record calls return Promise<void>; synchronous record input and all Arrow methods return void.

An empty record iterable carries no inferable columns and therefore requires options.field.

Iceberg is a namespace

The table format sits on top of the record encodings in the core, so it is one name here rather than a handful of top-level classes.

const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const arrow = require('apache-arrow')
const { Field, fields, iceberg } = require('yggdryl')

const schema = fields.struct('row', [Field.from('id: int64'), Field.from('venue: utf8')], {
  nullable: false,
})

const root = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'yggdryl-docs-')), 'trades')
const table = iceberg.Table.create(root, schema, ['venue'])
table.append(
  new arrow.Table({
    id: arrow.vectorFromArray([1n, 2n], new arrow.Int64()),
    venue: arrow.vectorFromArray(['XNAS', 'XNYS'], new arrow.Utf8()),
  }),
)

assert.equal(table.currentSnapshot.operation, 'append')
assert.equal(table.dataFiles().length, 2)
assert.equal(table.scan().intoTable().numRows, 2)

fs.rmSync(path.dirname(root), { recursive: true, force: true })

iceberg.Table, iceberg.Catalog, iceberg.IcebergOptions, iceberg.PartitionSpec, iceberg.PartitionField, iceberg.Snapshot, iceberg.SnapshotRef, iceberg.ManifestFile, iceberg.DataFile, iceberg.ScanPlan, and iceberg.Compaction are the classes; iceberg.assignFieldIds, iceberg.canPromote, iceberg.schemaFromJson, and iceberg.schemaIntoJson are the functions. These immutable result values expose equals, compare, stableHash, and clone over their complete Rust-core identity. Snapshot v1 manifests, v3 lineage and encryption key, manifest encryption and partition summaries, and complete data-file metadata stay available rather than being dropped at the boundary. ScanPlan is a bounded immutable report whose complete identity, in comparison order, is (recordCount, filesPlanned, filesSkipped, manifestsRead, manifestsSkipped); physical scan tasks and paths are not part of that public report. A 64-bit identifier crosses as a bigint so a snapshot id past 2^53 is exact.

An Iceberg table end to end

A warehouse is one iceberg.Catalog over a folder, and a dotted name is all a writer needs: the first append creates the table from the rows' own schema. Every rows argument here is widened by BatchReader.from exactly as it is on IOBase, so an Apache Arrow JS table appends directly. A column change is a chain recorded on updateSchema() and committed once, compact() rewrites undersized files as one replace snapshot and reports what it rewrote, and scanAt reads any retained snapshot - named by a bigint or an exact number - as a complete table.

const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const arrow = require('apache-arrow')
const { iceberg } = require('yggdryl')

const warehouse = fs.mkdtempSync(path.join(os.tmpdir(), 'yggdryl-doc-'))
const catalog = new iceberg.Catalog(warehouse)

// Rows and a dotted name are enough: the first append creates the table.
const rows = (ids, venues) =>
  new arrow.Table({
    id: arrow.vectorFromArray(ids, new arrow.Int64()),
    venue: arrow.vectorFromArray(venues, new arrow.Utf8()),
  })
const table = catalog.append('nyc.trades', rows([1n, 2n], ['XNAS', 'XNYS']))
const past = table.currentSnapshot.snapshotId
table.append(rows([3n], ['XASE']))
assert.deepEqual(catalog.namespace('nyc').tables.names(), ['trades'])
assert.equal(table.scan().intoTable().numRows, 3)

// A column change is a chain recorded on the update, committed once.
table.updateSchema().addColumn('', 'price: float64').commit()
assert.equal(table.scan().intoTable().getChild('price').get(0), null)

// Undersized files rewrite as one replace commit that reports itself.
const compaction = table.compact()
assert.equal(compaction.filesBefore, 2)
assert.equal(compaction.filesAfter, 1)
assert.equal(table.scan().intoTable().numRows, 3)

// And nothing rewrote history: the first snapshot reads as it was written.
assert.deepEqual(
  table.scanAt(past).intoTable().getChild('id').toArray(),
  BigInt64Array.from([1n, 2n]),
)

fs.rmSync(warehouse, { recursive: true, force: true })

The walk is the same in every language: the iceberg page shows each of these steps beside its Rust and Python form.

FIX is a namespace

fix.FixRegistry, fix.FixMsg, fix.globalRegistry(), fix.installGlobalRegistry() and the two constants fix.STANDARD_BRANCH ('standard') and fix.STANDARD_TAG_LIMIT (5000) are the whole surface; the fix: vocabulary itself is six accessor pairs on the field.fix protocol view - branch, id, tag, tags, aliases, description - and reading or writing one on any other protocol's view is a TypeError naming that view's scheme. Resolution, folding, merging, sharding and validation are the core's, documented on the fix page. What follows is only what the crossing adds.

Keys. A tag is a number and a name or a dotted path is a string, coerced once per call. A tag is checked exactly: a fractional value or one outside i32 throws rather than being narrowed into a different tag, which is the silent-truncation bug this boundary exists to prevent. Anything that is neither - a bigint, an object, null - is a TypeError naming what was given. registry.get(key), registry.has(key), registry.getField(key), registry.field(key), registry.remove(key), message.get(key) and message.at(key) all take that same pair, and in all of them a number is a tag and a string a name or dotted path in the standard branch - a colon-bearing string is a name, never an identifier. The message's failing half is at rather than the core's value, because value is the property holding the whole message value.

Branches and identifiers. Both cross as string and are parsed once at the boundary through the core's own FixBranch::from_str and FixId::from_str, so no class exists for either in JavaScript. field.fix.branch reads 'standard' when the key is absent and assigning 'standard' removes the key; field.fix.id is the 'branch:tag' text, null exactly when fix:tag is absent, and assigning one moves both halves at once - the only ordering-safe way to move a field between dictionaries. registry.getFieldById / fieldById take that text, and getFieldByName / fieldByName and getFieldByPath / fieldByPath take the branch as their leading argument, because a name is unique per branch and not registry-wide. getFieldByTag / fieldByTag mean the standard branch exactly. A spelling that is not a branch or an identifier throws the native parse failure - never a miss - and a non-string is the Node-API conversion error. message.branch answers the dictionary the message is spelled in, derived from the root field, and message.getById / byId name one dictionary exactly and do not tier.

Locations. FixRegistry.fromHandle and registry.writeInto take what every folder-shaped entry point takes - an IOBase handle, a Url, or the string naming one - through the same coercion new iceberg.Catalog(warehouse) uses. Naming a folder that is not there is not an error: it loads as the empty registry and creates nothing, and a write creates the folder and its parents under primitive/<branch>/ and nested/<branch>/. A root still holding the retired records/ folder throws, never an empty load.

Errors. A native refusal crosses unchanged, carrying the message the Rust error produced: absence from fieldById, fieldByTag, fieldByName, fieldByPath, field and every FixMsg failing half, a conflicting insert, a datatype disagreement in a merge, a malformed fix: property or a malformed branch or identifier, a shard that does not parse, an unresolved process default. The get-prefixed twins answer null instead - but only for a key that parses; a malformed branch or identifier throws from both halves. Only the two boundary refusals - a key that is neither a tag nor a name, and the typed vocabulary asked of another protocol's view - arrive as a TypeError, because those are argument mistakes rather than answers.

Mutation. insert, update, remove and removeById need the registry unshared: once a FixMsg links it, it is installed as the process default, or an unfinished keys() walk holds it, a mutation throws rather than changing a dictionary underneath something that already resolved against it. A walk stops sharing when it is drained or when a for...of break returns it; registry.clone() is the independently mutable deep copy. remove reads a string as a standard-branch name, so removeById(id) is how a vendor field leaves the dictionary - the JavaScript spelling of the core's remove(&FixId).

Values. Both wrappers implement the JavaScript value protocols over their native state: equals, stableHash() as a bigint, clone(), toString() and toJSON(). Iterating a registry walks its fields in ascending canonical-identifier order - branch-major, then by tag - and iterating a message walks its [name, value] pairs in the root's declared order, both over the core's own lazy cursor, so nothing is collected crossing the boundary. FixMsg's constructor is the one widening gate: a plain object is read through Scalar.fromJs as the record its Struct root declares, and the core alone types, orders and validates it.

const assert = require('node:assert/strict')
const path = require('node:path')
const { Field, IOBase, Url, fields, fix } = require('yggdryl')

const seed = path.resolve('config/fix')

// One folder, named however JavaScript names one - the coercion `Catalog` uses.
const url = Url.fromPath(seed)
for (const location of [seed, url.toString(), url, new IOBase(seed)]) {
  assert.equal(fix.FixRegistry.fromHandle(location).size, 34)
}
const registry = fix.FixRegistry.fromHandle(seed)

// A key is a number tag or a string name, and a tag that would not fit i32 is
// refused rather than narrowed into a different one.
assert.ok(registry.get(55).equals(registry.get('symbol')))
assert.throws(() => registry.getFieldByTag(2 ** 31), /tag must be a signed 32-bit integer/)
assert.throws(() => registry.get(55n), {
  name: 'TypeError',
  message: 'key must be a number tag or a string name, got BigInt',
})

// A branch and an identifier cross as text: the branch leads a name or path
// lookup, and a malformed one throws rather than missing.
assert.equal(fix.STANDARD_BRANCH, 'standard')
assert.equal(fix.STANDARD_TAG_LIMIT, 5000)
assert.equal(registry.fieldByName(fix.STANDARD_BRANCH, 'ticker').name, 'Symbol')
assert.equal(registry.fieldByPath(fix.STANDARD_BRANCH, 'NoPartyIDs.PartyID').fix.tag, 448)
assert.equal(registry.fieldById('standard:55').fix.id, 'standard:55')
assert.throws(() => registry.fieldByName('2cme', 'Symbol'), /fix branch/)
assert.throws(() => registry.fieldById('55'), /fix identifier/)
assert.throws(() => registry.fieldById(55), /into rust type `String`/)

// Absence throws with the native message; the `get` half answers null.
assert.throws(
  () => registry.fieldByName(fix.STANDARD_BRANCH, 'Nope'),
  /expected a fix field at "name/,
)
assert.equal(registry.getFieldByName(fix.STANDARD_BRANCH, 'Nope'), null)
assert.throws(() => registry.insert(Field.from('Untagged: utf8')), /fix:tag/)

// The typed vocabulary is answered by the fix view alone, and a tag the FIX
// specification assigns cannot move to another dictionary.
const symbol = registry.fieldByTag(55)
assert.equal(symbol.fix.tag, 55)
assert.equal(symbol.fix.id, 'standard:55')
assert.throws(() => symbol.iceberg.tag, { name: 'TypeError', message: /iceberg/ })
const vendor = Field.from('TradeID: utf8')
vendor.fix.id = 'CME:5001'
assert.equal(vendor.fix.id, 'cme:5001')
assert.equal(vendor.fix.branch, 'cme')
assert.throws(() => {
  vendor.fix.tag = 35
}, /fix:branch/)
assert.equal(vendor.fix.id, 'cme:5001')

// A message shares the dictionary it resolved against, so mutating it refuses.
const root = fields.struct('row', [symbol], { nullable: false })
const message = new fix.FixMsg(root, { Symbol: 'AAPL' }, registry)
assert.throws(() => registry.remove(55), /shared with a message/)
assert.equal(registry.clone().remove(55).name, 'Symbol')

// A vendor field leaves by its identifier: `remove` reads a string as a
// standard-branch name.
const venue = fix.FixRegistry.fromFields([vendor])
assert.equal(venue.remove('TradeID'), null)
assert.equal(venue.removeById('cme:5001').name, 'TradeID')
assert.equal(venue.size, 0)

// Both collections are lazy native iterators the loader gives the protocol.
assert.equal([...registry].length, 34)
assert.deepEqual([...message].map(([name]) => name), ['Symbol'])
assert.equal(message.at('ticker').asJs(), 'AAPL')
assert.equal(message.branch, fix.STANDARD_BRANCH)
assert.equal(message.byId('standard:55').asJs(), 'AAPL')
assert.equal(message.getById('cme:5001'), null)

Errors

A native error crosses unchanged and arrives as a TypeError or RangeError carrying the message the Rust error produced, including its path or byte offset.

const { DataType } = require('yggdryl')
const assert = require('node:assert/strict')

assert.throws(() => DataType.from('decimal(0,0)'), /precision/)