Reads Delta, DuckLake, and Iceberg tables as they were at an earlier
snapshot, through DuckDB's AT (VERSION => ...) and AT (TIMESTAMP => ...)
clauses.
A snapshot is a DateTime, which reads the latest snapshot committed at or
before it, or version: n, a Delta table version or a DuckLake snapshot id:
import QuackDB.Ecto.Snapshot
Order
|> join(:inner, [o], c in assoc(o, :customer))
|> put_snapshot(~U[2026-10-20 14:03:11Z])
|> Repo.all()put_snapshot/2 applies to every table the query reads: its joins,
including assoc joins, subqueries, and CTE bodies. CTE names, values/2,
fragments, and QuackDB.Source helpers are left as they are. Plain DuckDB
tables do not keep snapshots, so a query that also reads one fails with
DuckDB's Catalog type does not support time travel; give the snapshot to
the time-travelling binding alone with put_snapshot/3.
Repo option
To give a whole repo call a snapshot, preloads included, apply the
:snapshot option in your repo's Ecto.Repo.prepare_query/3:
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.QuackDB
@impl true
defdelegate prepare_query(operation, query, opts), to: QuackDB.Ecto.Snapshot
end
order = Repo.get!(Order, id, snapshot: event.at)
Repo.preload(order, :items, snapshot: event.at)Ecto passes a call's options on to its preload queries, so one snapshot
covers the records a call loads. Snapshots are read-only: the option
raises for update_all and delete_all.
Summary
Functions
Applies a repo call's :snapshot option to its query. Delegate
Ecto.Repo.prepare_query/3 to it, or call it from your own.
Reads every table in the query at snapshot. A nil snapshot leaves the
query unchanged.
Reads only the source bound to the named binding at snapshot, so the
query can join tables that do not keep snapshots, or read two snapshots of
one table. The snapshot overrides one given to the whole query.
Types
@type t() :: DateTime.t() | [{:version, non_neg_integer()}] | [{:timestamp, DateTime.t()}]
A snapshot to read: a commit time or a version.
Functions
@spec prepare_query(atom(), Ecto.Query.t(), keyword()) :: {Ecto.Query.t(), keyword()}
Applies a repo call's :snapshot option to its query. Delegate
Ecto.Repo.prepare_query/3 to it, or call it from your own.
@spec put_snapshot(Ecto.Queryable.t(), t() | nil) :: Ecto.Query.t()
Reads every table in the query at snapshot. A nil snapshot leaves the
query unchanged.
@spec put_snapshot(Ecto.Queryable.t(), atom(), t() | nil) :: Ecto.Query.t()
Reads only the source bound to the named binding at snapshot, so the
query can join tables that do not keep snapshots, or read two snapshots of
one table. The snapshot overrides one given to the whole query.
from(order in Order, as: :order, join: region in "regions", on: ...)
|> put_snapshot(:order, version: 42)Assoc joins lose their hints in Ecto's planner, so the binding must come
from from or a join on an explicit source.