go get github.com/sectionco/activeso/v2
Quick start
package main
import (
"context"
"os"
"github.com/sectionco/activeso/v2"
)
type User struct {
activeso.Record `activeso:"timestamps"`
ID string `db:"id"`
Email string `db:"email" activeso:"not_null,unique"`
Embedding activeso.Vector32 `db:"embedding"`
}
func example(ctx context.Context) error {
sync, err := activeso.OpenSync(ctx, activeso.SyncConfig{
Path: "app.db",
RemoteURL: "turso://my-db-my-org.aws-us-east-1.turso.io",
AuthToken: os.Getenv("TURSO_AUTH_TOKEN"),
})
if err != nil {
return err
}
defer sync.Close()
// Models work on the same handle as a plain database.
userModel := activeso.Model[User](sync.DB())
// You own the schema, on the cloud database too. Verify catches drift.
if err := userModel.Verify(ctx); err != nil {
return err
}
// Writes are local until you push.
user, err := userModel.Create(ctx, User{Email: "hello@null.live"})
if err != nil {
return err
}
// Sync pulls remote changes, rebases local ones on top, then pushes.
if err := sync.Sync(ctx); err != nil {
return err
}
// Records bound by Create keep working until the handle is closed.
user.Email = "updated@null.live"
return user.Save(ctx)
}
Local first, synced with Turso Cloud
Every ActiveSo database is a local Turso file that syncs with Turso Cloud. activeso.OpenSync(ctx, config) is the only way to open one: it opens the file at Path, downloads the remote state when the file is empty, and returns a *Sync. Sync is database-wide, so Pull, Push, and Stats live on that handle, not on models.
| Method | Behavior |
|---|---|
| DB() | The *sql.DB to pass to activeso.Model. |
| Pull(ctx) | Fetches remote changes and rebases local changes on top of them. Sends nothing. Reports whether remote changes were applied. |
| Push(ctx) | Sends local changes to the remote. Fetches nothing. |
| Sync(ctx) | Pull, then Push β the common βcatch up and publishβ case. |
| Checkpoint(ctx) | Compacts the local write-ahead log. Call it periodically on a long-lived replica with sustained writes. |
| Stats(ctx) | Local operations written since the last Pull (CdcOperations), last pull and push times, and network traffic. |
| Close() | Stops sync calls, closes every pooled connection, and releases the database, WAL, and changes files. Unpushed writes stay in the local file. Returns ErrSyncBusy while connections from DB() are still checked out. |
SyncConfig takes Path and RemoteURL (both required) and AuthToken, plus optional Namespace, ClientName, BusyTimeoutMs, and SkipBootstrap to leave an empty local file empty until you call Pull yourself. Writes through models stay local until you call Push or Sync; ActiveSo never pushes for you. Always release the database with sync.Close() β closing the *sql.DB directly leaves the database files open.
Bring your own schema
ActiveSo reads and writes tables that already exist. It does not create or migrate your schema β create and change tables with whatever migration tooling you prefer. Model uses plural snake_case table names by default, with English inflection for common and irregular forms: User maps to users, City to cities, and Person to people. Implement TableName() string when your database uses a specific name.
type User struct {
activeso.Record `activeso:"timestamps"`
ID string `db:"id"`
Email string `db:"email" activeso:"not_null,unique"`
Embedding activeso.Vector32 `db:"embedding"`
}
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
embedding BLOB,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX users_email
ON users (email);
Every persisted field needs a db tag naming its column. ActiveSo does not guess column names, so activeso.Model[T](db) panics at setup when an exported field has none; use db:"-" to exclude one. Columns in the table but not the struct are ignored, so you can add columns ahead of a deploy or drop a field's column later. The embedded Record holds ActiveSo's runtime persistence binding and is not a column.
Plain strings, numbers, and booleans read SQL NULL as their Go zero value, and a Vector32 reads NULL as a nil vector. Use sql.NullString, sql.NullInt64, sql.NullFloat64, or sql.NullBool when your application must distinguish NULL from an empty or zero value β ActiveSo retains Valid when loading and saving.
Timestamps, when you opt in
Record promotes CreatedAt and UpdatedAt, but ActiveSo only manages them when you add the timestamps hint to the embedded Record. With the hint, the table must have created_at and updated_at columns holding UTC timestamp text. They need no defaults or triggers.
Create(ctx, value)
Stamps both columns with CURRENT_TIMESTAMP in ActiveSo's own SQL.
user, err := userModel.Create(ctx, User{Email: "hello@null.live"})
// user.CreatedAt and user.UpdatedAt are UTC time.Time values
fmt.Println(user.CreatedAt, user.UpdatedAt)
Save(ctx)
Stamps updated_at and never rewrites created_at.
user.Email = "updated@null.live"
if err := user.Save(ctx); err != nil {
return err
}
fmt.Println(user.UpdatedAt)
If either column is missing, the first operation fails with an activeso.ErrSchemaMismatch error naming both; a stored value that is not valid UTC timestamp text (YYYY-MM-DD HH:MM:SS or RFC3339) fails when read. Without the hint, ActiveSo never touches those columns and the Go fields stay zero. Timestamps are metadata, not an authorization boundary: raw SQL outside ActiveSo does not refresh updated_at, and Bind does not load timestamps.
Hints describe your table
Hints live in the activeso struct tag and describe what your table already enforces. Hints never change the database β Verify checks them. An unknown or malformed hint makes activeso.Model[T](db) panic, so mistakes surface during setup.
| Hint | Meaning | Runtime behavior |
|---|---|---|
| primary_key | The field is the table's primary key. | Selects the identity used by Find, Save, Delete, and Bind. |
| unique | A unique index covers exactly this column. | When Turso rejects a write, Create and Save return an activeso.UniqueError matching activeso.ErrUnique. |
unique_with=column[+column...] |
A composite unique index begins with this column and continues with the named columns, in order. Do not repeat the tagged column. | Turso rejects duplicate combinations with an error matching activeso.ErrUnique. |
| index | An index begins with this column. | Makes FindBy lookups eligible to use the index. |
| not_null | The column is declared NOT NULL. |
Turso rejects NULL values. |
| belongs_to=table(column) | The column has a foreign key to table(column). |
Turso rejects non-NULL values missing from the referenced column. |
| on_delete=cascade | The belongs_to foreign key uses ON DELETE CASCADE. Requires belongs_to. |
Deleting a referenced row also deletes its matching child rows. |
If no field has primary_key, ActiveSo uses the field mapped to id. Exactly one primary key is required, using strings, booleans, signed integers, uint8βuint32, or floats; uint and uint64 are rejected because Turso stores integers as signed 64-bit values. A primary key satisfies not_null and index. On the embedded Record, the only hint is timestamps.
type City struct {
activeso.Record
ID string `db:"id"`
Name string `db:"name" activeso:"not_null,index"`
RegionID string `db:"region_id" activeso:"not_null,belongs_to=regions(id),on_delete=cascade"`
}
Foreign keys are enforced on every ActiveSo write outside a transaction. Create, Save, and Delete turn on PRAGMA foreign_keys for the exact pooled connection that runs the write, so a model cannot persist orphaned rows or delete a referenced parent. Your own direct SQL on sync.DB() is not covered. Writes on a *sql.Tx run as-is, because SQLite ignores the pragma once a transaction has begun, so a transaction from sync.DB().BeginTx does not enforce foreign keys. Take one connection, enable the pragma on it, and run statements or begin transactions there.
conn, err := sync.DB().Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
return err
}
tx, err := conn.BeginTx(ctx, nil)
Verify catches drift
Because ActiveSo trusts your tables, it can check that they match your model. There are two levels, and neither ever changes the database.
Automatic structural check
The first Create, Find, query, Save, or Delete checks that the table, every mapped column, and any timestamps columns exist, and that the primary key is a PRIMARY KEY or has a single-column unique index. A failure is retried on the next call; a pass is remembered for the model's lifetime, so create a model once and reuse it.
Verify(ctx)
Adds column types against Go types and every not_null, unique, unique_with, index, belongs_to, and on_delete=cascade hint. Call it at startup, after a Pull, or in a test.
SchemaError
Lists every problem found and matches activeso.ErrSchemaMismatch with errors.Is.
if err := userModel.Verify(ctx); err != nil {
var schemaError *activeso.SchemaError
if errors.As(err, &schemaError) {
for _, problem := range schemaError.Problems {
log.Println(problem)
// hint unique on email, but no unique index covers exactly that column
}
}
return err
}
A field type ActiveSo cannot map to a column is reported unless it implements driver.Valuer and sql.Scanner. A clean Verify also lets later operations skip their first-use structural check, which matters most inside transactions.
Transactions you control
ActiveSo accepts any activeso.Executor: *sql.DB, *sql.Tx, or *sql.Conn. You begin, commit, and roll back; ActiveSo only runs statements on the executor you give it. Every write and the single-record read has a transaction twin.
| Normal | In a transaction |
|---|---|
| Create(ctx, value) | CreateTx(ctx, tx, value) |
| Find(ctx, id) | FindTx(ctx, tx, id) |
| record.Save(ctx) | record.SaveTx(ctx, tx) |
| record.Delete(ctx) | record.DeleteTx(ctx, tx) |
CreateTx + SaveTx
Runs one call on tx. Returned records stay bound to the original model.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
user, err := userModel.CreateTx(ctx, tx, User{Email: "hello@null.live"})
if err != nil {
return err
}
user.Email = "updated@null.live"
if err := user.SaveTx(ctx, tx); err != nil {
return err
}
return tx.Commit()
Using(executor)
A transaction-scoped view for queries or several operations. Records it creates or loads stay on the transaction for Save and Delete.
txUsers := userModel.Using(tx)
matches, err := txUsers.Where("email LIKE ?", "%@null.live").All(ctx)
for _, match := range matches {
match.Email = "migrated@null.live"
if err := match.Save(ctx); err != nil { // runs on tx
return err
}
}
Foreign keys inside a transaction depend on its connection. SQLite ignores PRAGMA foreign_keys after a transaction begins, so a *sql.Tx from db.BeginTx can commit orphaned rows. For models with belongs_to, take a connection, enable the pragma, and begin the transaction on it β see Hints.
A *sql.Tx or *sql.Conn you pass must belong to the model's database; ActiveSo cannot check this. Records from a Using view fail with sql.ErrTxDone once the transaction ends, and Using(tx).Bind(record) moves an existing record into one. Turso begins transactions with snapshot isolation and ignores sql.TxOptions. With db.SetMaxOpenConns(1), an open transaction holds the only connection, so root-model calls wait until it ends β use Using(tx) or the Tx methods inside.
The record lifecycle
Records returned by Create, Find, query methods, or Bind carry Save(ctx) and Delete(ctx) with them β persistence behavior travels on the value itself.
Create(ctx, value)
Inserts value, generates an ID when its string ID field is empty, and returns the bound record.
user, err := userModel.Create(ctx, User{Email: "hello@null.live"})
// user is bound: Save and Delete are available on it
Save(ctx)
Updates all persisted fields except the primary key.
user.Email = "updated@null.live"
err := user.Save(ctx)
Delete(ctx)
Deletes a bound record by primary key.
err := user.Delete(ctx)
Bind(value)
Attaches persistence behavior to an existing record pointer, such as one loaded outside ActiveSo. It does not load timestamps.
externalUser := &User{ID: "8b291a21-e69b-47ed-a3e0-f43e7609b26d", Email: "hello@null.live"}
user, err := userModel.Bind(externalUser)
A record loaded outside ActiveSo must be attached first with Bind(value); an unbound record's Save and Delete return activeso.ErrUnboundRecord. A bound record's ID is captured when it becomes bound and cannot be changed β modifying it makes Save and Delete return activeso.ErrIDChanged; create a new record when you need a new identity.
Querying, including vectors
FindBy provides a mapped-column equality lookup; parameterized Where chains with OrderBy, Limit, First, and All; and Nearest orders by cosine distance over a Turso vector32 column.
Find(ctx, id)
Loads the record with id; returns activeso.ErrNotFound when no record exists.
user, err := userModel.Find(ctx, "8b291a21-e69b-47ed-a3e0-f43e7609b26d")
if errors.Is(err, activeso.ErrNotFound) {
// no row with that ID
}
FindBy(ctx, column, value)
Loads every record whose mapped column equals value. The column must exist on the model, so ActiveSo never accepts arbitrary SQL here.
cities, err := cityModel.FindBy(ctx, "name", "Denver")
All(ctx)
Loads every record from the model's table.
users, err := userModel.All(ctx)
for _, user := range users {
fmt.Println(user.Email)
}
Where(predicate, arguments...)
Starts a parameterized query β one argument for each ? placeholder.
user, err := userModel.Where("email = ?", "hello@null.live").First(ctx)
Where + OrderBy + Limit + All(ctx)
Query parts compose. OrderBy takes a raw SQL expression; Limit takes a positive maximum row count; First returns activeso.ErrNotFound when nothing matches.
excludedID := "8b291a21-e69b-47ed-a3e0-f43e7609b26d"
users, err := userModel.Where("email LIKE ? AND id <> ?", "%@null.live", excludedID).
OrderBy("email ASC").
Limit(10).
All(ctx)
Nearest(column, embedding)
Orders records by cosine distance from a Vector32 in a Turso vector32 column β lower distance is more similar. Vectors can also ride along in Where when the predicate calls vector32(?).
queryEmbedding := activeso.Vector32{0.12, -0.08, 0.63}
users, err := userModel.Nearest("embedding", queryEmbedding).Limit(10).All(ctx)
users, err = userModel.Where(
"vector_distance_cos(embedding, vector32(?)) < ?",
queryEmbedding,
0.2,
).All(ctx)
OrderBy(expression) called after Nearest replaces the vector-distance ordering and discards that ordering's embedding argument.
Sentinel errors
Match with errors.Is. Unique conflicts come back as a UniqueError that carries the offending field β activeso: email must be unique β and schema mismatches as a *SchemaError listing every problem.
ErrNotFoundA query did not return a matching record.ErrUnboundRecordA record was not created, loaded, or bound through a Model.ErrIDChangedA bound record's primary key was modified.ErrUniqueThe database rejected a write for violating a unique index.ErrSchemaMismatchA table does not match the model'sdbtags and hints.ErrSyncClosedASyncmethod was called afterClose.ErrSyncBusyClosewas called while connections fromDB()were still checked out.
Upgrading from 1.x
ActiveSo 2.0 removes all schema management. Plan these changes together, and back up production data before running any migration.
| Change | What to do |
|---|---|
| Import path | The module is now github.com/sectionco/activeso/v2. |
| Opening a database | Open with activeso.OpenSync(ctx, config) and pass sync.DB() to activeso.Model. |
| Migrations removed | AutoMigrate, DropUnique, DropUniqueWith, DropColumn, ChangeColumnType, and SetNotNull no longer exist. Existing tables keep working; changes to them are yours to write. |
Every field needs db | Fields that relied on derived snake_case names now panic at Model setup until they are tagged. |
| Constraints are hints | The tag syntax is unchanged, but hints no longer create anything. Verify checks them. |
| Unique errors from the database | Create and Save no longer run a check-then-write query. Keep the unique index in your schema; Verify reports a missing one. |
| Timestamps opt-in and renamed | Add activeso:"timestamps" to the embedded Record. Columns are now created_at and updated_at, stamped in ActiveSo's SQL instead of triggers. |
To move a 1.x table to the new timestamp columns, drop ActiveSo's triggers first β renaming a column rewrites trigger bodies, and the old triggers would then refer to columns that no longer exist β then rename the columns. List triggers with SELECT name FROM sqlite_schema WHERE type = 'trigger'.
DROP TRIGGER IF EXISTS activeso_7573657273_timestamps_insert;
DROP TRIGGER IF EXISTS activeso_7573657273_timestamps_update;
DROP TRIGGER IF EXISTS activeso_7573657273_timestamps_protect_created_at;
DROP TRIGGER IF EXISTS activeso_7573657273_timestamps_protect_updated_at;
ALTER TABLE users RENAME COLUMN activeso_created_at TO created_at;
ALTER TABLE users RENAME COLUMN activeso_updated_at TO updated_at;
Indexes that 1.x created, named like activeso_<hex>_<hex>_unique, keep working unchanged. Never open a live synced replica with a plain non-sync connection or the SQLite CLI; use the sync SDK or an offline copy.
A working Echo v5 example
The repository ships a small Echo v5 server with a single page for creating, finding, editing, and deleting users. It syncs a local activeso-example.db with a Turso Cloud database you own, syncs after each write, and shows any sync failure on the page β it never retries on its own. It listens on localhost:8080.
TURSO_DATABASE_URL=turso://my-db-my-org.aws-us-east-1.turso.io TURSO_AUTH_TOKEN=... go -C example run .
| Method | Path | Behavior |
|---|---|---|
| GET | / | Renders the create form, find form, and existing users. |
| GET | /users/find | Finds a user by email address from the email query parameter with FindBy, an exact match after trimming surrounding whitespace. |
| POST | /users | Creates a user from an email address. |
| POST | /users/batch | Creates one user per line in a single transaction; if any fails, none are saved. |
| POST | /sync | Pulls remote changes and pushes local ones. |
| POST | /users/:id | Updates a user's email address. |
| POST | /users/:id/delete | Deletes a user. |