- Go 99.8%
- Makefile 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| ether | ||
| kerr | ||
| klog | ||
| matter | ||
| meta | ||
| mongo | ||
| .gitignore | ||
| AGENTS.md | ||
| GEMINI.md | ||
| go.mod | ||
| go.sum | ||
| kosmos.go | ||
| kosmos_test.go | ||
| LICENSE | ||
| Makefile | ||
| MIGRATION.md | ||
| operations.go | ||
| README.md | ||
| test.env | ||
Kosmos-Go
Kosmos-Go is a Go framework and data persistence layer over MongoDB, wrapping the official driver (go.mongodb.org/mongo-driver/v2). It exists so every service in this estate reaches its database the same way. It is the Go counterpart of the kosmos Python package and tracks its 2.x design.
Two ideas carry most of the weight
A Form is anything a document decodes into. A Model is a Form with an identity. Keeping them apart means an aggregation result — a group total, a joined summary — does not have to pretend to an ObjectID it never has.
A query field is strict about nil. Fld("Score").Gt(threshold) with a nil threshold fails the query. It does not quietly drop the filter and match every document in the collection. When a filter genuinely should disappear on a missing value, say so with OFld.
Architecture & Packages
kosmos
The entry point. Ignition, the model bases you embed, and the query DSL:
Ignite(ctx, cmdSource, sources...)/IgniteBase(...)— loads configuration throughether, resolves secrets, and (forIgnite) proves the database is reachable. Both return an error; a service should decide for itself what a failed ignition means.BaseForm,Model,Particle,Persistable,Ledger— the bases you embed, in increasing order of what they carry.Fld/OFld— strict and lenient query fields.Detect[T](predicates...),ProjectInto[T](...),Record(ctx, obj),Update[T](filter)— read, project, write, and partially update.
meta and meta/expression
meta reads a model's kosmos struct tag and maps declared field names to their stored bson names, walking into nested documents. meta/expression is the query DSL: predicates, aggregation operators, accumulators, sorts, and the immutable Aggregation builder.
matter
The entities themselves and the collapse state machine: Ripple, Particle, Persistable, Ledger.
mongo
The driver layer: connection purposes (affinity.go), URI resolution and client dialing (client.go), connections (Dataverse), collections, and the read (Detector), write (Recorder), projection (Projector), and update (Updater) paths.
Each path connects under the PurposeAffinity its access needs — reads as Detector, writes as Recorder, and any pipeline ending in $merge/$out as Recorder however it was assembled. MONGODB_DETECTOR_URI, MONGODB_RECORDER_URI, MONGODB_CREATOR_URI, and MONGODB_ADMIN_URI are all optional: an unset purpose falls back to a credential that can already do its job, ending at MONGODB_URI, so a single-URI deployment works unchanged. See MIGRATION.md for the resolution table.
ether
Environment configuration and secrets, over Google Secret Manager, viper, and cobra.
kerr
The error hierarchy. Everything wraps ErrKosmos, and each category wraps it in turn, so errors.Is matches at whatever granularity you need — re-exported from the root package as km.ErrQuery, km.ErrConnection, and so on.
Database Lifecycle: Collapse & Decohere
Every write completes a two-phase cycle:
graph TD
A[Model: Unset/Material] -->|kosmos.Record / Collapse| B(Transition)
B -->|Recorder writes to MongoDB| C(Ripple: insert/update feedback)
C -->|Decohere| D[Model: Material]
- Collapse fixes what is about to be written and stages side effects in a
Ripple—created_atunder$setOnInsert, the update time stamped outright. The model enters transition. - Persistence: the
Recorderupserts, or inserts outright for aLedger. - Decohere folds the database's answer back in: the identity it landed on, and the creation time, which is only knowable once the write says whether it created the record.
A model that collapsed but never decohered is stuck in between and will refuse to collapse again. That is deliberate — a model whose write outcome is unknown should not be silently written a second time.
Getting Started
1. Ignite
package main
import (
"context"
"log"
kosmos "git.mypierian.com/borghives/kosmos-go/v2"
)
func main() {
ctx := context.Background()
if err := kosmos.Ignite(ctx, nil, "test.env"); err != nil {
log.Fatalf("failed to ignite: %v", err)
}
}
2. Define a model
Embed a kosmos base and use the kosmos tag to say where it lives. The tag reads branch>collection; a bare name uses the default branch, and a trailing ,vN pins a schema version to its own collection (user_v2).
type User struct {
kosmos.Persistable `bson:",inline" kosmos:"users"`
Name string `bson:"name"`
Email string `bson:"email"`
Age int `bson:"age"`
}
By default a model is written by its identity. Override SelfScope to key it on a natural unique field instead:
func (u User) SelfScope() kosmos.Scope {
return kosmos.CreateScope(kosmos.Fld("Email").Eq(u.Email))
}
Code Examples
A. Writing
user := &User{Name: "Jane Doe", Email: "jane.doe@example.com", Age: 30}
if err := kosmos.Record(ctx, user); err != nil {
log.Fatalf("failed to persist user: %v", err)
}
// Populated by decoherence.
fmt.Println(user.ID.Hex(), user.CreatedAt, user.UpdatedTime)
B. Querying
Predicates are written against declared Go field names and resolved to their bson names when the query compiles — nested paths included (Fld("Author.Name")).
found, err := kosmos.Detect[User](
kosmos.Fld("Email").Eq("jane.doe@example.com"),
).LoadOne(ctx)
adults, err := kosmos.Detect[User](
kosmos.Fld("Age").Gte(18),
).SortBy("Name", false).Limit(10).LoadMany(ctx)
Combine predicates with And, Or, AllOf, AnyOf, and NoneOf:
kosmos.Detect[User](
kosmos.Fld("Age").Gte(18).And(
kosmos.AnyOf(
kosmos.Fld("Name").Matches("^J", "i"),
kosmos.Fld("Email").IsIn(kosmos.Values(knownAddresses)...),
),
),
)
C. nil, and what it means
var threshold *int
kosmos.Fld("Age").Gt(threshold) // fails the query — an unset bound is a bug
kosmos.OFld("Age").Gt(threshold) // skips the filter — an optional bound is not
kosmos.Fld("Age").Eq(kosmos.Lit(nil)) // matches documents whose age is null
kosmos.Fld("Age").IsNull() // the same, spelled plainly
A strict field compared against nil records the error on the predicate. It travels with the query and surfaces when the query runs, so nothing executes on a filter nobody wrote.
D. Aggregating and projecting
The projection target is a BaseForm — it exists only as a result shape, so it declares no identity and no collection. A GroupBy key is a document, so the grouped value is lifted back out from beneath _id (With("_id.age")), which is also how the target's Age comes to decode from the result's _id.
type AgeGroup struct {
kosmos.BaseForm `bson:"-"`
Age int `bson:"_id"`
Count int `bson:"count"`
}
stats, err := kosmos.ProjectInto[AgeGroup](
kosmos.OFld("Age").With("_id.age"),
kosmos.OFld("Count").With("count"),
).Of(
kosmos.Detect[User](kosmos.Fld("Age").Gte(21)).
GroupBy("Age").
Acc(kosmos.OFld("count").WithSum(kosmos.Lit(1))),
).LoadMany(ctx)
E. Partial updates
For claim-and-reserve patterns, ExecLoad maps to findOneAndUpdate, so the read and the write are one server-side operation and a separate load cannot race another worker.
claimed, err := kosmos.Update[Task](kosmos.Fld("Status").Eq("pending")).
Set("Status", "claimed").
Inc("Attempts", 1).
ExecLoad(ctx)
ExecMany with an empty filter is refused unless you call AllowingAll, since it would otherwise touch every document in the collection.
Testing
Set TEST_MODE=true and every collection name gains a _test suffix, which keeps a test run off production data without changing a single model declaration.
Upgrading from 1.x
Version 2 is a breaking change throughout; see MIGRATION.md for the full list. The three that matter most:
Fldrejects nil. In 1.x a nil comparison silently produced an empty filter. Audit everyFld(...)whose right-hand side can be nil: useOFldfor optional filters,Lit(nil)orIsNull()for null comparisons.BaseModelsplit intoBaseForm/Model/Persistable. Projection targets embedBaseForm; persisted documents embedPersistable.Projector.Fromis now.Of, and the read methods areLoadOne/LoadMany/LoadToprather thanPullOne/PullAll.