Manages tag tracking for move-out support in Electric shapes.
This module handles tracking which keys have which tags, enabling the generation of synthetic delete messages when rows move out of a shape's subquery filter.
Data Structures
Three structures are maintained:
tag_to_keys:%{{position, hash} => MapSet<key>}- which keys have each position-hash pairkey_data:%{key => %{tags: MapSet<{pos, hash}>, active_conditions: [boolean()] | nil, msg: msg}}- each key's current statedisjunct_positions:[[integer()]] | nil- shared across all keys, derived once from the first tagged message
Tags arrive as slash-delimited strings per disjunct (e.g., "hash1/hash2/", "//hash3").
They are normalized into 2D arrays and indexed by {position, hash_value} tuples.
For shapes with active_conditions, visibility is evaluated using DNF (Disjunctive Normal Form):
a row is visible if at least one disjunct is satisfied (OR of ANDs over positions).
Summary
Functions
Generate synthetic delete messages for keys matching move-out patterns.
Activate positions for keys matching move-in patterns.
Normalize slash-delimited wire format tags to 2D arrays.
Evaluate DNF visibility from active_conditions and disjunct structure.
Update the tag index when a change message is received.
Types
@type disjunct_positions() :: [[non_neg_integer()]] | nil
@type key() :: String.t()
@type key_data() :: %{ optional(key()) => %{ tags: MapSet.t(position_hash()), active_conditions: [boolean()] | nil, msg: Electric.Client.Message.ChangeMessage.t() } }
@type position_hash() :: {non_neg_integer(), String.t()}
@type tag_to_keys() :: %{optional(position_hash()) => MapSet.t(key())}
Functions
@spec generate_synthetic_deletes( tag_to_keys(), key_data(), disjunct_positions(), [map()], DateTime.t() ) :: {[Electric.Client.Message.ChangeMessage.t()], tag_to_keys(), key_data()}
Generate synthetic delete messages for keys matching move-out patterns.
Patterns contain %{pos: position, value: hash} maps. For keys with
active_conditions, positions are deactivated and visibility is re-evaluated
using DNF with the shared disjunct_positions. For keys without
active_conditions, the old behavior applies: delete when no entries remain.
Returns {synthetic_deletes, updated_tag_to_keys, updated_key_data}.
@spec handle_move_in(tag_to_keys(), key_data(), [map()]) :: {tag_to_keys(), key_data()}
Activate positions for keys matching move-in patterns.
Sets active_conditions[pos] to true for keys that have
matching {pos, value} entries in the tag index.
Returns {updated_tag_to_keys, updated_key_data}.
Normalize slash-delimited wire format tags to 2D arrays.
Each tag string represents a disjunct with "/" separating position hashes. Empty strings are replaced with nil (position not relevant to this disjunct).
Examples
iex> Electric.Client.TagTracker.normalize_tags(["hash_a/hash_b"])
[["hash_a", "hash_b"]]
iex> Electric.Client.TagTracker.normalize_tags(["hash_a/", "/hash_b"])
[["hash_a", nil], [nil, "hash_b"]]
iex> Electric.Client.TagTracker.normalize_tags(["tag_a"])
[["tag_a"]]
@spec row_visible?([boolean()], [[non_neg_integer()]]) :: boolean()
Evaluate DNF visibility from active_conditions and disjunct structure.
A row is visible if at least one disjunct is satisfied.
A disjunct is satisfied when all its positions have active_conditions[pos] == true.
@spec update_tag_index( tag_to_keys(), key_data(), disjunct_positions(), Electric.Client.Message.ChangeMessage.t() ) :: {tag_to_keys(), key_data(), disjunct_positions()}
Update the tag index when a change message is received.
Tags are normalized from slash-delimited wire format to position-indexed entries.
disjunct_positions is derived once from the first tagged message and reused for all
subsequent messages, since it is determined by the shape's WHERE clause structure.
Returns {updated_tag_to_keys, updated_key_data, disjunct_positions}.