ActiveSo

ActiveSo is a small active record–like persistence layer for Go structs, built on Turso. Define a struct, get a table. Create, find, save, and delete directly on your Go values β€” explicit and lightweight, with no repository layer in between.
A Section Co. project Β· MIT licensed
View on GitHub go get github.com/sectionco/activeso go -C example run .

Quick start

main.gofrom the README
package main

import (
	"context"
	"database/sql"

	"github.com/sectionco/activeso"
	turso "turso.tech/database/tursogo"
)

type User struct {
	activeso.Record

	ID        string             `db:"id"`
	Email     string             `db:"email" activeso:"not_null,unique"`
	Embedding activeso.Vector32 `db:"embedding"`
}

func example(ctx context.Context) error {
	// Create a Turso connector and open a database/sql handle over it.
	connector, err := turso.NewConnector("app.db")
	if err != nil {
		return err
	}
	db := sql.OpenDB(connector)
	defer db.Close()

	// Initialize the User model.
	userModel := activeso.Model[User](db)

	// Optional: run during setup or deployment to create or update the schema.
	if err := userModel.AutoMigrate(ctx); err != nil {
		return err
	}

	// Create, update, and delete β€” right on the value.
	user, err := userModel.Create(ctx, User{Email: "hello@null.live"})
	if err != nil {
		return err
	}

	user.Email = "updated@null.live"
	if err := user.Save(ctx); err != nil {
		return err
	}

	return user.Delete(ctx)
}

A struct maps to a table

Model uses plural snake_case table names by default β€” User maps to users. The embedded Record holds ActiveSo's runtime persistence binding and is not a database column. The activeso tag adds constraints when AutoMigrate(ctx) runs.

UserGo
type User struct {
	activeso.Record

	ID    string `db:"id"`
	Email string `db:"email" activeso:"not_null,unique"`
}
maps to
usersSQL
CREATE TABLE users (
	id    TEXT PRIMARY KEY,
	email TEXT NOT NULL
);

CREATE UNIQUE INDEX activeso_7573657273_656d61696c_unique
	ON users (email);

db tags choose column names; exported fields without one derive a snake_case name. Plain strings, numbers, and booleans read SQL NULL as their Go zero value, keeping rows readable after additive nullable migrations. Use database/sql nullable types, such as sql.NullString, when your application must distinguish NULL from an empty or zero value.

Three constraint options

Constraints live in the struct tag. An unknown activeso constraint causes activeso.Model[T](db) to panic, so schema mistakes are caught during setup.

OptionAutoMigrate(ctx) behaviorWrite behavior
not_null Adds NOT NULL when creating a table. ActiveSo refuses to add a new required column to an existing table automatically. Turso rejects NULL values.
unique Creates a stable unique index named activeso_<table>_<column>_unique. Create and Save check for an existing value first and return an error matching activeso.ErrUnique; the Turso index remains the concurrency-safe authority.
primary_key Declares the field as the table primary key. Sets the identity used by Find, Save, and Delete.

If no field has primary_key, ActiveSo uses the field mapped to id. Exactly one primary key is required. Supported types are strings, booleans, signed integers, uint8–uint32, and floats; uint and uint64 are not supported because Turso stores integers as signed 64-bit values.

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.

externalUser := &User{ID: "8b291a21-e69b-47ed-a3e0-f43e7609b26d", Email: "hello@null.live"}
user, err := userModel.Bind(externalUser)

Full lifecycle

Create, mutate and save, delete β€” one bound *T from start to finish.

user, err := userModel.Create(ctx, User{Email: "hello@null.live"})
if err != nil {
	return err
}

user.Email = "updated@null.live"
if err := user.Save(ctx); err != nil {
	return err
}

return user.Delete(ctx)

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

Every call below is actual API surface. 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
}

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)

First(ctx)

Loads the first matching result; activeso.ErrNotFound when none match.

user, err := userModel.Where("email LIKE ?", "%@null.live").
	OrderBy("email ASC").
	First(ctx)

Where + OrderBy + Limit + All(ctx)

Query parts compose. OrderBy takes a raw SQL expression; Limit takes a positive maximum row count; terminators return bound records.

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.

Migrations stay deliberate

AutoMigrate(ctx) is intentionally additive and safe: it creates tables, adds missing nullable columns, and creates tagged unique indexes, but never drops data, removes indexes, changes column types, or tightens existing constraints. Run it during application setup or deployment; normal model initialization and record operations do not require it.

OperationRequired model changeEffect
DropUnique(ctx, column)Remove unique from the column's activeso tag.Drops ActiveSo's named unique index.
DropColumn(ctx, column)Remove the field from the model.Rebuilds the table without the column.
ChangeColumnType(ctx, column)Change the Go field's type.Rebuilds the table using the newly inferred Turso column type.
SetNotNull(ctx, column)Add not_null to the field's activeso tag.Rebuilds the table with NOT NULL; it fails if any existing row contains NULL.

Removing a struct field alone never drops a database column; use DropColumn explicitly. Each column migration changes only its named target. Rebuilds run inside a transaction β€” unsupported dependencies cause an error, and failed migrations roll back. Run destructive changes during a controlled deployment with a production backup; tables with dependent views, triggers, foreign keys, generated columns, or unsupported primary-key changes need a dedicated migration.

Four sentinel errors

Match with errors.Is. Unique conflicts come back as a UniqueError that carries the offending field β€” activeso: email must be unique.

A working Echo v5 example

The repository ships a small Echo v5 server with a single page for creating, editing, and deleting users. It creates a local activeso-example.db and listens on localhost:8080.

go -C example run .
MethodPathBehavior
GET/Renders the create form and existing users.
POST/usersCreates a user from an email address.
POST/users/:idUpdates a user's email address.
POST/users/:id/deleteDeletes a user.