honest framework

honest-persist

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

migrate : Schema → LiveDB → MigrationPlan. Idempotent. No file chain. The diff is a pure function.
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.

Formal laws and FP implementation notes

Type Signatures

column_spec   : { name: String, type: ColumnType, primary: Bool, unique: Bool,
                  nullable: Bool, default: Maybe Value }
table_config  : { table: String, columns: [ColumnSpec], indexes: [IndexSpec],
                  append_only: Bool }
live_schema   : Map TableName [ColumnSpec]  -- from DB inspection
migration_op  : CreateTable | AddColumn | AlterColumn | CreateIndex | DropIndex
migration_plan: [MigrationOp]

diff   : [TableConfig] → LiveSchema → MigrationPlan
apply  : Connection → MigrationPlan → IO ()
inspect: Connection → IO LiveSchema

Laws

Law 1 — Idempotency of apply: Running apply twice leaves the database in the same state as running it once.

apply conn (diff desired (inspect conn))  -- first run
apply conn (diff desired (inspect conn))  -- second run: empty plan

Formally:

∀ desired, conn:
  let plan1 = diff desired (inspect conn)
  apply conn plan1
  let plan2 = diff desired (inspect conn)
  plan2 = []

Law 2 — Correctness: After apply, the live schema matches the desired schema.

∀ desired, conn:
  apply conn (diff desired (inspect conn))
  inspect conn = normalise(desired)
-- where normalise maps TypeConfig to canonical DB representation

Law 3 — diff is pure:

diff desired live = diff desired live
-- same inputs always produce the same plan
-- no IO, no randomness, no global state

Law 4 — No destructive operations from diff:

∀ plan = diff desired live:
  DropTable ∉ plan
  DropColumn ∉ plan
-- diff never produces destructive operations automatically

Law 5 — Append-only monotonicity: For a table declared append_only:

∀ table with append_only = True:
  execute Update table = Fault { category: "server" }
  execute Delete table = Fault { category: "server" }
-- The log only grows. No retraction of facts.

Law 6 — emit() purity contract:

emit : EventType → Payload → IO EventId
-- emit is the only way facts enter the log
-- emit must be called from a boundary link (IO context)
-- calling emit from a pure function is HC-P004

Implementation Notes for FP Languages

Haskell: diff is a pure function — [TableConfig] -> LiveSchema -> [MigrationOp]. apply lives in IO. The separation is natural in Haskell's type system: the pure planning is in Haskell's pure world; the DDL execution is in IO.

Erlang: The append-only invariant maps to an ETS table with protected access and no delete operation exposed in the public API. The log is an ETS bag that only grows.

Schema as data in FP: A TableConfig is a record/struct with no methods. This is trivially idiomatic in any functional language — it is how FP languages represent data by default. The challenge in FP ports is typically the query interface, not the schema declaration.

diff algorithm note: The core of diff is three set operations: 1. Tables in desired but not in live: CREATE TABLE 2. Columns in desired but not in live table: ADD COLUMN 3. Columns in both but with different specs: ALTER COLUMN (where supported)

In Haskell this is three uses of Data.Set.difference and Data.Map.intersectionWith. The entire diff is computable with standard set operations on the schema data structures.

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