Documentation
¶
Overview ¶
Package crud provides runtime helpers used by the per-service handlers_crud_gen.go file forge generates from a service's CRUD RPCs.
Pattern ¶
Forge's CRUD generator emits a thin per-RPC shim that delegates to one of HandleCreate, HandleGet, HandleList, HandleUpdate, or HandleDelete. The shim carries the only things forge can know that this library cannot:
- The RPC signature (req/resp connect types).
- The proto -> entity field copy for Create.
- The repository call site (db.<Name> function).
- The response packing (proto field that holds the entity).
Everything else — auth check, tenant check, error mapping, cursor encoding/decoding, pagination clamping, list bookkeeping — moves into this package.
Auth and tenant ¶
The library is decoupled from the per-project middleware package. Auth and tenant checks are expressed as closures the shim passes in. This avoids a hard import dependency on the user project's middleware surface (which is itself generated and project-specific).
In a generated shim, the auth closure looks like this:
func(ctx context.Context) error {
claims, err := middleware.GetUser(ctx)
if err != nil { return err }
return s.deps.Authorizer.Can(ctx, claims, middleware.ActionCreate, "user")
}
The library invokes the closure (if non-nil) and returns its error wrapped as connect.CodePermissionDenied — preserving the previous generated behaviour.
Behavioural fingerprint ¶
The pre-existing per-method generator wrote three observable strings:
- "<op> <entity_lower>: <wrapped error>" for Create/Get/List/Update/Delete.
- "invalid page token" for an undecodable PageToken.
- "<op> <entity_lower>: <field> is required" when an Update request has a nil entity field.
All three are preserved verbatim by this package and locked by tests in [crud_test.go].
Index ¶
- func HandleCreate[Req, Resp, Ent any](op CreateOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)
- func HandleDelete[Req, Resp any](op DeleteOp[Req, Resp]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)
- func HandleGet[Req, Resp, Ent any](op GetOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)
- func HandleList[Req, Resp, Ent any](op ListOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)
- func HandleUpdate[Req, Resp, Ent any](op UpdateOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)
- type Authorize
- type CreateOp
- type DeleteOp
- type GetOp
- type ListOp
- type Repo
- func (r *Repo[M]) Columns(db orm.Context) []string
- func (r *Repo[M]) Count(ctx context.Context, db orm.Context, tenantID string, opts ...orm.QueryOption) (int64, error)
- func (r *Repo[M]) Create(ctx context.Context, db orm.Context, entity *M, tenantID string) error
- func (r *Repo[M]) Delete(ctx context.Context, db orm.Context, id any, tenantID string) error
- func (r *Repo[M]) Get(ctx context.Context, db orm.Context, id any, tenantID string) (*M, error)
- func (r *Repo[M]) List(ctx context.Context, db orm.Context, tenantID string, opts ...orm.QueryOption) ([]*M, error)
- func (r *Repo[M]) ListAll(ctx context.Context, db orm.Context, tenantID string, opts ...orm.QueryOption) ([]*M, error)
- func (r *Repo[M]) PkColumn(db orm.Context) string
- func (r *Repo[M]) Update(ctx context.Context, db orm.Context, entity *M, tenantID string) error
- func (r *Repo[M]) UpdateMasked(ctx context.Context, db orm.Context, entity *M, fields []string, ...) error
- type RequireTenant
- type Spec
- type UpdateOp
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func HandleCreate ¶
func HandleCreate[Req, Resp, Ent any](op CreateOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)
HandleCreate runs the canonical Create lifecycle:
auth -> tenant -> build entity -> persist -> pack response.
All error-mapping is fixed; the shim only carries data shape.
func HandleDelete ¶
func HandleDelete[Req, Resp any](op DeleteOp[Req, Resp]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)
HandleDelete runs auth -> tenant -> persist -> empty response.
func HandleGet ¶
func HandleGet[Req, Resp, Ent any](op GetOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)
HandleGet runs auth -> tenant -> fetch -> pack. Repository errors are mapped to CodeNotFound; the legacy generator did the same.
func HandleList ¶
func HandleList[Req, Resp, Ent any](op ListOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)
HandleList runs auth -> tenant -> page/order/filter assembly -> repository call -> trim -> pack.
func HandleUpdate ¶
func HandleUpdate[Req, Resp, Ent any](op UpdateOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)
HandleUpdate runs auth -> tenant -> validate-required -> persist -> pack.
AIP-134 update_mask semantics (when op.Mask is wired):
- mask absent/empty, or containing "*" → full-object replace via op.Persist. AIP-134 permits full replacement when the behavior is documented — this is that documentation. Callers that want a partial update MUST send a mask.
- mask with concrete paths → op.PersistMasked writes only those fields. Paths are proto field names (snake_case, == column names).
- unknown or immutable path → CodeInvalidArgument naming the path (mapped from orm.UnknownFieldError by mapRepoErr).
After a masked write the response echoes the request entity: masked fields hold their new values, unmasked fields hold whatever the caller sent (NOT necessarily the stored values). Re-read with Get for the authoritative row.
Types ¶
type Authorize ¶
Authorize is a hook the shim passes in to perform the per-RPC authorization check. The library runs it before the repository call and wraps its error as connect.CodePermissionDenied.
Returning nil allows the call. Returning a non-nil error fails the call. The hook may be nil — in which case authorization is skipped (mirroring methods declared without auth_required: true in proto).
type CreateOp ¶
type CreateOp[Req, Resp, Ent any] struct { EntityLower string Auth Authorize Tenant RequireTenant Entity func(req *Req) Ent Persist func(ctx context.Context, tenantID string, entity Ent) error Pack func(entity Ent) *Resp }
CreateOp wires the per-RPC concerns of a Create handler.
Create returns the constructed entity in the response. The shim supplies:
- Entity — proto request -> internal entity constructor.
- Persist — repository call. tenantID is empty when Tenant is nil.
- Pack — internal entity -> connect.Response.
- EntityLower — lowercase entity name used in the error envelope.
- Auth/Tenant — optional hooks.
type DeleteOp ¶
type DeleteOp[Req, Resp any] struct { EntityLower string Auth Authorize Tenant RequireTenant ID func(req *Req) string Persist func(ctx context.Context, tenantID string, id string) error // Pack is optional. When nil, HandleDelete returns the proto's // zero-value response (matching the legacy DeleteResponse{} shape). Pack func() *Resp }
DeleteOp wires the per-RPC concerns of a Delete handler.
type GetOp ¶
type GetOp[Req, Resp, Ent any] struct { EntityLower string Auth Authorize Tenant RequireTenant ID func(req *Req) string Fetch func(ctx context.Context, tenantID string, id string) (Ent, error) Pack func(entity Ent) *Resp }
GetOp wires the per-RPC concerns of a Get handler.
type ListOp ¶
type ListOp[Req, Resp, Ent any] struct { EntityLower string Auth Authorize Tenant RequireTenant PkColumnName string // empty disables PK-cursor pagination // Columns is the entity's declared column allowlist (the generated // db.<Entity>Columns var). User-supplied order_by columns are // validated against it — identifier-shape validation alone lets an // undeclared column reach the database, where some engines silently // treat it as a constant (an ordering no-op). Columns []string // Pagination knobs. The library applies the same defaults the legacy // template did when these are zero. HasPagination bool DefaultPageSize int // 0 -> 50 MaxPageSize int // 0 -> 100 // HasOrderBy enables req.Msg.OrderBy / req.Msg.Descending handling // via the OrderBy/Descending closures. HasOrderBy bool OrderBy func(req *Req) (clause string, descending bool) // Filters returns extra orm.QueryOption values built from per-field // filter logic. The shim implements this as a static sequence of // "if req.Msg.X != nil { opts = append(opts, orm.WhereILike(...)) }" // statements — same as the legacy template, just lifted into a // closure. Filters func(req *Req) []orm.QueryOption // PageToken / PageSize accessors. PageSize is clamped by the // library; PageToken is decoded by the library. PageToken func(req *Req) string PageSize func(req *Req) int // Query runs the repository call. tenantID is "" when Tenant is nil. // Returns slice + error; the library handles the +1 fetch and // trim-to-pageSize. Query func(ctx context.Context, tenantID string, opts []orm.QueryOption) ([]Ent, error) // EntityID extracts the cursor key from the last-of-page entity. // Required when HasPagination is true. EntityID func(entity Ent) string // Pack receives the trimmed item slice and the next page token (empty // when no further page). Shim assembles the response with the right // repeated-field name. Pack func(items []Ent, nextPageToken string) *Resp }
ListOp wires the per-RPC concerns of a List handler. Pagination, filter, and order-by bookkeeping live in the library; the per-RPC shim still provides the per-field filter -> orm.QueryOption mapping (this is data, not lifecycle, and reflection-free Go can't generalize it without a code-gen table).
type Repo ¶
type Repo[M any] struct { // contains filtered or unexported fields }
Repo is the generic data-access layer over a Bun-tagged model M. One Repo per entity replaces the ~250 LOC of per-entity Create/Get/List/ Count/ListAll/Update/UpdateMasked/Delete the generator used to emit; the generated code now supplies only the Bun-tagged struct, the ToProto/ FromProto pair, and a single crud.NewRepo[Model](Spec{...}) line.
All lifecycle semantics the pre-generic code carried are preserved exactly: tenant scoping, Bun-native + legacy-TEXT soft delete, the deleted_at IS NULL update-guard (Bun auto-scopes SELECT/DELETE to live rows but NOT UPDATE), AIP-134 masked updates with an updatable allowlist → orm.UnknownFieldError, managed-timestamp stamping, server-allocated / ULID PKs, and array nil→{} normalization. The QueryOption escape hatch (orm.QueryOption func(*bun.SelectQuery)) is threaded straight through to List/Count, and bun.IDB (db.Bun()) remains the raw-SQL escape hatch.
func NewRepo ¶
NewRepo constructs a Repo for model M. Metadata derivation is deferred to the first call (it needs a live bun.IDB to reach the dialect's table cache); spec carries the forge conventions Bun's schema can't infer.
func (*Repo[M]) Columns ¶
Columns is the entity's declared column allowlist — the value the List handler shim used to pull from the generated db.<Entity>Columns var and hand to pkg/crud for order_by validation. Derived from Bun's schema.
func (*Repo[M]) Count ¶
func (r *Repo[M]) Count(ctx context.Context, db orm.Context, tenantID string, opts ...orm.QueryOption) (int64, error)
Count returns the number of matching rows under the same scope as List.
func (*Repo[M]) Create ¶
Create inserts a new row. Plain INSERT, never an upsert: a duplicate PK is a real error. Chokepoint invariants (matching the pre-generic emitter): a string PK is ULID-generated when empty; a server-allocated integer PK is excluded from the INSERT and read back via RETURNING; managed timestamps are stamped; nil array fields are normalized to {}.
func (*Repo[M]) Delete ¶
Delete removes a row by PK. With Bun-native soft delete a plain NewDelete stamps deleted_at (Bun rewrites it to UPDATE) and auto-scopes to live rows. With legacy-TEXT soft delete the repo hand-rolls the CURRENT_TIMESTAMP stamp + deleted_at IS NULL guard (Bun's time.Time stamp can't round-trip a TEXT column). Otherwise it is a hard DELETE.
func (*Repo[M]) Get ¶
Get retrieves a row by primary key. A missing row satisfies errors.Is(err, orm.ErrNoRows). Tenant scope and the legacy-TEXT deleted_at filter are applied; Bun-native soft delete auto-excludes tombstones from the SELECT.
func (*Repo[M]) List ¶
func (r *Repo[M]) List(ctx context.Context, db orm.Context, tenantID string, opts ...orm.QueryOption) ([]*M, error)
List retrieves rows with optional QueryOption filtering/ordering/limit. Tenant scope and the legacy-TEXT soft-delete filter are applied; Bun's ,soft_delete excludes tombstones natively.
func (*Repo[M]) ListAll ¶
func (r *Repo[M]) ListAll(ctx context.Context, db orm.Context, tenantID string, opts ...orm.QueryOption) ([]*M, error)
ListAll retrieves rows INCLUDING soft-deleted ones (tenant scope still applies). Bun-native soft delete needs an explicit WhereAllWithDeleted to see tombstones; the legacy-TEXT path simply omits the deleted_at filter.
func (*Repo[M]) PkColumn ¶
PkColumn is the primary-key column name (the List handler's cursor column). Derived from Bun's schema.
func (*Repo[M]) Update ¶
Update writes the full updatable column set of an existing row by PK. updated_at is re-stamped under managed timestamps; created_at, the PK, the tenant key, and deleted_at are excluded from the SET clause. The deleted_at IS NULL guard applies to BOTH soft-delete modes: Bun auto-scopes SELECT/DELETE to live rows but NOT UPDATE, so without the guard an UPDATE could mutate a tombstoned row.
func (*Repo[M]) UpdateMasked ¶
func (r *Repo[M]) UpdateMasked(ctx context.Context, db orm.Context, entity *M, fields []string, tenantID string) error
UpdateMasked writes ONLY the named columns (AIP-134 update_mask paths; proto field names == column names). Paths outside the updatable allowlist return *orm.UnknownFieldError, which pkg/crud maps to a clean InvalidArgument. updated_at is stamped on masked writes too.
type RequireTenant ¶
RequireTenant is a hook the shim passes in for tenant-scoped operations. The library invokes it after the auth check, expects a tenant ID string back, and forwards it into the repository call. The returned error is wrapped as connect.CodeUnauthenticated.
Methods on tenant-aware entities pass a real closure that delegates to the project's middleware.RequireTenantID. Methods on global entities pass nil and the library treats the call as un-tenanted.
type Spec ¶
Spec is the IRREDUCIBLE per-entity descriptor the generator emits for a Repo. Everything Bun's own table schema can answer — table name, primary key column + Go field, autoincrement/server-allocated PK, the native (,soft_delete) deleted-at field, the full column set, array columns — is derived by reflection from the Bun-tagged model at first use (once per entity, never in the hot path). Spec carries ONLY the forge conventions Bun cannot infer from struct tags:
- TenantColumn — the multi-tenancy scoping column ("" = global entity, no tenant scoping). Bun has no concept of a tenant key.
- Timestamps — forge's managed created_at/updated_at stamping (set on Create, re-stamped on every Update/UpdateMasked). Distinct from a DB DEFAULT: forge stamps in Go so the value is identical across the row and visible to the caller without a re-read.
- LegacyTextDeletedAt — soft delete whose deleted_at column is a legacy TEXT column (Go type string) that Bun's time.Time-based ,soft_delete cannot round-trip. When true the repo hand-rolls the deleted_at IS NULL filter and the CURRENT_TIMESTAMP stamp (kalshi fr-3fba9166ba). Bun-native soft delete (proper time deleted_at) is detected from the schema and needs no flag.
type UpdateOp ¶
type UpdateOp[Req, Resp, Ent any] struct { EntityLower string EntityFieldLow string // lowercase form of the proto field that holds the entity, e.g. "user" Auth Authorize Tenant RequireTenant Entity func(req *Req) (entity Ent, ok bool) Persist func(ctx context.Context, tenantID string, entity Ent) error Pack func(entity Ent) *Resp // Mask extracts the AIP-134 update_mask paths from the request // (req.GetUpdateMask().GetPaths()). nil when the proto's update // request has no update_mask field — HandleUpdate then behaves // exactly as before this field existed (full replace via Persist). Mask func(req *Req) []string // PersistMasked writes ONLY the named fields (proto field names == // column names, snake_case). The generator wires it whenever it // wires Mask. If Mask is set but PersistMasked is nil and a request // arrives with concrete paths, HandleUpdate fails CodeInternal — // silently widening a masked write to a full replace is the // data-loss bug this hook exists to prevent. PersistMasked func(ctx context.Context, tenantID string, entity Ent, fields []string) error }
UpdateOp wires the per-RPC concerns of an Update handler. The shim supplies an EntityFromReq closure that returns (entity, ok). The library treats ok == false as "request missing entity" and returns CodeInvalidArgument with the same wording the legacy generator used.