reckon_db_links (reckon_db v5.11.14)
View SourceServer-side stream linking and projections for reckon-db.
A *link* is a derived stream computed from one or more source streams by applying an optional filter predicate and an optional transform function. The result is persisted under a stable name and exposed as just-another-subscribable-stream — consumers read it like any user stream.
This is the same primitive Greg Young's EventStoreDB exposes as *projections*: $ce-account (category events for account-* streams), $et-UserCreated (all events of one type), or arbitrary user-defined JavaScript projections that emit into custom destination streams. reckon-db's links cover the same conceptual ground with native Erlang funs instead of a scripting layer.
Why links exist
Reckon-db already has rich typed subscription filters (by_event_type, by_event_pattern, by_event_payload, by_tags) that filter the event stream on the fly. So why a separate "link" mechanism?
- **Transform.** Typed subscriptions can only filter. Links can rewrite events (add fields, re-key, redact, denormalise) before delivery. The transform is server-side, deterministic, and replayable.
- **Materialisation.** Typed subscriptions are recomputed for every consumer. A link computes once, persists the derived events on disk, and serves them like any other stream — N consumers cost N stream-reads, not N×(source-scans + filter + transform).
- **Stable named subscription targets.** Dynamically-created source streams (
order-018f6a...,order-018f6b..., ...) can't be subscribed to as a group via the bare-id selector; you'd need a wildcard pattern in the consumer. A link ($link:ordersfiltered to source patternorder-*) gives you one durable, named, listable target. - **Replayability.** A new consumer attaching to a link replays the persisted derived events from the start, in order, deterministically — same shape every time, regardless of how the source streams have grown. Typed subscriptions replay by re-scanning the entire global log and re-applying the predicate, which is correct but expensive at scale.
- **Composability.** Links can source from other links —
$link:high-value-ordersbuilt on top of$link:orders. Useful for incremental refinement of a view. - **Operational visibility.** Named derived streams show up in store listings (lazyreckon's
streamspane, for example) — operators can see "what derived views does this store maintain" at a glance. Typed subscriptions are ephemeral client state and invisible to tooling.
Stream-id namespace
Link streams live in the $ system namespace:
$link:<human-readable-name>
e.g. $link:high-value-orders. The $ prefix is the reckon-db convention for "system / projected stream, not user data" — same role as EventStoreDB's $ce- / $et- / $by_category / $stats- prefixes. User streams follow the <prefix>-<hex> format (e.g. account-018f6a7b8c9d4abc...). Both forms are accepted as subscription selectors via reckon_db_filters:by_stream/1.
Link subscriptions themselves get a related namespace:
$link-sub:<link-name>
so subscriptions managed by the link engine are distinguishable from user subscriptions in operational tooling.
Use cases (worked examples)
1. Materialised category view. Collate every event from all order-<hex> streams into one subscribable stream:
reckon_db_links:create(my_store, #{
name => <<"orders">>,
source => #{type => stream_pattern, pattern => <<"order-*">>}
}).
reckon_db_subscriptions:subscribe(
my_store, stream, <<"$link:orders">>, <<"orders-projector">>).2. Filtered derived stream. Just the high-value subset:
reckon_db_links:create(my_store, #{
name => <<"high-value-orders">>,
source => #{type => stream_pattern, pattern => <<"order-*">>},
filter => fun(E) ->
maps:get(total, E#event.data, 0) > 1000
end
}).3. Transform / re-shape. Strip PII from outbound webhook events:
reckon_db_links:create(my_store, #{
name => <<"webhook-feed">>,
source => #{type => stream_pattern, pattern => <<"order-*">>},
transform => fun(E) ->
D2 = maps:without([customer_email, customer_phone],
E#event.data),
E#event{data = D2}
end
}).4. Composition. Layer a filter on top of an existing link:
reckon_db_links:create(my_store, #{
name => <<"high-value-webhook">>,
source => #{type => stream, stream_id => <<"$link:webhook-feed">>},
filter => fun(E) ->
maps:get(total, E#event.data, 0) > 1000
end
}).When NOT to use a link
- Single-consumer one-off filter — a
by_event_typeorby_tagssubscription is cheaper and avoids persistent derived data. - Read model that lives in a database (postgres, sqlite, etc) — subscribe directly with the appropriate typed filter and project to the database. The link layer adds no value when the materialised target is elsewhere.
- Aggregation across many sources where the math is non-trivial — links emit one derived event per source event; they don't fold. For aggregation, use a process manager.
Running a link
start/2 starts the link's consumer (reckon_db_link_consumer), which follows the source live and, with backfill => true, writes what the source already held first. Each source event that passes the filter reaches the link stream once, also across a consumer crash or a store restart: the consumer stores, per source stream, the last version it handled. A link stored as running is resumed when the store's leader activates. stop/2 ends the consumer and removes its subscription; a later start/2 picks up from where it stopped, so the link also gets what the source received while it was stopped.
A link that cannot progress (a transform that crashes or returns something other than an event) stops with status error and the reason, and writes nothing for that event; the other links keep running. Fix the cause, then start/2 it again.
Status
Live delivery works for every source type since reckon-db#24; before that a link only ever held its backfill. Treat links as preview while we accumulate real usage.
Summary
Functions
Create a new link.
Delete a link.
Get a link by name.
Get detailed link info.
The stored link record.
The stream a link writes to.
List all links.
Store that the link stopped because it cannot progress: status error, and the reason.
Store the consumer's progress: the positions it has handled up to, the link-stream version its last append reached, and the events it has just written. One transaction, so a concurrent start/2 or stop/2 status write is never lost.
Store that the link's consumer has subscribed: status running, no reason, and the positions it starts from. The consumer writes this before it reads anything, so a stop it comes to is never overwritten by start/2.
Start the consumer of every link stored as running. Leader activation calls this, so running links come back after a restart. A link that cannot resume is logged and left as it is.
Start processing a link.
Stop processing a link: its consumer ends and its subscription is removed.
Types
-type event() :: #event{event_id :: binary(), event_type :: binary(), stream_id :: binary(), version :: non_neg_integer(), data :: map() | binary(), metadata :: map(), tags :: [binary()] | undefined, timestamp :: integer(), epoch_us :: integer(), data_content_type :: binary(), metadata_content_type :: binary(), prev_event_hash :: binary() | undefined, mac :: mac_value(), signature :: binary() | undefined}.
-type link_info() :: #{name := binary(), source := source_spec(), status := running | stopped | error, processed := non_neg_integer(), last_event => binary(), reason => term()}.
-type link_spec() :: #{name := binary(), source := source_spec(), filter => filter_fun(), transform => transform_fun(), backfill => boolean()}.
-type mac_value() :: {KeyId :: non_neg_integer(), MacBytes :: binary()} | {v2, KeyId :: non_neg_integer(), MacBytes :: binary()} | undefined.
Functions
Create a new link.
Options: - name: Link name (required, will create stream with $link prefix) - source: Source specification (stream, stream_pattern, or all) - filter: Predicate function to filter events - transform: Function to transform events - backfill: Process existing events (default: false)
A source the link cannot follow is refused with {error, {invalid_source, Source}} and nothing is stored: a stream source needs a non-empty binary stream_id other than the link's own stream, a stream_pattern source a non-empty binary pattern.
Delete a link.
Get a link by name.
Get detailed link info.
-spec link_record(atom(), binary()) -> {ok, #link{name :: binary(), source :: reckon_db_links:source_spec(), filter :: reckon_db_links:filter_fun() | undefined, transform :: fun((event()) -> term()) | undefined, backfill :: boolean(), created_at :: integer(), status :: running | stopped | error, processed :: non_neg_integer(), last_event :: binary() | undefined, positions :: #{binary() => integer()} | undefined, link_version :: integer(), reason :: term()}} | {error, term()}.
The stored link record.
The stream a link writes to.
List all links.
Store that the link stopped because it cannot progress: status error, and the reason.
-spec record_progress(atom(), binary(), #{binary() => integer()}, integer(), [event()], binary() | undefined) -> ok.
Store the consumer's progress: the positions it has handled up to, the link-stream version its last append reached, and the events it has just written. One transaction, so a concurrent start/2 or stop/2 status write is never lost.
Store that the link's consumer has subscribed: status running, no reason, and the positions it starts from. The consumer writes this before it reads anything, so a stop it comes to is never overwritten by start/2.
-spec resume(atom()) -> ok.
Start the consumer of every link stored as running. Leader activation calls this, so running links come back after a restart. A link that cannot resume is logged and left as it is.
Start processing a link.
Starts the link's consumer, which subscribes to the source, sets the status to running, writes the backfill if the link asks for it (or, on a later start, what the source received while the link was stopped), and then follows the source live. A link that cannot subscribe answers the subscribe error and keeps its status.
Stop processing a link: its consumer ends and its subscription is removed.