Duplicate(err) names a write refused for an existing key on either engine, so a store stops reading SQLite's error text, which PostgreSQL does not share. A table keyed by org_id alone is upserted naming no key. Co-authored-by: Hanzo Dev <[email protected]>
19 KiB
Changelog
All notable changes to github.com/hanzoai/orm are documented here.
The format is loosely Keep a Changelog and versioning follows SemVer.
v0.6.38
Added
tenant.Duplicate(err)— a write refused for an existing primary or unique key, on either engine (SQLSTATE 23505, or SQLite's constraint codes). A store maps "already exists" from this instead of an engine's error text, which PostgreSQL and SQLite do not share.- One row per org. A table whose key is
org_idalone is upserted, andCreateIfAbsent-ed, naming no key; the conflict target isorg_id. A table with a key still refuses a write that names none.
v0.6.37
Changed
pgtestis public (wasinternal/pgtest). A store built onorm/tenantlives in another module and has to hold its own behaviour on both engines; it starts a throwaway PostgreSQL the same way orm's suites do.
v0.6.36
Added
orm/tenant— shared tables, one org per statement, on SQLite and PostgreSQL. Tenancy is a column: every table carriesorg_idfirst, leading its primary key and every index, and the org comes from the context through the resolver the store is opened with — no call takes one as an argument.- The builder refuses a statement whose context names no org before any SQL
exists; constrains every table a statement names — the one it reads in
WHERE, every join in its ON, every subquery; takes an insert's org from the
context and refuses a row naming another; never lets an update move a row
between orgs; and leads every upsert's conflict target with
org_id. Conditions are a closed set of constructors and identifiers are validated, so there is no raw SQL for a caller to reach past the constraint with. - On PostgreSQL the migrator also enables and forces row-level security with a
tenant policy (
org_id = app.org) and a platform policy, and proves the table on every open: owner, forced RLS, exactly those two policies, row privileges and no TRUNCATE, key and every index leading withorg_id. Each statement runs in a transaction that sets its role andapp.org, both transaction-local. The login is NOINHERIT and holds no privilege of its own;Openrefuses a superuser, BYPASSRLS, an inheriting login, or roles other than the onesProvisionmakes. DB.Platform(ctx, why)is the one cross-org entry: audited, its own role, refused from a context that names an org — at entry and on every statement.DB.Txis SERIALIZABLE on PostgreSQL and reruns on a serialization conflict, so a read-modify-write written for a single-connection SQLite file stays atomic.ReadOnly(ctx)routes a read to the replica.- One suite, both engines:
tenantruns every behaviour against SQLite and a real PostgreSQL started for the test binary (internal/pgtest); row-level security is held on its own with raw SQL on the store's pool. A machine with no PostgreSQL says so by name.
- The builder refuses a statement whose context names no org before any SQL
exists; constrains every table a statement names — the one it reads in
WHERE, every join in its ON, every subquery; takes an insert's org from the
context and refuses a row naming another; never lets an update move a row
between orgs; and leads every upsert's conflict target with
Fixed
Typed[T].Countcounts the rows its query reads. It swapped the projection forCOUNT(*)and kept the rest, so an ordered query failed on PostgreSQL ("must appear in the GROUP BY clause") and passed on SQLite, a grouped one answered one group's count, and LIMIT and DISTINCT were ignored. It now counts the query as a subquery, order dropped. Found by running the relational tests on PostgreSQL, which they now do on every run.replicated/go.modwas untidy against the root module (zap-proto/http), socd replicated && go build ./...— a CI gate — failed on main.
v0.6.31
Fixed
- A put makes its row live.
Deleteleaves a tombstone (deleted = 1) rather than removing the row, and the put upsert replaced onlydata— so a write to an id that had once been deleted stored the entity, returned no error, and left the tombstone standing. Every reader filtersdeleted = 0, so the entity was unreadable byGet, by any query, and by any count, and writing it again changed nothing: the id was permanently unusable and nothing said so.Put,PutMultiand the transaction'sPuteach carried their own copy of that statement and each was missing the rule, so they are now ONEputSQL. The ZAP SQL backend had the same upsert and now carries the same rule.- The revival is guarded by kind, exactly as
createIfAbsentSQLalready guards it: the id column is a bare primary key, so a row of another kind can hold an id, and reviving that row would publish one kind's tombstone as another kind's entity. Same kind, live; other kind, the tombstone stands. - It is a credential bug wherever the entity is one. In hanzoai/iam a user's API
key row is named deterministically, so
revokethenmintwrote onto the tombstone: the mint returned a validsk-and the resolver answeredkey_unknownfor it, forever. put after delete brings the key backis now part of the cross-backend conformance contract, so a backend cannot answer this differently.
- The revival is guarded by kind, exactly as
v0.6.17
Removed
db.Manager,db.Config,db.DefaultConfig,db.Layer(+LayerUser,LayerOrg,LayerDatastore,LayerAll).Managerwas a second, worseNamespaces: an unboundedmap[string]DBper hardcoded tenant kind, handles closed only byManager.Close(), no materialisation, no eviction — the exact shapeNamespacesexists to prevent, shipped next to it.Configexisted only to configureManager, and itsSQLitedefaults had already drifted from the onesconfigPragmasactually applies (CacheSize-16000 vs -32000), so it was a second answer that gave a different one.Layerhad no reader anywhere. Nothing in the fleet imported any of them;SQLiteConfigandSQLiteDBConfig— the ones that are used — are untouched.
Changed
Namespaces.Open() int→Namespaces.Held() int. It reports how many databases are currently held open;Openon the same type is the opener inNamespacesConfig, and one word cannot be both a count and an action.- The namespace rename is finished below the type names.
Namespace/Namespaces/NamespacesConfig/OpenNamespacelanded in v0.6.13–v0.6.16 with no changelog entry, but the parameters, fields and prose underneath still said "tenant" and "registry" — including a doc comment naming aDomethod that no longer exists. Identifiers are nowns, the entry field isns, and the receiver isn. No behaviour change. replicated/go.modgainsreplace github.com/hanzoai/orm => ../. The two modules move together in one repo, so a build from a checkout now tests the tree instead of the last published orm — without it a change to a seam here goes green against the old orm and breaks on release, which is howHeldwas first missed. Go honoursreplaceonly in the main module, so a consumer's build is unaffected and still resolves the required version.
v0.6.16
Changed
github.com/zap-proto/httpv0.2.2 → v0.3.0. Wire break, not an API break: v0.3.0 replaces the JSON header map inside the ZAP frame with length-prefixed name/value pairs —[u32 count], then[u32 nameLen][name] [u32 valueLen][value]— making header decode 0 allocs/op. The exportedzaphttpsurface is unchanged, sodb/zap.goneeded no edit; the ZAP driver is byte-compatible with the new frame because the transport owns the codec.- A v0.2.x peer cannot talk to a v0.3.0 peer. Both ends of a ZAP-HTTP
connection must cross together. This module ships the client half; the
hanzo ZAP backends expose no listener yet (see
LLM.md), so nothing is live on the old wire today. Consumers still pinned to orm ≤ v0.6.15 must move with their peers, not ahead of them. - Verified end to end, not by version arithmetic:
zap_adapter_test.goround-trips create → get → update → delete against a real in-processzaphttp.ServerKV backend over a loopback socket, green under-race.
- A v0.2.x peer cannot talk to a v0.3.0 peer. Both ends of a ZAP-HTTP
connection must cross together. This module ships the client half; the
hanzo ZAP backends expose no listener yet (see
github.com/zap-proto/gov1.1.0 → v1.3.0 (indirect, the frame runtime). v0.3.0 requires only v1.1.0; carrying v1.3.0 keeps orm from forcing a peer that also importszap-proto/zipv1.10.0 down under MVS.
zap-proto/zip is deliberately not a dependency: orm imports none of it, so
go mod tidy drops it and its ~15-package tree (goja, esbuild, fiber) rather
than charging every ORM consumer for an HTTP framework. See "Why orm and not
zip" in LLM.md.
replicated/v0.1.2
Changed
- Pin
github.com/hanzoai/ormv0.6.15 → v0.6.16, carryingzap-proto/httpv0.2.2 → v0.3.0 (indirect). No code change —replicatednever touches the ZAP driver — but the pin is load-bearing: a service that imports onlyorm/replicatedresolves orm through this go.mod, so leaving it on v0.6.15 would hold that service'szap-proto/httpat v0.2.2 under MVS and strand it on the old frame. A submodule's pin is a wire decision for anyone downstream of it, not bookkeeping.
v0.6.10
Added
github.com/hanzoai/orm/replicated(replicated/v0.1.0) — the tenant registry's files made durable.replicated.Registry(cfg)isdb.NewRegistrywith the three durability seams filled in byhanzoai/replicate:OnOpenstarts a WAL stream for THAT tenant file at<base>/<type>/<id>,OnCloseflushes and stops it,Materializerestores the file when a node is asked for a tenant it does not have on disk. One stream per FILE, never one over the directory — per-file lifetime is what lets a node evict a tenant and any other node re-materialise it.- It is a separate module so
orm's dependency graph is unchanged:hanzoai/replicatebrings the AWS/GCS/Azure SDKs with it and only the consumers that want durability should pay for them. - Configuration is
REPLICATE_S3_ENDPOINTand friends, or an explicitRemoteURL(any replica URL:s3://,file://, …). With neither set it degrades to the current local-only registry rather than failing, so a laptop and a cluster construct it the same way. Encryption is fail-closed: a configured destination with noREPLICATE_AGE_RECIPIENTfails the tenant open instead of streaming customer data in the clear. - Requires
hanzoai/replicatev0.9.6, whose newStream/Restore/ReplicaURLentry points make the destination a per-file value instead of a process-wide environment variable.
- It is a separate module so
v0.6.9
Changed
db.Registryis parameterised on its handle type:Registry[T io.Closer],RegistryConfig[T],Do(ctx, Tenant, func(T) error). The registry calls exactly one method on a handle —Close— so pinning it to this package'sdb.DBwas incidental: it forced every owner of per-tenant files to adopt this package's entity API to get bounded lifecycle, which is whyhanzoai/commercegrew its own unboundeduserDBs/orgDBsmaps instead of reusing this.Registry[db.DB]here,Registry[yourDB]there, one implementation either way.RegistryConfig.Openis now required, and the SQLite opener is exported asdb.OpenSQLiteTenant. A defaultOpencan only be right for oneT, and a silently-wrong handle type is worse than a missing one.
v0.6.8
Added
db.Registry— per-tenant database lifecycle: open on demand, bound byMaxOpen, evict the coldest idle handle,Materializea file from object storage on a local miss,OnOpen/OnClosebracket a handle's life (the seam WAL replication attaches to).Do(ctx, Tenant, fn)is the only way in, so a handle cannot leak.Tenant{Type, ID}separates keyspaces that share an id.
v0.6.7
Added
orm.AdaptSQLite(conn *sql.DB) (DB, error)anddb.AdaptSQLDB(conn *sql.DB) (*SQLiteDB, error)— layer the ORM's typed-record model over an already-open*sql.DBthe CALLER owns, instead of opening one from a path. This is the seam for a store whose SQLite file is opened elsewhere (pragma'd, encrypted at rest, single-writer) and handed in as a*sql.DB: the ORM manages records in the file without owning the file's lifecycle. The single connection serves reads and writes (serialized bywriteMuas usual);initSchemaruns so_entitiesexists; andCloseis a no-op — the caller closes the connection it opened. A nil connection errors. Additive:NewSQLiteDB(open-from-path) is unchanged.
Added
DB.CreateIfAbsent(ctx, key, src) (created bool, err error)— a race-safe, first-writer-wins conditional insert on both the rootorm.DBand the innerdb.DB/db.Transactioninterfaces and every impl (SQLiteDB, sqliteTransaction, ZapDB, zapTransaction, dbAdapter, txAdapter, mockDB).created=trueiff this call inserted the row;created=falseiff a live row already held the key, left untouched. The non-upsert counterpart toPut: it never overwrites a live row, so the winner is immutable and a losing caller reads the existing row back deterministically — the CAS primitive constraint-based onboarding needs (create-org-if-absent, claim-once rows).- SQLite reference impl:
INSERT ... ON CONFLICT(id) DO UPDATE ... WHERE deleted=1 AND kind=excluded.kind RETURNING id. "Absent" means no live SAME-KIND row — a same-kind soft-deleted row resurrects — soCreateIfAbsentandGetshare one definition of existence. Atomic under the write mutex, hence race-safe with or without an enclosing transaction (serialized-writer AND autocommit contracts). - Existence is scoped to
(kind, id)with an exact-match id. An id already held by a DIFFERENT kind returns the newErrKindMismatch(re-exported asorm.ErrKindMismatch) rather than a silentcreated=falsethatGetcan't see; resurrection never changes a row's kind. An empty or incomplete stringID returnsErrInvalidKey. Callers must keep each kind in its own stringID keyspace and normalize (case/trim/Unicode) before building the key. - ZAP backends dispatch like
Put: SQLON CONFLICT ... RETURNING(kind-scoped WHERE) over/query, ValkeySET NX, document unique_id; fail-secure (an unrecognized reply errors, never a guessed create). Wire-complete; verified by the env-gated live test once a backend listener is reachable.
- SQLite reference impl:
Reviewed by Red — race core proven (crux 500x, 64-way resurrection single-winner, ctx-cancel no-strand, full interface completeness, fail-secure ZAP). This entry folds the follow-up hardening (empty-id guard, (kind,id) scoping + ErrKindMismatch, caller preconditions). Unreleased — tag on merge.
v0.5.1 (2026-04-21)
Patch release addressing Red review findings P6-C1, P6-H1, P6-H2, P6-M2, P6-M3, P6-M4, and P6-H3 (documentation tone).
Added
orm.SafeHashExp(map[string]any) (query.HashExp, error)validates column-identifier keys against a strict regex before returning aquery.HashExp. Defense-in-depth against the backtick-breakout injection path preserved bydbx.SqliteBuilder.QuoteSimpleColumnName.orm.MustSafeHashExpfor static call sites where invalid keys are a programming bug.orm.ErrNilDBsentinel returned byTyped[T]terminators when the Typed was constructed with a nil*query.DBor nil*query.SelectQuery. No more nil-pointer panics on a hot path.dbx.SelectQuery.Copy()(shipped inhanzoai/dbx v1.16.0) — deep-copy primitive that powers the immutable Typed[T] chain.
Changed
- BREAKING in spirit, additive in API: every
Typed[T]chain method (Where,AndWhere,OrWhere,OrderBy,Limit,Offset) now returns a Typed[T] wrapping a CLONED*SelectQueryinstead of mutating the receiver. Callers that relied on the old mutating behavior will see no functional difference because the returned value was already used for the next step in the chain. Shared*Typed[T]values are now safe to use across goroutines. Typed[T].Count()no longer mutates the receiver's SELECT list. The COUNT(*) rewrite happens on a clone; subsequent.All()/.One()calls return rows with the caller's original projection (P6-C1).Typed[T]terminators (All,One,First,Count) thread the passedctxthrough the underlying*SelectQuery.WithContextso cancellation and deadlines are honored by the driver (P6-M2).orm.Select[T](nil, …)andorm.NewTyped[T](nil)return a Typed[T] whose terminators fail withErrNilDBinstead of panicking (P6-M3).- Bumped
github.com/hanzoai/dbxto v1.16.0 for theSelectQuery.Copyprimitive.
Documented
docs/sql-query.mdnow explicitly warns thatquery.HashExpkeys are concatenated into SQL verbatim and must be developer-controlled; SafeHashExp is the boundary for untrusted input (P6-H2).
Migration posture
orm/queryis the canonical namespace for new code. Existinghanzoai/dbximports continue to work unchanged —orm/queryis a pure re-export via identity type aliases. Direct dbx imports are not broken and do not produce warnings; migration is per-package and per-release, not a flag day (P6-H3).
v0.5.0 (2026-04-21)
Added
- New subpackage
orm/querythat re-exports the SQL AST primitives fromhanzoai/dbxunder the canonical orm namespace. Every type is an identity alias (type X = dbx.X) and every constructor is a thin package-levelvarbound to the dbx function, so values produced viaorm/queryare bit-identical to values produced viadbxand pass through any type boundary without conversion. - New
orm.Typed[T]generic wrapper over*query.SelectQueryproviding type-safeAll / One / First / Countterminators. Construct withorm.Select[T](db, table, cols...)for the common case or withorm.NewTyped[T](sq)to wrap any existing*query.SelectQuery. - Documentation at
docs/sql-query.mdcovering when to use each surface and the mechanical migration pattern for existingdbxcallers.
Changed
- New
github.com/hanzoai/dbx v1.15.0direct dependency. Consumers that previously depended on bothhanzoai/ormandhanzoai/dbxshould flip theirdbximports toorm/queryper the migration guide; thedbxdependency can be dropped once every reference is migrated.
Migration posture
- Direct application-code imports of
hanzoai/dbxstill work; new code should preferhanzoai/orm/query. Thedbxmodule remains the canonical source of truth for the SQL AST;orm/queryre-exports it under the canonical orm namespace so consumers only ever need a single module dependency.
Compatibility
- This release is additive. No existing orm API changed. Existing callers of the Key / Model / Query / entity layer continue to work unchanged.
- Identity aliases guarantee
*dbx.SelectQueryand*query.SelectQueryare interchangeable at every call site, so migrating a package does not break its callers or its dependencies.
v0.4.0 and earlier
See git history. v0.4.0 was a KV / entity-only release.