Skip to content

#outline: deep

Migration v2 → v3

Overview

Comby v3 is a major release with breaking changes in three areas plus several additive features:

  1. JSON field renamingBaseAggregate and BaseIdentifier get an underscore prefix on their JSON tags to prevent name collisions with user domain fields.
  2. RLS Foundation — Postgres-only Row Level Security with dual-pool routing, opt-in per store via a new option.
  3. Bridge Tenant — new tenant type for B2B cross-tenant collaboration.

Additive features that are not breaking but new in v3:

  • New SnapshotStore interface with Memory/SQLite/Postgres/Redis implementations.
  • OriginRef on BaseAggregate for cross-tenant lineage.
  • comby.WithRLSBypass(ctx) context helper for cross-tenant reactors.

This guide covers what changes for users of the comby library and how to migrate an existing v2 application.

Tip: v3 is a clean release without backward compatibility on stored data. New v3 deployments start with empty stores; there is no v2 → v3 data migration path. If you need to keep v2 data running, leave it on v2 (the v2 branch will be maintained for security fixes for at least 6 months).

Breaking Changes

1. Import Path

Go semver requires major versions ≥ 2 to include /vN in the import path:

go
// v2
import "github.com/gradientzero/comby/v2"

// v3
import "github.com/gradientzero/comby/v3"

This is a mechanical find/replace across your codebase:

bash
find . -type f -name "*.go" -exec sed -i '' 's|gradientzero/comby/v2|gradientzero/comby/v3|g' {} +
sed -i '' 's|gradientzero/comby/v2|gradientzero/comby/v3|g' go.mod

2. JSON Tag Renaming on BaseAggregate

Only BaseAggregate (and the embedded BaseIdentifier) get an underscore prefix on their JSON tags. These are the only structs embedded in user aggregates and therefore the only ones that can collide with user domain fields.

go
// v2
type BaseAggregate struct {
    TenantUuid    string `json:"tenantUuid,omitempty"`
    AggregateUuid string `json:"aggregateUuid,omitempty"`
    Version       int64  `json:"version,omitempty"`
    // ...
}

// v3
type BaseAggregate struct {
    TenantUuid    string     `json:"_tenantUuid,omitempty"`
    WorkspaceUuid string     `json:"_workspaceUuid,omitempty"`  // new (RLS)
    AggregateUuid string     `json:"_aggregateUuid,omitempty"`
    Version       int64      `json:"_version,omitempty"`
    Deleted       bool       `json:"_deleted,omitempty"`
    OriginRef     *OriginRef `json:"_originRef,omitempty"`      // new (Bridge)
    Attributes    string     `json:"_attributes,omitempty"`
}

BaseIdentifier (embedded in BaseAggregate):

go
// v3
type BaseIdentifier struct {
    Domain string `json:"_domain,omitempty"`
    Name   string `json:"_name,omitempty"`
}

What does NOT change (no underscore prefix):

  • BaseEvent, BaseCommand, BaseQuery — they wrap user data in domainEvtBytes, the wrapper fields don't collide
  • RequestContext, Trace, ProjectionModel, HistoryEventModel — only nested via named fields
  • All domain events, readmodel models, API DTOs

Frontend impact: Code that reads BaseAggregate fields from a JSON response (e.g. an admin dashboard fetching aggregate data) needs to use _tenantUuid, _aggregateUuid, etc. The OpenAPI generator picks this up automatically.

Caller impact for OrderBy: ?orderBy=createdAt still works for store/event endpoints — the OrderBy parameter is a public API contract independent of JSON tag renaming.

3. New WorkspaceUuid Field

BaseEvent, BaseCommand, BaseAggregate, and SnapshotStoreModel gain a new WorkspaceUuid field. This is auto-propagated:

  • RequestContext.TargetWorkspaceUuidcmd.WorkspaceUuid (set by api/internal/command.go)
  • cmd.WorkspaceUuidevt.WorkspaceUuid (set in facade.command.go)
  • agg.WorkspaceUuidevt.WorkspaceUuid (set in NewEventFromAggregate)

Existing user code that does not set TargetWorkspaceUuid is unaffected — WorkspaceUuid defaults to empty.

4. New Tenant.Type Field

The Tenant aggregate gains a Type field with three possible values, used by the Bridge Tenant feature:

go
type TenantType string

const (
    TenantTypeRegular TenantType = "regular"  // default — same semantics as v2
    TenantTypeBridge  TenantType = "bridge"   // new in v3 — see Bridge Tenant
    TenantTypeSystem  TenantType = "system"   // framework-internal
)

Existing v2 tenants migrate as TenantTypeRegular automatically when their events are replayed. New tenants default to regular if Type is not specified on TenantCommandCreate.

5. Snapshot Store Schema

Postgres + SQLite snapshot tables gain tenant_uuid and workspace_uuid columns (used for RLS):

sql
ALTER TABLE snapshots
  ADD COLUMN tenant_uuid TEXT,
  ADD COLUMN workspace_uuid TEXT;

The migration is run automatically at store startup. Existing snapshots without these values get NULL; new snapshots are populated from the aggregate's tenant/workspace.

Additive Features (no breaking impact)

Bridge Tenant API

A whole new sub-system for B2B collaboration: Tenant.Type=bridge, stakeholder lifecycle (invite/accept/leave), bridge identity invitations with auto-revoke when the home stakeholder leaves, aggregate import with content-hashed OriginRef. See Bridge Tenant.

OriginRef on BaseAggregate

Tracks lineage when aggregates are imported across bridge tenant boundaries. nil for ordinary aggregates created in their own tenant. See Aggregate — OriginRef and Bridge Tenant — Aggregate Import.

SnapshotStore interface (entirely new in v3)

The framework now ships a SnapshotStore interface for periodic aggregate-state snapshots. Backends: in-memory, SQLite, Postgres, Redis. Opt-in via:

go
fc, _ := comby.NewFacade(
    // ...
    comby.FacadeWithSnapshotStore(ss),
    comby.FacadeWithSnapshotInterval(100),
)

Without configuration, GetAggregate continues full event replay (v2 behaviour). See the Snapshots tutorial and Snapshot Store reference.

Postgres RLS — Dual-Pool Routing (Postgres only)

Phase 2 of v3 adds opt-in Row Level Security. Activate via the new store option:

go
import store "github.com/gradientzero/comby-store-postgres"

es := store.NewEventStorePostgres(host, port, ownerUser, ownerPwd, dbName)
es.Init(ctx, comby.EventStoreOptionWithRLSAppRole("comby_app", "<password>"))

The store opens a second *sql.DB pool as a NOBYPASSRLS app role and routes tenant-scoped reads/writes through it inside SET LOCAL app.tenant_uuid sessions; cross-tenant ops stay on the original (BYPASSRLS) owner pool.

Cross-tenant reactors opt out explicitly via:

go
ctx = comby.WithRLSBypass(ctx)

SQLite and any non-Postgres backend ignore the option (no-op). See Row Level Security.

Account & ANONYMOUS_TENANT_UUID under RLS — caveat

If you turn on RLS, note that Account aggregates are stored under ANONYMOUS_TENANT_UUID (because Account is cross-tenant by design). RLS therefore does not add a defense-in-depth layer for Account access; the framework's app-layer permission check stays the only real boundary on Account endpoints. See Account — RLS section and RLS — Anonymous and System Tenant.

Migration Steps

Step 1: Update import paths

bash
# In your application repo
find . -type f -name "*.go" -exec sed -i '' 's|gradientzero/comby/v2|gradientzero/comby/v3|g' {} +
sed -i '' 's|gradientzero/comby/v2|gradientzero/comby/v3|g' go.mod

In go.mod the version string for the comby require also needs to satisfy semver:

require github.com/gradientzero/comby/v3 v3.0.0

Step 2: Update store dependencies

The store modules do not carry a /vN suffix in their import path, so their version strings stay on the v1.x line. Bump to the v3-compatible release of each:

require (
    github.com/gradientzero/comby-store-postgres v1.0.0  // v3-compatible
    github.com/gradientzero/comby-store-sqlite  v1.1.0
    // and similarly for redis / minio if used
)

Step 3: Update frontend (if you have one)

Regenerate the OpenAPI client. JSON-tag changes on BaseAggregate will appear in the generated types as _tenantUuid etc. Adjust UI code that reads these fields from API responses.

Step 4: Database migrations

The new columns are added automatically by the store on startup (ALTER TABLE IF NOT EXISTS workspace_uuid TEXT). No manual SQL required.

For new deployments: schema is created from scratch with the v3 columns.

Step 5: Optional — Enable Snapshots

If your aggregates accumulate many events and GetAggregate is on a hot path, configure a SnapshotStore. See the Snapshots tutorial.

Step 6: Optional — Enable RLS (Postgres)

For Postgres deployments that want database-layer tenant isolation, pass the new option to each store:

go
es := store.NewEventStorePostgres(...)
es.Init(ctx, comby.EventStoreOptionWithRLSAppRole(appUser, appPass))

cs := store.NewCommandStorePostgres(...)
cs.Init(ctx, comby.CommandStoreOptionWithRLSAppRole(appUser, appPass))

ss := store.NewSnapshotStorePostgres(host, port, user, pwd, db,
    store.SnapshotStorePostgresWithRLSAppRole(appUser, appPass))
ss.Init(ctx)

See Row Level Security for setup details, role provisioning, and the coverage gaps.

Step 7: Optional — Use Bridge Tenants

If you have a B2B sharing use case, see Bridge Tenant and the runnable code/bridge-app example.

Known Issues / Sharp Edges

  • OrderBy parameter values stay the same as v2 (?orderBy=createdAt, not _createdAt). Don't be confused that the JSON response shows _createdAt — the query parameter is an independent public contract.
  • Bridge identity → real Identity reactor is not shipped. After BridgeIdentityJoinedEvent fires, the framework records the acceptance but does not auto-create a real Identity aggregate in the bridge tenant — that step is application-specific. The frontend admin dashboard handles the case where the bridge identity record exists without a corresponding Identity aggregate. See Bridge Tenant — Bridge Identity → Real Identity Reactor.
  • Bridge custom permission registration is the application's responsibility. Comby ships default permission functions for the cross-tenant lifecycle ops (AcceptStakeholder, LeaveStakeholder, AcceptIdentity); registering bridge-specific custom commands and their permission entries is up to you. See Bridge Tenant — Bridge Permission Model.
  • RLS coverage gaps — operations whose request struct does not carry a tenant (Get(eventUuid), Delete(eventUuid), Total, Info, Reset) currently route to the owner pool and are therefore not RLS-bound. Tenant-scoped consumers should use List(WithTenantUuid) over UUID-only lookups. See RLS — Coverage Gaps.
  • Postgres test mode lock file — running the framework test suite under COMBY_TEST_BACKEND=postgres-rls acquires a POSIX flock on $TMPDIR/comby-test-postgres.lock for cross-package serialisation. Stale lock files can persist if a test process is kill -9-ed; safe to delete manually.