#outline: deep
Migration v2 → v3
Overview
Comby v3 is a major release with breaking changes in three areas plus several additive features:
- JSON field renaming —
BaseAggregateandBaseIdentifierget an underscore prefix on their JSON tags to prevent name collisions with user domain fields. - RLS Foundation — Postgres-only Row Level Security with dual-pool routing, opt-in per store via a new option.
- Bridge Tenant — new tenant type for B2B cross-tenant collaboration.
Additive features that are not breaking but new in v3:
- New
SnapshotStoreinterface with Memory/SQLite/Postgres/Redis implementations. OriginRefonBaseAggregatefor 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:
// v2
import "github.com/gradientzero/comby/v2"
// v3
import "github.com/gradientzero/comby/v3"This is a mechanical find/replace across your codebase:
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.mod2. 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.
// 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):
// 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 indomainEvtBytes, the wrapper fields don't collideRequestContext,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.TargetWorkspaceUuid→cmd.WorkspaceUuid(set byapi/internal/command.go)cmd.WorkspaceUuid→evt.WorkspaceUuid(set infacade.command.go)agg.WorkspaceUuid→evt.WorkspaceUuid(set inNewEventFromAggregate)
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:
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):
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:
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:
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:
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
# 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.modIn go.mod the version string for the comby require also needs to satisfy semver:
require github.com/gradientzero/comby/v3 v3.0.0Step 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:
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
BridgeIdentityJoinedEventfires, the framework records the acceptance but does not auto-create a realIdentityaggregate 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 useList(WithTenantUuid)over UUID-only lookups. See RLS — Coverage Gaps. - Postgres test mode lock file — running the framework test suite under
COMBY_TEST_BACKEND=postgres-rlsacquires a POSIXflockon$TMPDIR/comby-test-postgres.lockfor cross-package serialisation. Stale lock files can persist if a test process iskill -9-ed; safe to delete manually.