Replace three instances of leftover %T format verbs in logger.ErrorCtx() calls with proper structured logging fields. Add alphabetically-sorted reference documentation for six new contrib/claude/ guides and reorder the existing list. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
6.1 KiB
Coredata — Data Access Layer
All raw SQL lives in pkg/coredata — never in service, handler, or resolver packages. One file per entity, with companion *_filter.go and *_order_field.go files when needed.
Entity struct pattern
Every entity uses gid.GID for its ID, db tags for pgx mapping, and CreatedAt/UpdatedAt timestamps. The tenant_id column exists in the database but is never stored on the Go struct — it is injected at query time via Scoper.
type (
Asset struct {
ID gid.GID `db:"id"`
SnapshotID *gid.GID `db:"snapshot_id"`
Name string `db:"name"`
OrganizationID gid.GID `db:"organization_id"`
AssetType AssetType `db:"asset_type"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Assets []*Asset
)
Use pointer types (*T) for nullable database columns.
Scoper interface
Scoper provides tenant isolation. Two implementations:
| Type | Constructor | SQLFragment() |
GetTenantID() |
Use case |
|---|---|---|---|---|
Scope |
NewScope(tenantID) or NewScopeFromObjectID(gid) |
"tenant_id = @tenant_id" |
Returns tenant ID | Multi-tenant queries (default) |
NoScope |
NewNoScope() |
"TRUE" |
Panics — never call | Cross-tenant / administrative queries |
Always inject tenant_id at INSERT time using scope.GetTenantID(), never from the struct.
SQL query composition
All queries use fmt.Sprintf to inject scope/filter/cursor fragments, then pgx.StrictNamedArgs for parameters. Merge args with maps.Copy.
q := `
SELECT id, name, created_at, updated_at
FROM assets
WHERE
%s
AND organization_id = @organization_id
AND %s
AND %s
LIMIT %d;
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment(), cursor.Limit())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
All SQL must be static after fmt.Sprintf() injection — no conditional string building. Use CASE WHEN in SQL for optional filter logic.
Standard method signatures
| Method | Receiver | Returns | Purpose |
|---|---|---|---|
LoadByID(ctx, conn, scope, id) |
*Entity |
error |
Single entity by ID |
LoadBy*(ctx, conn, scope, key) |
*Entity |
error |
Single entity by unique key |
LoadAllBy*(ctx, conn, scope, parentID, cursor, filter) |
*Entities |
error |
Paginated list |
CountBy*(ctx, conn, scope, parentID, filter) |
*Entities |
(int, error) |
Count matching rows |
Insert(ctx, conn, scope) |
*Entity |
error |
Insert, uses scope.GetTenantID() |
Update(ctx, conn, scope) |
*Entity |
error |
Update with RETURNING |
Delete(ctx, conn, scope) |
*Entity |
error |
Delete entity |
CursorKey(orderField) |
*Entity |
page.CursorKey |
Cursor for pagination |
AuthorizationAttributes(ctx, conn) |
*Entity |
(map[string]string, error) |
Attributes for IAM policy evaluation |
Row collection
// Single row
rows, err := conn.Query(ctx, q, args)
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
*a = asset
// Multiple rows
rows, err := conn.Query(ctx, q, args)
assets, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Asset])
*a = assets
Sentinel errors
var (
ErrResourceNotFound = errors.New("resource not found")
ErrResourceAlreadyExists = errors.New("resource already exists")
ErrResourceInUse = errors.New("resource is in use")
)
Map pgx.ErrNoRows to ErrResourceNotFound. Check unique constraint violations for ErrResourceAlreadyExists, foreign key violations for ErrResourceInUse.
Filters
Filters implement SQLFragment() string and SQLArguments() pgx.NamedArgs. Use double pointers for three-state filtering: nil = no filter, *nil = IS NULL, *val = equals.
type AssetFilter struct {
snapshotID **gid.GID
}
func NewAssetFilter(snapshotID **gid.GID) *AssetFilter {
return &AssetFilter{snapshotID: snapshotID}
}
func (f *AssetFilter) SQLFragment() string {
if f.snapshotID == nil {
return "TRUE"
}
if *f.snapshotID == nil {
return "snapshot_id IS NULL"
}
return "snapshot_id = @filter_snapshot_id"
}
func (f *AssetFilter) SQLArguments() pgx.NamedArgs {
if f.snapshotID == nil || *f.snapshotID == nil {
return pgx.NamedArgs{}
}
return pgx.NamedArgs{"filter_snapshot_id": **f.snapshotID}
}
For complex multi-field filters, use CASE WHEN in SQL and always declare all argument keys in every code path (use nil for inactive ones).
Order fields
String-based enums with Column(), IsValid(), String(), and MarshalText/UnmarshalText:
type AssetOrderField string
const (
AssetOrderFieldCreatedAt AssetOrderField = "CREATED_AT"
AssetOrderFieldName AssetOrderField = "NAME"
)
Each entity implements CursorKey(field) returning page.NewCursorKey(entity.ID, sortValue), with a panic on unknown fields.
Entity type registry
Each entity gets a unique uint16 constant in entity_type_reg.go. Never reuse removed type numbers — use _ placeholder. Register new entities in the NewEntityFromID switch statement.
Migrations
Files in pkg/coredata/migrations/ use timestamp naming: YYYYMMDDTHHMMSSZ.sql (UTC). One logical change per file. Always create indexes for frequently queried columns.
New entity checklist
- Entity file (
entity.go) — struct withdbtags, slice type alias,LoadByID,Insert,Update,Delete,CursorKey,AuthorizationAttributes - Filter file (
entity_filter.go) — filter struct,NewEntityFilter,SQLFragment,SQLArguments - Order field file (
entity_order_field.go) — order field type, constants,Column,IsValid, marshaling - Entity type constant — add to
entity_type_reg.goandNewEntityFromID - Migration —
YYYYMMDDTHHMMSSZ.sqlwith CREATE TABLE