activeso

package module
v1.0.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 6, 2026 License: MIT Imports: 14 Imported by: 0

README

ActiveSo

ActiveSo is a small active record-like persistence layer for Go structs backed by Turso.

Table of contents

Quick start

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()

	// If using ActiveSo 'belongs_to', we recommend enabling enforcement for belongs_to foreign-key constraints on this connection.
	if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
		return err
	}

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

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

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

	// Update the user's email.
	user.Email = "updated@null.live"
	if err := user.Save(ctx); err != nil {
		return err
	}

	// Delete the user.
	return user.Delete(ctx)
}

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"
}

Run AutoMigrate(ctx) separately during application setup or deployment when you want ActiveSo to create the table, add missing nullable columns, and create tagged unique indexes; it is not required for normal model initialization or record operations.

Mapping a model to SQL

For example, the User model maps to the following Turso table.

type User struct {
	activeso.Record

	ID    string `db:"id"`
	Email string `db:"email" activeso:"not_null,unique"`
}
CREATE TABLE users (
	id TEXT PRIMARY KEY,
	email TEXT NOT NULL,
	activeso_created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
	activeso_updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX activeso_7573657273_656d61696c_unique ON users (email);

Record holds ActiveSo's runtime persistence binding and is not a database column. It promotes managed time.Time fields as CreatedAt and UpdatedAt on each model value. The db tags select column names; without them, ActiveSo derives snake_case names from exported field names. Every ActiveSo table also receives implicit activeso_created_at and activeso_updated_at UTC timestamp columns. New tables use CURRENT_TIMESTAMP defaults. On updates that name a non-timestamp column (including no-op assignments), a trigger refreshes activeso_updated_at unless the statement supplies an update timestamp different from the row's old value. Ordinary data updates leave activeso_created_at unchanged. Create, reads, and successful Save calls populate or refresh the Go fields; Bind only attaches persistence behavior and does not load timestamps. The activeso:"not_null,unique" tag adds NOT NULL and a unique index when AutoMigrate(ctx) runs.

Timestamp replay policy: Direct SQL may replace either timestamp, and a changed supplied activeso_updated_at is preserved rather than regenerated. This allows delayed row-image replay without a current-time guard aborting the entire update. A supplied update timestamp equal to the old value is indistinguishable from an omitted timestamp and refreshes on data-column updates; timestamp-only updates do not invoke the refresh trigger. This is a value-based policy, not replay detection: SQL-level creation-time immutability and protection against manual timestamp replacement are no longer enforced. ActiveSo Create and Save still manage timestamps automatically and do not persist assignments to the Go timestamp fields. SQL callers should supply valid UTC timestamp text (YYYY-MM-DD HH:MM:SS or RFC3339), since malformed values cannot be loaded into Record.

Upgrading existing databases: Run AutoMigrate(ctx) for each model after updating ActiveSo. It transactionally removes the previous rejection guards and replaces the update trigger; upgrading the package alone does not change triggers already installed in a database. Repeated migrations retain this policy. Deploy the updated policy to every database that may execute replayed writes. Full remote Push/Pull behavior must still be validated with the consumer's sync setup.

When loading a plain Go string, ActiveSo reads SQL NULL as "". This is the default: it lets a nullable string column added by AutoMigrate remain readable for pre-existing rows. 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 created before an additive nullable migration remain readable. Use nullable database/sql value types when the distinction between NULL and a zero value matters.

ActiveSo supports sql.NullString as TEXT, sql.NullInt64 as INTEGER, sql.NullFloat64 as REAL, and sql.NullBool as INTEGER. Use sql.NullString when your application needs to preserve the distinction between a missing string and an empty string. Import database/sql and use it directly in the model; 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.

Constraint options

Add comma-separated constraint options in an activeso struct tag:

type City struct {
	activeso.Record

	ID       string `db:"id"`
	Name     string `db:"name"`
	RegionID string `db:"region_id" activeso:"belongs_to=regions(id)"`
}
Option AutoMigrate(ctx) behavior Write 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, unambiguous unique index for the table and column. Create and Save check for an existing value first and return an error matching activeso.ErrUnique; the Turso index remains the concurrency-safe authority.
unique_with=column[+column...] Creates a stable composite unique index beginning with the tagged field followed by the named mapped columns. Turso rejects duplicate combinations with an error matching activeso.ErrUnique.
index Creates a stable, unambiguous non-unique index for the table and column. Makes equality lookups such as FindBy eligible to use a Turso index.
primary_key Declares the field as the table primary key. Selects the identity used by Find, Save, and Delete; the field must use a supported immutable scalar type.
belongs_to=table(column) Adds REFERENCES table(column) when creating a table or adding a missing nullable column. It does not attach a foreign key to an existing column. Turso rejects non-NULL values that do not exist in the referenced column when foreign-key enforcement is enabled.
on_delete=cascade Adds ON DELETE CASCADE to a belongs_to foreign key created on the same field. Requires belongs_to; it does not change an existing foreign key. Deleting the referenced row also deletes matching rows in this table when foreign-key enforcement is enabled.

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. An unknown or malformed activeso constraint causes activeso.Model[T](db) to panic so schema mistakes are caught during setup.

belongs_to=table(column) creates an inline foreign key on the tagged scalar column. For example, RegionID above becomes region_id TEXT REFERENCES regions(id). Add not_null when the relationship is required:

RegionID string `db:"region_id" activeso:"not_null,belongs_to=regions(id)"`

To delete child rows when their referenced row is deleted, add on_delete=cascade on the same field:

RegionID string `db:"region_id" activeso:"belongs_to=regions(id),on_delete=cascade"`

Changing the tag on an existing foreign key does not alter its database constraint; that requires a dedicated schema migration.

Use unique_with when a combination of fields, rather than one field alone, must be unique. The tagged field is the first index column and named columns follow it in the listed order:

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)"`
}

AutoMigrate(ctx) creates a unique index on (customer_id, organization_id), preventing a customer from joining the same organization twice while allowing that customer to join other organizations. Keep the individual index tags when FindBy queries either column independently.

To remove a unique_with constraint, remove the tag first and then run the explicit migration with columns in the same order. The columns may be omitted from the current model, allowing an index to be removed before a later DropColumn migration:

membershipModel := activeso.Model[OrganizationCustomer](db)
if err := membershipModel.DropUniqueWith(ctx, "customer_id", "organization_id"); err != nil {
	return err
}

AutoMigrate(ctx) rejects an implicit removal and directs callers to DropUniqueWith.

References

Enable foreign-key enforcement after opening the Turso database connection:

db := sql.OpenDB(connector)
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
	return err
}

PRAGMA foreign_keys is connection-specific in SQLite-compatible databases. Enable it before using models with belongs_to=table(column) constraints; if an application uses a connection pool, ensure every connection is configured accordingly.

API

Contents
Model

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)
AutoMigrate

AutoMigrate(ctx) creates the table, adds missing nullable columns, and creates tagged unique and ordinary indexes. Call it once during application setup or deployment, separately from normal record operations.

For an existing table, the model's ID column must be protected by a sole primary key or a single-column unique index. ActiveSo rejects composite primary keys that do not make the modeled ID independently unique, preventing Save and Delete from targeting multiple rows.

if err := userModel.AutoMigrate(ctx); err != nil {
	return err
}
Explicit migrations

AutoMigrate(ctx) is intentionally additive and safe: it never drops data, removes indexes, changes column types, or tightens existing constraints. When it detects a destructive change it can identify safely, its error directs you to the matching explicit API: a new not_null field or an existing nullable field made not_null requires a nullable addition, a backfill, and SetNotNull(ctx, column); a changed non-primary-key storage type requires ChangeColumnType(ctx, column); a primary-key type change requires a dedicated manual migration; and removing unique requires DropUnique(ctx, column) when ActiveSo's managed index exists. Make destructive schema changes deliberately by first updating the model definition, then calling the matching operation during deployment. Removing a model field is not treated as a drop request because ActiveSo intentionally supports database columns that are omitted from the Go struct; call DropColumn(ctx, column) explicitly.

Operation Required model change Effect
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.

For example, to stop requiring unique emails, remove unique from the tag and run an explicit migration:

type User struct {
	activeso.Record

	ID    string `db:"id"`
	Email string `db:"email" activeso:"not_null"`
}

userModel := activeso.Model[User](db)
if err := userModel.DropUnique(ctx, "email"); err != nil {
	return err
}

Each column migration changes only its named target. Existing columns omitted from your Go struct, their data, and unrelated defaults, constraints, and indexes are preserved. For example, ChangeColumnType(ctx, "email") leaves an existing nickname column intact even if the struct no longer contains it. Removing a struct field alone never drops its database column; call DropColumn explicitly.

These operations rebuild the existing database schema inside a transaction. Unsupported dependencies cause an error, and failed migrations roll back. Remove indexes or constraints referencing a column before dropping it. Rebuilds currently reject dependent views/triggers, foreign keys involving the table, generated columns, autoincrement columns, primary-key type changes, and dropping inline primary-key columns; those require a dedicated migration. Explicit NULL expressions and fully shadowed row IDs can also require a dedicated migration. Run destructive migrations during a controlled deployment and back up production data first.

Create

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"})
Find

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")
FindBy

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. Add index to frequently searched non-unique fields:

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

All(ctx) loads every record from the model's table.

users, err := userModel.All(ctx)
Where

Where(predicate, arguments...) starts a parameterized query. Pass one argument for each ? placeholder.

user, err := userModel.Where("email = ?", "hello@null.live").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

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(limit) applies a positive maximum row count to a query.

users, err := userModel.Where("email LIKE ?", "%@null.live").
	Limit(10).
	All(ctx)
Nearest

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

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

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)
Save

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'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 = "updated@null.live"
err := user.Save(ctx)
Delete

Delete(ctx) deletes a bound record by primary key.

err := user.Delete(ctx)

Browser example

The example/ directory contains a small Echo v5 server with a single HTML page for creating, editing, and deleting users. It creates a local activeso-example.db file and listens on http://localhost:8080.

go -C example run .
Inspect the local database

After stopping the browser example so it no longer has the database file open, start a local Turso server in one terminal:

turso dev --db-file activeso-example.db --port 8100

In a second terminal, connect its SQL shell:

turso db shell http://127.0.0.1:8100

Then inspect the database from the shell:

.tables
.schema users
SELECT * FROM users;

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotFound reports that a query did not return a matching record.
	ErrNotFound = errors.New("activeso: record not found")

	// ErrUnboundRecord reports that a record was not created, loaded, or bound through a Model.
	ErrUnboundRecord = errors.New("activeso: record is not bound to a model")

	// ErrIDChanged reports that a bound record's primary key was modified.
	ErrIDChanged = errors.New("activeso: bound record ID cannot be changed")

	// ErrUnique reports that a value conflicts with a field marked activeso:"unique".
	ErrUnique = errors.New("activeso: unique constraint violated")
)

Functions

func Model

func Model[T any](db *sql.DB) *model[T]

Model binds an application-defined struct type to its Turso table using db. T must embed activeso.Record and expose one primary_key field or an id column.

Types

type Record

type Record struct {
	CreatedAt time.Time
	UpdatedAt time.Time
	// contains filtered or unexported fields
}

Record embeds active-record persistence methods and managed timestamps into an application-defined model.

func (*Record) Delete

func (record *Record) Delete(ctx context.Context) error

Delete removes the owning record from its bound model's table.

func (*Record) Save

func (record *Record) Save(ctx context.Context) error

Save persists the owning record's current field values through its bound model.

type UniqueError

type UniqueError struct {
	Field string
}

UniqueError identifies the model field that conflicts with a unique constraint.

func (UniqueError) Error

func (error UniqueError) Error() string

Error returns a human-readable unique-constraint validation error.

func (UniqueError) Is

func (error UniqueError) Is(target error) bool

Is allows errors.Is to match a UniqueError against ErrUnique.

type Vector32

type Vector32 []float32

Vector32 is a dense, 32-bit floating point vector stored with Turso's vector32 type.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL