ActiveSo is a small active record-like persistence layer for Go structs backed by Turso. It generates the SQL for creating, reading, updating, and deleting records. It does not create or migrate your schema: you own your tables and their migrations, and ActiveSo checks that they match your Go types.
- Quick start
- Syncing with Turso Cloud
- Bring your own schema
- Transactions
- Hints
- Verifying your schema
- API
- Upgrading from 1.x
- Browser example
Every ActiveSo database is a local Turso file that syncs with Turso Cloud. OpenSync opens it, and models run on the handle it returns.
package main
import (
"context"
"fmt"
"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: "[email protected]"})
if err != nil {
return err
}
// Pull fetches remote changes and rebases local ones on top. It sends nothing.
changed, err := sync.Pull(ctx)
if err != nil {
return err
}
fmt.Println("remote changes applied:", changed)
// Push sends local changes. It fetches nothing.
if err := sync.Push(ctx); err != nil {
return err
}
// Sync does Pull, then Push, for the common "catch up and publish" case.
if err := sync.Sync(ctx); err != nil {
return err
}
// Stats reports sync activity, including local writes since the last Pull.
stats, err := sync.Stats(ctx)
if err != nil {
return err
}
fmt.Println(stats.CdcOperations, "local writes since the last pull")
// Records bound by Create keep working until the handle is closed.
user.Email = "[email protected]"
return user.Save(ctx)
}The tables Verify checks must already exist; see Bring your own schema for the users table this model maps to.
Model uses plural snake_case table names by default. English inflection handles common and irregular forms (User → users, City → cities, Person → people) and does not double-pluralize a type already named Users.
Implement TableName() string whenever your database uses a specific or domain-specific table name. For example:
type Customer struct {
activeso.Record
ID string `db:"id"`
}
func (Customer) TableName() string {
return "crm_customers"
}ActiveSo reads and writes tables that already exist. Create and change them with whatever migration tooling you prefer. The User model above maps to this table:
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 no db tag. Use db:"-" to exclude an exported field from persistence. Columns that exist in the table but not in the struct are ignored, so you can add columns ahead of a deploy or drop a field's column later.
Record holds ActiveSo's runtime persistence binding and is not a database column.
Record promotes CreatedAt and UpdatedAt fields, but ActiveSo only manages them when you opt in with the timestamps hint on the embedded Record:
type User struct {
activeso.Record `activeso:"timestamps"`
ID string `db:"id"`
Email string `db:"email"`
}With the hint, ActiveSo expects the table to have created_at and updated_at columns holding UTC timestamp text. Create stamps both with CURRENT_TIMESTAMP, Save stamps updated_at and never rewrites created_at, and reads populate the Go fields. The columns need no defaults or triggers. If either column is missing, the first operation fails with an activeso.ErrSchemaMismatch error that names created_at and updated_at; a stored value that is not valid UTC timestamp text (YYYY-MM-DD HH:MM:SS or RFC3339) fails when read with an error that names the same columns. Without the hint, ActiveSo never touches those columns and CreatedAt and UpdatedAt stay zero. A struct field cannot map to created_at or updated_at when the hint is set.
Timestamps are metadata written by ActiveSo, not an authorization boundary. Writes made with raw SQL outside ActiveSo do not refresh updated_at, and Bind attaches persistence behavior without loading timestamps.
When loading a plain Go string, ActiveSo reads SQL NULL as "". A plain string write always stores text, so NULL and an empty string become indistinguishable after the value is loaded or saved.
Plain numeric and boolean fields also read NULL as their Go zero values (0, 0.0, and false), so rows that predate a nullable column remain readable. Use nullable database/sql value types when the distinction between NULL and a zero value matters.
A Vector32 field reads a NULL column as a nil vector, and saving a nil vector writes NULL.
ActiveSo supports sql.NullString as TEXT, sql.NullInt64 as INTEGER, sql.NullFloat64 as REAL, and sql.NullBool as INTEGER. ActiveSo retains Valid when loading and saving:
type User struct {
activeso.Record
ID string `db:"id"`
Nickname sql.NullString `db:"nickname"`
}sql.NullString{Valid: false} represents SQL NULL; sql.NullString{String: "", Valid: true} represents an empty string.
ActiveSo accepts any activeso.Executor: *sql.DB, *sql.Tx, or *sql.Conn. You begin, commit, and roll back the transaction yourself; ActiveSo only runs statements on the executor you give it.
Every write and the single-record read has a transaction twin. Call the normal method to run on the model's own database, or the Tx method to run on a transaction you pass in:
| 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) |
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
user, err := userModel.CreateTx(ctx, tx, User{Email: "[email protected]"})
if err != nil {
return err
}
user.Email = "[email protected]"
if err := user.SaveTx(ctx, tx); err != nil {
return err
}
return tx.Commit()A *sql.Tx or *sql.Conn you pass must belong to the same database as the model; ActiveSo cannot check this. To use a model with a different database, pass that *sql.DB to Using, which inspects and caches its schema separately.
Records returned by the Tx methods stay bound to the original model, so a later plain Save or Delete runs on the model's own database, not on the finished transaction.
For queries (Where, Nearest, FindBy, All) or several operations on one transaction, use Using to take a transaction-scoped view of a model. Everything created, loaded, or queried through the view, and every Save and Delete on those records, stays on the transaction. Anything beyond that, such as savepoints or custom isolation, is plain database/sql that you write yourself on the same *sql.Tx.
txUsers := userModel.Using(tx)
matches, err := txUsers.Where("email LIKE ?", "%@null.live").All(ctx)
for _, match := range matches {
match.Email = "[email protected]"
if err := match.Save(ctx); err != nil { // runs on tx
return err
}
}Turso's driver begins transactions with snapshot isolation and ignores sql.TxOptions.
If you limit the pool with db.SetMaxOpenConns(1), an open transaction holds the only connection, so any call through the root model (Create, Find, All, queries, Verify, or a plain Save or Delete) waits until the transaction ends. Inside a transaction use Using(tx) or the *Tx methods, and call SaveTx rather than Save on records returned by CreateTx and FindTx. This is standard database/sql behavior.
Hints describe what your table already enforces and how its records relate. Add comma-separated hints in an activeso struct tag:
type City struct {
activeso.Record
ID string `db:"id"`
Name string `db:"name" activeso:"not_null,index"`
RegionID string `db:"region_id" activeso:"belongs_to=regions(id)"`
}Hints never change the database. 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. The field must use a supported immutable scalar type. |
unique |
A unique index covers exactly this column. | When Turso rejects a write for violating it, Create and Save return an error matching activeso.ErrUnique that is also an activeso.UniqueError naming the column. |
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 in the list. | Turso rejects duplicate combinations with an error matching activeso.ErrUnique. |
index |
An index begins with this column. | Verify checks it. It makes FindBy lookups eligible to use the index. |
not_null |
The column is declared NOT NULL. |
Verify checks it. Turso rejects NULL values. |
belongs_to=table(column) |
The column has a foreign key to table(column). |
Verify checks it. With foreign-key enforcement enabled, 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 on the same field. |
Verify checks it. With enforcement enabled, deleting a referenced row also deletes its matching child rows. |
On the embedded Record, the only hint is timestamps.
If no field has primary_key, ActiveSo uses the field mapped to id. Exactly one primary key is required. Supported primary-key types are strings, booleans, signed integers, uint8, uint16, uint32, and floats. uint and uint64 are rejected because their full range cannot be represented by Turso's signed 64-bit INTEGER.
belongs_to targets must use simple identifiers in table(column) form. A primary key satisfies not_null and index.
Use unique_with when a combination of fields, rather than one field alone, must be unique:
type OrganizationCustomer struct {
activeso.Record
ID string `db:"id"`
CustomerID string `db:"customer_id" activeso:"not_null,index,belongs_to=customers(id),unique_with=organization_id"`
OrganizationID string `db:"organization_id" activeso:"not_null,index,belongs_to=organizations(id)"`
}CREATE UNIQUE INDEX memberships_customer_organization ON organization_customers (customer_id, organization_id);Foreign-key enforcement is a connection setting in SQLite-compatible databases. ActiveSo enforces belongs_to foreign keys for every write it performs: 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 whichever pooled connection services the call.
Your own direct SQL on sync.DB() is not covered, because the pragma applies per connection and the Turso sync driver builds its own pool. Take one connection, enable the pragma on it, and run your statements 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
}A one-off sync.DB().ExecContext(ctx, "PRAGMA foreign_keys = ON") only configures whichever pooled connection happened to service that call.
Writes inside a transaction are the exception. SQLite ignores PRAGMA foreign_keys once a transaction has begun, so a *sql.Tx from sync.DB().BeginTx does not enforce foreign keys unless its connection already did. To enforce them, take a connection, enable the pragma on it, and begin the transaction 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)activeso.OpenSync(ctx, config) is the only way to open a database. It opens the local 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 and 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. It sends nothing. It reports whether remote changes were applied. |
Push(ctx) |
Sends local changes to the remote. It fetches nothing. |
Sync(ctx) |
Pull, then Push. |
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; it resets on Pull, not Push), last pull and push times, and network traffic. |
Close() |
Stops sync calls, closes every pooled connection, and releases the database, WAL, and changes files. Later Pull, Push, and Stats calls return ErrSyncClosed. Writes that were never pushed stay in the local file. If connections from DB() are still checked out it returns ErrSyncBusy; return them and call Close again. |
SyncConfig takes Path, RemoteURL, and AuthToken, plus optional Namespace, ClientName, BusyTimeoutMs, and SkipBootstrap (leave an empty local file empty on open and call Pull yourself). Path and RemoteURL are required.
Writes made through models are local until you call Push or Sync; ActiveSo does not push for you. Keep your schema up to date yourself on every database that syncs, then use Verify to catch drift, including after a Pull.
Call sync.Close() when you are done with the database. Closing the *sql.DB from sync.DB() directly closes the connections but leaves the database files open, because the Turso SDK keeps its own sync handle; only sync.Close() releases them.
Because ActiveSo trusts your tables, it can check that they match your model. There are two levels:
- Automatic structural check. The first
Create,Find, query,Save, orDeleteon a model checks that the table and every mapped column exist, that thetimestampscolumns exist when that hint is set, and that the primary-key column is aPRIMARY KEYor has a single-column unique index. A failure returns an*activeso.SchemaErrorand is retried on the next call, so a table created later is picked up. A passing check is remembered for the model's lifetime, so create a model once and reuse it. - Full check with
Verify(ctx). It adds everything the automatic check skips: column types against Go types (a field type ActiveSo cannot map to a column is reported unless it implementsdriver.Valuerandsql.Scanner), and everynot_null,unique,unique_with,index,belongs_to, andon_delete=cascadehint. Call it at startup or in a test. It only reads schema metadata and never changes the database. A clean result also lets later operations skip their first-use structural check, which matters most for transaction calls (see Transactions).
if err := userModel.Verify(ctx); err != nil {
var schemaError *activeso.SchemaError
if errors.As(err, &schemaError) {
for _, problem := range schemaError.Problems {
log.Println(problem)
}
}
return err
}SchemaError matches activeso.ErrSchemaMismatch with errors.Is and lists every problem found, for example hint unique on email, but no unique index covers exactly that column.
- Model
- Verify
- Create
- CreateTx
- Find
- FindTx
- FindBy
- All
- Where
- OrderBy
- Limit
- Nearest
- First
- Bind
- Using
- Save
- SaveTx
- Delete
- DeleteTx
activeso.Model[T](db) creates the persistence model for your Go struct type. In this example, T is User, so activeso.Model[User](db) maps User values to the users table.
userModel := activeso.Model[User](db)Verify(ctx) compares the live table with the model's db tags and hints and returns an *activeso.SchemaError listing every mismatch. See Verifying your schema.
if err := userModel.Verify(ctx); err != nil {
return err
}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: "[email protected]"})CreateTx(ctx, tx, value) behaves like Create but inserts on tx for that call only. The returned record is bound to the original model.
user, err := userModel.CreateTx(ctx, tx, User{Email: "[email protected]"})Find(ctx, id) loads the record with id. It returns activeso.ErrNotFound when no record exists.
user, err := userModel.Find(ctx, "8b291a21-e69b-47ed-a3e0-f43e7609b26d")FindTx(ctx, tx, id) behaves like Find but reads on tx for that call only, so it sees the transaction's uncommitted writes. The returned record is bound to the original model. For other reads inside a transaction, use Using.
user, err := userModel.FindTx(ctx, tx, id)FindBy(ctx, column, value) loads every record whose mapped column equals value. The column name must exist on the model, so ActiveSo quotes it safely rather than accepting arbitrary SQL. Index frequently searched non-unique columns in your schema and mark them with the index hint:
type City struct {
activeso.Record
ID string `db:"id"`
Name string `db:"name" activeso:"not_null,index"`
}
cities, err := cityModel.FindBy(ctx, "name", "Portland")All(ctx) loads every record from the model's table.
users, err := userModel.All(ctx)Where(predicate, arguments...) starts a parameterized query. Pass one argument for each ? placeholder.
user, err := userModel.Where("email = ?", "[email protected]").First(ctx)Use multiple arguments for predicates with multiple placeholders:
excludedID := "8b291a21-e69b-47ed-a3e0-f43e7609b26d"
users, err := userModel.Where("email LIKE ? AND id <> ?", "%@null.live", excludedID).All(ctx)OrderBy(expression) applies an ORDER BY expression to a query. Calling it after Nearest replaces the vector-distance ordering and discards that ordering's embedding argument.
users, err := userModel.Where("email LIKE ?", "%@null.live").
OrderBy("email ASC").
All(ctx)Limit(limit) applies a positive maximum row count to a query.
users, err := userModel.Where("email LIKE ?", "%@null.live").
Limit(10).
All(ctx)Nearest(column, embedding) orders records by cosine distance from a Vector32 embedding in the specified Turso vector32 column. Lower distance is more similar; combine it with Limit to perform a nearest-neighbor search.
queryEmbedding := activeso.Vector32{0.12, -0.08, 0.63}
users, err := userModel.Nearest("embedding", queryEmbedding).Limit(10).All(ctx)You can also use a vector as a Where argument when the predicate explicitly calls Turso's vector32(?) function:
queryEmbedding := activeso.Vector32{0.12, -0.08, 0.63}
users, err := userModel.Where(
"vector_distance_cos(embedding, vector32(?)) < ?",
queryEmbedding,
0.2,
).All(ctx)First(ctx) loads the first matching query result. It returns activeso.ErrNotFound when no record matches.
user, err := userModel.Where("email LIKE ?", "%@null.live").
OrderBy("email ASC").
First(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: "[email protected]"}
user, err := userModel.Bind(externalUser)Using(executor) is the way to run more than one operation, or any query, on a transaction. It returns a view of the model that runs every operation on executor, such as a *sql.Tx. Records created or loaded through the view stay bound to it, so their Save and Delete use the transaction too, and they fail with sql.ErrTxDone once it ends. Create models once and call Using per transaction; views share the model's metadata.
tx, err := db.BeginTx(ctx, nil)
txUsers := userModel.Using(tx)
user, err := txUsers.Create(ctx, User{Email: "[email protected]"})Using(tx).Bind(record) moves an existing record into a transaction.
Save(ctx) updates all persisted fields except the primary key. It is available on records returned by Create, Find, query methods, or Bind. A record loaded outside ActiveSo must be attached with Bind first; an unbound record's Save and Delete return activeso.ErrUnboundRecord.
A record's ID is captured when it becomes bound and cannot be changed. If you modify it, Save and Delete return activeso.ErrIDChanged; create a new record when you need a new identity.
user.Email = "[email protected]"
err := user.Save(ctx)SaveTx(ctx, tx) behaves like Save but runs on tx for that call only. The record stays bound to its original model, so a later Save uses the model's own executor.
err := user.SaveTx(ctx, tx)Delete(ctx) deletes a bound record by primary key.
err := user.Delete(ctx)DeleteTx(ctx, tx) behaves like Delete but runs on tx for that call only.
err := user.DeleteTx(ctx, tx)ActiveSo 2.0 removes all schema management. Plan these changes together:
- Import path. The module is now
github.com/sectionco/activeso/v2. - Remove migration calls.
AutoMigrate,DropUnique,DropUniqueWith,DropColumn,ChangeColumnType, andSetNotNullno longer exist. Existing tables keep working; changes to them are yours to write. - Tag every field with
db. Fields that relied on derived snake_case names now panic atModelsetup until they are tagged. - Rename constraints to hints. The tag syntax is unchanged, but hints no longer create anything.
unique,unique_with,index,not_null,belongs_to, andon_delete=cascadeare checked byVerify. - Unique errors come from the database.
CreateandSaveno longer run a check-then-write query first. Keep the unique index in your schema; a missing index means duplicates are accepted.Verifyreports it. - Timestamps are opt-in and renamed. Add
activeso:"timestamps"to the embeddedRecordto keepCreatedAtandUpdatedAt. The columns are nowcreated_atandupdated_at, and ActiveSo stamps them in its own SQL instead of using triggers.
To move a 1.x database 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. Trigger names are activeso_<hex of the lowercase table name>_timestamps_ followed by insert, update, protect_created_at, or protect_updated_at; list them 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 and need no changes. Back up production data before running any migration, and never open a live synced replica with a plain non-sync connection or the SQLite CLI; use the sync SDK or an offline copy.
The example/ directory contains a small Echo v5 server with a single HTML page for creating, editing, and deleting users. It syncs a local activeso-example.db file with a Turso Cloud database, so it needs a remote you own, and it listens on http://localhost:8080.
TURSO_DATABASE_URL=turso://my-db-my-org.aws-us-east-1.turso.io TURSO_AUTH_TOKEN=... go -C example run .activeso-example.db is a synced replica. Never open it with a plain non-sync connection or a SQLite client; stop the example, copy the file, and inspect the copy. Start a local Turso server on the copy in one terminal:
turso dev --db-file activeso-example-copy.db --port 8100In a second terminal, connect its SQL shell:
turso db shell http://127.0.0.1:8100Then inspect the database from the shell:
.tables
.schema users
SELECT * FROM users;