No description
  • Go 99.8%
  • Makefile 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-15 11:05:51 -07:00
ether refactor kosmos go 2026-08-15 11:04:30 -07:00
kerr Major upgrade v2.0.0 2026-08-02 09:59:27 -07:00
klog Major upgrade v2.0.0 2026-08-02 09:59:27 -07:00
matter improve MongoDB integration and modeling 2026-08-02 21:24:57 -07:00
meta refactor kosmos go 2026-08-15 11:04:30 -07:00
mongo refactor kosmos go 2026-08-15 11:04:30 -07:00
.gitignore update 2026-05-06 16:17:10 -07:00
AGENTS.md refactor kosmos go 2026-08-15 11:04:30 -07:00
GEMINI.md update naming 2026-05-02 10:05:01 -07:00
go.mod go.mod: Bump version of various dependencies 2026-08-15 11:05:51 -07:00
go.sum go.mod: Bump version of various dependencies 2026-08-15 11:05:51 -07:00
kosmos.go refactor kosmos go 2026-08-15 11:04:30 -07:00
kosmos_test.go Major upgrade v2.0.0 2026-08-02 09:59:27 -07:00
LICENSE Initial commit 2026-03-28 09:07:11 -07:00
Makefile update: update: go get command modified to be run in conjunction with mod t 2026-08-02 12:12:04 -07:00
MIGRATION.md refactor kosmos go 2026-08-15 11:04:30 -07:00
operations.go refactor kosmos go 2026-08-15 11:04:30 -07:00
README.md refactor kosmos go 2026-08-15 11:04:30 -07:00
test.env update kosmos observation 2026-04-23 12:30:32 -07:00

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 through ether, resolves secrets, and (for Ignite) 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]
  1. Collapse fixes what is about to be written and stages side effects in a Ripplecreated_at under $setOnInsert, the update time stamped outright. The model enters transition.
  2. Persistence: the Recorder upserts, or inserts outright for a Ledger.
  3. 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:

  1. Fld rejects nil. In 1.x a nil comparison silently produced an empty filter. Audit every Fld(...) whose right-hand side can be nil: use OFld for optional filters, Lit(nil) or IsNull() for null comparisons.
  2. BaseModel split into BaseForm / Model / Persistable. Projection targets embed BaseForm; persisted documents embed Persistable.
  3. Projector.From is now .Of, and the read methods are LoadOne / LoadMany / LoadTop rather than PullOne / PullAll.