pg_automergev0.1.0

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 from since_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's save_after(since_heads). A replica at since_heads loads it with loadIncremental / applyChanges, and merge(replica, automerge_changes_bytes(full, automerge_heads(replica))) has the heads and jsonb of full (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 of heads (Automerge's *_at reads), 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, otherwise 22023 (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.