History (read-only)
Automerge keeps a document as an op set plus a change graph: hash, actor, seq, op range, time, message and deps of every change. Change bytes are not stored; Automerge rebuilds them from the op set on request. So the metadata functions cost one load, while the functions returning change bytes cost a load plus re-encoding those changes (about as much again for all changes of a document). Nothing here creates changes.
Two composite types describe a change:
CREATE TYPE automerge_change AS (
hash text, -- 64 lowercase hex digits
actor text, -- lowercase hex
seq bigint, -- 1, 2, ... per actor
start_op bigint, -- counter of the change's first op
op_count bigint, -- number of ops (0 for an empty change)
"time" timestamptz, -- NULL when not set (0)
message text, -- NULL when not set
deps text[], -- sorted hashes of its dependencies
change bytea -- the change chunk (uncompressed, as save_after emits it)
);
-- automerge_change_meta: the same without `change`.automerge_changes(doc automerge, since_heads text[] DEFAULT '{}') → SETOF automerge_change: every change not reachable fromsince_heads(all changes for'{}'), in causal order: each change comes after its dependencies, and otherwise in Automerge's own order (which already is causal, so it is kept as is). Rebuilds the bytes of the returned changes.automerge_changes_meta(doc, since_heads text[] DEFAULT '{}') → SETOF automerge_change_meta: the same rows from the change graph only (get_changes_meta); no change is rebuilt. Use it whenever the bytes are not needed.automerge_changes_bytes(doc, since_heads text[] DEFAULT '{}') → bytea: those changes' chunks concatenated in the same causal order, i.e. Automerge'ssave_after(since_heads). A replica atsince_headsloads it withloadIncremental/applyChanges, andmerge(replica, automerge_changes_bytes(full, automerge_heads(replica)))has the heads and jsonb offull(tested). Empty when there is nothing new.automerge_get_change(doc, hash text) → automerge_change: one change with its bytes, or NULL if the document does not have it.automerge_change_count(doc) → bigint: number of changes. Read from a prefix of the stored value (see Heads fast path), without loading it.automerge_to_jsonb(doc automerge, heads text[]) → jsonb: the state as ofheads(Automerge's*_atreads), with the same mapping as the cast.'{}'is the state before any change ({}); the current heads give the current state. Every hash must be a change of the document, otherwise22023(automerge document does not contain change <hash>).
"Since heads" semantics are Automerge's get_changes(have_deps): the
changes that are neither in since_heads nor ancestors of them.
Hashes the document does not have are ignored, as Automerge does: a
replica that is ahead of the stored row (it has changes the row lacks)
gets everything that is not an ancestor of the heads the row knows, a
superset of what it is missing and never less, which it deduplicates on
load. (An error would make that sync case fail; a typo in a hash also
just returns more changes.) Automerge computes the set from the per-actor
sequence numbers of the known heads' ancestors; for histories written by
Automerge (each change depends on its actor's previous change) that is
exactly the set of non-ancestors, which a property test checks against a
walk of the dependency graph. automerge_to_jsonb(doc, heads) instead
rejects unknown hashes, since it cannot show a state it does not have.
When every current head of doc is in since_heads, the since-functions
return nothing without loading (heads read from the header).
Hash arguments must be exactly 64 hex digits, either case (22P02
invalid automerge change hash "..." otherwise); a NULL array element is
22004 (since_heads must not contain NULL).
Time: Automerge change times are Unix seconds (as documented for the
Rust crate's CommitOptions::with_time; check what your producer writes).
time is NULL when the change has none (0) and when Postgres cannot
represent it; a producer writing milliseconds shows up as a date tens of
thousands of years ahead.
Set-returning functions compute all rows on the first call: the document
is loaded, the rows are copied into a Rust Vec and the document is
dropped before the first row is returned. pgrx keeps that Vec in the
SRF's multi-call memory context, which Postgres deletes when the scan ends
or is cut short (LIMIT, a closed cursor, an error), dropping it; tested
with LIMIT on a target-list SRF, closed cursors and errors mid-scan.
Memory: for automerge_changes the Vec holds the change bytes (about the
size of the document's history) until the scan ends; in FROM, Postgres
additionally materializes the rows in a tuplestore (spilling to disk past
work_mem). Rows are built with the function's declared result type
(looked up by the function's OID), so they do not depend on search_path.
The descriptor each row is built with is checked first: exactly the
attributes the extension created, none dropped, of exactly their types
(text, text, bigint ×3, timestamptz, text, text[], and bytea). The types'
owner can ALTER TYPE automerge_change ALTER ATTRIBUTE seq TYPE text, and
heap_form_tuple would then read the bigint datum as a text pointer and
crash the backend; an altered type is XX000 (type automerge_change has been altered: its attributes must be those created by extension pg_automerge) instead.