Liking cljdoc? Tell your friends :D

Extending

boring handles every Clojure type without registration. This document is for the cases it cannot know about: your own types, and record types you want reconstructed rather than left inert.

Registries are values

A registry is an immutable value on both platforms. Registration returns a new one; you thread it and pass it as :registry.

(require '[boring.core :as boring])

(def registry
  (-> (boring/tag-registry)
      (boring/register-tag 40001 java.net.URI str #(java.net.URI. %))))

(boring/encode (java.net.URI. "https://example.com") {:registry registry})
(boring/decode bytes {:registry registry})

There is deliberately no process-global default registry. If there were, two libraries in one JVM — say a database and a storage backend — could both register the same tag, and which one won would depend on namespace load order. Silently. Passing :registry explicitly makes that impossible.

Custom tags

(boring/register-tag reg tag cls write-fn read-fn)
  • write-fn(fn [value] -> encodable); the result is written as the tag's content, so return something boring already knows how to write.
  • read-fn(fn [decoded-content] -> value).

Either may be nil. Registering only read-fn lets you consume a tag you never produce; registering only write-fn produces one you do not consume.

Pick tag numbers from IANA's First-Come-First-Served range (≥ 32768) and register them if the data leaves your own systems. Numbers below that are allocated by specification, and taking one means your documents disagree with the standard.

Without a read-fn, an incoming tag decodes to an inert boring.data/TaggedValue carrying the tag number and content — inspectable, and re-encodable. Nothing is lost and nothing is guessed.

An unknown tag's value is preserved SEMANTICALLY, not byte-for-byte. A TaggedValue holds the tag number and the decoded content, not the original encoding, so a non-preferred input normalises on the way out: the overlong da0000ffff 01 re-encodes as d9ffff 01, and indefinite lengths become definite. The tag and the value survive exactly; the encoding of them becomes boring's preferred form. Tag numbers are supported across the full unsigned 64-bit range, so a foreign encoder's 2^64-1 round-trips rather than overflowing.

Handlers beat structure

A registration wins over boring's built-in encoding for that exact class, even when the type also happens to be a map, set or collection. That is worth knowing because the alternative is a trap: a handler that is silently ignored because your type implements java.util.Set looks exactly like a handler that works, right up until you read the bytes back.

Records

Records already round-trip as themselves with no registration — boring writes the type name via CBOR tag 27 — so the type is never flattened into a plain map. This is the problem incognito exists to solve for fressian, and boring does not have it.

Registration only affects reading. Without it, a record decodes to a boring.data/UnknownRecord carrying the same name and fields, which re-encodes to identical bytes:

(defrecord Point [x y])

(def registry
  (-> (boring/tag-registry)
      (boring/register-record "my.ns.Point" map->Point)))

The wire name is boring.data/record-type-name of an instance: on the JVM the class name, and ClojureScript munges its own name to match. So one registration serves data written on either platform.

A handful of tag-27 names are reserved by boring for types CBOR cannot otherwise distinguish — clojure/sorted-map, clojure/sorted-set, clojure/queue, clojure/with-meta, clojure/char, java/period. They carry a slash, which a JVM class name never does, so your record can never collide with one by accident. If you want one of those names for yourself, take it: the registry is consulted before the built-in markers, both when reading and when writing. See COMPATIBILITY.md.

That ordering was a bug first. The registry used to be consulted after the built-in handling on both sides, so registering a tag for a type boring already knew about was silently ignored — the registration compiled, returned a registry, and did nothing.

On the JVM there is a reflective convenience that derives both the name and the map-> constructor:

(boring/register-record-class reg Point)

It is JVM-only — advanced compilation minifies ClojureScript constructor names, so there is nothing to reflect on. Use register-record in .cljc.

Portable registration

register-tag and register-record have identical signatures on both platforms and both return the registry, so registration code lives in a .cljc file with a reader conditional only around the type being registered:

(def registry
  (-> (boring/tag-registry)
      (boring/register-tag 40001
                           #?(:clj java.net.URI :cljs js/URL)
                           #?(:clj str :cljs #(.-href %))
                           #?(:clj #(java.net.URI. %) :cljs #(js/URL. %)))
      (boring/register-record "my.ns.Point" map->Point)))

Thread the return value. On the JVM an earlier design mutated in place, which meant registration code that ignored the return value worked there and silently did nothing on ClojureScript — it compiled either way. Both sides are values now, so that trap is gone.

Reconstructing records without registering them

Two mechanisms, because the platforms differ in what is knowable when.

auto-registry — compile time, both platforms (preferred)

(require '[boring.records :as records])

(def registry (records/auto-registry "my.app"))   ; literal ns prefix
(boring/decode bs {:registry registry})

A macro. It asks the compiler which records exist and emits a literal map of wire name to constructor, so nothing is resolved from wire content at run time and the constructible set is fixed when you build.

This is the only mechanism that can work on ClojureScript — advanced compilation minifies constructor names and there is no runtime resolve — and it works there because the constructors are compile-time links rather than name lookups. Verified under -O advanced: the record type's own name minifies to two characters and reconstruction still succeeds.

It is also the safer choice on the JVM. Records defined after the macro expands are not included; that is the trade for resolving nothing at run time.

Load order

auto-registry sees namespaces that are loaded when it expands. For a namespace that requires what it needs, that is exactly right:

(ns my.app.serialization
  (:require [my.app.model]              ; the records
            [boring.records :as records]))

(def registry (records/auto-registry "my.app"))   ; sees my.app.model

A namespace that requires nothing sees nothing — verified: a caller that did not require the record namespace decoded to UnknownRecord, and one that did reconstructed the record.

Both platforms see more than your require graph, and that is the sharp edge. On the JVM, loading is global: a namespace pulled in by something unrelated is visible to a caller that never required it, so the same source can produce different registries in a REPL and in an AOT build. On ClojureScript the compiler's analysis cache holds every namespace in the build, so a no-prefix auto-registry picks up records from namespaces the calling namespace never mentions — measured, with a record from an unrequired namespace reconstructing through a registry built elsewhere.

The prefix is therefore not tidiness; it is what makes the result predictable. When the contents matter, name them instead:

(def registry (records/registry-for my.app.model my.app.events))

registry-for contains exactly those namespaces' records and nothing else. On the JVM it requires them first, so the answer does not depend on load order at all.

{:auto-construct-records? true} — run time, JVM only

(boring/decode bs {:auto-construct-records? true})

For record types not known at build time: a plugin, a REPL, a dynamically loaded namespace. Off by default, and the default is the security posture — read SECURITY.md first. Resolution uses RT/classForNameNonLoading, so naming a class does not run its static initialiser, and the class must also be an IRecord with a static create(IPersistentMap).

Refused with :boring/unsupported-option on ClojureScript rather than silently ignored, so a .cljc codebase cannot decode to real records on one platform and fallbacks on the other.

Content-addressing with hasch

hasch walks a value's structure, and an unregistered frame is not a shape it knows: UnknownRecord implements IPersistentMap, so hasch hashed it as a bare map and dropped the type name. user.Point, other.Type and a plain {:x 3 :y 4} all produced one address, and a peer holding the record class addressed the same value differently from one that did not.

boring.hasch fixes both, and you do not have to require it: boring.core loads it automatically when hasch is on the classpath. Check boring.core/hasch-integration? if you want to be sure.

It is still optional and in its own source root — boring's only runtime dependency is Clojure, and hasch is EPL-1.0 while boring is Apache-2.0 — so nothing happens if you do not depend on hasch. The auto-load exists because forgetting it is not a loud failure: hashes simply come out wrong, and only a disagreement between two peers reveals it.

With it loaded, a record, an incognito tagged literal, an UnknownRecord and a TaggedLiteral for the same type and fields all hash identically.

When a value has no encoding

By default one unencodable value aborts the whole document. On a wire that is usually the wrong trade — a message that arrives with a placeholder beats a message that does not arrive:

(boring/encode v {:encode-fallback :placeholder})   ; built-in placeholder
(boring/encode v {:encode-fallback (fn [x] :redacted)})

:placeholder writes 27(["boring/unencodable", {:type … :repr …}]), which is readable by any CBOR implementation and obviously not the original value. A function receives the offending value and returns a replacement.

A fallback returning something also unencodable throws rather than looping.

Reading a stream larger than memory

(with-open [in (io/input-stream "dump.cbor")]
  (doseq [item (boring/decode-seq-from in)] ...))

Bounded by the largest single item plus the chunk size (:chunk-size, 64 KiB default), not by the stream — verified by decoding a 27 MB file in a 20 MB heap. That limit is not a compromise: an item has to fit in memory to be a Clojure value at all, so streaming can only ever mean a sequence of items, which is exactly what a dump is.

The reader's hot path is untouched; refilling happens between items, so decoding from a byte[] costs nothing extra.

Security

Registered callbacks run with your process's privileges. boring does not sandbox them; vet what you install.

What a hostile document cannot do is cause an arbitrary class to be instantiated. Reading dispatches on a tag number or a record name looked up in your registry — there is no Class.forName path, no eval, and no java.io.Serializable involvement, so Java deserialization gadget chains do not apply. A document naming java.lang.Runtime yields an inert UnknownRecord. See SECURITY.md.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close