Add temporal resource support - #233
Draft
C-Sinclair wants to merge 3 commits into
Draft
C-Sinclair wants to merge 3 commits into
C-Sinclair wants to merge 3 commits into
Conversation
added 3 commits
September 20, 2026 18:59
Temporal lives on the `temporal` branches of ash and ash_sql and is in no
released version, so ASH_VERSION and ASH_SQL_VERSION gain a "temporal" case
alongside the existing "main" and "local" ones.
ASH_VERSION=temporal ASH_SQL_VERSION=temporal mix test
Without it the feature compiles out entirely and the suite runs as before.
SQLite has no range type, so a range attribute is stored as a single JSON text column and every range predicate compiles to comparisons on json_extract of its keys. AshSqlite.Type.RangeBound encodes both sides of every comparison at one fixed ISO8601 precision. The comparison a range predicate performs is the inner type's stored comparison rather than the adapter's, and the two do not agree: DateTime.to_iso8601/1 keeps whatever precision the value carries, so one instant has two spellings that compare unequal and in the wrong direction. An absent bound is JSON null, so an unbounded side is tested with IS NULL rather than against a sentinel of the inner type. AshSqlite.SqlImplementation renders range_overlaps, range_contains over a range and over a point, range_adjacent, range_lower and range_upper. attribute_ecto_type/2 covers the write path, which the expression seam does not reach. The migration generator maps Ash.Type.Range to :text. Each test asserts agreement with the runtime Ash.Range function it mirrors.
A temporal resource stores one row per validity period. Postgres splits a period with one statement, `UPDATE ... FOR PORTION OF valid_at FROM $as_of TO NULL`, over a `PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)` exclusion constraint. SQLite has none of that, and no data-modifying CTE to build it out of, so AshSqlite.Temporal reaches the same outcome in three statements: close the version whose period contains as_of, copy that row forward under [as_of, prior_upper), then apply the caller's update to the copy. Step three is the ordinary non-temporal update path, which is why atomics, RETURNING and the changeset's filters need no temporal branch inside them. Closing before inserting means the two periods never overlap at any point between statements, so the non-overlap trigger sees no transient violation. The reverse order trips both the trigger and the partial unique index. The transaction is opened here rather than by Ash. Ash wraps an atomic update only when the resource has after-batch hooks or the data layer asks for prefer_transaction_for_atomic_updates?, and this one answers false because a non-temporal atomic update is a single statement. Without it a failure in the third statement leaves the history rewritten with no current version, and two concurrent writers interleave and lose a write. AshSqlite.Verifiers.VerifyTemporal refuses a temporal resource whose repo leaves write transactions off. The row identity on a temporal table is the primary key plus the period, so pkey_filter/2 carries the period and so does the join bulk_updatable_query/6 builds when a query needs one. The key alone names every version, and joining on it rewrites closed history. set_as_of/3 renders the containment test against the same json_extract expression the index is built on, so a keyed point-in-time read is an index seek rather than a scan. The upsert resolves its own match. SQLite will not use a partial unique index as an ON CONFLICT target and the only unique index on a temporal table is the partial one; Postgres cannot use ON CONFLICT against its exclusion constraint either. AshSqlite.Temporal.Migration generates what SQLite will not give from one declaration: an index on each key set plus the period's lower bound, a partial unique index refusing a second current version per key set, and a non-overlap trigger pair. Every identity gets the same treatment, because an identity on a temporal resource is unique per period rather than per table. The insert trigger deliberately omits the NEW.rowid exclusion the update trigger carries: an inserted row has no rowid yet, so the comparison is NULL and the trigger would silently never fire. The generator also stops emitting three things for a temporal resource. A PRIMARY KEY, because it makes a second version impossible and on an integer key makes the column SQLite's rowid alias. Identities as plain unique indexes, for the same reason. And a foreign key to a temporal destination: there is no unique key to point at, and SQLite creates the table anyway and then fails every insert into the child with "foreign key mismatch".
C-Sinclair
force-pushed
the
feat/temporal-support
branch
from
September 21, 2026 15:30
bfe88db to
55262c9
Compare
Contributor
|
This will be a bit of a monster to review, so give me some time 😄 |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Contributor checklist
Leave anything that you believe does not apply unchecked.
What
Temporal resource support for
ash_sqlite, matching the feature on ash'stemporalbranch.Postgres splits a validity period with one statement:
UPDATE ... FOR PORTION OF valid_at FROM $as_of TO NULL, over aPRIMARY KEY (id, valid_at WITHOUT OVERLAPS). The key shipped in PostgreSQL 18;FOR PORTION OFis PG19.SQLite has none of it, and no data-modifying CTE to build it from. So the period arithmetic happens in the data layer, the shape
Ash.DataLayer.Etsalready proves sufficient, with real SQLite constraints underneath.The whole feature is compile-gated on
Code.ensure_loaded?(Ash.Temporal), so it is inert on any released ash.How a write works
AshSqlite.Temporalsplits a period in three statements inside a transaction. A subscription created in January on the free tier is one row:Moving it to pro in March gives the same two rows Postgres reaches in one statement:
Statement 3 is the ordinary non-temporal update path, which is why atomics,
RETURNINGand the changeset's filters need no temporal branch inside them.Closing before inserting means the two periods never overlap between statements, so the non-overlap trigger sees no transient violation. The reverse order trips both the trigger and the partial unique index.
The transaction is opened in the data layer rather than by Ash.
Ash.Actions.Update.Bulkwraps an atomic update only when the resource has after-batch hooks or the data layer asks forprefer_transaction_for_atomic_updates?, and this one answersfalsebecause a non-temporal atomic update is a single statement. A split is three, soAshSqlite.Temporal.transactionally/2opens one withmode: :immediate, andAshSqlite.Verifiers.VerifyTemporalrefuses a temporal resource whose repo leaveswrite_transactions?off.Row identity on a temporal table is the primary key plus the period, so
pkey_filter/2carries the period, and so does the joinbulk_updatable_query/6builds when a query needs one.What the database enforces
Postgres gets uniqueness and the access path from one declaration. SQLite needs three pieces, which
AshSqlite.Temporal.Migrationgenerates for the primary key and for every identity:(key..., json_extract(period, '$.lower')). SQLite can index an expression, so a keyed point-in-time read is a seek.UNIQUE (key...) WHERE json_extract(period, '$.upper') IS NULLrefuses a second current version for a key, which is what a split that forgets to close the prior version would produce.BEFORE INSERT/BEFORE UPDATEtrigger pair rejecting overlapping closed periods withRAISE(ABORT).The insert trigger omits the
NEW.rowidexclusion the update trigger carries. An inserted row has no rowid yet, so that comparison is NULL and the trigger would never fire.For a temporal resource the generator also stops emitting a
PRIMARY KEY, plain unique indexes for identities, and any foreign key to a temporal destination. A temporal table has no unique key to reference; SQLite creates the child table anyway, then fails every insert into it withforeign key mismatch.Reads
set_as_of/3renders the containment test against the samejson_extractexpression the index is built on:Both bounds stay in the JSON rather than becoming generated columns. Generated columns reach an identical plan and cost every temporal resource two extra columns.
Compatibility
temporalbranch, Elixir 1.20 / OTP 29On released ash
can?(_, :temporal)isfalse,set_as_of/3is not defined, the test resources and their suite do not compile, and the compiler removes the rest. Generated migrations for a non-temporal resource are byte-identical tomain, snapshot hashes included.CI runs against released ash, so it proves nothing regressed but never executes a line of the temporal path or any of its 58 tests, which is the position
ash_postgresis in too.The
"temporal"arms added toash_version/1andash_sql_version/1run the suite against the unreleased branches. Happy to drop them forASH_VERSION=mainonce temporal merges.Performance
200k rows, 20k keys × 10 versions, both sides at the driver rather than through Ash. Postgres is 19beta3 in a container, running
PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)andUPDATE ... FOR PORTION OF.Read the first two rows with the transport in mind. Postgres is over TCP into a container and SQLite in process. The same PostgreSQL 18 answers the keyed read in 0.039 ms over a local Unix socket and 0.365 ms in a container, so roughly 0.3 ms of that gap is transport. Subtract it and SQLite is about four times faster on a keyed read rather than thirty.
The unkeyed scan is not transport-bound, and it is where Postgres wins outright:
Where it stops
None of these work, and each needs a change somewhere.
ash_sqlrenderingparent/1outside a lateral join.Ash.Resource.Verifiers.ValidateTemporalKeysrequirestemporal_keys, which bakesrange_overlaps(parent(valid_at), valid_at)into the relationship filter;can?(_, {:lateral_join, _})isfalsehere, soAshSql.Expr.default_dynamic_expr/6raises on the missing:parent_bindings. A relationship to a temporal resource works, viatemporal_keys {nil, :valid_at}.PERIODforeign keysupdate_timestampresolving toas_ofAshPostgres.DataLayer.update_defaults/2does. Today it reads the wall clock.[)VerifyTemporal.source:, attribute multitenancyTwo things found in ash while building this
Ash.Temporal.raw_instant/2accepts only%DateTime{}or:now, so a:dateresource cannot be given an explicitDateasas_of, even thoughAsh.Temporal.now_for/1returns aDatefor that same resource.parent/1in a relationship filter needs a lateral join. When a related query runs standalone the parent's value is already known, so substituting it as a literal, or rewriting the overlap as a correlatedEXISTS, would serve every data layer without lateral joins.