honest framework

honest-persist

Schema as data. Migration as diff. No ORM. No migration file chain.

You know Prisma migrations. honest-persist replaces the file chain with a schema diff. No V001, V002, V003.
What you do now
// migrations/20240101_add_status_to_users.ts
export async function up(db: Kysely) {
    await db.schema
        .alterTable("users")
        .addColumn("status", "text", c => c.defaultTo("active"))
        .execute()
}
export async function down(db: Kysely) {
    await db.schema
        .alterTable("users")
        .dropColumn("status")
        .execute()
}
// One file per change. Linear chain.
// Branch merges create migration conflicts.
What honest-persist does
// Declare the schema you want.
const UserSchema = {
    table: "users",
    columns: [
        { name: "id",     type: "text", primary: true },
        { name: "email",  type: "text", unique: true },
        { name: "status", type: "text", default: "active" },
    ]
}

// apply() diffs against the live DB and runs only what's needed.
// No migration files. No linear chain. No branch conflicts.
await apply(conn, [UserSchema])
What you do now
# db/migrate/20240101120000_add_status_to_users.rb
class AddStatusToUsers < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :status, :string, default: "active"
  end
end

# rails db:migrate
# One file per change. Linear chain.
# Branch merges create migration conflicts.
What honest-persist does
USER_SCHEMA = HonestPersist::Schema.new(
  table: "users",
  columns: [
    { name: "id",     type: "text", primary: true },
    { name: "email",  type: "text", unique: true },
    { name: "status", type: "text", default: "active" },
  ]
)

# apply() diffs against the live DB and runs only what's needed.
# No migration files. No linear chain. No branch conflicts.
HonestPersist.apply(conn, [USER_SCHEMA])
What you do now
# migrations/0002_add_status_to_user.py
from django.db import migrations, models

class Migration(migrations.Migration):
    dependencies = [("myapp", "0001_initial")]
    operations = [
        migrations.AddField(
            model_name="user",
            name="status",
            field=models.CharField(default="active", max_length=20),
        )
    ]
# One file per change. Linear chain.
# Branch merges create migration conflicts.
What honest-persist does
from honest_persist import table, field, apply
from pydantic import BaseModel

@table("users")
class User(BaseModel):
    id:     str
    email:  str
    status: str = "active"

# apply() diffs against the live DB and runs only what's needed.
# No migration files. No linear chain. No branch conflicts.
await apply(conn, [User])
What you do now
// migrations/000002_add_status_to_users.up.sql
ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';

// migrations/000002_add_status_to_users.down.sql
ALTER TABLE users DROP COLUMN status;

// One file per change. Linear chain.
// Branch merges create migration conflicts.
What honest-persist does
userSchema := honestpersist.Schema{
    Table: "users",
    Columns: []honestpersist.Column{
        {Name: "id",     Type: "text", Primary: true},
        {Name: "email",  Type: "text", Unique: true},
        {Name: "status", Type: "text", Default: "active"},
    },
}

// Apply() diffs against the live DB and runs only what's needed.
// No migration files. No linear chain. No branch conflicts.
err := honestpersist.Apply(ctx, conn, []Schema{userSchema})
What you do now
// database/migrations/2024_01_01_add_status_to_users.php
public function up(): void {
    Schema::table('users', function (Blueprint $table) {
        $table->string('status')->default('active');
    });
}
public function down(): void {
    Schema::table('users', function (Blueprint $table) {
        $table->dropColumn('status');
    });
}
// One file per change. Linear chain. Branch conflicts.
What honest-persist does
$userSchema = HonestPersist\Schema::make([
    'table' => 'users',
    'columns' => [
        ['name' => 'id',     'type' => 'text', 'primary' => true],
        ['name' => 'email',  'type' => 'text', 'unique'  => true],
        ['name' => 'status', 'type' => 'text', 'default' => 'active'],
    ],
]);

// apply() diffs against the live DB and runs only what's needed.
// No migration files. No linear chain. No branch conflicts.
HonestPersist::apply($conn, [$userSchema]);
The conventional pattern
# Migration files: one per change, linearly ordered.
# Adding a column means writing a new file.
# Branch merges create ordering conflicts.
# Rollback requires a separate down() function.
# The schema lives in the migration history, not in one place.
The honest pattern
from honest_persist import table, field, apply
from pydantic import BaseModel

@table("users")
class User(BaseModel):
    id:     str
    email:  str
    status: str = "active"

# apply() diffs schema against live DB.
# No migration files. No linear chain.
# The schema is the source of truth, always.
await apply(conn, [User])

Every migration system in widespread use today is built on the same assumption: schema change is a history of instructions. You write a migration file describing what to do. The system executes the files in order. The schema is whatever the history produces.

honest-persist discards that assumption. A schema is a declaration of what you want. Migration is a diff between what you have and what you want. The diff is a pure function: same inputs, same output.

The consequence is immediate: there are no migration files to write, no linear chain to maintain, no ordering conflicts when branches merge. You change the schema declaration. You run apply(). The system computes what SQL to run and runs it. The schema declaration is always the source of truth.

apply() in one diagram:

declared schema ──┐
                  ├──→ diff() ──→ migration plan ──→ execute()
live database  ──┘

Diff is a pure function. Execute is the only I/O. The migration plan is a plain data structure — inspectable, loggable, testable without a database.

apply() is safe to run on every deploy:

Run apply() twice against the same database and the second run does nothing — the schema already matches, so the diff is empty and nothing executes. This means you can call apply() as part of every deployment without checking whether it already ran, without version guards, without migration lock tables.

Queries as data:

honest-persist does not own the query language. Queries are plain data structures: table name, operation, parameters. The executor assembles the SQL and runs it. There is no ORM session, no identity map, no lazy loading. What you ask for is what executes.

rows = await q("select_all", t="users",
               cols="id, email, status",
               where="status = ?",
               params=("active",))

The query is explicit. The SQL is visible. The executor adds nothing you did not ask for.

The abstract principle

Schema is data. Migration is a pure function.

These two sentences contain the entire design. Everything else is implementation.

Schema as data

A database schema is a description of a desired state. It has no behavior. It has no lifecycle. It is a plain data structure: table names, column names, types, constraints, indexes. In every mainstream framework, this data structure is encoded as either a class hierarchy (Django models, ActiveRecord, SQLAlchemy) or a sequence of imperative mutation files (Alembic revisions, Rails migrations, Flyway scripts). Both encodings hide the data behind behavior.

honest-persist declares schema as a plain dict. A TableConfig is a dict of column specs. It is not a class. It is not a file. It is data that describes what the database should look like.

Migration as a pure function

Given the current database state and the desired schema, the migration is the diff: a set of DDL operations that transforms current into desired. This diff is a pure function: same inputs, same output, every time. No side effects. No file chain. No history.

diff : Schema → LiveDB → MigrationPlan

This function has a formal property called idempotency: apply(diff(S, DB)) followed by diff(S, DB') produces an empty plan. The desired state is reached; running again produces nothing to do. This is a mathematical guarantee, not a convention.

Conventional migration files do not have this property. They have ordering. They have history. They have up() and down(). They have linear chains that branch and merge in version control and produce conflicts. All of this complexity exists because the schema is not declared as data — it is encoded as a sequence of mutations.

Why no ORM

An ORM adds an identity map: a cache of objects that track which database rows they came from. This cache is state. It can be stale. It can be inconsistent with the database. It fires queries at times you did not choose. It hides the SQL.

honest-persist uses no identity map. Every query is explicit. Every SQL statement is visible or reconstructible from the operation name and parameters. The database is the state. The application never maintains a shadow copy.

This is the same principle as DATAOS applied to persistence: one source of truth. The DOM is the UI state. The database is the data state. No shadow copies in either place.

Append-only tables as a formal property

The event log table has a formal property: append-only. No UPDATE. No DELETE. This is not a convention or a discipline. It is enforced at the honest-persist level. Any attempt to update or delete a row in an append-only table returns a server fault before any SQL reaches the database.

Append-only is a monotonicity property from distributed systems: once a fact is recorded, it is never retracted. State is derived by reading the log forward. This is the foundation of honest-observe's event sourcing model. honest-persist enforces the invariant; honest-observe reads from it.

Full specification

The apply() Algorithm

FUNCTION apply(conn, desired_schemas):
    live_schema ← inspect(conn)           ← read actual DB state
    plan        ← diff(live_schema, desired_schemas)
    FOR EACH operation IN plan:
        execute_ddl(conn, operation)
        emit("hf.persist.migration", operation)
    RETURN plan

diff() is a pure function: it takes two schema representations and returns an ordered list of DDL operations. It never touches the database. apply() is the only function that runs DDL.

diff() produces operations in this order: 1. CREATE TABLE for tables that do not exist 2. ADD COLUMN for columns that do not exist in an existing table 3. ALTER COLUMN for columns whose type or constraints changed (where the DB supports it) 4. CREATE INDEX for declared indexes not present in live schema 5. DROP INDEX for live indexes not in the declared schema (with explicit opt-in only) 6. DROP COLUMN and DROP TABLE are never produced automatically — destructive operations require explicit declaration

Schema Declaration Rules

A schema is declared as a TableConfig dict:

{
    "table":   "users",
    "columns": [
        {"name": "id",     "type": "text",    "primary": True},
        {"name": "email",  "type": "text",    "unique": True, "nullable": False},
        {"name": "status", "type": "text",    "default": "active"},
        {"name": "created_at", "type": "timestamp", "default": "now()"},
    ],
    "indexes": [
        {"columns": ["email"], "unique": True},
        {"columns": ["status", "created_at"]},
    ],
}

Column type mapping:

Declared typePostgreSQLSQLiteTurso
textTEXTTEXTTEXT
integerINTEGERINTEGERINTEGER
booleanBOOLEANINTEGER (0/1)INTEGER (0/1)
timestampTIMESTAMPTZTEXT (ISO 8601)TEXT (ISO 8601)
jsonJSONBTEXTTEXT
uuidUUIDTEXTTEXT

SQLite and Turso store booleans as integers and timestamps as ISO 8601 strings. execute() and select() coerce transparently when the column type is declared.

The execute() Function

execute() is the only function that runs SQL. No SQL appears anywhere else in application code.

FUNCTION execute(conn, operation, table, columns, params, where, order, limit):
    sql    ← build_sql(operation, table, columns, where, order, limit)
    result ← conn.execute(sql, params)
    emit("hf.persist.query", {
        table, operation, row_count, duration_ns, sql_hash, request_id
    })
    RETURN result as typed records (plain dicts matching declared schema)

sql_hash is always emitted. Full SQL is emitted only in development mode.

Supported operations: select, select_one, insert, upsert, update, delete, raw.

raw accepts a literal SQL string. It is the escape hatch for queries that the operation set cannot express. It still emits hf.persist.query.

Query Style Support

honest-persist supports four equivalent query styles. All compile to the same parameterized SQL internally:

# Fluent
q.table("users").where(status="active").order("created_at DESC").select("id", "email")

# Django-style
User.objects.filter(status="active").order_by("-created_at").values("id", "email")

# Prisma-style
db.user.find_many(where={"status": "active"}, order_by={"created_at": "desc"})

# Pseudo-SQL
select("id", "email", from_table="users", where="status = :s",
       order="created_at DESC", params={"s": "active"})

All four produce identical parameterized SQL. Style is a per-project configuration. Mixed styles within a project are not permitted — honest-check HC-P013 flags them.

The Write Queue

For high-latency backends (Turso, remote Postgres), honest-persist maintains an optimistic write queue. Writes are acknowledged immediately and flushed asynchronously. The queue emits hf.persist.queue_stalled if writes remain unflushed for more than 6 hours.

The write queue is transparent to application code. execute("insert", ...) behaves identically whether the queue is active or not. Queue depth and stall events are observable via honest-observe projections.

Connection Pool Lifecycle

POOL_EVENTS = {
    "created":   "pool initialized",
    "exhausted": "all connections in use, caller waiting",
    "retry":     "connection attempt retried after failure",
    "closed":    "pool shut down gracefully",
    "error":     "unrecoverable pool error",
}

Every event emits hf.persist.pool to honest-observe. Pool exhaustion is never silent.

Append-Only Tables

A table declared append_only=True rejects UPDATE and DELETE at the honest-persist level:

{
    "table":       "honest_event_log",
    "append_only": True,
    "columns":     [...],
}

Any attempt to execute("update", ...) or execute("delete", ...) against an append-only table returns a server fault immediately, before any SQL reaches the database.

Conformance Requirements

RequirementTest
diff() is pure: same inputs → same planRun twice, compare
apply() is idempotent: run twice, second run produces empty planApply, apply again
execute() emits hf.persist.query on every callInstrument and verify
execute() returns plain dicts matching declared column schemaCheck return type
Append-only table rejects update/delete with server faultTest both operations
Pool exhaustion emits hf.persist.pool with event="exhausted"Exhaust pool, check log
Boolean columns coerce transparently on read (SQLite/Turso)Insert True, select, compare
Timestamp columns coerce to ISO 8601 string on SQLite/TursoInsert datetime, select
raw operation still emits hf.persist.queryTest raw SQL call
Schema diff does not produce DROP TABLE or DROP COLUMN automaticallyVerify diff output

Reference