honest-persist
Schema as data. Migration as diff. No ORM. No migration file chain.
// 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.
// 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])
# 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.
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])
# 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.
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])
// 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.
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})
// 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.
$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]);
# 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.
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])
The concept
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 type | PostgreSQL | SQLite | Turso |
|---|---|---|---|
text | TEXT | TEXT | TEXT |
integer | INTEGER | INTEGER | INTEGER |
boolean | BOOLEAN | INTEGER (0/1) | INTEGER (0/1) |
timestamp | TIMESTAMPTZ | TEXT (ISO 8601) | TEXT (ISO 8601) |
json | JSONB | TEXT | TEXT |
uuid | UUID | TEXT | TEXT |
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
| Requirement | Test |
|---|---|
diff() is pure: same inputs → same plan | Run twice, compare |
apply() is idempotent: run twice, second run produces empty plan | Apply, apply again |
execute() emits hf.persist.query on every call | Instrument and verify |
execute() returns plain dicts matching declared column schema | Check return type |
| Append-only table rejects update/delete with server fault | Test 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/Turso | Insert datetime, select |
raw operation still emits hf.persist.query | Test raw SQL call |
| Schema diff does not produce DROP TABLE or DROP COLUMN automatically | Verify diff output |
Reference
- honest-persist RubyGem Ruby implementation (coming)
- honest-persist-architecture.md Full language-agnostic spec