All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
[1.0.1] - 2026-07-03
Added
- Getting-started Livebook guide (
notebooks/getting_started.livemd), rendered in the docs and executed against Apache AGE in CI.
1.0.0 - 2026-07-03
The first stable release. A large, mostly additive expansion over 0.2.x:
multitenancy (:attribute and :context),
graph edges, bounded traversal, DB-enforced RLS, sensitive-data classification,
raw Cypher, bulk create, and composite primary keys. Existing 0.2.x builds are
unaffected until you upgrade.
Upgrading from 0.2.x
A few behavior changes to check before upgrading (the rest is new capability):
update/destroyon a missing or filter-excluded row now returnAsh.Error.Changes.StaleRecord(previouslyAsh.Error.Query.NotFound;destroypreviously returned:okon a no-match). Update any code that pattern-matches on the old error.update/destroynow honorchangeset.filter— mutations are scoped by the tenant/policy filter Ash attaches, not matched by primary key alone. This closes a cross-tenant write/delete gap; behavior changes only if you relied on the previous unscoped matching.AshAge.DataLayer.Info.attribute_types/1now returns{type, constraints}tuples (was bare types). Only affects code calling that introspection helper directly.- New compile-time checks reject a primary key listed in
age skipand a binary-typed multitenancy discriminator — both were already silently broken (perpetualStaleRecord/ inconsistent scoping).
Binary attribute storage ($age64$), range/sort rejection on binary storage,
and sensitive classification are new and do not affect existing non-binary
resources.
Added
- Sensitive classification (S7).
age do sensitive [:attrs] end+AshAge.DataLayer.Info.sensitive/1: fail-closed compile-time verifier (ValidateSensitive) — each sensitive attribute must be binary-storage-typed (app-side-encrypted bytes) or skipped; the multitenancy discriminator cannot be sensitive; sensitive-named edge properties require binary-storage-typed declared arguments (verified again at runtime on the edge write path). Spark surfaces verifier errors as compiler diagnostics; build with--warnings-as-errorsto make them blocking. ValidateSkipverifier: a primary-key attribute inage skipis now a verifier error (previously: every update/destroy silently returned StaleRecord).- usage-rules/README "Sensitive Data" guidance: searchable-vs-encrypted, erasure/crypto-shred, AshPaperTrail, plaintext-discriminator rationale.
- DB-enforced RLS (S6). Opt-in
:attribute-only PostgreSQL Row-Level Security, a defense-in-depth read-confidentiality backstop beneath Ash's app-layer tenant filter:age do graph :my_graph repo MyApp.Repo rls_guc "ash_age.tenant_id" endAshAge.DataLayer.Info.rls_guc/1reads the DSL option; a compile-time verifier (AshAge.DataLayer.Verifiers.ValidateMultitenancyAttr) requires:attributemultitenancy and rejectsglobal? true(a global read sets no GUC, so RLS would hide every row).AshAge.Migration.enable_tenant_rls/2(resource-derived) and/5(explicit graph/label/tenant-property/GUC) emitENABLE/FORCE ROW LEVEL SECURITY, a functional btree index on the tenant discriminator, and a fail-closed expression policy overproperties(current_setting(guc, true) <> '' AND <discriminator> = current_setting(guc, true)) — never aGENERATED ALWAYS ... STOREDcolumn.- All five CRUD callbacks (
read,create,update,destroy,bulk_create) and traversal (AshAge.ManualRelationships.Traverse) route through a newwith_rls/4wrapper: off (norls_guc) is a no-op; on, itset_configs the GUC insiderepo.transaction(pinning one connection) before running the operation, and fails closed with:rls_tenant_requiredon a blank/nil tenant before any query runs.unwrap_rls/2normalizes the result back to the data-layer contract. A newrls?key joins the value-free telemetry metadata allowlist. AshAge.with_tenant_rls/4— the auditable way to tenant-scope rawAshAge.cypher/5calls: runsfuninside a transaction with the GUCset_config'd on the same pinned connection.mix ash_age.verify --resource MyApp.Docdetects RLS drift (RLS not enabled, or a policy that doesn't reference both the tenant property and the GUC) and now exits non-zero on any failing check (previously always exited 0).- Three load-bearing AGE constraints this design is built around (each
verified live against
apache/age:release_PG16_1.6.0):- A
GENERATED ALWAYS ... STOREDcolumn on an AGE label table segfaults everycypher()write (signal 11, crash + recovery) — the RLS predicate must be a live expression overproperties, never a generated column. - AGE
cypher()CREATE bypassesWITH CHECK— a cross-tenant INSERT is not RLS-denied at the database. RLS is read/target-side only; the:attributeapp-layer force-set (Ash core) remains the actual write barrier. - RLS (including
FORCE ROW LEVEL SECURITY) is silently skipped for superusers and roles withBYPASSRLS— the application's DB role must be a non-superuser withoutBYPASSRLS, or the policy never applies with no error signal.
- A
- For
:attributeresources, RLS's row-scoping is redundant to (jointly enforced with) Ash's own tenant WHERE filter — RLS's distinct value is the DB-enforced read-confidentiality backstop beneath the app layer, not a replacement for it.
- Traversal (S5).
AshAge.ManualRelationships.Traverse— bounded variable-length graph traversal exposed as an Ash manual relationship:has_many :descendants, MyApp.Node do manual {AshAge.ManualRelationships.Traverse, edge_label: :LINK, direction: :outgoing, min_depth: 1, max_depth: 3} end- Returns a source-primary-key-keyed map of materialized destination records,
deduped per source (in Elixir, by destination PK — no SQL
DISTINCT, sorow_counttelemetry stays a genuine pre-dedup fan-out signal) and cardinality-aware (has_one→ one record,has_many→ list). - All three directions —
:outgoing,:incoming, and undirected:both. max_depthis required and bounded (integer>= 1); an unbounded*is forbidden.min_depthdefaults to1. Both single and composite primary keys on source and destination are supported.- Fail-closed tenancy.
:contextresolves the per-tenant graph (nil/blank tenant fails closed);:attributescopes every node on the path to$tenantvia a fixed-length UNION expansion — one basicMATCHbranch per length inmin_depth..max_depth, each node AND-scoped to the discriminator,UNION ALL-joined. (This AGE build's Cypher parser rejects theALL(n IN nodes(p) WHERE …)per-hop predicate, so the UNION expansion is the shipped mechanism — see probes below.) - Emits a value-free
:traversetelemetry span withdestination_count(post-dedup),row_count(pre-dedup fan-out),depth(max_depth),direction, andresult.
- Returns a source-primary-key-keyed map of materialized destination records,
deduped per source (in Elixir, by destination PK — no SQL
- Raw Cypher (S5).
AshAge.cypher(repo, graph, cypher, params \\ %{}, return_types)— a parameterized escape hatch for queries the Ash DSL cannot express:AshAge.cypher(MyApp.Repo, "my_graph", "MATCH (n:Person)-[:KNOWS*1..2]->(m) WHERE n.id = $id RETURN m", %{"id" => person_id}, [{:m, :agtype}]) #=> {:ok, [%{m: %AshAge.Type.Vertex{...}}, ...]}- Values reach AGE only as
$parameters; thegraphname isvalidate_identifier!-checked and a$$break-out in the body is rejected. - Returns
{:ok, [%{column_atom => decoded}]}(each cell a%AshAge.Type.Vertex{}/Edge{}/Path{}or a scalar) or{:error, %AshAge.Errors.QueryFailed{}}. - Decode boundary: a bare agtype aggregate (
collect(n),{k: v}) is returned as its raw agtype string — aggregate decoding is out of scope; useUNWIND. - Tenancy is explicit: the
graphyou pass IS the isolation boundary;cypher/5opens no transaction of its own (wrap in your own tenant-GUC transaction for RLS defense-in-depth). - Emits a value-free
:cyphertelemetry span withrow_countandresult;:depthwas added to the telemetry value-free metadata allowlist this slice.
- Values reach AGE only as
- Feasibility probes verifying AGE 1.6.0 behavior this slice depends on:
UNWIND+ variable-lengthMATCH(P-S5a = supported); a bound path variable withALL(n IN nodes(p) WHERE …)(P-S5b = rejected by this AGE build); the fixed-lengthUNION ALLexpansion as its equivalent (P-S5b-UNION = supported); andIN $paramlist binds (P-S5c = supported). Data-layer telemetry. Every operation emits a
:telemetry.span—[:ash_age, :read | :create | :bulk_create | :update | :destroy | :create_edge | :destroy_edge, :start | :stop | :exception]:- Metadata is value-free — schema identifiers, counts, booleans, and DSL enums only (
resource,multitenancy,tenant?,stale?,properties?,direction,row_count,batch_size,group_count,destination_count,result). Never a PK/property value, error reason, Cypher/filter string, or the tenant-derived graph name. AshAge.Telemetry(a new dependency-free module) owns the metadata allowlist and raises on any off-allowlist key — the single enforcement point for the value-free contract.:exceptionfires only on a programmer/config error (e.g. an undeclarededge:); DB errors are returned as redacted{:error, _}tuples and surface as:stopwithresult: :error.:telemetryis now a declared runtime dependency (already resolved transitively viaash/ecto).
- Metadata is value-free — schema identifiers, counts, booleans, and DSL enums only (
- Edge CRUD (S4). Two Ash
Resource.Changemodules for creating and destroying graph edges:AshAge.Changes.CreateEdge— creates edges viachange {AshAge.Changes.CreateEdge, edge: :name, to: :arg}, parameterized endpoint matching, optional edge properties (values from same-named action arguments, type-serialized as vertex attributes), atomic write inside the action's transaction (0-row match or DB error rolls the vertex back). Tenant-isolated::contextedges are same-graph fail-closed;:attributeedges scope both endpoints by the tenant discriminator.AshAge.Changes.DestroyEdge— destroys edges symmetrically, returningAsh.Error.Changes.StaleRecordon 0-row match (already gone or out of scope).- Edge
propertiesDSL option — a list of property keys, values sourced from same-named action arguments (declared argument type governs serialization: binary →$age64$-tagged base64, DateTime/Date → ISO8601). Unset properties are sparse (not written as null). :bothdirection — stored as:outgoing, readable via undirected Cypher match from either endpoint (contract for S5 traversal, pinned by integration test).- Constraint: edge destinations must have a single-attribute primary key.
- Edge-label auto-creation: AGE auto-creates edge labels on first
CREATE(verified live by probe P4); no provisioning required. Edge labels are provisioned like vertex labels: viacreate_edge_label/2in migrations or:elabelsinprovision_tenant/3.
- Bulk create (S4).
can?(:bulk_create)is nowtrue.Ash.bulk_createemitsUNWIND $rows AS row CREATE (n:Label) SET n.key = row.key … RETURN nper key-set group:- Key-set grouping — rows are grouped by which attributes are present, so a row with an optional attribute missing is NOT null-filled to match other rows. Each group's
UNWINDemits SET clauses for exactly that group's keys, preserving single-create's sparse stored shape. - Order-preserving — with
return_records?: truerecords come back mapped to input changesets viabulk_create_index; positional order matches input. - Binary/date round-trip — values serialize identically to single-create (
$age64$/ISO8601) and survive nested in the$rowsparameter. - Atomic-per-batch — one
UNWINDstatement, so any row's DB error fails the whole batch ({:error, …}, not:partial_success). Ontransaction: :batch(default, Ash wraps the batch), a later-group failure rolls back earlier groups; ontransaction: falsepartial writes are possible (same contract single-create and AshPostgres carry).:contextbatches with a nil tenant fail closed.
- Key-set grouping — rows are grouped by which attributes are present, so a row with an optional attribute missing is NOT null-filled to match other rows. Each group's
- Multitenancy (S3). Both Ash multitenancy strategies are now supported
(
can?(:multitenancy) → true)::attribute— works through Ash core (reads inject a tenant filter, writes force-change the tenant). ash_age adds a fail-closed compile verifier (AshAge.DataLayer.Verifiers.ValidateMultitenancyAttr): the multitenancy attribute must not appear inage do skip [...], or the tenant discriminator would never be written and the core-injected filter would silently match nothing.:context— graph-per-tenant physical isolation.set_tenant/3resolves a per-tenant AGE graph and threads it through reads; writes resolve it from the changeset tenant and fail closed on a missing tenant (there is no global graph). The graph name comes from a collision-free encoder (AshAge.Multitenancy.graph_name/2): identifier-clean tenants (ULID, integer, slug) pass through ast_<tenant>; others (e.g. a UUID) are base32-encoded asg<...>. The two prefixes are disjoint, so distinct tenants never collide. Overridable per resource via atenant_graphMFA in theageDSL block. Tenants longer than the 63-byte identifier limit fail closed with a value-free error (usetenant_graph).AshAge.tenant_graph/2— public helper so a host derives the same graph name query time uses.AshAge.Migration.provision_tenant/3— idempotent, runtime-safe (and migration-safe) helper the host app calls to create a tenant's graph + labels. Every graph/label is validated as an AGE identifier before use.- A missing tenant graph fails closed: a query against an unprovisioned
:contextgraph surfaces a redacted database error, never silent empty results.
- Live-AGE integration-test harness: a test
Ecto.Repo, a Postgrex agtype types module,AshAge.TestDomain, andAshAge.DataCase(Ecto SQL Sandbox + AGE session with awith_graph/3helper). Integration tests are tagged:integrationand run only whenAGE_DATABASE_URLis set; the pure-unit suite still runs with no database. - Feasibility probes verifying AGE 1.6.0 behavior later work depends on: bulk
UNWIND … SET n.k = row.k(supported), parameterizedMATCH (a),(b) … CREATE (a)-[:REL]->(b)(supported), and thatag_catalog.cypher()honorsFORCErow-level security under a non-superuser role with a GUC-keyed policy (supported — confirms DB-enforced tenant isolation on AGE is viable). - CI pins the Apache AGE service image by digest (
release_PG16_1.6.0, AGE 1.6.0 / PostgreSQL 16) and runs the integration lane against it. - Unit test coverage for the previously untested query-building path:
AshAge.Cypher.Parameterized,AshAge.Query,AshAge.Query.Filter,AshAge.Type.Agtype,AshAge.Type.Cast, andAshAge.DataLayer.set_clauses/1(47 new tests, no database required). AshAge.DataLayerdeclarescan?(_, :composite_primary_key) == true, so resources with a composite primary key now compile and CRUD correctly.
Fixed
- Sensitive data / binary-storage fixes (S7). Filter
eq/not_eq/in, primary-key match (update/destroy), traversal ids, and edge endpoint params now encode binary-storage values to the stored$age64$wire form — equality search on encrypted (binary) attributes and binary primary keys previously never matched (or raisedJason.EncodeError). - Non-JSON-encodable values (raw bytes nested in
:map/:list, or a struct with noJason.Encoderimpl nested in a param) now fail closed with a value-free error naming the attribute; previouslyJason.EncodeError/Protocol.UndefinedErrorleaked the raw bytes or inspected value into the exception message. StaleRecorderrors no longer carry primary-key/endpoint values in theirfilter(Ash inspects it into log messages); the filter keeps field names and replaces each value with"<redacted>".- An update whose primary-key
WHEREmatches more than one row (duplicate-keyed vertices are creatable outside Ash — AGE enforces no PK uniqueness) now fails closed with a value-freeUpdateFailedinstead of raisingFunctionClauseErroracross the data-layer callback boundary. - Attribute-to-attribute filter comparisons (
attr1 == attr2, and aRefnested in aninlist) now returnUnsupportedFilterinstead of binding theRefstruct as a parameter and surfacing downstream as a misleading "not JSON-encodable" error. Ash.Type.NewTypewrappers over date/datetime types now coerce stored ISO8601 values back to%Date{}/%DateTime{}/%NaiveDateTime{}on read (previously the raw string was returned, silently breaking traversal key-matching for wrapped date primary keys). Coercion dispatches on the resolved Ash STORAGE class (AshAge.Type.Cast.storage_class/2), the same resolution the binary predicate uses.- Attribute constraints now reach every wire path (encoder, decode gate, filter cast,
edge property guard) — a custom type whose
storage_type/1depends on instance constraints can no longer pass the sensitive-classification verifier yet store untagged. :attributetraversal scopes off the source strategy too (S5 closeout).AshAge.ManualRelationships.Traversenow applies per-node tenant scoping when either the source or the destination resource is:attribute-multitenant. Previously it keyed only on the destination, so a source-:attribute/ non-:attribute-destination traversal ran an unscoped query against the shared multi-tenant graph (fail-open); it is now fail-closed on that config too.- Date/DateTime source primary keys associate correctly in traversal (S5
closeout). The traversal F3 source key now coerces the decoded agtype scalar
to the source attribute type (via
AshAge.Type.Cast.coerce_value/2), so a:date/:utc_datetime/:naive_datetimeprimary key (stored as an ISO8601 string, held as a struct in the record) matches Ash's manual-result lookup. Previously the raw string key never matched and the source was silently dropped (returned[]/nil). UUID/integer/string PKs were unaffected. Infilter handles Ash'sMapSetright side (S5).AshAge.Query.Filternow normalizes theMapSetthatAsh.Query.Operator.Instores as its right side to a list before emittingn.attr IN $param, sofilter(x in ^list)— and nested loads that flow through traversal — work. Previously theMapSetshape fell through to{:error, UnsupportedFilter}.AshAge.Type.Pathdecodes AGE's inline-tagged path wire format (S5). A::pathbody is an array of individually::vertex/::edge-tagged agtype elements, not plain JSON; the decoder now splits at top-level commas (depth- and string-literal-aware) and recursively decodes each element. Previously it fed the body toJason.decode!and raisedJason.DecodeError(first exercised bycypher/5returning a path,RETURN p).AshAge.DataLayer.update/2no longer risks a parameter collision when a resource has an attribute literally namedmatch_id; the internal match parameter now uses a name guaranteed not to clash with a changed attribute.- Setup docs no longer show a Postgrex types snippet that fails to compile:
Postgrex.Types.define/3defines the module itself, so it is now called at the top level (adefmodulewrapper of the same name raises "cannot define module … currently being defined"). Themix ash_age.installoutput also referenced a non-existentAshAge.Type.Agtype.Extension; the correct module isAshAge.Postgrex.AgtypeExtension. Fixed in the install task, theAshAgemoduledoc, and the README. AshAge.DataLayer.update/2anddestroy/2derive their match predicate fromAsh.Resource.Info.primary_key/1, so resources with a composite primary key or a single non-:idprimary key match the correct rows. Both previously hardcodedWHERE n.id = $…, silently matching the wrong rows or nothing.:binaryattributes (including AshCloak-encrypted fields) no longer crashJason.encode!on create/update. Binary values are stored in a self-identifying wire format ("$age64$" <> base64(value)) and decoded back on read; plaintext strings are untouched. The$age64$tag makes decoding deterministic — a value ash_age did not encode (legacy or out-of-band data) is returned verbatim, never guess-decoded, even if it is syntactically valid base64.
Changed
- Binary-storage behavior changes (S7). Range filters (
>,<,>=,<=) on binary-storage attributes returnUnsupportedFilter(previously compared the tagged-base64 stored form — silently wrong results). Sort on binary storage is rejected at query build (can?({:sort, :binary})is false). - Stored binary values not written by ash_age (untagged/legacy/external) are readable
verbatim but no longer matchable through Ash filters or mutations (read-only grace):
match params now send the tagged form. Migrate such rows or store them as
:string. - A binary-storage-typed multitenancy discriminator is now rejected by a verifier (it would scope vertex filters, edge tenant params, traversal, and RLS paths inconsistently).
AshAge.DataLayer.Info.attribute_types/1now returns{type, constraints}tuples (previously bare types) so constraints reach the encode/decode paths;AshAge.Type.Cast.serialize_value/2andcoerce_value/2accept both bare types and{type, constraints}specs.update/2anddestroy/2now returnAsh.Error.Changes.StaleRecord(wasAsh.Error.Query.NotFound) when the primary-key + scoping-filterWHEREmatches no row — the Ash data-layer contract for a record-based mutation whose row is gone or excluded by a filter (NotFoundis for identifier lookups;StaleRecordis what the reference ETS data layer and Ash core return, and what Ash's bulk update/destroy paths pattern-match).destroy/2previously returned:okunconditionally on a no-match (DETACH DELETEgives no matched/unmatched signal); it now detects the 0-row case (viaRETURN n) and surfacesStaleRecord, so a scoping-denied or already-deleted destroy is observable and consistent withupdate/2.
Security
- Cross-tenant write/delete closed. ash_age now advertises
can?(:changeset_filter) → trueand honorschangeset.filterinupdate/destroy, translating it into the CypherWHERE(AND-ed with the primary-key match). Previously the data layer matched mutations by primary key only and silently dropped the tenant/policy scoping filter Ash attaches, so a fabricated or non-tenant-scoped changeset carrying another tenant's primary key could modify or delete that tenant's rows. Untranslatable filters fail closed (the mutation is rejected, never silently unscoped). This also makesAsh.Policyfilters apply to mutations. - Defense-in-depth against Cypher/SQL injection at every identifier interpolation
site. All dynamic values were already parameterized; this hardens the identifiers
that are interpolated into the query text:
AshAge.Query.to_cypher/1now validates the vertexlabeland everysortfield as an AGE identifier before interpolation, and requiresoffset/limitto be non-negative integers (raisingArgumentErrorotherwise).AshAge.DataLayercreate/update validate every property key (SET n.key = $key) and the label as AGE identifiers.AshAge.Cypher.Parameterized.build/*now reject any Cypher body containing a$$sequence — a final centralized guard against breaking out of AGE's dollar-quoted literal.AshAge.Cypher.Parameterizednow validatesreturn_typescolumn names and types as AGE identifiers before interpolating them into the outerAS (...)record clause (which sits outside AGE's$$dollar-quote). On the publicAshAge.cypher/5surface these are caller-controlled, so an unvalidated column name was a SQL-injection vector (S5 closeout).- The
$$-body rejection no longer echoes the Cypher body in itsArgumentErrormessage; that raise bypasses the redaction boundary, so a body carrying an interpolated value would otherwise leak it into logs (S5 closeout).
- Error messages no longer leak filtered values or database row contents.
AshAge.Errors.UnsupportedFilternow reports only the operator and referenced field name (never the filtered value).CreateFailed/QueryFailed/UpdateFailedsurface only the PostgreSQL SQLSTATE code (and constraint name), never the PostgresDETAILline that echoes offending values. A regression test pins the never-interpolate guarantee: values reach Cypher only as the$1parameter.
0.2.6 - 2026-02-14
Fixed
- Removed all phantom references to non-existent modules and features from documentation
usage-rules.md: Corrected Postgrex extension module name (AshAge.Type.Agtype.Extension→AshAge.Postgrex.AgtypeExtension)usage-rules.md: Removed phantomtraverse(),neighbors(),find_path()examples (not implemented)usage-rules.md: Replaced phantom depth limits section with actual supported capabilities listAGENTS.md: Removed phantomtelemetryandtraversalmodules from dependency levels and Key FilesAGENTS.md: Removed "Adding a New Traversal Pattern" section referencing non-existent filesAGENTS.md: Removed phantom "Breaking depth limits" from Common Pitfalls- Cleaned up all merged fix branches (local and remote)
0.2.5 - 2026-02-14
Fixed
- README installation section showed
~> 0.1.0instead of current version
0.2.4 - 2026-02-13
Fixed
- UUID primary key overwritten by AGE internal integer ID —
maybe_put_idusedMap.putwhich unconditionally replaced the UUID extracted from vertex properties; now usesMap.put_newto preserve UUID when present - Static Cypher queries passed
NULLas third argument toag_catalog.cypher()— AGE rejects this withinvalid_parameter_value; now omits the params argument entirely for parameterless queries
0.2.3 - 2026-02-13
Fixed
AgtypeExtension.encode/1missing<<byte_size(value)::int32()>>length prefix required by Postgrex wire protocolAgtypeExtension.decode/1missing<<len::int32(), value::binary-size(len)>>extraction — Postgrex sends length-prefixed data on the wire
0.2.2 - 2026-02-13
Fixed
AgtypeExtension.encode/1returned{:ok, value}tuple instead of raw binary — Postgrex expects iodata, causingArgumentErroron all parameterized queries- Added missing
rollback/2callback toAshAge.DataLayer— Ash calls this to roll back transactions on failure but it was undefined, causingUndefinedFunctionError
0.2.1 - 2026-02-13
Fixed
- Error struct field mismatches in
data_layer.ex—:message/:detailfields were silently dropped at runtime because the Splode error structs only define:reason(and:queryforQueryFailed) QueryFailedconstruction inrun_query/2anddestroy/2used non-existent:resourcefield instead of:query- Removed phantom
AshAge.Errors.TraversalDepthExceededreference fromusage-rules.md(module does not exist) - Updated
AGENTS.mdversion history to include v0.2.0 and v0.2.1 changes
0.2.0 - 2026-02-13
Added
AshAge.Type.Edgestruct for AGE edge data (id,label,start_id,end_id,properties)- Real agtype parser in
AshAge.Type.Agtype— decodes::vertex,::edge,::pathsuffixes and scalar values - Vertex-to-resource attribute mapping in
AshAge.Type.Castwith type coercion (ISO8601 → Date/DateTime) - DSL transformer validation:
ValidateGraph,EnsureLabelled,ValidateLabelFormat - Idempotent migration helpers —
create_age_graph/1,create_vertex_label/2,create_edge_label/2now check catalog before creating
Changed
AshAge.DataLayer.Inforeads all config dynamically from the resource's Spark DSL instead of hard-coded valuesAshAge.Cypher.Parameterizedwraps Cypher inag_catalog.cypher()with JSON-encoded$1parameterAshAge.Querygenerates properMATCH/WHERE/RETURN/ORDER BY/SKIP/LIMITCypher and accumulates params in a mapAshAge.Query.add_param/2correctly accumulates parameters with$paramNreferences
Fixed
- Postgrex extension module reference:
AshAge.Type.Agtype.Extension→AshAge.Postgrex.AgtypeExtensionin docs and README - Removed non-existent
AshAge.Cypher.Traversalfrom doc groups inmix.exs
0.1.2 - 2026-02-13
Added
- Add
AshAgeroot module with complete setup documentation - Add
mix ash_age.installtask for printing setup instructions - Add
mix ash_age.gen.migrationtask for generating AGE migrations - Add
mix ash_age.verifytask for runtime AGE configuration verification - Implement
AshAge.Sessionmodule withsetup/1forafter_connecthook - Implement
AshAge.Migrationmodule with graph, label, and index helpers - Add unit tests for Session, Migration, and Mix task modules
0.1.1 - 2026-02-13
Fixed
- Add missing
:filtersand:sortfields toAshAge.Querystruct - Fix pattern match arity in
AshAge.Query.to_cypher/1 - Add module aliases to satisfy Credo strict checks
- Replace
condwithif/elseinAshAge.Type.Agtype - Fix
mix docsCI step to use correct MIX_ENV
0.1.0 - 2025-01-01
Added
- Initial release of AshAge DataLayer for Apache AGE
- Cypher query generation from Ash queries
- Vertex and Edge resource support
- Custom Ash types:
Agtype,Vertex,Edge,Path - Graph creation and management via
AshAge.Migration - Session-based AGE graph binding via
AshAge.Session - Parameterized Cypher queries for safe value interpolation
- Query filtering with Ash filter translation