QuackDB.Ecto.Snapshot (quackdb v0.7.0)

Copy Markdown View Source

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

Types

t()

A snapshot to read: a commit time or a version.

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

t()

@type t() ::
  DateTime.t() | [{:version, non_neg_integer()}] | [{:timestamp, DateTime.t()}]

A snapshot to read: a commit time or a version.

Functions

prepare_query(operation, query, opts)

@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.

put_snapshot(queryable, snapshot)

@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.

put_snapshot(queryable, binding, snapshot)

@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.