Changelog¶
All notable changes to Den are documented here. The format is based on Keep a Changelog.
Unreleased¶
0.17.1 — 2026-06-05¶
Fixed¶
- SQLite: enabling FTS on an already-populated collection now backfills the index. Rows saved before the first
den:"fts"registration were invisible toSearchuntil re-saved; first-time FTS setup now indexes existing rows atomically with the table creation. PostgreSQL was never affected.
0.17.0 — 2026-06-02¶
Added¶
den.Marshal,den.LinkFields, and selectiveWithFetchLinks(fields...).den.Marshalis an output JSON marshaller that emits hydratedLink[T]values as their nested object anywhere in the value graph (unloaded links andjson.Marshalstay the bare id — the storage format is unchanged).den.LinkFields[T]enumerates a type's relation fields (JSON name, target collection, slice, eager).WithFetchLinksnow takes optional field names to hydrate only the chosen links. Together they make relation-expansion (?expand=) a thin layer for consumers.den.Replaceandden.PreserveServerFields.Replaceis a full-content replace (PUT) that overwrites a stored row with a client document while preserving Den's server-owned fields (_id,_created_at,_rev, soft-delete audit fields);PreserveServerFieldsexposes that field-copy as a building block. Consumers no longer hand-derive which fields Den owns.QuerySet.SearchRawandden.LiteralFTS5.SearchRawpasses the term straight to the backend's native FTS mechanism (FTS5 query syntax on SQLite,plainto_tsqueryon PostgreSQL) for callers who want operators.LiteralFTS5exposes the literal-terms transform for composing safe strings by hand.mise run cleanandmise run clean-alltasks.cleanremoves build artifacts and generated files (coverage outputs,site/, generated doc pages);clean-alladditionally wipes local SQLite test DBs (*.db,*.db-shm,*.db-wal) anywhere in the tree.clean-alldepends oncleanto avoid copy-paste drift.
Changed¶
QuerySet.Searchis now literal-by-default. The term is treated as plain words ANDed together, with FTS5 operators/punctuation neutralised, so raw user input is safe on both backends (matching PostgreSQL's existingplainto_tsquerybehaviour). On SQLite this means FTS5 query operators in the term are no longer interpreted — use the newSearchRawfor those.
0.16.1 — 2026-05-23¶
Fixed¶
- Top-level
Or()no longer swallows sibling AND-predicates.buildWhereClausesand the FTS-path predicate emitter on both backends wrap each top-level clause in parens so SQLAND > ORprecedence can't reparse(a) OR (b) AND xas(a) OR ((b) AND x). AffectedNewQuery(..., Or(...), Eq(...)), chained.Where()afterOr, andSearch()withOrsiblings. Workarounds using an explicitwhere.Andwrap are no longer required. - SQLite:
time.Timecomparison operators now match the JSON storage encoding. Boundtime.Time/*time.Timevalues are pre-formatted as RFC3339Nano in the value's original location; previously the driver's default form mismatched stored JSON and returned zero rows. Postgres unaffected. - SQLite:
[]bytecomparison operators now match the base64 JSON storage. Bound[]bytevalues are pre-encoded as standard base64; previously the driver bound raw blob bytes that mismatched the stored base64 string. Postgres unaffected. - SQLite:
json.RawMessagecomparison operators now match the inline JSON storage.json.RawMessageshares[]byte's underlying type but itsMarshalJSONinlines the payload — the bind path now passes it through as JSON text. Postgres unaffected.
0.16.0 — 2026-05-23¶
Changed¶
internal/coresplit into themed public sub-packages. The engine is now publicly importable asden/engine, with contract types spread acrossden/backend,den/storage,den/search,den/lock, andden/maintenance. The ULID generator moved alongside asden/idgen. Thedenroot keeps every existing alias and wrapper, soden.XIS<subpackage>.X— custom backends and storage backends can now spell their return types without importingden. No behaviour change.
0.15.0 — 2026-05-20¶
Removed¶
storage/s3backend. Droppedminio/minio-go/v7,gofakes3, and their transitives — 18 module-graph entries gone in total. The package wasn't used by any current consumer. Breaking for anyone who importedgithub.com/oliverandrich/den/storage/s3: implement theStorageinterface against your S3 client of choice. Thefile://backend, theStorageinterface, anddocument.Attachmentare unaffected.
Changed¶
- In-tree monotonic ULID generator. Dropped
oklog/ulid/v2; Den now produces IDs frominternal/idgenwith strict intra-millisecond monotonicity. Wire format is unchanged (still 26-char Crockford base32 ULID). Fixes a latent ordering bug — the previous call used the non-monotonic constructor, so two inserts in the same ms could sort unpredictably underSort("_id")and cursor pagination. - JSON encoding back to
encoding/json. Droppedgoccy/go-json; stdlib has closed the practical gap and DB I/O dominates the cost. One less third-party dep in the critical path. - Storage encode seam uses a pooled
EncoderwithSetEscapeHTML(false). JSONB columns have no browser to defend, so&/</>in stored fields (URLs, markup) keep their literal bytes instead of being expanded to&/</>. Wire-compatible with existing rows — stdlibjson.Unmarshaldecodes both forms identically.
0.14.0 — 2026-05-20¶
Added¶
den:tags on nested struct fields.den:"index"/unique/unique_together/index_together/ftsnow flow through to fields of named-struct and pointer-to-struct fields at arbitrary depth on both backends — see Nested Field Indexes.
Fixed¶
den.Revertnow zeroes the doc before decoding the snapshot. Previously fields absent from the snapshot JSON (nil pointers withomitempty, zero-valued nested structs) silently retained their current in-memory value — a behaviour change for callers that relied on the undocumented merge semantics.
0.13.2 — 2026-05-17¶
Documentation-only release. Source behaviour is identical to v0.13.1 — this tag exists so the published docs site picks up audit-driven corrections.
Fixed¶
Settingsstruct doc dropped phantomOmitEmpty/NestingDepthPerFieldfields that never existed in source.- Hydration uniformity in
queries.mdandrelations.md: replaced incorrect "single-level" / "Iter doesn't recurse" claims with the actual behaviour — every read terminal recurses to the same depth, pinned byTestEagerLink_*. - Errors reference filled in missing
ErrUnsupportedSchemeand*DanglingLinkError; correctedvalidate.FieldErrorfield names; broadenedErrLockedtriggers. migrate.NewRegistrysignature corrected to show(opts ...Option); addedWithLoggerrow.- Three broken recipe anchors repointed to their actual headings.
0.13.1 — 2026-05-17¶
(v0.13.0 was withdrawn before publication; this is the same change set under a fresh tag.)
Changed¶
Register/WithTypes/dentest.MustOpen*accept...document.Documentinstead of...any. Non-documents are now a compile error rather than a runtimeanalyze: ...failure. Migration: callers passing&T{}forTembeddingdocument.Baseare unchanged; replace[]any{…}with[]document.Document{…}where the slice forms are used.core.Registerandcore.DB.pendingTypestightened in lockstep.
0.12.1 — 2026-05-16¶
Changed¶
- Dev tooling migrated from
justfileto mise..mise.tomlpins the Go toolchain and dev tools; tasks live in[tasks.*]ormise-tasks/.just <recipe>→mise run <task>. Aligns Den with the rest of the Burrow ecosystem. - Write path skips
validate.Documentfor types withoutvalidate:tags. Detected once at Register time by scanning the registered type's reachable type tree (including named struct fields, pointers, slices, maps). Behavior is unchanged for users — tagged types still validate on every Save; theValidator.Validate(ctx)hook is untouched. Internal optimization:BenchmarkRW_SQLite_Insertallocs drop 49 → 31/op (-37%) on a tagless fixture. Link[T].UnmarshalJSONfast-path for escape-free IDs. ULID-shaped (and other escape-free) link bodies are taken directly from the JSON bytes instead of re-entering the goccy decoder. Payloads with escapes or unusual shape still fall through tojson.Unmarshal. Read-path optimization:BenchmarkRW_SQLite_Iter1000allocs drop ~1000/op (-3.5%) on docs carrying one Link per row.Link[T].MarshalJSONfast-path symmetric toUnmarshalJSON. When the ID needs no JSON escaping (",\, or any control byte), the encoded form is built directly as"+ ID +"instead of routing throughjson.Marshal. Anything that would force an escape falls through, so the byte-for-byte output stays identical. Write-path optimization: -1 alloc/op per Link onBenchmarkRW_SQLite_Insert(scales linearly with batch size).- S3 backend tests run against an in-process
gofakes3server. Replaces thetestcontainers-goMinIO container, dropping Docker as a developer prerequisite and removing the entiremoby/containerd/dockerindirect-dep tree fromgo.mod. Test runtime drops from container-boot seconds to sub-second. - SQLite backend test surface widened with go-sqlmock. Mirrors the pgxmock layer for Postgres —
backend/sqlite/mock_test.godrives the error paths the file-backed driver can't easily trigger:getStmtsPrepare failures (each of the three statements),Put/Delete/Query/Count/Exists/Aggregate/GroupByexec/query errors, ErrNoRows →ErrNotFoundmapping, mid-stream iterator errors,DropIndexfailures. SQLite operates on*sql.DBdirectly so the mock slots in without a production interface. Three-tier test convention now documented on the SQLite side too (backend/sqlite/doc.go). - Postgres backend test surface widened with pgxmock. A new
pgPoolinterface inbackend/postgreslets apgxmock-backed pool substitute for*pgxpool.Poolin tests, closing the coverage gap on advisory-lock SQL emission,FOR UPDATElock-mode SQL (with55P03 → ErrLockedpropagation), mid-stream iterator errors, and pool-acquire failures. No production behavior change; pgxmock is a test-only dependency. Three-tier convention documented inbackend/postgres/doc.go(pgxmock for error paths, parity_test.go for cross-backend behavior, real PG for concurrency-driven failures).
Fixed¶
QuerySet.GroupByon PostgreSQL returned wrong results when combiningWhere(...)withAfter()/Before()cursors.buildGroupBySQLdiscarded the next-placeholder index frombuildWhereClausesand hardcoded the cursor's first placeholder to$1, colliding with the first WHERE arg — pgx then bound both placeholders to the WHERE arg, so theid > $N/id < $Ncursor filter comparedidagainst the wrong value (silently returning the wrong subset). ScalarQuerySet.Count/Existswere not affected; the bug was specific toGroupBy. Cursor-pagination parity tests now pin all three builders.
0.12.0 — 2026-05-15¶
The doc-in-hand and by-condition write surfaces collapse into one verb each. Save (with SaveAll/DeleteAll/RefreshAll for slices) is the only top-level write entry point — its branch is decided by the document's ID, not by the caller picking Insert vs Update. Everything by-condition lives on QuerySet as a chainable terminal. Tag validation is unconditionally always-on. One small marker interface (document.Document) gates validate.Document at compile time.
Added¶
Save/SaveAll/DeleteAll/RefreshAllas the doc-in-hand top-level entry points.Saveinspects the document ID and routes to the insert or update path; the*Allhelpers apply the same per-doc operation across a slice inside a single transaction (fail-fast).QuerySet.Delete/UpdateOne/UpsertOne/GetOrCreate/BackLinkswrite terminals. By-condition mutations now compose with the same chain (Where(...).IncludeDeleted()…) as reads.QuerySet.Deletedrains its iterator in chunks of 1000 rows before issuing per-row writes — fixes a latent pgx "conn busy" failure under cursor pinning and keeps memory bounded on unbounded-size match sets.den.NewID()as the public entry point for generating a fresh ULID outside a save (worker IDs, correlation IDs, pre-assigned doc IDs, deterministic test fixtures).document.Documentmarker interface. Any type embeddingdocument.Basesatisfies it automatically. Used as the parameter type onvalidate.Documentso non-document structs fail at compile time.
Changed¶
- Hydration depth is uniform across every read terminal.
FindByID,Refresh,Iter, and the upsert insert branch used to be silently single-level even when the caller had requested deeper recursion (e.g. viaWithNestingDepth); they now route through the same batched resolver asAll/AllWithCount/Searchand recurse up tonestDepth(ordefaultNestingDepth=3for the non-QuerySet reads).FetchAllLinkskeeps its fixed one-hop contract — callers needing transitive hydration use a QuerySet terminal. Affects only docs with nested eager-tagged orWithFetchLinkslink chains. validate.Struct(any)→validate.Document(document.Document). The function only ever validated Den documents; the typed signature rejects non-document structs at compile time. Use go-playground/validator/v10 directly for arbitrary struct validation. Migration: rename the call.
Removed¶
-
All deprecated top-level CRUD wrappers.
Insert,Update,InsertMany,UpdateMany,DeleteMany,FindOneAndUpdate,FindOneAndUpsert,FindOrCreate,BackLinks,BackLinksFieldare gone. Migration:Removed Replacement Insert(...),Update(...)Save(...)InsertMany(...)SaveAll(...)UpdateMany(...)NewQuery[T](s, conds...).Update(ctx, fields)DeleteMany(...)NewQuery[T](s, conds...).Delete(ctx)FindOneAndUpdate(...)NewQuery[T](s, conds...).UpdateOne(ctx, fields)FindOneAndUpsert(...)NewQuery[T](s, conds...).UpsertOne(ctx, defaults, fields)FindOrCreate(...)NewQuery[T](s, conds...).GetOrCreate(ctx, defaults)BackLinks[T](...)NewQuery[T](s).BackLinks(field, id).All(ctx)BackLinksFieldno direct replacement — pick the field name explicitly -
InsertManyscaffolding.PreValidate/ContinueOnError/MaxRecordedFailuresoptions,InsertManyError/InsertFailuretypes,ErrIncompatibleScope/ErrIncompatibleOptionssentinels.SaveAllis fail-fast and rolls back on the first error; validation is always-on so the pre-validate opt-in had nothing to opt into. den.WithTagValidatoroption andvalidate.WithValidation()helper. Tag validation is now unconditionally always-on. Migration: drop the call fromOpen/OpenURL. Struct tags stay unchanged.den.Encoderinterface andBackend.Encoder()method. Single concrete implementation across all backends (goccy/go-jsonMarshal/Unmarshal); inlined asdb.encode/db.decode. Den is JSON-only by design.den/idsubpackage anddocument.NewID()helper. Three hops to generate one ULID was two too many. Body now lives inden.NewID(); callers replaceid.New()anddocument.NewID()withden.NewID().
0.11.2 — 2026-05-03¶
Added¶
SeekableStorageoptional Storage capability. Backends with cheap random access can additionally implementOpenSeekable(ctx, att) (io.ReadSeekCloser, error)so callers (e.g. an HTTP handler usinghttp.ServeContent) can serve Range and conditional-GET requests directly. The file backend implements it (it returns*os.Fileeither way); S3 deliberately doesn't, because every Seek would round-trip another HTTP GET — remote-storage Range support belongs at the URL layer (pre-signed URLs).
0.11.1 — 2026-05-03¶
Added¶
den.ErrUnsupportedSchemeandstorage.ErrUnsupportedScheme— typed sentinels exported by bothOpenURLpaths so callers can detect missing-backend-import errors viaerrors.Is(...)instead of scraping the message text. Both error messages are unchanged for backward compatibility.
0.11.0 — 2026-04-27¶
Breaking Changes¶
-
Validator.Validatenow takes acontext.Context— the interface signature changed fromValidate() errortoValidate(ctx context.Context) error, matching every other Den hook. Validators that need to honor cancellation, hit a database, call out to another service, or attach to a tracing span now have ctx in scope without capturing one from outer scope. Update implementations:// before func (a *Article) Validate() error { ... } // after func (a *Article) Validate(ctx context.Context) error { ... }Pure validators that don't use ctx can take it as
_ context.Context. Resolves the long-standing asymmetry where Validator was the only hook on the document-struct surface that didn't carry context. -
FindOneAndUpdatenow requires a unique match — previously the function silently picked the first row when conditions matched more than one document. It now returns the newErrMultipleMatchesinstead. The conditions parameter has also moved from variadicwhere.Conditionto a[]where.Conditionslice to make room for trailingCRUDOptions. Update call sites: -
GroupByRow.Key string→GroupByRow.Keys []stringandBackend.GroupBy(groupField string, ...)→Backend.GroupBy(groupFields []string, ...). Internal interface contracts only — the publicQuerySet.GroupByAPI stays backward-compatible through variadic arguments. External backend implementers (none known) must adapt to the new signatures;Keysholds one entry per requested group field in call order. -
Cursor + offset pagination now rejected — chaining
After/BeforewithSkipon a QuerySet returns the newErrIncompatiblePaginationat every terminal (All,First,Iter,Count,Search,Project, aggregates,GroupBy.Into). Previously the combination ran with undefined semantics. DropSkipfrom any chain that uses cursor pagination, or drop the cursor. -
Hard-delete with attachments now requires a configured Storage — calling
den.Delete(ctx, db, doc, den.HardDelete())(or aLinkDeletecascade that reaches such a doc) on a document carryingdocument.Attachmentbytes returnsErrValidationwhen noStoragewas installed viaWithStorage. Previously the DB row was removed and aslog.Warnorphaned the bytes — now the contract matches the godoc onWithStorage. Install a Storage at Open, or soft-delete the document instead. -
Backend.Begin(ctx, writable bool)→Backend.Begin(ctx)— internal backend interface signature change. Neither backend honored thewritablehint; external backend implementers (none known) must drop the parameter. A typed option can reintroduce read-only tx mode when there's concrete demand. -
storage.OpenURL(dsn, urlPrefix string)→storage.OpenURL(dsn string),OpenerFunc(location, urlPrefix string)→OpenerFunc(location string)— the URL prefix moves into the DSN as aurl_prefix=…query parameter, joining the same backend-specific config pattern asregion,presign_ttl, andendpoint. The S3 backend always ignored the second argument anyway (S3 returns absolute URLs); the uniform single-arg signature on both the public API and the internal opener removes that smell. Update call sites:// before storage.OpenURL("file:///uploads", "/media/") storage.OpenURL("s3://bucket?region=eu-central-1", "/media/") // /media/ was ignored // after storage.OpenURL("file:///uploads?url_prefix=/media") storage.OpenURL("s3://bucket?region=eu-central-1") // no vestigial argBackends that honour
?url_prefix=(file, and any future GCS/Azure) call the new exported helperstorage.URLPrefixFromLocation(location string) (stripped, prefix string)from theirOpenerFuncto extract it; the helper handles encoding edges (slashes in value, empty value, malformed query) uniformly. Backends that ignore url_prefix (S3) skip the call — their existingurl.Values.Get("known_key")-style parsers silently drop the unknown param. Empty value (?url_prefix=) is treated the same as not specified.Backend implementers update their
OpenerFunc:// before func init() { storage.Register("myscheme", func(location, urlPrefix string) (den.Storage, error) { return New(location, urlPrefix) }) } // after — URL-prefix-aware backend func init() { storage.Register("myscheme", func(location string) (den.Storage, error) { path, urlPrefix := storage.URLPrefixFromLocation(location) return New(path, urlPrefix) }) } // after — absolute-URL backend (S3-style); url_prefix in DSN silently ignored func init() { storage.Register("myscheme", func(location string) (den.Storage, error) { return New(parseDSN(location)) }) }
Added¶
-
storage/s3Storage backend —github.com/oliverandrich/den/storage/s3package backed byminio-go, works against real S3 and any S3-compatible service (MinIO, localstack). Optional: Den core does not import the package, so binaries that don't_-import it pay nothing for the s3 code path (the linker drops it via dead-code elimination). DSN forms3://<bucket>[/<prefix>][?region=…&endpoint=…&secure=true|false&presign_ttl=15m]; credentials come fromAWS_*env vars or the IAM instance profile via the standard chain.Storage.URLreturns SigV4-presigned GET URLs (default TTL 15 min, override viapresign_ttl=ors3.WithPresignTTL). Tested against MinIO viatestcontainers-go. -
FindOneAndUpsert[T]— atomic find-or-create-then-update in a single transaction. Returns(doc, inserted, err)so callers can branch on whether the document was new. Hooks fire on exactly one path: Insert hooks on miss, Update hooks on hit. Soft-deleted matches are skipped by default; passIncludeDeleted()to update them in place. Concurrent upserts on the same missing row rely on a unique constraint to fail one inserter withErrDuplicate— there is no internal retry. -
IncludeDeleted()CRUDOption — opts lookup-style operations into considering soft-deleted documents. Honored byFindOneAndUpdateandFindOneAndUpsert. Mirrors the existingQuerySet.IncludeDeleted()modifier so the same name works for both query-driven reads and CRUD-style lookups; the two are separate identifiers (a method on QuerySet vs a top-level function), but they share the name on purpose. -
Soft-delete audit fields —
document.SoftDeletegained optionalDeletedByandDeleteReasonstrings. Populate them via two new CRUDOptions:Both default to empty with
omitempty, so existing data stays compatible. Silently no-ops on theHardDelete()path and on types that do not embeddocument.SoftDelete. -
BeforeSoftDeleter/AfterSoftDeleterhook interfaces — fire only on the soft-delete path. Ordering:BeforeDelete → BeforeSoftDelete → [write] → AfterSoftDelete → AfterDelete.BeforeDelete/AfterDeletestill fire for both soft and hard deletes, so existing hook code is unaffected. Use the soft-only pair for audit-log side effects that should not run onHardDelete(). CollectionMeta.HasChangeTracking— new bool that reports whether a registered collection implementsdocument.Trackable(typically via thedocument.Trackedembed). MirrorsHasSoftDeleteandHasRevisionso tooling walkingMeta[T]can detect change-tracking collections without poking at the struct itself.CollectionMeta.HasRevision— new bool that reports whether a registered collection opts into revision tracking viaDenSettings().UseRevision. Rounds out theHasSoftDelete/HasChangeTrackingtriad.ErrMultipleMatches— returned when a single-document lookup matches more than one row.InsertManynow accepts...CRUDOption— backward-compatible signature change. Two new options ride along:PreValidate()runs the full insert hook + validation chain on every document before opening the write transaction. A late-failing document fails the batch without writing anything.BeforeInsert/BeforeSave/Validatefire exactly once per document — the pre-pass caches the encoded bytes and the in-transaction commit only performs the Put +AfterInsert/AfterSave. CombiningPreValidate()withWithLinkRule(LinkWrite)disables the caching optimization (cascade must run inside the tx), so hooks fire twice on that specific combination.ContinueOnError()writes each document in its own short-lived transaction and returns an*InsertManyErrorlisting per-document failures by input index. Trades cross-document atomicity for partial commit. Honorsctxcancellation between documents. ReturnsErrIncompatibleScopewhen called inside a*Tx; returnsErrIncompatibleOptionswhen combined withPreValidate.
InsertManyError— new struct error type carrying[]InsertFailure{Index, Err}. ImplementsUnwrap() []errorsoerrors.Istraverses every wrapped failure.ErrIncompatibleScopeandErrIncompatibleOptions— new sentinels for option/scope mismatches.ErrIncompatiblePagination— new sentinel surfaced when cursor (After/Before) and offset (Skip) pagination are combined on the same QuerySet.MaxRecordedFailures(n)CRUDOption +InsertManyError.Truncated/.TotalFailures—InsertManywithContinueOnError()now caps the recorded failure list at 100 by default to bound memory on large bad batches. Override viaMaxRecordedFailures(n)(0 = unlimited).TotalFailuresalways reports the uncapped count;Truncatedflags a sampled list.errors.Is/Aswalk only the recorded entries. CombiningMaxRecordedFailureswith a non-ContinueOnErrorbatch returnsErrIncompatibleOptions.-
Multi-key
GroupBy—qs.GroupBy(fields ...string)now accepts more than one field. Target structs declare positional slots withden:"group_key:N":type Stats struct { Category string `den:"group_key:0"` Region string `den:"group_key:1"` Count int64 `den:"count"` Total float64 `den:"sum:price"` } qs.GroupBy("category", "region").Into(ctx, &stats)Single-field callers keep using
den:"group_key"unchanged (treated as slot 0). Invalid tag shapes — missing slots, duplicate slots, mixed unindexed + positional tags, out-of-range slots — are caught pre-query with a clear error. -
migrate.Registryobservability hook —migrate.NewRegistry(migrate.WithLogger(l))routes migration lifecycle events through a*slog.Logger. Default isslog.Default(). Emitted events:migration_start,migration_success(withduration_ms),migration_failure(withduration_msanderror), andensure_table_failure(fires at most once via the Registry's stickysync.Once). Every event carriesversionanddirection(up/down). Errors still bubble up the call chain — logging is additive, not a replacement. -
ORDER BY + LIMIT on
GroupBy.Into— grouped results are now sortable and paginatable server-side.qs.Sort("category", den.Asc)sorts by a group key (non-key field returns an error — useOrderByAgg); newGroupByBuilder.OrderByAgg(op, field, dir)sorts by an aggregate expression.qs.Limit(n)/qs.Skip(n)cap / offset the group rows. Combines into Top-N queries:Previously both SQLite and PostgreSQL ignored
SortFields/LimitN/SkipNin theirbuildGroupBySQL, forcing callers to sort and trim in Go. -
den.Save[T]insert-or-update helper — branches ondoc.ID == "": empty →Insert, populated →Update. Convenience for the common case where the caller doesn't want to think about whether the row already exists. Options pass through; hooks fire on whichever branch runs. Trade-off note in the godoc: a stale-revUpdatewould have failed withErrRevisionConflict, but an empty-IDSaveinstead silently routes toInsert— reach for explicitInsert/Updatewhen conflict semantics matter. den.UpdateMany[T]top-level helper — discoverable next toInsert/Update/DeleteManyinstead of buried underQuerySet.Update. Pure shim overNewQuery[T](s, conditions...).Update(ctx, fields); semantics inherited from QuerySet.Update (per-row hooks, fail-fast, SetFields key validation, transaction wrapping).den.FetchLinkField[T]— typed alternative toFetchLink(doc, "fieldname"). Pass the*Link[T]directly; no string lookup, immune to JSON-tag renames on the parent struct. Same idempotency contract (no-op when the link's ID is empty orLoadedis already true).FetchLinkstays as-is; godoc points at the typed variant as preferred.den.BackLinksField[H, T]— typed alternative toBackLinks[H](ctx, db, "field", id). Identifies the link field by walking H's struct for a uniqueLink[T]field; no string field name. Errors clearly when the holder has zero, multiple, or only-sliceLink[T]fields — pointing at the string-basedBackLinks(or a manualContainsquery for slice-link cases) for those edges. Same call shape and result type as the original.den.FindOrCreate[T]— find-or-create-with-defaults shorthand. Returns the existing row if conditions match, otherwise insertsdefaults. Existing rows are NEVER modified — that's the contract that distinguishes it fromFindOneAndUpsert(which can also apply post-find field updates). Same(doc, inserted, err)shape, same atomicity, sameErrMultipleMatcheson non-unique conditions.-
den:"eager"struct tag +WithoutFetchLinks()modifier (QuerySet + CRUDOption) — declare per-field "always hydrate this link by default" on the schema, mirroring Django'sselect_relatedon a default queryset.Link[T]and[]Link[T]fields taggedden:"eager"are hydrated automatically by every Den read API:QuerySet.All/AllWithCount/Search/Iter, plus the CRUD-style readsFindByID,FindByIDs,Refresh,BackLinks,BackLinksField,FindOneAndUpdate,FindOneAndUpsert, andFindOrCreate. Untagged links stay lazy. Three modes:- default (no modifier) — hydrate eager-tagged fields only
WithFetchLinks()— QuerySet only; hydrates every link, eager or notWithoutFetchLinks()— opt-out, available as both a QuerySet modifier and a CRUDOption (den.WithoutFetchLinks()); hydrates nothing, even eager fields
type House struct { document.Base Door den.Link[Door] `json:"door" den:"eager"` Owner den.Link[Person] `json:"owner"` // stays lazy } // QuerySet path houses, _ := den.NewQuery[House](db).All(ctx) // doors hydrated, owners not houses, _ = den.NewQuery[House](db).WithFetchLinks().All(ctx) // both houses, _ = den.NewQuery[House](db).WithoutFetchLinks().All(ctx) // neither // CRUD path — same default semantics, same opt-out h, _ := den.FindByID[House](ctx, db, id) // door hydrated h, _ = den.FindByID[House](ctx, db, id, den.WithoutFetchLinks()) // not hydratedThe batched paths (
All/AllWithCount/Search, plusFindByIDs/BackLinks) recurse up tonestDepth(default 3); the per-row paths (Iter,FindByID,Refresh,FindOneAndUpdate,FindOneAndUpsert) are strictly single-level — they hydrate the direct eager fields but do not descend into the loaded targets' own eager links.Itertherefore costs N+1 for eager fields by construction; prefer.Allwhen transitive hydration matters.Eager hydration is soft-delete-blind: a link to a soft-deleted target loads the soft-deleted record (matching
FindByID-by-ID's contract). Detect vialink.Value.IsDeleted().den:"eager"placed on a field that is notLink[T]/[]Link[T]is rejected atRegisterwithErrValidation, mirroring the existing register-time guards forindex/unique/ftson incompatible field types. -where.AnyOf[T]typed-slice spread — closes theField("id").In(typedSlice)footgun where a typed slice silently matched against the literal slice value. Generic overT, returns[]anyfor spreading:where.Field("id").In(where.AnyOf(stringIDs)...). Type inference picks T from the argument; no explicit type parameter at the call site. Documented as a warning callout in queries.md and a subsection in the operators reference. - Constants for reserved JSON field names —den.FieldID,den.FieldCreatedAt,den.FieldUpdatedAt,den.FieldRev,den.FieldDeletedAt,den.FieldDeletedBy,den.FieldDeleteReason. Use these whenever you'd otherwise type the underscore-prefixed string intowhere.Field,Sort,SetFields,After/Before, or aden:"from:..."tag — refactor-safe, IDE-discoverable, no typos. The string values are unchanged so storage stays binary-compatible. Documented under "Reserved JSON Field Names" in the Struct Tags reference.
Changed¶
QuerySet.Iterchecksctx.Err()before each row — cancellation now terminates the iteration within at most one row, regardless of how aggressively the backend's own cursor reacts to context cancellation. The seq2 error path carries the context error.- Per-row
ctx.Err()check extended to the remaining drain loops —QuerySet.Update(drain + write phases),DeleteMany,drainIter(shared byAllwithWithFetchLinks,AllWithCount,Search,BackLinks,FindByIDs),Project, andforEachLinkField(cascade write / delete / fetch-links) now all honor cancellation between rows or between link fields. Cancellation mid-bulk-Update rolls the whole batch back, matching the pre-existing all-or-nothing contract. - Documented
QuerySet.Update's fail-fast contract — any per-row error (hook, validation, revision conflict, backend write) rolls the batch transaction back and returns(0, err). Field names inSetFieldsare validated before the write transaction opens. No behavior change; docs and tests pin the existing contract. FindOneAndUpdate/FindOneAndUpsertnow validateSetFieldsbefore the transaction opens — an unknown field name aborts the call without touching storage, mirroring the pre-tx contractQuerySet.Updatehas carried since 0.10.x. The error, position in the call graph, and semantics are otherwise unchanged.- Clarified that
LinkDeletecascade is single-level — docs previously claimed recursive cascade ("If a Door has its own links, those are also deleted"), but the code has always stopped at the immediate targets. Docs and godoc onLinkDelete/cascadeDeleteLinks/deleteSingleLinkedValuenow match the code; no behavior change. Callers that need transitive cleanup must walk the graph themselves. - URL-scheme registration and lookup are now case-insensitive in both
den.RegisterBackend/den.OpenURLandstorage.Register/storage.OpenURL. Both sides normalize schemes to lowercase, matching standard URL semantics:"file","File", and"FILE"all address the same backend. - Latent bug fixed:
den.RegisterBackend("SQLITE", ...)previously stored the backend under"SQLITE"whileOpenURLlooked up"sqlite"via its pre-existing lowercasing, so the lookup silently failed. Any caller that registered with mixed-case schemes will now see their backends resolve. - Duplicate-registration panic in
storage.Registernow triggers when the same scheme is registered under different casings (e.g."a"then"A"), because both normalize to the same registry key. den.RegisterBackendnow panics on duplicate, empty, or nil-opener registration — previously it silently overwrote an existing entry, so two packages claiming the same scheme (a fork viareplace, or a manualRegisterBackendcall after a side-effect import) left whicheverinit()ran last in the registry. The new guards matchstorage.Registersemantics and surface the mis-wiring at process start instead of at first lookup.- FTS
Searchhonors cursor pagination —NewQuery[T](db).After(id).Search(ctx, "foo")now applies the cursor on both backends, matching the non-FTS QuerySet path. PreviouslyAfter/Beforewere silently dropped by the FTS SQL builders. Cursor +Skipis rejected withErrIncompatiblePagination, same as the rest of the API. Default ordering is still rank (FTS5rankon SQLite,ts_rankon PostgreSQL) — pair cursor pagination with an explicitSort("_id", den.Asc)for predictable page boundaries. ErrNotRegisteredmessage is now actionable — the wrapped error now names the qualified Go type, spells out the exactden.Register(ctx, db, &Type{})call to add (or alternativelyden.WithTypes()at Open), and links to the quickstart docs. Every-type-must-be-registered is the most common new-user stumble; the message is now self-correcting instead of just informational.errors.Is(err, ErrNotRegistered)is unchanged.- Dangling-link errors are now typed
*DanglingLinkError— the batched link resolver previously surfaced "ID referenced but missing" asfmt.Errorf("%w: %s id=%q", ErrNotFound, ...)with the collection name and ID embedded only in the formatted string. The new exportedDanglingLinkErrorstruct (Collection,IDfields) wrapsErrNotFoundso the existingerrors.Is(err, ErrNotFound)check stays unchanged, while callers that need to surface or act on the broken (collection, id) canerrors.As(err, &dle)without parsing the message. Sameden: document not found: <coll> id="<id>"text format as before. - Storage dedup TOCTOU closed —
file.Storage.Storereplaced theStat+Renamededup flow with a single atomicos.Link, treatingfs.ErrExistas a successful dedup hit. Concurrent uploads of identical content no longer race on the rename step. - Postgres
Deleteerror mapping — the backend'sDeletereturned raw pgx errors, silently bypassingmapPGError. Sentinel wrapping now works uniformly with the rest of the backend write paths (matters once callers add FK triggers that can fail a delete).
Fixed¶
- Soft-delete now participates in the revision chain —
Deleteon a document that opts into bothSoftDeleteandUseRevisionverifies and bumps_revjust likeUpdate. Previously the soft-delete path wrote directly without revision accounting, so a concurrent writer holding the pre-delete revision could silently clobberDeletedAt. Combines atomically via the same auto-wrapping write tx the update path uses;IgnoreRevision()opts out;HardDelete()is unaffected. Iter+WithFetchLinksinside a*Txnow reads links through the tx — previously the per-row link fetch still routed throughdb.backend, so uncommitted link targets surfaced asErrNotFoundand pgx could tripconn busyagainst the iterator connection. Same bug pattern that was fixed forAllWithCountin 0.10;Iternow matches.LinkDeletecascade cleans up child attachment bytes — the cascade hard-delete path ranb.Deleteon the child without then removing itsdocument.Attachmentbytes, orphaning them in Storage. The top-levelDeletealways did the cleanup; cascade now matches.LinkDeletecascade firesBeforeSoftDelete/AfterSoftDelete— soft-deletable cascade targets previously fired onlyBeforeDelete/AfterDelete, so audit-log side effects hooked into the soft-only pair silently missed cascade-triggered deletes. Flow now mirrors the top-level soft-delete.LinkDeletecascade now honorsHardDelete()—Delete(ctx, db, parent, HardDelete(), WithLinkRule(LinkDelete))previously hard-deleted the parent but left soft-deletable linked targets as soft-deleted ghost rows, because thecrudOptswere not threaded intocascadeDeleteLinks. The cascade now mirrorsdeleteCore's branch (HasSoftDelete && !hardDelete) — soft path unchanged for the default case, hard path on aSoftDelete-embedding linked target physically removes the row and fires onlyBeforeDelete/AfterDelete(the soft-only hook pair is skipped).QuerySet.Searchnow honors the caller's scope —NewQuery[T](tx).Search(...)previously routed throughdb.backendeven when bound to a transaction, so the whole query (FTS match + Where + Sort + Limit + cursor) silently operated on committed data and ignored the tx's uncommitted writes. Same bug pattern as theIter + WithFetchLinks inside *Txfix in 0.10.x. The newFTSSearcherinterface (read-side only —EnsureFTSstays onFTSProviderfor registration) is implemented by both backends and their transaction types, so a tx-bound Search now sees tx-local writes via SQLite FTS5 triggers on the same connection or PostgreSQL MVCC, and rolls them back together with the rest of the tx.GroupBy.Intorejects duplicate aggregate tags — two struct fields carrying the sameden:"sum:price"(or any other aggregate tag) previously survived as a redundant SQL column with both fields receiving the same value, silently masking copy-paste typos like "I meantsum:xbut typedavg:xtwice."buildAggsFromMappingsnow returns an error when a tag is registered twice, mirroring the existinggroup_key:Nduplicate-slot guard. No behaviour change for any well-formed target struct.NewLinkextracts ID via type-walked structural lookup, panics on missingdocument.Base— the previous implementation usedreflect.Value.FieldByName("ID"), which silently producedLink{ID: ""}for any type that didn't promote anIDfield (nodocument.Baseembed at all, or ambiguous promotion). The cascade-write path then propagated a Link with no ID and the failure surfaced far from the call site. The new structural walker findsdocument.Baseanywhere in the struct tree (direct embed, nested-via-wrapper, or named field), and panics with a clearden: NewLink: type X does not embed document.Basemessage when none is present. An emptyBase.IDstill produces an empty-ID Link — that's the intentional cascade-write input, not the bug. Well-formed callers (everyone embeddingdocument.Basethe standard way) see no change.LinkDeletecascade soft-delete now participates in the revision chain — the cascade soft-path previously did a rawb.Putafter flippingDeletedAt, leaving the stored_revunchanged. A concurrent writer holding the pre-cascade revision could then run anUpdatethat silently clobbered the cascade-set deletion. The cascade now routes through the samesoftDeletehelper the top-levelDeleteuses, so revision-aware linked targets bump_rev(concurrent stale-revUpdatereturnsErrRevisionConflict),SoftDeleteBy/SoftDeleteReasonaudit fields propagate from the parent's options to the cascade target, and the change-tracking snapshot is captured the same way. Same fix pattern as the 0.10.x direct-delete-revision-chain participation; the cascade was the missing third path. PassIgnoreRevision()on the parent'sDeleteto bypass for any cascade soft-delete that doesn't want the check.InsertManyError.Unwrapbuilds a fresh slice on each call — the previoussync.Oncecache meant mutatingFailuresafter the firstUnwrapleft subsequenterrors.Is/errors.Aswalks reading the stale snapshot. Dropped the cache entirely; allocation is sub-microsecond at the defaultMaxRecordedFailurescap of 100. The struct fields (Failures,Truncated,TotalFailures) are now the only state — direct struct-literal construction works without quirks.
0.10.1 — 2026-04-19¶
Changed¶
- SQLite backend auto-creates missing parent directories —
Open(ctx, "./data/app.db")nowMkdirAlls./databefore handing the path to the driver, matching the filesystem-storage backend which has always created its root directory on construction. Fresh-checkout defaults likesqlite:///data/app.dbnow work without a manualmkdirstep. The:memory:form and thefile:URI form are left alone — those carry their own semantics (no filesystem footprint / VFS and host semantics respectively).
0.10.0 — 2026-04-19¶
Breaking Changes¶
storage.FilesystemStoragemoved tostorage/file— the filesystem backend now lives in its own sub-package, analogous tobackend/sqliteandbackend/postgres. This makes room for additional backends (storage/s3,storage/gcs, …) and keeps the rootstoragepackage trim (interface + registry only). Import-path changes:storage.FilesystemStorage→file.Storagestorage.NewFilesystemStorage(root, urlPrefix)→file.New(root, urlPrefix)- Import
github.com/oliverandrich/den/storage/fileinstead of (or in addition to)github.com/oliverandrich/den/storage.
Added¶
-
storage.OpenURL(dsn, urlPrefix)+ scheme registry — a DSN-based factory that dispatches to the backend registered for the scheme. Backends register themselves viastorage.Register("scheme", opener)from aninit(), matching the pattern Den already uses for database backends. The filesystem backend side-effect-registersfile://:import ( "github.com/oliverandrich/den/storage" _ "github.com/oliverandrich/den/storage/file" // registers file:// ) fs, err := storage.OpenURL("file:///uploads", "/media")The
file://DSN follows the same SQLAlchemy/JDBC-style convention assqlite://for consistency: three slashes for a relative path, four slashes for an absolute path (the leading slash of the path is stripped on parse, which lets standard URL libraries treat the path component uniformly with the authority empty). Examples:file:///data/media→ relativedata/mediafile:////var/media→ absolute/var/media
Direct construction via
file.New(...)still works and takes the filesystem path literally. The registry enables config-driven setups (such as Burrow's--storage-dsnflag) to pick the backend at runtime without code changes when future backends land.
0.9.1 — 2026-04-19¶
Added¶
(*FilesystemStorage).URLPrefix() string— returns the HTTP path prefix the storage serves its files under. HTTP-layer packages (burrow/contrib/uploads) type-assert on aURLPrefix() stringinterface to mount their serving handler on the same route theURLmethod produces. Remote-URL backends (S3, GCS) intentionally do not implement this — the absent method is the signal to skip local serving.
0.9.0 — 2026-04-19¶
Added¶
document.Attachmentembed — a reusable file-reference field for documents. CarriesStoragePath,Mime,Size, andSHA256, all validated via struct tags. Embed it to turn a document INTO a file (type Media struct { document.Base; document.Attachment; ... }) or add it as named fields to have ONE document point at MULTIPLE files (type Product struct { document.Base; Hero, Thumbnail document.Attachment }).IsZero()distinguishes "no file attached yet" from "file present".den.Storageinterface —Store(ctx, r, ext, mime) (Attachment, error),Open(ctx, Attachment) (io.ReadCloser, error),Delete(ctx, Attachment) error,URL(Attachment) string. Installed on the DB viaden.WithStorage(...). Reachable at runtime viadb.Storage(). One Storage per DB — application code that owns the upload flow (web handlers, CLI importers) calls it directly.den/storage.FilesystemStorage— reference Storage implementation. Content-addresses paths toYYYY/MM/<sha256-prefix>.<ext>so identical uploads dedupe on both disk and the unique StoragePath index.os.Rootguards every open/remove against path traversal, idempotent Delete tolerates missing paths, and zero-byte uploads are refused at the boundary. Used to live inline inwarren; hoisted here so every Den-using project gets it.- Hard-delete cascade for attachments —
den.Delete(..., den.HardDelete())on a document that contains one or moredocument.Attachmentfields now asks the configured Storage to remove the bytes. Walked via reflection into embedded structs and pointer-to-struct fields; zero Attachments are skipped. Best-effort: storage failures are logged viaslogbut do not fail the database delete — orphan bytes are recoverable via an offline sweep, while the reverse (surviving DB references to missing bytes) would break the public site. NoStorageinstalled + non-zero attachments → warning log, delete proceeds. zizmorworkflow audit in CI — newzizmorjob in.github/workflows/ci.ymlruns the zizmor static analyzer against all workflow files on every PR and push. A.github/zizmor.ymlconfig documents the one accepted risk (theworkflow_runtrigger inrelease.yml, gated on branch-prefix + CI success).
Changed¶
- CI and release workflows hardened — all
actions/*andgolangci/*uses are now pinned to commit SHAs with version comments (the blanket policy zizmor enforces).persist-credentials: falseon everyactions/checkout.actions/setup-goruns withcache: falseto prevent cache-poisoning on tag pushes.release.ymlroutesgithub.event.workflow_run.head_branchthrough aVERSIONenv var instead of direct${{ … }}interpolation insiderun:blocks, closing the template-injection vector. The manualactions/cachesteps were removed; setup-go's built-in cache handling would have re-introduced the poisoning concern without offering meaningful speedup.
0.8.0 — 2026-04-18¶
Breaking Changes¶
Tx*CRUD functions unified into a sealedScopeinterface —Insert,InsertMany,Update,Delete,DeleteMany,FindByID,FindByIDs,FindOneAndUpdate,Refresh,FetchLink,FetchAllLinks,BackLinksnow accept aden.Scopeparameter satisfied by both*DBand*Tx. TheTxInsert/TxUpdate/TxDelete/TxFindByIDvariants are removed. Migration: replaceden.TxInsert(tx, doc)withden.Insert(ctx, tx, doc)—ctxis already in scope from the enclosingRunInTransaction(ctx, db, …)closure.Scopeis sealed (unexported methods) so only*DBand*Txcan satisfy it; backend authors are unaffected.InsertMany/DeleteMany/FindOneAndUpdatekeep their auto-tx behavior when the scope is*DBand run inline when the scope is*Tx.Txno longer storescontext.Context. The previously-implicittx.ctxis gone; every tx-scoped entry point takesctxexplicitly, matching the precedent set byQuerySet.All(ctx). Tx-scope-only operations also drop the now-redundantTxprefix — the*Txparameter already enforces the transaction-scope constraint. Migration:den.TxLockByID(tx, id, opts…)→den.LockByID(ctx, tx, id, opts…)den.TxRawGet(tx, col, id)andden.TxRawPut(tx, col, id, data)are removed entirely — they were a public escape hatch that invited misuse alongsideInsert/Update. Infrastructure code that genuinely needs raw bytes (the migration log) now uses the new(t *Tx) Transaction() Transactionaccessor:tx.Transaction().Get(ctx, col, id)/tx.Transaction().Put(ctx, col, id, data). The accessor is documented as low-level and not intended for application codeden.TxAdvisoryLock(tx, key)→den.AdvisoryLock(ctx, tx, key)TxQuerySet.All()→TxQuerySet.All(ctx); same forFirst
NewTxQuery/TxQuerySetremoved —NewQuerynow takesScope— the follow-up step of the Scope unification.NewQuery[T](scope Scope, ...)accepts*DBand*Txjust like the CRUD helpers do; the separate transaction-scoped builder goes away.ForUpdate(opts ...LockOption)becomes a chain method on the unifiedQuerySet[T]. CallingForUpdateon a*DB-bound QuerySet is accepted syntactically but terminal methods return the new sentinelden.ErrLockRequiresTransaction. Migration:den.NewTxQuery[T](tx, conds...).ForUpdate().All(ctx)→den.NewQuery[T](tx, conds...).ForUpdate().All(ctx);TxQuerySet[T]references →QuerySet[T].den.ErrLockRequiresTransactionadded as the sentinel surfaced whenQuerySet.ForUpdateis set but the scope is a*DB.den.Rollbackrenamed toden.Revert— the change-tracking helper that restores a document to its snapshot state has nothing to do with transactions; the old name collided withtx.Rollback()every time both appeared in the same file. Single-symbol rename, semantics unchanged. Migration:den.Rollback(db, doc)→den.Revert(db, doc).document.SoftBase/TrackedBase/TrackedSoftBasecollapsed into composable embeds — the four named base types were a 2² matrix of two orthogonal features (soft delete × change tracking). The matrix is gone;document.Basestays required, anddocument.SoftDeleteanddocument.Trackedare now independent composable embeds. Compose freely:struct { document.Base; document.SoftDelete; document.Tracked; ... }. Migration:document.SoftBase→document.Base+document.SoftDeletedocument.TrackedBase→document.Base+document.Trackeddocument.TrackedSoftBase→document.Base+document.SoftDelete+document.TrackedJSON wire format is unchanged (only struct layout changes). Internal detection is structural (_deleted_atfield /Trackableinterface), so any type that matches the shape participates — not just these named embeds.
-
CollectionMeta.HasSoftBaserenamed toHasSoftDelete— consistency with the new embed name. Custom backend implementations that read this field must update. -
Backendinterface extended — theBackendinterface gained aListRecordedIndexes(ctx, collection) ([]RecordedIndex, error)method. Custom backend implementations must add this method. It should return the indexes tracked in the backend's private metadata table (managed indexes such as GIN or FTS auxiliary objects must not be tracked and therefore not returned) Transactioninterface extended — theTransactioninterface gained aGetForUpdate(ctx, collection, id, mode LockMode) ([]byte, error)method. Custom transaction implementations must add this method. On PostgreSQL it should emitSELECT ... FOR UPDATE(withSKIP LOCKEDorNOWAITsuffix per mode); on serializing-writer backends like SQLite it can delegate toGetand ignore the modeTransactioninterface extended — theTransactioninterface gained anAdvisoryLock(ctx, key int64) errormethod. Custom transaction implementations must add this method. On PostgreSQL it should map topg_advisory_xact_lock; on serializing-writer backends like SQLite it can be a no-op since IMMEDIATE transactions already serialize writersQuerystruct locking fields collapsed —Query.ForUpdate boolandQuery.LockMode LockModereplaced byQuery.Lock *LockMode.nilmeans no lock; a non-nil pointer's value selects the mode. Custom backends must substituteq.Lock != nilforq.ForUpdate, and*q.Lockforq.LockMode. The new shape makes the previously-possible invalid pair(ForUpdate=false, LockMode!=LockDefault)unrepresentableHardDeleteis now aCRUDOption— replaces the top-levelHardDelete[T](ctx, db, doc, opts...)function. Callers migrate toDelete(ctx, db, doc, HardDelete()). The CRUDOption composes with other options (WithLinkRule, future options), soHardDeleteno longer needs to silently inject itself through a private option helperdb.SetTagValidator(fn)replaced byWithTagValidator(fn) Option— configure tag validation at Open instead of via a post-construction method. Avoids the race window where a concurrentRegistercould race against a lateSetTagValidator. Thevalidate.WithValidation()helper continues to work transparently — it just wrapsWithTagValidatorinternallyTxGet/TxPutrenamed toTxRawGet/TxRawPut— these are raw-bytes escape hatches intended only for infrastructure code (for example, the migration log). The new names make the limited purpose obvious. Callers using them for normal document I/O should migrate toTxFindByID/TxInsert/TxUpdate, which preserve the encoder and registry contractOpenandOpenURLtake a leadingcontext.Context— and pass it into backend setup (connection dialing, metadata-table creation, server version check) and any registration work triggered byWithTypes. Callers with a startup deadline or cancellable shutdown can now abort the database open cleanly. The backend-opener type registered byRegisterBackendalso gains a leadingctxparameter. Migration:den.OpenURL(dsn, opts...)→den.OpenURL(ctx, dsn, opts...);den.Open(backend, opts...)→den.Open(ctx, backend, opts...); custom backends must update theirOpenentry point and init-timeRegisterBackendcall to takectxQuerySet[T]no longer storesctxin the struct; terminal methods take it as a parameter — the long-standing Go antipattern of stashing acontext.Contexton a struct was confusing: thectxcaptured atNewQuery(ctx, db, …)silently overrode any deadline a caller might introduce later.NewQuery[T]now takes only the*DB(plus optional conditions), and every terminal method takesctxas its first argument:All(ctx),First(ctx),Count(ctx),Exists(ctx),Iter(ctx),AllWithCount(ctx),Update(ctx, fields),Avg(ctx, field)and the other aggregates,Search(ctx, queryText),Project(ctx, target),GroupBy(field).Into(ctx, target).TxQuerySet[T]is unchanged (itsctxstill flows from the enclosing transaction, which is scoped correctly). Migration is mechanical: dropctx,from everyNewQuery[T](ctx, db, …)call and addctxas the first argument to the terminal call
Added¶
den.DropStaleIndexes()— explicit API for cleaning up indexes that were created by a previousRegister()but no longer correspond to anyIndexDefinitionin the current struct. Passden.DryRun()to preview the plan without mutating the database. Returns aDropStaleResultlisting bothDroppedandKeptindexes. Backed by a new_den_indexesmetadata table created automatically onOpen()for both SQLite and PostgreSQLden.TxLockByID[T]()— transaction-only API that reads a document and acquires a row-level lock held until the transaction commits or rolls back. On PostgreSQL emitsSELECT ... FOR UPDATE; on SQLite is a no-op because IMMEDIATE transactions already serialize writers. The*den.Txparameter enforces transaction-only usage at compile time- Lock modifiers:
den.SkipLocked()andden.NoWait()— options forTxLockByIDthat change how contention is handled on PostgreSQL.SkipLockedmaps toFOR UPDATE SKIP LOCKEDand returnsErrNotFoundimmediately when another transaction holds the row — the queue-consumer primitive.NoWaitmaps toFOR UPDATE NOWAITand returns the newErrLockedsentinel. Both are no-ops on SQLite. Conflicting options resolve as "last wins" den.ErrLocked— new sentinel error forTxLockByIDwithNoWait()when the row is held by another transactionden.NewTxQuery[T]andTxQuerySet[T]— transaction-scoped query builder withForUpdate(opts ...LockOption)for multi-row locking. Minimal chainable API (Where,Sort,Limit,Skip,ForUpdate) plusAll/Firstterminals. Reuses theSkipLocked/NoWaitoptions from single-row locking. Only callable via*den.Tx, enforcing transaction scope at compile time.Querystruct gains additiveForUpdateandLockModefieldsden.ErrDeadlock— new sentinel error returned when PostgreSQL reports40P01 deadlock_detected. Enableserrors.Is(err, den.ErrDeadlock)instead of type-switching on pgx internalsden.ErrSerialization— new sentinel error returned when PostgreSQL reports40001 serialization_failure. Becomes relevant once callers opt into stricter isolation; the sentinel is available now so that upgrade path is straightforwardden.WithTypes(...any) Option— register document types at Open. Lets the entire setup read as a single expression:den.OpenURL(dsn, den.WithTypes(&Note{}, &Tag{})). Registration errors abort Open and are surfaced as its error. UseRegisterdirectly when you need to supply a specific contextden.ErrFTSNotSupported— new sentinel error returned byQuerySet.Searchwhen the backend does not implementFTSProvider. Callers canerrors.Isagainst the sentinel instead of pattern-matching on the error string
Changed¶
- Non-blocking PostgreSQL index creation —
Register()now emitsCREATE INDEX CONCURRENTLYfor both expression indexes and the auto-created GIN index. Concurrent writes are no longer blocked during index creation on large collections. If a previous concurrent run left an invalid index behind,EnsureIndexdetects it viapg_index.indisvalidand recreates it automatically. SQLite behavior is unchanged QuerySet.Iter()terminates on the first error — previously, if a decode or fetch-links failure happened mid-iteration the loop yielded the error and then continued to the next row. Mostiter.Seq2producers in the ecosystem stop at the first error, soIter()now matches that convention. Callers doingfor doc, err := range qs.Iter()should handle the error and exit the loop — further rows will not be yielded after the first error- Per-op reflection amortized — pre-resolved pointers to the embedded base fields (
_id,_rev,_created_at,_updated_at,_deleted_at) are now cached onStructInfoduring one-time analysis so CRUD, revision, and soft-delete paths use directFieldByIndexinstead of per-operationFieldByName.Link[T]sub-field indices (ID,Value,Loaded) are cached onlinkFieldInfothe same way, turning each link resolution into index-based access. GROUP BY scan buffers (scanDest,vals) are hoisted outside the per-row loop on both backends so only their pointer-target slots are reset each iteration. Public API surface unchanged - Drain loops consolidated — the near-duplicated decode-and-collect loops in
NewTxQuery.All,BackLinks,AllWithCount, andSearchnow share a singledrainIter[T]helper. Fewer places to regress when the iteration contract changes - Row-decode allocations cut —
decodeIterRowno longer pre-copies iterator bytes before decoding. Both backend iterators already return a fresh[]byteper row (pgxScananddatabase/sqlScandocument this contract), so the slice is stable beyond the nextNext()and is used directly. Non-Trackabledocument types now incur zero rowbuf overhead;Trackabletypes share the same slice as both decode input and snapshot. Micro-benchmark onQueryAll100/QueryIter100shows ~100 fewer allocations per call (−8%) and ~15% less peak bytes allocated per op - PostgreSQL
toJSONBParamreturns[]byteinstead ofstring— drops thestring(b)conversion for every JSONB-cast query parameter. One allocation saved per JSONB parameter; on high-cardinalityWhere.In(...)queries that scales linearly with the number of values. pgx accepts[]bytefor::jsonbcasts verbatim - PostgreSQL simple
Eqpredicates now use containment so the GIN index can serve them —where.Field("status").Eq("published")previously emittedjsonb_extract_path(data, 'status') = $1::jsonb, a functional expression theGIN(data jsonb_path_ops)index cannot satisfy, so Postgres fell back to a sequential scan. The builder now emitsdata @> $1::jsonbwith a{"status":"published"}parameter when the LHS is a top-level field and the RHS is a scalar (string,bool, integer, float). The GIN index is used directly;EXPLAINshowsBitmap Index Scaninstead ofSeq Scan. Nested paths, non-scalar values (slices, maps),nil, andFieldRefcomparisons continue to use the extract form, because containment would either have different semantics (subset vs equality) or cannot build a safe top-level JSONB literal.Ne/Gt/Lt/Gte/Lteare unchanged —jsonb_path_opscannot satisfy range predicates either way QuerySet.All(ctx)withWithFetchLinks()batches link resolution instead of per-row Get — the previous implementation reused.Iter(), which calledGetper linked document and per row. For N parents with one link each that was N round-trips on PostgreSQL..All()now drains the iterator first, then resolves each link field in oneWHERE _id IN (…)query per target type per nesting level, deduplicating IDs so a hot target shared across many parents is fetched once. Parents referencing the same target id now share the decoded pointer (observable via==).WithFetchLinkson streaming.Iter()is unchanged and still resolves per row so iteration stays streaming. On the benchmark with 20 parents + one shared authorWithFetchLinksdrops from ~1.6 ms to ~600 µs on PostgreSQL (~2.7× faster); on SQLite from ~107 µs to ~73 µs.QuerySet.AllWithCountandQuerySet.Searchuse the same batched resolverWithNestingDepth(n)now resolves links recursively on loaded targets — the previous per-row resolver passed the depth value around but never descended: aWithNestingDepth(2)query againstRoot → Mid → Leafonly loadedMid. The batched.All()/.AllWithCount()/.Search()path now runs one batched query per depth level, soRoot.Mid.Value.Leafis now populated as documented. Streaming.Iter()still only resolves the direct level
Fixed¶
- Revision check silently skipped when in-memory
_revis empty —checkAndUpdateRevisionguarded the conflict check withcurrentRev != "", which causedUpdateof a revisioned document constructed with only an ID (no_rev) to silently overwrite the stored document. The guard now keys off document existence (id != ""), so an empty in-memory rev against a populated DB rev correctly returnsErrRevisionConflict - Bulk
QuerySet.Updatedeadlocked on PostgreSQL — the iterator was drained while issuing writes on the same transaction, butpgx.Rowspins the connection until closed, so the second statement returnedconn busy. The implementation now materializes matching documents into a slice, closes the iterator, then runs updates. SQLite behavior is unchanged - Unsanitized JSON field names reached SQL construction — defense-in-depth fix for field names from struct tags.
Register()now rejects any JSON name that doesn't match^[A-Za-z_][A-Za-z0-9_]*$with an error wrappingden.ErrValidation. The SQLite FTS column-list path and the PostgreSQL expression-index path also applysanitizeFieldNameto every field, closing the raw-interpolation gaps even if a custom pipeline bypassed registration migrate.UpTOCTOU on the applied-migrations log —loadAppliedread the log outside any transaction, so two processes starting simultaneously both saw the same snapshot and both ran the same pending migration, producing duplicate work and — for non-idempotent forward functions — broken state. The "already applied?" check now happens inside each migration's own transaction, guarded by an advisory lock, so every version runs exactly once across concurrent startersAllWithCountwithWithFetchLinks()exhausted the PostgreSQL connection pool — the read transaction held one connection for the iterator while each per-row link resolution grabbed a separate pool connection viadb.backend.Get. With default pool sizing and a handful of concurrent callers every connection was consumed by active iterators plus their link fetches, causingbegin read txto time out. Link resolution now routes through the iterator's transaction (iterator is fully drained before link lookups to avoid pgx's "conn busy" on active rows)SkipLocked()andNoWait()passed together silently let the last-registered option win — they are mutually exclusive in PostgreSQL, so the previous behavior masked programmer mistakes.TxLockByIDnow returns a clear error on conflict;TxQuerySet.ForUpdate(chainable, can't return an error directly) captures the error and surfaces it on the terminalAll/Firstcall- Unsorted
NewTxQuery(...).ForUpdate().All()could deadlock on PostgreSQL — without anORDER BYclause, two concurrent callers with overlapping result sets acquired row locks in different heap orders and triggered40P01 deadlock_detected.buildSelectSQLnow appends a defaultORDER BY id ASCwhen a lock is requested and no explicit sort is set, so every caller walks the lock order identically mapPGErrornow recognizes deadlock and serialization failures —40P01maps toden.ErrDeadlockand40001maps toden.ErrSerialization. Callers previously saw raw pgx errors for these cases, defeating the purpose of sentinel errorsmigrate.Down/migrate.DownOneTOCTOU on the applied-migrations log — symmetric to theUpfix:loadAppliedwas read outside any transaction, so two concurrent rollback starters both saw the same applied set and both ranBackwardfor the same version.runBackwardnow acquires the same advisory lock used for forward migrations and re-reads the log inside the transaction, so every version is rolled back exactly once across concurrent startersUpdate/Deleteon a document without an ID returned a plainfmt.Errorf— callers could noterrors.Isthe failure. Both paths now wrap the sentinelErrValidation, matching the rest of the validation surfaceDenSettings()defined on a pointer receiver was silently ignored when the user passed a value toRegister— the direct type assertion againstDenSettableonly matched the exact receiver kind.getSettingsnow retries via a synthesized pointer so settings are picked up regardless of whether the user passedT{}or&T{}
0.7.0 — 2026-04-15¶
Breaking Changes¶
ReadWriterandBackendinterfaces extended — Both interfaces now include aGroupBymethod for SQL-native group-by aggregation. Custom backend implementations must add this method- Dead
Settingsfields removed —OmitEmpty,UseCache,CacheCapacity,CacheExpiration, andNestingDepthPerFieldwere declared but never read by any code. They have been removed from theSettingsstruct. If your code set these fields, remove the assignments — they had no effect ParseDenTagreturns error — Now returns(TagOptions, error)instead ofTagOptions. Unrecognized tag options produce an error atRegister()time. If you calledParseDenTagdirectly (unlikely outside Den internals), update the call siteARCHITECTURE.mdremoved — Documentation now lives exclusively indocs/andllms-full.txt. If you referenced ARCHITECTURE.md, use the docs site instead
Added¶
den.Open()exported — allows constructing a*DBfrom aBackendinstance directly, without going through a URL scheme. Useful for custom or mock backendsomitemptyrecognized in den tag —den:"omitempty"is now a valid tag option
Changed¶
- Shared code extracted to internal —
sanitizeFieldName,escapeLike, and JSON encoding deduplicated from both backends into theinternalpackage Collections()returns sorted names — output is now deterministic- GroupBy SQL pushdown —
GroupBy().Into()now generates a native SQLGROUP BYstatement instead of loading all documents into memory. This reduces O(N) memory and CPU to a single database query. NewGroupBymethod on theReadWriterinterface
Fixed¶
- PostgreSQL type-aware JSONB comparisons — The PostgreSQL backend now uses
jsonb_extract_pathwith::jsonbcasts instead ofdata->>'field'text extraction. This fixes four related bugs: numeric sorts were lexicographic ("9" sorted after "100"),Gt/Lton string fields crashed with::floatcast,Eq/Neused text comparison whileGt/Ltused float (semantic inconsistency), and nested dot-notation fields likeaddress.citysilently matched nothing - Nested field paths on PostgreSQL —
where.Field("address.city").Eq("Berlin")now correctly traverses nested objects usingjsonb_extract_path(data, 'address', 'city')instead of the brokendata->>'address.city'literal key lookup - Revision check TOCTOU race —
den.Update()with revision checking now auto-wraps the revision check and write in a transaction when not already in one, preventing concurrent writers from interleaving on PostgreSQL - LinkWrite validation bypass — Documents written via
WithLinkRule(LinkWrite)now run both struct tag validation andValidator.Validate(), matching the same hook order as directInsert/Update - Panic in aggregate SQL for unknown ops —
buildAggregateSQLin both backends now returns an error instead of panicking on unsupported aggregate operations - AllWithCount consistency —
AllWithCountnow wraps Count and Query in a single read transaction so the total is consistent with results under concurrent writes - Unknown den tag options rejected —
ParseDenTagnow returns an error for unrecognized options (e.g.den:"indx"), surfacing typos atRegister()time - Link resolution with custom collection names —
FetchLink,WithLinkRule(LinkWrite), and cascade delete now respect customCollectionNamefromDenSettings()
0.6.0 — 2026-04-08¶
Breaking Changes¶
- Hook order reversed around validation — Mutating hooks (
BeforeInsert,BeforeUpdate,BeforeSave) now run before both struct tag validation and theValidator.Validate()interface. The new insert order isBeforeInsert → BeforeSave → tag validation → Validate() → write, matching the pattern used by ActiveRecord, Django ORM, and SQLAlchemy. This lets aBeforeInserthook populate a field that the validator requires — for example, deriving a slug from a title and having the slug markedvalidate:"required". The previous order ran validation first, which made this pattern impossible.
Migration: if your code relied on Validate() running before BeforeInsert (unusual — most code wants the opposite), move the check into BeforeInsert itself.
0.5.0 — 2026-04-06¶
Added¶
- Composite indexes via struct tags —
den:"unique_together:group"andden:"index_together:group"allow declarative multi-field indexes. Fields sharing a group name are combined into a single composite index. Both SQLite and PostgreSQL backends generate correct partial indexes with NULL-exclusion WHERE clauses - Settings.Indexes now wired up —
DenSettings().Indexeswas previously declared but never applied duringRegister(). CustomIndexDefinitionentries are now merged into the collection metadata and created as actual database indexes
0.4.2 — 2026-04-06¶
Added¶
- PostgreSQL version check — the PostgreSQL backend now verifies the server version on connect and requires PostgreSQL 13 or later. Provides a clear error message instead of cryptic SQL failures on unsupported versions
- LLM documentation —
llms.txtandllms-full.txtfor AI tool discoverability, following the llms.txt standard
0.4.1 — 2026-04-05¶
Added¶
- Documentation site — full MkDocs documentation with Material theme, hosted on ReadTheDocs. Covers getting started, guides (CRUD, queries, relations, aggregations, FTS, transactions, hooks, soft delete, change tracking, revision control, validation, migrations, testing), and API reference
- Third-party licenses —
scripts/generate-licenses.shfor automated license generation viago-licenses - justfile targets —
just docs(serve locally),just docs-build(static build),just licenses(regenerate third-party licenses) - ReadTheDocs configuration —
.readthedocs.yamlfor automated builds via Zensical
0.4.0 — 2026-04-05¶
Added¶
den/idpackage — public leaf package for ULID generation (id.New()), no framework dependencies.den.NewID()anddocument.NewID()both delegate to it. Useful for generating IDs outside of document contexts (e.g. worker IDs, correlation IDs).
0.3.0 — 2026-04-05¶
Added¶
- String matching operators —
StringContains(substr),StartsWith(prefix),EndsWith(suffix)for LIKE-based substring matching on string fields, with proper escaping of special characters
0.2.1 — 2026-04-05¶
Fixed¶
- SQLite PRAGMA handling — user-provided PRAGMAs in the DSN are now preserved; defaults are only applied when not overridden. Previously, passing query parameters caused a malformed DSN with duplicate
?separators.
Added¶
- SQLite performance PRAGMAs — added
temp_store(MEMORY),mmap_size(134217728),journal_size_limit(27103364), andcache_size(2000)as defaults, matching dj-lite and Burrow's recommended configuration
0.2.0 — 2026-04-05¶
Breaking Changes¶
den.Open(backend)replaced byden.OpenURL(dsn)— URL-based opening with automatic scheme detection. Backend packages now register viainit()and are imported with_for side effects.den.Openis unexported.sqlite:///path/to/dbfor SQLitesqlite://:memory:for in-memory SQLitepostgres://user:pass@host/dbfor PostgreSQL
Added¶
- Benchmark suite — per-operation benchmarks for both backends covering Insert, FindByID, QueryAll, QueryIter, Update, Delete, and QueryWithCondition with
just benchrecipe
Changed¶
- Reduced allocations on hot paths — cached
reflect.ValueOf(now)in setBaseFields (-1 alloc/op on Insert/Update), pre-allocated result slices inAll()/Search()when Limit is set (-4 allocs on limited queries), consolidated row decode pattern intodecodeIterRoweliminating double-copy for Trackable documents dentesthelpers accepttesting.TB— benchmark tests can now reuseMustOpen/MustOpenPostgres- PostgreSQL tests always run — removed
//go:build postgrestag andDEN_POSTGRES_URLskip guard, PG is always available
0.1.0 — 2026-04-04¶
Added¶
- Core ODM — document-oriented storage with JSONB encoding, ULID-based IDs, and automatic timestamps
- SQLite backend — embedded, pure Go (
modernc.org/sqlite), JSONB storage, FTS5 full-text search - PostgreSQL backend — server-based, native JSONB + GIN indexes, tsvector full-text search
- Chainable QuerySet —
NewQuery[T](ctx, db).Where(...).Sort(...).Limit(n).All()with lazy evaluation - Range iteration —
Iter()returnsiter.Seq2[*T, error]for memory-efficient streaming - Typed relations —
Link[T]for one-to-one,[]Link[T]for one-to-many, with cascade write/delete and eager/lazy fetch - Back-references —
BackLinks[T]finds all documents referencing a given target - Native aggregation —
Avg,Sum,Min,Maxpushed down to SQL;GroupByandProjectfor analytics - Full-text search — FTS5 for SQLite, tsvector for PostgreSQL, same
Search()API - Lifecycle hooks —
BeforeInsert,AfterUpdate,Validate, and more via interfaces on document structs - Change tracking — opt-in via
TrackedBase:IsChanged,GetChanges,Rollbackwith byte-level snapshots - Soft delete — embed
SoftBaseinstead ofBase, automatic query filtering,HardDeletefor permanent removal - Optimistic concurrency — revision-based conflict detection with
ErrRevisionConflict - Transactions —
RunInTransactionwith panic-safe rollback - Migrations — registry-based, each migration runs atomically in a transaction
- Expression indexes —
den:"index",den:"unique", nullable unique for pointer fields - Struct tag validation — optional
validate:"required,email"tags viago-playground/validator, enabled withvalidate.WithValidation()option - Functional options —
den.Open(backend, opts...)pattern for extensible configuration - Test helpers —
dentest.MustOpenanddentest.MustOpenPostgreswith automatic cleanup